diff --git a/.github/workflows/cache-maintenance.yml b/.github/workflows/cache-maintenance.yml new file mode 100644 index 000000000..a1c6d0db0 --- /dev/null +++ b/.github/workflows/cache-maintenance.yml @@ -0,0 +1,41 @@ +name: Cache maintenance + +# GitHub keeps 10 GB of Actions caches per repository. The platform dependency +# trees are large (the Windows Tauri tree alone is ~1.6 GB), so unused scopes +# evict the caches the CI jobs restore - which silently turns every run into a +# cold build. This job keeps the budget visible and drops the scopes that can +# never be restored again: pull requests that are already closed or merged. +# +# It runs weekly and on demand. It never touches branch or tag scopes, so the +# caches the release path depends on are safe. +on: + schedule: + # Monday 03:17 UTC: after the weekend, before the working week. + - cron: '17 3 * * 1' + workflow_dispatch: + +permissions: + actions: write # required to delete caches + contents: read + +concurrency: + group: cache-maintenance + cancel-in-progress: false + +jobs: + report: + name: Report and prune caches + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - name: Report cache usage and prune dead pull request scopes + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + LIMIT_GB: '8' + working-directory: openless-all/app + run: bash scripts/ci-cache-usage.sh --prune --repo "$REPO" --limit-gb "$LIMIT_GB" diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index 01fbcdadb..0e19fba96 100644 --- a/.github/workflows/check-linux-egui.yml +++ b/.github/workflows/check-linux-egui.yml @@ -1,16 +1,168 @@ name: Linux egui build and backend checks + +# Only the CI and tag-release workflows call this deb/rpm build and verification path. +# No standalone push trigger: a Linux PR must show one combined Linux check. on: - pull_request: - branches: [beta] - paths: - - 'openless-all/app/**' - - 'openless-all/scripts/linux-*/**' - - '.github/workflows/*linux-egui.yml' - push: - branches: [Linux-egui] - workflow_dispatch: + workflow_call: + inputs: + # 'none' skips a change set that is only prose (see + # scripts/ci-changed-areas.sh): the callers that must always build the + # packages, including the tag release, leave the default in place. + scope: + required: false + type: string + default: full + permissions: - contents: write + contents: read + +concurrency: + group: linux-egui-build-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: - native: - uses: ./.github/workflows/release-linux-egui.yml + build-linux-egui: + # PipeWire/libspa in this host needs 24.04. Packages target glibc >= 2.39. + runs-on: ubuntu-24.04 + # A cold dependency tree costs ~18 minutes; anything past this is a hang. + timeout-minutes: 90 + env: + RELEASE_TAG: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') && github.ref_name || '' }} + # Debug info dominates both compile time and cache size for this host, and + # the gate below never reads it. The Linux job keeps its own cache so a + # red run still starts warm. + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + steps: + - uses: actions/checkout@v6 + with: + submodules: false + + - name: Install native build and packaging dependencies + if: inputs.scope != 'none' + run: | + sudo add-apt-repository -y universe || true + sudo apt-get update + sudo apt-get install -y \ + appstream build-essential cmake cpio desktop-file-utils \ + fcitx5-modules-dev file libasound2-dev libdbus-1-dev \ + libfcitx5config-dev libfcitx5core-dev libfcitx5utils-dev \ + liblzma-dev libpipewire-0.3-dev libpulse-dev libssl-dev \ + libwayland-dev libx11-dev libxkbcommon-dev pkg-config ripgrep rpm + + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + if: inputs.scope != 'none' + + - uses: actions/setup-node@v4 + with: + node-version: '22' + if: inputs.scope != 'none' + + - name: Resolve package version and validate release tag + id: version + working-directory: openless-all/app + env: + VERSION_TAG: ${{ env.RELEASE_TAG }} + run: | + set -euo pipefail + VERSION=$(node -p "require('./package.json').version") + if [ -n "$VERSION_TAG" ]; then + TAG_VERSION=${VERSION_TAG#v} + TAG_VERSION=${TAG_VERSION%-tauri} + if [ "${VERSION%%+*}" != "$TAG_VERSION" ]; then + echo "::error::tag $VERSION_TAG does not match package.json version $VERSION" + exit 1 + fi + fi + # Linux packages use the same explicitly incremented -N revision as + # earlier Beta.2-78/-79 builds. Keep it separate from the Tauri tag. + REVISION=$(< linux-egui/package-revision) + [[ "$REVISION" =~ ^[1-9][0-9]*$ ]] + echo "version=${VERSION%%+*}-$REVISION" >> "$GITHUB_OUTPUT" + + - uses: swatinem/rust-cache@v2 + if: inputs.scope != 'none' + with: + workspaces: 'openless-all/app -> target' + # Saving after a failed test step keeps the next push incremental + # instead of repeating a cold build of the whole dependency tree. + cache-on-failure: true + + - name: Build and test fcitx5 plugin + if: inputs.scope != 'none' + working-directory: openless-all/scripts/linux-fcitx5-plugin + run: | + cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr + cmake --build build --parallel + ctest --test-dir build --output-on-failure + test -s build/libopenless.so + test -s build/openless.conf + + - name: Test Core, Linux host, and platform boundaries + if: inputs.scope != 'none' + working-directory: openless-all/app + 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 clippy --locked -p openless-linux-egui --all-targets -- -D warnings + cargo check --locked -p openless-linux-egui --all-targets + node scripts/linux-egui-tauri-free-contract.test.mjs + node scripts/linux-egui-release-contract.test.mjs + node scripts/ci-changed-areas.test.mjs + node scripts/tauri-linux-free-contract.test.mjs + 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: Test remote input TLS identity and Unix key permissions + if: inputs.scope != 'none' + working-directory: openless-all/app + run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls + + - name: Validate Linux desktop metadata + if: inputs.scope != 'none' + run: | + desktop-file-validate openless-all/app/linux-egui/packaging/openless.desktop + appstreamcli validate --no-net openless-all/app/linux-egui/packaging/top.openless.OpenLess.metainfo.xml + + - name: Build Linux egui host + if: inputs.scope != 'none' + working-directory: openless-all/app + run: cargo build --locked --release -p openless-linux-egui + + - name: Package deb and rpm without Tauri + if: inputs.scope != 'none' + working-directory: openless-all/app + env: + OPENLESS_LINUX_VERSION: ${{ steps.version.outputs.version }} + OPENLESS_LINUX_ARCH: x86_64 + run: | + # CI runners are disposable. Ignore old outputs restored by a cache. + rm -f target/linux-egui-packages/*.deb target/linux-egui-packages/*.rpm \ + target/linux-egui-packages/SHA256SUMS + bash scripts/package-linux-egui.sh + + - name: Generate checksums and verify distributable packages + if: inputs.scope != 'none' + working-directory: openless-all/app + run: | + set -euo pipefail + OUTPUT=target/linux-egui-packages + ( cd "$OUTPUT" && sha256sum ./*.deb ./*.rpm > SHA256SUMS ) + bash scripts/verify-linux-egui-packages.sh + + - name: Upload verified Linux packages as workflow artifacts + # Push builds keep the release path warm but do not publish artifacts; + # the tag release and pull request runs are the consumers. + if: inputs.scope != 'none' && (github.event_name != 'push' || env.RELEASE_TAG != '') + uses: actions/upload-artifact@v7 + with: + name: openless-linux-egui-x86_64 + path: openless-all/app/target/linux-egui-packages/* + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2c2f191b3..1b5019e7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,8 +3,9 @@ name: CI # 多平台跨语言质量门禁。release-tauri.yml 是发版流水线(仅打包构建),这里负责 # 在合并前快速验证两件事: # 1. 全部前端/契约测试与 vite bundle 通过(捕获行为、合同及跨 locale 类型 drift) -# 2. Tauri 后端在 macOS / Windows、移动端在 Android 编译;Linux 只测试共享 core。 -# 跑 build-mac.sh / windows-package-msvc.ps1 / Tauri bundle 太重;只跑轻量 cargo check + vite build。 +# 2. Tauri 后端在 macOS / Windows、移动端在 Android 编译;Linux Core、 +# remote_tls、egui 和 deb/rpm 统一使用发版构建链检查(PR 不上传 Release)。 +# macOS/Windows/Android 不跑完整 Tauri bundle;Linux 则运行完整 deb/rpm 发版构建校验。 on: push: @@ -25,10 +26,42 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + # 判定这次改动能不能影响到各平台门禁。只有“改动物理上够不到”的变更才会 + # 跳过对应 job:判定脚本对任何未知情况(没有基线、git 失败、空 diff)都返回 + # true。push / tag / 手动运行不带基线,因此始终全量验证。 + changes: + name: Changed areas + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + tauri: ${{ steps.detect.outputs.tauri }} + msrv: ${{ steps.detect.outputs.msrv }} + linux: ${{ steps.detect.outputs.linux }} + steps: + - uses: actions/checkout@v4 + with: + # 需要基线提交才能算出差异,客户端不再重复 fetch。 + fetch-depth: 0 + + - name: Detect changed areas + id: detect + working-directory: openless-all/app + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + set -euo pipefail + for area in tauri msrv linux; do + verdict=$(bash scripts/ci-changed-areas.sh "$area" "$BASE_SHA") + # 失败即运行:脚本读不到差异时返回 true,这里也只接受明确的 false。 + echo "$area=$( [ "$verdict" = 'false' ] && echo false || echo true )" >> "$GITHUB_OUTPUT" + done + android-check: - if: github.event_name != 'workflow_dispatch' || inputs.platform != 'macos' + needs: [changes] + if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.tauri != 'false' name: Android cargo check runs-on: ubuntu-latest + timeout-minutes: 60 defaults: run: working-directory: openless-all/app @@ -92,6 +125,10 @@ jobs: - uses: swatinem/rust-cache@v2 with: workspaces: 'openless-all/app/src-tauri -> target' + # 仓库缓存上限 10 GB,超出后按 LRU 淘汰。过去每个 PR 都会各存一份 + # ~1.6 GB 的依赖树,把其他 job 的缓存挤掉,导致每次运行都从零编译。 + # 现在 PR 只从基线分支恢复,只有分支推送和 tag 才写入缓存。 + save-if: ${{ github.event_name != 'pull_request' }} - uses: gradle/actions/setup-gradle@v4 @@ -172,31 +209,24 @@ jobs: -x :app:rustBuildX86_64Debug --no-daemon - linux-core-contract: + # The Linux core/egui/deb+rpm checks live in a reusable workflow, which runs + # the same `cargo test -p openless-core` as upstream's `linux-core-contract` + # plus the Linux host, fcitx5 and package contracts. + linux-egui-package: + needs: [changes] if: github.event_name != 'workflow_dispatch' || inputs.platform != 'macos' - name: Linux core tests - runs-on: ubuntu-22.04 - defaults: - run: - working-directory: openless-all/app - steps: - - uses: actions/checkout@v4 - with: - submodules: false - - - uses: dtolnay/rust-toolchain@stable - - - uses: swatinem/rust-cache@v2 - with: - workspaces: 'openless-all/app -> target' - - - name: Test Core - run: cargo test --locked -p openless-core - - - name: Test remote input TLS identity and Unix key permissions - run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --test remote_tls + name: Linux Core, egui and deb/rpm checks + permissions: + contents: read + uses: ./.github/workflows/check-linux-egui.yml + with: + # 散文改动跳过整条 Linux 构建与打包链;输出缺失/未知时退回 full。 + scope: ${{ needs.changes.outputs.linux == 'false' && 'none' || 'full' }} cross-platform: + needs: [changes] + # 失败即运行:只有判定脚本明确说“够不到”才跳过。 + if: needs.changes.outputs.tauri != 'false' name: ${{ matrix.label }} checks strategy: # 一个平台挂掉不阻塞其他平台拿到验证结果。 @@ -204,6 +234,7 @@ jobs: matrix: include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"os":"macos-latest","label":"macOS","preflight":false}]' || '[{"os":"macos-latest","label":"macOS","preflight":false},{"os":"windows-latest","label":"Windows","preflight":true}]') }} runs-on: ${{ matrix.os }} + timeout-minutes: 60 env: # 新增 shared Core 后,macOS 首次编译同时构建 MLX C++ 依赖和完整 # Tauri test binary;限制并发避免 arm64 runner 在峰值内存处被系统终止。 @@ -242,12 +273,14 @@ jobs: if: runner.os == 'Windows' with: workspaces: 'openless-all/app/src-tauri -> target' + save-if: ${{ github.event_name != 'pull_request' }} - name: Cache macOS stable dependencies if: runner.os == 'macOS' uses: swatinem/rust-cache@v2 with: key: macos-stable-v1 + save-if: ${{ github.event_name != 'pull_request' }} workspaces: | openless-all/app -> target openless-all/app/src-tauri -> target @@ -368,8 +401,12 @@ jobs: echo "[ok] 全部 5 处版本号一致:$PKG" macos-msrv: + needs: [changes] + # 失败即运行:只有判定脚本明确说“够不到”才跳过。 + if: needs.changes.outputs.msrv != 'false' name: macOS Rust 1.88 MSRV runs-on: macos-latest + timeout-minutes: 60 env: CARGO_BUILD_JOBS: 2 CARGO_PROFILE_DEV_DEBUG: 0 @@ -391,6 +428,7 @@ jobs: - uses: swatinem/rust-cache@v2 with: key: macos-msrv-v1 + save-if: ${{ github.event_name != 'pull_request' }} workspaces: | openless-all/app/src-tauri -> target openless-all/app/src-tauri/backend-tests -> target diff --git a/.github/workflows/release-linux-egui.yml b/.github/workflows/release-linux-egui.yml index 3be418a43..bf5ef2263 100644 --- a/.github/workflows/release-linux-egui.yml +++ b/.github/workflows/release-linux-egui.yml @@ -1,268 +1,54 @@ name: Release Linux egui -# This workflow deliberately has no automatic tag trigger until real Ubuntu -# audio, focus/input, install, upgrade, and rollback evidence is recorded. It is -# already reusable by a release orchestrator: pass release_tag to upload verified packages. +# An admin pushes v*-tauri only after CI and Linux device acceptance. This +# workflow calls the exact same deb/rpm build used by CI, then attaches its +# verified assets to the shared Tauri/Android Release (Beta remains a draft). on: + push: + tags: + - 'v*-tauri' workflow_dispatch: - inputs: - release_tag: - description: Existing GitHub release tag to receive Linux assets; blank only uploads Actions artifacts - required: false - type: string - workflow_call: - inputs: - release_tag: - required: false - type: string - secrets: - LINUX_EGUI_MINISIGN_SECRET_KEY: - required: false permissions: - contents: write + contents: read + +concurrency: + group: release-linux-egui-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} + cancel-in-progress: false jobs: build-linux-egui: - runs-on: ubuntu-22.04 - env: - RELEASE_TAG: ${{ inputs.release_tag }} + permissions: + contents: read + uses: ./.github/workflows/check-linux-egui.yml + + publish-linux-egui: + # Manual builds only upload Actions artifacts; they never change a Release. + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') + needs: build-linux-egui + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: write steps: - - uses: actions/checkout@v4 - with: - submodules: false - - - name: Install native build and packaging dependencies - run: | - sudo add-apt-repository -y universe || true - sudo apt-get update - sudo apt-get install -y \ - appstream \ - build-essential \ - cmake \ - clang \ - desktop-file-utils \ - extra-cmake-modules \ - fcitx5-modules-dev \ - file \ - fuse \ - libasound2-dev \ - libdbus-1-dev \ - libfcitx5config-dev \ - libfcitx5core-dev \ - libfcitx5utils-dev \ - libopenblas-dev \ - libkf5globalaccel-dev \ - qtbase5-dev \ - libatspi2.0-dev \ - pulseaudio-utils \ - fonts-noto-cjk \ - libssl-dev \ - libwayland-dev \ - libx11-dev \ - libxkbcommon-dev \ - patchelf \ - pkg-config \ - ripgrep \ - rpm \ - ruby-dev \ - wget - sudo gem install --no-document fpm -v 1.16.0 - - - name: Install appimagetool - env: - APPIMAGETOOL_SHA256: b90f4a8b18967545fda78a445b27680a1642f1ef9488ced28b65398f2be7add2 - run: | - wget -q https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage \ - -O /tmp/appimagetool - echo "$APPIMAGETOOL_SHA256 /tmp/appimagetool" | sha256sum --check --strict - chmod +x /tmp/appimagetool - sudo install -m 0755 /tmp/appimagetool /usr/local/bin/appimagetool - file /usr/local/bin/appimagetool | grep -q ELF - - - uses: dtolnay/rust-toolchain@stable - with: - 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 + - uses: actions/download-artifact@v8 with: - workspaces: 'openless-all/app -> target' - - - name: Build fcitx5 plugin - working-directory: openless-all/scripts/linux-fcitx5-plugin - run: | - cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr - cmake --build build --parallel - ctest --test-dir build --output-on-failure - test -s build/libopenless.so - test -s build/openless.conf - - - name: Checkout pinned Qwen ASR runtime source - run: git submodule update --init --depth 1 -- openless-all/app/src-tauri/vendor/qwen-asr - - - name: Build portable Qwen ASR runtime - working-directory: openless-all/app/src-tauri/vendor/qwen-asr - run: | - make blas CFLAGS_BASE="-Wall -Wextra -O3 -ffast-math -mtune=generic" - test -x qwen_asr - ./qwen_asr --help >/dev/null 2>&1 - - - name: Verify framework-independent Linux contract - working-directory: openless-all/app - shell: 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 - 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 - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - if [ -z "${RELEASE_TAG:-}" ]; then - exit 0 - fi - case "$RELEASE_TAG" in - v*-tauri) ;; - *) - echo "::error::release_tag must name an existing v*-tauri release" - exit 1 - ;; - esac - if grep -q 'openless-linux-egui host stub' openless-all/app/linux-egui/src/main.rs; then - echo "::error::The egui UI stub cannot be uploaded as a release asset" - exit 1 - fi - gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null - - - name: Validate Linux desktop metadata - run: | - desktop-file-validate openless-all/app/linux-egui/packaging/openless.desktop - appstreamcli validate --no-net openless-all/app/linux-egui/packaging/top.openless.OpenLess.metainfo.xml - - - name: Build Linux egui host - working-directory: openless-all/app - run: cargo build --locked --release -p openless-linux-egui - - - name: Resolve package version - id: version - shell: bash - working-directory: openless-all/app - run: | - if [ -n "${RELEASE_TAG:-}" ]; then - VERSION=${RELEASE_TAG#v} - VERSION=${VERSION%-tauri} - else - VERSION=$(sed -n 's/^version = "\([^"]*\)"/\1/p' src-tauri/Cargo.toml | head -1) - fi - test -n "$VERSION" - echo "version=$VERSION" >> "$GITHUB_OUTPUT" - - - name: Package deb, rpm, and AppImage without Tauri - working-directory: openless-all/app - env: - OPENLESS_LINUX_VERSION: ${{ steps.version.outputs.version }} - OPENLESS_LINUX_ARCH: x86_64 - APPIMAGE_EXTRACT_AND_RUN: '1' - run: bash scripts/package-linux-egui.sh - - - name: Verify package contents and ELF dependencies - working-directory: openless-all/app - run: bash scripts/verify-linux-egui-packages.sh - - - name: Sign AppImage and write independent updater manifest - working-directory: openless-all/app - env: - MINISIGN_SECRET: ${{ secrets.LINUX_EGUI_MINISIGN_SECRET_KEY }} - VERSION: ${{ steps.version.outputs.version }} - run: | - OUTPUT=target/linux-egui-packages - APPIMAGE=$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' -print -quit) - if [ -n "${RELEASE_TAG:-}" ] && [ -z "${MINISIGN_SECRET:-}" ]; then - echo "::error::LINUX_EGUI_MINISIGN_SECRET_KEY is required for release upload" - exit 1 - fi - if [ -n "${MINISIGN_SECRET:-}" ]; then - if ! command -v minisign >/dev/null 2>&1; then - cargo install --locked --version 0.9.1 minisign - fi - printf '%s' "$MINISIGN_SECRET" > "$RUNNER_TEMP/linux-egui.minisign.key" - minisign -S -s "$RUNNER_TEMP/linux-egui.minisign.key" -m "$APPIMAGE" -x "$APPIMAGE.minisig" - fi - SHA256=$(sha256sum "$APPIMAGE" | cut -d' ' -f1) - ASSET=$(basename "$APPIMAGE") - RELEASE_REPOSITORY="${GITHUB_REPOSITORY:-Open-Less/openless}" - SIGNATURE=null - if [ -f "$APPIMAGE.minisig" ]; then - SIGNATURE=$(base64 -w0 "$APPIMAGE.minisig" | jq -R .) - fi - jq -n \ - --arg version "$VERSION" \ - --arg url "https://github.com/$RELEASE_REPOSITORY/releases/download/${RELEASE_TAG:-manual}/$ASSET" \ - --arg sha256 "$SHA256" \ - --argjson signature "$SIGNATURE" \ - '{schemaVersion:1, host:"linux-egui", arch:"x86_64", version:$version, url:$url, sha256:$sha256, minisign:$signature}' \ - > "$OUTPUT/latest-linux-egui-x86_64.json" + name: openless-linux-egui-x86_64 + path: linux-egui-packages - - name: Verify Linux updater manifest matches the artifact - working-directory: openless-all/app + - name: Verify downloaded release assets run: | set -euo pipefail - OUTPUT=target/linux-egui-packages - APPIMAGE=$(find "$OUTPUT" -maxdepth 1 -name '*.AppImage' -print -quit) - MANIFEST="$OUTPUT/latest-linux-egui-x86_64.json" - test -s "$APPIMAGE" - test -s "$MANIFEST" - SHA256=$(sha256sum "$APPIMAGE" | cut -d' ' -f1) - RELEASE_REPOSITORY="${GITHUB_REPOSITORY:-Open-Less/openless}" - jq -e \ - --arg sha256 "$SHA256" \ - --arg repository "$RELEASE_REPOSITORY" \ - '.schemaVersion == 1 and .host == "linux-egui" and .arch == "x86_64" and .sha256 == $sha256 and (.url | startswith("https://github.com/" + $repository + "/releases/download/"))' \ - "$MANIFEST" >/dev/null - - - name: Upload Linux egui workflow artifacts - uses: actions/upload-artifact@v4 - with: - name: openless-linux-egui-x86_64 - path: openless-all/app/target/linux-egui-packages/* - if-no-files-found: error + test "$(find linux-egui-packages -maxdepth 1 -name '*.deb' | wc -l)" -eq 1 + test "$(find linux-egui-packages -maxdepth 1 -name '*.rpm' | wc -l)" -eq 1 + test "$(find linux-egui-packages -maxdepth 1 -name '*.AppImage' | wc -l)" -eq 0 + ( cd linux-egui-packages && sha256sum --check --strict SHA256SUMS ) - - name: Upload Linux egui assets to existing release - if: inputs.release_tag != '' + - name: Attach Linux assets to the shared release draft uses: softprops/action-gh-release@v2 with: - tag_name: ${{ inputs.release_tag }} - files: openless-all/app/target/linux-egui-packages/* + tag_name: ${{ github.ref_name }} + draft: ${{ endsWith(github.ref_name, '-beta-tauri') || contains(github.ref_name, '-Beta.') }} + prerelease: ${{ endsWith(github.ref_name, '-beta-tauri') || contains(github.ref_name, '-Beta.') }} + files: linux-egui-packages/* fail_on_unmatched_files: true diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 68b66be60..d81e34382 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -42,6 +42,9 @@ jobs: matrix: include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"}]' || '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"},{"platform":"windows-latest","rust-target":"x86_64-pc-windows-msvc","updater-target":"windows","updater-arch":"x86_64"}]') }} runs-on: ${{ matrix.platform }} + # A release build takes ~33 minutes from a cold cache; anything past this is + # a hang that would otherwise hold the runner and the release for hours. + timeout-minutes: 120 env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} @@ -562,6 +565,7 @@ jobs: name: Update Homebrew cask (stable only) needs: build runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: write if: >- diff --git a/README.md b/README.md index 74f5f506b..4619efc37 100644 --- a/README.md +++ b/README.md @@ -213,9 +213,9 @@ Every item below is one more layer sedimented into a default — a capability yo - **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor). - **Local model management** — manage on-disk local-ASR model storage from Settings. - **Multilingual UI** — Settings → Language switches between 简体中文 / 繁體中文 / English / 日本語 / 한국어 (auto-detected on first launch). -- **In-app auto-update on the Tauri hosts** — Settings → About → Check; signed updater artifacts via the Tauri updater plugin on macOS, Windows, and Android. Linux has an independent manifest and updater contract. +- **In-app auto-update on the Tauri hosts** — Settings → About → Check; signed updater artifacts via the Tauri updater plugin on macOS, Windows, and Android. Linux deb/rpm packages have no in-app updater or AppImage manifest. - **Beta channel (opt-in)** — Settings → About → Join Beta channel exposes the latest pre-release build for manual download. Beta releases never reach Stable users automatically (see [Contributing workflow](#contributing-workflow)). -- **Distribution channels** — direct DMG/EXE from [Releases](../../releases), Homebrew Cask (add the project tap first; see installation below), and a Windows installer. Linux packages are not published until the separate egui UI is complete and its release gate is enabled. +- **Distribution channels** — direct DMG/EXE from [Releases](../../releases), Homebrew Cask (add the project tap first; see installation below), and a Windows installer. Linux deb/rpm packages attach to the shared Release only after device acceptance and an admin's release tag. - **Single-instance lock** — prevents two OpenLess processes from racing the same hotkey edge. - Dictionary entries are injected as Volcengine ASR `context.hotwords` and as semantic hints during polish; hits accumulate per session. - Platform-native global hotkey: CGEventTap on macOS, low-level keyboard hook (`WH_KEYBOARD_LL`) on Windows. @@ -237,7 +237,7 @@ Go to [Releases](../../releases) and download: - In-app updates (Settings → About) use `latest-android-{arch}.json` manifests; Beta users join Beta in Advanced settings. - Debug smoke builds: `OpenLess-android-debug-{abi}-*.apk` from workflow_dispatch artifacts. - If unsure, run `adb shell getprop ro.product.cpu.abi` and pick the matching APK. -- **Linux**: the Tauri/WebView build has been retired. `linux-egui` now contains a native `eframe` UI backed by the shared Core 2.0 services. Production release still requires Linux CI artifacts plus real Ubuntu audio, focus/input, install, upgrade, and rollback evidence. +- **Linux**: the Tauri/WebView build has been retired. The native `eframe` host uses the shared Core 2.0 services. After CI and real Ubuntu audio, input, install, upgrade and rollback acceptance, the shared release includes deb/rpm packages and `SHA256SUMS`. Linux requires glibc ≥ 2.39 (Ubuntu 24.04 or a comparable distribution); there is no AppImage or in-app updater. - **macOS (Homebrew)**: ```bash brew tap Open-Less/openless https://github.com/Open-Less/openless @@ -267,7 +267,7 @@ For the full end-user walkthrough, see [USAGE.md](USAGE.md). The active workspace lives in `openless-all/app/`. `crates/openless-core` is the framework-independent backend, `src-tauri` hosts macOS/Windows/Android, and `linux-egui` contains the native Linux UI and its platform adapters. Initialize submodules before a Tauri source build: its manifest resolves local path dependencies even when their target-specific code is not compiled. These include macOS ASR engines such as [`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr) under `src-tauri/vendor/`. The root Core/Linux workspace excludes `src-tauri`, so its independent checks do not parse the Tauri manifest or require those submodules. Start with the [documentation index](docs/index.md), [architecture](docs/architecture.md), and [source structure](docs/structure.md). -Rust 1.88 is the minimum supported toolchain for source builds; the latest stable Rust is recommended. CI verifies both Rust 1.88 and stable on macOS, Windows, and Linux. +Rust 1.88 is the minimum supported toolchain for the desktop application sources (`openless-core`, `src-tauri`); the latest stable Rust is recommended. CI verifies both Rust 1.88 and stable on macOS and Windows; the Linux egui host requires Rust 1.95 and is verified on stable. On Apple Silicon, compiling the optional Qwen3-ASR MLX backend requires Xcode's MetalToolchain component. Install it with `xcodebuild -downloadComponent MetalToolchain` and verify it with `xcrun --find metal`. This is a source-build dependency; packaged OpenLess applications do not require it at runtime. @@ -398,7 +398,7 @@ egui UI ── Linux Adapter (no Tauri/WebKitGTK) ────┘ `openless-core` owns the stable DTOs, errors, semantic events, repositories, credentials contract, and host-facing use-case Interface. Host-only concerns—IPC, windows, tray, permissions, updater, keyring, fcitx5, and package resource paths—are implemented by Adapters. Legacy React command/event names stay in the Tauri compatibility Adapter; Linux calls the typed Rust Interface in process. See [`docs/linux-egui-backend-contract.md`](docs/linux-egui-backend-contract.md). -The `v-tauri` / `v-Beta.N-tauri` workflows publish the macOS, Windows, and Android hosts. Linux deb/rpm/AppImage assets are built by `release-linux-egui.yml` with an independent manifest; automatic release remains gated on successful artifacts and real Ubuntu install/runtime/upgrade/rollback evidence. +The `v-tauri` / `v-Beta.N-tauri` workflows build the macOS, Windows and Android hosts alongside the independent Linux egui deb/rpm workflow. After Linux device acceptance and an admin's tag, verified Linux packages attach to the shared Release; Beta assets remain in a draft until an admin publishes them. Linux has no AppImage or in-app updater manifest. The dictation pipeline: `hotkey edge → Recorder.start + ASR.openSession → [audio frames] → hotkey edge → Recorder.stop + ASR.sendLastFrame → Polish → Insert → History.save`. diff --git a/README.zh.md b/README.zh.md index 2e06ad3e4..7443e9663 100644 --- a/README.zh.md +++ b/README.zh.md @@ -218,9 +218,9 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 - **本地模型管理**——在设置中管理本地 ASR 模型在磁盘上的存储。 - **多语言界面**——设置 → 语言 可在 简体中文 / 繁體中文 / English / 日本語 / 한국어 之间切换(首次启动自动检测)。 -- **Tauri 宿主内自动更新**——macOS、Windows 与 Android 通过 设置 → 关于 → 检查 获取签名产物;Linux 使用独立更新清单与 updater 契约。 +- **Tauri 宿主内自动更新**——macOS、Windows 与 Android 通过 设置 → 关于 → 检查 获取签名产物;Linux deb/rpm 不提供应用内更新或 AppImage 更新清单。 - **Beta 频道(可选加入)**——设置 → 关于 → 加入 Beta 频道,可下载最新预发布版本进行手动安装。Beta 版本绝不会自动推送给 Stable 用户(见[贡献流程](#贡献流程))。 -- **分发渠道**——从 [Releases](../../releases) 直接下载 DMG/EXE、Homebrew Cask(须先添加项目 tap,见下方安装步骤)、Windows 安装包。Linux 包会等独立 egui UI 完成并启用发布门禁后再正式发布。 +- **分发渠道**——从 [Releases](../../releases) 直接下载 DMG/EXE、Homebrew Cask(须先添加项目 tap,见下方安装步骤)、Windows 安装包。Linux deb/rpm 在真机验收、管理员创建发版 tag 后附到共用 Release。 - **单实例锁**——防止两个 OpenLess 进程争抢同一个快捷键边沿。 - 词典条目注入到支持热词的 ASR 提供方(Volcengine 的 `context.hotwords`、StepFun 的 `hotwords`、Whisper 兼容的 `prompt`(ZenMux 除外——其 JSON 协议不携带 `prompt`/`hotwords`)、百炼的 vocabulary_id),并在润色时作为语义提示;命中次数按会话累计。讯飞实时语音转写标准版没有请求级热词参数,需在讯飞控制台配置个性化热词。 - 平台原生全局快捷键:macOS 上为 CGEventTap,Windows 上为低级键盘钩子(`WH_KEYBOARD_LL`)。 @@ -242,7 +242,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - 应用内更新(设置 → 关于)读取 `latest-android-{arch}.json`;Beta 用户在高级设置加入 Beta 渠道。 - 调试包:`OpenLess-android-debug-{abi}-*.apk`(workflow_dispatch 产物)。 - 不确定时执行 `adb shell getprop ro.product.cpu.abi`,下载对应 ABI 的包。 -- **Linux**:原 Tauri/WebView 构建已退出 Linux 路线。`linux-egui` 现已包含基于共享 Core 2.0 服务的原生 `eframe` UI;正式发布仍需 Linux CI 产物以及 Ubuntu 真实音频、焦点输入、安装、升级和回滚证据。 +- **Linux**:原 Tauri/WebView 构建已退出 Linux 路线。基于共享 Core 2.0 服务的原生 `eframe` 宿主经 CI 和 Ubuntu 真实音频、输入、安装、升级、回滚验收后,随共用 Release 提供 deb/rpm 和 `SHA256SUMS`。要求 glibc ≥ 2.39(Ubuntu 24.04 或同等级发行版);没有 AppImage 或应用内更新。 - **macOS(Homebrew)**: ```bash brew tap Open-Less/openless https://github.com/Open-Less/openless @@ -272,7 +272,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 活跃 workspace 位于 `openless-all/app/`:`crates/openless-core` 是框架无关后端,`src-tauri` 承载 macOS/Windows/Android,`linux-egui` 包含 Linux 原生 UI 与平台 Adapter。源码构建 Tauri 前需初始化子模块:即使不编译对应平台代码,Cargo 仍会解析 manifest 中的本地 path 依赖,其中包括 `src-tauri/vendor/` 下的 macOS ASR 引擎,如 [`Open-Less/qwen-asr`](https://github.com/Open-Less/qwen-asr)。根 Core/Linux workspace 排除了 `src-tauri`,其独立检查不解析 Tauri manifest,也不要求这些子模块。阅读入口为 [docs/index.md](docs/index.md)、[架构](docs/architecture.md)和[目录结构](docs/structure.md)。 -Rust 1.88 是从源码构建所支持的最低工具链版本;建议使用最新 stable Rust。CI 会在 macOS、Windows 和 Linux 上同时验证 Rust 1.88 与 stable。 +Rust 1.88 是桌面应用源码(`openless-core`、`src-tauri`)所支持的最低工具链版本;建议使用最新 stable Rust。CI 在 macOS 与 Windows 上同时验证 Rust 1.88 与 stable;Linux egui 宿主需要 Rust 1.95,按 stable 验证。 Apple Silicon 编译可选的 Qwen3-ASR MLX 后端时需要 Xcode 的 MetalToolchain 组件。执行 `xcodebuild -downloadComponent MetalToolchain` 安装,并用 `xcrun --find metal` 验证。它只属于源码构建依赖,已打包的 OpenLess 应用运行时不需要该组件。 @@ -403,7 +403,7 @@ egui UI ── Linux Adapter(无 Tauri/WebKitGTK)───┘ `openless-core` 负责稳定 DTO、错误、语义事件、repository、凭据契约和面向宿主的 use-case Interface。IPC、窗口、托盘、权限、更新、keyring、fcitx5 与打包资源路径等宿主能力由 Adapter 实现。旧 React command/event 名称只保留在 Tauri 兼容 Adapter;Linux 与 core 同进程,通过类型化 Rust Interface 调用。详细契约见 [`docs/linux-egui-backend-contract.md`](docs/linux-egui-backend-contract.md)。 -`v-tauri` / `v-Beta.N-tauri` 工作流发布 macOS、Windows 与 Android 宿主。Linux deb/rpm/AppImage 由 `release-linux-egui.yml` 使用独立 manifest 构建;自动发布仍以产物成功和 Ubuntu 真实安装、运行、升级、回滚证据为门禁。 +`v-tauri` / `v-Beta.N-tauri` 工作流构建 macOS、Windows、Android 宿主以及独立的 Linux egui deb/rpm。Linux 真机验收完成、管理员创建 tag 后,验证过的 Linux 包自动附到共用 Release;Beta 资产先留在草稿中供管理员核对发布。Linux 不提供 AppImage 或应用内更新清单。 听写流水线:`hotkey edge → Recorder.start + ASR.openSession → [audio frames] → hotkey edge → Recorder.stop + ASR.sendLastFrame → Polish → Insert → History.save`。 diff --git a/RELEASING.md b/RELEASING.md index 382099e94..f84c6995f 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -7,7 +7,7 @@ day-to-day development. **Only repository administrators may create version tags and publish releases.** -- Only an admin may create a release tag (`v*-tauri`), invoke the Linux asset workflow for an existing release, or publish a GitHub Release. +- Only an admin may create a release tag (`v*-tauri`) or publish a GitHub Release. Linux assets are attached automatically by the tag workflow after product acceptance. - Contributors (including AI agents) **must not** create release tags, publish releases, or trigger release automation. If a release is needed, **request an admin to cut it** — open an issue or ping a maintainer with the target version and the @@ -43,9 +43,13 @@ component, which is a 16-bit field. The full version still appears in the app an updater manifest; NSIS uses its supported numeric fallback for file metadata. See [the pinned NSIS bundler](https://github.com/tauri-apps/tauri/blob/tauri-cli-v2.10.1/crates/tauri-bundler/src/bundle/windows/nsis/mod.rs#L149). -These tags publish the macOS, Windows, and Android Tauri hosts. Linux is not part of the Tauri matrix. Its independent host is built by `.github/workflows/release-linux-egui.yml`, which accepts an existing `release_tag` and writes `latest-linux-egui-x86_64.json`. This workflow has no automatic tag trigger; Linux publication requires its own product acceptance below. +These tags build the macOS, Windows, and Android Tauri hosts and the independent Linux egui host in parallel. Linux is not part of the Tauri matrix: `.github/workflows/release-linux-egui.yml` builds only deb/rpm, attaches them to the same Release draft and publishes no in-app updater manifest or AppImage. Linux product acceptance must be completed **before** an admin pushes the shared tag. CI runs the same Linux packaging and verification path on PRs without attaching release assets. +For each new Linux package candidate, increment `openless-all/app/linux-egui/package-revision` +from the last `-N` suffix (for example `2.0.0-Beta.2-79` → `2.0.0-Beta.2-80`). +Both local packaging and the shared CI/tag build use this explicit revision; never drop +it or derive it from the Tauri tag, whose SemVer remains `2.0.0-Beta.2`. -Under the [2026-09-06 2.0 requirements](docs/2.0-requirements.md), Windows and macOS must fully retain their respective Tauri 1.x features. This first delivery includes a usable Core contract and a [split handoff directory](docs/linux-egui-handoff/README.md) for Linux; the egui team owns remaining Linux Host/UI work and Linux product acceptance. Incomplete Linux application features do not independently block this Windows/macOS delivery. Shared Core defects and the desktop platforms' own acceptance requirements still do. Existing Android builds do not expand this scope into a new full-support commitment. +Under the [2026-09-06 2.0 requirements](docs/2.0-requirements.md), Windows and macOS must fully retain their respective Tauri 1.x features. The egui team owns Linux Host/UI work and Linux product acceptance. Although Linux application gaps did not previously block Windows/macOS delivery, this shared-tag workflow now attaches Linux packages automatically: do not cut a shared release tag until Linux is accepted as well. Existing Android builds do not expand this scope into a new full-support commitment. ## Version-sync gate @@ -74,26 +78,26 @@ The script takes a plain `X.Y.Z`; for a prerelease version such as 1. Branch is the intended channel (`beta` for Beta, `main` for Stable). 2. All five version files match (version-sync gate green). 3. CI is green on the commit being tagged. -4. The applicable [desktop feature and device acceptance](docs/2.0-desktop-acceptance.md), signing, and distribution requirements are met; green builds alone do not establish product readiness. +4. The applicable [desktop feature and device acceptance](docs/2.0-desktop-acceptance.md), signing, and distribution requirements are met; green builds alone do not establish product readiness. Linux acceptance below is required before including Linux assets. 5. Then, and only then, push the release tag. -6. Beta tag workflows upload desktop and Android assets to a shared draft. - Wait for both workflows to succeed, verify the packages and Beta updater - manifests, then publish that draft as a prerelease. Do not rerun an asset - workflow after publication without first returning the release to draft. +6. Beta tag workflows upload Tauri, Android and Linux egui assets to a shared draft. + Wait for **all three** workflows to succeed, verify the packages, Linux deb/rpm + checksums and Beta updater manifests, then publish that draft as a prerelease. + Do not rerun an asset workflow after publication without first returning the release to draft. -Before attaching Linux assets, additionally require all of the following: +Before pushing a tag that will automatically attach Linux assets, additionally require all of the following: 1. The egui team has completed the [Linux Host/UI gaps and acceptance](docs/linux-egui-handoff/07-acceptance.md). The existing `eframe::App` is a starting implementation; its presence and successful packaging alone do not establish product completeness. 2. Linux core/host tests, dependency gates, and secret-surface gates are green on Ubuntu. -3. The manual Linux workflow verifies the ELF dependency list, deb/rpm/AppImage contents, desktop metadata, fcitx5 plugin paths, minisign output, and independent updater manifest. -4. An admin passes the already published Tauri release tag as `release_tag`; a non-empty tag also requires `LINUX_EGUI_MINISIGN_SECRET_KEY`. +3. The Linux workflow verifies ELF dependencies, deb/rpm contents, desktop metadata, fcitx5 plugin paths and package SHA-256 checksums. There is no AppImage, minisign key or Linux updater manifest. +4. The shared tag points to the same commit whose CI and Linux product acceptance were reviewed. A `workflow_dispatch` build only uploads Actions artifacts, never Release assets. ## Process summary 1. Land work on `beta` via PRs (open PRs against `beta`, never `main`). 2. For a Stable release, a maintainer merges `beta → main`. 3. An **admin** bumps the Tauri version (five-location sync), verifies CI is green, - and pushes the release tag, which triggers the macOS/Windows/Android publish and - auto-update pipeline. -4. After Linux product acceptance and packaging have passed on its native - runner, an admin invokes the independent Linux workflow against that existing tag. + and pushes the release tag, which triggers the macOS/Windows/Android and Linux + asset workflows (only supported platforms generate auto-update manifests). +4. After Linux product acceptance and the shared tag's Tauri, Android and Linux + workflows pass, an admin reviews all assets and publishes the shared draft. diff --git a/docs/architecture.md b/docs/architecture.md index e1ba031c1..408387d1c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,7 +65,7 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装 ## 5. 窗口体系 -`src-tauri/tauri.conf.json` 声明 `main`、`capsule` 两个窗口。`src/main.tsx` 读取 `?window=`,`src/App.tsx` 按类型加载胶囊、`qa`、`selection-polish-preview`、`selection-voice-intent`、`less-computer` 和 `less-computer-glow`;未指定类型时进入主界面。各 WebView 共用前端入口,重页面按需加载;移动端再依据平台能力选择布局。Linux 单实例由 `linux-egui/src/single_instance.rs` 守护并转发启动意图。 +`src-tauri/tauri.conf.json` 声明 `main`、`capsule` 两个窗口。`src/main.tsx` 读取 `?window=`,`src/App.tsx` 按类型加载胶囊、`qa`(含复用它的「润色结果」模式)、`selection-voice-intent`、`less-computer` 和 `less-computer-glow`;未指定类型时进入主界面。各 WebView 共用前端入口,重页面按需加载;移动端再依据平台能力选择布局。Linux 单实例由 `linux-egui/src/single_instance.rs` 守护并转发启动意图。 主窗口默认逻辑尺寸为 1300×835,允许用户调整;macOS 原生窗口按钮左侧和顶部均留出 16px,前端保留 44px 拖动区。桌面侧栏宽 226px,主内容从版本行下方开始,设置面板单独限制高度并在内部滚动。 diff --git a/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md new file mode 100644 index 000000000..4b0b9e611 --- /dev/null +++ b/docs/ci-trigger-and-cache-policy.md @@ -0,0 +1,108 @@ +# CI 触发范围与缓存配额 + +状态:canonical;更新:2026-09-26。本文说明 CI 为什么在什么情况下跑哪些 job、缓存在 10 GB 预算内怎么分配,以及发版前怎么把耗时压下来。 + +## 1. 触发与门控 + +| 事件 | 行为 | +|---|---| +| `pull_request` → `main` / `beta` | 按改动范围门控:改动够不到的平台直接跳过(见下) | +| `push` → `main` / `beta` | 全量验证(合并后的基线必须有完整证据),不跳过任何 job | +| tag `v*-tauri` | 发版:调用同一个可复用 Linux 打包工作流,并发布 deb/rpm | +| `workflow_dispatch` | 全量;`platform=macos` 只跑 macOS(仅 macOS 验证入口) | + +同一 PR 连续推送会取消旧运行(`concurrency.cancel-in-progress`)。 + +### 改动范围判定 + +`openless-all/app/scripts/ci-changed-areas.sh` 对每个重平台 job 判定“改动物理上够不够得到它”,CI 的 `changes` job 调用它填 `changes.outputs.tauri` / `changes.outputs.msrv`: + +| 区域 | 跳过条件(全部改动都命中才算跳过) | 覆盖的 job | +|---|---|---| +| `tauri` | 仅 `linux-egui/**`(但**不含**它的 `Cargo.toml`,那会动全工作区 lock)、`scripts/linux-fcitx5-plugin/**`、`docs/**`、`*.md`、`LICENSE` | macOS / Windows 桌面检查、Android 检查 | +| `msrv` | 全部改动里没有任何 `.rs`、`Cargo.toml`、`Cargo.lock`、`rust-toolchain*`、`.github/**` | macOS Rust 1.88 MSRV | +| `linux` | 全部改动都是散文(`docs/**`、`*.md`、`LICENSE`、`NOTICE`) | Linux Core/egui 测试与 deb/rpm 打包链 | + +规则是**失败即运行**,而且这一原则贯穿三层: + +1. 脚本层:没有基线提交(push / tag / 手动)、git 读取失败、diff 为空、区域名未知,一律返回 `true`。 +2. 工作流层:`changes` job 只把明确的 `false` 当作跳过依据,其余(含输出缺失)都算运行;`linux` 的 `scope` 输入默认 `full`。 +3. 契约层:`scripts/ci-changed-areas.test.mjs` 既驱动脚本对真实 git 差异做正反判定,也检查 ci.yml 是否把每个区域都发布成 job 输出、每个门控是否写成 fail-open、可复用工作流是否默认全量构建。区域名或输出接线写错会直接测试失败——这正是它抓到的一次真实故障(`linux` 输出漏写导致 Linux job 被误跳过)。 + +跳过只允许是“改动可达性可证明”的结果,不允许是“读不到差异”或“接线漏了”的兜底。 + +`linux-egui-package` 通过可复用工作流的 `scope` 输入接收结果(`full` / `none`,**默认 `full`**):纯散文改动跳过整个 Linux 构建与打包链,而 push / tag / 发版调用不传该输入,因此永远全量构建。 + +## 2. MSRV 为什么是独立 job + +- `openless-core`、`src-tauri`、`src-tauri/backend-tests` 都声明 `rust-version = "1.88"`,README 对外承诺该版本,因此必须有 CI 用真的 1.88 编译:MSRV 只会被“依赖升级抬高要求”或“自己用了更新的语言特性/API”打破,两者都只有在 1.88 下编译才会暴露。 +- 工具链不同 ⇒ cargo fingerprint 全失效、产物与缓存不能共用。历史上 stable 与 MSRV 挤在同一个 job 里共用缓存,串行 29 分 59 秒(见 [`macos-build-performance.md`](macos-build-performance.md));拆成并行独立 job、缓存分开后是 stable 8m36s / MSRV 3m39s。 +- 平台专属依赖图不同,所以 macOS 与 Windows 各自验证:macOS 是 `keyring/apple-native`、MLX/qwen-asr;Windows 是 `keyring/windows-native`、TSF IME。 +- Linux 宿主 `linux-egui` 声明 `rust-version = "1.95"`(egui 0.36 的要求),因此 Linux 侧不存在 1.88 检查,README 已按此修正。 + +## 3. 缓存配额分配(10 GB / 仓库) + +GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job 只会打印 `No cache found.`,表现为“无理由全量冷编译”。实测过的对照:`cargo test -p openless-core` 命中 148 s,冷启动 579 s。 + +实测体积与收益(一次命中节省 / 该 job 缓存体积): + +| job | 缓存 | 命中收益 | 性价比 | +|---|---|---|---| +| macOS Rust 1.88 MSRV | 0.72 GB | −13.1 min | 1102 s/GB | +| macOS stable | 0.88 GB | −11.4 min | 775 s/GB | +| Linux egui | 1.34 GB | −10.8 min | 483 s/GB | +| Android | 0.75 GB | −2.4 min | 188 s/GB | +| Windows | 1.62 GB | −4.6 min | 169 s/GB | + +分配原则:**按平台配额 + 只有基线分支/tag/迭代中的 job 允许写。** + +| 归属 | 目标 | 谁写 | 谁读 | +|---|---|---|---| +| Windows 依赖树 | 1.2–1.6 GB | `push`(beta/main)、tag | 所有 PR、发版 | +| macOS(stable 0.88 + MSRV 0.72 + MLX 0.30) | ~1.9 GB | 同上 | 同上 | +| Linux(egui + core) | ~1.7 GB | `push`、tag,**以及 PR 里的 `build-linux-egui`** | 同上 | +| Android | 0.75 GB | `push`、tag | PR | +| 发版(tag 作用域) | ~1.0 GB | tag / 发版前预热 | — | +| 余量 | ≥2 GB | — | — | + +对应的机制: + +- 重量级 job 用 `save-if: ${{ github.event_name != 'pull_request' }}`:PR 只从基线分支恢复,不再各自复制一份 1.6 GB 依赖树(此前两个 PR 就占了 10.87 GB,把基线缓存挤掉)。 +- `build-linux-egui` 保留 PR 内自写并开 `cache-on-failure: true`:失败的运行也会把依赖缓存存下来,下一次迭代仍然热;同时关掉 dev/test debug info,缓存更小、链接更快。 +- key 结构为 `v0-rust---<环境哈希>-`:环境哈希含工具链与 `CARGO_PROFILE_*`,所以 stable / MSRV / release 天然分开,不要用跨平台 `shared-key` 混合。 +- `Cargo.lock` 变化时精确键失效,但 `restore-keys` 前缀回退会拿到同前缀的旧缓存,所以“锁变了”不等于“全冷”——前提是那份没被淘汰,这正是要守预算的原因。 + +## 4. 维护 + +- 报告与清理:`bash openless-all/app/scripts/ci-cache-usage.sh [--prune] [--limit-gb 8] [--repo OWNER/NAME]`。`--prune` 只删已关闭/已合并 PR 的 `refs/pull/*` 作用域(先查 PR 状态,状态读不到就保留),永不触碰分支与 tag 作用域;用量超过预算时退出码为 1。 +- 自动维护:`.github/workflows/cache-maintenance.yml` 每周一 03:17 UTC 运行并支持手动触发,预算 8 GB。 +- 合并/关闭 PR 后可手动立即回收:`gh api -X DELETE repos/Open-Less/openless/actions/caches?ref=refs/pull//merge`。 + +## 5. 实测对照 + +同一 PR(#1060)、同一批 job,改动前后: + +| job | 改动前(冷 / 超配额) | 缓存配额生效后 | 门控生效后 | +|---|---|---|---| +| Changed areas | — | — | 11 s | +| macOS checks | 20m01s | 8m36s | 6m16s | +| Windows checks | 20m04s | 15m30s | 13m17s | +| Android | 8m11s | 5m50s | 5m50s | +| macOS Rust 1.88 MSRV | 16m47s | 3m39s | 3m39s | +| Linux egui(测试 + 打包) | 17m21s | 6m35s(旧缓存前缀) | 8m08s(新前缀首次命中) | + +同一步骤的冷 / 热对照:`cargo test -p openless-core` 命中 201 s、冷启动 579 s;`cargo build --release -p openless-linux-egui` 命中 124 s、冷启动 463 s。整轮墙钟时间由最慢的 job 决定(本轮 Windows 13m17s),改动前是 20m12s。 + +## 6. 发版耗时 + +`Release Tauri (cross-platform)` 的基线是 ~32.7 分钟,关键路径是 macOS Intel 构建。原因是 tag 运行只能读默认分支(`beta`)的缓存,而基线分支跑的是 debug/test profile,不产生 `macos-release-v1` 前缀的 release 缓存,因此每个新 tag 都要从零编译 release 依赖。 + +**发版前先预热**:把一个 `Release Tauri (cross-platform)` 运行 dispatch 到普通分支(上游历史上就在 `perf/macos-ci-build` 这样做过,单次 11–19 分钟),预热完成后再打 tag,可把发版压到 ~10 分钟量级。 + +Linux 发版不受影响:`release-linux-egui.yml` 复用 CI 的同一个 `build-linux-egui` job(同名同 key),tag 运行能恢复基线分支的缓存。 + +## 7. 已知取舍 + +- 改动范围门控只跳过“够不到”的平台;`push` 到 `main`/`beta` 始终全量,所以合并后仍有完整证据。 +- `build-linux-egui` 在 `push` 时仍会构建 release 二进制(保持基线缓存里有 release 产物、并持续验证打包链),但不再上传 Action artifact;artifact 只在 PR 与 tag 运行产生。 +- 所有 job 都设了 `timeout-minutes`,避免卡死的运行长时间占用并发与时间。 diff --git a/docs/index.md b/docs/index.md index fe3f406d5..21f4dc94f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,7 @@ ## 平台与运营 - [macOS CI 与打包耗时](macos-build-performance.md):基线日志、Rust 编译优化、缓存边界与仅 macOS 验证入口。 +- [CI 触发范围与缓存配额](ci-trigger-and-cache-policy.md):改动范围门控、10 GB 缓存分配与维护、发版预热点。 - [Android APK 编译耗时调研与实现](android-build-time-research-1103.md):ABI 并行、缓存回写和发布验证。 - [Android APK / 悬浮窗计划](android-mobile-apk-overlay-plan.md)(实施中) - [火山引擎 ASR 配置](volcengine-setup.md) diff --git a/docs/linux-egui-backend-contract.md b/docs/linux-egui-backend-contract.md index aa803fcfb..83d60420e 100644 --- a/docs/linux-egui-backend-contract.md +++ b/docs/linux-egui-backend-contract.md @@ -1,6 +1,6 @@ # Linux egui 后端接口契约(2.0.0) -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-07。范围以[2.0 需求](2.0-requirements.md)为准;本文是长接口与实现参考,交接材料以[交接目录](linux-egui-handoff/README.md)为准。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-27。范围以[2.0 需求](2.0-requirements.md)为准;本文是长接口与实现参考,交接材料以[交接目录](linux-egui-handoff/README.md)为准。 ## 1. 合同文件 @@ -17,8 +17,8 @@ ## 2. Linux 侧公开签名(源码为准) -- `linux-egui/src/lib.rs`:`pub struct LinuxHost`;`LinuxHost::new(Arc)`、`with_settings_runtime`、`backend()`、`subscribe() -> EventSubscription`、`snapshot() -> BackendSnapshot`、`save_settings(...)`、`update_settings_strict(...)`、`drain_events(...)`、`feed_less_computer_pcm(&[u8])`。 -- `linux-egui/src/backend.rs`:`LinuxBackendRuntime`;`LinuxBackendBuilder::from_shared_providers(BackendConfig)` + `with_task_spawner / with_recorder / with_auxiliary_polisher / with_text_inserter / with_credential_store / with_services / with_host_actions / with_settings_runtime / with_local_asr_runtime / with_polish_failure_policy` → `build() -> LinuxBackendRuntime`。 +- `linux-egui/src/lib.rs`:`pub struct LinuxHost`;`LinuxHost::new(Arc)`、`with_settings_runtime`、`backend()`、`subscribe() -> EventSubscription`、`snapshot() -> BackendSnapshot`、`save_settings(...)`、`update_settings_strict(...)`、`update_preference_fields(...)`(`preference_patch.rs`) 、`drain_events(...)`、`feed_less_computer_pcm(&[u8])`。 +- `linux-egui/src/backend.rs`:`LinuxBackendRuntime`;`LinuxBackendBuilder::from_shared_providers(BackendConfig)` + `with_task_spawner / with_recorder / with_auxiliary_polisher / with_text_inserter / with_credential_store / with_services / with_settings_runtime / with_polish_failure_policy` → `build() -> LinuxBackendRuntime`。 - 事件消费:`drain_events`(`lib.rs`)批量取走 Core 语义事件;订阅经 `EventSubscription`。 ## 3. 注入接口(`crates/openless-core/src/ports.rs`) @@ -28,11 +28,17 @@ ## 4. 与桌面共享 - 云 ASR/LLM/Omni/Auxiliary 实现两端共用(Core `asr/`、`provider_*`、`omni`、`llm_gemini`);平台 Host 只注入原生录音、凭据、窗口、进程、焦点与插入 Adapter。 -- 旧 React command/event 名称只保留在 Tauri 兼容 Adapter;Linux 与 Core 同进程,经类型化 Rust Interface 调用。 +- 旧 React command/event 名称只保留在 Tauri 兼容 Adapter;Linux 常驻宿主与 Core 同进程,经类型化 Rust Interface 调用;独立 UI 子进程经 Unix socket JSONL v2 通信,先 Hello/Ready 握手再收业务快照和热键。关闭 UI 不停止宿主或 Backend。 - provider 公开目录:Core `provider_rules::provider_descriptors` → 生成 `src/lib/ipc/provider-descriptors.generated.json`(`cargo run --locked -p openless-core --example export_provider_descriptors`)。 ## 5. 行为约定(合同级) +- fcitx5 为生产启动硬依赖:持有单实例锁后检查插件和会话 D-Bus,按需刷新并在 15 秒就绪预算内等待,再启动监听器、注册必需热键、启动 Core 和 Remote Input。失败清理已启动资源并显示错误窗口;关闭后以非零状态退出,外层锁覆盖完整生命周期。 +- 设置保存状态与动作处理位于 `linux_app/settings_save.rs`:按序列化字段生成补丁、串行提交、只重试明确 revision 冲突(最多三次),失败保留草稿并显式重试;Core 合同与持久化格式不变。 +- 窗口生命周期与 client 位于 `linux_app/window.rs`,协议门禁位于 `ui/bridge.rs`。显式显示窗口请求创建窗口或发送 FocusMain;Wayland 是否激活由合成器决定。 +- 历史缓存位于 `linux_app/history.rs`:初始加载、history revision 变化、事件重放截断或显式刷新触发单任务读取,历史文件和 WAV 探测只在 `spawn_blocking` 中进行;概览复用缓存。 +- Linux 当前不包含任何本地 ASR 推理运行时;Qwen、MLX、Foundry 均明确不支持,不记作待设备验证。 + - 原生预加载必须使用请求的 target/provider type;平台无法准备流式时以 `supports_streaming=false` 保留一次性落字。 - Remote stop 保持可取消的 session;socket 下行只转发本连接所属 session 的事件。 - 自动 contract 不替代真实平台证据:Ubuntu/Windows/macOS/Android 的设备、安装、升级与签名结果按[验收](linux-egui-handoff/07-acceptance.md)与[桌面验收](2.0-desktop-acceptance.md)分别记录。 diff --git a/docs/linux-egui-dependency-upgrade.md b/docs/linux-egui-dependency-upgrade.md new file mode 100644 index 000000000..1e55c3593 --- /dev/null +++ b/docs/linux-egui-dependency-upgrade.md @@ -0,0 +1,32 @@ +# Linux egui 依赖升级与 Vulkan 渲染 + +更新:2026-09-24。以 `linux-egui/Cargo.toml`、Cargo.lock 和当前源码为准;旧 Pi 记录和此前暂缓计划只作为线索。 + +## 已完成 + +- eframe / egui / egui-winit / egui-wgpu 从 0.33.3 升至 0.36.2;wgpu 从 27 升至 30.0.1;Glow 绑定随之升至 0.17。 +- Linux 主窗口和普通 eframe 弹窗显式选择 `Renderer::Wgpu`,并把 wgpu 实例可用后端限定为 Vulkan。 +- Siri 波形、圆点和选区助手环改用 egui 原生图形绘制,避免 Glow shader callback 在 WGPU 窗口中被忽略后退化成普通音量条。 +- 0.36 破坏性 API 已适配:`App::ui`、`Context::run_ui`、`CentralPanel::show(&mut Ui)`、文本编辑框 frame、UI 样式按主题设置,以及 Glow layer-shell 纹理增量可变传递。 +- Cargo.lock 已解析升级 62 个兼容依赖;包括 eframe/egui 图形栈、accesskit、wgpu/Naga 等。其余直接依赖未为追新而跨主版本改动。 + +## 仍存在的 GL 路径 + +自建的 Wayland wlr-layer-shell 胶囊面仍通过 `glutin`/EGL 和 `egui_glow::Painter` 提交 epaint 图元。这是独立于 eframe 主窗/普通弹窗的 surface 实现;Siri 效果本身已不再依赖 GL shader。X11 overlay 也保留现有窗口管理器路径。 + +若要让 layer-shell surface 也由 WGPU/Vulkan 提交,需要将 `popup_layer.rs` 的手工 Wayland surface、presentation 和帧同步整体改成 wgpu surface;这不是 egui 升级的 API 适配,需另行实现与设备验证。 + +## 当前验证与限制 + +- 已运行 `cargo fmt --manifest-path openless-all/app/Cargo.toml --package openless-linux-egui`。 +- 已运行 `cargo check --manifest-path openless-all/app/Cargo.toml -p openless-linux-egui`,在 Rust 1.95.0 下通过。Cargo 缓存和 build 输出位于 `/tmp`。 +- 本环境 `vulkaninfo --summary` 只枚举出 llvmpipe CPU Vulkan 设备;没有真实 GPU,因此不能据此证明硬件 Vulkan 性能、Wayland layer-shell 和发行版驱动表现。 +- 未运行测试或完整安装包构建。egui 0.36 API 变更可能影响现有 GUI 测试源码,运行验证前需单独完成测试 API 迁移。 +- eframe 仍依赖 winit 0.30;此版本升级不会解决 Wayland 触摸屏拖动窗口所需 serial 的缺口。 + +## 后续设备验收 + +1. 在带 Vulkan 硬件驱动的 Wayland 与 X11 主机启动主窗及三个弹窗,确认日志枚举到硬件 Vulkan adapter。 +2. 检查 Siri 录音波形、处理动画和 QA 录音环在透明 surface 上的颜色、裁剪、帧率与显存/CPU 占用。 +3. 检查 LayerShell 胶囊的 EGL surface、窗口位置、焦点与音频动画;当前这条 surface 仍走 GL。 +4. 在无 Vulkan loader 或 adapter 时确认故障提示清晰。目前选择的是 Vulkan-only,启动时没有自动回退 GL。 diff --git a/docs/linux-egui-handoff/02-gap-register.md b/docs/linux-egui-handoff/02-gap-register.md index 093fecd7d..554cd9825 100644 --- a/docs/linux-egui-handoff/02-gap-register.md +++ b/docs/linux-egui-handoff/02-gap-register.md @@ -1,20 +1,37 @@ -# 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 编译代替 | - -完成判据:实现与自动证据齐备后可提交人工验收;只有对应矩阵和签名发布证据完成,才关闭真实桌面/分发维度。所有待人工项均保持未验收,不能标成已通过。 +# 02:Linux 缺口登记与实施顺序 + +状态:以当前 `openless-linux-egui` 源码盘点;更新:2026-09-24。此表区分代码实现、自动验证和设备验证,不把待实测项目记作未实现。 + +## 1. 状态定义 + +- **待实现**:Linux 生产路径缺少实际代码或用户入口。 +- **已实现,待设备验证**:生产代码和自动化覆盖已存在,仍需真实桌面、设备或安装证据。 +- **明确不支持**:当前 Linux 能力边界,不得在 capability/UI 中宣称可用。 + +## 2. 待办登记 + +| ID | 当前状态与源码锚点 | 关闭标准 | +| --- | --- | --- | +| L01 | **部分完成**:QA 编辑预览的“确认并替换”现已按 Tauri 处理应用失败/成功/取消结果,失败时保留预览;Selection Voice 本身仍无 Linux 生产触发和录音/意图/应用完整编排 | 从按键边沿、捕获原选区、录音终结、意图分流到 QA/编辑预览及应用/取消/撤回均按 session 所有权执行;失败/迟到结果不能作用于新会话 | +| L02 | **明确不支持**:Linux 不保证前台应用身份或插入后手改观察;`backend.rs` 使用 Core Noop HostContext/EditObservation adapter | 保持 `source_app = None` 和 capability 真实;只有找到跨 X11/Wayland 可靠且尊重隐私的机制才重开 | +| L03 | **已实现,待设备验证**:`audio.rs` CPAL 录音、WAV 归档、保留策略、静音/恢复、提示音及历史录音操作已接入 | X11/Wayland 验证设备拔插、取消/错误/退出后的恢复、提示音与实际音量设备行为 | +| L04 | **已实现,待设备验证**:fcitx5 为启动硬依赖;插件、会话 D-Bus 或必需热键失败进入错误窗口,关闭后退出;不提供 IBus/系统全局热键替代 | X11/Wayland 验证触发、冲突拒绝、重绑/重启恢复及不支持场景提示 | +| L05 | **已实现,待设备验证**:远程手机输入 HTTPS/WSS、TLS 身份、PIN、录音页、Core 外部音频会话与设置 UI 位于 `remote_input.rs` / `runtime.rs` / `settings.rs` | 用真实手机和 Linux 主机完成同网连接、证书信任、PIN、录音插入/仅转写、断线恢复、禁用/关停撤销;记录平台/浏览器矩阵 | +| L06 | **已实现,待设备验证**:Secret Service 凭据、fcitx5、托盘/通知/自启均有 Host 实现;Qwen 等本地 ASR **明确不支持**,没有推理运行时;更新器不适用(只发 deb/rpm,宿主不自更新) | 逐项在目标发行版/桌面运行验证;不得将未实现的本地 ASR 记作待设备验证 | +| L07 | **明确不支持**:Linux 不承诺 IBus 或通用全局热键;Selection Voice 在 L01 完成前保持隐藏 | 能力发现、设置和产品文案持续反映实际能力 | +| L08 | **已实现,待设备验证**:deb/rpm 包和发布工作流已存在(AppImage 已下线) | 完成安装、升级、回滚、校验和与分发验收记录 | + +## 3. 实施顺序 + +1. 完成 L01,按 Tauri 的 Selection Voice 状态机接线,并保留 Linux 对不可用目标能力的显式拒绝。 +2. 完成 L05 的真实手机端到端验证;自动化测试通过不能替代手机/桌面证据。 +3. 依序补齐 L03/L04/L06/L08 的设备证据;L02/L07 是明确边界,不伪造支持。 + +## 4. 源码审计补充 + +- `openless-core` 内有 **5 处 `target_os = "linux"` 条件编译,分布在 4 个文件**:ASR frame、Volcengine ASR、`shared_types.rs` 两处、polish。另有 2 处 `cfg(unix)`,分别是事务替换和录音归档,属于 Unix 通用逻辑。其余 Linux 文本主要是文档/测试或平台中立策略说明。 +- 目前不建议拆 Core:这些条件编译只覆盖少量输入格式/供应商差异,服务、策略和契约仍平台中立;Linux 的 DBus、录音、fcitx5、桌面窗口和远程输入适配器已经位于 `linux-egui`。 +- 无边框窗口拖动的触摸屏限制来自 Wayland 拖动协议所需的指针按键 serial,winit 丢弃触摸 serial;升级 egui 不能改变该协议条件。桌面侧可用 KWin 的 **Alt+F7** 移动窗口(键盘移动后用触摸板/方向键定位);若当前会话是 X11,可由窗口管理器移动。此项目不在应用内伪造拖动实现。 +- 依赖升级与 Vulkan 后端切换已落入源码;见 [`linux-egui-dependency-upgrade.md`](../linux-egui-dependency-upgrade.md)。设备图形路径仍需在真实 GPU/Wayland/X11 主机验收。 + +记录格式:`ID / commit / 实现效果 / 自动证据 / 设备证据 / 剩余限制`。 diff --git a/docs/linux-egui-handoff/03-hotkeys-and-windows.md b/docs/linux-egui-handoff/03-hotkeys-and-windows.md index fe77bfd5d..be44f2fdd 100644 --- a/docs/linux-egui-handoff/03-hotkeys-and-windows.md +++ b/docs/linux-egui-handoff/03-hotkeys-and-windows.md @@ -1,13 +1,11 @@ # 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. 已有实现 - **fcitx5 热键监听**:`hotkeys.rs` `Fcitx5HotkeyListener::start()/drain()/take_error()`——经 fcitx5 插件路由热键事件(已实现待实测,见 L11)。 -- **fcitx5 输入/选区**:`fcitx5.rs`——插件安装(AppImage 在 listener 前安装)、PRIMARY 选区、落字。 +- **fcitx5 输入/选区**:`fcitx5.rs`——插件安装(deb/rpm 下把随包插件装进用户 fcitx5 搜索路径)、PRIMARY 选区、落字。 - **单实例**:`single_instance.rs` `SingleInstanceGuard::acquire(path)` + `SingleInstanceBroker::acquire_or_forward()/drain()`——二次启动意图转发给首实例。 - **设置侧拒绝逻辑**:`settings.rs:56-61` 对 `switch_style` / `open_app` / `style_packs` 三类热键修改给出明确拒绝信息(L01 的直接证据)。 - **能力探测**:`capabilities.rs` `LinuxCapabilitySnapshot`(session 类型、`PlatformCapabilities`、权限快照),`supports_tray` 只是探测字段。 diff --git a/docs/linux-egui-handoff/04-ui-domains.md b/docs/linux-egui-handoff/04-ui-domains.md index d17b15730..d071b6195 100644 --- a/docs/linux-egui-handoff/04-ui-domains.md +++ b/docs/linux-egui-handoff/04-ui-domains.md @@ -1,7 +1,5 @@ # 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. 十页现状 @@ -14,7 +12,7 @@ | Selection | 划词润色预览/撤回(`selection.rs`) | Selection Voice 生产触发/捕获/意图路由(L04) | | Agent | Less Computer 工具调用、审批固定在页面外、后台事件保留 | Agent 检测/模型/路径/权限配置(L09) | | Services | AI 服务配置、渠道管理(`settings.rs` + Core provider 面) | Omni 有效模式配置入口完整性(L09) | -| Models | 本地 Qwen 下载/激活/取消 | 完整模型管理:路径/镜像/详情/删除/预载/释放(L08) | +| 本地 ASR | 明确不支持;Linux 不包含 Qwen 推理运行时 | 保持能力声明与 UI 一致,不作为待设备验证功能 | | Remote | 手机输入独立页、连接状态、陈旧地址处理 | — | | History | 只读最近条目 | 完整历史/统计/录音操作(L07,依赖 L03) | | Settings | 现有设置独立页 | 词典/纠错/风格包/市场无页面(L05/L06);多数设置缺实际消费者(L09) | 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 8c1a69dce..5eb0cade0 100644 --- a/docs/linux-egui-handoff/05-native-host-and-data.md +++ b/docs/linux-egui-handoff/05-native-host-and-data.md @@ -1,7 +1,5 @@ # 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`) @@ -22,7 +20,7 @@ Core `ports.rs` 提供 `EditObservationSink` / `EditObservationAdapter`(`ports ## 4. 桌面集成(L10) -未实现:托盘(`capabilities.rs` 仅探测 `supports_tray`)、自启、通知(仅状态栏)、检查/下载/安装更新与重启。AppImage 能力判定(`LinuxPackageKind`)不等于更新器已接好。关闭标准:各能力有真实 Host 效果与失败反馈,不以配置状态伪造就绪。 +未实现:托盘(`capabilities.rs` 仅探测 `supports_tray`)、自启、通知(仅状态栏)。更新不在范围内:Linux 只发 deb/rpm,宿主无法替换自己,`PlatformCapabilities::supports_auto_update` 恒为 false,界面据此隐藏上游的全部更新控件(检查按钮、Beta 渠道卡)。关闭标准:各能力有真实 Host 效果与失败反馈,不以配置状态伪造就绪。 ## 5. 进程与运行时 diff --git a/docs/linux-egui-handoff/06-events-and-sessions.md b/docs/linux-egui-handoff/06-events-and-sessions.md index c900e5679..20bd11252 100644 --- a/docs/linux-egui-handoff/06-events-and-sessions.md +++ b/docs/linux-egui-handoff/06-events-and-sessions.md @@ -1,7 +1,5 @@ # 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 6b81eafc5..205bdc97c 100644 --- a/docs/linux-egui-handoff/07-acceptance.md +++ b/docs/linux-egui-handoff/07-acceptance.md @@ -1,7 +1,5 @@ # 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. 两个不同的完成门 @@ -17,7 +15,7 @@ | Core | `cargo test -p openless-core --locked` | | Linux Host/合同 | `cargo test -p openless-linux-egui --locked` | | Linux 目标编译 | `cargo check -p openless-linux-egui --all-targets --target x86_64-unknown-linux-gnu --locked`(需配置好带 `core/std` 的目标工具链) | -| 打包 | `release-linux-egui.yml`(deb/rpm/AppImage,独立 manifest) | +| 打包 | `release-linux-egui.yml`(deb/rpm + `SHA256SUMS`;AppImage 已从该渠道下线) | ## 3. 真机矩阵(L11/L12) @@ -25,7 +23,7 @@ - 输入:fcitx5 插件热键路由、PRIMARY 选区、落字目标应用矩阵。 - 音频:CPAL 设备、系统静音/恢复终态。 - 凭据:Secret Service(含锁定/未解锁态)。 -- 打包:AppImage / deb / rpm 安装、升级、回滚、卸载残留;签名与分发渠道证据。 +- 打包:deb / rpm 安装、升级、回滚、卸载残留;校验和与分发渠道证据。 ## 4. 记录格式 diff --git a/docs/linux-egui-handoff/08-linux-egui-2.0.md b/docs/linux-egui-handoff/08-linux-egui-2.0.md deleted file mode 100644 index 932e6d47d..000000000 --- a/docs/linux-egui-handoff/08-linux-egui-2.0.md +++ /dev/null @@ -1,136 +0,0 @@ -# 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 61e9dfd61..b1236c61a 100644 --- a/docs/linux-egui-handoff/README.md +++ b/docs/linux-egui-handoff/README.md @@ -1,8 +1,6 @@ # Linux 接入交接总览 -状态: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 缺口描述是历史基线,不能代表本分支现状。 +状态:canonical(2026-09-07 以源码为准重写,基线 `openless-linux-egui` + Core `639c2fbf` 后的 beta);更新:2026-09-07。 ## 1. 责任与范围 @@ -13,17 +11,16 @@ 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/frontend/view_model.rs` 八个主页面,`ui/settings.rs` 七类设置;`host_*.rs` 负责页面异步操作和窗口生命周期,`main.rs` 负责共享会话及事件归并。 -- 打包:`release-linux-egui.yml`(deb/rpm/AppImage,独立 manifest)。 +- 页面:`ui_state.rs` `Page` 枚举 10 页(Start/Dictation/Qa/Selection/Agent/Services/Models/Remote/History/Settings),主循环 `main.rs`。 +- 打包:`release-linux-egui.yml`(deb/rpm + `SHA256SUMS`;AppImage 已下线)。 ## 3. 阅读顺序 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):原领域合同;当前页面映射见 08。 +4. [04 页面与领域](04-ui-domains.md):10 页现状与界面缺口。 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/docs/linux-egui-tauri2-parity.md b/docs/linux-egui-tauri2-parity.md new file mode 100644 index 000000000..7ae8bd5eb --- /dev/null +++ b/docs/linux-egui-tauri2-parity.md @@ -0,0 +1,92 @@ +# Linux egui / Tauri 2 parity tracker + +This stack is maintained in PR #1060 (review baseline `227374ea`). Linux ships one +`openless-linux-egui` executable and does not link Tauri, Wry, or WebKitGTK. +Windows, macOS, and Android remain on Tauri. + +## Implemented on the native Core 2.0 path + +- eframe/egui 0.36.2 (wgpu 30.0.1, CPAL 0.18.2) shell, system CJK fallback, single instance, minimized + startup, close-to-tray, safe shutdown, XDG autostart, notifications, external + URLs, file dialogs, file logging, and diagnostic-log export +- fcitx5 dictation, QA, selection polish, translation, style switching, main + window, style-pack direct, and applicable Coding Agent hotkeys; settings use + strict collision checks; startup waits for plugin reload before registration +- CPAL recording with canonical WAV archives, Core retention policy, history + playback/export and failed-session retranscription +- Native `mute_during_recording` (PipeWire `wpctl`/PulseAudio `pactl`) with + guaranteed sink restore by RAII across stop/cancel/error/drop/shutdown +- Native recording start/stop audio cues synthesized to the default sink on a + worker thread (never blocking the egui frame), gated by `audio_cue_on_record` + and muted-aware (`audio_mute`/`audio_cue` modules) +- Core-backed history search/delete/clear/copy/re-polish/retranscribe, vocabulary, + pending corrections, shared vocabulary presets, correction rules, and complete + style-pack CRUD/reset/prompt diagnostics/ZIP import-export/direct hotkeys +- provider channel CRUD/order/enable/activation, credential metadata, endpoint, + model listing, validation, and revision-aware settings conflict merging +- Remote Input TLS service, URLs, PIN, locale, connection count, and error events +- Marketplace list/detail/install/download/upload/update/delete, likes, authored + packs, GitHub device flow, polling, cancel, and logout +- dictation, QA text/voice, selection polish preview/confirm/cancel/revert, and + Less Computer text/voice/stream/tool approval/cancel through high-level Core APIs +- #997 native QA/selection/capsule popup design: same-executable re-entry, + versioned serde JSONL, Markdown, drag/Esc, QA microphone and Enter submit, + session/sequence/kind guards, nonblocking pipes, crash restart, and snapshot replay +- ~~stable/beta AppImage checks, scheduling, byte progress, SHA-256/minisign + verification, atomic replace~~ — not ported: AppImage is retired from the Linux + channel and deb/rpm cannot replace themselves, so the host keeps no update path +- Remote Input assets, shared icons, version parsing, packaging, + and release workflow are independent of `src-tauri` + +The backend host remains alive when its independent UI process closes. Internal +UI JSONL protocol v2 requires Hello/Ready, rejects mismatches, and resends bindings +and a full snapshot on reconnect. Explicit tray/launcher/open-window requests +create a missing UI or send FocusMain; Wayland activation remains compositor policy. +Core retains contract 2.0.0. Settings field patches are serialized and rebased onto +Core revisions; history and WAV probes run in one event-driven blocking task, +with cached results shared by History and Overview. + +## Automated verification commands + +- `cargo test -p openless-core --locked` +- `cargo test -p openless-linux-egui --locked --all-targets` +- `cargo clippy --locked -p openless-core --all-targets -- -D warnings` +- `cargo check --locked -p openless-linux-egui --all-targets` +- `cargo clippy --locked -p openless-linux-egui --all-targets -- -D warnings` +- PR #1019 Core/public-surface/dependency contract scripts +- fcitx5 C++ build plus `input_target_contract` +- `cargo tree` and release ELF `ldd` checks for Tauri/Wry/WebKitGTK +- Linux Tauri-free source, packaging, workflow, production-mock, capability, popup, + settings-conflict, IPC handshake/reconnect, lifecycle, and staged-file gates + +## Deliberate limits and device evidence still required + +- fcitx5 is a startup requirement: missing addon, unavailable session D-Bus or + failed required shortcut registration prevents Backend startup. An error window + explains recovery; closing it exits nonzero while releasing the process lock. + There is no IBus or global-hotkey fallback. +- Selection Voice remains hidden because the Linux production target/intent adapter + is not implemented. It must not be advertised through capabilities. Selection + polish and QA remain available. +- Local ASR is unsupported on Linux, including Generic Qwen, Foundry and MLX. + No inference runtime is shipped; this is not an awaiting-device-verification item. +- Foreground-application identity and native post-insertion edit observation + are explicit `Unsupported` on Linux: fcitx5 exposes surrounding text but no + reliable app/control identity across X11/Wayland, PRIMARY cannot prove an + original selection, and there is deliberately no IBus/global-hotkey fallback. + The factory keeps Core's Noop HostContext/EditObservation adapters (reporting + `source_app = None`) instead of faking edits. These are L02/L03 follow-ups, + not simulated UI data or false capabilities. +- Mute/restore and start/stop cues are implemented (`audio_mute`/`audio_cue`), + but the cpal cue-playback and real sink mute/restore paths still require + X11/Wayland device runs before being recorded as verified; a Linux output + stream is a PipeWire/KDE sink-input, so device evidence must confirm the cue + does not disturb the session or trigger unexpected volume OSD. +- X11 and Wayland device runs are still required for focus, Unicode insertion, + popup positioning, tray, fcitx5 reload/rebind, microphone unplug/recovery, + Secret Service, real phone Remote Input, and startup failure/recovery. Ignored hardware tests or a green build are not + recorded as device proof. + +The automated scope is complete only when every command above passes at the +current PR #1060 head. The device-only rows remain “implemented, awaiting device +evidence” or “explicit unsupported” and must not be described as verified. diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index d0091d982..f92c98e4f 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -3,60 +3,67 @@ version = 4 [[package]] -name = "ab_glyph" -version = "0.2.32" +name = "accesskit" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", + "uuid", ] [[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" +name = "accesskit_atspi_common" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant 5.15.0", +] [[package]] -name = "accesskit" -version = "0.21.1" +name = "accesskit_consumer" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf203f9d3bd8f29f98833d1fbef628df18f759248a547e7e01cfbf63cda36a99" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] [[package]] -name = "accesskit_atspi_common" -version = "0.14.2" +name = "accesskit_consumer" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "890d241cf51fc784f0ac5ac34dfc847421f8d39da6c7c91a0fcc987db62a8267" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" dependencies = [ "accesskit", - "accesskit_consumer", - "atspi-common", - "serde", - "thiserror 1.0.69", - "zvariant 5.15.0", + "hashbrown 0.16.1", ] [[package]] name = "accesskit_consumer" -version = "0.31.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db81010a6895d8707f9072e6ce98070579b43b717193d2614014abd5cb17dd43" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" dependencies = [ "accesskit", - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] name = "accesskit_macos" -version = "0.22.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0089e5c0ac0ca281e13ea374773898d9354cc28d15af9f0f7394d44a495b575" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" dependencies = [ "accesskit", - "accesskit_consumer", - "hashbrown 0.15.5", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -64,9 +71,9 @@ dependencies = [ [[package]] name = "accesskit_unix" -version = "0.17.2" +version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301e55b39cfc15d9c48943ce5f572204a551646700d0e8efa424585f94fec528" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -82,23 +89,23 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.29.2" +version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2d63dd5041e49c363d83f5419a896ecb074d309c414036f616dc0b04faca971" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" dependencies = [ "accesskit", - "accesskit_consumer", - "hashbrown 0.15.5", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", "static_assertions", - "windows 0.61.3", - "windows-core 0.61.2", + "windows", + "windows-core", ] [[package]] name = "accesskit_winit" -version = "0.29.2" +version = "0.32.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8cfabe59d0eaca7412bfb1f70198dd31e3b0496fee7e15b066f9c36a1a140a0" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" dependencies = [ "accesskit", "accesskit_macos", @@ -157,6 +164,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "alsa" version = "0.11.0" @@ -213,6 +226,22 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710e8eae58854cdc1790fcb56cca04d712a17be849eeb81da2a724bf4bae2bc4" +dependencies = [ + "anstyle", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -261,6 +290,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.8" @@ -273,6 +308,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + [[package]] name = "ashpd" version = "0.11.1" @@ -300,7 +344,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -469,20 +513,19 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "atspi" -version = "0.25.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c83247582e7508838caf5f316c00791eee0e15c0bf743e6880585b867e16815c" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" dependencies = [ "atspi-common", - "atspi-connection", "atspi-proxies", ] [[package]] name = "atspi-common" -version = "0.9.0" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33dfc05e7cdf90988a197803bf24f5788f94f7c94a69efa95683e8ffe76cfdfb" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" dependencies = [ "enumflags2", "serde", @@ -494,23 +537,11 @@ dependencies = [ "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" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2eebcb9e7e76f26d0bcfd6f0295e1cd1e6f33bedbc5698a971db8dc43d7751c" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" dependencies = [ "atspi-common", "serde", @@ -588,20 +619,39 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "annotate-snippets", + "bitflags 2.13.2", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "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" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bitflags" @@ -838,7 +888,26 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom 7.1.3", +] + +[[package]] +name = "cfg-expr" +version = "0.20.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +dependencies = [ + "smallvec", + "target-lexicon", ] [[package]] @@ -905,7 +974,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "num-traits", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -919,6 +988,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clipboard-win" version = "5.4.1" @@ -930,13 +1010,22 @@ dependencies = [ [[package]] name = "codespan-reporting" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "unicode-width", ] +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + [[package]] name = "combine" version = "4.6.8" @@ -962,6 +1051,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "cookie-factory" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2" + [[package]] name = "core-foundation" version = "0.9.4" @@ -1002,6 +1097,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "coreaudio-rs" version = "0.14.2" @@ -1042,11 +1146,12 @@ dependencies = [ "objc2-core-audio-types", "objc2-core-foundation", "objc2-foundation 0.3.2", + "pipewire", "portable-atomic", "pulseaudio", "web-sys", - "windows 0.62.2", - "windows-core 0.62.2", + "windows", + "windows-core", ] [[package]] @@ -1167,6 +1272,12 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "dbus" version = "0.9.12" @@ -1210,7 +1321,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -1336,9 +1447,9 @@ checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" [[package]] name = "ecolor" -version = "0.33.3" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71ddb8ac7643d1dba1bb02110e804406dd459a838efcb14011ced10556711a8e" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" dependencies = [ "bytemuck", "emath", @@ -1346,9 +1457,9 @@ dependencies = [ [[package]] name = "eframe" -version = "0.33.3" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457481173e6db5ca9fa2be93a58df8f4c7be639587aeb4853b526c6cf87db4e6" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" dependencies = [ "ahash", "bytemuck", @@ -1357,51 +1468,53 @@ dependencies = [ "egui-wgpu", "egui-winit", "egui_glow", - "glow", "glutin", "glutin-winit", "image", "js-sys", "log", - "objc2 0.5.2", - "objc2-app-kit 0.2.2", - "objc2-foundation 0.2.2", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", + "pollster 1.0.1", "profiling", "raw-window-handle", "static_assertions", "wasm-bindgen", - "wasm-bindgen-futures", "web-sys", "web-time", + "wgpu", "windows-sys 0.61.2", "winit", ] [[package]] name = "egui" -version = "0.33.3" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9b567d356674e9a5121ed3fedfb0a7c31e059fe71f6972b691bcd0bfc284e3" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" dependencies = [ "accesskit", "ahash", "bitflags 2.13.2", "emath", "epaint", + "itertools 0.15.0", "log", "nohash-hasher", "profiling", "smallvec", "unicode-segmentation", + "web-sys", ] [[package]] name = "egui-wgpu" -version = "0.33.3" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e4d209971c84b2352a06174abdba701af1e552ce56b144d96f2bd50a3c91236" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" dependencies = [ "ahash", "bytemuck", @@ -1419,40 +1532,36 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.33.3" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec6687e5bb551702f4ad10ac428bab12acf9d53047ebb1082d4a0ed8c6251a29" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" dependencies = [ "accesskit_winit", "arboard", "bytemuck", "egui", "log", - "objc2 0.5.2", - "objc2-foundation 0.2.2", - "objc2-ui-kit", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", "smithay-clipboard", "web-time", - "webbrowser", "winit", ] [[package]] name = "egui_glow" -version = "0.33.3" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6420863ea1d90e750f75075231a260030ad8a9f30a7cef82cdc966492dc4c4eb" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" dependencies = [ "bytemuck", "egui", "glow", "log", - "memoffset", "profiling", - "wasm-bindgen", - "web-sys", "winit", ] @@ -1464,9 +1573,9 @@ checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "emath" -version = "0.33.3" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "491bdf728bf25ddd9ad60d4cf1c48588fa82c013a2440b91aa7fc43e34a07c32" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" dependencies = [ "bytemuck", ] @@ -1511,27 +1620,34 @@ dependencies = [ [[package]] name = "epaint" -version = "0.33.3" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009d0dd3c2163823a0abdb899451ecbc78798dec545ee91b43aff1fa790bab62" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" dependencies = [ - "ab_glyph", "ahash", "bytemuck", "ecolor", "emath", "epaint_default_fonts", + "font-types", + "harfrust", "log", "nohash-hasher", "parking_lot", "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.33.3" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c4fbe202b6578d3d56428fa185cdf114a05e49da05f477b3c7f0fbb221f1862" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" [[package]] name = "equivalent" @@ -1555,6 +1671,15 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1596,6 +1721,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + [[package]] name = "ferrous-opencc" version = "0.4.0" @@ -1652,6 +1783,12 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + [[package]] name = "fnv" version = "1.0.7" @@ -1660,15 +1797,40 @@ checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" -version = "0.1.5" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "foldhash" -version = "0.2.0" +name = "font-types" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "slotmap", + "tinyvec", + "ttf-parser", +] [[package]] name = "foreign-types" @@ -1840,7 +2002,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.4", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1906,11 +2068,33 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[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" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ "js-sys", "slotmap", @@ -1984,6 +2168,28 @@ dependencies = [ "gl_generator", ] +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.20", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + [[package]] name = "h2" version = "0.4.19" @@ -2016,12 +2222,15 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.15.5" +name = "harfrust" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" dependencies = [ - "foldhash 0.1.5", + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", ] [[package]] @@ -2030,7 +2239,9 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash 0.2.0", + "allocator-api2", + "equivalent", + "foldhash", ] [[package]] @@ -2038,6 +2249,15 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" @@ -2051,12 +2271,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hexf-parse" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" - [[package]] name = "hkdf" version = "0.12.4" @@ -2195,7 +2409,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -2327,10 +2541,16 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png", + "png 0.18.1", "tiff", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "indexmap" version = "2.14.2" @@ -2357,6 +2577,24 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2377,7 +2615,7 @@ dependencies = [ "simd_cesu8", "thiserror 2.0.20", "walkdir", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2462,14 +2700,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] -name = "lazy_static" -version = "1.5.0" +name = "kurbo" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] [[package]] -name = "libc" -version = "0.2.189" +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" @@ -2489,7 +2750,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2510,6 +2771,39 @@ dependencies = [ "redox_syscall 0.9.4", ] +[[package]] +name = "libspa" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882f7427e7989dcc9d388b7f05c4630390a1d7696f9ffa469cd4a7a48f0b4c40" +dependencies = [ + "bitflags 2.13.2", + "cc", + "cookie-factory", + "libc", + "libspa-sys", + "nom 8.0.0", + "rustix 1.1.4", + "system-deps", +] + +[[package]] +name = "libspa-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b6e17bdaf63ed0d5e4144022624032b41fd9733112e8c74ac26fc9bf1291924" +dependencies = [ + "bindgen", + "cc", + "system-deps", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-keyutils" version = "0.2.5" @@ -2665,12 +2959,6 @@ 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" @@ -2735,9 +3023,9 @@ dependencies = [ [[package]] name = "naga" -version = "27.0.3" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "066cf25f0e8b11ee0df221219010f213ad429855f57c494f995590c861a9a7d8" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", "bit-set", @@ -2746,18 +3034,31 @@ dependencies = [ "cfg_aliases", "codespan-reporting", "half", - "hashbrown 0.16.1", - "hexf-parse", + "hashbrown 0.17.1", "indexmap", "libm", "log", + "naga-types", "num-traits", "once_cell", "rustc-hash 1.1.0", + "spirv", "thiserror 2.0.20", "unicode-ident", ] +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.20", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2846,6 +3147,15 @@ 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" @@ -3005,7 +3315,7 @@ dependencies = [ "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -3142,7 +3452,7 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", ] [[package]] @@ -3224,6 +3534,17 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-quartz-core" version = "0.2.2" @@ -3234,7 +3555,20 @@ dependencies = [ "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", - "objc2-metal", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", ] [[package]] @@ -3262,12 +3596,24 @@ dependencies = [ "objc2-core-location", "objc2-foundation 0.2.2", "objc2-link-presentation", - "objc2-quartz-core", + "objc2-quartz-core 0.2.2", "objc2-symbols", "objc2-uniform-type-identifiers", "objc2-user-notifications", ] +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-uniform-type-identifiers" version = "0.2.2" @@ -3350,7 +3696,7 @@ dependencies = [ [[package]] name = "openless-linux-egui" -version = "2.0.0-Beta.2+build.20260924" +version = "0.1.0" dependencies = [ "axum", "base64", @@ -3359,19 +3705,24 @@ dependencies = [ "dbus", "eframe", "egui", + "egui_glow", "fs2", "futures-util", + "glow", + "glutin", "hyper-util", "image", "keyring", "libc", "local-ip-address", "log", - "minisign-verify", "openless-core", + "raw-window-handle", "rcgen", "reqwest", + "resvg", "rfd", + "roxmltree", "rustls", "semver", "serde", @@ -3383,6 +3734,9 @@ dependencies = [ "tokio", "tokio-rustls", "uuid", + "wayland-client", + "wayland-protocols-wlr", + "wgpu", "x11rb", "x509-parser", ] @@ -3398,22 +3752,22 @@ dependencies = [ ] [[package]] -name = "ordered-stream" -version = "0.2.0" +name = "ordered-float" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" dependencies = [ - "futures-core", - "pin-project-lite", + "num-traits", ] [[package]] -name = "owned_ttf_parser" -version = "0.25.1" +name = "ordered-stream" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" dependencies = [ - "ttf-parser", + "futures-core", + "pin-project-lite", ] [[package]] @@ -3442,7 +3796,7 @@ dependencies = [ "libc", "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -3476,6 +3830,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo 0.13.1", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3535,6 +3902,12 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project" version = "1.1.13" @@ -3572,6 +3945,31 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pipewire" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde71084c4e25959d68f1ea54daa75e5ecdb338e5caf0b5510143b79baa32d5c" +dependencies = [ + "bitflags 2.13.2", + "libc", + "libspa", + "libspa-sys", + "pipewire-sys", + "rustix 1.1.4", +] + +[[package]] +name = "pipewire-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce653f53e63e5b93853218092ee9a8906a5d082c92f3f1db26316955dd63ce0" +dependencies = [ + "bindgen", + "libspa-sys", + "system-deps", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3584,6 +3982,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "png" version = "0.18.1" @@ -3617,6 +4028,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + [[package]] name = "poly1305" version = "0.8.0" @@ -3628,6 +4045,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -3667,6 +4093,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -3925,6 +4357,18 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -3939,6 +4383,17 @@ dependencies = [ "yasna", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + [[package]] name = "redox_syscall" version = "0.4.1" @@ -4052,6 +4507,20 @@ dependencies = [ "webpki-roots 1.0.9", ] +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", +] + [[package]] name = "rfd" version = "0.16.0" @@ -4067,7 +4536,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", - "pollster", + "pollster 0.4.0", "raw-window-handle", "urlencoding", "wasm-bindgen", @@ -4076,6 +4545,15 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -4120,6 +4598,12 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4147,7 +4631,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -4218,6 +4702,24 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.23" @@ -4264,6 +4766,12 @@ dependencies = [ "zbus 4.4.0", ] +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + [[package]] name = "semver" version = "1.0.28" @@ -4324,6 +4832,15 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4358,6 +4875,12 @@ 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" @@ -4396,6 +4919,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + [[package]] name = "simplelog" version = "0.12.2" @@ -4413,6 +4945,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.12" @@ -4516,6 +5058,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -4528,6 +5079,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + [[package]] name = "strsim" version = "0.11.1" @@ -4540,6 +5100,16 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "syn" version = "2.0.119" @@ -4603,6 +5173,19 @@ dependencies = [ "libc", ] +[[package]] +name = "system-deps" +version = "7.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7" +dependencies = [ + "cfg-expr", + "heck", + "pkg-config", + "toml", + "version-compare", +] + [[package]] name = "tar" version = "0.4.46" @@ -4614,6 +5197,12 @@ dependencies = [ "xattr", ] +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + [[package]] name = "tempfile" version = "3.27.0" @@ -4722,6 +5311,32 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -4815,6 +5430,21 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "1.1.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4845,6 +5475,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -4933,6 +5569,9 @@ name = "ttf-parser" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] [[package]] name = "tungstenite" @@ -4986,6 +5625,30 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -5001,12 +5664,30 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + [[package]] name = "unicode-width" version = "0.2.2" @@ -5048,6 +5729,33 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "utf-8" version = "0.7.6" @@ -5072,6 +5780,39 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.20", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + [[package]] name = "version_check" version = "0.9.5" @@ -5335,22 +6076,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webbrowser" -version = "1.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" -dependencies = [ - "jni", - "log", - "ndk-context", - "objc2 0.6.4", - "objc2-app-kit 0.3.2", - "objc2-foundation 0.3.2", - "url", - "web-sys", -] - [[package]] name = "webpki-roots" version = "0.26.11" @@ -5377,22 +6102,27 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "27.0.1" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe68bac7cde125de7a731c3400723cadaaf1703795ad3f4805f187459cd7a77" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" dependencies = [ "arrayvec", "bitflags 2.13.2", + "bytemuck", "cfg-if", "cfg_aliases", "document-features", - "hashbrown 0.16.1", + "hashbrown 0.17.1", + "js-sys", "log", "portable-atomic", "profiling", "raw-window-handle", "smallvec", "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", "wgpu-core", "wgpu-hal", "wgpu-types", @@ -5400,9 +6130,9 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "27.0.3" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a75de515543b1897b26119f93731b385a19aea165a1ec5f0e3acecc229cae7" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", "bit-set", @@ -5411,10 +6141,11 @@ dependencies = [ "bytemuck", "cfg_aliases", "document-features", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "indexmap", "log", "naga", + "naga-types", "once_cell", "parking_lot", "portable-atomic", @@ -5425,49 +6156,78 @@ dependencies = [ "thiserror 2.0.20", "wgpu-core-deps-windows-linux-android", "wgpu-hal", + "wgpu-naga-bridge", "wgpu-types", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "27.0.0" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71197027d61a71748e4120f05a9242b2ad142e3c01f8c1b47707945a879a03c3" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "27.0.4" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b21cb61c57ee198bc4aff71aeadff4cbb80b927beb912506af9c780d64313ce" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" dependencies = [ + "android_system_properties", + "arrayvec", + "ash", "bitflags 2.13.2", + "bytemuck", "cfg-if", "cfg_aliases", + "gpu-allocator", + "hashbrown 0.17.1", + "libc", "libloading", "log", "naga", + "naga-types", + "ordered-float", + "parking_lot", "portable-atomic", "portable-atomic-util", + "profiling", "raw-window-handle", + "raw-window-metal", "renderdoc-sys", + "smallvec", + "static_assertions", "thiserror 2.0.20", + "wgpu-naga-bridge", + "wgpu-types", + "windows", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", "wgpu-types", ] [[package]] name = "wgpu-types" -version = "27.0.1" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afdcf84c395990db737f2dd91628706cb31e86d72e53482320d368e52b5da5eb" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" dependencies = [ "bitflags 2.13.2", "bytemuck", "js-sys", "log", - "thiserror 2.0.20", + "naga-types", + "raw-window-handle", + "static_assertions", "web-sys", ] @@ -5502,38 +6262,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "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.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ - "windows-collections 0.3.2", - "windows-core 0.62.2", - "windows-future 0.3.2", - "windows-numerics 0.3.1", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", ] [[package]] @@ -5542,20 +6280,7 @@ 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.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -5566,20 +6291,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading 0.1.0", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -5588,9 +6302,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", - "windows-threading 0.2.1", + "windows-core", + "windows-link", + "windows-threading", ] [[package]] @@ -5615,36 +6329,20 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-numerics" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.62.2", - "windows-link 0.2.1", + "windows-core", + "windows-link", ] [[package]] @@ -5653,18 +6351,9 @@ 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.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -5673,16 +6362,7 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -5691,7 +6371,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5727,7 +6407,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5752,7 +6432,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -5763,22 +6443,13 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -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 = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -5903,7 +6574,7 @@ dependencies = [ "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", - "objc2-ui-kit", + "objc2-ui-kit 0.2.2", "orbclient", "percent-encoding", "pin-project", @@ -5991,7 +6662,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "ring", "rusticata-macros", @@ -6050,6 +6721,12 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + [[package]] name = "xz2" version = "0.1.7" diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 72e274dd3..408e7e704 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -2502,6 +2502,7 @@ impl OpenLessBackend { Arc::clone(&marketplace), github_client_id, BackendEventPublisher::new(Arc::clone(&events)), + Arc::clone(&deps.task_spawner), )?; encrypted_sync = Some(service); encrypted_sync_store = Some(store); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs index 16d826ab2..1db57215a 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs @@ -16,6 +16,9 @@ use crate::cloud_sync_e2ee_store::{ use super::{document_error, local::LocalStorage, service::SyncServiceData, SyncResult}; +// Composition boundary: keep the existing injected services explicit rather than +// adding a second dependency bundle solely for this constructor. +#[allow(clippy::too_many_arguments)] pub(crate) fn build( config: super::EncryptedSyncConfig, data_dir: &Path, @@ -24,6 +27,7 @@ pub(crate) fn build( marketplace: Arc, github_client_id: String, events: crate::events::BackendEventPublisher, + tasks: Arc, ) -> SyncResult<(super::EncryptedSyncService, Arc)> { let origin = url::Url::parse(&config.service_origin) .map_err(|_| super::error("unsupported_protocol"))?; @@ -63,6 +67,7 @@ pub(crate) fn build( device, gate, Arc::new(local.clone()), + tasks, ) .map_err(document_error)?, ); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs index 97829cd08..d153cff4f 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs @@ -36,6 +36,7 @@ struct Inner { device: SourceDevice, gate: Arc, extensions: Arc, + tasks: Arc, restoring: AtomicBool, metadata: tokio::sync::Mutex<()>, runtime_idle: std::sync::Mutex bool + Send + Sync>>>, @@ -50,6 +51,7 @@ impl CoreSyncStore { device: SourceDevice, gate: Arc, extensions: Arc, + tasks: Arc, ) -> DocumentResult { let registered = gate::open_for_data_dir(&data_dir)?; if !Arc::ptr_eq(®istered, &gate) { @@ -70,6 +72,7 @@ impl CoreSyncStore { device, gate, extensions, + tasks, restoring: AtomicBool::new(false), metadata: tokio::sync::Mutex::new(()), runtime_idle: std::sync::Mutex::new(None), @@ -207,7 +210,7 @@ impl CoreSyncStore { let plan = crate::cloud_sync_e2ee_documents::prepare_sync_restore(desired, context)?; let store = self.clone(); // Detach only the owned, journalled operation: dropping an IPC waiter cannot cancel it. - tokio::spawn(async move { + self.spawn_owned(async move { let _metadata = store.inner.metadata.lock().await; let _runtime = store.begin_runtime_restore()?; store.runtime_effects()?; @@ -224,7 +227,6 @@ impl CoreSyncStore { store.capture_locked(&scope, &permit).await }) .await - .map_err(|_| DocumentError::RecoveryRequired)? } pub async fn record_baseline( @@ -235,12 +237,11 @@ impl CoreSyncStore { ) -> DocumentResult<()> { self.validate_local_scope(&scope)?; let store = self.clone(); - tokio::spawn(async move { + self.spawn_owned(async move { let _metadata = store.inner.metadata.lock().await; store.baseline_locked(&scope, documents, revision).await }) .await - .map_err(|_| DocumentError::RecoveryRequired)? } pub async fn recover_registered(&self) -> DocumentResult<()> { @@ -249,7 +250,7 @@ impl CoreSyncStore { return Ok(()); } let store = self.clone(); - tokio::spawn(async move { + self.spawn_owned(async move { use crate::cloud_sync_e2ee_documents::JournalStore; let _metadata = store.inner.metadata.lock().await; let _runtime = store.begin_runtime_restore()?; @@ -283,7 +284,24 @@ impl CoreSyncStore { Ok(()) }) .await - .map_err(|_| DocumentError::RecoveryRequired)? + } + + /// Runs an owned operation on the host task spawner and awaits its outcome. + /// Dropping the awaiting caller cannot cancel the operation: the spawned + /// task owns the sender side of the channel. + async fn spawn_owned(&self, operation: F) -> DocumentResult + where + T: Send + 'static, + F: std::future::Future> + Send + 'static, + { + let (sender, receiver) = tokio::sync::oneshot::channel(); + self.inner.tasks.spawn(Box::pin(async move { + let _ = sender.send(operation.await); + })); + match receiver.await { + Ok(outcome) => outcome, + Err(_) => Err(DocumentError::RecoveryRequired), + } } fn validate_local_scope(&self, scope: &SyncScope) -> DocumentResult<()> { @@ -420,7 +438,7 @@ impl CoreSyncStore { return Err(DocumentError::InvalidDocument); } let store = self.clone(); - tokio::spawn(async move { + self.spawn_owned(async move { let _metadata = store.inner.metadata.lock().await; let permit = store.inner.gate.begin_mutation()?; if let Some(expected) = expected_revision { @@ -459,7 +477,6 @@ impl CoreSyncStore { permit.commit(ChangeOrigin::User).map(|_| ()) }) .await - .map_err(|_| DocumentError::RecoveryRequired)? } } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs index 73a2098d4..96080501e 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs @@ -317,6 +317,7 @@ impl Fixture { device, gate, extensions.clone(), + Arc::new(crate::TokioTaskSpawner), ) .unwrap(); store.bind_runtime_idle_probe(Arc::new(|| true)).unwrap(); @@ -359,6 +360,7 @@ impl Fixture { device, gate, extensions.clone(), + Arc::new(crate::TokioTaskSpawner), ) .unwrap(); store.bind_runtime_idle_probe(Arc::new(|| true)).unwrap(); diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index 089c1e168..9519b3703 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -797,7 +797,14 @@ pub fn is_tencent_cloud_provider(id: &str) -> bool { pub fn is_whisper_compatible_provider(id: &str) -> bool { matches!( id, - "whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter" | "stepfun" | "zenmux" | "minimax" + "whisper" + | "siliconflow" + | "zhipu" + | "groq" + | "openrouter" + | "stepfun" + | "zenmux" + | "minimax" ) || id == OPENAI_COMPATIBLE_ASR_PROVIDER_ID } 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 87c246c6a..6c3de357e 100644 --- a/openless-all/app/crates/openless-core/src/provider_service.rs +++ b/openless-all/app/crates/openless-core/src/provider_service.rs @@ -1874,20 +1874,52 @@ mod tests { {"id":"legacy/chat"} ]}"#; for (kind, channel_kind, key_account, expected) in [ - (ProviderKind::Llm, ChannelKind::Llm, LLM_API_KEY_ACCOUNT, - vec!["google/gemini-2.5-flash", "google/gemini-tts", "google/gemini-unknown", "orcarouter/fusion-flash"]), - (ProviderKind::Asr, ChannelKind::Asr, ASR_API_KEY_ACCOUNT, - vec!["google/gemini-2.5-flash"]), + ( + ProviderKind::Llm, + ChannelKind::Llm, + LLM_API_KEY_ACCOUNT, + vec![ + "google/gemini-2.5-flash", + "google/gemini-tts", + "google/gemini-unknown", + "orcarouter/fusion-flash", + ], + ), + ( + ProviderKind::Asr, + ChannelKind::Asr, + ASR_API_KEY_ACCOUNT, + vec!["google/gemini-2.5-flash"], + ), ] { let credentials = Arc::new(InMemoryCredentialStore::default()); - let channel = create_channel_with_values(&credentials, channel_kind, "orcarouter", &[]).await; + let channel = + create_channel_with_values(&credentials, channel_kind, "orcarouter", &[]).await; let transport = Arc::new(FakeProviderTransport::default()); transport.push_response(200, catalog.as_bytes().to_vec()); - let service = ProviderService::new_with_transport(credentials.clone(), Arc::new(crate::TokioTaskSpawner), transport.clone()); - let request = ProviderRequest { kind, channel_id: Some(channel.clone()), thinking_enabled: false }; + let service = ProviderService::new_with_transport( + credentials.clone(), + Arc::new(crate::TokioTaskSpawner), + transport.clone(), + ); + let request = ProviderRequest { + kind, + channel_id: Some(channel.clone()), + thinking_enabled: false, + }; assert!(service.list_models(request.clone()).await.is_err()); - let namespace = if kind == ProviderKind::Llm { CredentialNamespace::Llm } else { CredentialNamespace::Asr }; - credentials.write(CredentialKey::new(namespace, Some(channel), key_account).unwrap(), SecretValue::new("fixture-key")).await.unwrap(); + let namespace = if kind == ProviderKind::Llm { + CredentialNamespace::Llm + } else { + CredentialNamespace::Asr + }; + credentials + .write( + CredentialKey::new(namespace, Some(channel), key_account).unwrap(), + SecretValue::new("fixture-key"), + ) + .await + .unwrap(); let result = service.list_models(request).await.unwrap(); assert_eq!(result.models, expected); let requests = transport.requests(); @@ -1991,22 +2023,44 @@ mod tests { #[tokio::test] async fn orcarouter_validation_uses_shared_audio_chat_transcription() { - let (endpoint, request) = spawn_http_response("200 OK", "application/json", - r#"{"choices":[{"message":{"content":"transcript"}}]}"#); + let (endpoint, request) = spawn_http_response( + "200 OK", + "application/json", + r#"{"choices":[{"message":{"content":"transcript"}}]}"#, + ); let credentials = Arc::new(InMemoryCredentialStore::default()); - let channel = create_channel_with_values(&credentials, ChannelKind::Asr, "orcarouter", &[ - (ASR_ENDPOINT_ACCOUNT, &endpoint), (ASR_API_KEY_ACCOUNT, "fixture-key"), - ]).await; + let channel = create_channel_with_values( + &credentials, + ChannelKind::Asr, + "orcarouter", + &[ + (ASR_ENDPOINT_ACCOUNT, &endpoint), + (ASR_API_KEY_ACCOUNT, "fixture-key"), + ], + ) + .await; let service = ProviderService::new(credentials, Arc::new(crate::TokioTaskSpawner)); - service.validate(ProviderRequest { kind: ProviderKind::Asr, channel_id: Some(channel), thinking_enabled: false }).await.unwrap(); - let request = String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap(); + service + .validate(ProviderRequest { + kind: ProviderKind::Asr, + channel_id: Some(channel), + thinking_enabled: false, + }) + .await + .unwrap(); + let request = + String::from_utf8(request.recv_timeout(Duration::from_secs(2)).unwrap()).unwrap(); assert!(request.starts_with("POST /v1/chat/completions ")); - let body: serde_json::Value = serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); + let body: serde_json::Value = + serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap(); assert_eq!(body["model"], crate::asr::mimo::ORCAROUTER_DEFAULT_MODEL); let content = &body["messages"][0]["content"]; assert_eq!(content[0]["type"], "text"); assert_eq!(content[1]["input_audio"]["format"], "wav"); - assert!(!content[1]["input_audio"]["data"].as_str().unwrap().starts_with("data:")); + assert!(!content[1]["input_audio"]["data"] + .as_str() + .unwrap() + .starts_with("data:")); } async fn service_with_fake_transport() -> (ProviderService, Arc, String) diff --git a/openless-all/app/crates/openless-core/src/streaming_insert.rs b/openless-all/app/crates/openless-core/src/streaming_insert.rs index 11fc17bbd..994f7a678 100644 --- a/openless-all/app/crates/openless-core/src/streaming_insert.rs +++ b/openless-all/app/crates/openless-core/src/streaming_insert.rs @@ -168,14 +168,10 @@ pub fn strip_echoed_scaffolding(text: &str) -> String { const MARKER: &str = "__OPENLESS_SCAFFOLD_BODY__"; let raw = crate::prompts::user_prompt(MARKER); let selection = crate::prompts::selection_user_prompt(MARKER); - let raw_traditional = apply_chinese_script_preference( - &raw, - ChineseScriptPreference::Traditional, - ); - let selection_traditional = apply_chinese_script_preference( - &selection, - ChineseScriptPreference::Traditional, - ); + let raw_traditional = + apply_chinese_script_preference(&raw, ChineseScriptPreference::Traditional); + let selection_traditional = + apply_chinese_script_preference(&selection, ChineseScriptPreference::Traditional); let stripped = [raw.as_str(), raw_traditional.as_str()] .into_iter() @@ -186,12 +182,7 @@ pub fn strip_echoed_scaffolding(text: &str) -> String { [selection.as_str(), selection_traditional.as_str()] .into_iter() .find_map(|template| { - strip_complete_template( - text, - template, - "", - "", - ) + strip_complete_template(text, template, "", "") }) }) .unwrap_or_else(|| text.to_string()); @@ -220,7 +211,10 @@ pub fn streaming_insert_eligible( mod tests { #[test] fn strip_echoed_scaffolding_none_passes_through() { - assert_eq!(strip_echoed_scaffolding("純正文,沒有標籤。"), "純正文,沒有標籤。"); + assert_eq!( + strip_echoed_scaffolding("純正文,沒有標籤。"), + "純正文,沒有標籤。" + ); } #[test] diff --git a/openless-all/app/crates/openless-core/tests/selection_contract.rs b/openless-all/app/crates/openless-core/tests/selection_contract.rs index c0834e5b5..c50c3f53a 100644 --- a/openless-all/app/crates/openless-core/tests/selection_contract.rs +++ b/openless-all/app/crates/openless-core/tests/selection_contract.rs @@ -719,12 +719,11 @@ async fn transient_platform_failure_keeps_the_preview_retryable() { async fn stale_preview_apply_settles_the_session_and_hides_the_preview() { // apply 报「目标已失效」类错误(Cancelled 语义)时 session 必须结算, // 预览隐藏、不允许无限重试一个已经不存在的目标。 - let runtime = RecordingSelectionRuntime::new("source text").with_apply_error( - BackendError::new( + let runtime = + RecordingSelectionRuntime::new("source text").with_apply_error(BackendError::new( BackendErrorCode::Cancelled, "selection target is no longer active", - ), - ); + )); let host = openless_core::testing::RecordingHostActions::default(); let (backend, data_dir) = backend_with_selection_parts_and_host( runtime.clone(), diff --git a/openless-all/app/linux-egui/Cargo.toml b/openless-all/app/linux-egui/Cargo.toml index 2dff30b63..6880ad512 100644 --- a/openless-all/app/linux-egui/Cargo.toml +++ b/openless-all/app/linux-egui/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "openless-linux-egui" -version = "2.0.0-Beta.2+build.20260924" +version = "0.1.0" license = "AGPL-3.0-only" description = "Linux host seam for the OpenLess egui frontend" edition = "2021" -rust-version = "1.88" +rust-version = "1.95" publish = false [dependencies] @@ -15,7 +15,6 @@ 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"] } @@ -23,18 +22,34 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } +[features] +default = ["x11-overlay"] +# Native X11 overlay placement for X11 sessions. Wayland sessions use native +# layer-shell when available and never route the capsule through XWayland. +x11-overlay = ["dep:x11rb"] + [target.'cfg(target_os = "linux")'.dependencies] dbus = "0.9" -x11rb = { version = "0.13.2", features = ["randr", "xinput"] } keyring = { version = "3.6.3", default-features = false, features = ["linux-native-sync-persistent", "crypto-rust"] } -# 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"] } +cpal = { version = "0.18.2", default-features = false, features = ["pipewire", "pulseaudio"] } rfd = { version = "0.16", default-features = false, features = ["xdg-portal", "tokio"] } -egui = "=0.33.3" -eframe = { version = "=0.33.3", default-features = false, features = ["accesskit", "default_fonts", "glow", "wayland", "x11"] } +egui = "=0.36.2" +# wlr-layer-shell 胶囊窗口:KWin 6.7+/sway/Hyprland 等合成器直接给 +# 「贴底居中 + 不抢键盘焦点」的 overlay surface;不支持时回退到 X11 叠加层。 +wayland-client = "0.31" +wayland-protocols-wlr = { version = "0.3", features = ["client"] } +glutin = { version = "0.32", default-features = false, features = ["egl", "wayland"] } +egui_glow = "0.36.2" +glow = "0.17" +raw-window-handle = "0.6" +eframe = { version = "=0.36.2", default-features = false, features = ["accesskit", "default_fonts", "wgpu_no_default_features", "wayland", "x11"] } +wgpu = { version = "30.0.1", default-features = false, features = ["std", "vulkan", "wgsl"] } image = { version = "0.25.10", default-features = false, features = ["png"] } +# Rasterize user-supplied SVG icons locally before sending PNG to Core. +resvg = { version = "=0.45.1", default-features = false, features = ["text", "system-fonts"] } +roxmltree = "0.20" libc = "0.2" +x11rb = { version = "0.13.2", optional = true } axum = { version = "0.7", default-features = false, features = ["ws", "http1", "tokio"] } hyper-util = { version = "0.1", features = ["tokio", "server-auto", "server", "http1"] } local-ip-address = "0.6" diff --git a/openless-all/app/linux-egui/assets/lucide/Bolt.svg b/openless-all/app/linux-egui/assets/lucide/Bolt.svg new file mode 100644 index 000000000..78748b21b --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Bolt.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Chat.svg b/openless-all/app/linux-egui/assets/lucide/Chat.svg new file mode 100644 index 000000000..48dfb4549 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Chat.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Check.svg b/openless-all/app/linux-egui/assets/lucide/Check.svg new file mode 100644 index 000000000..0ababf6bb --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Check.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/ChevronRight.svg b/openless-all/app/linux-egui/assets/lucide/ChevronRight.svg new file mode 100644 index 000000000..8f9e50c0c --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/ChevronRight.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Clock.svg b/openless-all/app/linux-egui/assets/lucide/Clock.svg new file mode 100644 index 000000000..915477f9c --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Clock.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Close.svg b/openless-all/app/linux-egui/assets/lucide/Close.svg new file mode 100644 index 000000000..758ed3575 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Close.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Cloud.svg b/openless-all/app/linux-egui/assets/lucide/Cloud.svg new file mode 100644 index 000000000..9414ba4c6 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Cloud.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Copy.svg b/openless-all/app/linux-egui/assets/lucide/Copy.svg new file mode 100644 index 000000000..b74704d2f --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Copy.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Doc.svg b/openless-all/app/linux-egui/assets/lucide/Doc.svg new file mode 100644 index 000000000..c7b33053d --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Doc.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Download.svg b/openless-all/app/linux-egui/assets/lucide/Download.svg new file mode 100644 index 000000000..f0cbcdbbf --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Download.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/External.svg b/openless-all/app/linux-egui/assets/lucide/External.svg new file mode 100644 index 000000000..c9a963580 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/External.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Feather.svg b/openless-all/app/linux-egui/assets/lucide/Feather.svg new file mode 100644 index 000000000..38ea56aa3 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Feather.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Hash.svg b/openless-all/app/linux-egui/assets/lucide/Hash.svg new file mode 100644 index 000000000..70768ee48 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Hash.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Help.svg b/openless-all/app/linux-egui/assets/lucide/Help.svg new file mode 100644 index 000000000..64f18ff63 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Help.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/History.svg b/openless-all/app/linux-egui/assets/lucide/History.svg new file mode 100644 index 000000000..04bb758be --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/History.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Info.svg b/openless-all/app/linux-egui/assets/lucide/Info.svg new file mode 100644 index 000000000..99f57f761 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Info.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Layout.svg b/openless-all/app/linux-egui/assets/lucide/Layout.svg new file mode 100644 index 000000000..8a3e2b32e --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Layout.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Mic.svg b/openless-all/app/linux-egui/assets/lucide/Mic.svg new file mode 100644 index 000000000..b0cae04b2 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Mic.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Monitor.svg b/openless-all/app/linux-egui/assets/lucide/Monitor.svg new file mode 100644 index 000000000..9993e5ebf --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Monitor.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/More.svg b/openless-all/app/linux-egui/assets/lucide/More.svg new file mode 100644 index 000000000..6a9e28d3c --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/More.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Overview.svg b/openless-all/app/linux-egui/assets/lucide/Overview.svg new file mode 100644 index 000000000..c8860e9a5 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Overview.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Pencil.svg b/openless-all/app/linux-egui/assets/lucide/Pencil.svg new file mode 100644 index 000000000..7e41600df --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Pencil.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Pin.svg b/openless-all/app/linux-egui/assets/lucide/Pin.svg new file mode 100644 index 000000000..96c9ffd7f --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Pin.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Play.svg b/openless-all/app/linux-egui/assets/lucide/Play.svg new file mode 100644 index 000000000..75d41b638 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Play.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Plus.svg b/openless-all/app/linux-egui/assets/lucide/Plus.svg new file mode 100644 index 000000000..18e59d9f8 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Plus.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Refresh.svg b/openless-all/app/linux-egui/assets/lucide/Refresh.svg new file mode 100644 index 000000000..7bb7c5cc9 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Refresh.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Search.svg b/openless-all/app/linux-egui/assets/lucide/Search.svg new file mode 100644 index 000000000..8ff1644e3 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Search.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/SelectionAsk.svg b/openless-all/app/linux-egui/assets/lucide/SelectionAsk.svg new file mode 100644 index 000000000..810570249 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/SelectionAsk.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Send.svg b/openless-all/app/linux-egui/assets/lucide/Send.svg new file mode 100644 index 000000000..85a3dfd28 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Send.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Settings.svg b/openless-all/app/linux-egui/assets/lucide/Settings.svg new file mode 100644 index 000000000..069f0b146 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Settings.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Shield.svg b/openless-all/app/linux-egui/assets/lucide/Shield.svg new file mode 100644 index 000000000..fa50b2f5d --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Shield.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Sparkle.svg b/openless-all/app/linux-egui/assets/lucide/Sparkle.svg new file mode 100644 index 000000000..0606c1cae --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Sparkle.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Stop.svg b/openless-all/app/linux-egui/assets/lucide/Stop.svg new file mode 100644 index 000000000..ed39baf12 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Stop.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Style.svg b/openless-all/app/linux-egui/assets/lucide/Style.svg new file mode 100644 index 000000000..d0eae47a8 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Style.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Trash.svg b/openless-all/app/linux-egui/assets/lucide/Trash.svg new file mode 100644 index 000000000..11f17232e --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Trash.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Upload.svg b/openless-all/app/linux-egui/assets/lucide/Upload.svg new file mode 100644 index 000000000..eaf26fe41 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Upload.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/lucide/Vocab.svg b/openless-all/app/linux-egui/assets/lucide/Vocab.svg new file mode 100644 index 000000000..a816d47f7 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Vocab.svg @@ -0,0 +1 @@ + diff --git a/openless-all/app/linux-egui/assets/ui-locales.json b/openless-all/app/linux-egui/assets/ui-locales.json deleted file mode 100644 index b15ac5f09..000000000 --- a/openless-all/app/linux-egui/assets/ui-locales.json +++ /dev/null @@ -1 +0,0 @@ -{"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/build.rs b/openless-all/app/linux-egui/build.rs new file mode 100644 index 000000000..7e43c7fc9 --- /dev/null +++ b/openless-all/app/linux-egui/build.rs @@ -0,0 +1,43 @@ +//! Stamp the product version into the Linux egui binary. +//! +//! The egui host and the Tauri app ship as one product, so the version shown in +//! the UI must track `package.json` — the release workflow derives the package +//! version from the same file. `CARGO_PKG_VERSION` only carries the internal +//! crate version (`0.1.0`), which is not what users should see. + +use std::path::PathBuf; + +fn main() { + println!("cargo:rerun-if-changed=../package.json"); + println!("cargo:rerun-if-changed=package-revision"); + println!("cargo:rerun-if-env-changed=OPENLESS_LINUX_VERSION"); + let base = read_package_version().expect("package.json must declare the product version"); + let revision = std::fs::read_to_string( + PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("package-revision"), + ) + .expect("Linux package revision is required"); + let revision = revision.trim(); + assert!( + !revision.is_empty() && revision.bytes().all(|c| c.is_ascii_digit()), + "invalid Linux package revision" + ); + let expected = format!("{}-{revision}", base.split('+').next().unwrap_or(&base)); + if let Ok(version) = std::env::var("OPENLESS_LINUX_VERSION") { + assert_eq!(version, expected, "binary and package versions must match"); + } + println!("cargo:rustc-env=OPENLESS_APP_VERSION={expected}"); +} + +/// Extract the top-level `version` field. The build script stays +/// dependency-free on purpose, so this is a small targeted scan rather than a +/// JSON parse: `package.json` declares exactly one `"version"` key. +fn read_package_version() -> Option { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").ok()?); + let raw = std::fs::read_to_string(manifest.join("../package.json")).ok()?; + let key = "\"version\""; + let rest = &raw[raw.find(key)? + key.len()..]; + let rest = &rest[rest.find(':')? + 1..]; + let rest = &rest[rest.find('"')? + 1..]; + let end = rest.find('"')?; + Some(rest[..end].to_string()) +} diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision new file mode 100644 index 000000000..257e56326 --- /dev/null +++ b/openless-all/app/linux-egui/package-revision @@ -0,0 +1 @@ +102 diff --git a/openless-all/app/linux-egui/packaging/AppRun b/openless-all/app/linux-egui/packaging/AppRun deleted file mode 100644 index 473d1cea0..000000000 --- a/openless-all/app/linux-egui/packaging/AppRun +++ /dev/null @@ -1,6 +0,0 @@ -#!/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/icons/128x128.png b/openless-all/app/linux-egui/packaging/icons/128x128.png new file mode 100644 index 000000000..358c3b3b2 Binary files /dev/null and b/openless-all/app/linux-egui/packaging/icons/128x128.png differ diff --git a/openless-all/app/linux-egui/packaging/icons/128x128@2x.png b/openless-all/app/linux-egui/packaging/icons/128x128@2x.png new file mode 100644 index 000000000..50040e0c9 Binary files /dev/null and b/openless-all/app/linux-egui/packaging/icons/128x128@2x.png differ diff --git a/openless-all/app/linux-egui/packaging/icons/32x32.png b/openless-all/app/linux-egui/packaging/icons/32x32.png new file mode 100644 index 000000000..2ff63a232 Binary files /dev/null and b/openless-all/app/linux-egui/packaging/icons/32x32.png differ diff --git a/openless-all/app/linux-egui/packaging/icons/64x64.png b/openless-all/app/linux-egui/packaging/icons/64x64.png new file mode 100644 index 000000000..6086d0651 Binary files /dev/null and b/openless-all/app/linux-egui/packaging/icons/64x64.png differ diff --git a/openless-all/app/linux-egui/packaging/icons/icon.png b/openless-all/app/linux-egui/packaging/icons/icon.png new file mode 100755 index 000000000..3bdcb6b84 Binary files /dev/null and b/openless-all/app/linux-egui/packaging/icons/icon.png differ diff --git a/openless-all/app/linux-egui/packaging/openless-desktop-integration b/openless-all/app/linux-egui/packaging/openless-desktop-integration deleted file mode 100644 index b7b6f3eda..000000000 --- a/openless-all/app/linux-egui/packaging/openless-desktop-integration +++ /dev/null @@ -1,5 +0,0 @@ -#!/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 deleted file mode 100644 index cfd312be3..000000000 --- a/openless-all/app/linux-egui/src/atspi.rs +++ /dev/null @@ -1,118 +0,0 @@ -//! 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 edf36d4b3..daaeafa62 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -48,6 +48,8 @@ impl AudioRecorder for LinuxCpalRecorder { // 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(); + let require_archive = + context.output_target == openless_core::DictationOutputTarget::QuickNote; Box::pin(async move { #[cfg(target_os = "linux")] { @@ -56,6 +58,7 @@ impl AudioRecorder for LinuxCpalRecorder { session_id, preferred_device_name, recordings_dir, + require_archive, mute_during_recording, consumer, progress, @@ -92,7 +95,6 @@ struct LinuxActiveRecording { mute: Option, } -#[cfg(target_os = "linux")] impl Drop for LinuxActiveRecording { fn drop(&mut self) { // Taking the guard here forces the field to be consumed (and therefore @@ -100,10 +102,6 @@ impl Drop for LinuxActiveRecording { // 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.stop.store(true, std::sync::atomic::Ordering::Release); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } self.mute.take(); } } @@ -198,6 +196,7 @@ fn start_linux_recording( session_id: SessionId, preferred_device_name: Option, recordings_dir: Option, + require_archive: bool, mute_during_recording: bool, consumer: Arc, progress: Arc, @@ -230,11 +229,23 @@ fn start_linux_recording( (Some(Arc::new(std::sync::Mutex::new(writer))), Some(archive)) } Err(error) => { + if require_archive { + return Err(BackendError::new( + BackendErrorCode::Persistence, + format!("quick note recording archive is required: {error}"), + )); + } log::warn!("failed to create Linux recording archive: {error}"); (None, None) } } } + None if require_archive => { + return Err(BackendError::new( + BackendErrorCode::Persistence, + "quick note recording archive directory is missing", + )) + } None => (None, None), }; let thread = std::thread::Builder::new() diff --git a/openless-all/app/linux-egui/src/audio_cue.rs b/openless-all/app/linux-egui/src/audio_cue.rs index 0d65577e2..35d8e4fec 100644 --- a/openless-all/app/linux-egui/src/audio_cue.rs +++ b/openless-all/app/linux-egui/src/audio_cue.rs @@ -1,10 +1,23 @@ -//! Native recording start/stop audio cues for Linux. +//! Native recording cue 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). +//! The Windows/macOS Tauri shell synthesizes the "recording started" chime with +//! the Web Audio API in a webview (`app/src/lib/audioCue.ts`, +//! `recordStartCueTones()` + `scheduleCueVoices()`). This crate is the native +//! egui host, so there is no webview; the same cue is rendered as PCM here and +//! played to the default output sink with cpal (already a dependency for the +//! microphone). Parameters **and** envelope semantics are kept identical to +//! `audioCue.ts` so Linux and Windows/macOS share one sound: +//! +//! | `audioCue.ts` | here | +//! |------------------------------------------------------|-------------------------------------| +//! | `recordStartCueTones()` 880 Hz / 0 ms / 130 ms / .16 | `start_cue_tones()[0]` | +//! | 1108.73 Hz / 95 ms / 170 ms / .18 | `start_cue_tones()[1]` | +//! | `osc.type = 'sine'` | `sin(2 * pi * freq_hz * t)` | +//! | `setValueAtTime(0.0001, t0)` | `CUE_FLOOR` | +//! | `exponentialRamp(peakGain, t0 + 0.005)` | 5 ms 指数 attack | +//! | `exponentialRamp(0.0001, tEnd)` | 指数 decay 到 `CUE_FLOOR` | +//! | `stopAudioCue()` = `stopVoices()`,**不发声** | `play_cue_stop()` = 作废当前代次 | +//! | `scheduleCueVoices()` 先 `stopVoices()` 防叠音 | 新一代次使在播的旧音静音 | //! //! Honesty rules: //! - The frame is never blocked: `play_cue_start`/`play_cue_stop` enqueue a @@ -15,14 +28,26 @@ //! - 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. +//! a needless PipeWire/KDE sink-input blip). //! - 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::atomic::{AtomicU64, Ordering}; use std::sync::Arc; +/// Web Audio 的指数斜坡下限(`audioCue.ts` 里用 0.0001 代替 0:0 无法走指数)。 +const CUE_FLOOR: f32 = 0.0001; +/// `audioCue.ts` 的 attack 时间:`exponentialRampToValueAtTime(peakGain, t0 + 0.005)`。 +const CUE_ATTACK_SECS: f32 = 0.005; + +/// 当前提示音代次。与 Tauri 的 `playSeq` / `stopVoices()` 对应: +/// - 每次 `play_cue_start` 递增:新音开始时在播的旧音立即静音(Tauri 排期前会 +/// `stopVoices()`,避免连按热键叠音越来越响)。 +/// - `play_cue_stop` 也递增:等价于 Tauri 的 `stopAudioCue()` —— 只停不发声, +/// Tauri 在录音结束时并没有“结束提示音”。 +static CUE_GENERATION: AtomicU64 = AtomicU64::new(0); + /// A single synthesized sine note relative to the cue start. #[derive(Debug, Clone, Copy, PartialEq)] pub struct CueTone { @@ -36,8 +61,8 @@ pub struct CueTone { 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. +/// "Recording started" chime: rising minor third (A5 -> C#6) — 与 Tauri +/// `recordStartCueTones()` 逐字段一致(880/0ms/130ms/0.16 + 1108.73/95ms/170ms/0.18)。 pub fn start_cue_tones() -> Vec { vec![ CueTone { @@ -55,25 +80,6 @@ pub fn start_cue_tones() -> Vec { ] } -/// "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| { @@ -94,27 +100,29 @@ pub fn render_cue_mono(tones: &[CueTone], sample_rate: u32) -> Vec { 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; + let dur_secs = dur as f32 / sr; + // `math.exp` 的指数斜坡在**增益空间是直线**: + // gain.setValueAtTime(0.0001, t0) + // gain.exponentialRampToValueAtTime(peakGain, t0 + 0.005) + // gain.exponentialRampToValueAtTime(0.0001, tEnd) + // 即 g(t) = a * (b/a)^(t/T)。这里逐步复现同一个曲线(不是线性 attack + + // 任意 tau 的衰减)。 + let attack_secs = CUE_ATTACK_SECS.min(dur_secs.max(f32::EPSILON)); + let decay_secs = (dur_secs - attack_secs).max(f32::EPSILON); 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 + let t = i as f32 / sr; + let env = if t < attack_secs { + CUE_FLOOR * (tone.peak_gain / CUE_FLOOR).powf(t / attack_secs) } else { - 1.0 + let frac = ((t - attack_secs) / decay_secs).min(1.0); + tone.peak_gain * (CUE_FLOOR / tone.peak_gain).powf(frac) }; - 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; + out[idx] += phase.sin() * env; } } for sample in &mut out { @@ -124,24 +132,33 @@ pub fn render_cue_mono(tones: &[CueTone], sample_rate: u32) -> Vec { } /// Play a start cue asynchronously (never blocks the caller/frame). +/// 与 Tauri `playRecordStartCue()` 同语义:新的一生效后,上一声立即静音。 pub fn play_cue_start() { - play_cue(start_cue_tones()); + let generation = CUE_GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + play_cue(start_cue_tones(), generation); } -/// Play a stop cue asynchronously (never blocks the caller/frame). +/// 停止当前提示音。对应 Tauri `stopAudioCue()` —— **只停不发声**: +/// Tauri 在录音结束时调用的就是它,并没有“结束提示音”。 +/// 正在播放的提示音会被静音(代次作废),未播放的不再开始。 pub fn play_cue_stop() { - play_cue(stop_cue_tones()); + CUE_GENERATION.fetch_add(1, Ordering::SeqCst); +} + +/// 该代次是否仍是当前代次;`false` 表示应立刻静音(等价 `stopVoices()`)。 +fn cue_generation_is_current(generation: u64) -> bool { + CUE_GENERATION.load(Ordering::SeqCst) == generation } /// Best-effort asynchronous playback on a detached worker thread. -fn play_cue(tones: Vec) { +fn play_cue(tones: Vec, generation: u64) { 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) { + if let Err(error) = play_cue_blocking(&tones, generation) { log::debug!("[audio-cue] cue playback unavailable: {error}"); } }) @@ -150,7 +167,7 @@ fn play_cue(tones: Vec) { } #[cfg(target_os = "linux")] -fn play_cue_blocking(tones: &[CueTone]) -> Result<(), String> { +fn play_cue_blocking(tones: &[CueTone], generation: u64) -> Result<(), String> { use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; let host = cpal::default_host(); @@ -182,6 +199,16 @@ fn play_cue_blocking(tones: &[CueTone]) -> Result<(), String> { device.build_output_stream::<$ty, _, _>( *config, move |data: &mut [$ty], _: &cpal::OutputCallbackInfo| { + // 代次不再是最新(被新的提示音接管,或录音结束调了 + // `play_cue_stop`)→ 立刻静音并结束,等价 Tauri 的 + // `stopVoices()`。 + if !cue_generation_is_current(generation) { + for sample in data.iter_mut() { + *sample = $convert(0.0); + } + done.store(true, std::sync::atomic::Ordering::Release); + return; + } let frames = data.len() / channels; let mut pos = idx.load(std::sync::atomic::Ordering::Acquire); for frame in 0..frames { @@ -240,7 +267,7 @@ fn play_cue_blocking(tones: &[CueTone]) -> Result<(), String> { } #[cfg(not(target_os = "linux"))] -fn play_cue_blocking(_tones: &[CueTone]) -> Result<(), String> { +fn play_cue_blocking(_tones: &[CueTone], _generation: u64) -> Result<(), String> { Err("audio cue playback is only available on Linux".to_string()) } @@ -248,21 +275,62 @@ fn play_cue_blocking(_tones: &[CueTone]) -> Result<(), String> { mod tests { use super::*; + /// 与 Tauri `recordStartCueTones()`(`src/lib/audioCue.ts:29-34`)逐字段一致。 #[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"); + fn start_cue_matches_the_tauri_tone_table() { + let tones = start_cue_tones(); + assert_eq!(tones.len(), 2, "the Tauri cue has two tones"); + assert_eq!(tones[0].freq_hz, 880.0); + assert_eq!(tones[0].start_ms, 0.0); + assert_eq!(tones[0].duration_ms, 130.0); + assert_eq!(tones[0].peak_gain, 0.16); + assert_eq!(tones[1].freq_hz, 1108.73, "rising minor third A5 -> C#6"); + assert_eq!(tones[1].start_ms, 95.0); + assert_eq!(tones[1].duration_ms, 170.0); + assert_eq!(tones[1].peak_gain, 0.18); + assert_eq!( + cue_total_duration_ms(&tones), + 265, + "total duration is 265ms" + ); } + /// 包络复现 Web Audio 的两段指数斜坡:0.0001 → 5ms 到峰值 → 到末尾回到 0.0001。 #[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); + fn envelope_is_two_exponential_ramps_like_web_audio() { + let sr = 48_000u32; + let tone = CueTone { + freq_hz: 880.0, + start_ms: 0.0, + duration_ms: 130.0, + peak_gain: 0.16, + }; + // 用 1 Hz 的音来取包络:sin(2pi * 1 * t) 在 t<0.25s 内近似线性递增, + // 便于直接看曲线;这里改为直接验证端点与峰值位置。 + let mono = render_cue_mono(&[tone], sr); + let peak = mono.iter().fold(0.0f32, |m, s| m.max(s.abs())); + assert!( + peak > 0.05 && peak <= 0.17, + "peak should sit near peak_gain: {peak}" + ); + // 起点的指数 attack 从 0.0001 开始 → 首样本几乎无声。 + assert!( + mono[0].abs() < 0.01, + "first sample starts at CUE_FLOOR: {}", + mono[0] + ); + // 末尾回到 floor → 最后一个样本远小于峰值。 + let last = mono[mono.len() - 1].abs(); + assert!( + last < peak * 0.5, + "tail decays towards the floor: {last} vs peak {peak}" + ); + // 5ms 之后就应在衰减(第 5ms 样本的包络低于峰值)。 + let at_5ms = (0.005 * sr as f32) as usize; + assert!( + mono[at_5ms].abs() < peak, + "exponential decay starts after 5ms" + ); } #[test] @@ -285,16 +353,31 @@ mod tests { ); } + /// `play_cue_stop` 对应 Tauri `stopAudioCue()`:只停不发声(作废代次), + /// 因此不能合成任何“结束音”。 + #[test] + fn stopping_a_cue_only_silences_it() { + let before = CUE_GENERATION.load(Ordering::SeqCst); + play_cue_stop(); + let after = CUE_GENERATION.load(Ordering::SeqCst); + assert_eq!(after, before + 1, "stop only bumps the generation"); + assert!( + !cue_generation_is_current(before), + "the previous generation must be invalidated (an in-flight cue is silenced)" + ); + } + #[test] fn empty_tones_render_to_empty_and_play_is_a_noop() { assert!(render_cue_mono(&[], 44_100).is_empty()); - play_cue(Vec::new()); + let generation = CUE_GENERATION.fetch_add(1, Ordering::SeqCst) + 1; + play_cue(Vec::new(), generation); } #[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); + let at_44k = render_cue_mono(&start_cue_tones(), 44_100); + let at_48k = render_cue_mono(&start_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 index c19d505b4..863d2d15e 100644 --- a/openless-all/app/linux-egui/src/audio_mute.rs +++ b/openless-all/app/linux-egui/src/audio_mute.rs @@ -1,192 +1,212 @@ -//! Restore the output that was muted at activation, even if the default changes. -use std::sync::Arc; +//! Temporary system-output mute while recording. +//! +//! Linux parity with the legacy desktop [`AudioMuteGuard`] behavior. +//! `mute_during_recording` is applied by +//! the host recorder (never by Core) so the capture itself never hears the +//! user's speakers. Restore is guaranteed by RAII: the guard restores the +//! previous mute state when it is dropped, which happens on every terminal +//! path of the recorder lifecycle — normal stop, cancel, runtime fault/error +//! and process shutdown — because Core consumes/drops the active recording +//! handle on each of those. +//! +//! Muting is intentionally best-effort like the reference: if neither `wpctl` +//! (PipeWire) nor `pactl` (PulseAudio) can drive the default sink, activation +//! fails but capture still proceeds. The parsed state helpers are pure so +//! they can be unit-tested without a live audio server. -trait OutputControl: Send + Sync { - fn current(&self) -> Result<(String, bool), String>; - fn set_muted(&self, sink: &str, muted: bool) -> Result<(), String>; +/// The output-mute backend discovered at activation time. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MuteBackend { + Wpctl, + Pactl, } +/// Guard that restores the previous default-sink mute state on drop. +#[derive(Debug)] pub struct AudioMuteGuard { - inner: Option<(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() - } + inner: Option, } + impl AudioMuteGuard { + /// Mute the default output sink unless it is already muted, capturing the + /// previous state for later restore. Best-effort: on failure returns an + /// error and leaves the sink untouched. pub fn activate() -> Result { - 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) + Ok(Self { + inner: Some(platform::activate()?), + }) } + + /// A no-op guard used when `mute_during_recording` is disabled. pub fn none() -> Self { Self { inner: None } } } + impl Drop for AudioMuteGuard { fn drop(&mut self) { - if let Some((control, sink, was_muted)) = self.inner.take() { - if let Err(error) = control.set_muted(&sink, was_muted) { - log::warn!("restore recording output mute: {error}"); - } + if let Some(inner) = self.inner.take() { + inner.restore(); } } } + +/// `true` when a `wpctl get-volume` capture reports the sink muted. pub fn parse_wpctl_muted(output: &str) -> bool { output.contains("[MUTED]") } + +/// `true` when a `pactl get-sink-mute` capture reports the sink muted. pub fn parse_pactl_muted(output: &str) -> bool { - output.to_ascii_lowercase().contains("yes") || output.contains('是') + let lower = output.to_ascii_lowercase(); + lower.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() - )); +mod platform { + use super::{parse_pactl_muted, parse_wpctl_muted, MuteBackend}; + use std::process::Command; + + #[derive(Debug)] + pub struct PlatformMuteGuard { + backend: MuteBackend, + was_muted: bool, } - 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))); + + pub fn activate() -> Result { + if let Ok(was_muted) = wpctl_muted() { + if !was_muted { + set_wpctl_muted(true)?; } + return Ok(PlatformMuteGuard { + backend: MuteBackend::Wpctl, + was_muted, + }); + } + let was_muted = pactl_muted()?; + if !was_muted { + set_pactl_muted(true)?; + } + Ok(PlatformMuteGuard { + backend: MuteBackend::Pactl, + was_muted, + }) + } + + impl PlatformMuteGuard { + pub fn restore(self) { + let result = match self.backend { + MuteBackend::Wpctl => set_wpctl_muted(self.was_muted), + MuteBackend::Pactl => set_pactl_muted(self.was_muted), + }; + if let Err(error) = result { + log::warn!("[audio-mute] restore output mute failed: {error}"); + } + } + } + + fn wpctl_muted() -> Result { + let output = Command::new("wpctl") + .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) + .output() + .map_err(|error| format!("wpctl get-volume failed: {error}"))?; + if !output.status.success() { + return Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()); + } + Ok(parse_wpctl_muted( + std::str::from_utf8(&output.stdout).unwrap_or_default(), + )) + } + + fn set_wpctl_muted(muted: bool) -> Result<(), String> { + let value = if muted { "1" } else { "0" }; + let output = Command::new("wpctl") + .args(["set-mute", "@DEFAULT_AUDIO_SINK@", value]) + .output() + .map_err(|error| format!("wpctl set-mute failed: {error}"))?; + if output.status.success() { + Ok(()) + } else { + Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()) } - 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> { + + fn pactl_muted() -> Result { + let output = Command::new("pactl") + .args(["get-sink-mute", "@DEFAULT_SINK@"]) + .output() + .map_err(|error| format!("pactl get-sink-mute failed: {error}"))?; + if !output.status.success() { + return Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()); + } + Ok(parse_pactl_muted( + std::str::from_utf8(&output.stdout).unwrap_or_default(), + )) + } + + fn set_pactl_muted(muted: bool) -> Result<(), String> { let value = if muted { "1" } else { "0" }; - 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])?; + let output = Command::new("pactl") + .args(["set-sink-mute", "@DEFAULT_SINK@", value]) + .output() + .map_err(|error| format!("pactl set-sink-mute failed: {error}"))?; + if output.status.success() { + Ok(()) } else { - return Err("invalid output identity".into()); + Err(std::str::from_utf8(&output.stderr) + .unwrap_or_default() + .trim() + .to_string()) } - Ok(()) } } + #[cfg(not(target_os = "linux"))] -impl OutputControl for NativeOutput { - fn current(&self) -> Result<(String, bool), String> { - Err("Linux audio unavailable".into()) +mod platform { + #[derive(Debug)] + pub struct PlatformMuteGuard; + + pub fn activate() -> Result { + Err("output mute is not supported on this platform".to_string()) } - fn set_muted(&self, _: &str, _: bool) -> Result<(), String> { - Err("Linux audio unavailable".into()) + + impl PlatformMuteGuard { + pub fn restore(self) {} } } + #[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() { + fn wpctl_output_parser_detects_muted_flag() { assert!(parse_wpctl_muted("Volume: 0.00 [MUTED]")); assert!(!parse_wpctl_muted("Volume: 0.82")); + } + + #[test] + fn pactl_output_parser_detects_muted_flag_in_any_locale() { assert!(parse_pactl_muted("Mute: yes")); + assert!(parse_pactl_muted("静音:是")); assert!(!parse_pactl_muted("Mute: no")); + assert!(!parse_pactl_muted("静音:否")); + } + + #[test] + fn disabled_guard_is_a_noop_that_drops_without_panicking() { + // `none()` is a pure no-op guard; dropping it must not panic and has no + // command side effect to assert, so we only verify construction/drop. + let guard = AudioMuteGuard::none(); + drop(guard); } } diff --git a/openless-all/app/linux-egui/src/audio_player.rs b/openless-all/app/linux-egui/src/audio_player.rs new file mode 100644 index 000000000..1a351934a --- /dev/null +++ b/openless-all/app/linux-egui/src/audio_player.rs @@ -0,0 +1,234 @@ +//! In-app playback for history recordings. +//! +//! Recordings are 16 kHz mono signed-16 WAV. This plays them through the default +//! CPAL output device with linear resampling to whatever rate the device runs +//! at, so the history page can show a real player bar instead of shelling out to +//! an external player. + +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::Arc; + +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; + +/// A playing clip. Dropping it stops playback. +pub struct ClipPlayer { + _stream: cpal::Stream, + played_samples: Arc, + total_samples: usize, + channels: usize, + sample_rate: u32, + paused: Arc, +} + +impl ClipPlayer { + /// Play `pcm` (16 kHz mono little-endian i16 bytes). + pub fn play(pcm: &[u8]) -> Result { + let mono: Vec = pcm + .as_chunks::<2>() + .0 + .iter() + .map(|pair| i16::from_le_bytes(*pair) as f32 / 32_768.0) + .collect(); + if mono.is_empty() { + return Err("empty recording".to_string()); + } + + let host = cpal::default_host(); + let device = host + .default_output_device() + .ok_or_else(|| "no default output device".to_string())?; + let supported = device + .default_output_config() + .map_err(|error| error.to_string())?; + let sample_rate = supported.sample_rate(); + let channels = supported.channels() as usize; + let sample_format = supported.sample_format(); + let config: cpal::StreamConfig = supported.into(); + + // Linear resample 16 kHz -> device rate and fan out to every channel. + let ratio = 16_000.0_f64 / sample_rate as f64; + let out_frames = (mono.len() as f64 / ratio).ceil() as usize; + let mut interleaved: Vec = Vec::with_capacity(out_frames * channels); + for frame in 0..out_frames { + let source = frame as f64 * ratio; + let index = source.floor() as usize; + let frac = (source - index as f64) as f32; + let first = mono.get(index).copied().unwrap_or(0.0); + let second = mono.get(index + 1).copied().unwrap_or(first); + let value = first + (second - first) * frac; + for _ in 0..channels { + interleaved.push(value); + } + } + + let data = Arc::new(interleaved); + let played_samples = Arc::new(AtomicUsize::new(0)); + let paused = Arc::new(AtomicBool::new(false)); + let total_samples = data.len(); + + let stream = build_stream( + &device, + config, + sample_format, + data.clone(), + played_samples.clone(), + paused.clone(), + )?; + stream.play().map_err(|error| error.to_string())?; + + Ok(Self { + _stream: stream, + played_samples, + total_samples, + channels, + sample_rate, + paused, + }) + } + + /// Playback head in milliseconds. + pub fn position_ms(&self) -> u64 { + let frames = self + .played_samples + .load(Ordering::Relaxed) + .min(self.total_samples) + / self.channels.max(1); + (frames as u64 * 1000) / self.sample_rate.max(1) as u64 + } + + /// Clip length in milliseconds. + pub fn total_ms(&self) -> u64 { + let frames = self.total_samples / self.channels.max(1); + (frames as u64 * 1000) / self.sample_rate.max(1) as u64 + } + + pub fn is_finished(&self) -> bool { + self.played_samples.load(Ordering::Relaxed) >= self.total_samples + } + + pub fn is_paused(&self) -> bool { + self.paused.load(Ordering::Relaxed) + } + + pub fn toggle_pause(&self) { + self.paused.fetch_xor(true, Ordering::Relaxed); + } + + /// Seek in whole frames so all output channels stay aligned. The callback uses a + /// compare-exchange to avoid overwriting this position if a seek races an audio block. + pub fn seek_ms(&self, ms: u64) { + let frames = seek_frame( + ms, + self.sample_rate, + self.total_samples / self.channels.max(1), + ); + self.played_samples + .store(frames * self.channels, Ordering::Relaxed); + } +} + +fn seek_frame(ms: u64, rate: u32, frames: usize) -> usize { + ((ms as u128 * rate as u128) / 1000).min(frames.saturating_sub(1) as u128) as usize +} + +fn build_stream( + device: &cpal::Device, + config: cpal::StreamConfig, + format: cpal::SampleFormat, + data: Arc>, + played: Arc, + paused: Arc, +) -> Result { + let error_callback = |error| log::warn!("audio playback error: {error}"); + // Fill the output buffer from `data`, starting at the played head. + macro_rules! writer { + ($ty:ty, $convert:expr) => {{ + let data = data.clone(); + let played = played.clone(); + let paused = paused.clone(); + device + .build_output_stream( + config.clone(), + move |output: &mut [$ty], _| { + let convert: fn(f32) -> $ty = $convert; + write_samples(output, &data, &played, &paused, convert); + }, + error_callback, + None, + ) + .map_err(|error| error.to_string()) + }}; + } + match format { + cpal::SampleFormat::F32 => writer!(f32, |value| value), + cpal::SampleFormat::I16 => { + writer!(i16, |value| (value * 32_767.0).clamp(-32_768.0, 32_767.0) + as i16) + } + cpal::SampleFormat::U16 => { + writer!( + u16, + |value| ((value * 0.5 + 0.5) * 65_535.0).clamp(0.0, 65_535.0) as u16 + ) + } + other => Err(format!("unsupported output sample format: {other:?}")), + } +} + +fn write_samples( + output: &mut [T], + data: &[f32], + played: &AtomicUsize, + paused: &AtomicBool, + convert: fn(f32) -> T, +) { + let silence = convert(0.0); + if paused.load(Ordering::Relaxed) { + output.fill(silence); + return; + } + let start = played.load(Ordering::Relaxed); + for (offset, slot) in output.iter_mut().enumerate() { + *slot = data.get(start + offset).copied().map_or(silence, convert); + } + // A seek can arrive while we fill the device buffer: never let this callback + // overwrite the new cursor. Completion derives from the cursor, so seeking + // back from the end cannot race with a separate `finished` flag. + let _ = played.compare_exchange( + start, + start.saturating_add(output.len()).min(data.len()), + Ordering::Relaxed, + Ordering::Relaxed, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn seek_clamps_at_both_ends_and_aligns_output_channels() { + assert_eq!(seek_frame(250, 48_000, 48_000), 12_000); + assert_eq!(seek_frame(u64::MAX, 48_000, 48_000), 47_999); + assert_eq!(seek_frame(0, 48_000, 0), 0); + // Two interleaved channels: a 250ms seek starts at sample 24_000. + let samples = seek_frame(250, 48_000, 48_000) * 2; + assert_eq!(samples, 24_000); + assert_eq!(samples % 2, 0); + } + + #[test] + fn paused_output_is_silent_and_does_not_advance() { + let data = [0.5, -0.5, 0.25]; + let played = AtomicUsize::new(1); + let paused = AtomicBool::new(true); + let mut output = [1.0; 2]; + write_samples(&mut output, &data, &played, &paused, |v| v); + assert_eq!(output, [0.0, 0.0]); + assert_eq!(played.load(Ordering::Relaxed), 1); + paused.store(false, Ordering::Relaxed); + write_samples(&mut output, &data, &played, &paused, |v| v); + assert_eq!(output, [-0.5, 0.25]); + assert_eq!(played.load(Ordering::Relaxed), data.len()); + } +} diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index bf52fbcc5..d01c3c4d1 100644 --- a/openless-all/app/linux-egui/src/backend.rs +++ b/openless-all/app/linux-egui/src/backend.rs @@ -3,14 +3,13 @@ use std::sync::Arc; use futures_util::future::BoxFuture; use openless_core::{ - AudioConsumer, AudioRecorder, BackendConfig, BackendDependencies, BackendError, - BackendErrorCode, BackendRepositories, BackendServices, CredentialStore, DictationEngine, - DictationEngineRouter, MarketplaceConfig, ModelStore, ModelStoreConfig, OpenLessBackend, - PipelineDictationEngine, PolishFailurePolicy, ProviderService, SettingsRuntime, - SharedAuxiliaryTextPolisher, SharedCloudTextPolisher, SharedCloudTranscriptionEngine, - SharedOmniDictationEngine, TextInserter, TextPolisher, TextPolisherRouter, TextStreamSink, - TranscriptOutput, TranscriptionEngine, TranscriptionRouter, TranscriptionSession, - SHARED_CLOUD_ASR_PROVIDER_TYPES, SHARED_CLOUD_LLM_PROVIDER_TYPES, SHARED_OMNI_PROVIDER_TYPES, + AudioRecorder, BackendConfig, BackendDependencies, BackendError, BackendErrorCode, + BackendRepositories, BackendServices, CredentialStore, DictationEngine, DictationEngineRouter, + MarketplaceConfig, OpenLessBackend, PipelineDictationEngine, PolishFailurePolicy, + ProviderService, SettingsRuntime, SharedAuxiliaryTextPolisher, SharedCloudTextPolisher, + SharedCloudTranscriptionEngine, SharedOmniDictationEngine, TextInserter, TextPolisher, + TextPolisherRouter, TranscriptionEngine, TranscriptionRouter, SHARED_CLOUD_ASR_PROVIDER_TYPES, + SHARED_CLOUD_LLM_PROVIDER_TYPES, SHARED_OMNI_PROVIDER_TYPES, }; use crate::qa::LinuxQaRuntime; @@ -47,619 +46,6 @@ impl openless_core::TaskSpawner for LinuxTaskSpawner { } } -const QWEN_PREPARE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); -const QWEN_TRANSCRIBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); - -#[derive(Debug)] -struct QwenProcessOutput { - status: std::process::ExitStatus, - stdout: Vec, - stderr: Vec, -} - -async fn run_qwen_process( - executable: std::path::PathBuf, - args: Vec, - stdin: Option>, - cancelled: Arc, - timeout: std::time::Duration, -) -> Result { - use tokio::io::{AsyncReadExt, AsyncWriteExt}; - - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )); - } - let mut command = tokio::process::Command::new(&executable); - command - .args(args) - .stdin(if stdin.is_some() { - std::process::Stdio::piped() - } else { - std::process::Stdio::null() - }) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - crate::coding_agent::isolate_process_group(&mut command); - let mut child = command.spawn().map_err(|error| { - BackendError::new( - if matches!( - error.kind(), - std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied - ) { - BackendErrorCode::Unsupported - } else { - BackendErrorCode::Platform - }, - format!("Qwen ASR runtime unavailable: {error}"), - ) - })?; - let stdin_task = stdin.map(|input| { - let mut pipe = child.stdin.take().expect("piped Qwen stdin"); - tokio::spawn(async move { - pipe.write_all(&input).await?; - pipe.shutdown().await - }) - }); - let mut stdout = child.stdout.take().expect("piped Qwen stdout"); - let mut stderr = child.stderr.take().expect("piped Qwen stderr"); - let stdout_task = tokio::spawn(async move { - let mut bytes = Vec::new(); - stdout.read_to_end(&mut bytes).await.map(|_| bytes) - }); - let stderr_task = tokio::spawn(async move { - let mut bytes = Vec::new(); - stderr.read_to_end(&mut bytes).await.map(|_| bytes) - }); - let started = tokio::time::Instant::now(); - let status = loop { - tokio::select! { - status = child.wait() => break Ok(status.map_err(qwen_platform_error)?), - _ = tokio::time::sleep(std::time::Duration::from_millis(20)) => { - let error = if cancelled.load(std::sync::atomic::Ordering::Acquire) { - Some(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )) - } else if started.elapsed() >= timeout { - Some(BackendError::new( - BackendErrorCode::Provider, - "Qwen ASR operation timed out", - ).retryable(true)) - } else { - None - }; - if let Some(error) = error { - crate::coding_agent::kill_process_group(&mut child)?; - let _ = child.wait().await; - break Err(error); - } - } - } - }; - let stdin_result = match stdin_task { - Some(task) => Some(task.await.map_err(qwen_internal_error)?), - None => None, - }; - let stdout = stdout_task - .await - .map_err(qwen_internal_error)? - .map_err(qwen_platform_error)?; - let stderr = stderr_task - .await - .map_err(qwen_internal_error)? - .map_err(qwen_platform_error)?; - let status = status?; - if status.success() { - if let Some(result) = stdin_result { - result.map_err(qwen_platform_error)?; - } - } - Ok(QwenProcessOutput { - status, - stdout, - stderr, - }) -} - -fn qwen_platform_error(error: impl std::fmt::Display) -> BackendError { - BackendError::new(BackendErrorCode::Platform, error.to_string()) -} - -fn qwen_internal_error(error: impl std::fmt::Display) -> BackendError { - BackendError::new(BackendErrorCode::Internal, error.to_string()) -} - -fn qwen_executable() -> Option { - crate::resources::detect_qwen_runtime_path() - .ok() - .filter(|path| qwen_executable_is_available(path)) -} - -fn qwen_executable_is_available(path: &std::path::Path) -> bool { - let Ok(metadata) = path.metadata() else { - return false; - }; - if !metadata.is_file() { - return false; - } - #[cfg(target_os = "linux")] - { - use std::os::unix::fs::PermissionsExt; - metadata.permissions().mode() & 0o111 != 0 - } - #[cfg(not(target_os = "linux"))] - true -} - -struct LinuxGenericAsrEngine { - root: std::sync::Arc>, - executable: Option, -} - -struct LinuxGenericLocalAsrRuntime { - root: std::sync::Arc>, - loaded_model: std::sync::Arc>>, - // ponytail: one shared cancellation flag serializes model operations; per-model tokens if parallel downloads are needed. - cancelled: std::sync::Arc, - executable: Option, -} - -impl Default for LinuxGenericLocalAsrRuntime { - fn default() -> Self { - Self::from_models_root(Self::default_root(), qwen_executable()) - } -} - -impl LinuxGenericLocalAsrRuntime { - const READY_SENTINEL: &'static str = openless_core::MODEL_READY_SENTINEL; - - fn default_root() -> std::path::PathBuf { - if let Some(data) = std::env::var_os("XDG_DATA_HOME") { - return std::path::PathBuf::from(data) - .join("OpenLess") - .join("models"); - } - std::env::var_os("HOME") - .map(std::path::PathBuf::from) - .unwrap_or_else(std::env::temp_dir) - .join(".local") - .join("share") - .join("OpenLess") - .join("models") - } - - fn from_models_root(root: std::path::PathBuf, executable: Option) -> Self { - Self { - root: std::sync::Arc::new(std::sync::Mutex::new(root)), - loaded_model: std::sync::Arc::new(std::sync::Mutex::new(None)), - cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - executable, - } - } - - fn is_ready_dir(dir: &std::path::Path) -> bool { - dir.join(Self::READY_SENTINEL).is_file() - } - - fn ensure_qwen_target(target: &openless_core::LocalAsrTarget) -> Result<(), BackendError> { - let supported = target.runtime == openless_core::LocalAsrRuntime::Generic - && openless_core::LocalAsrModelId::from_wire_id(target.model_id()) - .is_some_and(openless_core::LocalAsrModelId::is_qwen); - if supported { - Ok(()) - } else { - Err(BackendError::new( - BackendErrorCode::Unsupported, - "Linux local ASR currently supports Qwen models only", - )) - } - } -} - -pub(crate) fn qwen_engine_available() -> bool { - qwen_executable().is_some() -} - -impl openless_core::ModelRuntimeAdapter for LinuxGenericLocalAsrRuntime { - fn engine_available(&self, runtime: openless_core::LocalAsrRuntime) -> bool { - runtime == openless_core::LocalAsrRuntime::Generic - && self - .executable - .as_deref() - .is_some_and(qwen_executable_is_available) - } - - fn supports_model(&self, target: &openless_core::LocalAsrTarget) -> bool { - Self::ensure_qwen_target(target).is_ok() - } - - fn rebind_storage( - &self, - models_root: std::path::PathBuf, - ) -> BoxFuture<'static, Result> { - *self.root.lock().expect("Linux ASR root lock poisoned") = models_root; - Box::pin(async { Ok(openless_core::StorageRebind::Applied) }) - } - - fn runtime_status( - &self, - settings: openless_core::LocalAsrSettings, - _model_dir: std::path::PathBuf, - ) -> BoxFuture<'static, Result> { - let available = self.engine_available(settings.runtime); - let loaded_model = std::sync::Arc::clone(&self.loaded_model); - Box::pin(async move { - let loaded = loaded_model - .lock() - .expect("Linux ASR loaded lock poisoned") - .clone(); - let loaded = available.then_some(loaded).flatten(); - Ok(openless_core::LocalAsrRuntimeStatus { - runtime: settings.runtime, - provider_id: settings.provider_id, - available, - loaded: loaded.is_some(), - active_model: settings.active_model.clone(), - model_id: loaded, - keep_loaded_secs: settings.keep_loaded_secs, - runtime_source: settings.runtime_source, - endpoint: None, - operation: None, - error: (!available).then(|| "packaged Qwen ASR runtime is not available".into()), - last_error: None, - last_prepare_ms: None, - last_transcribe_ms: None, - last_audio_ms: None, - }) - }) - } - - fn prepare( - &self, - target: openless_core::LocalAsrTarget, - _source: openless_core::FoundryRuntimeSource, - dir: std::path::PathBuf, - _progress: openless_core::ModelPrepareProgressSink, - ) -> BoxFuture<'static, Result> { - if let Err(error) = Self::ensure_qwen_target(&target) { - return Box::pin(async move { Err(error) }); - } - if let Some(root) = dir.parent() { - *self.root.lock().expect("Linux ASR root lock poisoned") = root.to_path_buf(); - } - let executable = self.executable.clone(); - let loaded = std::sync::Arc::clone(&self.loaded_model); - let cancelled = std::sync::Arc::clone(&self.cancelled); - Box::pin(async move { - cancelled.store(false, std::sync::atomic::Ordering::Release); - if !Self::is_ready_dir(&dir) { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - format!("local ASR model is not downloaded: {}", target.model_id()), - )); - } - let executable = executable.ok_or_else(|| { - BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime is not available", - ) - })?; - let output = run_qwen_process( - executable, - vec!["--help".into()], - None, - Arc::clone(&cancelled), - QWEN_PREPARE_TIMEOUT, - ) - .await?; - if !output.status.success() { - return Err(BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime failed its self-check", - )); - } - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )); - } - *loaded.lock().expect("Linux ASR loaded lock poisoned") = - Some(target.model_id().to_string()); - Ok(target.model_id().to_string()) - }) - } - - fn release( - &self, - runtime: openless_core::LocalAsrRuntime, - ) -> BoxFuture<'static, Result<(), BackendError>> { - let loaded = std::sync::Arc::clone(&self.loaded_model); - Box::pin(async move { - if runtime != openless_core::LocalAsrRuntime::Generic { - return Ok(()); - } - *loaded.lock().expect("Linux ASR loaded lock poisoned") = None; - Ok(()) - }) - } - - fn release_lease( - &self, - lease: openless_core::LocalAsrRuntimeLease, - ) -> BoxFuture<'static, Result<(), BackendError>> { - let loaded = Arc::clone(&self.loaded_model); - Box::pin(async move { - let mut current = loaded.lock().expect("Linux ASR loaded lock poisoned"); - if current.as_deref() == Some(lease.target.model_id()) { - *current = None; - } - Ok(()) - }) - } - - fn preload( - &self, - target: openless_core::LocalAsrTarget, - model_dir: std::path::PathBuf, - _provider_type: String, - ) -> BoxFuture<'static, Result<(), BackendError>> { - if let Err(error) = Self::ensure_qwen_target(&target) { - return Box::pin(async move { Err(error) }); - } - if let Some(root) = model_dir.parent() { - *self.root.lock().expect("Linux ASR root lock poisoned") = root.to_path_buf(); - } - let loaded = std::sync::Arc::clone(&self.loaded_model); - Box::pin(async move { - if loaded - .lock() - .expect("Linux ASR loaded lock poisoned") - .as_deref() - != Some(target.model_id()) - { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - "prepare the selected local ASR model before preloading", - )); - } - Ok(()) - }) - } - - fn cancel_prepare( - &self, - runtime: openless_core::LocalAsrRuntime, - ) -> BoxFuture<'static, Result<(), BackendError>> { - if runtime != openless_core::LocalAsrRuntime::Generic { - return Box::pin(async { - Err(BackendError::new( - BackendErrorCode::Unsupported, - "Linux Generic/Qwen runtime only supports generic models", - )) - }); - } - self.cancelled - .store(true, std::sync::atomic::Ordering::Release); - Box::pin(async { Ok(()) }) - } - - fn test_model( - &self, - target: openless_core::LocalAsrTarget, - dir: std::path::PathBuf, - ) -> BoxFuture<'static, Result> { - if let Err(error) = Self::ensure_qwen_target(&target) { - return Box::pin(async move { Err(error) }); - } - let executable = self.executable.clone(); - let cancelled = Arc::clone(&self.cancelled); - Box::pin(async move { - if !Self::is_ready_dir(&dir) { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - "local ASR model is not downloaded", - )); - } - let audio = std::env::var_os("OPENLESS_QWEN_ASR_TEST_AUDIO") - .map(std::path::PathBuf::from) - .filter(|path| path.is_file()) - .ok_or_else(|| { - BackendError::new( - BackendErrorCode::Unsupported, - "set OPENLESS_QWEN_ASR_TEST_AUDIO to an audio fixture for model testing", - ) - })?; - let executable = executable.ok_or_else(|| { - BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime is not available", - ) - })?; - cancelled.store(false, std::sync::atomic::Ordering::Release); - let started = std::time::Instant::now(); - let output = run_qwen_process( - executable.clone(), - vec![ - "-d".into(), - dir.into_os_string(), - "-i".into(), - audio.into_os_string(), - "--silent".into(), - ], - None, - Arc::clone(&cancelled), - QWEN_TRANSCRIBE_TIMEOUT, - ) - .await?; - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "Qwen ASR operation cancelled", - )); - } - if !output.status.success() { - return Err(BackendError::new( - BackendErrorCode::Provider, - String::from_utf8_lossy(&output.stderr).trim().to_string(), - )); - } - Ok(openless_core::LocalAsrTestResult { - target, - backend: executable.to_string_lossy().into_owned(), - expected_text: std::env::var("OPENLESS_QWEN_ASR_TEST_EXPECTED").unwrap_or_default(), - transcribed_text: String::from_utf8_lossy(&output.stdout).trim().to_string(), - audio_ms: 0, - load_ms: 0, - transcribe_ms: started.elapsed().as_millis() as u64, - }) - }) - } -} - -struct LinuxGenericAsrSession { - model: Option, - root: std::sync::Arc>, - pcm: std::sync::Mutex>, - cancelled: std::sync::Arc, - executable: std::path::PathBuf, -} - -impl TranscriptionEngine for LinuxGenericAsrEngine { - fn start( - &self, - _session_id: openless_core::SessionId, - context: std::sync::Arc, - _partials: std::sync::Arc, - ) -> BoxFuture<'static, Result, BackendError>> { - let Some(executable) = self.executable.clone() else { - return Box::pin(async { - Err(BackendError::new( - BackendErrorCode::Unsupported, - "packaged Qwen ASR runtime is not available", - )) - }); - }; - let session: std::sync::Arc = - std::sync::Arc::new(LinuxGenericAsrSession { - model: context.asr.model.clone(), - root: std::sync::Arc::clone(&self.root), - pcm: std::sync::Mutex::new(Vec::new()), - cancelled: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - executable, - }); - Box::pin(async move { Ok(session) }) - } -} - -impl AudioConsumer for LinuxGenericAsrSession { - fn consume_pcm_chunk(&self, pcm: &[u8]) { - if !self.cancelled.load(std::sync::atomic::Ordering::Acquire) { - self.pcm - .lock() - .expect("Linux generic ASR PCM lock poisoned") - .extend_from_slice(pcm); - } - } -} - -impl TranscriptionSession for LinuxGenericAsrSession { - fn finish(&self) -> BoxFuture<'static, Result> { - let pcm = std::mem::take( - &mut *self - .pcm - .lock() - .expect("Linux generic ASR PCM lock poisoned"), - ); - let model = self.model.clone(); - let root = std::sync::Arc::clone(&self.root); - let cancelled = std::sync::Arc::clone(&self.cancelled); - let executable = self.executable.clone(); - Box::pin(async move { - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "ASR cancelled", - )); - } - let duration_ms = (pcm.len() as u64).saturating_mul(1000) / 32_000; - if pcm.is_empty() { - return Ok(TranscriptOutput { - text: String::new(), - duration_ms, - }); - } - let model = model - .filter(|model| !model.trim().is_empty()) - .ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidState, - "Qwen ASR model is not selected", - ) - })?; - let model_dir = root - .lock() - .expect("Linux ASR root lock poisoned") - .join(&model); - if !LinuxGenericLocalAsrRuntime::is_ready_dir(&model_dir) { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - format!("Linux local ASR model is not prepared: {model}"), - )); - } - let output = run_qwen_process( - executable, - vec![ - "--stdin".into(), - "--silent".into(), - "-d".into(), - model_dir.into_os_string(), - ], - Some(openless_core::encode_dictation_wav(&pcm)?), - Arc::clone(&cancelled), - QWEN_TRANSCRIBE_TIMEOUT, - ) - .await?; - if cancelled.load(std::sync::atomic::Ordering::Acquire) { - return Err(BackendError::new( - BackendErrorCode::Cancelled, - "ASR cancelled", - )); - } - if !output.status.success() { - let message = String::from_utf8_lossy(&output.stderr).trim().to_string(); - return Err(BackendError::new( - BackendErrorCode::Provider, - if message.is_empty() { - "Linux Generic/Qwen ASR failed".into() - } else { - message - }, - )); - } - let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(TranscriptOutput { - text: result, - duration_ms, - }) - }) - } - - fn cancel(&self) -> BoxFuture<'static, Result<(), BackendError>> { - self.cancelled - .store(true, std::sync::atomic::Ordering::Release); - self.pcm - .lock() - .expect("Linux generic ASR PCM lock poisoned") - .clear(); - Box::pin(async { Ok(()) }) - } -} - /// Assemble the non-UI Linux runtime from shared provider Interfaces. /// /// The egui team only supplies a repaint callback and consumes the returned @@ -677,7 +63,6 @@ pub struct LinuxBackendBuilder { services: Option, host_actions: Option>, settings_runtime: Option>, - local_asr_runtime: Option>, polish_failure_policy: PolishFailurePolicy, task_spawner: Option>, } @@ -701,10 +86,11 @@ impl LinuxBackendBuilder { // their isolation must also hold when linked against a production lib. if let Some(home_dir) = config.home_dir.as_deref() { if let Err(error) = store.migrate_legacy(home_dir) { - // A locked/unavailable Secret Service must not disable local ASR. - // The migration marker remains unset, so unlocking and restarting - // retries the original sources. Log only the classification, never - // a provider/keyring message that might contain secret values. + // A locked/unavailable Secret Service is not a reason to block + // construction: the migration marker remains unset, so unlocking + // and restarting retries the original sources. Log only the + // classification, never a provider/keyring message that might + // contain secret values. log::warn!( "Legacy credential migration is incomplete ({:?}); unlock the credential vault and restart to retry. Original credentials were retained.", error.code @@ -722,27 +108,8 @@ impl LinuxBackendBuilder { for provider_type in SHARED_CLOUD_ASR_PROVIDER_TYPES { transcription.register(*provider_type, Arc::clone(&cloud_transcription))?; } - let configured_models_root = - openless_core::PreferencesStore::open(config.data_dir.join("preferences.json")) - .ok() - .map(|preferences| preferences.get().local_asr_models_base_dir) - .filter(|base| !base.trim().is_empty()) - .map(std::path::PathBuf::from) - .filter(|base| base.is_absolute()) - .map(|base| base.join("OpenLess").join("models")) - .unwrap_or_else(LinuxGenericLocalAsrRuntime::default_root); - let qwen_executable = qwen_executable(); - let linux_local_runtime = Arc::new(LinuxGenericLocalAsrRuntime::from_models_root( - configured_models_root, - qwen_executable.clone(), - )); - let linux_local_asr: Arc = Arc::new(LinuxGenericAsrEngine { - root: Arc::clone(&linux_local_runtime.root), - executable: qwen_executable, - }); - for provider_id in ["local-qwen3", "local-qwen3-c"] { - transcription.register(provider_id, Arc::clone(&linux_local_asr))?; - } + // Linux has no local inference runtime: only shared cloud ASR providers + // are registered above, and local-ASR requests stay unsupported in Core. let polisher = Arc::new(TextPolisherRouter::default()); let cloud_polisher: Arc = Arc::new(SharedCloudTextPolisher::new(Arc::clone(&credential_store))); @@ -756,20 +123,6 @@ 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 - .lock() - .expect("Linux ASR root lock poisoned") - .clone(), - ) { - if let Ok(model_store) = ModelStore::new(model_config) { - services.configure_model_store(Arc::new(model_store)); - } - } services.provider = Arc::new(ProviderService::new( Arc::clone(&credential_store), Arc::clone(&task_spawner), @@ -783,7 +136,6 @@ impl LinuxBackendBuilder { .with_auxiliary_polisher(auxiliary_polisher) .with_credential_store(credential_store) .with_services(services) - .with_local_asr_runtime(linux_local_runtime) .with_marketplace_config(MarketplaceConfig::production()) .with_settings_runtime(Arc::new(LinuxSettingsRuntime::new(store)))) } @@ -807,7 +159,6 @@ impl LinuxBackendBuilder { services: None, host_actions: None, settings_runtime: None, - local_asr_runtime: None, polish_failure_policy: PolishFailurePolicy::UseRawText, task_spawner: None, } @@ -850,24 +201,11 @@ impl LinuxBackendBuilder { self } - pub fn with_host_actions(mut self, actions: Arc) -> Self { - self.host_actions = Some(actions); - self - } - pub fn with_settings_runtime(mut self, runtime: Arc) -> Self { self.settings_runtime = Some(runtime); self } - pub fn with_local_asr_runtime( - mut self, - runtime: Arc, - ) -> Self { - self.local_asr_runtime = Some(runtime); - self - } - pub fn with_polish_failure_policy(mut self, policy: PolishFailurePolicy) -> Self { self.polish_failure_policy = policy; self @@ -879,18 +217,11 @@ impl LinuxBackendBuilder { None => Arc::new(LinuxTaskSpawner::capture_current()?), }; let repositories = BackendRepositories::open(&self.config.data_dir)?; + let recordings_dir = self.config.data_dir.join("recordings"); let recorder = self.recorder.unwrap_or_else(|| { - Arc::new(LinuxCpalRecorder::with_recordings_dir( - None, - self.config.data_dir.join("recordings"), - )) as Arc + Arc::new(LinuxCpalRecorder::with_recordings_dir(None, recordings_dir)) + as Arc }); - let recorder: Arc = Arc::new(openless_core::AudioRecorderRouter::new( - recorder, - openless_core::ExternalAudioRecorder::with_recordings_directory( - self.config.data_dir.join("recordings"), - ), - )); let text_inserter = self .text_inserter .unwrap_or_else(|| Arc::new(Fcitx5TextInserter::new(true)) as Arc); @@ -908,13 +239,7 @@ impl LinuxBackendBuilder { } }; let settings_runtime = self.settings_runtime.unwrap_or(default_settings_runtime); - 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 - }); + let mut services = self.services.unwrap_or_else(BackendServices::unsupported); services.platform = Arc::new(LinuxPlatformApi::new(self.config.platform.clone())); let host_actions = self .host_actions @@ -960,10 +285,10 @@ impl LinuxBackendBuilder { task_spawner, credential_store, services, - local_asr_runtime: Some( - self.local_asr_runtime - .unwrap_or_else(|| Arc::new(LinuxGenericLocalAsrRuntime::default())), - ), + // Linux ships no local inference runtime. Leaving this unset + // makes every local-ASR call resolve to Core's unsupported + // adapter instead of fabricating a local capability. + local_asr_runtime: None, marketplace_config: self.marketplace_config, selection_runtime: Some(Arc::new(LinuxSelectionRuntime::new())), selection_polisher: Some(selection_polisher), @@ -987,7 +312,7 @@ mod tests { }; use openless_core::{ BackendErrorCode, CodingAgentProvider, CodingAgentTestRequest, InMemoryCredentialStore, - InsertOutcome, ModelRuntimeAdapter, ProviderKind, ProviderRequest, + InsertOutcome, ProviderKind, ProviderRequest, }; use super::*; @@ -1126,8 +451,8 @@ mod tests { .services() .provider .list_models(ProviderRequest { - thinking_enabled: false, kind: ProviderKind::Llm, + thinking_enabled: false, channel_id: None, }) .await @@ -1243,248 +568,12 @@ mod tests { } #[tokio::test] - async fn generic_local_asr_runtime_tracks_real_model_files_and_lifecycle() { - let root = std::env::temp_dir().join(format!( - "openless-linux-local-asr-{}", - uuid::Uuid::new_v4().simple() - )); - let models_root = root.join("OpenLess").join("models"); - let runtime = LinuxGenericLocalAsrRuntime::from_models_root( - models_root.clone(), - Some(std::env::current_exe().unwrap()), - ); - let store = openless_core::ModelStore::new( - openless_core::ModelStoreConfig::new(models_root.clone()).unwrap(), - ) - .unwrap(); - let target = openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::Generic, - "qwen3-asr-0.6b", - ) - .unwrap(); - let model_dir = models_root.join(target.model_id()); - std::fs::create_dir_all(&model_dir).unwrap(); - std::fs::write( - model_dir.join(LinuxGenericLocalAsrRuntime::READY_SENTINEL), - b"ready", - ) - .unwrap(); - std::fs::write(model_dir.join("weights.bin"), [1_u8, 2, 3]).unwrap(); - - let models = store - .list_models(openless_core::LocalAsrRuntime::Generic) - .unwrap(); - let model = models - .iter() - .find(|model| model.target.model_id() == target.model_id()) - .unwrap(); - assert!(model.installed); - assert!(model.downloaded_bytes >= 3); - assert_eq!( - runtime - .prepare( - target.clone(), - openless_core::FoundryRuntimeSource::Auto, - model_dir.clone(), - Arc::new(|_| {}), - ) - .await - .unwrap(), - target.model_id() - ); - assert!( - runtime - .runtime_status( - openless_core::LocalAsrSettings { - runtime: openless_core::LocalAsrRuntime::Generic, - provider_id: "local-qwen3".into(), - active_model: target.model_id().into(), - mirror: openless_core::LocalAsrMirror::Huggingface, - models_base_dir: Some(root.clone()), - models_root_dir: models_root.clone(), - engine_available: false, - language_hint: None, - runtime_source: None, - keep_loaded_secs: 0, - }, - model_dir.clone() - ) - .await - .unwrap() - .loaded - ); - let next_target = openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::Generic, - "qwen3-asr-1.7b", - ) - .unwrap(); - let next_model_dir = models_root.join(next_target.model_id()); - std::fs::create_dir_all(&next_model_dir).unwrap(); - std::fs::write( - next_model_dir.join(LinuxGenericLocalAsrRuntime::READY_SENTINEL), - b"ready", - ) - .unwrap(); - runtime - .prepare( - next_target.clone(), - openless_core::FoundryRuntimeSource::Auto, - next_model_dir, - Arc::new(|_| {}), - ) - .await - .unwrap(); - runtime - .release_lease(openless_core::LocalAsrRuntimeLease { - target: target.clone(), - generation: 1, - }) - .await - .unwrap(); - assert_eq!( - runtime.loaded_model.lock().unwrap().as_deref(), - Some(next_target.model_id()) - ); - runtime - .release(openless_core::LocalAsrRuntime::Generic) - .await - .unwrap(); - store.delete_model(&target).unwrap(); - assert!(!model_dir.exists()); - let _ = std::fs::remove_dir_all(root); - } - - #[tokio::test] - async fn missing_qwen_runtime_never_reports_a_prepared_model() { - let root = std::env::temp_dir().join(format!( - "openless-linux-missing-qwen-runtime-{}", - uuid::Uuid::new_v4().simple() - )); - let target = openless_core::LocalAsrTarget::parse( - openless_core::LocalAsrRuntime::Generic, - "qwen3-asr-0.6b", - ) - .unwrap(); - let model_dir = root.join(target.model_id()); - std::fs::create_dir_all(&model_dir).unwrap(); - std::fs::write( - model_dir.join(LinuxGenericLocalAsrRuntime::READY_SENTINEL), - b"ready", - ) - .unwrap(); - let runtime = LinuxGenericLocalAsrRuntime::from_models_root(root.clone(), None); - - let error = runtime - .prepare( - target, - openless_core::FoundryRuntimeSource::Auto, - model_dir, - Arc::new(|_| {}), - ) - .await - .expect_err("model files alone must not fake a loaded runtime"); - - assert_eq!(error.code, BackendErrorCode::Unsupported); - assert!(runtime.loaded_model.lock().unwrap().is_none()); - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(target_os = "linux")] - async fn wait_for_pid(path: &std::path::Path) -> i32 { - for _ in 0..100 { - if let Ok(value) = std::fs::read_to_string(path) { - if let Ok(pid) = value.trim().parse() { - return pid; - } - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("fixture child PID was not written"); - } - - #[cfg(target_os = "linux")] - async fn assert_process_exited(pid: i32) { - for _ in 0..100 { - // SAFETY: signal 0 only checks whether the fixture process still exists. - if unsafe { libc::kill(pid, 0) } == -1 { - return; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - panic!("fixture child process {pid} survived"); - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn qwen_runtime_cancellation_kills_the_process_group() { - let root = std::env::temp_dir().join(format!( - "openless-qwen-cancel-{}", - uuid::Uuid::new_v4().simple() - )); - std::fs::create_dir_all(&root).unwrap(); - let pid_file = root.join("pid"); - let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let task = tokio::spawn(run_qwen_process( - std::path::PathBuf::from("/bin/sh"), - vec![ - "-c".into(), - format!("sleep 30 & echo $! > '{}'; wait", pid_file.display()).into(), - ], - None, - Arc::clone(&cancelled), - std::time::Duration::from_secs(30), - )); - let child_pid = wait_for_pid(&pid_file).await; - cancelled.store(true, std::sync::atomic::Ordering::Release); - - let error = task.await.unwrap().unwrap_err(); - assert_eq!(error.code, BackendErrorCode::Cancelled); - assert_process_exited(child_pid).await; - let _ = std::fs::remove_dir_all(root); - } - - #[cfg(target_os = "linux")] - #[tokio::test] - async fn qwen_runtime_timeout_kills_the_process_group() { - let root = std::env::temp_dir().join(format!( - "openless-qwen-timeout-{}", - uuid::Uuid::new_v4().simple() - )); - std::fs::create_dir_all(&root).unwrap(); - let pid_file = root.join("pid"); - - let error = run_qwen_process( - std::path::PathBuf::from("/bin/sh"), - vec![ - "-c".into(), - format!("sleep 30 & echo $! > '{}'; wait", pid_file.display()).into(), - ], - None, - Arc::new(std::sync::atomic::AtomicBool::new(false)), - std::time::Duration::from_secs(2), - ) - .await - .unwrap_err(); - - assert!(error.message.contains("timed out")); - assert_process_exited(wait_for_pid(&pid_file).await).await; - let _ = std::fs::remove_dir_all(root); - } - - #[tokio::test] - async fn shared_builder_restores_custom_model_root_and_filters_unsupported_models() { + async fn shared_provider_build_reports_local_asr_unsupported() { let data_dir = std::env::temp_dir().join(format!( - "openless-linux-custom-model-root-{}", + "openless-linux-local-asr-unsupported-{}-{}", + std::process::id(), uuid::Uuid::new_v4().simple() )); - let custom = data_dir.join("external"); - std::fs::create_dir_all(&data_dir).unwrap(); - let preferences = - openless_core::PreferencesStore::open(data_dir.join("preferences.json")).unwrap(); - let mut value = preferences.get(); - value.local_asr_models_base_dir = custom.to_string_lossy().into_owned(); - preferences.set(value).unwrap(); - let runtime = LinuxBackendBuilder::from_shared_providers(BackendConfig { data_dir: data_dir.clone(), ..BackendConfig::default() @@ -1492,26 +581,17 @@ mod tests { .unwrap() .build() .unwrap(); - let storage = runtime - .backend - .services() - .local_asr - .storage_settings() - .await - .unwrap(); - assert_eq!( - storage.models_root_dir, - custom.join("OpenLess").join("models") - ); - let models = runtime + + // Linux ships no local inference runtime: Core must answer Unsupported + // rather than fabricate a Generic/Qwen capability. + let error = runtime .backend .services() .local_asr - .list_models(openless_core::LocalAsrRuntime::Generic) + .runtime_status(openless_core::LocalAsrRuntime::Generic) .await - .unwrap(); - assert_eq!(models.len(), 2); - assert!(models.iter().all(|model| model.family == "qwen3")); + .expect_err("Linux must not advertise a local ASR engine"); + assert_eq!(error.code, BackendErrorCode::Unsupported); let _ = std::fs::remove_dir_all(data_dir); } diff --git a/openless-all/app/linux-egui/src/capabilities.rs b/openless-all/app/linux-egui/src/capabilities.rs index 15fdf6ffb..68bda6223 100644 --- a/openless-all/app/linux-egui/src/capabilities.rs +++ b/openless-all/app/linux-egui/src/capabilities.rs @@ -6,7 +6,7 @@ use openless_core::{ PlatformCapabilities, }; -use crate::{fcitx5_available, LinuxPackageKind}; +use crate::fcitx5_available; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LinuxDesktopSession { @@ -29,7 +29,6 @@ impl LinuxCapabilitySnapshot { x11_display: Option<&str>, fcitx5_ready: bool, tray_available: bool, - package_kind: LinuxPackageKind, ) -> Self { let session = if wayland_display.is_some_and(|value| !value.trim().is_empty()) { LinuxDesktopSession::Wayland @@ -48,10 +47,16 @@ impl LinuxCapabilitySnapshot { supports_tray: desktop && tray_available, supports_overlay: session == LinuxDesktopSession::X11, supports_ime_input: desktop && fcitx5_ready, - supports_local_asr: desktop, + // Linux ships no local inference engine (Generic/Qwen, MLX or + // Foundry). Report false on every desktop session so the UI and + // downstream gate on the honest answer. + supports_local_asr: false, supports_local_qwen3_mlx: false, supports_in_app_dictation: false, - supports_auto_update: package_kind == LinuxPackageKind::AppImage, + // Linux ships deb/rpm only (no AppImage, no updater manifest), + // so the host can never replace its own package. Always false — + // the UI gates every update control on it. + supports_auto_update: false, }, permissions: PermissionSnapshot { microphone: if desktop { @@ -64,7 +69,7 @@ impl LinuxCapabilitySnapshot { } } - pub fn detect(tray_available: bool, package_kind: LinuxPackageKind) -> Self { + pub fn detect(tray_available: bool) -> Self { let wayland = std::env::var("WAYLAND_DISPLAY").ok(); let x11 = std::env::var("DISPLAY").ok(); Self::from_environment( @@ -72,7 +77,6 @@ impl LinuxCapabilitySnapshot { x11.as_deref(), fcitx5_available(), tray_available, - package_kind, ) } } @@ -192,7 +196,7 @@ impl PlatformApi for LinuxPlatformApi { #[cfg(target_os = "linux")] fn enumerate_microphones() -> Result, BackendError> { - use cpal::traits::HostTrait; + use cpal::traits::{DeviceTrait, HostTrait}; let host = cpal::default_host(); let default_name = host.default_input_device().map(|device| device.to_string()); @@ -202,17 +206,91 @@ fn enumerate_microphones() -> Result, BackendError> { format!("failed to enumerate Linux microphones: {error}"), ) })?; - devices - .enumerate() - .map(|(index, device)| { - let name = device.to_string(); - Ok(MicrophoneDevice { - id: format!("cpal:{index}:{name}"), - is_default: default_name.as_deref() == Some(name.as_str()), - name, - }) - }) - .collect() + let mut out: Vec = Vec::new(); + for (index, device) in devices.enumerate() { + let name = device.to_string(); + let channels = device + .default_input_config() + .map(|config| config.channels()) + .unwrap_or(0); + if !selectable_microphone(&name, channels) { + continue; + } + // 同一个设备会以多条节点出现(例如「内置音频 Pro」出现两次)。 + if out.iter().any(|existing| existing.name == name) { + continue; + } + out.push(MicrophoneDevice { + id: format!("cpal:{index}:{name}"), + is_default: default_name.as_deref() == Some(name.as_str()), + name, + }); + } + Ok(out) +} + +/// cpal 的 PipeWire 后端把**所有节点**都当成输入设备:输出监听(`sink-*`、 +/// `*.monitor`)、合成默认节点、以及 HDMI/环绕监听都会出现在 `input_devices()` 里, +/// 同名设备还会重复出现。原样透传会让设置页的「首选麦克风」变成一个装满噪声的 +/// 下拉框(用户反馈「没有首选麦克风功能」),所以这里按可识别特征过滤: +/// +/// * 合成节点(`default_input` / `default_sink` / `unknown`)——界面里已经有 +/// 「系统默认」这一项; +/// * PulseAudio/PipeWire 的 sink 与 monitor 命名; +/// * 声道数 > 2 的节点(HDMI 与环绕监听),真实麦克风是 1 或 2 声道。取不到配置时 +/// 保留(0 声道),宁可多列也不误删用户真正的麦克风。 +#[cfg(target_os = "linux")] +fn selectable_microphone(name: &str, channels: u16) -> bool { + let lowered = name.trim().to_ascii_lowercase(); + if lowered.is_empty() || lowered == "unknown" { + return false; + } + if lowered == "default_input" || lowered == "default_sink" { + return false; + } + if lowered.starts_with("sink-") || lowered.starts_with("sink_") || lowered.ends_with(".monitor") + { + return false; + } + channels <= 2 +} + +#[cfg(all(test, target_os = "linux"))] +mod microphone_filter_tests { + use super::selectable_microphone; + + #[test] + fn non_capture_nodes_are_not_offered_as_microphones() { + // 实测(本机 PipeWire):这 5 个都出现在 cpal 的 input_devices() 里。 + for junk in [ + "default_sink", + "default_input", + "sink-sunshine-stereo", + "sink-sunshine-surround71", + "unknown", + ] { + assert!( + !selectable_microphone(junk, 2), + "{junk} is not a selectable microphone" + ); + } + // HDMI/环绕监听是 6/8 声道。 + assert!(!selectable_microphone( + "TU116 High Definition Audio Controller Pro 9", + 8 + )); + assert!(!selectable_microphone( + "TU116 High Definition Audio Controller Pro", + 6 + )); + // 真实设备保留(含非 ASCII 描述名与取不到配置的兜底)。 + assert!(selectable_microphone( + "\u{5185}\u{7f6e}\u{97f3}\u{9891} Pro", + 2 + )); + assert!(selectable_microphone("USB microphone", 1)); + assert!(selectable_microphone("Scarlett Solo", 0)); + } } #[cfg(test)] @@ -221,24 +299,15 @@ mod tests { #[test] fn x11_and_wayland_have_explicitly_different_overlay_capabilities() { - let x11 = LinuxCapabilitySnapshot::from_environment( - None, - Some(":0"), - true, - true, - LinuxPackageKind::AppImage, - ); + let x11 = LinuxCapabilitySnapshot::from_environment(None, Some(":0"), true, true); assert_eq!(x11.session, LinuxDesktopSession::X11); assert!(x11.capabilities.supports_overlay); - assert!(x11.capabilities.supports_auto_update); + assert!(!x11.capabilities.supports_local_asr); + // deb/rpm 是本平台唯一的发布格式:宿主永远不能替换自己。 + assert!(!x11.capabilities.supports_auto_update); - let wayland = LinuxCapabilitySnapshot::from_environment( - Some("wayland-0"), - Some(":0"), - false, - false, - LinuxPackageKind::SystemPackage, - ); + let wayland = + LinuxCapabilitySnapshot::from_environment(Some("wayland-0"), Some(":0"), false, false); assert_eq!(wayland.session, LinuxDesktopSession::Wayland); assert!(!wayland.capabilities.supports_overlay); assert!(!wayland.capabilities.supports_desktop_hotkey); @@ -247,13 +316,7 @@ mod tests { #[test] fn headless_session_does_not_claim_desktop_or_microphone_support() { - let snapshot = LinuxCapabilitySnapshot::from_environment( - None, - None, - false, - false, - LinuxPackageKind::Development, - ); + let snapshot = LinuxCapabilitySnapshot::from_environment(None, None, false, false); assert_eq!(snapshot.session, LinuxDesktopSession::Headless); assert!(!snapshot.capabilities.supports_local_asr); assert_eq!( diff --git a/openless-all/app/linux-egui/src/coding_agent.rs b/openless-all/app/linux-egui/src/coding_agent.rs index 9de3f13f3..75598f4f6 100644 --- a/openless-all/app/linux-egui/src/coding_agent.rs +++ b/openless-all/app/linux-egui/src/coding_agent.rs @@ -124,13 +124,6 @@ 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, @@ -335,29 +328,19 @@ mod tests { cancel_task.await.unwrap(); let pids = std::fs::read_to_string(&ready).expect("child must actually start"); let _ = std::fs::remove_file(&ready); - let mut running = Vec::new(); - for pid in pids.split_whitespace() { - 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 { - libc::kill(pid.parse().unwrap(), libc::SIGKILL); - } + // SIGKILL is delivered immediately but the kernel retires the process + // (and its orphaned children) asynchronously, so give the group a + // bounded moment to disappear before calling the cancellation broken. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let mut running = alive_pids(&pids); + while !running.is_empty() && std::time::Instant::now() < deadline { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + running = alive_pids(&pids); + } + // Only fixture PIDs read from our private ready file are killed. + for pid in &running { + unsafe { + libc::kill(pid.parse().unwrap(), libc::SIGKILL); } } let result = result.expect("cancelled child must exit promptly").unwrap(); @@ -367,4 +350,20 @@ mod tests { "cancelled process group is still running: {running:?}" ); } + + /// Fixture PIDs that are still alive (a zombie is already dead). + fn alive_pids(pids: &str) -> Vec { + pids.split_whitespace() + .filter(|pid| { + std::fs::read_to_string(format!("/proc/{pid}/stat")) + .ok() + .and_then(|stat| { + stat.rsplit_once(") ") + .map(|(_, rest)| !rest.starts_with('Z')) + }) + .unwrap_or(false) + }) + .map(str::to_owned) + .collect() + } } diff --git a/openless-all/app/linux-egui/src/context.rs b/openless-all/app/linux-egui/src/context.rs deleted file mode 100644 index acb3060fc..000000000 --- a/openless-all/app/linux-egui/src/context.rs +++ /dev/null @@ -1,298 +0,0 @@ -//! 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/design_tokens.rs b/openless-all/app/linux-egui/src/design_tokens.rs deleted file mode 100644 index cc2431073..000000000 --- a/openless-all/app/linux-egui/src/design_tokens.rs +++ /dev/null @@ -1,230 +0,0 @@ -//! 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_bridge.rs b/openless-all/app/linux-egui/src/desktop_bridge.rs deleted file mode 100644 index fb4c12977..000000000 --- a/openless-all/app/linux-egui/src/desktop_bridge.rs +++ /dev/null @@ -1,491 +0,0 @@ -//! 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/dictation_feedback.rs b/openless-all/app/linux-egui/src/dictation_feedback.rs new file mode 100644 index 000000000..2500dee48 --- /dev/null +++ b/openless-all/app/linux-egui/src/dictation_feedback.rs @@ -0,0 +1,416 @@ +//! 听写终态:给用户看的分类,以及胶囊何时自动收起。 +//! +//! Core 的 `mark_dictation_failed` 只把错误码写进 `message` +//! (`format!("{:?}", error.code)`,例如 `InvalidArgument`),那是日志用语: +//! 直接送进胶囊/状态栏既看不懂,也像是宿主崩了。说话时正常、不说话就报这个, +//! 因为空音频在 Core 里就是 `InvalidArgument`(`providers.rs` 的 +//! "recording contains no audio")。 +//! +//! 这里把「相位 + message」归一成可本地化的分类,并把 Tauri Host 的收起时序 +//! (成功/失败 2 秒、取消立即、进行中不收)固化成纯函数,宿主与单测共用同一份 +//! 规则,避免两边各写一套。 + +use std::time::Duration; + +use openless_core::{BackendError, BackendErrorCode, DictationPhase}; + +/// Tauri `coordinator.rs::CAPSULE_AUTO_HIDE_DELAY_MS`:终态在屏上的停留时长。 +pub const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000; + +/// Core 在失败终态里塞进 `message` 的错误码名,一律不能当用户文案。 +pub fn is_backend_error_code(message: &str) -> bool { + matches!( + message.trim(), + "InvalidArgument" + | "InvalidState" + | "Busy" + | "Cancelled" + | "PermissionDenied" + | "Unsupported" + | "Provider" + | "Persistence" + | "Platform" + | "OutcomeUnknown" + | "Internal" + ) +} + +/// 胶囊这一帧该显示什么;具体文案由宿主用本地化 key 渲染。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CapsuleOutcome { + /// 成功:宿主显示「已插入 N」。 + Inserted, + Cancelled, + Failed, + /// 进行中:原样转发 Core 的 message(通常是空串)。 + Progress(String), +} + +/// 相位 + message → 胶囊分类。绝不把内部错误码当文案。 +pub fn capsule_outcome(phase: DictationPhase, message: Option<&str>) -> CapsuleOutcome { + let message = message.unwrap_or("").trim(); + match phase { + DictationPhase::Completed => CapsuleOutcome::Inserted, + DictationPhase::Cancelled => CapsuleOutcome::Cancelled, + // Core 的失败 message 永远是错误码名,交给宿主显示本地化文案。 + DictationPhase::Failed => CapsuleOutcome::Failed, + _ => { + if message.is_empty() || message == "inserted" || is_backend_error_code(message) { + CapsuleOutcome::Progress(String::new()) + } else { + CapsuleOutcome::Progress(message.to_string()) + } + } + } +} + +/// 终态收起延时:成功/失败 2 秒、取消立即;进行中(含 `Inserting`)不收起。 +pub fn capsule_hide_delay(phase: DictationPhase) -> Option { + match phase { + DictationPhase::Completed | DictationPhase::Failed => { + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + } + DictationPhase::Cancelled => Some(Duration::ZERO), + _ => None, + } +} + +/// 延时到点时是否还该收起。 +/// +/// 判据是「这个会话已经不再进行」而不是「快照仍停在终态」:Core 在终态事件之后 +/// **立刻** `reset_dictation_session()`(见 `api.rs` 的 +/// `mark_dictation_failed(..); reset_dictation_session(..); return Err(..)`), +/// 整个 `DictationStateSnapshot` 被重置成 `Idle` 且 `session_id` 清空。 +/// 所以 2 秒后回看快照,`current_session` 是 `None` —— 早先要求 +/// `current_session == Some(scheduled)` 的写法让**报错路径永远收不回胶囊**。 +/// +/// 保留的防护:用户在这 2 秒里又按了录音 → 快照里是**另一个**会话 id → 不收; +/// 同一会话又回到进行中相位(理论上不会,兜底)→ 不收。 +pub fn capsule_hide_is_still_current( + current_session: Option<&str>, + scheduled_session: &str, + phase: DictationPhase, +) -> bool { + match current_session { + // Core 已经收尾(终态后重置,或根本没有会话语义)→ 正是该收起的时候。 + None => true, + Some(current) if current == scheduled_session => !phase_shows_capsule(phase), + // 另一个会话正在进行 → 不能把新胶囊一起关掉。 + Some(_) => false, + } +} + +/// 快照里会话已经消失、而宿主从未安排收起时,该补收的会话 id。 +/// +/// Core 只有**部分**错误路径会先 `mark_dictation_failed`(发布终态事件)再 reset; +/// 另一些(例如转写阶段的空音频)**只 reset 不发布**,宿主就永远等不到终态, +/// 药丸会一直贴在屏幕上。这里按快照自身判断「会话已经没了」,补一次收起。 +/// +/// 返回 `Some(session)` = 该为这个会话安排收起;`None` = 什么都不用做。 +pub fn capsule_needs_fallback_dismissal( + capsule_session: Option<&str>, + live_session: Option<&str>, + already_scheduled: Option<&str>, +) -> Option { + let capsule_session = capsule_session?; + // 同一会话仍在跑:等它自己的终态。 + if live_session == Some(capsule_session) { + return None; + } + // 已经为它安排过收起(事件路径已经处理):别重复计时。 + if already_scheduled == Some(capsule_session) { + return None; + } + Some(capsule_session.to_string()) +} + +/// 这个相位是否需要胶囊在屏幕上:只有进行中的相位才该按需拉起弹窗。 +/// +/// 终态不再拉起——否则一个迟到的终态事件会把刚刚自动收起的药丸又喊回来; +/// `Idle` 也不拉(它没有内容可显示)。 +pub fn phase_shows_capsule(phase: DictationPhase) -> bool { + matches!( + phase, + DictationPhase::Starting + | DictationPhase::Recording + | DictationPhase::Transcribing + | DictationPhase::Polishing + | DictationPhase::Inserting + ) +} + +/// 停止/取消听写时「本来就可能发生」的错误,不该弹成失败: +/// `InvalidArgument` = 没录到音频(没说话、麦克风没出声); +/// `InvalidState`/`Busy` = 会话已经收尾(连点两次、自动停止与手动停止撞车); +/// `Cancelled` = 用户自己取消。 +pub fn is_expected_stop_error(code: BackendErrorCode) -> bool { + matches!( + code, + BackendErrorCode::InvalidArgument + | BackendErrorCode::InvalidState + | BackendErrorCode::Busy + | BackendErrorCode::Cancelled + ) +} + +/// 归一化一次停止/取消听写的结果:预期内的错误当作「没有结果」而不是失败。 +/// +/// 真正的失败(网络、鉴权、持久化…)仍然原样向上报。 +pub fn normalize_stop_result( + result: Result, +) -> Result, BackendError> { + match result { + Ok(value) => Ok(Some(value)), + Err(error) if is_expected_stop_error(error.code) => Ok(None), + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn terminal_timing_matches_the_tauri_host_contract() { + assert_eq!( + capsule_hide_delay(DictationPhase::Completed), + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + ); + assert_eq!( + capsule_hide_delay(DictationPhase::Failed), + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + ); + assert_eq!( + capsule_hide_delay(DictationPhase::Cancelled), + Some(Duration::ZERO) + ); + for phase in [ + DictationPhase::Idle, + DictationPhase::Starting, + DictationPhase::Recording, + DictationPhase::Transcribing, + DictationPhase::Polishing, + DictationPhase::Inserting, + ] { + assert_eq!(capsule_hide_delay(phase), None, "{phase:?} must stay up"); + } + } + + #[test] + fn silence_failure_is_a_normal_terminal_state() { + // 没说话 → Core 报 InvalidArgument(空音频):算终态、2 秒后收起。 + let phase = DictationPhase::Failed; + assert_eq!( + capsule_outcome(phase, Some("InvalidArgument")), + CapsuleOutcome::Failed + ); + assert_eq!( + capsule_hide_delay(phase), + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + ); + assert!(is_expected_stop_error(BackendErrorCode::InvalidArgument)); + } + + #[test] + fn backend_error_codes_never_become_capsule_copy() { + for code in [ + "InvalidArgument", + "InvalidState", + "Busy", + "Cancelled", + "PermissionDenied", + "Unsupported", + "Provider", + "Persistence", + "Platform", + "OutcomeUnknown", + "Internal", + ] { + assert!(is_backend_error_code(code), "{code} is an internal code"); + assert_eq!( + capsule_outcome(DictationPhase::Transcribing, Some(code)), + CapsuleOutcome::Progress(String::new()), + "{code} must not reach the capsule" + ); + } + assert!(!is_backend_error_code("12 characters")); + assert!(!is_backend_error_code("")); + } + + #[test] + fn progress_messages_still_pass_through() { + assert_eq!( + capsule_outcome(DictationPhase::Transcribing, Some("12 characters")), + CapsuleOutcome::Progress("12 characters".to_string()) + ); + // Core 成功时塞的内部词同样不显示。 + assert_eq!( + capsule_outcome(DictationPhase::Polishing, Some("inserted")), + CapsuleOutcome::Progress(String::new()) + ); + assert_eq!( + capsule_outcome(DictationPhase::Recording, None), + CapsuleOutcome::Progress(String::new()) + ); + } + + #[test] + fn a_new_session_cancels_the_pending_dismissal() { + assert!(capsule_hide_is_still_current( + Some("s1"), + "s1", + DictationPhase::Completed + )); + // 另一个会话(用户已经又按了录音)→ 不能收起新胶囊。 + assert!(!capsule_hide_is_still_current( + Some("s2"), + "s1", + DictationPhase::Completed + )); + // 同一会话又回到进行中相位 → 也不收。 + assert!(!capsule_hide_is_still_current( + Some("s1"), + "s1", + DictationPhase::Recording + )); + } + + #[test] + fn a_session_that_vanishes_without_a_terminal_event_still_hides_the_capsule() { + // Core 有些错误路径只 reset、不发布终态事件(转写阶段空音频就是), + // 宿主必须自己发现「胶囊的会话已经不在快照里」并补一次收起。 + assert_eq!( + capsule_needs_fallback_dismissal(Some("s1"), None, None), + Some("s1".to_string()) + ); + // 会话仍在跑 → 等它自己的终态。 + assert_eq!( + capsule_needs_fallback_dismissal(Some("s1"), Some("s1"), None), + None + ); + // 事件路径已经安排过 → 不重复计时。 + assert_eq!( + capsule_needs_fallback_dismissal(Some("s1"), None, Some("s1")), + None + ); + // 新会话顶掉了旧会话(旧胶囊复用同一进程)→ 旧会话该收。 + assert_eq!( + capsule_needs_fallback_dismissal(Some("s1"), Some("s2"), None), + Some("s1".to_string()) + ); + // 根本没有胶囊在屏上 → 什么都不做。 + assert_eq!(capsule_needs_fallback_dismissal(None, None, None), None); + } + + #[test] + fn a_vanished_session_hides_the_capsule_through_the_fallback_path() { + // 兜底链路的端到端判据:会话消失(无终态事件)→ 取兜底会话 → 按失败终态 + // 的时长 → 到点时判据为真 → 收起。 + let session = capsule_needs_fallback_dismissal(Some("s1"), None, None) + .expect("a vanished session must be picked up"); + assert_eq!( + capsule_hide_delay(DictationPhase::Failed), + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + ); + assert!(capsule_hide_is_still_current( + None, + &session, + DictationPhase::Idle + )); + } + + #[test] + fn the_failure_path_ends_with_a_dismissal() { + // 串起报错路径的四环(不看实现,看行为): + // 1) 失败是终态 → 2 秒后收起; + // 2) 这 2 秒里 Core 已经把快照 reset 成 Idle、session_id 清空; + // 3) 到点时的判据必须为真 → 真的收起; + // 4) 但若这 2 秒里用户又按了录音(新会话)→ 不收,新胶囊活着。 + assert_eq!( + capsule_hide_delay(DictationPhase::Failed), + Some(Duration::from_millis(CAPSULE_AUTO_HIDE_DELAY_MS)) + ); + assert!(capsule_hide_is_still_current( + None, + "session", + DictationPhase::Idle + )); + assert!(!capsule_hide_is_still_current( + Some("next"), + "session", + DictationPhase::Starting + )); + } + + #[test] + fn the_core_reset_after_a_terminal_phase_still_dismisses_the_capsule() { + // 真实链路:Core 的失败路径是 + // `mark_dictation_failed(..); reset_dictation_session(..); return Err(..)`, + // 后者把整个快照重置成 Idle 并清空 session_id。2 秒后回看快照只剩 + // `None` —— 这正是「报错弹窗收不回」的原因,必须仍然收起。 + for phase in [ + DictationPhase::Idle, + DictationPhase::Completed, + DictationPhase::Failed, + DictationPhase::Cancelled, + ] { + assert!( + capsule_hide_is_still_current(None, "s1", phase), + "a finished session ({phase:?}) must still hide the capsule" + ); + // 同一 id 但相位已经落回 Idle:同样属于「不再进行」。 + assert!(capsule_hide_is_still_current(Some("s1"), "s1", phase)); + } + } + + #[test] + fn only_in_flight_phases_spawn_a_capsule() { + for phase in [ + DictationPhase::Starting, + DictationPhase::Recording, + DictationPhase::Transcribing, + DictationPhase::Polishing, + DictationPhase::Inserting, + ] { + assert!( + phase_shows_capsule(phase), + "{phase:?} must be able to spawn" + ); + } + for phase in [ + DictationPhase::Idle, + DictationPhase::Completed, + DictationPhase::Cancelled, + DictationPhase::Failed, + ] { + assert!(!phase_shows_capsule(phase), "{phase:?} must not re-spawn"); + } + } + + #[test] + fn expected_stop_errors_do_not_become_failures() { + for code in [ + BackendErrorCode::InvalidArgument, + BackendErrorCode::InvalidState, + BackendErrorCode::Busy, + BackendErrorCode::Cancelled, + ] { + assert!( + normalize_stop_result::<()>(Err(BackendError::new(code, "boom"))) + .unwrap() + .is_none(), + "{code:?} is an expected stop outcome" + ); + } + let real = normalize_stop_result::<()>(Err(BackendError::new( + BackendErrorCode::Provider, + "upstream 500", + ))); + assert_eq!(real.unwrap_err().code, BackendErrorCode::Provider); + assert_eq!( + normalize_stop_result(Ok(7)).unwrap(), + Some(7), + "successful stops keep their value" + ); + } +} diff --git a/openless-all/app/linux-egui/src/fcitx5.rs b/openless-all/app/linux-egui/src/fcitx5.rs index a03006c3c..b18f04427 100644 --- a/openless-all/app/linux-egui/src/fcitx5.rs +++ b/openless-all/app/linux-egui/src/fcitx5.rs @@ -8,8 +8,7 @@ use openless_core::{ TextInsertionSession, }; -use crate::{LinuxPackageKind, LinuxResourceLayout, FCITX_PLUGIN_CONFIG, FCITX_PLUGIN_LIBRARY}; -use openless_core::ResourceResolver; +use crate::LinuxResourceLayout; #[cfg(target_os = "linux")] pub(crate) const DESTINATION: &str = "org.fcitx.Fcitx5"; @@ -19,38 +18,31 @@ pub(crate) const OBJECT_PATH: &str = "/openless"; pub(crate) const INTERFACE: &str = "org.fcitx.Fcitx.OpenLess1"; #[cfg(target_os = "linux")] const TIMEOUT: Duration = Duration::from_secs(3); +/// fcitx5's own management interface. `Restart` makes the daemon replace +/// itself in place: the call returns immediately and nothing of ours is +/// inherited, unlike the `fcitx5 -r` command (see `reload_running_fcitx5`). +#[cfg(target_os = "linux")] +pub(crate) const CONTROLLER_PATH: &str = "/controller"; +#[cfg(target_os = "linux")] +pub(crate) const CONTROLLER_INTERFACE: &str = "org.fcitx.Fcitx.Controller1"; +#[cfg(target_os = "linux")] +const CONTROLLER_TIMEOUT: Duration = Duration::from_secs(2); #[derive(Debug, Clone, PartialEq, Eq)] pub struct FcitxPluginInstallPlan { - pub source_library: Option, - pub source_config: Option, pub target_library: PathBuf, pub target_config: PathBuf, - pub copy_required: bool, } impl FcitxPluginInstallPlan { pub fn for_layout(layout: &LinuxResourceLayout, home: &Path) -> Result { let target_library = home.join(".local/lib/fcitx5/libopenless.so"); let target_config = home.join(".local/share/fcitx5/addon/openless.conf"); - if layout.package_kind == LinuxPackageKind::AppImage { - let resolver = layout.resolver()?; - Ok(Self { - source_library: Some(resolver.resolve(Path::new(FCITX_PLUGIN_LIBRARY))?), - source_config: Some(resolver.resolve(Path::new(FCITX_PLUGIN_CONFIG))?), - target_library, - target_config, - copy_required: true, - }) - } else { - Ok(Self { - source_library: None, - source_config: None, - target_library, - target_config, - copy_required: false, - }) - } + let _ = layout; + Ok(Self { + target_library, + target_config, + }) } } @@ -58,137 +50,29 @@ impl FcitxPluginInstallPlan { pub enum FcitxPluginStatus { Ready, Missing, - Updated, } -pub fn ensure_plugin_installed( - plan: &FcitxPluginInstallPlan, -) -> Result { - if !plan.copy_required { - return if system_plugin_available() || user_plugin_available(plan) { - Ok(FcitxPluginStatus::Ready) - } else { - Ok(FcitxPluginStatus::Missing) - }; - } - let source_library = plan.source_library.as_ref().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "AppImage plugin plan is missing the bundled library", - ) - })?; - let source_config = plan.source_config.as_ref().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "AppImage plugin plan is missing the bundled config", - ) - })?; - let library = read_non_empty(source_library)?; - let config = read_non_empty(source_config)?; - let library_changed = target_differs(&plan.target_library, &library)?; - let config_changed = target_differs(&plan.target_config, &config)?; - if !library_changed && !config_changed { - return Ok(FcitxPluginStatus::Ready); - } - if library_changed { - atomic_write(&plan.target_library, &library, true)?; - } - if config_changed { - atomic_write(&plan.target_config, &config, false)?; - } - Ok(FcitxPluginStatus::Updated) -} - -fn read_non_empty(path: &Path) -> Result, BackendError> { - let bytes = std::fs::read(path).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to read fcitx5 resource {}: {error}", path.display()), - ) - })?; - if bytes.is_empty() { - return Err(BackendError::new( - BackendErrorCode::Platform, - format!("fcitx5 resource {} is empty", path.display()), - )); - } - Ok(bytes) -} - -fn target_differs(path: &Path, expected: &[u8]) -> Result { - match std::fs::read(path) { - Ok(actual) => Ok(actual != expected), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(true), - Err(error) => Err(BackendError::new( - BackendErrorCode::Platform, - format!( - "failed to read existing fcitx5 file {}: {error}", - path.display() - ), - )), +#[cfg(any(target_os = "linux", test))] +impl FcitxPluginStatus { + fn require_ready(self) -> Result<(), BackendError> { + match self { + Self::Ready => Ok(()), + Self::Missing => Err(BackendError::new( + BackendErrorCode::Platform, + "OpenLess fcitx5 plugin is missing; reinstall the OpenLess package and enable its addon", + )), + } } } -fn atomic_write(path: &Path, bytes: &[u8], executable: bool) -> Result<(), BackendError> { - let parent = path.parent().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "fcitx5 target has no parent directory", - ) - })?; - std::fs::create_dir_all(parent).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to create fcitx5 target directory: {error}"), - ) - })?; - let temporary = parent.join(format!( - ".{}.{}.tmp", - path.file_name() - .and_then(|name| name.to_str()) - .unwrap_or("openless"), - std::process::id() - )); - std::fs::write(&temporary, bytes).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to stage fcitx5 resource: {error}"), - ) - })?; - #[cfg(unix)] - if executable { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o755)).map_err( - |error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to set fcitx5 plugin permissions: {error}"), - ) - }, - )?; - } - #[cfg(not(unix))] - let _ = executable; - // POSIX rename replaces an existing file atomically, so the previously - // working plugin remains available if staging or commit fails. The - // non-Unix branch exists only for portable unit tests/tooling, where the - // platform rename API may require explicitly removing the destination. - #[cfg(not(unix))] - if path.exists() { - std::fs::remove_file(path).map_err(|error| { - BackendError::new( - BackendErrorCode::Platform, - format!("failed to replace fcitx5 resource: {error}"), - ) - })?; +pub fn ensure_plugin_installed( + plan: &FcitxPluginInstallPlan, +) -> Result { + if system_plugin_available() || user_plugin_available(plan) { + Ok(FcitxPluginStatus::Ready) + } else { + Ok(FcitxPluginStatus::Missing) } - std::fs::rename(&temporary, path).map_err(|error| { - let _ = std::fs::remove_file(&temporary); - BackendError::new( - BackendErrorCode::Platform, - format!("failed to commit fcitx5 resource: {error}"), - ) - }) } fn user_plugin_available(plan: &FcitxPluginInstallPlan) -> bool { @@ -223,6 +107,385 @@ fn system_plugin_available() -> bool { .any(|dir| dir.join("libopenless.so").is_file()) } +/// Path of the installed addon library the running fcitx5 would load: the user +/// install wins over the system one because fcitx5 searches it first. +/// Which copy of the addon fcitx5 will actually load. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PluginSource { + /// The package's `/usr/.../fcitx5/libopenless.so`. + System, + /// A per-user copy in `~/.local/` (manual install / AppImage). + User, + None, +} + +/// The package copy wins over a per-user copy. fcitx5 searches the user addon +/// directory first, so a leftover `~/.local` copy from an older manual install +/// would otherwise keep shadowing every package upgrade forever. +pub(crate) fn resolve_plugin_source(system: Option<&Path>, user: &Path) -> PluginSource { + if system.is_some() { + PluginSource::System + } else if user.is_file() { + PluginSource::User + } else { + PluginSource::None + } +} + +/// A per-user copy is stale when the package already provides the addon and the +/// per-user files are still there to shadow it. +pub(crate) fn shadows_package_plugin(system_present: bool, user_library: &Path) -> bool { + system_present && user_library.is_file() +} + +/// Reasons fcitx5 has to be restarted before a plugin change takes effect. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReloadReason { + /// The installed plugin's content differs from the one we last loaded. + ContentChanged, + /// Same content, but the file is newer than the running daemon (a package + /// upgrade replaced the image the daemon still holds). + NewerThanDaemon, + /// The daemon has a *different* library mapped (a stale manual install, or + /// the pre-upgrade package image). Restarting is the only way to make it + /// pick up the copy we ship. + DaemonHoldsOtherCopy, +} + +/// Whether the running fcitx5 must be restarted. +/// +/// `marker` is the fingerprint of the plugin content recorded the last time the +/// host looked at it; a missing marker means "unknown baseline" and is treated +/// as a change (one restart, then the marker exists and the check is exact). +pub(crate) fn reload_reason( + marker: Option<&str>, + current: &str, + newer_than_daemon: bool, + daemon_holds_installed_copy: bool, +) -> Option { + if !daemon_holds_installed_copy { + return Some(ReloadReason::DaemonHoldsOtherCopy); + } + if marker != Some(current) { + return Some(ReloadReason::ContentChanged); + } + if newer_than_daemon { + return Some(ReloadReason::NewerThanDaemon); + } + None +} + +/// Where the fingerprint of the plugin content last handed to fcitx5 lives. +pub fn plugin_fingerprint_path(data_dir: &Path) -> PathBuf { + data_dir.join("fcitx5-plugin.sha256") +} + +/// sha256 of a file, or `None` when it cannot be read. +pub(crate) fn file_fingerprint(path: &Path) -> Option { + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(path).ok()?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + Some(format!("{:x}", hasher.finalize())) +} + +/// Drop a per-user copy that would shadow the package's addon, so a package +/// upgrade always wins. Best effort: a failure is a warning, never fatal. +fn remove_shadowing_user_copy(plan: &FcitxPluginInstallPlan, system_present: bool) -> bool { + if !shadows_package_plugin(system_present, &plan.target_library) { + return false; + } + log::warn!( + "[fcitx] removing the per-user addon copy {} — the package provides a newer \ + plugin and fcitx5 loads the user copy first", + plan.target_library.display() + ); + let mut removed = false; + for path in [&plan.target_library, &plan.target_config] { + match std::fs::remove_file(path) { + Ok(()) => removed = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => log::warn!("[fcitx] could not remove {}: {error}", path.display()), + } + } + removed +} + +fn installed_plugin_library(plan: &FcitxPluginInstallPlan) -> Option { + let system = system_plugin_library(); + match resolve_plugin_source(system.as_deref(), &plan.target_library) { + PluginSource::System => system, + PluginSource::User => Some(plan.target_library.clone()), + PluginSource::None => None, + } +} + +/// Addon-library search order, mirroring how fcitx5 resolves `Library=`. +/// +/// The packaging prefix (Debian multiarch, e.g. `/usr/lib/x86_64-linux-gnu`) +/// comes first because that is where the .deb/.rpm puts the plugin and what +/// package upgrades replace. A manually installed `/usr/local` copy is last on +/// purpose: it is never upgraded by the package manager, so treating it as +/// "the packaged plugin" made every start believe the plugin had changed. +pub(crate) fn plugin_library_search_dirs(usr_lib_subdirs: &[String]) -> Vec { + let mut dirs = Vec::new(); + let mut push = |dir: PathBuf| { + if !dirs.contains(&dir) { + dirs.push(dir); + } + }; + for subdir in usr_lib_subdirs { + push(PathBuf::from("/usr/lib").join(subdir).join("fcitx5")); + } + push(PathBuf::from("/usr/lib/fcitx5")); + push(PathBuf::from("/usr/lib64/fcitx5")); + // /usr/local is deliberately *not* a package path: anything there is a + // leftover manual install that dpkg never replaces. Treating it as "the + // packaged plugin" made every start believe the addon had changed and + // kept a stale build live; `report_stale_manual_plugin` removes it instead. + dirs +} + +/// Sub-directory names of `/usr/lib` (the multiarch triples). +fn usr_lib_subdirs() -> Vec { + std::fs::read_dir("/usr/lib") + .map(|entries| { + entries + .flatten() + .filter(|entry| entry.path().is_dir()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect() + }) + .unwrap_or_default() +} + +/// The package's addon library, found through the same directories fcitx5 uses. +fn system_plugin_library() -> Option { + plugin_library_search_dirs(&usr_lib_subdirs()) + .into_iter() + .map(|dir| dir.join("libopenless.so")) + .find(|candidate| candidate.is_file()) +} + +/// Which `libopenless.so` the running daemon actually mapped, read from +/// `/proc//maps`. This is the authoritative answer to "what is live". +pub(crate) fn parse_maps_plugin_path(maps: &str) -> Option { + maps.lines().find_map(|line| { + let path = line.split_whitespace().last()?; + path.ends_with("/libopenless.so") + .then(|| PathBuf::from(path)) + }) +} + +#[cfg(target_os = "linux")] +fn running_daemon_plugin() -> Option { + let pid = fcitx5_process_id()?; + let maps = std::fs::read_to_string(format!("/proc/{pid}/maps")).ok()?; + parse_maps_plugin_path(&maps) +} + +#[cfg(not(target_os = "linux"))] +fn running_daemon_plugin() -> Option { + None +} + +/// Point out (and drop when we may) an OpenLess plugin left behind by an older +/// manual install under `/usr/local`: dpkg never replaces it, it can shadow the +/// packaged copy for fcitx5, and it makes fingerprint checks ambiguous. +/// Only our own `libopenless.so` / `openless.conf` are ever touched. +fn report_stale_manual_plugin(installed: &Path, installed_fingerprint: &str) { + let library = PathBuf::from("/usr/local/lib/fcitx5/libopenless.so"); + let config = PathBuf::from("/usr/local/share/fcitx5/addon/openless.conf"); + if library == installed || !library.is_file() { + return; + } + let Some(fingerprint) = file_fingerprint(&library) else { + return; + }; + if fingerprint == installed_fingerprint { + return; + } + match std::fs::remove_file(&library) { + Ok(()) => { + log::warn!( + "[fcitx] removed the stale manual addon {} — the packaged plugin {} wins", + library.display(), + installed.display() + ); + // The addon conf in the same prefix shadows the packaged one; with + // the library gone fcitx5 would fail to load `openless` at all, so + // drop it as well (only ever our own file). + match std::fs::remove_file(&config) { + Ok(()) => log::warn!("[fcitx] removed the stale manual addon config {}", config.display()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => log::warn!( + "[fcitx] could not remove {}: {error}; sudo rm -f {} {}", + config.display(), + library.display(), + config.display() + ), + } + } + Err(error) => log::warn!( + "[fcitx] the manual addon {} (fingerprint {}) differs from the packaged plugin \ + and is not replaced by the package manager; remove it with: sudo rm -f {} {} ({error})", + library.display(), + &fingerprint[..fingerprint.len().min(12)], + library.display(), + config.display() + ), + } +} + +/// `/proc/stat` -> `btime` (boot time as a UNIX timestamp in seconds). +pub(crate) fn parse_boot_time(proc_stat: &str) -> Option { + proc_stat.lines().find_map(|line| { + line.strip_prefix("btime ") + .and_then(|value| value.trim().parse::().ok()) + }) +} + +/// `/proc//stat` -> process start time in clock ticks since boot. +/// +/// The second field is the executable name in parentheses and may contain +/// spaces, so split after the last ')' before counting fields. +pub(crate) fn parse_process_start_ticks(proc_pid_stat: &str) -> Option { + let after_comm = proc_pid_stat.rsplit_once(')')?.1; + let mut fields = after_comm.split_whitespace(); + // After the comm field, state is field 3; starttime is field 22 => the 20th + // field of the remaining slice. + fields.nth(19)?.parse::().ok() +} + +/// USER_HZ for /proc values is 100 on Linux regardless of the kernel HZ. +const PROC_CLOCK_TICKS: u64 = 100; + +/// True when the installed addon library is newer than the running fcitx5, i.e. +/// a package upgrade replaced the .so while the daemon still holds the old +/// image. Without a restart the new matching rules never take effect. +pub(crate) fn plugin_is_newer_than_running_fcitx5( + plugin_modified: u64, + boot_time: u64, + process_start_ticks: u64, +) -> bool { + let process_started = boot_time + process_start_ticks / PROC_CLOCK_TICKS; + // One second of slack: both timestamps are second-resolution. + plugin_modified > process_started.saturating_add(1) +} + +/// Restart fcitx5 when the installed addon is newer than the running daemon so +/// an upgraded plugin is actually loaded. Returns true when fcitx5 was replaced. +pub fn reload_fcitx5_if_plugin_updated(plan: &FcitxPluginInstallPlan, data_dir: &Path) -> bool { + let system = system_plugin_library(); + // A leftover per-user copy shadows the package plugin in fcitx5's search + // order, so drop it first and judge the package's copy. + remove_shadowing_user_copy(plan, system.is_some()); + let Some(library) = installed_plugin_library(plan) else { + return false; + }; + let Some(current) = file_fingerprint(&library) else { + return false; + }; + let fingerprint_path = plugin_fingerprint_path(data_dir); + let marker = std::fs::read_to_string(&fingerprint_path) + .ok() + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()); + let newer = plugin_written_after_running_daemon(&library); + // Judge the copy fcitx5 actually mapped: a leftover manual install in a + // legacy prefix is never replaced by the package manager. + let loaded = running_daemon_plugin(); + let daemon_loaded = loaded.as_deref(); + report_stale_manual_plugin(&library, ¤t); + let daemon_holds_installed_copy = match daemon_loaded { + Some(loaded) => file_fingerprint(loaded).as_deref() == Some(current.as_str()), + None => true, + }; + log::info!( + "[fcitx] addon {} fingerprint={} loaded={} recorded={} newer_than_daemon={}", + library.display(), + ¤t[..current.len().min(12)], + daemon_loaded + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "".to_string()), + marker + .as_deref() + .map(|value| &value[..value.len().min(12)]) + .unwrap_or(""), + newer, + ); + let reason = reload_reason( + marker.as_deref(), + ¤t, + newer, + daemon_holds_installed_copy, + ); + // Record the fingerprint *before* asking for the restart (and also when no + // restart is needed): a daemon that never comes back, or a start that had + // nothing to do, must not make the next start repeat the decision. + if let Some(parent) = fingerprint_path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Err(error) = std::fs::write(&fingerprint_path, ¤t) { + log::warn!("[fcitx] could not record the plugin fingerprint: {error}"); + } + let Some(reason) = reason else { + return false; + }; + log::info!("[fcitx] restarting fcitx5 to load the addon update ({reason:?})"); + reload_running_fcitx5() +} + +/// Whether the addon file is newer than the running fcitx5 process. +fn plugin_written_after_running_daemon(library: &Path) -> bool { + let Some(modified) = std::fs::metadata(library) + .and_then(|metadata| metadata.modified()) + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs()) + else { + return false; + }; + let (Some(proc_stat), Some(pid)) = ( + std::fs::read_to_string("/proc/stat").ok(), + fcitx5_process_id(), + ) else { + return false; + }; + let Some(proc_pid_stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok() else { + return false; + }; + let (Some(boot_time), Some(start_ticks)) = ( + parse_boot_time(&proc_stat), + parse_process_start_ticks(&proc_pid_stat), + ) else { + return false; + }; + plugin_is_newer_than_running_fcitx5(modified, boot_time, start_ticks) +} + +/// PID of the running fcitx5, by scanning /proc for the process name. +#[cfg(target_os = "linux")] +fn fcitx5_process_id() -> Option { + for entry in std::fs::read_dir("/proc").ok()?.flatten() { + let name = entry.file_name(); + let pid = name.to_string_lossy().parse::().ok(); + let Some(pid) = pid else { continue }; + let Ok(comm) = std::fs::read_to_string(entry.path().join("comm")) else { + continue; + }; + if comm.trim() == "fcitx5" { + return Some(pid); + } + } + None +} + +#[cfg(not(target_os = "linux"))] +fn fcitx5_process_id() -> Option { + None +} + #[derive(Debug, Clone)] pub struct Fcitx5TextInserter { clipboard_fallback: bool, @@ -249,7 +512,6 @@ 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) }) @@ -587,7 +849,6 @@ 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) }) @@ -607,7 +868,6 @@ 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) })? { @@ -630,7 +890,6 @@ 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) })? { @@ -653,7 +912,6 @@ 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) })?; @@ -671,7 +929,6 @@ 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( @@ -761,6 +1018,69 @@ pub fn available() -> bool { .is_ok() } +/// Production startup barrier: install, request at most one reload, then wait +/// for the new owner's addon interface before registering any shortcuts. +#[cfg(target_os = "linux")] +pub fn prepare_fcitx5(plan: &FcitxPluginInstallPlan, data_dir: &Path) -> Result<(), BackendError> { + use dbus::blocking::BlockingSender; + ensure_plugin_installed(plan)?.require_ready()?; + let connection = dbus::blocking::Connection::new_session().map_err(dbus_error)?; + let owner = |timeout| -> Result { + let message = dbus::Message::new_method_call( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "GetNameOwner", + ) + .map_err(dbus_error)? + .append1(DESTINATION); + connection + .send_with_reply_and_block(message, timeout) + .map_err(dbus_error)? + .read1::() + .map_err(dbus_error) + }; + let previous_owner = owner(TIMEOUT)?; + let reloaded = reload_fcitx5_if_plugin_updated(plan, data_dir); + let deadline = std::time::Instant::now() + Duration::from_secs(15); + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Err(BackendError::new(BackendErrorCode::Platform, + "fcitx5 OpenLess interface did not become ready within 15 seconds; enable the addon and restart fcitx5")); + } + let current_owner = owner(TIMEOUT.min(remaining)); + if current_owner + .as_ref() + .is_ok_and(|current| !reloaded || *current != previous_owner) + { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + continue; + } + let message = dbus::Message::new_method_call( + DESTINATION, + OBJECT_PATH, + "org.freedesktop.DBus.Introspectable", + "Introspect", + ) + .map_err(dbus_error)?; + if connection + .send_with_reply_and_block(message, TIMEOUT.min(remaining)) + .ok() + .and_then(|reply| reply.read1::().ok()) + .is_some_and(|xml| xml.contains(INTERFACE)) + { + return Ok(()); + } + } + std::thread::sleep( + Duration::from_millis(100) + .min(deadline.saturating_duration_since(std::time::Instant::now())), + ); + } +} + #[cfg(not(target_os = "linux"))] pub fn available() -> bool { false @@ -776,25 +1096,71 @@ pub fn available() -> bool { /// 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 +/// Reload failures are logged; the production startup barrier still requires +/// a ready addon and successful required hotkey registration. 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}"); + if restart_fcitx5_via_dbus() { + log::info!("[fcitx] reloaded fcitx5 after addon update"); + return true; + } + spawn_detached_fcitx5_restart() +} + +/// `org.fcitx.Fcitx.Controller1.Restart` on `/controller`: the daemon replaces +/// itself, the call returns as soon as the method is dispatched, and nothing of +/// ours is inherited. +#[cfg(target_os = "linux")] +pub(crate) fn restart_fcitx5_via_dbus() -> bool { + use dbus::blocking::BlockingSender; + let Ok(connection) = dbus::blocking::Connection::new_session() else { + return false; + }; + let Ok(message) = dbus::Message::new_method_call( + DESTINATION, + CONTROLLER_PATH, + CONTROLLER_INTERFACE, + "Restart", + ) else { + return false; + }; + match connection.send_with_reply_and_block(message, CONTROLLER_TIMEOUT) { + Ok(_) => true, + Err(error) => { + log::warn!( + "[fcitx] D-Bus Restart unavailable ({error}); falling back to a detached fcitx5 -r" + ); false } + } +} + +/// Last resort when the controller interface is missing: spawn `fcitx5 -r` +/// fully detached. `fcitx5 -r` *becomes* the daemon and keeps running in the +/// foreground, so `.status()`/`.wait()` would block the caller forever — the +/// child is deliberately dropped instead. +#[cfg(target_os = "linux")] +fn spawn_detached_fcitx5_restart() -> bool { + use std::process::Stdio; + match std::process::Command::new("fcitx5") + .arg("-r") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + { + Ok(child) => { + let pid = child.id(); + drop(child); + log::info!("[fcitx] spawned a detached fcitx5 -r (pid {pid})"); + true + } Err(error) => { - log::warn!("[fcitx] could not run fcitx5 -r: {error}"); + log::warn!("[fcitx] could not spawn fcitx5 -r: {error}"); false } } @@ -851,16 +1217,8 @@ pub fn copy_to_clipboard(text: &str) -> Result<(), BackendError> { } } -#[cfg(not(target_os = "linux"))] -pub fn copy_to_clipboard(_text: &str) -> Result<(), BackendError> { - Err(BackendError::new( - BackendErrorCode::Unsupported, - "fcitx5 clipboard requires Linux", - )) -} - #[cfg(target_os = "linux")] -fn dbus_error(error: dbus::Error) -> BackendError { +fn dbus_error(error: impl std::fmt::Display) -> BackendError { BackendError::new( BackendErrorCode::Unsupported, format!("fcitx5 DBus service is unavailable: {error}"), @@ -876,6 +1234,237 @@ fn platform_error(message: String) -> BackendError { mod tests { use super::*; + #[test] + fn missing_plugin_is_a_startup_error_with_recovery_instructions() { + let error = FcitxPluginStatus::Missing.require_ready().unwrap_err(); + assert_eq!(error.code, BackendErrorCode::Platform); + assert!(error.message.contains("reinstall")); + assert!(FcitxPluginStatus::Ready.require_ready().is_ok()); + } + + #[test] + fn boot_time_and_process_start_are_parsed_from_proc() { + let stat = "cpu 1 2 3\nbtime 1700000000\nprocesses 42\n"; + assert_eq!(parse_boot_time(stat), Some(1_700_000_000)); + assert_eq!(parse_boot_time("cpu 1 2 3\n"), None); + + // Field 2 is the comm in parentheses and may contain spaces/parens; the + // 22nd field (starttime) sits 20 fields after it. Line copied from a real + // /proc//stat of this machine (starttime = 284037). + let pid_stat = "38066 (bash) S 32218 38066 38066 0 -1 4194304 245 0 0 0 0 0 0 0 20 0 1 0 284037 10760192 917 18446744073709551615 93845596229632"; + assert_eq!(parse_process_start_ticks(pid_stat), Some(284037)); + // A comm containing a closing parenthesis must not shift the fields. + let paren_comm = + "999 (fcitx5 (5.1)) S 1 999 999 0 -1 4194304 1 0 0 0 0 0 0 0 20 0 1 0 77777 13"; + assert_eq!(parse_process_start_ticks(paren_comm), Some(77777)); + assert_eq!(parse_process_start_ticks(""), None); + assert_eq!(parse_process_start_ticks("1 (short) S 1"), None); + } + + #[test] + fn a_plugin_newer_than_the_running_fcitx5_asks_for_a_restart() { + // fcitx5 started at boot + 2500 ticks (25 s). + let boot = 1_700_000_000; + let started = 2500; + // Plugin written before the daemon started: nothing to do. + assert!(!plugin_is_newer_than_running_fcitx5( + boot + 10, + boot, + started + )); + // Same second (the daemon read the file it just got): nothing to do. + assert!(!plugin_is_newer_than_running_fcitx5( + boot + 25, + boot, + started + )); + // Plugin replaced by a package upgrade while the daemon kept running. + assert!(plugin_is_newer_than_running_fcitx5( + boot + 600, + boot, + started + )); + } + + #[test] + fn a_daemon_holding_another_copy_always_asks_for_a_restart() { + // Even with a matching fingerprint, a daemon that mapped a different + // libopenless.so (stale manual /usr/local install, pre-upgrade image) + // keeps the old matching rules until it is restarted. + assert_eq!( + reload_reason(Some("abc"), "abc", false, false), + Some(ReloadReason::DaemonHoldsOtherCopy) + ); + // Steady state: nothing mapped differently, fingerprint recorded. + assert_eq!(reload_reason(Some("abc"), "abc", false, true), None); + } + + #[test] + fn the_packaged_addon_is_searched_before_a_manual_install() { + let dirs = plugin_library_search_dirs(&["x86_64-linux-gnu".to_string()]); + // A manual /usr/local install is never treated as a package path; it is + // cleaned up separately, otherwise every start thinks the addon changed. + assert!( + !dirs.contains(&PathBuf::from("/usr/local/lib/fcitx5")), + "a manual install must not masquerade as the packaged addon: {dirs:?}" + ); + let multiarch = dirs + .iter() + .position(|dir| dir == Path::new("/usr/lib/x86_64-linux-gnu/fcitx5")) + .expect("multiarch addon dir"); + assert!( + multiarch == 0, + "the multiarch package dir must be searched first: {dirs:?}" + ); + // No duplicate entries when /usr/lib scans repeat a prefix. + let twice = plugin_library_search_dirs(&[ + "x86_64-linux-gnu".to_string(), + "x86_64-linux-gnu".to_string(), + ]); + assert_eq!(twice, dirs); + } + + #[test] + fn the_daemon_maps_the_plugin_we_read_from_proc() { + let maps = "7f00-8000 r-xp 00000000 08:01 42 /usr/lib/fcitx5/libother.so +\ + 8000-9000 r-xp 00000000 08:01 43 /usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so +"; + assert_eq!( + parse_maps_plugin_path(maps), + Some(PathBuf::from( + "/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so" + )) + ); + assert_eq!(parse_maps_plugin_path(""), None); + assert_eq!( + parse_maps_plugin_path("1-2 r-xp 0 00:00 0 /usr/lib/fcitx5/libopenless.so.old"), + None + ); + } + + #[test] + fn the_package_plugin_wins_over_a_per_user_copy() { + let system = Path::new("/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so"); + let user = Path::new("/home/u/.local/lib/fcitx5/libopenless.so"); + assert_eq!( + resolve_plugin_source(Some(system), user), + PluginSource::System + ); + // No package plugin: fall back to the per-user copy (AppImage/manual). + let missing = Path::new("/definitely/not/here/libopenless.so"); + assert_eq!( + resolve_plugin_source(None, &PathBuf::from("/tmp/x")), + PluginSource::None + ); + assert!(!missing.is_file()); + // The decision itself must not depend on the user copy existing. + assert_eq!( + resolve_plugin_source(None, Path::new("/definitely/not/here")), + PluginSource::None + ); + } + + #[test] + fn a_per_user_copy_only_shadows_when_the_package_has_the_addon() { + // Both branches need a real per-user file, but never a real *package* + // file: asserting on `/usr/lib/.../libopenless.so` only held on a + // machine that had the deb installed and failed on clean CI runners. + let home = tempfile::tempdir().expect("temp home"); + let user = home.path().join(".local/lib/fcitx5/libopenless.so"); + assert!(!shadows_package_plugin(true, &user)); + assert!(!shadows_package_plugin(false, &user)); + std::fs::create_dir_all(user.parent().expect("user addon dir")) + .expect("create user addon dir"); + std::fs::write(&user, b"stale per-user copy").expect("write user addon"); + // Presence of the package copy plus an existing user file is the case + // that used to keep an upgraded package plugin from ever loading. + assert!(shadows_package_plugin(true, &user)); + // Without a package copy there is nothing to shadow: the per-user file + // is the AppImage/manual plugin fcitx5 is meant to load. + assert!(!shadows_package_plugin(false, &user)); + } + + #[test] + fn reload_is_driven_by_content_before_mtime() { + // Same content, daemon older than the file: mtime still asks for a reload. + assert_eq!( + reload_reason(Some("abc"), "abc", true, true), + Some(ReloadReason::NewerThanDaemon) + ); + // Same content, daemon newer: nothing to do (steady state every start). + assert_eq!(reload_reason(Some("abc"), "abc", false, true), None); + // Different content: reload regardless of timestamps (downgrades, files + // restored from a backup, same-second package upgrades). + assert_eq!( + reload_reason(Some("abc"), "def", false, true), + Some(ReloadReason::ContentChanged) + ); + // Unknown baseline (first run of this check): reload once, then exact. + assert_eq!( + reload_reason(None, "abc", false, true), + Some(ReloadReason::ContentChanged) + ); + } + + #[test] + fn probing_the_plugin_never_writes_anything() { + // 用户拍板的策略:运行时只校验、绝不二次安装。这里把「探测不写入」 + // 锁进测试:两个目标路径都不存在时必须返回 Missing,且目录里不留任何 + // 新文件(旧实现会往 ~/.local 拷一份,反而盖过 deb 装的插件)。 + let home = tempfile::tempdir().unwrap(); + let layout = LinuxResourceLayout { + package_kind: crate::LinuxPackageKind::SystemPackage, + resource_root: PathBuf::from("/usr/lib/openless/resources"), + }; + let plan = FcitxPluginInstallPlan::for_layout(&layout, home.path()).unwrap(); + let library_dir = plan.target_library.parent().unwrap().to_path_buf(); + let config_dir = plan.target_config.parent().unwrap().to_path_buf(); + + // The probe itself must not create the per-user addon tree. + assert!( + !library_dir.exists(), + "probe must not create {}", + library_dir.display() + ); + assert!( + !config_dir.exists(), + "probe must not create {}", + config_dir.display() + ); + } + + #[test] + fn removing_a_shadowing_copy_leaves_other_addons_alone() { + let home = tempfile::tempdir().unwrap(); + let layout = LinuxResourceLayout { + package_kind: crate::LinuxPackageKind::SystemPackage, + resource_root: PathBuf::from("/usr/lib/openless/resources"), + }; + let plan = FcitxPluginInstallPlan::for_layout(&layout, home.path()).unwrap(); + for path in [&plan.target_library, &plan.target_config] { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, b"stale").unwrap(); + } + // A neighbouring addon from another project must survive the cleanup. + let neighbour = plan + .target_config + .parent() + .unwrap() + .join("other-addon.conf"); + std::fs::write(&neighbour, b"keep me").unwrap(); + + // Without a package plugin the per-user copy is the only one: keep it. + assert!(!remove_shadowing_user_copy(&plan, false)); + assert!(plan.target_library.is_file()); + + // With the package plugin present the stale copy would shadow it: remove. + assert!(remove_shadowing_user_copy(&plan, true)); + assert!(!plan.target_library.exists()); + assert!(!plan.target_config.exists()); + assert_eq!(std::fs::read(&neighbour).unwrap(), b"keep me"); + } + #[test] fn plugin_plan_is_probe_only_for_system_packages() { let layout = LinuxResourceLayout { diff --git a/openless-all/app/linux-egui/src/host_history.rs b/openless-all/app/linux-egui/src/host_history.rs deleted file mode 100644 index ee3a7a6cf..000000000 --- a/openless-all/app/linux-egui/src/host_history.rs +++ /dev/null @@ -1,61 +0,0 @@ -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 deleted file mode 100644 index db8fe54c2..000000000 --- a/openless-all/app/linux-egui/src/host_marketplace.rs +++ /dev/null @@ -1,94 +0,0 @@ -#[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 deleted file mode 100644 index 4a975a131..000000000 --- a/openless-all/app/linux-egui/src/host_models.rs +++ /dev/null @@ -1,282 +0,0 @@ -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 deleted file mode 100644 index 8fa0c3b3a..000000000 --- a/openless-all/app/linux-egui/src/host_omni.rs +++ /dev/null @@ -1,276 +0,0 @@ -#[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 deleted file mode 100644 index ac135589d..000000000 --- a/openless-all/app/linux-egui/src/host_onboarding.rs +++ /dev/null @@ -1,106 +0,0 @@ -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 deleted file mode 100644 index 52cde5f9b..000000000 --- a/openless-all/app/linux-egui/src/host_settings.rs +++ /dev/null @@ -1,768 +0,0 @@ -// 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), - ( - "/stableTranscriptionEnabled", - "稳定模式(先录音后识别)", - 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 deleted file mode 100644 index 15745317f..000000000 --- a/openless-all/app/linux-egui/src/host_styles.rs +++ /dev/null @@ -1,68 +0,0 @@ -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 deleted file mode 100644 index 5aec40c46..000000000 --- a/openless-all/app/linux-egui/src/host_windows.rs +++ /dev/null @@ -1,179 +0,0 @@ -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 56f60990f..e182f9edb 100644 --- a/openless-all/app/linux-egui/src/hotkeys.rs +++ b/openless-all/app/linux-egui/src/hotkeys.rs @@ -12,9 +12,11 @@ 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)] -pub(crate) struct HotkeyPressIds { +struct HotkeyPressIds { dictation: std::sync::atomic::AtomicU64, less_computer: std::sync::atomic::AtomicU64, + qa: std::sync::atomic::AtomicBool, + quick_note: std::sync::atomic::AtomicBool, } #[cfg(any(target_os = "linux", test))] @@ -25,9 +27,6 @@ fn next_press_id() -> u64 { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LinuxHotkeyEvent { - DesktopDisconnected, - LessComputerPanelPressed, - LessComputerQuickPressed, DictationPressed { symbol: u32, states: u32, @@ -65,8 +64,12 @@ pub enum LinuxHotkeyEvent { at: std::time::Instant, }, QaPressed, + QuickNotePressed, SelectionPolishPressed, - TranslationPressed, + TranslationPressed { + symbol: u32, + states: u32, + }, SwitchStylePressed, OpenAppPressed, StylePackPressed { @@ -244,7 +247,7 @@ fn run_listener( } #[cfg(any(target_os = "linux", test))] -pub(crate) fn event_from_signal( +fn event_from_signal( member: &str, symbol: u32, states: u32, @@ -331,13 +334,38 @@ pub(crate) fn event_from_signal( .load(std::sync::atomic::Ordering::Acquire), at, }), - ("QaShortcutEvent", true) => Some(LinuxHotkeyEvent::QaPressed), + ("QaShortcutEvent", true) + if !press_ids.qa.swap(true, std::sync::atomic::Ordering::AcqRel) => + { + Some(LinuxHotkeyEvent::QaPressed) + } + ("QaShortcutEvent", true) => None, + ("QaShortcutEvent", false) => { + press_ids + .qa + .store(false, std::sync::atomic::Ordering::Release); + None + } + ("QuickNoteEvent", true) + if !press_ids + .quick_note + .swap(true, std::sync::atomic::Ordering::AcqRel) => + { + Some(LinuxHotkeyEvent::QuickNotePressed) + } + ("QuickNoteEvent", true) => None, + ("QuickNoteEvent", false) => { + press_ids + .quick_note + .store(false, std::sync::atomic::Ordering::Release); + None + } ("SelectionPolishEvent", true) => Some(LinuxHotkeyEvent::SelectionPolishPressed), - ("TranslationModifierEvent", true) => Some(LinuxHotkeyEvent::TranslationPressed), + ("TranslationModifierEvent", true) => { + Some(LinuxHotkeyEvent::TranslationPressed { symbol, states }) + } ("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 }) } diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index f591038d4..b784238d7 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -20,9 +20,6 @@ pub enum Lang { En, Ja, Ko, - Es, - Fr, - De, } /// The persisted UI-locale preference. `System` means "follow the host OS @@ -34,16 +31,7 @@ pub enum LocalePref { Lang(Lang), } -pub const LANGS: [Lang; 8] = [ - Lang::ZhCn, - Lang::ZhTw, - Lang::En, - Lang::Ja, - Lang::Ko, - Lang::Es, - Lang::Fr, - Lang::De, -]; +pub const LANGS: [Lang; 5] = [Lang::ZhCn, Lang::ZhTw, Lang::En, Lang::Ja, Lang::Ko]; /// JSON/wire tags, matching the Tauri `SUPPORTED_LOCALES`. pub const FOLLOW_SYSTEM: &str = "system"; @@ -57,9 +45,6 @@ impl Lang { Lang::En => "en", Lang::Ja => "ja", Lang::Ko => "ko", - Lang::Es => "es", - Lang::Fr => "fr", - Lang::De => "de", } } @@ -67,15 +52,6 @@ impl Lang { /// 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") @@ -174,20 +150,17 @@ const fn row( // referenced resolves. pub const CATALOG: &[Msg] = &[ // ---- Shell / navigation ------------------------------------------------ + Msg { - key: "shell.workspace", + key: "shell.version", text: row( - "工作台", - "工作臺", - "Workspace", - "ワークスペース", - "작업 공간", + "版本 {}", + "版本 {}", + "Version {}", + "バージョン {}", + "버전 {}", ), }, - Msg { - key: "shell.capabilities", - text: row("能力", "能力", "Capabilities", "機能", "기능"), - }, Msg { key: "nav.overview", text: row("概览", "概覽", "Overview", "概要", "개요"), @@ -197,229 +170,87 @@ pub const CATALOG: &[Msg] = &[ text: row("历史", "歷史", "History", "履歴", "기록"), }, Msg { - key: "nav.vocab", - text: row( - "词汇与纠错", - "詞彙與糾錯", - "Vocabulary & Correction", - "語彙と修正", - "어휘 및 교정", - ), + key: "marketplace.derivativeBadge", + text: row("衍生自 @{}", "衍生自 @{}", "Derived from @{}", "@{} から派生", "@{}에서 파생"), }, Msg { - key: "nav.styles", - text: row( - "风格包", - "風格包", - "Style Packs", - "スタイルパック", - "스타일 팩", - ), + key: "nav.quickNote", + text: row("速记", "速記", "Quick notes", "速記", "속기"), }, + Msg { key: "quickNote.kicker", text: row("速记", "速記", "Quick notes", "速記", "속기") }, + Msg { key: "quickNote.title", text: row("速记", "速記", "Quick notes", "速記", "속기") }, + Msg { key: "quickNote.desc", text: row("永久保留录音,支持回放、导出、重新转录和重新润色。", "永久保留錄音,支援回放、匯出、重新轉錄與重新潤色。", "Permanent audio with playback, export, retranscription, and repolish.", "音声を保持し、再生・書き出し・再文字起こし・再推敲に対応します。", "오디오를 영구 보관하고 재생·내보내기·재전사·다시 다듬기를 지원합니다.") }, + Msg { key: "quickNote.start", text: row("开始录音", "開始錄音", "Start recording", "録音を開始", "녹음 시작") }, + Msg { key: "quickNote.finish", text: row("完成录音", "完成錄音", "Finish recording", "録音を終了", "녹음 종료") }, + Msg { key: "quickNote.noTranscript", text: row("还没有转写内容。", "尚未有轉錄內容。", "No transcript yet.", "まだ文字起こしがありません。", "아직 전사 내용이 없습니다.") }, + Msg { key: "quickNote.recording", text: row("录音中…", "錄音中…", "Recording…", "録音中…", "녹음 중…") }, + Msg { key: "quickNote.shortcutTitle", text: row("速记快捷键", "速記快捷鍵", "Quick note shortcut", "速記ショートカット", "속기 단축키") }, + Msg { key: "quickNote.shortcutDesc", text: row("按一次开始永久录音,再按一次结束并保存。", "按一下開始永久錄音,再按一下結束並保存。", "Press once to start a permanent capture, then press again to finish.", "一度押して録音を開始し、もう一度押して保存します。", "한 번 눌러 녹음하고 다시 눌러 저장합니다.") }, + Msg { + key: "nav.vocab", + text: row("词典", "詞彙表", "Dictionary", "辞書", "사전"), + }, + 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", - "録音終了", - "녹음 종료", - ), + key: "nav.group_style", + text: row("风格", "風格", "Style", "スタイル", "스타일"), }, Msg { - key: "btn.stop_recording", - text: row( - "停止录音", - "停止錄音", - "Stop recording", - "録音停止", - "녹음 중지", - ), + key: "nav.group_tools", + text: row("工具", "工具", "Tools", "ツール", "도구"), }, Msg { - key: "btn.voice_ask", + key: "nav.polish_mode", text: row( - "语音提问", - "語音提問", - "Voice ask", - "音声で質問", - "음성 질문", + "润色模式", + "潤色模式", + "Polish mode", + "推敲モード", + "다듬기 모드", ), }, 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", - "選択範囲プレビュー", - "선택 영역 미리보기", - ), + key: "nav.translation", + text: row("翻译", "翻譯", "Translation", "翻訳", "번역"), }, - Msg { - key: "heading.insert_preview", - text: row( - "插入预览", - "插入預覽", - "Insert preview", - "挿入プレビュー", - "삽입 미리보기", - ), + Msg { + key: "nav.selection_ask", + text: row("划词追问", "劃詞追問", "Ask", "選択追問", "선택 질문"), }, Msg { - key: "heading.qa_preview", + key: "nav.corrections", text: row( - "划词追问", - "劃詞追問", - "Ask on selection", - "選択範囲で質問", - "선택어 질문", + "纠错规则", + "糾錯規則", + "Corrections", + "修正ルール", + "교정 규칙", ), }, + // ---- Common controls ---------------------------------------------------- + Msg { - key: "heading.recent", - text: row("最近识别", "最近辨識", "Recent", "最近の認識", "최근 기록"), - }, - Msg { - key: "heading.local_models", - text: row( - "本地模型", - "本機模型", - "Local Models", - "ローカルモデル", - "로컬 모델", - ), + key: "btn.close", + text: row("关闭", "關閉", "Close", "閉じる", "닫기"), }, + // ---- 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( @@ -440,46 +271,7 @@ pub const CATALOG: &[Msg] = &[ "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( @@ -540,67 +332,9 @@ pub const CATALOG: &[Msg] = &[ "선택 영역 바꾸기가 취소되었습니다", ), }, - 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( @@ -611,16 +345,7 @@ pub const CATALOG: &[Msg] = &[ "7일 {}건 · 30일 {}건", ), }, - Msg { - key: "metric.total_segments", - text: row( - "共 {} 段", - "共 {} 段", - "{} segments", - "合計 {} 件", - "총 {}건", - ), - }, + Msg { key: "loading.overview", text: row( @@ -631,36 +356,7 @@ pub const CATALOG: &[Msg] = &[ "개요를 불러오는 중…", ), }, - 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( @@ -671,20 +367,7 @@ pub const CATALOG: &[Msg] = &[ "(설정 안 됨)", ), }, - Msg { - key: "overview.configured", - text: row("已配置", "已設定", "Configured", "設定済み", "설정됨"), - }, - Msg { - key: "overview.configured_dot", - text: row( - "● 已配置", - "● 已設定", - "● Configured", - "● 設定済み", - "● 설정됨", - ), - }, + Msg { key: "overview.unconfigured", text: row("未配置", "未設定", "Not configured", "未設定", "미설정"), @@ -699,396 +382,812 @@ pub const CATALOG: &[Msg] = &[ "아직 받아쓰기 기록이 없습니다. 위의 시작을 눌러주세요.", ), }, - Msg { - key: "overview.no_text", + + // ---- Overview page (2.0 dashboard) ------------------------------------ + Msg { + key: "overview.title", text: row( - "(无文本)", - "(無文字)", - "(no text)", - "(テキストなし)", - "(텍스트 없음)", + "今日概览", + "今日概覽", + "Today's overview", + "本日の概要", + "오늘 개요", ), }, Msg { - key: "overview.heatmap_title", - text: row( - "近一年每日活动次数", - "近一年每日活動次數", - "Daily activity · past year", - "過去1年の日別活動回数", - "지난 1년 일별 활동 횟수", - ), + key: "overview.mode_raw", + text: row("原文", "原文", "Verbatim", "原文", "원문"), }, Msg { - key: "overview.heatmap_empty", + key: "overview.mode_light", text: row( - "暂无活动数据", - "暫無活動資料", - "No activity data yet", - "まだ活動データはありません", - "아직 활동 데이터가 없습니다", + "轻度润色", + "輕度潤色", + "Light polish", + "軽い推敲", + "가벼운 다듬기", ), }, Msg { - key: "overview.heatmap_less", - text: row("少", "少", "Less", "少", "적음"), - }, - Msg { - key: "overview.heatmap_more", - text: row("多", "多", "More", "多", "많음"), + key: "overview.mode_structured", + text: row( + "清晰结构", + "清晰結構", + "Structured", + "明確な構造", + "명확한 구조", + ), }, Msg { - key: "overview.heatmap_footnote", + key: "overview.mode_formal", text: row( - "(近 {} 天 · {} 天有记录)", - "(近 {} 天 · {} 天有記錄)", - "({} days · {} active)", - "({} 日間・{} 日記録あり)", - "({}일 · {}일 기록)", + "正式表达", + "正式表達", + "Formal", + "フォーマル", + "격식체", ), }, - // ---- Durations --------------------------------------------------------- - Msg { - key: "dur.ms", - text: row("{} 毫秒", "{} 毫秒", "{} ms", "{} ミリ秒", "{} 밀리초"), + Msg { + key: "overview.refresh", + text: row( + "刷新状态", + "重新整理狀態", + "Refresh status", + "状態を更新", + "상태 새로고침", + ), }, - Msg { - key: "dur.sec", - text: row("{} 秒", "{} 秒", "{} s", "{} 秒", "{} 초"), + Msg { + key: "overview.stats_title", + text: row( + "使用记录", + "使用紀錄", + "Your activity", + "利用記録", + "사용 기록", + ), }, - Msg { - key: "dur.min_sec", + Msg { + key: "overview.metric_chars", text: row( - "{} 分 {} 秒", - "{} 分 {} 秒", - "{}m {}s", - "{} 分 {} 秒", - "{}분 {}초", + "今日字数", + "今日字數", + "Characters today", + "本日の文字数", + "오늘 글자 수", ), }, - // ---- Language selector -------------------------------------------------- - Msg { - key: "settings.language", - text: row("界面语言", "介面語言", "UI language", "UI 言語", "UI 언어"), + Msg { + key: "overview.metric_segments", + text: row("{} 段", "{} 段", "{} segments", "{} セグメント", "{} 세그먼트"), }, - Msg { - key: "settings.language_follow_system", + Msg { + key: "overview.metric_duration", text: row( - "跟随系统", - "跟隨系統", - "Follow system", - "システムに従う", - "시스템 따르기", + "今日总时长", + "今日總時長", + "Total duration today", + "本日の合計時間", + "오늘 총 시간", ), }, - Msg { - key: "lang.zh-CN", + Msg { + key: "overview.metric_avg", text: row( - "简体中文", - "简体中文", - "Simplified Chinese", - "簡体中国語", - "중국어(간체)", + "平均段落", + "平均段落", + "Avg per segment", + "平均セグメント", + "평균 세그먼트", ), }, - Msg { - key: "lang.zh-TW", + Msg { + key: "overview.metric_avg_trend", text: row( - "繁体中文", - "繁體中文", - "Traditional Chinese", - "繁体中国語", - "중국어(번체)", + "今日均值", + "今日均值", + "Today's average", + "本日の平均", + "오늘 평균", ), }, + Msg { + key: "overview.metric_no_data", + text: row("暂无数据", "暫無資料", "No data", "データなし", "데이터 없음"), + }, + Msg { - key: "lang.en", - text: row("English", "English", "English", "英語", "영어"), + key: "overview.metric_total", + text: row( + "累计记录", + "累計記錄", + "Total records", + "累計記録", + "누적 기록", + ), }, Msg { - key: "lang.ja", - text: row("日本語", "日本語", "Japanese", "日本語", "일본어"), + key: "overview.metric_total_trend", + text: row( + "本机存档(上限 {})", + "本機存檔(上限 {})", + "Stored locally (max {})", + "ローカル保存(上限 {})", + "로컬 저장(최대 {})", + ), }, Msg { - key: "lang.ko", - text: row("한국어", "한국어", "Korean", "韓国語", "한국어"), + key: "overview.period_last7", + text: row("近 7 天", "近 7 天", "Last 7 days", "直近 7 日", "최근 7일"), }, Msg { - key: "settings.locale_saved", + key: "overview.period_last30", text: row( - "界面语言已更新", - "介面語言已更新", - "UI language updated", - "UI 言語を更新しました", - "UI 언어가 업데이트되었습니다", + "近 30 天", + "近 30 天", + "Last 30 days", + "直近 30 日", + "최근 30일", ), }, - // ---- Appearance (settings) --------------------------------------------- Msg { - key: "settings.theme", - text: row("主题", "佈景主題", "Theme", "テーマ", "테마"), + key: "overview.daily_avg", + text: row("日均 {}", "日均 {}", "{} / day", "1日平均 {}", "일평균 {}"), }, Msg { - key: "theme.light", - text: row("浅色", "淺色", "Light", "ライト", "라이트"), + key: "overview.metric_count", + text: row("条数", "條數", "Count", "件数", "건수"), }, Msg { - key: "theme.dark", - text: row("深色", "深色", "Dark", "ダーク", "다크"), + key: "overview.metric_chars_name", + text: row("字数", "字數", "Characters", "文字数", "글자 수"), }, Msg { - key: "theme.system", + key: "overview.metric_duration_name", + text: row("时长", "時長", "Duration", "時間", "시간"), + }, + Msg { + key: "overview.recent_title", text: row( - "系统默认", - "系統預設", - "System default", - "システム既定", - "시스템 기본", + "最近识别", + "最近識別", + "Recent transcripts", + "最近の認識", + "최근 인식", ), }, - // ---- Status / host ------------------------------------------------------ - Msg { - key: "status.core_started", + Msg { + key: "overview.recent_all", + text: row("全部记录 →", "全部記錄 →", "View all →", "すべて表示 →", "전체 보기 →"), + }, + Msg { + key: "overview.recent_empty_hint", text: row( - "Core 2.0 已启动", - "Core 2.0 已啟動", - "Core 2.0 ready", - "Core 2.0 起動済み", - "Core 2.0 시작됨", + "还没有听写记录。跟着上方的引导试一次,结果会显示在这里。", + "還沒有聽寫紀錄。依照上方引導試一次,結果就會顯示在這裡。", + "No dictations yet. Follow the guide above to try one; your result will appear here.", + "まだ音声入力の記録がありません。上の案内に沿って試すと、ここに結果が表示されます。", + "아직 받아쓰기 기록이 없습니다. 위 안내에 따라 사용해 보면 결과가 여기에 표시됩니다.", ), }, + Msg { - key: "status.startup_failed", - text: row( - "启动失败", + key: "overview.retry", + text: row("重试", "重試", "Retry", "再試行", "다시 시도"), + }, + Msg { + key: "overview.activity_title", + text: row( + "年度活动", + "年度活動", + "Annual activity", + "年間アクティビティ", + "연간 활동", + ), + }, + Msg { + key: "overview.activity_count", + text: row( + "{} 次听写", + "{} 次聽寫", + "{} dictation(s)", + "{} 回の入力", + "{}회 받아쓰기", + ), + }, + + Msg { + key: "overview.services_title", + text: row( + "当前语音服务", + "目前的語音服務", + "Current voice services", + "使用中の音声サービス", + "현재 음성 서비스", + ), + }, + Msg { + key: "overview.asr_kind", + text: row( + "语音识别", + "ASR 語音", + "Speech recognition", + "音声認識", + "음성 인식", + ), + }, + Msg { + key: "overview.llm_kind", + text: row( + "文字处理", + "LLM 模型", + "Text processing", + "テキスト処理", + "텍스트 처리", + ), + }, + Msg { + key: "overview.provider_help_asr", + text: row( + "将语音转成文字。", + "將語音轉成文字。", + "Turns your speech into text.", + "音声をテキストに変換します。", + "음성을 텍스트로 변환합니다.", + ), + }, + Msg { + key: "overview.provider_help_llm", + text: row( + "按你的风格整理和润色文字。", + "依照你的風格整理和潤飾文字。", + "Organizes and polishes text in your style.", + "あなたのスタイルに合わせて文章を整えます。", + "내 스타일에 맞게 글을 정리하고 다듬습니다.", + ), + }, + Msg { + key: "overview.configure_provider", + text: row("去配置", "前往設定", "Configure", "設定する", "설정하기"), + }, + Msg { + key: "overview.manage_provider", + text: row( + "管理服务", + "管理服務", + "Manage service", + "サービスを管理", + "서비스 관리", + ), + }, + Msg { + key: "overview.status_loading", + text: row( + "正在读取服务配置…", + "正在讀取服務設定…", + "Reading service configuration…", + "サービス設定を読み込み中…", + "서비스 설정을 불러오는 중…", + ), + }, + + Msg { + key: "overview.week_days", + text: row( + "日|一|二|三|四|五|六", + "日|一|二|三|四|五|六", + "Sun|Mon|Tue|Wed|Thu|Fri|Sat", + "日|月|火|水|木|金|土", + "일|월|화|수|목|금|토", + ), + }, + Msg { + key: "overview.months", + text: row( + "1月|2月|3月|4月|5月|6月|7月|8月|9月|10月|11月|12月", + "1月|2月|3月|4月|5月|6月|7月|8月|9月|10月|11月|12月", + "Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec", + "1月|2月|3月|4月|5月|6月|7月|8月|9月|10月|11月|12月", + "1월|2월|3월|4월|5월|6월|7월|8월|9월|10월|11월|12월", + ), + }, + Msg { + key: "overview.minutes", + text: row("{} 分钟", "{} 分鐘", "{} min", "{} 分", "{}분"), + }, + Msg { + key: "overview.hours_minutes", + text: row( + "{} 小时 {} 分", + "{} 小時 {} 分", + "{} h {} min", + "{} 時間 {} 分", + "{}시간 {}분", + ), + }, + Msg { + key: "overview.copy", + text: row("复制", "複製", "Copy", "コピー", "복사"), + }, + Msg { + key: "overview.copied", + text: row("已复制", "已複製", "Copied", "コピー済み", "복사됨"), + }, + // ---- Shared controls --------------------------------------------------- + Msg { + key: "common.refresh", + text: row("刷新", "重新整理", "Refresh", "更新", "새로고침"), + }, + Msg { + key: "common.clear", + text: row("清空", "清空", "Clear", "クリア", "지우기"), + }, + Msg { + key: "common.loading", + text: row("加载中…", "載入中…", "Loading…", "読み込み中…", "로딩 중…"), + }, + Msg { + key: "common.retry", + text: row("重试", "重試", "Retry", "再試行", "다시 시도"), + }, + Msg { + key: "common.copy", + text: row("复制", "複製", "Copy", "コピー", "복사"), + }, + Msg { + key: "common.copied", + text: row("已复制", "已複製", "Copied", "コピーしました", "복사됨"), + }, + + Msg { + key: "status.copied", + text: row("已复制", "已複製", "Copied", "コピー済み", "복사됨"), + }, + // egui-only: the style page's pack counter and new-pack hint are not part of + // the Tauri catalog (it renders the equivalent UI from Core data). + + // egui-only: the Core vocabulary presets are loaded from the backend in the + // Tauri app; the egui host lists the four built-ins by name. + Msg { + key: "vocab.presets_dev_tools", + text: row( + "开发工具", + "開發工具", + "Dev tools", + "開発ツール", + "개발 도구", + ), + }, + Msg { + key: "vocab.presets_products", + text: row( + "产品与平台", + "產品與平台", + "Products & platforms", + "製品とプラットフォーム", + "제품 및 플랫폼", + ), + }, + Msg { + key: "vocab.presets_terms", + text: row( + "技术术语", + "技術術語", + "Technical terms", + "技術用語", + "기술 용어", + ), + }, + Msg { + key: "vocab.presets_english", + text: row( + "英文写作", + "英文寫作", + "English writing", + "英語ライティング", + "영어 작문", + ), + }, + // egui-only: the not-yet-wired page placeholder and the marketplace's + // decorative preview text have no Tauri counterpart. + Msg { + key: "common.unsupported_title", + text: row( + "此页面暂未接线", + "此頁面暫未接線", + "This page is not wired up yet", + "このページは未接続です", + "이 페이지는 아직 연결되지 않았습니다", + ), + }, + Msg { + key: "common.unsupported_hint", + text: row( + "数据桥接将在后续阶段完成", + "資料橋接將在後續階段完成", + "Data wiring lands in a later stage", + "データ連携は後続の段階で完了します", + "데이터 연결은 이후 단계에서 완료됩니다", + ), + }, + + Msg { + key: "common.delete", + text: row("删除", "刪除", "Delete", "削除", "삭제"), + }, + Msg { + key: "common.cancel", + text: row("取消", "取消", "Cancel", "キャンセル", "취소"), + }, + Msg { + key: "common.confirm", + text: row("确认", "確認", "Confirm", "確認", "확인"), + }, + Msg { + key: "common.duration_minutes", + text: row("{} 分钟", "{} 分鐘", "{}m", "{} 分", "{}분"), + }, + // ---- History ----------------------------------------------------------- + Msg { + key: "history.kicker", + text: row("历史记录", "HISTORY", "HISTORY", "履歴", "기록"), + }, + Msg { + key: "history.title", + text: row("历史记录", "歷史記錄", "History", "履歴", "기록"), + }, + Msg { + key: "history.desc", + text: row( + "本机保存的识别记录。", + "本機儲存的識別記錄。", + "Locally stored transcripts.", + "ローカルに保存された認識記録。", + "로컬에 저장된 인식 기록.", + ), + }, + Msg { + key: "history.search_placeholder", + text: row( + "搜索转写内容…({})", + "搜尋轉寫內容…({})", + "Search transcripts… ({})", + "文字起こしを検索…({})", + "기록 검색…({})", + ), + }, + Msg { + key: "history.empty", + text: row( + "还没有历史记录。", + "還沒有歷史記錄。", + "No history yet.", + "履歴はまだありません。", + "아직 기록이 없습니다.", + ), + }, + Msg { + key: "history.search_no_match", + text: row( + "没有匹配「{}」的记录。", + "沒有符合「{}」的記錄。", + "No entries match “{}”.", + "「{}」に一致する項目はありません。", + "“{}”과(와) 일치하는 항목이 없습니다.", + ), + }, + Msg { + key: "history.load_failed", + text: row( + "加载历史失败:{}", + "載入歷史失敗:{}", + "Failed to load history: {}", + "履歴の読み込みに失敗:{}", + "기록 로드 실패: {}", + ), + }, + Msg { + key: "history.select_hint", + text: row( + "左侧选一条查看详情。", + "左側選一條檢視詳情。", + "Select an entry on the left to see details.", + "左側から 1 件選択して詳細を表示。", + "왼쪽에서 하나를 선택하여 자세히 보기.", + ), + }, + Msg { + key: "history.recorded", + text: row("录音 {}", "錄音 {}", "Recorded {}", "録音 {}", "녹음 {}"), + }, + // egui-only in-app player controls; Tauri opens the recording externally. + Msg { + key: "history.pause", + text: row("暂停播放", "暫停播放", "Pause recording", "再生を一時停止", "재생 일시 정지"), + }, + Msg { + key: "history.resume", + text: row("继续播放", "繼續播放", "Resume recording", "再生を再開", "재생 계속"), + }, + + Msg { + key: "history.play", + text: row( + "播放录音", + "播放錄音", + "Play recording", + "録音を再生", + "녹음 재생", + ), + }, + Msg { + key: "history.export", + text: row( + "导出录音", + "匯出錄音", + "Export recording", + "録音をエクスポート", + "녹음 내보내기", + ), + }, + Msg { + key: "history.retranscribe", + text: row( + "重新转写", + "重新轉寫", + "Retranscribe", + "再文字起こし", + "다시 받아쓰기", + ), + }, + Msg { + key: "history.raw_label", + text: row("原文", "原文", "Raw", "原文", "원문"), + }, + Msg { + key: "history.raw_empty", + text: row("(空)", "(空)", "(empty)", "(空)", "(비어 있음)"), + }, + Msg { + key: "history.step_asr", + text: row("识别", "辨識", "Transcribe", "認識", "인식"), + }, + Msg { + key: "history.step_polish", + text: row("润色", "潤飾", "Polish", "推敲", "다듬기"), + }, + Msg { + key: "history.step_insert", + text: row("插入", "插入", "Insert", "挿入", "삽입"), + }, + Msg { + key: "history.chars", + text: row("{} 字", "{} 字", "{} chars", "{} 文字", "{}자"), + }, + Msg { + key: "history.vocab_hits", + text: row( + "{} 个热词", + "{} 個熱詞", + "{} vocab hits", + "{} ホットワード", + "핫워드 {}개", + ), + }, + Msg { + key: "history.inserted", + text: row("已插入", "已插入", "Inserted", "入力済み", "입력됨"), + }, + Msg { + key: "history.paste_sent", + text: row("已尝试粘贴", "已嘗試貼上", "Paste sent", "貼り付けを試行", "붙여넣기 시도됨"), + }, + Msg { + key: "history.copied_fallback", + text: row( + "已复制(需 {})", + "已複製(需 {})", + "Copied (use {})", + "コピー済み(要 {})", + "복사됨({} 필요)", + ), + }, + Msg { + key: "history.insert_failed", + text: row( + "插入失败", + "插入失敗", + "Insert failed", + "入力失敗", + "입력 실패", + ), + }, + Msg { + key: "history.confirm_clear", + text: row( + "确定清空全部 {} 条记录?此操作不可恢复。", + "確定清空全部 {} 條記錄?此操作不可還原。", + "Delete all {} history entries? This cannot be undone.", + "全 {} 件の記録を削除しますか?この操作は取り消せません。", + "전체 {}건의 기록을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + ), + }, + Msg { + key: "history.confirm_delete", + text: row( + "确定删除这条记录?此操作不可恢复。", + "確定刪除這筆記錄?此操作無法復原。", + "Delete this record? This cannot be undone.", + "この記録を削除しますか?元に戻せません。", + "이 기록을 삭제할까요? 되돌릴 수 없습니다.", + ), + }, + Msg { + key: "history.clear_failed", + text: row( + "清空失败:{}", + "清空失敗:{}", + "Failed to clear history: {}", + "履歴の消去に失敗:{}", + "기록 비우기 실패: {}", + ), + }, + Msg { + key: "history.delete_failed", + text: row( + "删除失败:{}", + "刪除失敗:{}", + "Failed to delete entry: {}", + "記録の削除に失敗:{}", + "항목 삭제 실패: {}", + ), + }, + Msg { + key: "history.copy_failed", + text: row( + "复制失败:{}", + "複製失敗:{}", + "Failed to copy: {}", + "コピーに失敗:{}", + "복사 실패: {}", + ), + }, + Msg { + key: "history.export_failed", + text: row( + "导出失败:{}", + "匯出失敗:{}", + "Failed to export: {}", + "エクスポート失敗:{}", + "내보내기 실패: {}", + ), + }, + Msg { + key: "history.retranscribe_failed", + text: row( + "重新转录失败:{}", + "重新轉錄失敗:{}", + "Retranscribe failed: {}", + "再認識に失敗:{}", + "다시 인식 실패: {}", + ), + }, + // ---- Durations --------------------------------------------------------- + Msg { + key: "dur.ms", + text: row("{} 毫秒", "{} 毫秒", "{} ms", "{} ミリ秒", "{} 밀리초"), + }, + Msg { + key: "dur.sec", + text: row("{} 秒", "{} 秒", "{} s", "{} 秒", "{} 초"), + }, + + // ---- Language selector -------------------------------------------------- + + Msg { + key: "settings.language_follow_system", + text: row( + "跟随系统", + "跟隨系統", + "Follow system", + "システムに従う", + "시스템 따라가기", + ), + }, + + // ---- Appearance (settings) --------------------------------------------- + + // ---- 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", "起動に失敗しました", "시작 실패", ), }, + Msg { + key: "startup.fcitx_help", + text: row( + "请安装并启动 fcitx5,启用 OpenLess 插件,并确认当前桌面会话的 D-Bus 可用。必要时重新安装 OpenLess 软件包。修复后关闭此窗口并重新启动应用。", + "請安裝並啟動 fcitx5,啟用 OpenLess 插件,並確認目前桌面工作階段的 D-Bus 可用。必要時重新安裝 OpenLess 套件。修復後關閉此視窗並重新啟動應用程式。", + "Install and start fcitx5, enable the OpenLess addon, and check the current desktop session's D-Bus. Reinstall the OpenLess package if needed. Close this window and restart the application after fixing the environment.", + "fcitx5 を起動し、OpenLess アドオンとデスクトップセッションの D-Bus を有効にしてください。必要なら OpenLess を再インストールしてください。修正後、このウィンドウを閉じてアプリを再起動してください。", + "fcitx5를 설치하고 시작한 후 OpenLess 애드온과 현재 데스크톱 세션의 D-Bus를 활성화하세요. 필요하면 OpenLess를 다시 설치하세요. 환경을 수정한 후 이 창을 닫고 앱을 다시 시작하세요。", + ), + }, + Msg { key: "common.saving", text: row("正在保存…", "正在儲存…", "Saving…", "保存中…", "저장 중…") }, + Msg { key: "startup.connecting", text: row("正在连接 OpenLess…", "正在連線 OpenLess…", "Connecting to OpenLess…", "OpenLess に接続中…", "OpenLess에 연결 중…") }, // ---- 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", "チャネルの順序を更新しました", "채널 순서 업데이트됨") }, @@ -1106,36 +1205,32 @@ pub const CATALOG: &[Msg] = &[ 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 팝업 동작 무시됨") }, @@ -1153,10 +1248,4449 @@ pub const CATALOG: &[Msg] = &[ Msg { key: "dialog.recording_export_cancelled", text: row("录音导出已取消", "錄音匯出已取消", "Recording export cancelled", "録音のエクスポートをキャンセル", "녹음 내보내기 취소됨") }, Msg { key: "dialog.marketplace_zip_cancelled", text: row("Marketplace 下载已取消", "Marketplace 下載已取消", "Marketplace download cancelled", "Marketplace のダウンロードをキャンセル", "마켓플레이스 다운로드 취소됨") }, Msg { key: "status.history_cleared", text: row("历史已清空", "歷史已清空", "History cleared", "履歴をクリアしました", "기록이 지워졌습니다") }, - Msg { key: "status.remote_pin_reset", text: row("远程输入配对码已重置", "遠端輸入配對碼已重設", "Remote input pairing code reset", "リモート入力のペアリングコードを再発行しました", "원격 입력 페어링 코드 재설정됨") }, + Msg { key: "tray.show", text: row("显示 OpenLess", "顯示 OpenLess", "Show OpenLess", "OpenLess を表示", "OpenLess 표시") }, Msg { key: "tray.previous_style", text: row("切换到上一风格", "切換到上一風格", "Switch to previous style", "前のスタイルに切り替え", "이전 스타일로 전환") }, Msg { key: "tray.quit", text: row("退出", "結束", "Quit", "終了", "종료") }, + Msg { + key: "selection_ask.desc", + text: row( + "选中文字后语音提问,支持多轮追问。", + "選中文字後語音提問,支援多輪追問。", + "Select text and ask questions by voice, with multi-turn follow-ups.", + "テキストを選択して音声で質問。複数ターンの追問対応。", + "텍스트 선택 후 음성으로 질문. 다중 라운드 후속 질문 지원.", + ), + }, + Msg { + key: "selection_ask.guide_ask_desc", + text: row( + "按 {} 录音,再按一次提交。", + "按 {} 錄音,再按一次提交。", + "Press {} to record, then press again to submit.", + "{} で録音し、もう一度押して送信します。", + "{}로 녹음하고, 다시 눌러 전송하세요.", + ), + }, + Msg { + key: "selection_ask.guide_ask_title", + text: row( + "开口说出问题", + "開口說出問題", + "Say your question", + "声で質問する", + "말로 질문하기", + ), + }, + Msg { + key: "selection_ask.guide_dismiss", + text: row( + "关闭浮窗,结束本次对话", + "關閉浮窗,結束本次對話", + "Close the panel and end this conversation", + "パネルを閉じて、この会話を終了", + "패널을 닫고 이번 대화 종료", + ), + }, + Msg { + key: "selection_ask.guide_followup", + text: row( + "继续使用录音快捷键,即可多轮追问。", + "繼續使用錄音快捷鍵,即可多輪追問。", + "Use the recording shortcut again to ask a follow-up.", + "録音キーでもう一度、続けて質問できます。", + "녹음 단축키를 다시 눌러 후속 질문을 할 수 있어요.", + ), + }, + Msg { + key: "selection_ask.guide_open_desc", + text: row( + "按 {},开始一轮对话。", + "按 {},開始一輪對話。", + "Press {} to start a conversation.", + "{} で会話を始めます。", + "{}로 대화를 시작하세요.", + ), + }, + Msg { + key: "selection_ask.guide_open_title", + text: row( + "打开追问浮窗", + "開啟追問浮窗", + "Open the panel", + "パネルを開く", + "질문 패널 열기", + ), + }, + Msg { + key: "selection_ask.guide_select_title", + text: row( + "选中想了解的内容", + "選取想了解的內容", + "Select something to explore", + "知りたい内容を選択", + "궁금한 내용 선택", + ), + }, + Msg { + key: "selection_ask.guide_unset_desc", + text: row( + "先在快捷键设置中,为划词追问设置一个快捷键。", + "先到快捷鍵設定中,為劃詞追問設定快捷鍵。", + "Assign a Selection Ask shortcut in Shortcut settings first.", + "まずショートカット設定で選択追問のキーを割り当ててください。", + "먼저 단축키 설정에서 선택 질문 단축키를 지정하세요.", + ), + }, + Msg { + key: "selection_ask.history_desc", + text: row( + "开启后在本地保存问答记录,默认关闭。", + "開啟後在本地儲存問答記錄,預設關閉。", + "Save Q&A records locally when enabled. Off by default.", + "有効時、Q&A 記録をローカルに保存。デフォルト OFF。", + "활성화 시 Q&A 기록을 로컬에 저장. 기본 OFF.", + ), + }, + Msg { + key: "selection_ask.history_title", + text: row("保存历史", "儲存歷史", "Save history", "履歴を保存", "기록 저장"), + }, + Msg { + key: "selection_ask.howto_step2", + text: row( + "在任意 app 选中文字。", + "在任意 app 選中文字。", + "Select text in any app.", + "任意のアプリでテキストを選択。", + "아무 앱에서 텍스트 선택.", + ), + }, + Msg { + key: "selection_ask.howto_title", + text: row("使用方法", "使用方法", "How to use", "使い方", "사용 방법"), + }, + Msg { + key: "selection_ask.title", + text: row( + "划词追问", + "劃詞追問", + "Selection Ask", + "選択追問", + "선택 질문", + ), + }, + Msg { + key: "translation.desc", + text: row( + "录音后自动翻译为目标语言再插入。", + "錄音後自動翻譯為目標語言再插入。", + "Auto-translate recordings into a target language before insertion.", + "録音後に自動翻訳してから入力。", + "녹음 후 대상 언어로 자동 번역하여 삽입.", + ), + }, + Msg { + key: "translation.howto_step1", + text: row( + "在任意输入框聚焦光标。", + "在任意輸入框聚焦遊標。", + "Place cursor in any text field.", + "任意の入力欄にカーソルを置く。", + "아무 입력 필드에 커서를 놓으세요.", + ), + }, + Msg { + key: "translation.howto_step2", + text: row( + "按 {} 开始录音。", + "按 {} 開始錄音。", + "Press {} to start recording.", + "{} を押して録音開始。", + "{} 를 눌러 녹음 시작.", + ), + }, + Msg { + key: "translation.howto_step3", + text: row( + "录音中按一下 {} 激活翻译。", + "錄音中按一下 {} 啟動翻譯。", + "Press {} once during recording to activate translation.", + "録音中に {} を一度押して翻訳を起動。", + "녹음 중 {} 를 한 번 눌러 번역 활성화.", + ), + }, + Msg { + key: "translation.howto_step4", + text: row( + "再按 {} 停止录音。", + "再按 {} 停止錄音。", + "Press {} again to stop.", + "再度 {} を押して停止。", + "다시 {} 를 눌러 정지.", + ), + }, + Msg { + key: "translation.howto_step5", + text: row( + "翻译结果自动插入到光标位置。", + "翻譯結果自動插入到遊標位置。", + "Translated text is inserted at the cursor.", + "翻訳結果がカーソル位置に挿入されます。", + "번역 결과가 커서 위치에 삽입됩니다.", + ), + }, + Msg { + key: "translation.howto_title", + text: row("使用方法", "使用方法", "How to use", "使い方", "사용 방법"), + }, + Msg { + key: "translation.kicker", + text: row("翻译", "TRANSLATION", "TRANSLATION", "翻訳", "번역"), + }, + Msg { + key: "translation.status_disabled", + text: row("未启用", "未啟用", "Disabled", "無効", "비활성화됨"), + }, + Msg { + key: "translation.status_enabled", + text: row("已启用", "已啟用", "Enabled", "有効", "활성화됨"), + }, + Msg { + key: "translation.style_desc", + text: row( + "自动继承「风格」页当前激活的风格包。", + "自動沿用「風格」頁目前啟用的風格包。", + "Automatically inherits the active style pack from the Style page.", + "「スタイル」ページで現在有効なスタイルパックを自動的に引き継ぎます。", + "「스타일」 페이지에서 현재 활성화된 스타일 팩을 자동으로 사용합니다.", + ), + }, + Msg { + key: "translation.style_title", + text: row( + "翻译风格", + "翻譯風格", + "Translation style", + "翻訳スタイル", + "번역 스타일", + ), + }, + Msg { + key: "translation.target_desc", + text: row( + "录音时按 Shift 触发翻译。选「不启用」则 Shift 无效。", + "錄音時按 Shift 觸發翻譯。選「不啟用」則 Shift 無效。", + "Press Shift during recording to trigger translation. \"Disabled\" makes Shift a no-op.", + "録音中に Shift で翻訳を起動。「無効」で Shift 無効化。", + "녹음 중 Shift 로 번역 실행. \"비활성화\" 시 Shift 무효.", + ), + }, + Msg { + key: "translation.target_disabled", + text: row( + "不启用(Shift 按下不触发翻译)", + "不啟用(Shift 按下不觸發翻譯)", + "Disabled (Shift does nothing)", + "無効(Shift で翻訳を発動しない)", + "비활성화 (Shift 로 번역 발동 안 함)", + ), + }, + Msg { + key: "translation.target_same_as_working", + text: row( + "目标语言与你唯一的工作语言相同,翻译不会生效:按 Shift 仍按普通润色处理。换一个目标语言,或在上方多勾选一个工作语言。", + "目標語言與你唯一的工作語言相同,翻譯不會生效:按 Shift 仍按普通潤色處理。換一個目標語言,或在上方多勾選一個工作語言。", + "The target matches your only working language, so translation cannot take effect — Shift will just run a normal polish. Pick a different target, or add another working language above.", + "ターゲット言語が唯一の作業言語と同じため、翻訳は発動しません(Shift を押しても通常の整文になります)。別のターゲットを選ぶか、上で作業言語を追加してください。", + "대상 언어가 유일한 작업 언어와 같아 번역이 실행되지 않습니다. Shift 를 눌러도 일반 정리로 처리됩니다. 다른 대상 언어를 고르거나 위에서 작업 언어를 추가하세요.", + ), + }, + Msg { + key: "translation.target_title", + text: row( + "翻译目标语言", + "翻譯目標語言", + "Translation target language", + "翻訳ターゲット言語", + "번역 대상 언어", + ), + }, + Msg { + key: "translation.title", + text: row("翻译", "翻譯", "Translation", "翻訳", "번역"), + }, + Msg { + key: "translation.working_desc", + text: row( + "勾选日常使用的语言,影响润色与翻译效果。", + "勾選日常使用的語言,影響潤色與翻譯效果。", + "Select languages you use regularly to improve polish and translation.", + "日常使用する言語を選択し、整文と翻訳に反映。", + "일상적으로 사용하는 언어를 선택하여 정리와 번역에 반영.", + ), + }, + Msg { + key: "translation.working_title", + text: row( + "工作语言", + "工作語言", + "Working languages", + "作業言語", + "작업 언어", + ), + }, + Msg { + key: "vocab.corrections_empty", + text: row( + "还没有纠正规则。", + "還沒有糾正規則。", + "No correction rules yet.", + "補正ルールはまだありません。", + "아직 교정 규칙이 없습니다.", + ), + }, + Msg { + key: "vocab.corrections_learned_badge", + text: row("自动", "自動", "auto", "自動", "자동"), + }, + Msg { + key: "vocab.corrections_pattern_placeholder", + text: row( + "误识别写法,如 {num}粒", + "誤識別寫法,如 {num}粒", + "Mistaken text, e.g. {num}粒", + "誤認識された表記(例:{num}粒)", + "오인식 표현, 예: {num}粒", + ), + }, + Msg { + key: "vocab.corrections_replacement_placeholder", + text: row( + "目标写法,如 {num}例", + "目標寫法,如 {num}例", + "Target text, e.g. {num}例", + "修正後の表記(例:{num}例)", + "대상 표현, 예: {num}例", + ), + }, + Msg { + key: "vocab.corrections_tip", + text: row( + "修正常见 ASR 误识别,支持 {num} 数字通配。", + "修正常見 ASR 誤識別,支援 {num} 數字通配。", + "Fix common ASR mistakes. Supports {num} number wildcard.", + "ASR の誤認識を修正。{num} 数字ワイルドカード対応。", + "ASR 오인식 수정. {num} 숫자 와일드카드 지원.", + ), + }, + Msg { + key: "vocab.corrections_title", + text: row( + "纠正规则", + "糾正規則", + "Correction rules", + "補正ルール", + "교정 규칙", + ), + }, + Msg { + key: "vocab.desc", + text: row( + "添加生词或专业术语,提高识别准确率。", + "新增生詞或專業術語,提高識別準確率。", + "Add terms or jargon to improve recognition accuracy.", + "新語や専門用語を追加して認識精度を向上。", + "새 단어나 전문 용어를 추가하여 인식 정확도 향상.", + ), + }, + Msg { + key: "vocab.kicker", + text: row("词典", "VOCABULARY", "DICTIONARY", "辞書", "사전"), + }, + Msg { + key: "vocab.learned_section", + text: row( + "自动收集({})", + "自動收集({})", + "Auto-collected ({})", + "自動収集({})", + "자동 수집 ({})", + ), + }, + Msg { + key: "vocab.placeholder", + text: row( + "输入词语,按 Enter 或点添加…", + "輸入詞語,按 Enter 或點新增…", + "Type a word, press Enter or click Add…", + "単語を入力し、Enter または追加をクリック…", + "단어를 입력하고 Enter 또는 추가 클릭…", + ), + }, + Msg { + key: "vocab.presets_apply", + text: row( + "启用所选", + "啟用所選", + "Apply selected", + "選択中を有効化", + "선택 활성화", + ), + }, + Msg { + key: "vocab.presets_create", + text: row("新建预设", "新建預設", "New preset", "プリセット新規作成", "프리셋 새로 만들기"), + }, + Msg { + key: "vocab.presets_edit", + text: row("编辑 {}", "編輯 {}", "Edit {}", "{} を編集", "{} 편집"), + }, + Msg { + key: "vocab.presets_name_placeholder", + text: row("预设名称", "預設名稱", "Preset name", "プリセット名", "프리셋 이름"), + }, + Msg { + key: "vocab.presets_new_preset", + text: row("新预设", "新預設", "New preset", "新しいプリセット", "새 프리셋"), + }, + Msg { + key: "vocab.presets_save", + text: row("保存预设", "儲存預設", "Save preset", "プリセットを保存", "프리셋 저장"), + }, + Msg { + key: "vocab.presets_tip", + text: row( + "可多选批量启用,支持编辑和新建。", + "可多選批次啟用,支援編輯和新建。", + "Multi-select to apply in batch. Supports edit and create.", + "複数選択で一括適用。編集・新規作成対応。", + "다중 선택 일괄 적용 가능. 편집 및 생성 지원.", + ), + }, + Msg { + key: "vocab.presets_title", + text: row( + "场景预设", + "場景預設", + "Scenario presets", + "シーンプリセット", + "시나리오 프리셋", + ), + }, + Msg { + key: "vocab.presets_words_placeholder", + text: row( + "词条(用逗号或换行分隔)", + "詞條(用逗號或換行分隔)", + "Terms (comma or newline separated)", + "語彙(カンマまたは改行区切り)", + "어휘(쉼표 또는 줄바꿈으로 구분)", + ), + }, + Msg { + key: "vocab.remove_all_learned", + text: row("全部删除", "全部刪除", "Remove all", "すべて削除", "모두 삭제"), + }, + Msg { + key: "vocab.section_title", + text: row("词条", "詞條", "Entries", "項目", "항목"), + }, + Msg { + key: "vocab.tip", + text: row( + "支持中英混合 · 数字开头按字面识别 · 命中次数自动计数", + "支援中英混合 · 數字開頭按字面識別 · 命中次數自動計數", + "Mixed Chinese/English supported · numeric prefixes are matched literally · hits counted automatically", + "日本語と英数の混在対応 · 数字始まりは字面通り認識 · ヒット回数を自動カウント", + "한영 혼용 지원 · 숫자로 시작하면 그대로 인식 · 적중 횟수 자동 카운트", + ), + }, + Msg { + key: "vocab.title", + text: row("词典", "詞彙表", "Dictionary", "辞書", "사전"), + }, + Msg { + key: "style.custom_prompt_save", + text: row("保存提示词", "儲存提示詞", "Save prompt", "プロンプトを保存", "프롬프트 저장"), + }, + Msg { + key: "style.desc", + text: row( + "选择录音的默认输出风格。", + "選擇錄音的預設輸出風格。", + "Choose the default output style for recording.", + "録音のデフォルト出力スタイルを選択。", + "녹음의 기본 출력 스타일 선택.", + ), + }, + Msg { + key: "style.kicker", + text: row("风格", "STYLE", "STYLE", "スタイル", "스타일"), + }, + Msg { + key: "style.pack.builtin", + text: row("内置", "內建", "Built-in", "ビルトイン", "기본"), + }, + Msg { + key: "style.pack.current", + text: row("当前", "目前", "Current", "現在", "현재"), + }, + Msg { + key: "style.pack.dictation_prompt_title", + text: row( + "录音 / ASR Prompt", + "錄音 / ASR Prompt", + "Recording / ASR prompt", + "録音 / ASRプロンプト", + "녹음 / ASR 프롬프트", + ), + }, + Msg { + key: "style.pack.dictation_tab", + text: row( + "录音 / ASR 风格", + "錄音 / ASR 風格", + "Recording / ASR styles", + "録音 / ASRスタイル", + "녹음 / ASR 스타일", + ), + }, + Msg { + key: "style.pack.new_description", + text: row( + "简短描述这个风格的使用场景。", + "簡短描述這個風格的使用情境。", + "Briefly describe when to use this style.", + "このスタイルを使う場面を簡潔に説明してください。", + "이 스타일을 언제 사용하는지 간단히 설명하세요.", + ), + }, + Msg { + key: "style.pack.selection_tab", + text: row( + "选区润色", + "選區潤色", + "Selection polish", + "選択範囲の推敲", + "선택 영역 다듬기", + ), + }, + Msg { + key: "style.title", + text: row("输出风格", "輸出風格", "Output style", "出力スタイル", "출력 스타일"), + }, + Msg { + key: "marketplace.desc", + text: row( + "浏览、安装和分享社区风格包。", + "瀏覽、安裝和分享社群風格包。", + "Browse, install, and share community style packs.", + "コミュニティのスタイルパックを閲覧・インストール・共有。", + "커뮤니티 스타일 팩 둘러보기, 설치, 공유.", + ), + }, + Msg { + key: "marketplace.download_zip_btn", + text: row("下载 ZIP", "下載 ZIP", "Download ZIP", "ZIP をダウンロード", "ZIP 다운로드"), + }, + Msg { + key: "marketplace.empty", + text: row( + "还没有风格包", + "還沒有風格包", + "No style packs yet", + "まだスタイルパックがありません", + "아직 스타일 팩이 없습니다", + ), + }, + Msg { + key: "marketplace.empty_hint", + text: row( + "换个搜索词,或自己上传一个分享给社区", + "換個搜尋詞,或自己上傳一個分享給社群", + "Try a different keyword, or upload your own", + "別のキーワードを試すか、自分のパックを共有してみましょう", + "다른 키워드로 검색하거나 직접 업로드해 보세요", + ), + }, + Msg { + key: "marketplace.install_btn", + text: row("安装到本地", "安裝到本機", "Install", "インストール", "설치"), + }, + Msg { + key: "marketplace.kicker", + text: row("风格市场", "MARKETPLACE", "MARKETPLACE", "マーケット", "마켓"), + }, + Msg { + key: "marketplace.upload_btn", + text: row("上传", "上傳", "Upload", "アップロード", "업로드"), + }, + Msg { + key: "marketplace.upload.title", + text: row( + "选择要上传的风格包", + "選擇要上傳的風格包", + "Pick a style pack to upload", + "アップロードするパックを選択", + "업로드할 팩 선택", + ), + }, + Msg { + key: "marketplace.upload.updateTitle", + text: row("更新「{}」", "更新「{}」", "Update \"{}\"", "「{}」を更新", "\"{}\" 업데이트"), + }, + Msg { + key: "marketplace.upload.hint", + text: row("选择一个本地风格包发布到市场。", "選擇一個本機風格包發布到市場。", "Choose a local style pack to publish.", "公開するローカルスタイルパックを選択してください。", "게시할 로컬 스타일 팩을 선택하세요."), + }, + Msg { + key: "marketplace.upload.updateHint", + text: row( + "选中要上传的本地新版本风格包,下方点「确定上传」。同名包默认预选。", + "選中要上傳的本機新版本風格包,下方點「確定上傳」。同名包預設預選。", + "Pick the local newer version, then click \"Confirm upload\". A same-name pack is pre-selected.", + "アップロードするローカルの新版を選んで「アップロード確定」を押してください。同名パックは自動選択されます。", + "업로드할 로컬 최신본을 선택하고 \"업로드 확정\"을 누르세요. 동명 팩이 기본 선택됩니다.", + ), + }, + Msg { + key: "marketplace.upload.noLocal", + text: row( + "本地没有可上传的风格包", + "本機沒有可上傳的風格包", + "No local style packs to upload", + "アップロード可能なローカルパックがありません", + "업로드 가능한 로컬 팩이 없습니다", + ), + }, + Msg { + key: "marketplace.upload.confirm", + text: row("确定上传", "確定上傳", "Upload", "アップロード", "업로드"), + }, + Msg { + key: "marketplace.upload.submitting", + text: row("正在提交…", "正在提交…", "Submitting…", "送信中…", "제출 중…"), + }, + Msg { + key: "marketplace.withdraw.confirm", + text: row("确定要下架“{}”吗?", "確定要下架「{}」嗎?", "Withdraw “{}” from the marketplace?", "「{}」をマーケットから取り下げますか?", "“{}”을(를) 마켓에서 내릴까요?"), + }, + Msg { + key: "marketplace.withdraw.confirmBtn", + text: row("确认下架", "確認下架", "Withdraw", "取り下げる", "내리기"), + }, + Msg { + key: "marketplace.withdraw.success", + text: row("已下架", "已下架", "Publication withdrawn", "公開を取り下げました", "게시물을 내렸습니다"), + }, + Msg { + key: "marketplace.my_packs_button_label", + text: row("我的发布", "我的發布", "My Packs", "自分の公開", "내 게시물"), + }, + Msg { + key: "marketplace.refresh_btn", + text: row("刷新", "重新整理", "Refresh", "更新", "새로고침"), + }, + Msg { + key: "marketplace.search_placeholder", + text: row( + "搜索名称 / 描述 / 标签…", + "搜尋名稱 / 描述 / 標籤…", + "Search name / description / tags…", + "名前 / 説明 / タグを検索…", + "이름 / 설명 / 태그 검색…", + ), + }, + Msg { + key: "marketplace.sort_liked", + text: row("我赞过的", "我讚過的", "Liked", "いいね済み", "좋아요한 팩"), + }, + Msg { + key: "marketplace.sort_new", + text: row("最新", "最新", "Newest", "新着", "최신"), + }, + Msg { + key: "marketplace.sort_popular", + text: row("按热度", "按熱度", "Popular", "人気順", "인기순"), + }, + Msg { + key: "hotkey.triggers.right_option", + text: row("右 Option", "右 Option", "Right Option", "右 Option", "오른쪽 Option"), + }, + Msg { + key: "modal.about.export_error_log", + text: row( + "导出错误日志", + "匯出錯誤日誌", + "Export error log", + "エラーログをエクスポート", + "오류 로그 내보내기", + ), + }, + Msg { + key: "modal.sections.help_center", + text: row("帮助中心", "幫助中心", "Help center", "ヘルプセンター", "도움말 센터"), + }, + Msg { + key: "modal.sections.release_notes", + text: row( + "发布日志", + "釋出日誌", + "Release notes", + "リリースノート", + "릴리스 노트", + ), + }, + Msg { + key: "overview.actions.shortcuts", + text: row("快捷键", "快捷鍵", "Shortcuts", "ショートカット", "단축키"), + }, + Msg { + key: "overview.llm_name", + text: row( + "OpenAI 兼容", + "OpenAI 相容", + "OpenAI-compatible", + "OpenAI 互換", + "OpenAI 호환", + ), + }, + Msg { + key: "settings.coding_agent.enable", + text: row( + "启用 Less Computer", + "啟用 Less Computer", + "Enable Less Computer", + "Less Computer を有効化", + "Less Computer 켜기", + ), + }, + Msg { + key: "settings.coding_console.permission_mode", + text: row( + "权限模式", + "權限模式", + "Permission mode", + "権限モード", + "권한 모드", + ), + }, + Msg { + key: "settings.coding_console.title", + text: row( + "Claude 控制台", + "Claude 主控臺", + "Claude Console", + "Claude コンソール", + "Claude 콘솔", + ), + }, + Msg { + key: "settings.coding_console.workdir", + text: row( + "工作目录", + "工作目錄", + "Working directory", + "作業ディレクトリ", + "작업 디렉터리", + ), + }, + Msg { + key: "settings.data_storage.title", + text: row("数据存储", "資料儲存", "Data storage", "データ保存", "데이터 저장"), + }, + Msg { + key: "settings.debug.title", + text: row("调试工具", "除錯工具", "Debug tools", "デバッグツール", "디버그 도구"), + }, + Msg { + key: "settings.language.title", + text: row( + "界面语言", + "介面語言", + "Interface language", + "表示言語", + "인터페이스 언어", + ), + }, + Msg { + key: "settings.language.zh", + text: row("简体中文", "簡體中文", "简体中文", "简体中文", "简体中文"), + }, + Msg { + key: "settings.layout.title", + text: row("布局", "佈局", "Layout", "レイアウト", "레이아웃"), + }, + Msg { + key: "settings.marketplace.github.open_github", + text: row("打开 GitHub", "開啟 GitHub", "Open GitHub", "GitHub を開く", "GitHub 열기"), + }, + Msg { + key: "settings.marketplace.title", + text: row("扩展市场", "擴充市集", "Marketplace", "拡張マーケット", "확장 마켓"), + }, + Msg { + key: "settings.network.use_system_proxy_label", + text: row( + "使用系统代理", + "使用系統代理", + "Use system proxy", + "システムプロキシを使用", + "시스템 프록시 사용", + ), + }, + Msg { + key: "settings.permissions.title", + text: row("权限", "權限", "Permissions", "権限", "권한"), + }, + Msg { + key: "settings.recording.auto_update_check_label", + text: row( + "自动检查更新", + "自動檢查更新", + "Auto-check for updates", + "アップデートを自動チェック", + "자동 업데이트 확인", + ), + }, + Msg { + key: "settings.recording.desc", + text: row( + "全局录音的快捷键与触发方式。", + "定義全域錄音的快捷鍵與觸發方式。", + "Global recording hotkey and trigger mode.", + "グローバル録音のショートカットとトリガー方式を定義します。", + "전역 녹음의 단축키와 트리거 방식을 정의합니다.", + ), + }, + Msg { + key: "settings.recording.insert_group_title", + text: row( + "插入与剪贴板", + "插入與剪貼簿", + "Insertion & clipboard", + "挿入とクリップボード", + "삽입 및 클립보드", + ), + }, + Msg { + key: "settings.recording.microphone_system_default", + text: row( + "系统默认", + "系統預設", + "system default", + "システムデフォルト", + "시스템 기본값", + ), + }, + Msg { + key: "settings.recording.mode_label", + text: row("录音方式", "錄音方式", "Trigger mode", "録音方式", "녹음 방식"), + }, + Msg { + key: "settings.recording.mode_toggle", + text: row("切换式", "切換式", "Toggle", "トグル式", "토글 방식"), + }, + Msg { + key: "settings.recording.mute_during_recording_label", + text: row( + "录音时静音", + "錄音時靜音", + "Mute while recording", + "録音中はミュート", + "녹음 중 음소거", + ), + }, + Msg { + key: "settings.recording.paste_shortcut_label", + text: row( + "模拟粘贴快捷键", + "模擬貼上快捷鍵", + "Simulated paste shortcut", + "貼り付けショートカット", + "붙여넣기 단축키", + ), + }, + Msg { + key: "settings.recording.startup_group_title", + text: row("启动", "啟動", "Startup", "起動", "시작"), + }, + Msg { + key: "settings.remote_input.enable_label", + text: row( + "启用远程输入", + "啟用遠端輸入", + "Enable remote input", + "リモート入力を有効化", + "원격 입력 활성화", + ), + }, + Msg { + key: "settings.remote_input.port_label", + text: row("监听端口", "監聽連線埠", "Port", "待ち受けポート", "수신 포트"), + }, + Msg { + key: "settings.remote_input.title", + text: row("远程输入", "遠端輸入", "Remote Input", "リモート入力", "원격 입력"), + }, + Msg { + key: "settings.theme.dark", + text: row("深色", "深色", "Dark", "ダーク", "다크"), + }, + Msg { + key: "settings.theme.label", + text: row("主题", "主題", "Theme", "テーマ", "테마"), + }, + Msg { + key: "settings.theme.light", + text: row("浅色", "淺色", "Light", "ライト", "라이트"), + }, + Msg { + key: "marketplace.title", + text: row( + "风格包市场", + "風格包市場", + "Style Pack Marketplace", + "スタイルパック マーケット", + "스타일 팩 마켓", + ), + }, + Msg { + key: "settings.selection_workspace.title", + text: row( + "选区助手", + "選區助手", + "Selection Assistant", + "選択範囲アシスタント", + "선택 영역 도우미", + ), + }, + Msg { + key: "modal.sections.about", + text: row( + "关于与更新", + "關於", + "About & updates", + "バージョンと更新", + "정보 및 업데이트", + ), + }, + Msg { + key: "modal.sections.advanced", + text: row( + "实验与扩展", + "高階", + "Experiments & extensions", + "実験機能と拡張", + "실험 기능 및 확장", + ), + }, + Msg { + key: "modal.sections.appearance", + text: row( + "外观与语言", + "外觀與語言", + "Appearance & language", + "外観と言語", + "모양 및 언어", + ), + }, + Msg { + key: "modal.sections.general", + text: row( + "录音与输入", + "通用", + "Recording & input", + "録音と入力", + "녹음 및 입력", + ), + }, + Msg { + key: "modal.sections.privacy", + text: row( + "权限与数据", + "隱私", + "Permissions & data", + "権限とデータ", + "권한 및 데이터", + ), + }, + Msg { + key: "modal.sections.services", + text: row( + "AI 服务与模型", + "服務", + "AI services & models", + "AI サービスとモデル", + "AI 서비스 및 모델", + ), + }, + Msg { + key: "modal.sections.shortcuts", + text: row( + "快捷键与选区", + "快捷鍵與選取文字", + "Shortcuts & selection", + "ショートカットと選択", + "단축키 및 선택", + ), + }, + Msg { + key: "settings.selection_workspace.hint", + text: row( + "选中文字后按同一快捷键:关闭语音编辑时直接润色;开启后口述指令,说完再选择「提问」或「编辑选区」。", + "選中文字後按同一快捷鍵:關閉語音編輯時直接潤色;開啟後口述指令,說完再選擇「提問」或「編輯選區」。", + "Select text, then use one shortcut: polish when voice edit is off; hold and speak when voice edit is on, then choose Ask or Edit.", + "テキスト選択後、同じショートカットで:音声編集オフ時は推敲、オン時は押しながら話してから「質問」か「編集」を選択。", + "텍스트 선택 후 같은 단축키: 음성 편집 끄면 바로 다듬기, 켜면 누른 채 말한 뒤 「질문」 또는 「편집」 선택.", + ), + }, + Msg { + key: "settings.selection_workspace.voice_enable", + text: row("语音编辑", "語音編輯", "Voice edit", "音声編集", "음성 편집"), + }, + Msg { + key: "settings.advanced.multimodal_pipeline_label", + text: row( + "启用多模态识别管线", + "啟用多模態辨識管線", + "Enable multimodal pipeline", + "マルチモーダルパイプラインを有効化", + "멀티모달 파이프라인 활성화", + ), + }, + Msg { + key: "settings.advanced.multimodal_pipeline_title", + text: row( + "多模态识别管线", + "多模態辨識管線", + "Multimodal recognition pipeline", + "マルチモーダル認識パイプライン", + "멀티모달 인식 파이프라인 ", + ), + }, + Msg { + key: "settings.language.label", + text: row("语言", "語言", "Language", "言語", "언어"), + }, + Msg { + key: "settings.network.title", + text: row("网络", "網路", "Network", "ネットワーク", "네트워크"), + }, + Msg { + key: "settings.recording.title", + text: row( + "录音与输入", + "錄音與輸入", + "Recording & input", + "録音と入力", + "녹음 및 입력", + ), + }, + Msg { + key: "selection_ask.shortcut_settings", + text: row( + "快捷键设置", + "快捷鍵設定", + "Shortcut settings", + "ショートカット設定", + "단축키 설정", + ), + }, + Msg { + key: "vocab.corrections_only_learned", + text: row( + "只看自动收集的({})", + "只看自動收集的({})", + "Only auto-collected ({})", + "自動収集のみ表示({})", + "자동 수집만 보기 ({})", + ), + }, + Msg { + key: "vocab.corrections_remove_all_learned", + text: row( + "删除全部自动收集的", + "刪除全部自動收集的", + "Delete all auto-collected", + "自動収集をすべて削除", + "자동 수집 전체 삭제", + ), + }, + Msg { + key: "vocab.empty", + text: row( + "还没有词条。在上面输入一个生词或专业术语,让模型在听写时优先匹配。", + "還沒有詞條。在上面輸入一個生詞或專業術語,讓模型在聽寫時優先匹配。", + "No entries yet. Add a new term or piece of jargon above so the model can prioritize it.", + "語彙がありません。新語や専門用語を上に入力すると、ディクテーション時に優先的にマッチします。", + "어휘가 없습니다. 위에 새 단어나 전문 용어를 입력하면 받아쓰기 시 우선 매칭됩니다.", + ), + }, + Msg { + key: "vocab.filter_all", + text: row("所有", "所有", "All", "すべて", "전체"), + }, + Msg { + key: "vocab.filter_auto", + text: row("自动添加", "自動新增", "Auto-Added", "自動追加", "자동 추가"), + }, + Msg { + key: "vocab.filter_manual", + text: row( + "手动添加", + "手動新增", + "Manually Added", + "手動追加", + "수동 추가", + ), + }, + Msg { + key: "vocab.new_word", + text: row("新词", "新詞", "New Word", "新語", "새 단어"), + }, + Msg { + key: "vocab.search_empty", + text: row( + "没有匹配的词条。", + "沒有符合的詞條。", + "No matching words.", + "一致する単語がありません。", + "일치하는 단어가 없습니다.", + ), + }, + Msg { + key: "vocab.search_placeholder", + text: row("搜索", "搜尋", "Search", "検索", "검색"), + }, + Msg { + key: "translation.howto_fallback_desc", + text: row( + "翻译失败时回退为插入原始转写,不会丢字。", + "翻譯失敗時回退為插入原始轉寫,不會丟字。", + "If translation fails, the raw transcript is inserted instead.", + "翻訳失敗時は原文がそのまま挿入されます。", + "번역 실패 시 원본 전사가 삽입됩니다.", + ), + }, + Msg { + key: "translation.howto_fallback_title", + text: row( + "安全兜底", + "安全兜底", + "Safety fallbacks", + "セーフティフォールバック", + "안전 폴백", + ), + }, + Msg { + key: "translation.howto_indicator_desc", + text: row( + "按 Shift 后屏幕底部会显示蓝色「正在翻译」标识。", + "按 Shift 後螢幕底部會顯示藍色「正在翻譯」標識。", + "A blue \"Translating\" indicator appears at the bottom of the screen after pressing Shift.", + "Shift を押すと画面下部に青い「翻訳中」表示が出ます。", + "Shift 를 누르면 화면 하단에 파란색 \"번역 중\" 표시가 나타납니다.", + ), + }, + Msg { + key: "translation.howto_indicator_title", + text: row( + "翻译模式指示", + "怎麼知道翻譯模式生效了", + "How to confirm translation mode is on", + "翻訳モードの確認方法", + "번역 모드 활성화 확인 방법", + ), + }, + Msg { + key: "translation.language_support_hint", + text: row( + "语音服务支持的语种可能不同;翻译目标不受界面语言限制。", + "語音服務支援的語種可能不同;翻譯目標不受介面語言限制。", + "Available speech languages depend on your provider. Translation targets are independent of the app language.", + "音声認識で使える言語はサービスによって異なります。翻訳先はアプリの表示言語とは独立しています。", + "음성 서비스에 따라 지원 언어가 다릅니다. 번역 언어는 앱 표시 언어와 별개입니다.", + ), + }, + Msg { + key: "translation.no_matching_languages", + text: row( + "没有匹配的语言", + "沒有符合的語言", + "No matching languages", + "一致する言語がありません", + "일치하는 언어가 없습니다", + ), + }, + Msg { + key: "translation.search_languages", + text: row( + "搜索语言…", + "搜尋語言…", + "Search languages…", + "言語を検索…", + "언어 검색…", + ), + }, + Msg { + key: "translation.selected_languages", + text: row( + "已选择 {} 种语言", + "已選擇 {} 種語言", + "{} languages selected", + "{} 言語を選択中", + "언어 {}개 선택됨", + ), + }, + Msg { + key: "modal.auto_save_hint", + text: row( + "修改后自动保存", + "修改後自動儲存", + "Changes save automatically", + "変更は自動保存されます", + "변경 사항이 자동 저장됩니다", + ), + }, + Msg { + key: "modal.search_placeholder", + text: row( + "查找设置分类…", + "尋找設定分類…", + "Find a settings category…", + "設定カテゴリを検索…", + "설정 카테고리 찾기…", + ), + }, + Msg { + key: "modal.service_views.asr", + text: row( + "语音识别", + "語音辨識", + "Speech recognition", + "音声認識", + "음성 인식", + ), + }, + Msg { + key: "modal.service_views.connections", + text: row("连接与扩展", "連線與擴充", "Connections", "接続と拡張", "연결 및 확장"), + }, + Msg { + key: "modal.service_views.llm", + text: row( + "语言模型", + "語言模型", + "Language models", + "言語モデル", + "언어 모델", + ), + }, + Msg { + key: "modal.service_views.models", + text: row("本地模型", "本機模型", "Local models", "ローカルモデル", "로컬 모델"), + }, + Msg { + key: "selection_ask.hotkey_title", + text: row( + "弹出浮窗的快捷键", + "彈出浮窗的快捷鍵", + "Hotkey to open the panel", + "フロートウィンドウのショートカット", + "플로팅 창 단축키", + ), + }, + Msg { + key: "settings.about.docs", + text: row("文档", "檔案", "Docs", "ドキュメント", "문서"), + }, + Msg { + key: "settings.about.feedback", + text: row("反馈", "反饋", "Feedback", "フィードバック", "피드백"), + }, + Msg { + key: "settings.about.links_title", + text: row( + "文档链接", + "檔案連結", + "Documentation", + "ドキュメント", + "문서 링크", + ), + }, + Msg { + key: "settings.about.local_first", + text: row("本地优先", "本地優先", "Local-first", "ローカル優先", "로컬 우선"), + }, + Msg { + key: "settings.about.privacy_desc", + text: row( + "录音可能会发送到你配置的云端服务商进行转写。", + "錄音可能會傳送至你設定的雲端服務商進行轉寫。", + "Recordings may be sent to the cloud provider you configure for transcription.", + "録音は、設定したクラウドプロバイダーへ文字起こしのため送信される場合があります。", + "녹음은 전사를 위해 설정한 클라우드 공급자에게 전송될 수 있습니다.", + ), + }, + Msg { + key: "settings.about.qq", + text: row( + "社区 QQ 群", + "社群 QQ 群", + "QQ community group", + "コミュニティ QQ グループ", + "커뮤니티 QQ 그룹", + ), + }, + Msg { + key: "settings.about.source", + text: row("源码", "原始碼", "Source", "ソース", "소스"), + }, + Msg { + key: "settings.about.tagline", + text: row( + "自然说话,完美书写", + "自然說話,完美書寫", + "Speak naturally, write perfectly", + "自然に話し、きれいに書く", + "자연스럽게 말하고, 정확하게 작성하세요", + ), + }, + Msg { + key: "settings.advanced.local_asr_desc", + text: row( + "把转写从云端切到本机推理。仅推荐离线 / 隐私敏感场景。", + "把轉寫從雲端切到本機推理。僅推薦離線 / 隱私敏感場景。", + "Move transcription from cloud ASR to on-device inference. Offline / privacy-sensitive use only.", + "転写をクラウドから本機推論に切り替えます。オフライン/プライバシー重視向け。", + "전사를 클라우드에서 로컬 추론으로 전환합니다. 오프라인 / 프라이버시용에만 권장됩니다.", + ), + }, + Msg { + key: "settings.advanced.multimodal_pipeline_title_hint", + text: row( + "用单个多模态模型一步完成语音识别;与传统 ASR + LLM 配置完全隔离。", + "用單一多模態模型一步完成語音辨識;與傳統 ASR + LLM 設定完全隔離。", + "One-pass audio recognition with a single multimodal model; traditional ASR + LLM configuration is fully isolated from it.", + "1つのマルチモーダルモデルで音声認識を一括実行。従来の ASR + LLM 設定から完全に分離されます。", + "단일 멀티모달 모델로 음성 인식을 한 번에 처리합니다. 기존 ASR + LLM 설정과 완전히 분리됩니다.", + ), + }, + Msg { + key: "settings.advanced.platform_not_supported", + text: row( + "该平台暂未支持本地 ASR 模型集成。", + "該平臺暫未支援本地 ASR 模型整合。", + "Local ASR model integration is not supported on this platform.", + "このプラットフォームではローカル ASR モデル統合に対応していません。", + "이 플랫폼에서는 로컬 ASR 모델 통합이 아직 지원되지 않습니다.", + ), + }, + Msg { + key: "settings.advanced.streaming_insert_desc", + text: row( + "逐字实时插入,降低感知延迟。不满足条件时回落到一次性粘贴。", + "逐字即時插入,降低感知延遲。不滿足條件時回落到一次性貼上。", + "Streams text to cursor character by character, reducing perceived latency. Falls back to one-shot paste when conditions are not met.", + "逐字リアルタイム挿入で体感遅延を低減。条件不一致時はワンショット貼り付けにフォールバック。", + "실시간 글자별 삽입으로 체감 지연 감소. 조건 불충족 시 일괄 붙여넣기로 전환.", + ), + }, + Msg { + key: "settings.advanced.streaming_insert_label", + text: row( + "流式输入", + "流式輸入", + "Streaming insertion", + "ストリーミング入力", + "스트리밍 입력", + ), + }, + Msg { + key: "settings.advanced.streaming_insert_save_clipboard_label", + text: row( + "同步到剪贴板", + "同步到剪貼簿", + "Copy to clipboard", + "クリップボードに保存", + "클립보드에 저장", + ), + }, + + Msg { + key: "settings.channels.add", + text: row("添加渠道", "新增渠道", "Add channel", "チャネルを追加", "채널 추가"), + }, + Msg { + key: "settings.channels.asr_title", + text: row( + "语音识别渠道", + "語音辨識渠道", + "Speech recognition channels", + "音声認識チャンネル", + "음성 인식 채널", + ), + }, + Msg { + key: "settings.channels.create", + text: row("创建", "建立", "Create", "作成", "만들기"), + }, + Msg { + key: "settings.channels.current", + text: row( + "当前使用", + "目前使用", + "Currently used", + "使用中", + "현재 사용 중", + ), + }, + Msg { + key: "settings.channels.delete", + text: row( + "删除渠道", + "刪除渠道", + "Delete channel", + "チャネルを削除", + "채널 삭제", + ), + }, + Msg { + key: "settings.channels.disabled", + text: row("已停用", "已停用", "Disabled", "無効", "사용 안 함"), + }, + Msg { + key: "settings.channels.elapsed", + text: row("耗时 {} ms", "耗時 {} ms", "Took {} ms", "所要時間 {} ms", "소요 시간 {} ms"), + }, + Msg { + key: "settings.channels.empty", + text: row( + "还没有渠道。点击「添加渠道」,连接你的第一个服务。", + "還沒有渠道。點下面的「新增渠道」建立一個。", + "No channels yet. Choose \"Add channel\" to connect your first service.", + "チャネルがまだありません。「チャネルを追加」で最初のサービスを接続しましょう。", + "아직 채널이 없습니다. \"채널 추가\"로 첫 서비스를 연결하세요.", + ), + }, + Msg { + key: "settings.channels.enabled", + text: row("启用", "啟用", "Enabled", "有効", "사용"), + }, + Msg { + key: "settings.channels.failed", + text: row( + "验证失败 · {}", + "驗證失敗 · {}", + "Check failed · {}", + "確認に失敗 · {}", + "확인 실패 · {}", + ), + }, + Msg { + key: "settings.channels.llm_title", + text: row( + "文字处理渠道", + "文字處理渠道", + "Text processing channels", + "テキスト処理チャンネル", + "텍스트 처리 채널", + ), + }, + Msg { + key: "settings.channels.name_hint", + text: row( + "名称仅用于区分同一供应商的多个渠道,不影响模型或连接。", + "名稱僅用於區分同一供應商的多個渠道,不影響模型或連線。", + "This name distinguishes channels from the same provider. It does not affect the model or connection.", + "同じプロバイダーのチャンネルを区別するための名前です。モデルや接続には影響しません。", + "같은 제공업체의 여러 채널을 구분하는 이름입니다. 모델이나 연결에는 영향을 주지 않습니다.", + ), + }, + Msg { + key: "settings.channels.name_placeholder", + text: row( + "例如:硅基流动-主号", + "例如:矽基流動-主帳號", + "e.g. SiliconFlow — main key", + "例:SiliconFlow — メインキー", + "예: SiliconFlow — 메인 키", + ), + }, + Msg { + key: "settings.channels.not_verified", + text: row( + "尚未验证", + "尚未驗證", + "Not checked yet", + "未確認", + "아직 확인하지 않음", + ), + }, + Msg { + key: "settings.channels.order_hint", + text: row( + "列表中第一个启用的渠道用于请求。拖动调整顺序;停用的渠道移到末尾。", + "拖曳排序,最上面的優先使用;關掉的渠道會自動排到末尾。", + "Requests use the first enabled channel. Drag to reorder; disabled channels move to the bottom.", + "有効なチャネルのうち、先頭のものを使用します。ドラッグで順序を変更できます。無効なチャネルは末尾に移動します。", + "사용 중인 채널 중 맨 위의 채널로 요청합니다. 드래그로 순서를 바꾸면 사용하지 않는 채널은 맨 아래로 이동합니다.", + ), + }, + Msg { + key: "settings.channels.passed", + text: row("验证通过", "驗證通過", "Check passed", "確認に成功", "확인 성공"), + }, + Msg { + key: "settings.channels.verify", + text: row("验证", "驗證", "Verify", "検証", "검증"), + }, + Msg { + key: "settings.coding_agent.desc", + text: row( + "按住一个键说话,由所选 Agent 帮你操作电脑。仅 macOS。", + "按住一個鍵說話,由所選 Agent 幫你操作電腦。僅 macOS。", + "Hold a key, speak, and your selected agent operates your computer. macOS only.", + "キーを押して話すと、選択した Agent が PC を操作します。macOS のみ。", + "키를 누르고 말하면 선택한 Agent가 PC를 조작합니다. macOS 전용.", + ), + }, + Msg { + key: "settings.coding_agent.title", + text: row( + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + ), + }, + Msg { + key: "settings.coding_console.desc", + text: row( + "检测本机 Claude Code 与 MCP(computer use)状态,并护栏化地无头跑一次 Claude、流式查看输出与用量。", + "偵測本機 Claude Code 與 MCP(computer use)狀態,並以護欄方式無頭執行一次 Claude、串流檢視輸出與用量。", + "Detect your local Claude Code and MCP (computer use) status, then run Claude headlessly behind guardrails and watch the streamed output and cost.", + "ローカルの Claude Code と MCP(computer use)の状態を検出し、ガードレール付きで Claude をヘッドレス実行して、出力とコストをストリーミング表示します。", + "로컬 Claude Code 와 MCP(computer use) 상태를 감지하고, 가드레일 아래에서 Claude 를 헤드리스로 실행하여 출력과 비용을 스트리밍으로 확인합니다.", + ), + }, + Msg { + key: "settings.coding_console.detect", + text: row("检测", "偵測", "Detect", "検出", "감지"), + }, + Msg { + key: "settings.coding_console.status", + text: row("状态", "狀態", "Status", "状態", "상태"), + }, + Msg { + key: "settings.data_storage.desc", + text: row( + "本机保留的历史会话与对话上下文。", + "本機保留的歷史會話與對話上下文。", + "Conversation history and context kept on this device.", + "この端末に保存される会話履歴とコンテキスト。", + "이 기기에 보관되는 대화 기록과 컨텍스트.", + ), + }, + Msg { + key: "settings.debug.desc", + text: row( + "排查识别问题时使用,平时无需开启。", + "排查辨識問題時使用,平時無需開啟。", + "For troubleshooting recognition issues; off by default.", + "認識の問題を調査するときに使用。通常はオフのままで構いません。", + "인식 문제를 진단할 때 사용합니다. 평소에는 꺼두어도 됩니다.", + ), + }, + Msg { + key: "settings.language.desc", + text: row( + "切换 UI 显示语言。当前会话即时生效,下次启动自动沿用。", + "切換 UI 顯示語言。當前會話即時生效,下次啟動自動沿用。", + "Switch the UI language. Applies to the current session immediately and persists across launches.", + "UI の表示言語を切り替えます。現在のセッションに即時反映され、次回起動時も維持されます。", + "UI 표시 언어를 전환합니다. 현재 세션에 즉시 반영되며 다음 실행에도 유지됩니다.", + ), + }, + Msg { + key: "settings.language.en", + text: row("English", "English", "English", "English", "English"), + }, + Msg { + key: "settings.language.follow_system", + text: row( + "跟随系统", + "跟隨系統", + "Follow system", + "システムに従う", + "시스템 따라가기", + ), + }, + Msg { + key: "settings.language.ja", + text: row("日本語 (Beta)", "日本語 (Beta)", "日本語 (Beta)", "日本語 (Beta)", "日本語 (Beta)"), + }, + Msg { + key: "settings.language.ko", + text: row("한국어 (Beta)", "한국어 (Beta)", "한국어 (Beta)", "한국어 (Beta)", "한국어 (Beta)"), + }, + Msg { + key: "settings.language.restart_hint", + text: row( + "部分原生菜单(系统托盘等)可能需要重启 App 才会切换。", + "部分原生選單(系統託盤等)可能需要重啟 App 才會切換。", + "Some native menus (system tray, etc.) may require an app restart to fully switch.", + "一部のネイティブメニュー(トレイ等)は再起動後に反映されます。", + "일부 네이티브 메뉴(트레이 등)는 앱 재시작 후 반영될 수 있습니다.", + ), + }, + Msg { + key: "settings.language.zh_tw", + text: row("繁體中文", "繁體中文", "繁體中文", "繁體中文", "繁體中文"), + }, + Msg { + key: "settings.marketplace.desc", + text: row( + "风格市场的上传身份。浏览与安装风格在「风格」页内完成。", + "風格市集的上傳身份。瀏覽與安裝風格在「風格」頁內完成。", + "Upload identity for the style marketplace. Browse and install styles on the Styles page.", + "スタイルマーケットの投稿者 ID。スタイルの閲覧とインストールは「スタイル」ページで行います。", + "스타일 마켓 업로드 신원. 스타일 둘러보기와 설치는 「스타일」 페이지에서 합니다.", + ), + }, + Msg { + key: "settings.marketplace.github.sign_in", + text: row( + "用 GitHub 账号登录", + "用 GitHub 帳號登入", + "Sign in with GitHub", + "GitHub でログイン", + "GitHub로 로그인", + ), + }, + Msg { + key: "settings.permissions.acc_label", + text: row( + "辅助功能", + "輔助功能", + "Accessibility", + "アクセシビリティ", + "접근성", + ), + }, + Msg { + key: "settings.permissions.desc_no_acc", + text: row( + "麦克风必需;全局快捷键状态用来检测 native hook 是否运行。", + "OpenLess 需要麥克風可用,並依賴全域快捷鍵監聽狀態判斷 native hook 是否正常工作。", + "OpenLess needs microphone access and uses the global hotkey listener state to verify the native hook is running.", + "OpenLess はマイクへのアクセスと、グローバルショートカット監視状態を通じてネイティブフックの正常動作を判定する必要があります。", + "OpenLess 는 마이크 사용과 전역 단축키 감지 상태를 통해 네이티브 후크의 정상 동작을 판정해야 합니다.", + ), + }, + Msg { + key: "settings.permissions.granted", + text: row("已授权", "已授權", "Granted", "許可済み", "허용됨"), + }, + Msg { + key: "settings.permissions.hotkey_label", + text: row( + "全局快捷键", + "全域快捷鍵", + "Global hotkey", + "グローバルショートカット", + "전역 단축키", + ), + }, + Msg { + key: "settings.permissions.indeterminate", + text: row("未确定", "未確定", "Undetermined", "未確定", "미결정"), + }, + Msg { + key: "settings.permissions.mic_label", + text: row("麦克风", "麥克風", "Microphone", "マイク", "마이크"), + }, + Msg { + key: "settings.permissions.network_label", + text: row("网络", "網路", "Network", "ネットワーク", "네트워크"), + }, + Msg { + key: "settings.permissions.network_ok", + text: row("可用", "可用", "Available", "利用可能", "사용 가능"), + }, + Msg { + key: "settings.permissions.open_system", + text: row( + "打开系统设置", + "開啟系統設定", + "Open System Settings", + "システム設定を開く", + "시스템 설정 열기", + ), + }, + Msg { + key: "settings.providers.credential_storage_notice", + text: row( + "凭据保存在系统凭据库中。", + "憑據儲存在系統憑據庫中。", + "Credentials are stored in the OS credential vault.", + "資格情報は OS の資格情報ストアに保存されます。", + "자격 증명은 OS 자격 증명 저장소에 보관됩니다.", + ), + }, + Msg { + key: "settings.recording.audio_cue_label", + text: row( + "录音提示音", + "錄音提示音", + "Recording start sound", + "録音開始音", + "녹음 시작음", + ), + }, + Msg { + key: "settings.recording.audio_recording_max_entries_label", + text: row( + "原始录音保留条数", + "原始錄音保留條數", + "Max raw recordings", + "元音声の保持件数", + "원본 녹음 보관 개수", + ), + }, + Msg { + key: "settings.recording.history_group_title", + text: row( + "历史与上下文", + "歷史與上下文", + "History & context", + "履歴とコンテキスト", + "기록 및 컨텍스트", + ), + }, + Msg { + key: "settings.recording.history_max_entries_label", + text: row( + "历史条数上限", + "歷史條數上限", + "Max history entries", + "履歴件数の上限", + "기록 개수 상한", + ), + }, + Msg { + key: "settings.recording.history_retention_label", + text: row( + "历史保留天数", + "歷史保留天數", + "History retention (days)", + "履歴保持期間(日)", + "기록 보관 기간(일)", + ), + }, + Msg { + key: "settings.recording.hotkey_label", + text: row( + "录音快捷键", + "錄音快捷鍵", + "Recording hotkey", + "録音ショートカット", + "녹음 단축키", + ), + }, + Msg { + key: "settings.recording.microphone_label", + text: row( + "首选麦克风", + "首選麥克風", + "Preferred microphone", + "優先マイク", + "기본 선택 마이크", + ), + }, + Msg { + key: "settings.recording.paste_shortcut_ctrl_shift_v", + text: row( + "Ctrl+Shift+V(kitty / alacritty / wezterm / 多数终端)", + "Ctrl+Shift+V(kitty / alacritty / wezterm / 多數終端)", + "Ctrl+Shift+V (kitty / alacritty / wezterm / most terminals)", + "Ctrl+Shift+V(kitty / alacritty / wezterm / ほとんどのターミナル)", + "Ctrl+Shift+V (kitty / alacritty / wezterm / 대부분 터미널)", + ), + }, + Msg { + key: "settings.recording.paste_shortcut_ctrl_v", + text: row( + "Ctrl+V(默认 / 多数应用)", + "Ctrl+V(預設 / 多數應用)", + "Ctrl+V (default / most apps)", + "Ctrl+V(既定 / ほとんどのアプリ)", + "Ctrl+V (기본 / 대부분 앱)", + ), + }, + Msg { + key: "settings.recording.paste_shortcut_shift_insert", + text: row( + "Shift+Insert(xterm / urxvt)", + "Shift+Insert(xterm / urxvt)", + "Shift+Insert (xterm / urxvt)", + "Shift+Insert(xterm / urxvt)", + "Shift+Insert (xterm / urxvt)", + ), + }, + Msg { + key: "settings.recording.record_audio_for_debug_label", + text: row( + "保留原始录音(调试)", + "保留原始錄音(除錯)", + "Keep raw recording (debug)", + "元の録音を保持(デバッグ)", + "원본 녹음 보관(디버그)", + ), + }, + Msg { + key: "settings.recording.restore_clipboard_label", + text: row( + "插入后恢复剪贴板", + "插入後還原剪貼簿", + "Restore clipboard after insert", + "入力後にクリップボードを復元", + "입력 후 클립보드 복원", + ), + }, + Msg { + key: "settings.recording.silence_auto_stop_label", + text: row( + "静音后自动停止", + "靜音後自動停止", + "Auto-stop after silence", + "無音で自動停止", + "침묵 시 자동 중지", + ), + }, + Msg { + key: "settings.recording.silence_auto_stop_seconds_label", + text: row( + "静音时长", + "靜音時長", + "Silence duration", + "無音の長さ", + "침묵 시간", + ), + }, + Msg { + key: "settings.recording.silence_auto_stop_seconds_value", + text: row("{} 秒", "{} 秒", "{}s", "{} 秒", "{}초"), + }, + Msg { + key: "settings.recording.start_minimized_label", + text: row( + "启动时静默运行", + "啟動時靜默執行", + "Start minimized (no main window)", + "起動時にメインウィンドウを表示しない", + "시작 시 메인 창 숨기기", + ), + }, + Msg { + key: "settings.recording.startup_at_boot", + text: row( + "开机自启", + "開機自啟", + "Launch at login", + "起動時に自動起動", + "부팅 시 자동 시작", + ), + }, + Msg { + key: "settings.remote_input.default_mode_label", + text: row( + "默认录音方式", + "預設錄音方式", + "Default recording mode", + "既定の録音方式", + "기본 녹음 방식", + ), + }, + Msg { + key: "settings.remote_input.enable_desc", + text: row( + "手机/平板浏览器连到电脑录音,语音实时落到电脑光标处(需 HTTPS,首次访问要信任证书)", + "手機/平板瀏覽器連到電腦錄音,語音即時落到電腦遊標處(需 HTTPS,首次存取要信任憑證)", + "Record from a phone/tablet browser on your LAN; speech is typed at your computer's cursor (HTTPS required; trust the certificate on first visit)", + "スマホ/タブレットのブラウザから PC に接続して録音し、音声を PC のカーソル位置にリアルタイムで入力します(HTTPS が必要。初回アクセス時は証明書を信頼してください)", + "휴대폰/태블릿 브라우저를 PC에 연결해 녹음하고, 음성을 PC 커서 위치에 실시간으로 입력합니다(HTTPS 필요, 첫 접속 시 인증서를 신뢰해야 함)", + ), + }, + Msg { + key: "settings.remote_input.mode_hold", + text: row("按住说话", "按住說話", "Hold to talk", "押し続けて話す", "눌러서 말하기"), + }, + Msg { + key: "settings.remote_input.mode_toggle", + text: row( + "点击切换", + "點選切換", + "Tap to toggle", + "タップで切替", + "탭하여 전환", + ), + }, + Msg { + key: "settings.shortcuts.agent_voice", + text: row( + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + ), + }, + Msg { + key: "settings.shortcuts.cancel", + text: row( + "取消本次录音", + "取消本次錄音", + "Cancel current recording", + "本回の録音をキャンセル", + "이번 녹음 취소", + ), + }, + Msg { + key: "settings.shortcuts.desc_no_acc", + text: row( + "所有快捷键全局生效。若无响应,请在权限页查看全局快捷键监听状态。", + "所有快捷鍵全域生效。若無響應,請在權限頁檢視全域快捷鍵監聽狀態。", + "All shortcuts apply globally. If unresponsive, check the global hotkey status in Permissions.", + "すべてのショートカットはグローバルで有効。応答がない場合は権限ページでグローバルショートカット監視の状態を確認してください。", + "모든 단축키는 전역에서 작동. 응답이 없으면 권한 페이지에서 전역 단축키 감지 상태를 확인해 주세요.", + ), + }, + Msg { + key: "settings.shortcuts.open_app", + text: row( + "打开 OpenLess", + "開啟 OpenLess", + "Open OpenLess", + "OpenLess を開く", + "OpenLess 열기", + ), + }, + Msg { + key: "settings.shortcuts.start_stop", + text: row( + "开始 / 停止录音", + "開始 / 停止錄音", + "Start / Stop recording", + "録音開始 / 停止", + "녹음 시작 / 정지", + ), + }, + Msg { + key: "settings.shortcuts.style_pack_add", + text: row( + "添加风格快捷键", + "新增風格快捷鍵", + "Add style shortcut", + "スタイルショートカットを追加", + "스타일 단축키 추가", + ), + }, + Msg { + key: "settings.shortcuts.style_pack_title", + text: row( + "风格直达快捷键", + "風格直達快捷鍵", + "Style shortcuts", + "スタイル直行ショートカット", + "스타일 바로가기 단축키", + ), + }, + Msg { + key: "settings.shortcuts.switch_style", + text: row( + "切换到上一个风格", + "切換到上一個風格", + "Switch to previous style", + "前のスタイルに切り替え", + "이전 스타일로 전환", + ), + }, + Msg { + key: "settings.shortcuts.title", + text: row( + "快捷键设置", + "快捷鍵設定", + "Shortcut settings", + "ショートカット設定", + "단축키 설정", + ), + }, + Msg { + key: "settings.theme.activity_heatmap_label", + text: row( + "概览页显示年度活动热力图", + "概覽頁顯示年度活動熱力圖", + "Show annual activity heatmap on Overview", + "概要ページに年間アクティビティを表示", + "개요 페이지에 연간 활동 표시", + ), + }, + Msg { + key: "settings.theme.conservative_layout_label", + text: row( + "保守排版", + "保守排版", + "Conservative layout", + "保守レイアウト", + "보수적 레이아웃", + ), + }, + Msg { + key: "settings.theme.stacked_row_layout_label", + text: row( + "易读布局(防溢出换行)", + "易讀佈局(防溢出換行)", + "Readable layout (wrap rows)", + "読みやすいレイアウト(はみ出し防止)", + "읽기 쉬운 레이아웃(넘침 방지 줄바꿈)", + ), + }, + Msg { + key: "settings.theme.system", + text: row( + "跟随系统", + "跟隨系統", + "Follow system", + "システムに従う", + "시스템 따르기", + ), + }, + Msg { + key: "settings.theme.title", + text: row("外观", "外觀", "Appearance", "外観", "모양"), + }, + Msg { + key: "vocab.delete_selected", + text: row( + "删除已选({})", + "刪除已選({})", + "Delete selected ({})", + "選択項目を削除({})", + "선택 항목 삭제({})", + ), + }, + Msg { + key: "vocab.select_all_visible", + text: row( + "选择当前结果", + "選取目前結果", + "Select current results", + "現在の結果を選択", + "현재 결과 선택", + ), + }, + Msg { + key: "vocab.selected_count", + text: row( + "已选择 {} 个词", + "已選取 {} 個詞", + "{} words selected", + "{} 語を選択中", + "단어 {}개 선택됨", + ), + }, + Msg { + key: "settings.providers.presets.ark", + text: row( + "ARK(火山方舟)", + "ARK(火山方舟)", + "ARK (Volcengine Ark)", + "ARK(Volcengine Ark)", + "ARK (Volcengine Ark)", + ), + }, + Msg { key: "settings.providers.presets.deepseek", text: row("DeepSeek", "DeepSeek", "DeepSeek", "DeepSeek", "DeepSeek") }, + Msg { + key: "settings.providers.presets.siliconflow", + text: row("硅基流动", "矽基流動", "SiliconFlow", "SiliconFlow", "SiliconFlow"), + }, + Msg { key: "settings.providers.presets.atlascloud", text: row("Atlas Cloud", "Atlas Cloud", "Atlas Cloud", "Atlas Cloud", "Atlas Cloud") }, + Msg { key: "settings.providers.presets.openai", text: row("OpenAI", "OpenAI", "OpenAI", "OpenAI", "OpenAI") }, + Msg { + key: "settings.providers.presets.gemini", + text: row( + "Google Gemini", + "Google Gemini", + "Google Gemini", + "Google Gemini", + "Google Gemini", + ), + }, + Msg { key: "settings.providers.presets.codexOAuth", text: row("Codex OAuth", "Codex OAuth", "Codex OAuth", "Codex OAuth", "Codex OAuth") }, + Msg { key: "settings.providers.presets.mimo", text: row("小米 MiMo", "小米 MiMo", "Xiaomi MiMo", "Xiaomi MiMo", "Xiaomi MiMo") }, + Msg { key: "settings.providers.presets.cometapi", text: row("CometAPI", "CometAPI", "CometAPI", "CometAPI", "CometAPI") }, + Msg { + key: "settings.providers.presets.openrouterFree", + text: row( + "OpenRouter(免费模型)", + "OpenRouter(免費模型)", + "OpenRouter (free models)", + "OpenRouter(無料モデル)", + "OpenRouter(무료 모델)", + ), + }, + Msg { key: "settings.providers.presets.orcarouter", text: row("OrcaRouter", "OrcaRouter", "OrcaRouter", "OrcaRouter", "OrcaRouter") }, + Msg { + key: "settings.providers.presets.alibabaCoding", + text: row( + "阿里云 Coding Plan", + "阿里雲 Coding Plan", + "Alibaba Cloud Coding Plan", + "Alibaba Cloud Coding Plan", + "Alibaba Cloud Coding Plan", + ), + }, + Msg { key: "settings.providers.presets.codingPlanX", text: row("CodingPlanX", "CodingPlanX", "CodingPlanX", "CodingPlanX", "CodingPlanX") }, + Msg { key: "settings.providers.presets.minimax", text: row("MiniMax(M3)", "MiniMax(M3)", "MiniMax (M3)", "MiniMax(M3)", "MiniMax (M3)") }, + Msg { + key: "settings.providers.presets.stepfun", + text: row( + "StepFun(阶跃星辰)", + "StepFun(階躍星辰)", + "StepFun", + "StepFun(階躍星辰)", + "StepFun", + ), + }, + Msg { key: "settings.providers.presets.opencode", text: row("OpenCode Zen", "OpenCode Zen", "OpenCode Zen", "OpenCode Zen", "OpenCode Zen") }, + Msg { + key: "settings.providers.presets.tencentTokenHub", + text: row( + "腾讯云 TokenHub", + "騰訊雲 TokenHub", + "Tencent Cloud TokenHub", + "Tencent Cloud TokenHub", + "Tencent Cloud TokenHub", + ), + }, + Msg { + key: "settings.providers.presets.customChatCompletions", + text: row( + "自定义 · Chat Completions", + "自訂 · Chat Completions", + "Custom · Chat Completions", + "カスタム · Chat Completions", + "사용자 지정 · Chat Completions", + ), + }, + Msg { + key: "settings.providers.presets.customResponses", + text: row( + "自定义 · Responses", + "自訂 · Responses", + "Custom · Responses", + "カスタム · Responses", + "사용자 지정 · Responses", + ), + }, + Msg { + key: "settings.providers.presets.customMessages", + text: row( + "自定义 · Messages", + "自訂 · Messages", + "Custom · Messages", + "カスタム · Messages", + "사용자 지정 · Messages", + ), + }, + Msg { key: "settings.providers.presets.custom", text: row("自定义", "自定義", "Custom", "カスタム", "사용자 정의") }, + Msg { + key: "settings.providers.presets.asrVolcengine", + text: row( + "火山引擎 bigasr", + "火山引擎 bigasr", + "Volcengine bigasr", + "Volcengine bigasr", + "Volcengine bigasr", + ), + }, + Msg { + key: "settings.providers.presets.asrBailian", + text: row( + "阿里云百炼实时 ASR", + "阿里雲百煉即時 ASR", + "Alibaba Bailian realtime ASR", + "Alibaba Bailian リアルタイム ASR", + "Alibaba Bailian 실시간 ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrBailianQwen3", + text: row( + "阿里云百炼 Qwen3 实时 ASR", + "阿里雲百煉 Qwen3 即時 ASR", + "Bailian Qwen3 Realtime ASR", + "Bailian Qwen3 リアルタイム ASR", + "Bailian Qwen3 실시간 ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrBailianFunAsrFlash", + text: row( + "阿里云百炼 Fun-ASR-Flash(录音文件)", + "阿里雲百煉 Fun-ASR-Flash(錄音檔)", + "Bailian Fun-ASR-Flash (recorded file)", + "Bailian Fun-ASR-Flash(録音ファイル)", + "Bailian Fun-ASR-Flash (녹음 파일)", + ), + }, + Msg { + key: "settings.providers.presets.asrSiliconflow", + text: row( + "硅基流动 SenseVoice", + "矽基流動 SenseVoice", + "SiliconFlow SenseVoice", + "SiliconFlow SenseVoice", + "SiliconFlow SenseVoice", + ), + }, + Msg { + key: "settings.providers.presets.asrStepfun", + text: row( + "阶跃星辰 StepAudio", + "階躍星辰 StepAudio", + "StepFun StepAudio ASR", + "StepFun StepAudio ASR", + "StepFun StepAudio ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrZhipu", + text: row( + "智谱 GLM-ASR", + "智譜 GLM-ASR", + "Zhipu GLM-ASR", + "Zhipu GLM-ASR", + "Zhipu GLM-ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrGroq", + text: row( + "Groq Whisper-large-v3", + "Groq Whisper-large-v3", + "Groq Whisper-large-v3", + "Groq Whisper-large-v3", + "Groq Whisper-large-v3", + ), + }, + Msg { + key: "settings.providers.presets.asrWhisper", + text: row( + "OpenAI Whisper(兼容)", + "OpenAI Whisper(相容)", + "OpenAI Whisper (compatible)", + "OpenAI Whisper(互換)", + "OpenAI Whisper(호환)", + ), + }, + Msg { + key: "settings.providers.presets.asrOpenrouter", + text: row( + "OpenRouter Whisper", + "OpenRouter Whisper", + "OpenRouter Whisper", + "OpenRouter Whisper", + "OpenRouter Whisper", + ), + }, + Msg { key: "settings.providers.presets.asrZenmux", text: row("ZenMux", "ZenMux", "ZenMux", "ZenMux", "ZenMux") }, + Msg { + key: "settings.providers.presets.asrOpenAiCompatible", + text: row( + "自定义 OpenAI 兼容", + "自訂 OpenAI 相容", + "Custom OpenAI-compatible", + "カスタム OpenAI 互換", + "커스텀 OpenAI 호환", + ), + }, + Msg { + key: "settings.providers.presets.asrXiaomiMimo", + text: row( + "小米 MiMo ASR", + "小米 MiMo ASR", + "Xiaomi MiMo ASR", + "Xiaomi MiMo ASR", + "Xiaomi MiMo ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrIflytek", + text: row( + "讯飞实时语音转写", + "訊飛即時語音轉寫", + "iFlytek Realtime ASR", + "iFlytek リアルタイム音声認識", + "iFlytek 실시간 음성 인식", + ), + }, + Msg { + key: "settings.providers.presets.asrTencentCloud", + text: row( + "腾讯云混元实时 ASR", + "騰訊雲混元即時 ASR", + "Tencent Cloud Hunyuan Realtime ASR", + "Tencent Cloud Hunyuan リアルタイム ASR", + "Tencent Cloud Hunyuan 실시간 ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrElevenLabs", + text: row( + "ElevenLabs Scribe", + "ElevenLabs Scribe", + "ElevenLabs Scribe", + "ElevenLabs Scribe", + "ElevenLabs Scribe", + ), + }, + Msg { + key: "settings.providers.presets.asrSherpaOnnxLocal", + text: row( + "本地 sherpa-onnx(实验性)", + "本地 sherpa-onnx(實驗性)", + "Local sherpa-onnx (Experimental)", + "ローカル sherpa-onnx(実験的)", + "로컬 sherpa-onnx(실험적)", + ), + }, + Msg { + key: "settings.providers.presets.asrFoundryLocalWhisper", + text: row( + "本地 Whisper(Foundry Local)", + "本地 Whisper(Foundry Local)", + "Local Whisper (Foundry Local)", + "ローカル Whisper(Foundry Local)", + "로컬 Whisper(Foundry Local)", + ), + }, + Msg { + key: "settings.providers.presets.asrLocalWhisper", + text: row( + "本地 Whisper(批量解码)", + "本地 Whisper(批次解碼)", + "Local Whisper (batch)", + "ローカル Whisper(バッチ)", + "로컬 Whisper(배치)", + ), + }, + Msg { + key: "settings.providers.presets.asrLocalQwen3", + text: row( + "本地 Qwen3-ASR", + "本地 Qwen3-ASR", + "Local Qwen3-ASR", + "ローカル Qwen3-ASR", + "로컬 Qwen3-ASR", + ), + }, + Msg { + key: "settings.providers.presets.asrLocalQwen3Mlx", + text: row( + "本地 Qwen3-ASR(MLX / Metal)", + "本地 Qwen3-ASR(MLX / Metal)", + "Local Qwen3-ASR (MLX / Metal)", + "ローカル Qwen3-ASR(MLX / Metal)", + "로컬 Qwen3-ASR(MLX / Metal)", + ), + }, + Msg { + key: "settings.providers.presets.asrLocalQwen3C", + text: row( + "本地 Qwen3-ASR(C / CPU)", + "本地 Qwen3-ASR(C / CPU)", + "Local Qwen3-ASR (C / CPU)", + "ローカル Qwen3-ASR(C / CPU)", + "로컬 Qwen3-ASR(C / CPU)", + ), + }, + Msg { + key: "settings.providers.presets.asrAppleSpeech", + text: row( + "Apple 语音(macOS)", + "Apple 語音(macOS)", + "Apple Speech (macOS)", + "Apple 音声認識 (macOS)", + "Apple 음성 (macOS)", + ), + }, + Msg { + key: "settings.providers.presets.omniOpenai", + text: row( + "OpenAI(支持音频)", + "OpenAI(支援音訊)", + "OpenAI (audio-capable)", + "OpenAI(音声対応)", + "OpenAI (오디오 지원)", + ), + }, + Msg { + key: "settings.providers.presets.omniGemini", + text: row( + "Google Gemini", + "Google Gemini", + "Google Gemini", + "Google Gemini", + "Google Gemini", + ), + }, + Msg { + key: "settings.providers.presets.omniDashscope", + text: row( + "阿里云百炼 Omni", + "阿里雲百煉 Omni", + "Alibaba DashScope Omni", + "Alibaba DashScope Omni", + "Alibaba DashScope Omni", + ), + }, + + Msg { + key: "style.pack.activate", + text: row("激活", "啟用", "Activate", "有効化", "활성화"), + }, + Msg { + key: "style.pack.add_pack_tile_hint", + text: row( + "从空白模板开始。", + "從空白範本開始。", + "Start from a blank template.", + "空のテンプレートから開始。", + "빈 템플릿으로 시작.", + ), + }, + Msg { + key: "style.pack.add_pack_tile_title", + text: row("新建风格包", "新建風格包", "New Pack", "新規パック", "새 팩"), + }, + Msg { + key: "style.pack.dictation_prompt_hint", + text: row( + "用于录音转写后的 ASR 文本;这里可以写口语整理、ASR 错字纠正和专有名词还原规则。", + "用於錄音轉寫後的 ASR 文字;這裡可以寫口語整理、ASR 錯字糾正和專有名詞還原規則。", + "For ASR text after dictation; write spoken-language cleanup, ASR typo fixes and term restoration rules here.", + "録音の書き起こし後のASRテキスト用。口語整理、ASR誤字修正、固有名詞の復元ルールをここに書けます。", + "녹음 후 받아쓰기한 ASR 텍스트용. 구어 정리, ASR 오타 수정, 고유명사 복원 규칙을 여기에 작성하세요.", + ), + }, + Msg { + key: "style.pack.edit", + text: row("编辑", "編輯", "Edit", "編集", "편집"), + }, + Msg { + key: "style.pack.export_short", + text: row("导出", "匯出", "Export", "エクスポート", "내보내기"), + }, + Msg { + key: "style.pack.import_zip", + text: row("导入 ZIP", "匯入 ZIP", "Import ZIP", "ZIP をインポート", "ZIP 가져오기"), + }, + Msg { + key: "style.pack.imported", + text: row("导入", "匯入", "Imported", "インポート", "가져옴"), + }, + Msg { + key: "style.pack.list_count", + text: row("{} 个风格包", "{} 個風格包", "{} packs", "{} 個", "{}개"), + }, + Msg { + key: "style.pack.list_desc", + text: row( + "浏览和切换风格包。", + "瀏覽和切換風格包。", + "Browse and switch packs.", + "パックを閲覧・切替。", + "팩 둘러보기·전환.", + ), + }, + Msg { + key: "style.pack.list_title", + text: row("本地风格包", "本機風格包", "Local Packs", "ローカルパック", "로컬 팩"), + }, + Msg { + key: "marketplace.liked_empty", + text: row( + "你还没有赞过任何风格包", + "你還沒有讚過任何風格包", + "You have not liked any style packs yet", + "まだいいねしたパックがありません", + "아직 좋아요한 팩이 없습니다", + ), + }, + Msg { + key: "marketplace.liked_empty_hint", + text: row( + "点开任一风格包,红色星星点亮后会出现在这里", + "點開任一風格包,紅色星星點亮後會出現在這裡", + "Open any pack and tap the star — liked packs appear here", + "パックを開いて星をタップするとここに表示されます", + "팩을 열고 별을 누르면 여기에 표시됩니다", + ), + }, + Msg { + key: "modal.descriptions.about", + text: row( + "查看当前版本、更新渠道与自动更新设置。", + "查看目前版本、更新管道與自動更新設定。", + "View your version, update channel and automatic update settings.", + "現在のバージョン、更新チャンネル、自動更新を確認します。", + "현재 버전, 업데이트 채널 및 자동 업데이트 설정을 확인합니다.", + ), + }, + Msg { + key: "modal.descriptions.advanced", + text: row( + "按需配置 Less Computer、多模态与调试功能。", + "按需設定 Less Computer、多模態與除錯功能。", + "Configure Less Computer, multimodal processing and debugging as needed.", + "必要に応じて Less Computer、マルチモーダル処理、デバッグを設定します。", + "필요에 따라 Less Computer, 멀티모달 처리 및 디버깅을 설정합니다.", + ), + }, + Msg { + key: "modal.descriptions.appearance", + text: row( + "调整主题、页面排版和界面语言,让阅读更舒服。", + "調整主題、頁面排版和介面語言,讓閱讀更舒服。", + "Adjust the theme, page layout and interface language for comfortable reading.", + "テーマ、レイアウト、表示言語を読みやすく調整します。", + "테마, 페이지 배치, 인터페이스 언어를 편하게 읽도록 조정합니다.", + ), + }, + Msg { + key: "modal.descriptions.general", + text: row( + "选择麦克风、设置录音方式与文字输入,也可连接手机输入。", + "選擇麥克風、設定錄音方式與文字輸入,也可連接手機輸入。", + "Choose a microphone, adjust recording and text input, or connect your phone.", + "マイク、録音方法、文字入力を設定し、スマートフォンからの入力を接続します。", + "마이크, 녹음 방식, 텍스트 입력을 설정하고 휴대폰 입력을 연결합니다.", + ), + }, + Msg { + key: "modal.descriptions.privacy", + text: row( + "检查系统权限与连接状态,管理历史、录音和本地数据。", + "檢查系統權限與連線狀態,管理歷史、錄音和本機資料。", + "Check system permissions and connections. Manage history, recordings and local data.", + "システム権限と接続を確認し、履歴、録音、ローカルデータを管理します。", + "시스템 권한과 연결을 확인하고 기록, 녹음 및 로컬 데이터를 관리합니다.", + ), + }, + Msg { + key: "modal.descriptions.services", + text: row( + "选择语音识别与文字处理服务,管理渠道、本地模型和网络连接。", + "選擇語音辨識與文字處理服務,管理管道、本機模型和網路連線。", + "Choose speech recognition and text processing services. Manage channels, local models and connections.", + "音声認識と文章処理のサービス、チャンネル、ローカルモデル、接続を管理します。", + "음성 인식과 텍스트 처리 서비스, 채널, 로컬 모델 및 연결을 관리합니다.", + ), + }, + Msg { + key: "modal.descriptions.shortcuts", + text: row( + "设置各功能的触发方式,以及选中文字后的操作。", + "設定各功能的觸發方式,以及選取文字後的操作。", + "Set up shortcuts and choose what happens when you select text.", + "各機能のショートカットと、テキスト選択後の操作を設定します。", + "기능별 단축키와 텍스트 선택 후 동작을 설정합니다.", + ), + }, + Msg { + key: "modal.advanced_pages.debug", + text: row( + "保留调试录音、探测光标上下文和导出日志。", + "保留偵錯錄音、探測游標上下文與匯出日誌。", + "Keep debug recordings, inspect cursor context, and export logs.", + "デバッグ録音の保持、カーソル周辺の確認、ログの書き出しを行います。", + "디버그 녹음을 보관하고 커서 문맥을 확인하며 로그를 내보냅니다.", + ), + }, + Msg { + key: "modal.advanced_pages.less_computer", + text: row( + "选择 Agent,配置模型、权限与工作目录。", + "選擇 Agent,設定模型、權限與工作目錄。", + "Choose an agent and configure its model, permissions, and working directory.", + "Agent を選び、モデル・権限・作業ディレクトリを設定します。", + "Agent를 선택하고 모델, 권한, 작업 디렉터리를 설정합니다.", + ), + }, + Msg { + key: "modal.advanced_pages.multimodal", + text: row( + "管理多模态识别的实验性开关。", + "管理多模態辨識的實驗性開關。", + "Manage the experimental multimodal recognition switch.", + "実験的なマルチモーダル認識の有効・無効を設定します。", + "실험적 멀티모달 인식 기능의 사용 여부를 설정합니다.", + ), + }, + Msg { + key: "modal.service_views.omni", + text: row("多模态模型", "多模態模型", "Multimodal", "マルチモーダル", "멀티모달"), + }, + Msg { + key: "settings.advanced.multimodal_pipeline_hint", + text: row( + "开启后,「服务 → AI 提供商」页出现「传统模式 / 多模态模式」切换。传统 = ASR + LLM;多模态 = 单个支持音频的模型。两套配置分开存储、绝不共享凭据。", + "開啟後,「服務 → AI 提供者」頁出現「傳統模式 / 多模態模式」切換。傳統 = ASR + LLM;多模態 = 單一支援音訊的模型。兩套設定分開儲存、絕不共用憑證。", + "Adds a Traditional / Multimodal switch on the AI providers page. Traditional = ASR + LLM; Multimodal = one audio-capable model. The two configurations are stored separately and never share credentials.", + "有効にすると「サービス → AI プロバイダー」ページに従来 / マルチモーダルの切り替えが表示されます。従来 = ASR + LLM、マルチモーダル = 音声対応モデル1つ。設定は別々に保存され、認証情報を共有しません。", + "활성화하면 「서비스 → AI 공급자」 페이지에 전통 / 멀티모달 전환이 나타납니다. 전통 = ASR + LLM, 멀티모달 = 오디오 지원 모델 1개. 두 설정은 별도로 저장되며 자격 증명을 공유하지 않습니다.", + ), + }, + Msg { + key: "settings.language.label_desc", + text: row( + "选择「跟随系统」时按操作系统当前语言显示。", + "選擇「跟隨系統」時按作業系統當前語言顯示。", + "Choose \"Follow system\" to match the OS language at launch.", + "「システムに従う」を選ぶと OS の言語に合わせます。", + "\"시스템 따라가기\"를 선택하면 OS 언어를 따릅니다.", + ), + }, + Msg { + key: "settings.network.use_system_proxy_desc", + text: row( + "开启时请求跟随系统代理;关闭后所有网络请求直连(国内服务延迟通常更低),GitHub 登录、更新等境外服务可能连不上。实时语音流与 Less Computer 不受此开关影响。", + "開啟時請求跟隨系統代理;關閉後所有網路請求直連(國內服務延遲通常更低),GitHub 登入、更新等境外服務可能連不上。即時語音串流與 Less Computer 不受此開關影響。", + "When on, requests follow the system proxy. When off, all requests connect directly (usually lower latency for domestic services), but overseas services such as GitHub sign-in and updates may fail. Realtime voice streams and Less Computer are unaffected.", + "オンにするとリクエストはシステムプロキシを経由します。オフにするとすべて直接接続します(国内サービスの遅延が低くなる傾向)。GitHub ログインやアップデートなど海外サービスには接続できない場合があります。リアルタイム音声ストリームと Less Computer は影響を受けません。", + "켜면 요청이 시스템 프록시를 따릅니다. 끄면 모든 요청이 직결됩니다(국내 서비스는 보통 더 빠름). GitHub 로그인·업데이트 등 해외 서비스는 연결되지 않을 수 있습니다. 실시간 음성 스트림과 Less Computer는 영향을 받지 않습니다.", + ), + }, + Msg { + key: "settings.permissions.denied", + text: row("未授权", "未授權", "Not granted", "未許可", "허용되지 않음"), + }, + Msg { + key: "settings.permissions.not_applicable", + text: row("无需授权", "無需授權", "Not required", "権限不要", "권한 불필요"), + }, + Msg { + key: "settings.recording.audio_cue_desc", + text: row( + "按下热键开始录音时播放一段合成提示音,提醒已开始录音。胶囊隐藏时也会响。", + "按下熱鍵開始錄音時播放一段合成提示音,提醒已開始錄音。膠囊隱藏時也會響。", + "Play a short synthesized chime when you press the hotkey to start recording. Plays even when the capsule is hidden.", + "ホットキーで録音を開始するとき、合成した短い通知音を再生します。カプセルが非表示でも鳴ります。", + "단축키로 녹음을 시작할 때 합성된 짧은 알림음을 재생합니다. 캡슐이 숨겨져 있어도 재생됩니다.", + ), + }, + Msg { + key: "settings.recording.audio_recording_max_entries_desc", + text: row( + "本地保留 wav 文件数上限,留空 = 200。", + "本地保留 wav 檔案數上限,留空 = 200。", + "Max wav files retained locally. Blank = 200.", + "ローカル保持 wav ファイル上限。空欄 = 200。", + "로컬 보관 wav 파일 상한. 빈칸 = 200.", + ), + }, + Msg { + key: "settings.recording.combo_disable_hint", + text: row( + "核心快捷键不可停用,录音必须绑定一个热键", + "核心快捷鍵不可停用,錄音必須繫結一個快捷鍵", + "Core hotkey cannot be disabled — recording needs a hotkey", + "コアショートカットは無効化できません(録音にはショートカットが必須です)", + "핵심 단축키는 비활성화할 수 없습니다 (녹음에는 단축키가 필수입니다)", + ), + }, + Msg { + key: "settings.recording.microphone_desc", + text: row( + "选择优先输入设备。设备断开时自动切到系统默认。", + "選擇優先使用的輸入裝置。裝置暫時不可用時會使用系統預設麥克風,重新連線後自動切回首選裝置。", + "Choose the preferred input device; falls back to system default when unavailable.", + "優先して使用する入力デバイスを選択します。一時的に利用できない場合はシステムのデフォルトマイクを使い、再接続後に自動で優先デバイスへ戻します。", + "우선 사용할 입력 장치를 선택합니다. 장치를 일시적으로 사용할 수 없으면 시스템 기본 마이크를 사용하고, 다시 연결되면 자동으로 우선 장치로 돌아갑니다.", + ), + }, + Msg { + key: "settings.recording.mode_auto", + text: row("自动", "自動", "Auto", "自動", "자동"), + }, + Msg { + key: "settings.recording.mode_desc", + text: row( + "切换式按一次开始、再按一次结束;按住说话按下保持、松开结束。", + "切換式 = 按一次開始、再按一次結束;按住說話 = 按住開始、鬆開結束。", + "Toggle = tap once to start, again to stop. Push-to-talk = hold to record.", + "トグル式 = 1 回押して開始、もう 1 回押して終了;押し続けて話す = 押している間だけ録音。", + "토글 방식 = 한 번 누르면 시작, 다시 누르면 종료; 눌러서 말하기 = 누르고 있는 동안만 녹음.", + ), + }, + Msg { + key: "settings.recording.mode_hold", + text: row("按住说话", "按住說話", "Push-to-talk", "押し続けて話す", "눌러서 말하기"), + }, + Msg { + key: "settings.recording.mute_during_recording_desc", + text: row( + "录音期间临时静音系统输出,避免扬声器回音。", + "錄音期間臨時靜音系統輸出,避免揚聲器迴音。", + "Temporarily mute system output during voice input to avoid speaker echo.", + "録音中にシステム出力を一時的にミュートし、スピーカーのエコーを防ぎます。", + "녹음 중 시스템 출력을 일시적으로 음소거하여 스피커 에코를 방지합니다.", + ), + }, + Msg { + key: "settings.recording.paste_shortcut_desc", + text: row( + "插入时模拟按下的粘贴键,部分终端类应用需要 Ctrl+Shift+V(仅 Windows / Linux)。", + "插入時模擬按下的貼上鍵,部分終端類應用需要 Ctrl+Shift+V(僅 Windows / Linux)。", + "Which paste combo to simulate when inserting; some terminals need Ctrl+Shift+V (Windows / Linux only).", + "挿入時に模擬するペーストショートカット。一部のターミナルでは Ctrl+Shift+V が必要(Windows / Linux のみ)。", + "삽입 시 시뮬레이션할 붙여넣기 단축키. 일부 터미널은 Ctrl+Shift+V 가 필요 (Windows / Linux 만).", + ), + }, + Msg { + key: "settings.recording.polish_context_window_desc", + text: row( + "把最近 N 分钟内已润色的转写作为多轮上下文,0 = 关闭。", + "把最近 N 分鐘內已潤色的轉寫作為多輪上下文,0 = 關閉。", + "Use the last N minutes of polished transcripts as multi-turn context; 0 = disabled.", + "直近 N 分間の整文済み転写をマルチターン文脈として渡します。0 = 無効。", + "최근 N 분간 정리된 전사를 멀티턴 컨텍스트로 전달합니다. 0 = 비활성화.", + ), + }, + Msg { + key: "settings.recording.polish_context_window_label", + text: row( + "对话上下文窗口(分钟)", + "對話上下文視窗(分鐘)", + "Polish context window (minutes)", + "会話コンテキスト窓(分)", + "대화 컨텍스트 윈도(분)", + ), + }, + Msg { + key: "settings.recording.restore_clipboard_desc", + text: row( + "粘贴成功后恢复你原来的剪贴板内容(仅 Windows / Linux)。", + "貼上成功後還原你原來的剪貼簿內容(僅 Windows / Linux)。", + "Restore your original clipboard after a successful paste (Windows / Linux only).", + "ペースト成功後に元のクリップボード内容を復元(Windows / Linux のみ)。", + "붙여넣기 성공 후 원래 클립보드 내용을 복원합니다 (Windows / Linux 만).", + ), + }, + Msg { + key: "settings.recording.silence_auto_stop_desc", + text: row( + "仅切换模式生效。检测到语音后,连续静音达到所选时长即自动结束并提交;一直没说话则 10 秒后取消。默认关闭;第二次按键停止和 Esc 取消仍然有效。", + "僅切換模式生效。偵測到語音後,連續靜音達到所選時長即自動結束並提交;一直沒說話則 10 秒後取消。預設關閉;第二次按鍵停止和 Esc 取消仍然有效。", + "Toggle only. After speech is detected, recording stops and submits automatically once silence lasts the chosen duration. Off by default; a second hotkey press and Esc still work.", + "トグルモードのみ有効。音声を検出した後、無音が選択した時間続いたら録音を自動停止して送信します。一度も話さない場合は10秒後にキャンセル。既定ではオフで、2回目のキー押下による停止と Esc によるキャンセルは引き続き有効です。", + "토글 모드에서만 동작합니다. 음성이 감지된 후 선택한 시간 동안 침묵이 이어지면 녹음을 자동으로 종료하고 제출합니다. 말을 전혀 하지 않으면 10초 후 취소됩니다. 기본적으로 꺼져 있으며, 두 번째 키 누름으로 중지하고 Esc로 취소하는 동작은 그대로 유지됩니다.", + ), + }, + Msg { + key: "settings.remote_input.cert_fingerprint_copy", + text: row( + "复制完整指纹", + "複製完整指紋", + "Copy full fingerprint", + "指紋全体をコピー", + "전체 지문 복사", + ), + }, + Msg { + key: "settings.remote_input.cert_fingerprint_label", + text: row( + "本机根证书 SHA-256", + "本機根憑證 SHA-256", + "This computer's root CA SHA-256", + "このコンピューターのルート CA SHA-256", + "이 컴퓨터의 루트 CA SHA-256", + ), + }, + Msg { + key: "settings.remote_input.cert_fingerprint_unavailable", + text: row( + "完整指纹不可用。请勿安装或信任下载的证书。", + "完整指紋無法取得。請勿安裝或信任下載的憑證。", + "The full fingerprint is unavailable. Do not install or trust a downloaded certificate.", + "完全な指紋を取得できません。ダウンロードした証明書をインストールしたり信頼したりしないでください。", + "전체 지문을 확인할 수 없습니다. 다운로드한 인증서를 설치하거나 신뢰하지 마세요.", + ), + }, + Msg { + key: "settings.remote_input.cert_verify_hint", + text: row( + "在手机系统的证书详情中找到 SHA-256,与这里的全部 64 个字符逐一核对(忽略空格和冒号)。必须在开启完全信任前完成。网页、描述文件名称和标识不能证明证书身份;若不一致或无法查看完整指纹,请停止并移除已下载或安装的描述文件。", + "在手機系統的憑證詳細資訊中找到 SHA-256,與此處全部 64 個字元逐一核對(忽略空格和冒號)。必須在開啟完全信任前完成。網頁、描述檔名稱與識別碼不能證明憑證身分;若不一致或無法查看完整指紋,請停止並移除已下載或安裝的描述檔。", + "Find SHA-256 in the phone's system certificate details and compare all 64 characters with this value (ignore spaces and colons) before enabling full trust. A web page, profile name or identifier cannot prove identity. If the fingerprint differs or cannot be viewed in full, stop and remove the downloaded or installed profile.", + "スマートフォンのシステム証明書詳細にある SHA-256 の全 64 文字を、空白とコロンを除いてこの値と照合し、完全に信頼する前に確認してください。Web ページ、プロファイル名や識別子は身元の証明にはなりません。一致しない場合や全体を表示できない場合は中止し、ダウンロード済みまたはインストール済みのプロファイルを削除してください。", + "휴대폰 시스템의 인증서 상세 정보에서 SHA-256을 찾아, 완전한 신뢰를 켜기 전에 공백과 콜론을 제외한 64자 전체를 이 값과 비교하세요. 웹 페이지, 프로파일 이름이나 식별자는 신원 증명이 아닙니다. 일치하지 않거나 전체 지문을 볼 수 없으면 중단하고 다운로드했거나 설치한 프로파일을 제거하세요.", + ), + }, + Msg { + key: "settings.remote_input.pin_label", + text: row("配对码", "配對碼", "Pairing code", "ペアリングコード", "페어링 코드"), + }, + Msg { + key: "settings.remote_input.security_hint", + text: row( + "仅同一局域网可访问,需输入配对码;不用时建议关闭。", + "僅同一區域網路可存取,需輸入配對碼;不用時建議關閉。", + "Reachable only on the same LAN and requires the pairing code; turn it off when not in use.", + "同一 LAN からのみアクセス可能で、ペアリングコードの入力が必要です。使わないときはオフにすることを推奨します。", + "같은 LAN에서만 접속 가능하며 페어링 코드 입력이 필요합니다. 사용하지 않을 때는 끄는 것을 권장합니다.", + ), + }, + Msg { + key: "settings.remote_input.url_label", + text: row("访问网址", "存取網址", "Access URL", "アクセス URL", "접속 URL"), + }, + Msg { + key: "settings.selection_polish.direct_replace", + text: row( + "直接覆盖", + "直接覆蓋", + "Replace directly", + "直接置き換え", + "직접 교체", + ), + }, + Msg { + key: "settings.selection_polish.preview_confirm", + text: row( + "预览确认", + "預覽確認", + "Preview & confirm", + "プレビューして確認", + "미리보기 후 확인", + ), + }, + Msg { + key: "settings.selection_workspace.polish_delivery", + text: row( + "结果处理", + "結果處理", + "Result handling", + "結果の処理", + "결과 처리", + ), + }, + Msg { + key: "settings.selection_workspace.polish_hotkey", + text: row( + "选区助手快捷键", + "選區助手快捷鍵", + "Selection assistant shortcut", + "選択範囲アシスタントのショートカット", + "선택 영역 도우미 단축키", + ), + }, + Msg { + key: "settings.selection_workspace.polish_hotkey_desc", + text: row( + "关闭语音编辑时直接润色;开启语音编辑时按住口述指令(录音方式跟随全局设置)。", + "關閉語音編輯時直接潤色;開啟語音編輯時按住口述指令(錄音方式跟隨全域設定)。", + "Polishes directly when voice edit is off; hold to speak when voice edit is on (recording follows global settings).", + "音声編集オフ時は推敲、オン時は押しながら話す(録音方式はグローバル設定に従う)。", + "음성 편집 끄면 바로 다듬기, 켜면 누른 채 말하기(녹음 방식은 전역 설정 따름).", + ), + }, + Msg { + key: "settings.shortcuts.style_pack_desc", + text: row( + "为常用风格包各配一个快捷键,按下直接切换;停用中的包会自动启用。", + "為常用風格包各配一個快捷鍵,按下直接切換;停用中的包會自動啟用。", + "Bind a shortcut to each favorite style pack for one-press switching; disabled packs are re-enabled automatically.", + "よく使うスタイルパックにショートカットを割り当てて一発切替;無効中のパックは自動で有効化されます。", + "자주 쓰는 스타일 팩에 단축키를 지정해 한 번에 전환합니다. 비활성화된 팩은 자동으로 다시 활성화됩니다.", + ), + }, + Msg { + key: "modal.about.docs_btn", + text: row( + "openless.app/docs ↗", + "openless.app/docs ↗", + "openless.app/docs ↗", + "openless.app/docs ↗", + "openless.app/docs ↗", + ), + }, + Msg { + key: "modal.about.feedback_btn", + text: row( + "GitHub Issues ↗", + "GitHub Issues ↗", + "GitHub Issues ↗", + "GitHub Issues ↗", + "GitHub Issues ↗", + ), + }, + Msg { + key: "settings.coding_agent.coming_soon_note", + text: row( + "配置即时保存;热键触发与执行链路随后续版本生效。", + "設定即時儲存;熱鍵觸發與執行鏈路隨後續版本生效。", + "Config is saved now; hotkey triggering and the execution flow land in a later version.", + "設定はすぐ保存されます。ホットキー起動と実行フローは今後のバージョンで対応。", + "설정은 즉시 저장됩니다. 단축키 트리거와 실행 흐름은 이후 버전에서 제공됩니다.", + ), + }, + Msg { + key: "settings.coding_agent.exe", + text: row( + "可执行文件路径", + "可執行檔路徑", + "Executable path", + "実行ファイルのパス", + "실행 파일 경로", + ), + }, + Msg { + key: "settings.coding_agent.hotkey_hint", + text: row( + "开启后,按住快捷键说话,松开后由所选 Agent 处理并把结果显示在胶囊里。", + "開啟後,按住快捷鍵說話,放開後由所選 Agent 處理並把結果顯示在膠囊裡。", + "When enabled, hold the shortcut to talk; release it and the selected agent shows the result in the capsule.", + "有効にすると、ショートカットを押しながら話し、離すと選択した Agent の結果がカプセルに表示されます。", + "켜면 단축키를 누른 채 말하고, 놓으면 선택한 Agent 결과가 캡슐에 표시됩니다.", + ), + }, + Msg { + key: "settings.coding_agent.model", + text: row("模型", "模型", "Model", "モデル", "모델"), + }, + Msg { + key: "settings.coding_agent.model_hint", + text: row( + "Haiku 最快 · Sonnet 均衡 · Opus 最强", + "Haiku 最快 · Sonnet 均衡 · Opus 最強", + "Haiku = fastest · Sonnet = balanced · Opus = strongest", + "Haiku = 最速 · Sonnet = バランス · Opus = 最強", + "Haiku = 가장 빠름 · Sonnet = 균형 · Opus = 최강", + ), + }, + Msg { + key: "settings.coding_agent.model_placeholder", + text: row( + "默认 sonnet", + "預設 sonnet", + "Default: sonnet", + "デフォルト: sonnet", + "기본: sonnet", + ), + }, + Msg { + key: "settings.coding_agent.provider", + text: row( + "Agent 后端", + "Agent 後端", + "Agent backend", + "Agent バックエンド", + "Agent 백엔드", + ), + }, + Msg { + key: "settings.coding_console.mode.accept_edits", + text: row( + "放行(可恢复操作)", + "放行(可復原操作)", + "Allow (reversible)", + "許可(復元可能)", + "허용(복구 가능)", + ), + }, + Msg { + key: "settings.coding_console.mode.bypass_permissions", + text: row( + "完全放行(高风险)", + "完全放行(高風險)", + "Full bypass (risky)", + "完全許可(高リスク)", + "완전 허용(위험)", + ), + }, + Msg { + key: "settings.coding_console.mode.default", + text: row( + "默认(逐项确认)", + "預設(逐項確認)", + "Default (ask each)", + "デフォルト(都度確認)", + "기본(매번 확인)", + ), + }, + Msg { + key: "settings.coding_console.mode.plan", + text: row( + "只读 / 计划", + "唯讀 / 計畫", + "Read-only / plan", + "読み取り専用 / 計画", + "읽기 전용 / 계획", + ), + }, + Msg { + key: "settings.coding_console.workdir_desc", + text: row( + "可选。Claude 在此目录内运行;填写 git 仓库可启用运行前快照回滚。", + "選填。Claude 在此目錄內執行;填入 git 儲存庫可啟用執行前快照回滾。", + "Optional. Claude runs inside this dir; a git repo enables a pre-run snapshot for rollback.", + "任意。Claude はこのディレクトリ内で実行。git リポジトリなら実行前スナップショットで巻き戻し可能。", + "선택 사항. Claude 가 이 디렉터리에서 실행됩니다. git 저장소이면 실행 전 스냅샷으로 되돌릴 수 있습니다.", + ), + }, + Msg { + key: "settings.coding_console.workdir_placeholder", + text: row( + "留空则在临时目录运行", + "留空則於暫存目錄執行", + "Empty = run in a temp dir", + "空欄なら一時ディレクトリで実行", + "비우면 임시 디렉터리에서 실행", + ), + }, + Msg { + key: "common.experimental", + text: row("实验性", "實驗性", "Experimental", "実験的", "실험적"), + }, + Msg { + key: "hotkey.mode_auto_suffix", + text: row( + "(自动识别)", + "(自動識別)", + " (auto-detect)", + "(自動判別)", + "(자동 인식)", + ), + }, + Msg { + key: "hotkey.mode_hold_suffix", + text: row( + "(按住说话)", + "(按住說話)", + " (push-to-talk)", + "(押し続けて話す)", + "(눌러서 말하기)", + ), + }, + Msg { + key: "hotkey.mode_toggle_suffix", + text: row( + "(开始 / 停止)", + "(開始 / 停止)", + " (start / stop)", + "(開始 / 停止)", + "(시작 / 정지)", + ), + }, + Msg { + key: "settings.coding_agent.voice_hotkey_desc", + text: row( + "按住说话、松开执行。支持 Ctrl/Option/Fn 等单键。功能说明参见「高级」设置页。", + "按住說話、放開執行。支援 Ctrl/Option/Fn 等單鍵。功能說明參見「進階」設定頁。", + "Hold to talk, release to run. Supports Ctrl/Option/Fn single keys. See the Advanced settings page for what it does.", + "押して話す、離して実行。Ctrl/Option/Fn などの単キー対応。機能の説明は「詳細」設定ページを参照。", + "누르고 말하고 놓으면 실행. Ctrl/Option/Fn 단일 키 지원. 기능 설명은 「고급」 설정 페이지 참조.", + ), + }, + Msg { + key: "settings.recording.combo_conflict", + text: row( + "该快捷键组合不可用", + "此快捷鍵組合不可用", + "This shortcut combination is not available", + "このショートカットの組み合わせは使用できません", + "이 단축키 조합은 사용할 수 없습니다", + ), + }, + Msg { + key: "settings.recording.combo_record_btn", + text: row( + "录制快捷键", + "錄製快捷鍵", + "Record shortcut", + "ショートカットを記録", + "단축키 녹화", + ), + }, + Msg { + key: "settings.recording.combo_record_hint", + text: row( + "请按下快捷键组合…", + "請按下快捷鍵組合…", + "Press your shortcut combination…", + "ショートカットの組み合わせを押してください…", + "단축키 조합을 눌러 주세요…", + ), + }, + Msg { + key: "settings.shortcuts.disable", + text: row("停用", "停用", "Disable", "無効化", "비활성화"), + }, + Msg { + key: "settings.shortcuts.style_pack_disabled_suffix", + text: row("(已停用)", "(已停用)", " (disabled)", "(無効)", " (비활성화됨)"), + }, + Msg { + key: "settings.shortcuts.style_pack_remove", + text: row("移除", "移除", "Remove", "削除", "제거"), + }, + Msg { + key: "capsule.cancelled", + text: row("已取消", "已取消", "Cancelled", "キャンセルしました", "취소됨"), + }, + Msg { + key: "capsule.error", + text: row( + "出错了", + "出錯了", + "Something went wrong", + "エラーが発生しました", + "오류 발생", + ), + }, + Msg { + key: "capsule.inserted", + text: row("已插入 {}", "已插入 {}", "Inserted {}", "{} 文字を入力しました", "{}자 입력됨"), + }, + Msg { + key: "capsule.thinking", + text: row("thinking", "thinking", "thinking", "thinking", "thinking"), + }, + Msg { + key: "qa.close_tooltip", + text: row("关闭", "關閉", "Close", "閉じる", "닫기"), + }, + Msg { + key: "qa.composer_placeholder", + text: row( + "输入问题,Enter 发送", + "輸入問題,Enter 傳送", + "Type a question. Enter to send", + "質問を入力。Enter で送信", + "질문을 입력하세요. Enter로 보내기", + ), + }, + Msg { + key: "qa.empty_desc", + text: row( + "选中任意文字后开始追问,或直接在下方输入问题。回答会显示在这里,可以连续多轮。", + "選中任意文字後開始追問,或直接在下方輸入問題。回答會顯示在這裡,可以連續多輪。", + "Select any text to ask about it, or just type your question below. Answers appear here — ask as many follow-ups as you like.", + "テキストを選択して質問するか、下に直接入力してください。回答はここに表示され、続けて質問できます。", + "텍스트를 선택해 질문하거나 아래에 직접 입력하세요. 답변이 여기에 표시되며 계속 이어서 질문할 수 있습니다.", + ), + }, + Msg { + key: "qa.empty_title", + text: row( + "有什么可以帮你?", + "有什麼可以幫你?", + "How can I help?", + "ご用件は?", + "무엇을 도와드릴까요?", + ), + }, + Msg { + key: "qa.error_retry_hint", + text: row( + "请再试一次。", + "請再試一次。", + "Please try again.", + "もう一度お試しください。", + "다시 시도해 주세요.", + ), + }, + Msg { + key: "qa.header_hint", + text: row("随时提问", "隨時提問", "Ask anytime", "いつでも質問", "언제든 질문하세요"), + }, + Msg { + key: "qa.selection_preview", + text: row( + "基于选中文本:", + "基於選中文字:", + "From selected text:", + "選択テキスト:", + "선택된 텍스트 기반:", + ), + }, + Msg { + key: "qa.thinking", + text: row("思考中…", "思考中…", "Thinking…", "思考中…", "생각 중…"), + }, + Msg { + key: "qa.title", + text: row("划词追问", "劃詞追問", "Ask", "質問", "질문"), + }, + Msg { + key: "selection.polish_preview.cancel", + text: row("取消", "取消", "Cancel", "キャンセル", "취소"), + }, + Msg { + key: "selection.polish_preview.confirm_replace", + text: row( + "确认并替换", + "確認並替換", + "Confirm & replace", + "確認して置き換え", + "확인 후 교체", + ), + }, + Msg { + key: "selection.polish_preview.source_prefix", + text: row("原文:", "原文:", "Original: ", "原文:", "원문: "), + }, + Msg { + key: "selection.polish_preview.subtitle", + text: row( + "可直接编辑;点击确认后才会替换原选区。", + "可直接編輯;點選確認後才會替換原選區。", + "Editable; the original selection is replaced only after you confirm.", + "編集可能です。確認後はじめて元の選択範囲を置き換えます。", + "편집 가능합니다. 확인을 클릭한 뒤에만 원래 선택 영역을 교체합니다.", + ), + }, + Msg { + key: "selection.polish_preview.title", + text: row( + "选区润色预览", + "選區潤色預覽", + "Selection Polish Preview", + "選択範囲の推敲プレビュー", + "선택 영역 다듬기 미리보기", + ), + }, + Msg { + key: "capsule.translating", + text: row("正在翻译", "正在翻譯", "Translating", "翻訳中", "번역 중"), + }, + Msg { + key: "qa.edit_apply_replace", + text: row( + "预览并确认插入", + "確認並替換選區", + "Preview and confirm insert", + "プレビューして挿入を確認", + "미리보기 후 삽입 확인", + ), + }, + Msg { + key: "qa.edit_instruction_mode", + text: row( + "编辑指令", + "編輯指令", + "Edit instruction", + "編集指示", + "편집 지시", + ), + }, + Msg { + key: "qa.edit_revert_previous", + text: row( + "保留上一版本", + "保留上一版本", + "Keep previous version", + "前のバージョンを保持", + "이전 버전 유지", + ), + }, + Msg { + key: "qa.pin_tooltip", + text: row( + "固定(不自动关闭)", + "固定(不自動關閉)", + "Pin (stay open)", + "ピン留め(自動で閉じない)", + "고정(자동으로 닫히지 않음)", + ), + }, + Msg { + key: "qa.unpin_tooltip", + text: row("取消固定", "取消固定", "Unpin", "ピン留めを解除", "고정 해제"), + }, + Msg { + key: "less_computer.approval_rerun_warning", + text: row( + "注意:批准后将在已被修改的工作区上重新运行,可能对不可重入操作产生副作用", + "注意:批准後將在已被修改的工作區上重新執行,可能對不可重入操作產生副作用", + "Note: approving re-runs on an already-modified workspace and may have side effects on non-idempotent operations.", + "注意:承認すると、すでに変更されたワークスペース上で再実行され、冪等でない操作に副作用が生じる可能性があります。", + "주의: 승인하면 이미 수정된 작업 공간에서 다시 실행되어 멱등하지 않은 작업에 부작용이 생길 수 있습니다.", + ), + }, + Msg { + key: "less_computer.approval_title", + text: row( + "执行被拦截的命令?", + "執行被攔截的指令?", + "Run blocked command?", + "ブロックされたコマンドを実行?", + "차단된 명령을 실행할까요?", + ), + }, + Msg { + key: "less_computer.approve", + text: row("允许", "允許", "Approve", "許可", "허용"), + }, + Msg { + key: "less_computer.compaction", + text: row( + "上下文已压缩", + "上下文已壓縮", + "Context compacted", + "コンテキストを圧縮しました", + "컨텍스트가 압축되었습니다", + ), + }, + Msg { + key: "less_computer.cost", + text: row("${}", "${}", "${}", "${}", "${}"), + }, + Msg { + key: "less_computer.deny", + text: row("拒绝", "拒絕", "Deny", "拒否", "거부"), + }, + Msg { + key: "less_computer.input_placeholder", + text: row( + "输入指令,Enter 发送", + "輸入指令,Enter 傳送", + "Type a command, Enter to send", + "指示を入力、Enter で送信", + "명령을 입력하고 Enter로 전송", + ), + }, + Msg { + key: "less_computer.send", + text: row("发送", "傳送", "Send", "送信", "전송"), + }, + Msg { + key: "less_computer.subtitle", + text: row( + "想让电脑做什么?", + "想讓電腦做什麼?", + "What should your computer do?", + "コンピュータに何をさせますか?", + "컴퓨터로 무엇을 할까요?", + ), + }, + Msg { + key: "less_computer.title", + text: row( + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + "Less Computer", + ), + }, + Msg { + key: "less_computer.tool", + text: row("调用了 {}", "呼叫了 {}", "Used {}", "{} を使用", "{} 사용"), + }, + Msg { + key: "less_computer.working", + text: row("正在操控电脑…", "正在操控電腦…", "Operating…", "操作中…", "조작 중…"), + }, + Msg { + key: "onboarding.mic_no_device_hint", + text: row( + "未检测到麦克风,请连接并启用麦克风后重试。", + "未偵測到麥克風,請連線並啟用麥克風後重試。", + "No microphone detected. Connect and enable a microphone, then retry.", + "マイクが検出されません。マイクを接続して有効にしてから、もう一度お試しください。", + "마이크가 감지되지 않습니다. 마이크를 연결하고 활성화한 후 다시 시도하세요.", + ), + }, + Msg { + key: "settings.recording.audio_cue_preview", + text: row("试听", "試聽", "Preview", "試聴", "미리듣기"), + }, + Msg { + key: "settings.recording.capsule_desc", + text: row( + "录音 / 转写时显示屏幕底部胶囊。", + "錄音 / 轉寫時在螢幕底部顯示半透明膠囊。", + "Show a translucent capsule at the bottom of the screen while recording.", + "録音 / 転写中、画面下部に半透明のカプセルを表示。", + "녹음 / 전사 중 화면 하단에 반투명 캡슐을 표시합니다.", + ), + }, + Msg { + key: "settings.recording.capsule_label", + text: row( + "录音胶囊", + "錄音膠囊", + "Recording capsule", + "録音カプセル", + "녹음 캡슐", + ), + }, + Msg { + key: "settings.recording.capsule_style_classic", + text: row( + "Openless 默认风格", + "Openless 預設風格", + "OpenLess default style", + "Openless デフォルトスタイル", + "Openless 기본 스타일", + ), + }, + Msg { + key: "settings.recording.capsule_style_label", + text: row( + "胶囊样式", + "膠囊樣式", + "Capsule style", + "カプセルスタイル", + "캡슐 스타일", + ), + }, + Msg { + key: "settings.recording.capsule_style_siri", + text: row( + "流光 Siri 风格", + "流光 Siri 風格", + "Shimmer Siri style", + "光条 Siri スタイル", + "시리 광선 스타일", + ), + }, + Msg { + key: "settings.recording.capsule_style_typeless", + text: row( + "Typeless 传统风格", + "Typeless 傳統風格", + "Typeless compact style", + "Typeless コンパクトスタイル", + "Typeless 컴팩트 스타일", + ), + }, + Msg { + key: "settings.recording.microphone_load_error", + text: row( + "麦克风列表读取失败:{}", + "麥克風列表讀取失敗:{}", + "Failed to load microphones: {}", + "マイクの読み込みに失敗:{}", + "마이크 로드 실패: {}", + ), + }, + Msg { + key: "settings.recording.stable_transcription_desc", + text: row( + "开启后,录音期间不连接 ASR,停止后才提交整段音频。结果出现更晚,但录音不受建连延迟和录音期间网络抖动影响。", + "開啟後,錄音期間不連接 ASR,停止後才提交整段音訊。結果會較晚出現,但錄音不受連線延遲和錄音期間的網路波動影響。", + "When enabled, ASR connects only after recording stops and receives the complete audio. Results arrive later, but connection delays and network instability during recording cannot interrupt capture.", + "有効にすると、録音中は ASR に接続せず、停止後に音声全体を送信します。結果は遅くなりますが、接続遅延や録音中のネットワーク変動に録音が影響されません。", + "켜면 녹음 중에는 ASR에 연결하지 않고 중지한 뒤 전체 오디오를 전송합니다. 결과는 늦게 표시되지만 연결 지연이나 녹음 중 네트워크 불안정이 녹음에 영향을 주지 않습니다.", + ), + }, + Msg { + key: "settings.recording.stable_transcription_label", + text: row( + "稳定模式(先录音后识别)", + "穩定模式(先錄音後辨識)", + "Stable mode (record, then transcribe)", + "安定モード(録音後に文字起こし)", + "안정 모드 (녹음 후 전사)", + ), + }, + Msg { + key: "capsule.selectionPolish.noSelection", + text: row( + "未选中内容", + "未選中內容", + "Nothing selected", + "選択されていません", + "선택된 내용 없음", + ), + }, + Msg { + key: "settings.channels.availableModels", + text: row( + "可用模型", + "可用模型", + "Available models", + "利用可能なモデル", + "사용 가능한 모델", + ), + }, + Msg { + key: "settings.channels.modelHint", + text: row( + "直接输入模型名称,或拉取并选择供应商的可用模型。", + "直接輸入模型名稱,或取得並選擇供應商的可用模型。", + "Enter a model name directly, or fetch and select a model from your provider.", + "モデル名を直接入力するか、プロバイダーから一覧を取得して選択します。", + "모델 이름을 직접 입력하거나 공급자의 모델 목록을 가져와 선택하세요.", + ), + }, + Msg { + key: "settings.channels.modelTitle", + text: row( + "模型设置", + "模型設定", + "Model settings", + "モデル設定", + "모델 설정", + ), + }, + Msg { + key: "settings.providers.customModelLabel", + text: row( + "自定义模型…", + "自訂模型…", + "Custom model…", + "カスタムモデル…", + "사용자 정의 모델…", + ), + }, + Msg { + key: "settings.providers.fetchModels", + text: row("拉取模型", "拉取模型", "Fetch models", "モデル一覧", "모델 가져오기"), + }, + Msg { + key: "settings.providers.loadingModels", + text: row( + "拉取模型中…", + "拉取模型中…", + "Fetching models…", + "モデル取得中…", + "모델 가져오는 중…", + ), + }, + Msg { + key: "settings.providers.modelLabel", + text: row("模型", "模型", "Model", "モデル", "모델"), + }, + Msg { + key: "settings.providers.modelSaved", + text: row( + "已保存模型 {}。", + "已儲存模型 {}。", + "Saved model {}.", + "モデル {} を保存しました。", + "모델 {} 을(를) 저장했습니다.", + ), + }, + Msg { + key: "settings.providers.modelsEmpty", + text: row( + "鉴权成功,但没有返回可用模型。", + "鑑權成功,但沒有返回可用模型。", + "Credentials are valid, but no models were returned.", + "認証成功ですが、利用可能なモデルが返されませんでした。", + "인증 성공이지만 사용 가능한 모델이 반환되지 않았습니다.", + ), + }, + Msg { + key: "settings.providers.modelsLoaded", + text: row( + "已拉取 {} 个模型。", + "已拉取 {} 個模型。", + "Fetched {} models.", + "{} 個のモデルを取得しました。", + "{}개의 모델을 가져왔습니다.", + ), + }, + Msg { + key: "settings.providers.noMatchingModels", + text: row( + "没有匹配的模型", + "沒有符合的模型", + "No matching models", + "一致するモデルがありません", + "일치하는 모델이 없습니다", + ), + }, + Msg { + key: "settings.providers.planModelsHint", + text: row( + "打开套餐控制台,复制支持的文本模型 ID 并填写到模型栏。", + "開啟方案控制台,複製支援的文字模型 ID 並填入模型欄位。", + "Open the plan console, copy a supported text model ID, and enter it in the model field.", + "プランのコンソールで対応するテキストモデル ID をコピーし、モデル欄に入力してください。", + "요금제 콘솔에서 지원되는 텍스트 모델 ID를 복사해 모델 필드에 입력하세요.", + ), + }, + Msg { + key: "settings.providers.presetListLabel", + text: row( + "返回预设列表", + "返回預設清單", + "Back to presets", + "プリセットに戻る", + "프리셋으로 돌아가기", + ), + }, + Msg { + key: "settings.providers.searchModels", + text: row( + "搜索模型…", + "搜尋模型…", + "Search models…", + "モデルを検索…", + "모델 검색…", + ), + }, + Msg { + key: "settings.providers.selectModel", + text: row( + "选择一个模型写入上方字段", + "選擇一個模型寫入上方欄位", + "Select a model to fill the field above", + "モデルを選んで上記欄に入力", + "모델을 선택해 위 필드에 입력", + ), + }, + Msg { + key: "settings.providers.viewModels", + text: row( + "查看支持的模型", + "查看支援的模型", + "View supported models", + "対応モデルを確認", + "지원 모델 보기", + ), + }, + Msg { + key: "history.hide_raw", + text: row( + "隐藏原文", + "隱藏原文", + "Hide raw transcript", + "原文を隠す", + "원문 숨기기", + ), + }, + Msg { + key: "history.play_recording", + text: row( + "播放录音", + "播放錄音", + "Play recording", + "録音を再生", + "녹음 재생", + ), + }, + Msg { + key: "history.show_raw", + text: row( + "查看原文", + "查看原文", + "Show raw transcript", + "原文を表示", + "원문 보기", + ), + }, + Msg { + key: "shell.beta_tag", + text: row("BETA", "BETA", "BETA", "BETA", "BETA"), + }, + Msg { + key: "common.close", + text: row("关闭", "關閉", "Close", "閉じる", "닫기"), + }, + Msg { + key: "vocab.newWordAddSelected", + text: row("添加所选", "新增所選", "Add Selected", "選択を追加", "선택 추가"), + }, + Msg { + key: "vocab.newWordDesc", + text: row( + "直接输入新词,或从预设模板批量导入。", + "直接輸入新詞,或從預設範本批次匯入。", + "Type a word directly, or import preset templates in bulk.", + "単語を直接入力、またはプリセットテンプレートから一括インポート。", + "단어를 직접 입력하거나 프리셋 템플릿에서 일괄 가져오세요.", + ), + }, + Msg { + key: "vocab.newWordInputPlaceholder", + text: row( + "输入词语,按 Enter 添加…", + "輸入詞語,按 Enter 新增…", + "Type a word, press Enter to add…", + "単語を入力して Enter で追加…", + "단어 입력 후 Enter로 추가…", + ), + }, + Msg { + key: "vocab.newWordTemplates", + text: row( + "预设模板", + "預設範本", + "Preset Templates", + "プリセットテンプレート", + "프리셋 템플릿", + ), + }, + Msg { + key: "vocab.newWordTitle", + text: row( + "添加新词", + "新增新詞", + "Add New Words", + "新語を追加", + "새 단어 추가", + ), + }, + Msg { + key: "history.repolish.empty", + text: row( + "(模型返回了空结果)", + "(模型返回了空結果)", + "(the model returned an empty result)", + "(モデルが空の結果を返しました)", + "(모델이 빈 결과를 반환했습니다)", + ), + }, + Msg { + key: "history.repolish.hint", + text: row( + "基于上面的原文再跑一次润色。结果只在本次查看时显示,不写回这条记录。原风格包已删除或旧记录时,重试将使用当前风格。", + "基於上面的原文再跑一次潤色。結果只在本次檢視時顯示,不寫回這條記錄。原風格包已刪除或舊記錄時,重試將使用當前風格。", + "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.", + "上の原文でもう一度整文を実行します。結果は今回の表示のみで、この記録には書き戻しません。元のスタイルパックが削除されているか、古い記録の場合は、再試行では現在のスタイルを使用します。", + "위 원문으로 다듬기를 다시 실행합니다. 결과는 이번 조회에만 표시되며 기록에 반영되지 않습니다. 원래 스타일 팩이 삭제되었거나 오래된 기록인 경우, 다시 시도 시 현재 스타일을 사용합니다.", + ), + }, + Msg { + key: "history.repolish.retry", + text: row( + "用原风格重试", + "用原風格重試", + "Retry with same style", + "同じスタイルで再試行", + "같은 스타일로 재시도", + ), + }, + Msg { + key: "history.repolish.retryResultTitle", + text: row("重试结果", "重試結果", "Retry result", "再試行の結果", "재시도 결과"), + }, + Msg { + key: "history.repolish.title", + text: row("重新润色", "重新潤色", "Re-polish", "再整文", "다시 다듬기"), + }, + Msg { + key: "marketplace.myPacks.buttonLabel", + text: row("我的发布", "我的發布", "My Packs", "自分の公開", "내 게시물"), + }, + Msg { + key: "marketplace.myPacks.searchPlaceholder", + text: row( + "搜索名称、标签", + "搜尋名稱、標籤", + "Search name or tags", + "名前・タグを検索", + "이름·태그 검색", + ), + }, + Msg { + key: "marketplace.myPacks.emptyTitle", + text: row( + "你还没有发布过风格包", + "你還沒有發布過風格包", + "You have not published any style packs yet", + "まだ公開したパックはありません", + "아직 게시한 팩이 없습니다", + ), + }, + Msg { + key: "marketplace.myPacks.emptyHint", + text: row( + "在「风格」页面编辑后点「发布到风格市场」,或点击右上角上传本地风格包。", + "在「風格」頁面編輯後點「發布到風格市場」,或點選右上角上傳本機風格包。", + "Edit a pack in the Style page and click \"Publish to Marketplace\", or upload a local pack from the top-right.", + "「スタイル」ページで編集して「マーケットに公開」をクリックするか、右上からローカルパックをアップロードしてください。", + "\"스타일\" 페이지에서 편집 후 \"마켓에 게시\"를 누르거나, 오른쪽 위에서 로컬 팩을 업로드하세요.", + ), + }, + Msg { + key: "marketplace.myPacks.noMatch", + text: row( + "没有匹配的风格包", + "沒有符合的風格包", + "No matching style packs", + "一致するパックがありません", + "일치하는 팩이 없습니다", + ), + }, + Msg { + key: "marketplace.myPacks.loadingTitle", + text: row("正在拉取,请稍后…", "正在拉取,請稍後…", "Loading…", "読み込み中…", "불러오는 중…"), + }, + Msg { + key: "marketplace.myPacks.loadingHint", + text: row( + "从风格市场获取你最新发布的风格包。", + "從風格市場獲取你最新發布的風格包。", + "Fetching your latest publications from the marketplace.", + "マーケットからあなたの最新公開を取得しています。", + "마켓에서 최신 게시물을 가져오는 중입니다.", + ), + }, + Msg { + key: "marketplace.myPacks.loadErrorTitle", + text: row("加载失败", "載入失敗", "Load failed", "読み込み失敗", "불러오기 실패"), + }, + Msg { + key: "marketplace.myPacks.loadErrorRetry", + text: row("重试", "重試", "Retry", "再試行", "다시 시도"), + }, + Msg { + key: "marketplace.myPacks.summary", + text: row("已发布 {} 个风格包", "已發布 {} 個風格包", "{} published", "公開済み {} 個", "게시 {}개"), + }, + Msg { + key: "style.pack.derivativeBadge", + text: row( + "衍生自 @{}", + "衍生自 @{}", + "Derived from @{}", + "@{} から派生", + "@{}에서 파생", + ), + }, + Msg { + key: "style.pack.fieldDescription", + text: row("描述", "描述", "Description", "説明", "설명"), + }, + Msg { + key: "style.pack.fieldName", + text: row("名称", "名稱", "Name", "名前", "이름"), + }, + Msg { + key: "style.pack.fieldTags", + text: row("标签", "標籤", "Tags", "タグ", "태그"), + }, + Msg { + key: "style.pack.fieldTagsPlaceholder", + text: row( + "用英文逗号分隔,例如 community, voiceover, formal", + "用英文逗號分隔,例如 community, voiceover, formal", + "Comma-separated tags, e.g. community, voiceover, formal", + "カンマ区切り、例: community, voiceover, formal", + "쉼표로 구분, 예: community, voiceover, formal", + ), + }, + Msg { + key: "style.pack.selectionPromptHint", + text: row( + "用于用户主动选中的书面文字;不经过 ASR,不把内容当成转写,也不回答其中的问题。", + "用於使用者主動選中的書面文字;不經過 ASR,不把內容當成轉寫,也不回答其中的問題。", + "For user-selected written text; not ASR output. Do not treat it as a transcript or answer its questions.", + "ユーザーが選択した書面テキスト用。ASRは経由せず、書き起こしとして扱わず、その中の質問にも答えません。", + "사용자가 선택한 서면 텍스트용. ASR을 거치지 않으며, 받아쓰기로 취급하지 않고 그 안의 질문에도 답하지 않습니다.", + ), + }, + Msg { + key: "style.pack.selectionPromptTitle", + text: row( + "选区润色 Prompt(无 ASR)", + "選區潤色 Prompt(無 ASR)", + "Selection polish prompt (no ASR)", + "選択範囲の推敲プロンプト(ASRなし)", + "선택 영역 다듬기 프롬프트(ASR 없음)", + ), + }, + Msg { + key: "style.pack.voiceEditPromptTitle", + text: row( + "选区语音编辑 Prompt(EditPlan)", + "選區語音編輯 Prompt(EditPlan)", + "Selection voice edit prompt (EditPlan)", + "選択範囲の音声編集プロンプト(EditPlan)", + "선택 영역 음성 편집 프롬프트(EditPlan)", + ), + }, + Msg { + key: "marketplace.myPacks.buttonTitleEmpty", + text: row( + "先在 Settings → 风格市场 填写发布身份", + "先在 Settings → 風格市場 填寫發布身份", + "Set publisher identity in Settings → Marketplace first", + "先に 設定 → マーケット で公開者名を設定してください", + "먼저 설정 → 마켓에서 게시자 이름을 입력하세요", + ), + }, + Msg { + key: "marketplace.myPacks.notLoggedIn", + text: row( + "请先在 Settings → 风格市场 填写发布身份", + "請先在 Settings → 風格市場 填寫發布身份", + "Set publisher identity in Settings → Marketplace first", + "先に 設定 → マーケット で公開者名を設定してください", + "먼저 설정 → 마켓에서 게시자 이름을 입력하세요", + ), + }, + Msg { + key: "marketplace.modal.loggedInLabel", + text: row("已登录", "已登入", "Signed in", "ログイン済み", "로그인됨"), + }, + Msg { + key: "marketplace.oauth.loginBtn", + text: row("登录", "登入", "Sign in", "サインイン", "로그인"), + }, + Msg { + key: "style.pack.iconInvalid", + text: row( + "请选择不含外部资源的有效 SVG 图标(最大 256 KB)。", + "請選擇不含外部資源的有效 SVG 圖示(最大 256 KB)。", + "Choose a valid SVG icon with no external resources (up to 256 KB).", + "外部リソースを含まない有効な SVG を選択してください(最大 256 KB)。", + "외부 리소스가 없는 유효한 SVG 아이콘을 선택하세요(최대 256 KB).", + ), + }, + Msg { + key: "style.pack.iconSaved", + text: row("图标已保存", "圖示已儲存", "Icon saved", "アイコンを保存しました", "아이콘이 저장되었습니다"), + }, + Msg { + key: "style.pack.resetIcon", + text: row( + "恢复默认图标", + "還原預設圖示", + "Restore default icon", + "既定のアイコンに戻す", + "기본 아이콘 복원", + ), + }, + Msg { + key: "style.pack.uploadIcon", + text: row( + "为「{}」上传 SVG 图标", + "為「{}」上傳 SVG 圖示", + "Upload an SVG icon for {}", + "{} の SVG アイコンをアップロード", + "{}의 SVG 아이콘 업로드", + ), + }, + Msg { + key: "common.hide", + text: row("隐藏", "隱藏", "Hide", "非表示", "숨기기"), + }, + Msg { + key: "quickNote.showShortcut", + text: row( + "显示速记快捷键", + "顯示速記快捷鍵", + "Show quick note shortcut", + "速記ショートカットを表示", + "속기 단축키 표시", + ), + }, + Msg { + key: "style.pack.active", + text: row("当前", "目前", "Active", "使用中", "사용 중"), + }, + Msg { + key: "style.pack.closeEditor", + text: row("关闭", "關閉", "Close", "閉じる", "닫기"), + }, + Msg { + key: "style.pack.deleteImported", + text: row("删除", "刪除", "Delete", "削除", "삭제"), + }, + Msg { + key: "style.pack.editorTitle", + text: row("编辑风格", "編輯風格", "Edit Pack", "パック編集", "팩 편집"), + }, + Msg { + key: "style.pack.exportZip", + text: row("导出 ZIP", "匯出 ZIP", "Export ZIP", "ZIP をエクスポート", "ZIP 내보내기"), + }, + Msg { + key: "style.pack.fieldAuthor", + text: row("作者", "作者", "Author", "作者", "작성자"), + }, + Msg { + key: "style.pack.fieldAuthorPlaceholder", + text: row( + "可选,方便标注来源", + "可選,方便標註來源", + "Optional source label", + "任意。ソース表示用", + "선택. 출처 표시용", + ), + }, + Msg { + key: "style.pack.fieldCompatibility", + text: row( + "兼容版本", + "相容版本", + "Compatible App Version", + "互換アプリバージョン", + "호환 앱 버전", + ), + }, + Msg { + key: "style.pack.fieldCompatibilityPlaceholder", + text: row( + "可选,例如 >=1.3.0", + "可選,例如 >=1.3.0", + "Optional, e.g. >=1.3.0", + "任意。例: >=1.3.0", + "선택. 예: >=1.3.0", + ), + }, + Msg { + key: "style.pack.fieldModel", + text: row( + "推荐模型(仅元数据)", + "建議模型(僅元資料)", + "Recommended Model (Metadata)", + "推奨モデル(メタデータのみ)", + "권장 모델(메타데이터)", + ), + }, + Msg { + key: "style.pack.fieldModelHint", + text: row( + "仅作说明,不会切换实际模型。", + "僅作說明,不會切換實際模型。", + "Metadata only. Does not switch model.", + "メタデータのみ。実際のモデルは切り替わりません。", + "메타데이터일 뿐 실제 모델을 전환하지 않습니다.", + ), + }, + Msg { + key: "style.pack.fieldModelPlaceholder", + text: row( + "可选,例如 gpt-4.1 / deepseek-v3", + "可選,例如 gpt-4.1 / deepseek-v3", + "Optional, e.g. gpt-4.1 / deepseek-v3", + "任意。例: gpt-4.1 / deepseek-v3", + "선택. 예: gpt-4.1 / deepseek-v3", + ), + }, + Msg { + key: "style.pack.fieldVersion", + text: row("版本", "版本", "Version", "バージョン", "버전"), + }, + Msg { + key: "style.pack.resetBuiltin", + text: row("重置", "重設", "Reset", "リセット", "재설정"), + }, + Msg { + key: "style.pack.revert", + text: row("撤销", "還原", "Revert", "元に戻す", "되돌리기"), + }, + Msg { + key: "style.pack.save", + text: row("保存", "儲存", "Save", "保存", "저장"), + }, + Msg { + key: "style.pack.unsaved", + text: row("未保存", "未儲存", "Unsaved", "未保存", "저장 안 됨"), + }, + Msg { + key: "style.pack.dictationPromptEditorDesc", + text: row( + "当前编辑录音 / ASR 风格 Prompt;输入对象是语音识别后的转写文本。", + "目前編輯錄音 / ASR 風格 Prompt;輸入物件是語音辨識後的轉寫文字。", + "Editing the recording / ASR style prompt; input is ASR transcript text after dictation.", + "録音 / ASRスタイルのプロンプトを編集中。入力は音声認識後の書き起こしテキストです。", + "녹음 / ASR 스타일 프롬프트를 편집 중입니다. 입력은 음성 인식 후 받아쓰기 텍스트입니다.", + ), + }, + Msg { + key: "style.pack.selectionPromptEditorDesc", + text: row( + "当前编辑选区润色 Prompt;输入对象是用户主动选中的书面文字,不经过 ASR。", + "目前編輯選區潤色 Prompt;輸入物件是使用者主動選中的書面文字,不經過 ASR。", + "Editing the selection polish prompt; input is written text the user actively selected, without ASR.", + "選択範囲の推敲プロンプトを編集中。入力はユーザーが選択した書面テキストで、ASRは経由しません。", + "선택 영역 다듬기 프롬프트를 편집 중입니다. 입력은 사용자가 선택한 서면 텍스트이며 ASR을 거치지 않습니다.", + ), + }, + Msg { + key: "style.pack.runtimeActive", + text: row("当前生效", "目前生效", "Active", "有効", "활성"), + }, + Msg { + key: "style.pack.runtimeContextDesc", + text: row( + "来自语言与应用上下文", + "來自語言與應用上下文", + "From language and app context", + "言語とアプリのコンテキストから", + "언어·앱 컨텍스트에서", + ), + }, + Msg { + key: "style.pack.runtimeContextEmpty", + text: row( + "当前不会附加", + "目前不會附加", + "Not added in the current preview.", + "現在のプレビューでは付加されません。", + "현재 미리보기에는 추가되지 않습니다.", + ), + }, + Msg { + key: "style.pack.runtimeContextTitle", + text: row( + "上下文前提", + "上下文前提", + "Context premise", + "コンテキスト前提", + "컨텍스트 전제", + ), + }, + Msg { + key: "style.pack.runtimeDesc", + text: row( + "只读的运行时辅助项。", + "只讀的執行時輔助項。", + "Read-only runtime helpers.", + "読み取り専用の実行時ヘルパー。", + "읽기 전용 런타임 보조.", + ), + }, + Msg { + key: "style.pack.runtimeHistoryDesc", + text: row( + "仅用于实时多轮 polish", + "僅用於即時多輪 polish", + "Only for live multi-turn polish", + "ライブのマルチターン polish のみで使用", + "실시간 멀티턴 polish 전용", + ), + }, + Msg { + key: "style.pack.metaTitle", + text: row( + "安装信息", + "安裝資訊", + "Installation Info", + "インストール情報", + "설치 정보", + ), + }, + Msg { + key: "style.pack.metaSource", + text: row("来源", "來源", "Source", "ソース", "소스"), + }, + Msg { + key: "style.pack.metaBaseMode", + text: row("基础模式", "基礎模式", "Base Mode", "ベースモード", "베이스 모드"), + }, + Msg { + key: "style.pack.metaUpdatedAt", + text: row("更新时间", "更新時間", "Updated", "更新日時", "업데이트"), + }, + Msg { + key: "style.pack.examplesTitle", + text: row( + "效果示例", + "效果範例", + "Effect Examples", + "効果例", + "효과 예시", + ), + }, + Msg { + key: "style.pack.examplesDesc", + text: row( + "会随风格包一起导出。", + "會隨風格包一起匯出。", + "Exported with the pack.", + "パックと一緒にエクスポートされます。", + "팩과 함께 내보내집니다.", + ), + }, + Msg { + key: "style.pack.addExample", + text: row("新增示例", "新增範例", "Add Example", "例を追加", "예시 추가"), + }, + Msg { + key: "style.pack.examplesEmpty", + text: row( + "还没有示例。", + "還沒有範例。", + "No examples yet.", + "まだ例がありません。", + "아직 예시가 없습니다.", + ), + }, + Msg { + key: "style.pack.exampleTitlePlaceholder", + text: row( + "示例 {} 标题", + "範例 {} 標題", + "Example {} title", + "例 {} のタイトル", + "예시 {} 제목", + ), + }, + Msg { + key: "style.pack.exampleInput", + text: row("输入", "輸入", "Input", "入力", "입력"), + }, + Msg { + key: "style.pack.exampleOutput", + text: row("输出", "輸出", "Output", "出力", "출력"), + }, + Msg { + key: "style.pack.runtimeHistoryEmpty", + text: row( + "只有存在 prior turns 时才会附加", + "只有存在 prior turns 時才會附加", + "Only added when prior turns exist.", + "前のターンが存在する場合のみ付加。", + "이전 턴이 있을 때만 추가됩니다.", + ), + }, + Msg { + key: "style.pack.runtimeHistoryTitle", + text: row( + "多轮历史保护段", + "多輪歷史保護段", + "Multi-turn history guardrail", + "マルチターン履歴ガード", + "멀티턴 히스토리 가드", + ), + }, + Msg { + key: "style.pack.runtimeHotwordDesc", + text: row( + "来自已启用热词", + "來自已啟用熱詞", + "From enabled hotwords", + "有効なホットワードから", + "활성화된 핫워드에서", + ), + }, + Msg { + key: "style.pack.runtimeHotwordEmpty", + text: row( + "当前不会附加", + "目前不會附加", + "Not added in the current preview.", + "現在のプレビューでは付加されません。", + "현재 미리보기에는 추가되지 않습니다.", + ), + }, + Msg { + key: "style.pack.runtimeHotwordTitle", + text: row( + "热词提示段", + "熱詞提示段", + "Hotword block", + "ホットワードブロック", + "핫워드 블록", + ), + }, + Msg { + key: "style.pack.runtimeInactive", + text: row("当前未生效", "目前未生效", "Inactive", "無効", "비활성"), + }, + Msg { + key: "style.pack.runtimePreviewOmittedFrontApp", + text: row( + "预览已省略前台 app 标签。", + "預覽已省略前臺 app 標籤。", + "Preview omits the front-app label.", + "プレビューはフロントアプリのラベルを省略しています。", + "미리보기에서 프런트앱 라벨이 생략되었습니다.", + ), + }, + Msg { + key: "style.pack.runtimeTitle", + text: row( + "OpenLess 运行时附加指令", + "OpenLess 執行時附加指令", + "OpenLess Runtime Directives", + "OpenLess 実行時付加指令", + "OpenLess 런타임 추가 지시", + ), + }, + Msg { + key: "marketplace.installingBtn", + text: row("安装中…", "安裝中…", "Installing…", "インストール中…", "설치 중…"), + }, + Msg { + key: "vocab.newWordTemplateCount", + text: row("{} 词", "{} 詞", "{} words", "{} 語", "{}개 단어"), + }, + Msg { + key: "style.pack.publishMarketplace", + text: row( + "发布到风格市场", + "發布到風格市場", + "Publish to Marketplace", + "マーケットに公開", + "마켓에 게시", + ), + }, + Msg { + key: "marketplace.myPacks.actions.update", + text: row("更新", "更新", "Update", "更新", "업데이트"), + }, + Msg { + key: "marketplace.myPacks.actions.withdraw", + text: row("下架", "下架", "Withdraw", "取り下げ", "내리기"), + }, + Msg { + key: "marketplace.myPacks.stats", + text: row("★ {} · ↓ {}", "★ {} · ↓ {}", "★ {} · ↓ {}", "★ {} · ↓ {}", "★ {} · ↓ {}"), + }, + Msg { + key: "marketplace.myPacks.versionDate", + text: row("v{} · {}", "v{} · {}", "v{} · {}", "v{} · {}", "v{} · {}"), + }, + Msg { + key: "marketplace.oauth.browserHint", + text: row( + "在浏览器中打开 {} 并输入下方代码:", + "在瀏覽器中開啟 {} 並輸入下方程式碼:", + "Open {} in your browser and enter this code:", + "ブラウザで {} を開き、このコードを入力してください:", + "브라우저에서 {}을(를) 열고 아래 코드를 입력하세요:", + ), + }, + Msg { + key: "marketplace.oauth.cancelBtn", + text: row("取消", "取消", "Cancel", "キャンセル", "취소"), + }, + Msg { + key: "marketplace.oauth.copyBtn", + text: row("复制", "複製", "Copy", "コピー", "복사"), + }, + Msg { + key: "marketplace.oauth.generating", + text: row( + "正在生成设备验证码…", + "正在產生裝置驗證碼…", + "Generating device code…", + "デバイスコードを生成中…", + "디바이스 코드 생성 중…", + ), + }, + Msg { + key: "marketplace.oauth.openBrowserBtn", + text: row("打开浏览器", "開啟瀏覽器", "Open browser", "ブラウザを開く", "브라우저 열기"), + }, + Msg { + key: "marketplace.oauth.retryBtn", + text: row("重试", "重試", "Retry", "再試行", "다시 시도"), + }, + Msg { + key: "marketplace.oauth.title", + text: row( + "用 GitHub 登录", + "用 GitHub 登入", + "Sign in with GitHub", + "GitHub でサインイン", + "GitHub로 로그인", + ), + }, + Msg { + key: "marketplace.oauth.waiting", + text: row( + "等待你在浏览器中授权…", + "等待你在瀏覽器中授權…", + "Waiting for browser authorization…", + "ブラウザでの認可を待っています…", + "브라우저에서 인증을 기다리는 중…", + ), + }, + Msg { + key: "style.modes.raw.desc", + text: row( + "只补标点和必要分句,不改写不扩写。", + "只補標點和必要分句,不改寫不擴寫。", + "Only adds punctuation and natural breaks — no rewriting or expansion.", + "句読点と必要な区切りのみ補い、書き換えや拡張はしません。", + "구두점과 필요한 문장 구분만 보충하고 다시 쓰거나 확장하지 않습니다.", + ), + }, + Msg { + key: "style.modes.light.desc", + text: row( + "去口癖、补标点,整理为可发送的自然文字。", + "去口癖、補標點,整理為可傳送的自然文字。", + "Drops fillers, adds punctuation, and produces sendable natural prose.", + "口癖の除去、句読点の補完、自然な送信可能テキストへの整理。", + "입버릇 제거, 구두점 보충, 자연스럽게 보낼 수 있는 텍스트로 정리합니다.", + ), + }, + Msg { + key: "style.modes.structured.desc", + text: row( + "面向编程协作、技术排障和产品反馈,准确保留术语并梳理结构。", + "多個主題或步驟時,自動組織為分點列表。", + "Organize coding discussions, troubleshooting and product feedback with precise terminology.", + "開発の相談、技術的な問題解決、製品への意見を、用語を正確に保って整理します。", + "개발 협업, 기술 문제 해결, 제품 피드백을 정확한 용어와 명확한 구조로 정리합니다.", + ), + }, + Msg { + key: "style.modes.formal.desc", + text: row( + "工作沟通和邮件场景,更专业更完整。", + "工作溝通和郵件場景,更專業更完整。", + "Email and workplace tone — more complete, more professional.", + "業務コミュニケーションやメール用途向け。よりプロフェッショナルで完成度の高い文体。", + "업무 커뮤니케이션과 메일에 적합. 더 전문적이고 완성도 높은 문체.", + ), + }, + Msg { + key: "style.pack.builtinTags.minimalEdits", + text: row("最小改写", "最小改寫", "Minimal edits", "最小限の修正", "최소 수정"), + }, + Msg { + key: "style.pack.builtinTags.strongCorrection", + text: row("强纠错", "強糾錯", "Strong corrections", "誤認識を補正", "정확한 교정"), + }, + Msg { + key: "style.pack.builtinTags.communication", + text: row("沟通", "溝通", "Communication", "コミュニケーション", "의사소통"), + }, + Msg { + key: "style.pack.builtinTags.natural", + text: row("自然", "自然", "Natural", "自然な文章", "자연스러움"), + }, + Msg { + key: "style.pack.builtinTags.organized", + text: row("条理", "條理", "Organized", "整理", "체계적 정리"), + }, + Msg { + key: "style.pack.builtinTags.workplaceCommunication", + text: row("工作沟通", "工作溝通", "Work communication", "仕事のやり取り", "업무 소통"), + }, + Msg { + key: "style.pack.builtinTags.aiCoding", + text: row("AI 编程", "AI 程式開發", "AI coding", "AIコーディング", "AI 코딩"), + }, + Msg { + key: "style.pack.builtinTags.technicalStructure", + text: row("技术结构化", "技術結構化", "Technical structure", "技術内容の構造化", "기술 내용 구조화"), + }, + Msg { + key: "style.pack.writtenPolish", + text: row("书面润色", "書面潤色", "Written polish", "書面の推敲", "서면 다듬기"), + }, + ]; fn lang_index(lang: Lang) -> usize { @@ -1166,7 +5700,6 @@ fn lang_index(lang: Lang) -> usize { Lang::En => 2, Lang::Ja => 3, Lang::Ko => 4, - Lang::Es | Lang::Fr | Lang::De => 2, } } @@ -1342,7 +5875,7 @@ mod tests { 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("fr_FR"), None); assert_eq!(Lang::parse(""), None); } diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index db2ac81ec..889bfeaa8 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -4,26 +4,25 @@ //! 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 audio_player; mod backend; mod capabilities; mod coding_agent; -pub mod context; mod credentials; -pub mod design_tokens; mod desktop; -pub mod desktop_bridge; +mod dictation_feedback; mod fcitx5; mod host_actions; mod hotkeys; mod i18n; +pub mod local_hotkeys; mod logging; -mod marketplace; mod popup; +mod popup_layer; +mod popup_window; mod preference_patch; mod qa; mod recordings; @@ -31,19 +30,15 @@ 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 audio_player::ClipPlayer; pub use backend::{LinuxBackendBuilder, LinuxBackendRuntime}; pub use capabilities::{LinuxCapabilitySnapshot, LinuxDesktopSession, LinuxPlatformApi}; pub use credentials::LinuxCredentialStore; @@ -51,64 +46,89 @@ pub use desktop::{ atomic_save, notify, open_external, open_local_file, validate_save_path, AutostartManager, DesktopError, Notification, }; +pub use dictation_feedback::{ + capsule_hide_delay, capsule_hide_is_still_current, capsule_needs_fallback_dismissal, + capsule_outcome, is_backend_error_code, is_expected_stop_error, normalize_stop_result, + phase_shows_capsule, CapsuleOutcome, CAPSULE_AUTO_HIDE_DELAY_MS, +}; +#[cfg(target_os = "linux")] +pub use fcitx5::prepare_fcitx5; pub use fcitx5::{ available as fcitx5_available, commit_text as fcitx5_commit_text, 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, + ensure_plugin_installed as ensure_fcitx5_plugin_installed, reload_fcitx5_if_plugin_updated, + reload_running_fcitx5, selection_text as fcitx5_selection_text, + set_hotkeys as set_fcitx5_hotkeys, set_less_computer_hotkey_raw as set_fcitx5_less_computer_hotkey_raw, Fcitx5TextInserter, FcitxPluginInstallPlan, FcitxPluginStatus, }; pub use host_actions::LinuxHostActions; + +/// Apply one QA/selection-voice edit through the same fcitx5 selection target +/// the `SelectionApi` uses. `session_id` is the selection-voice session the host +/// registered via `rekey_selection_target`, i.e. the Core's apply ticket +/// `session_id`. Returns `Cancelled` when the selection changed meanwhile. +pub fn apply_selection_voice_target( + session_id: &str, + source: &str, + replacement: &str, +) -> Result<(), openless_core::BackendError> { + fcitx5::apply_selection_target(session_id, source, replacement) +} pub use hotkeys::{Fcitx5HotkeyListener, LinuxHotkeyEvent}; pub use i18n::{fmt_catalog as fmt_l10n, tr_catalog as tr_l10n, Lang, LocalePref, LANGS}; +pub use local_hotkeys::{ + next_local_press_id, plugin_event_hotkey, style_pack_raw, translation_hotkey_event, + HotkeyDeduplicator, LocalHotkey, LocalHotkeyEdge, LocalHotkeyEdgeKind, HOTKEY_DEDUPE_WINDOW, +}; 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, + force_x11_for, popup_command, read_jsonl, run_popup, write_jsonl, + ApplyOutcome as PopupApplyOutcome, CapsulePopupState, HostToPopup, LessComputerApproval, + LessComputerEntry, LessComputerPopupState, PopupActionGuard, PopupChatMessage, PopupKind, + PopupSendError, PopupState, PopupSupervisor, PopupSupervisorEvent, PopupToHost, + ProtocolError as PopupProtocolError, ProtocolErrorKind as PopupProtocolErrorKind, + QaPolishState, 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 popup_layer::{ + capsule_geometry, capsule_path_override, choose_capsule_path, detect_capsule_path, + has_layer_shell, layer_shell_available, pointer_events, probe_layer_shell, run_layer_capsule, + CapsuleGeometry, CapsulePath, LayerFrame, CAPSULE_PATH_ENV, CONFIGURE_TIMEOUT, LAYER_NAMESPACE, + LAYER_SHELL_GLOBAL, MAX_FRAME_PAUSE, +}; +#[cfg(all(target_os = "linux", feature = "x11-overlay"))] +pub use popup_window::X11Overlay; +pub use popup_window::{ + bottom_center, clamp_to_area, monitor_containing, place_overlay, popup_position, popup_size, + select_overlay_window, x11_available, OverlayEnvironment, OverlayPlacement, OverlayX11, + WindowCandidate, WindowMatch, X11Rect, CAPSULE_BOTTOM_GAP, CAPSULE_WINDOW_SIZE, + LESS_COMPUTER_WINDOW_SIZE, QA_WINDOW_SIZE, }; + +pub use openless_core::contract::*; +pub use preference_patch::patch_preferences; +pub use recordings::{read_recording_wav, recording_path, recording_pcm, RecordingError}; pub use resources::{ LinuxPackageKind, LinuxResourceLayout, LinuxResourceResolver, FCITX_PLUGIN_CONFIG, FCITX_PLUGIN_LIBRARY, }; pub use runtime::{LinuxNativeRuntime, LinuxRuntimePumpResult}; pub use selection::LinuxSelectionRuntime; -pub use selection_voice::LinuxSelectionVoice; -pub use settings::{LinuxSettingsEffects, LinuxSettingsRuntime}; +pub use settings::{is_bare_modifier_binding, LinuxSettingsEffects, LinuxSettingsRuntime}; pub use single_instance::{ LinuxLaunchIntent, SingleInstanceBroker, SingleInstanceGuard, SingleInstanceRole, }; pub use tray::{LinuxTray, TrayCommand, TrayError, TrayMicrophone}; pub use ui_state::{ - load_locale_pref, 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, + load_locale_pref, load_quick_note_shortcut_hidden, save_locale_pref, + save_quick_note_shortcut_hidden, ui_state_dir, ui_state_path, UiStateError, }; -pub use openless_core::contract::*; - /// Coordinates Core operations with Linux settings and recording lifecycles. /// /// 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, @@ -277,7 +297,6 @@ 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), @@ -290,9 +309,6 @@ 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. /// @@ -362,28 +378,6 @@ 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 @@ -397,9 +391,6 @@ 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); @@ -415,36 +406,26 @@ impl LinuxHost { .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?; - } + 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::QuickNotePressed => Ok(None), // Host toggles the permanent recording. + LinuxHotkeyEvent::QaPressed => { + // 用户报「选区助手快捷键打不开」时,这一行 + 宿主的 ShowQa/HideQa + // 日志能直接区分「键没到」和「到了但被隐藏」。 + log::info!("[hotkey] selection-ask hotkey pressed; toggling the QA panel"); self.backend - .dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Combined { press_id, at }) + .dispatch_cli_intent(CliIntent::ToggleQa) .await .map(Some) } - LinuxHotkeyEvent::QaPressed => self - .backend - .dispatch_cli_intent(CliIntent::ToggleQa) - .await - .map(Some), LinuxHotkeyEvent::SelectionPolishPressed => { let preferences = self.backend.get_preferences(); let style_pack = self @@ -461,7 +442,7 @@ impl LinuxHost { .await?; Ok(None) } - LinuxHotkeyEvent::TranslationPressed => { + LinuxHotkeyEvent::TranslationPressed { .. } => { if self.backend.snapshot().dictation.phase == DictationPhase::Idle { self.translation_pending .store(true, std::sync::atomic::Ordering::Release); @@ -552,39 +533,6 @@ impl LinuxHost { } Ok(None) } - - /// Feed one canonical PCM frame from the Linux cpal callback into the - /// active Less Computer voice session. - pub fn feed_less_computer_pcm(&self, pcm: &[u8]) -> Result<(), BackendError> { - self.less_computer_voice - .lock() - .expect("Linux Less Computer voice lock poisoned") - .session - .as_ref() - .ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidState, - "Less Computer voice session is not active", - ) - })? - .feed_pcm(pcm) - } - - /// Download a Core-validated Marketplace archive and save it to a user-selected - /// Linux filesystem path without exposing HTTP, OAuth or archive validation to UI code. - pub async fn download_marketplace_archive( - &self, - pack_id: String, - target: std::path::PathBuf, - ) -> Result<(), BackendError> { - let bytes = self - .backend - .services() - .marketplace - .download_archive(pack_id) - .await?; - marketplace::write_archive(&target, &bytes) - } } #[cfg(test)] diff --git a/openless-all/app/linux-egui/src/linux_app/history.rs b/openless-all/app/linux-egui/src/linux_app/history.rs new file mode 100644 index 000000000..7facbd5de --- /dev/null +++ b/openless-all/app/linux-egui/src/linux_app/history.rs @@ -0,0 +1,219 @@ +use super::*; + +/// One read at a time; the last successful result survives failures and refreshes. +#[derive(Default)] +pub(super) struct HistoryCache { + pub(super) entries: Vec, + pub(super) error: Option, + policy: Option<(u32, Option, Option)>, + revision: u64, + wanted_revision: Option, + generation: u64, + in_flight: Option<(u64, u64)>, + pending: bool, +} + +impl HistoryCache { + pub(super) fn new(preferences: &UserPreferences) -> Self { + let mut cache = Self::default(); + cache.policy_changed(preferences); + cache + } + + pub(super) fn policy_changed(&mut self, preferences: &UserPreferences) -> bool { + let policy = ( + preferences.history_retention_days, + preferences.history_max_entries, + preferences.audio_recording_max_entries, + ); + self.policy + .replace(policy) + .is_some_and(|previous| previous != policy) + } + + pub(super) fn observe(&mut self, revision: u64) { + if self.wanted_revision.is_none_or(|wanted| revision > wanted) { + self.request(revision); + } + } + + pub(super) fn request(&mut self, revision: u64) { + self.wanted_revision = Some(self.wanted_revision.unwrap_or(0).max(revision)); + self.generation += 1; + self.pending = true; + } + + pub(super) fn loading(&self) -> bool { + self.pending || self.in_flight.is_some() + } + + fn begin(&mut self) -> Option<(u64, u64)> { + if !self.pending || self.in_flight.is_some() { + return None; + } + self.pending = false; + let request = (self.generation, self.wanted_revision.unwrap_or(0)); + self.in_flight = Some(request); + Some(request) + } + + pub(super) fn finish( + &mut self, + generation: u64, + revision: u64, + result: Result, String>, + ) -> bool { + if self.in_flight != Some((generation, revision)) { + return false; + } + self.in_flight = None; + if generation != self.generation || revision < self.revision { + return false; + } + match result { + Ok(entries) => { + self.entries = entries; + self.revision = revision; + self.error = None; + true + } + Err(error) => { + self.error = Some(error); + false + } + } + } +} + +impl OpenLessEguiApp { + pub(super) fn load_history(&mut self) { + let Some(backend) = self.backend() else { + return; + }; + let Some((generation, revision)) = self.history.begin() else { + return; + }; + spawn_history_read( + &self.tokio, + self.tx.clone(), + generation, + revision, + move || { + let mut entries = backend.list_history().map_err(|error| error.to_string())?; + for entry in &mut entries { + entry.has_audio_recording = Some( + entry.has_audio_recording.unwrap_or(false) + || openless_linux_egui::recording_path( + &backend.config().data_dir, + &entry.id, + ) + .is_ok_and(|path| path.exists()), + ); + } + Ok(entries) + }, + ); + } +} + +fn spawn_history_read( + runtime: &tokio::runtime::Runtime, + tx: mpsc::Sender, + generation: u64, + revision: u64, + read: impl FnOnce() -> Result, String> + Send + 'static, +) { + runtime.spawn(async move { + let result = tokio::task::spawn_blocking(read) + .await + .map_err(|error| error.to_string()) + .and_then(|result| result); + let _ = tx.send(UiResult::HistoryLoaded { + generation, + revision, + result, + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idle_ticks_do_not_read_and_refreshes_coalesce_while_blocked() { + let mut cache = HistoryCache::default(); + cache.observe(0); + let first = cache.begin().unwrap(); + for _ in 0..1000 { + cache.observe(0); + assert!(cache.begin().is_none()); + } + cache.observe(1); + cache.observe(2); + cache.request(2); + assert!(!cache.finish(first.0, first.1, Ok(Vec::new()))); + let latest = cache.begin().unwrap(); + assert_eq!(latest.1, 2); + assert!(!cache.finish(first.0, first.1, Err("stale".into()))); + assert!(cache.finish(latest.0, latest.1, Ok(Vec::new()))); + for _ in 0..1000 { + cache.observe(2); + assert!(cache.begin().is_none()); + } + cache.request(2); + let refresh = cache.begin().unwrap(); + assert!(!cache.finish(refresh.0, refresh.1, Err("disk unavailable".into()))); + assert!(cache.error.is_some()); + cache.observe(2); + assert!( + cache.begin().is_none(), + "failed reads must wait for an explicit refresh or new revision" + ); + cache.observe(3); + assert!(cache.begin().is_some()); + } + #[test] + fn a_blocked_disk_read_does_not_block_host_messages_or_spawn_more_reads() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let runtime = tokio::runtime::Runtime::new().unwrap(); + let reads = Arc::new(AtomicUsize::new(0)); + let (tx, rx) = mpsc::channel(); + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let mut cache = HistoryCache::default(); + cache.observe(1); + let (generation, revision) = cache.begin().unwrap(); + let count = reads.clone(); + spawn_history_read(&runtime, tx.clone(), generation, revision, move || { + count.fetch_add(1, Ordering::SeqCst); + started_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(3)).unwrap(); + Ok(Vec::new()) + }); + started_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + for _ in 0..1000 { + cache.observe(1); + assert!(cache.begin().is_none()); + } + tx.send(UiResult::Message("host still handles actions".into())) + .unwrap(); + assert!(matches!(rx.try_recv(), Ok(UiResult::Message(_)))); + release_tx.send(()).unwrap(); + match rx.recv_timeout(Duration::from_secs(2)).unwrap() { + UiResult::HistoryLoaded { + generation, + revision, + result, + } => { + assert!(cache.finish(generation, revision, result)); + } + _ => panic!("expected the one history read"), + } + for _ in 0..1000 { + cache.observe(1); + assert!(cache.begin().is_none()); + } + assert_eq!(reads.load(Ordering::SeqCst), 1); + } +} diff --git a/openless-all/app/linux-egui/src/linux_app/settings_save.rs b/openless-all/app/linux-egui/src/linux_app/settings_save.rs new file mode 100644 index 000000000..3a4f3a2a6 --- /dev/null +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -0,0 +1,739 @@ +use super::*; +use serde_json::Value; +use std::collections::BTreeMap; + +type Patch = BTreeMap; + +#[derive(Default)] +pub(super) struct SettingsSave { + observed: Option, + pending: Patch, + pub(super) in_flight: Option<(u64, Patch)>, + next_request: u64, + pub(super) error: Option, +} + +impl SettingsSave { + pub(super) fn new(preferences: &UserPreferences) -> Self { + Self { + observed: Some(serde_json::to_value(preferences).expect("serializable preferences")), + ..Self::default() + } + } + + fn capture(&mut self, draft: &UserPreferences) { + let next = serde_json::to_value(draft).expect("serializable preferences"); + if let Some(previous) = &self.observed { + for (key, value) in next.as_object().expect("preference document") { + if previous[key] == *value { + continue; + } + // Mode and trigger are separate edits; shortcut bindings and + // style-hotkey collections are atomic preference fields. + if key == "hotkey" { + for (field, value) in value.as_object().expect("hotkey document") { + if previous[key][field] != *value { + self.pending + .insert(format!("/hotkey/{field}"), value.clone()); + } + } + } else { + self.pending.insert(format!("/{key}"), value.clone()); + } + } + } + self.observed = Some(next); + } + + pub(super) fn rebase(&mut self, latest: &UserPreferences) -> UserPreferences { + let mut edits = self + .in_flight + .as_ref() + .map(|(_, edits)| edits.clone()) + .unwrap_or_default(); + edits.extend(self.pending.clone()); + let draft = openless_linux_egui::patch_preferences(latest, &edits) + .expect("patches generated from typed preferences"); + self.observed = Some(serde_json::to_value(&draft).expect("serializable preferences")); + draft + } + + fn begin(&mut self) -> Option<(u64, Patch)> { + if self.in_flight.is_some() || self.error.is_some() || self.pending.is_empty() { + return None; + } + self.next_request += 1; + let request = (self.next_request, std::mem::take(&mut self.pending)); + self.in_flight = Some(request.clone()); + Some(request) + } + + fn finish(&mut self, request: u64, result: &Result<(), String>) -> bool { + if self.in_flight.as_ref().map(|(id, _)| *id) != Some(request) { + return false; + } + let (_, mut submitted) = self.in_flight.take().expect("matched request"); + if let Err(error) = result { + submitted.extend(std::mem::take(&mut self.pending)); + self.pending = submitted; + self.error = Some(error.clone()); + } else { + self.error = None; + } + true + } +} + +impl OpenLessEguiApp { + pub(super) fn save_settings_if_dirty(&mut self) { + if let Some(draft) = &self.preferences { + self.settings_save.capture(draft); + } + let Some(native) = &self.native else { + return; + }; + let Some((request, edits)) = self.settings_save.begin() else { + return; + }; + let host = native.host_arc(); + let tx = self.tx.clone(); + self.status = tr_l10n(self.lang, "common.saving").to_string(); + self.tokio.spawn(async move { + let backend = host.backend().clone(); + let result = async { + tokio::task::spawn_blocking(move || host.update_preference_fields(&edits)) + .await + .map_err(|error| error.to_string())? + .map_err(|error| error.to_string())?; + let preferences = backend.get_preferences(); + backend + .services() + .remote_input + .configure(openless_core::RemoteInputConfig { + enabled: preferences.remote_input_enabled, + port: preferences.remote_input_port, + }) + .await + .map_err(|error| error.to_string()) + } + .await; + let _ = tx.send(UiResult::SettingsSaved { request, result }); + }); + } + + pub(super) fn finish_settings_save(&mut self, request: u64, result: Result<(), String>) { + if !self.settings_save.finish(request, &result) { + return; + } + if let Some(backend) = self.backend() { + self.preferences = Some(self.settings_save.rebase(&backend.get_preferences())); + self.snapshot = Some(backend.snapshot()); + } + self.hydrate_text_fields = true; + match result { + Ok(()) => { + self.status = tr_l10n(self.lang, "status.settings_saved").to_string(); + self.load_overview(); + self.save_settings_if_dirty(); + } + Err(error) => self.status = error, + } + } + pub(super) 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; + } + frontend::view_model::SettingsField::StableTranscription => { + preferences.stable_transcription_enabled = + !preferences.stable_transcription_enabled; + } + frontend::view_model::SettingsField::StartMinimized => { + preferences.start_minimized = !preferences.start_minimized; + } + frontend::view_model::SettingsField::AutoUpdate => { + preferences.auto_update_check = !preferences.auto_update_check; + } + frontend::view_model::SettingsField::RemoteInput => { + preferences.remote_input_enabled = !preferences.remote_input_enabled; + } + frontend::view_model::SettingsField::ActivityHeatmap => { + preferences.show_overview_activity_heatmap = + !preferences.show_overview_activity_heatmap; + } + frontend::view_model::SettingsField::RestoreClipboard => { + preferences.restore_clipboard_after_paste = + !preferences.restore_clipboard_after_paste; + } + frontend::view_model::SettingsField::SystemProxy => { + preferences.use_system_proxy = !preferences.use_system_proxy; + } + frontend::view_model::SettingsField::Multimodal => { + preferences.multimodal_pipeline_enabled = !preferences.multimodal_pipeline_enabled; + } + frontend::view_model::SettingsField::LessComputer => { + preferences.coding_agent_enabled = !preferences.coding_agent_enabled; + } + frontend::view_model::SettingsField::SilenceAutoStop => { + preferences.silence_auto_stop_enabled = !preferences.silence_auto_stop_enabled; + } + frontend::view_model::SettingsField::AudioCue => { + preferences.audio_cue_on_record = !preferences.audio_cue_on_record; + } + frontend::view_model::SettingsField::ShowCapsule => { + preferences.show_capsule = !preferences.show_capsule; + + // 关掉就要立刻收起,否则药丸会留在屏幕上直到下一次录音。 + if !preferences.show_capsule { + self.dismiss_capsule(); + } + } + frontend::view_model::SettingsField::MuteWhileRecording => { + preferences.mute_during_recording = !preferences.mute_during_recording; + } + frontend::view_model::SettingsField::RecordAudioForDebug => { + preferences.record_audio_for_debug = !preferences.record_audio_for_debug; + } + frontend::view_model::SettingsField::StreamingSaveClipboard => { + preferences.streaming_insert_save_clipboard = + !preferences.streaming_insert_save_clipboard; + } + frontend::view_model::SettingsField::LaunchAtLogin => { + preferences.launch_at_login = !preferences.launch_at_login; + } + } + self.save_settings_if_dirty(); + } + + /// Apply a settings combo change from the frontend. + pub(super) 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.frontend_vm.settings.theme = index; + } + frontend::view_model::SettingsComboField::Language => { + let pref = match index { + 0 => LocalePref::System, + 1 => LocalePref::Lang(Lang::ZhCn), + 2 => LocalePref::Lang(Lang::ZhTw), + 3 => LocalePref::Lang(Lang::En), + 4 => LocalePref::Lang(Lang::Ja), + 5 => LocalePref::Lang(Lang::Ko), + _ => return, + }; + self.apply_locale_pref(pref); + self.frontend_vm.settings.language = index; + } + frontend::view_model::SettingsComboField::RecordingMode => { + // Tauri 的三档:切换式 / 按住说话 / 自动识别。 + preferences.hotkey.mode = match index { + 1 => openless_core::shared_types::HotkeyMode::Hold, + 2 => openless_core::shared_types::HotkeyMode::Auto, + _ => openless_core::shared_types::HotkeyMode::Toggle, + }; + } + frontend::view_model::SettingsComboField::CodingAgentProvider => { + preferences.coding_agent_provider = match index { + 1 => "opencode-cli", + 2 => "codex-cli", + 3 => "dsh-cli", + _ => "claude-code-cli", + } + .to_string(); + } + frontend::view_model::SettingsComboField::CodingAgentPermission => { + preferences.coding_agent_permission_mode = match index { + 1 => "plan", + 2 => "default", + 3 => "bypassPermissions", + _ => "acceptEdits", + } + .to_string(); + } + frontend::view_model::SettingsComboField::SelectionPolishDelivery => { + preferences.selection_polish_output_mode = match index { + 1 => openless_core::shared_types::SelectionPolishOutputMode::PreviewConfirm, + _ => openless_core::shared_types::SelectionPolishOutputMode::DirectReplace, + }; + } + frontend::view_model::SettingsComboField::SilenceSeconds => { + preferences.silence_auto_stop_seconds = index as f32 + 1.0; + } + frontend::view_model::SettingsComboField::CapsuleStyle => { + preferences.capsule_style = match index { + 1 => openless_core::shared_types::CapsuleStyle::Classic, + 2 => openless_core::shared_types::CapsuleStyle::Typeless, + _ => openless_core::shared_types::CapsuleStyle::Siri, + }; + } + frontend::view_model::SettingsComboField::Microphone => { + preferences.microphone_device_name = if index == 0 { + String::new() + } else { + self.frontend_vm + .settings + .microphone_options + .get(index - 1) + .cloned() + .unwrap_or_default() + }; + } + frontend::view_model::SettingsComboField::RemoteDefaultMode => { + preferences.remote_input_default_mode = if index == 1 { + "hold".to_string() + } else { + "toggle".to_string() + }; + } + } + self.save_settings_if_dirty(); + } + + /// 快捷键录入完成:写入对应偏好,并以 strict 模式保存以便立即应用热键副作用。 + pub(super) fn apply_shortcut_captured( + &mut self, + field: frontend::view_model::ShortcutField, + primary: String, + modifiers: Vec, + ) { + let binding = openless_core::shared_types::ShortcutBinding { primary, modifiers }; + if let Err(error) = openless_core::validate_shortcut_binding(&binding) { + self.frontend_vm.settings_notice = Some(fmt_l10n( + self.lang, + "settings.recording.combo_conflict", + &[&error.to_string()], + )); + self.frontend_vm.shortcut_recording = None; + return; + } + // 修饰键触发(按住说话)照常保存:插件只观察不吞修饰键, + // 按住期间若又按了别的键就判定为组合键、放弃触发。 + let draft_pack_id = self + .frontend_vm + .style_packs + .get(self.frontend_vm.style_hotkey_draft_pack) + .map(|pack| pack.id.clone()); + let Some(preferences) = self.preferences.as_mut() else { + self.frontend_vm.shortcut_recording = None; + self.frontend_vm.shortcut_pending_modifier = None; + return; + }; + use frontend::view_model::ShortcutField; + match field { + ShortcutField::Dictation => preferences.dictation_hotkey = binding, + ShortcutField::Translation => preferences.translation_hotkey = binding, + ShortcutField::Qa => preferences.qa_hotkey = Some(binding), + ShortcutField::QuickNote => preferences.quick_note_hotkey = Some(binding), + ShortcutField::SwitchStyle => preferences.switch_style_hotkey = Some(binding), + ShortcutField::OpenApp => preferences.open_app_hotkey = Some(binding), + ShortcutField::CodingAgentVoice => { + preferences.coding_agent_voice_hotkey = Some(binding); + // 「按住说话」有了触发键,Agent 也就该启用(Tauri 同样顺带打开)。 + preferences.coding_agent_enabled = true; + } + ShortcutField::SelectionPolish => preferences.selection_polish_hotkey = Some(binding), + ShortcutField::StylePack(index) => { + if let Some(row) = preferences.style_pack_hotkeys.get_mut(index) { + row.binding = binding; + } + } + ShortcutField::StyleDraft => { + if let Some(pack_id) = draft_pack_id { + preferences + .style_pack_hotkeys + .retain(|entry| entry.pack_id != pack_id); + preferences + .style_pack_hotkeys + .push(openless_core::shared_types::StylePackHotkey { pack_id, binding }); + self.frontend_vm.style_hotkey_draft_open = false; + } + } + } + + self.frontend_vm.shortcut_recording = None; + self.frontend_vm.shortcut_menu = None; + self.save_settings_if_dirty(); + } + + /// 停用某个快捷键绑定(核心录音快捷键没有停用,UI 里也不给按钮)。 + pub(super) fn apply_shortcut_disable(&mut self, field: frontend::view_model::ShortcutField) { + let Some(preferences) = self.preferences.as_mut() else { + return; + }; + use frontend::view_model::ShortcutField; + match field { + ShortcutField::Qa => preferences.qa_hotkey = None, + ShortcutField::QuickNote => preferences.quick_note_hotkey = None, + ShortcutField::SwitchStyle => preferences.switch_style_hotkey = None, + ShortcutField::OpenApp => preferences.open_app_hotkey = None, + ShortcutField::CodingAgentVoice => preferences.coding_agent_voice_hotkey = None, + ShortcutField::SelectionPolish => preferences.selection_polish_hotkey = None, + ShortcutField::StylePack(index) => { + if let Some(row) = preferences.style_pack_hotkeys.get(index) { + let pack_id = row.pack_id.clone(); + preferences + .style_pack_hotkeys + .retain(|entry| entry.pack_id != pack_id); + } + } + // 录音/翻译必须保留一个绑定;草稿行还没有内容。 + ShortcutField::Dictation | ShortcutField::Translation | ShortcutField::StyleDraft => { + return; + } + } + + self.frontend_vm.shortcut_menu = None; + self.save_settings_if_dirty(); + } + + pub(super) fn apply_style_hotkey_remove(&mut self, index: usize) { + let pack_id = self + .frontend_vm + .settings + .style_pack_hotkeys + .get(index) + .map(|row| row.pack_id.clone()); + let Some(pack_id) = pack_id else { + return; + }; + if let Some(preferences) = self.preferences.as_mut() { + preferences + .style_pack_hotkeys + .retain(|entry| entry.pack_id != pack_id); + } + + self.frontend_vm.shortcut_menu = None; + self.save_settings_if_dirty(); + } + + /// 换绑到另一个风格包(目标包已有绑定时忽略,与 Tauri 的下拉置灰同义)。 + pub(super) fn apply_style_hotkey_repack(&mut self, index: usize, pack_index: usize) { + let pack_id = self + .frontend_vm + .settings + .style_pack_hotkeys + .get(index) + .map(|row| row.pack_id.clone()); + let target = self + .frontend_vm + .style_packs + .get(pack_index) + .map(|pack| pack.id.clone()); + let (Some(current), Some(target)) = (pack_id, target) else { + return; + }; + if current == target { + return; + } + if let Some(preferences) = self.preferences.as_mut() { + if preferences + .style_pack_hotkeys + .iter() + .any(|entry| entry.pack_id == target) + { + return; + } + if let Some(entry) = preferences + .style_pack_hotkeys + .iter_mut() + .find(|entry| entry.pack_id == current) + { + entry.pack_id = target; + } + } + + self.save_settings_if_dirty(); + } + + /// Apply a settings text field change from the frontend. + pub(super) 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.frontend_vm.settings.remote_port = text; + } + } + frontend::view_model::SettingsTextField::RetentionDays => { + let parsed = text.trim().parse::().unwrap_or(0).min(365); + preferences.history_retention_days = parsed; + + self.frontend_vm.settings.retention_days = parsed.to_string(); + } + frontend::view_model::SettingsTextField::PolishContextWindow => { + let parsed = text.trim().parse::().unwrap_or(0).min(60); + preferences.polish_context_window_minutes = parsed; + + self.frontend_vm.settings.polish_context_window = parsed.to_string(); + } + frontend::view_model::SettingsTextField::AudioRecordingMaxEntries => { + preferences.audio_recording_max_entries = text + .trim() + .parse::() + .ok() + .map(|value| value.clamp(1, 200)); + + self.frontend_vm.settings.audio_recording_max_entries = text; + } + frontend::view_model::SettingsTextField::CodingAgentModel => { + preferences.coding_agent_model = if text.trim().is_empty() { + None + } else { + Some(text.trim().to_string()) + }; + + self.frontend_vm.settings.coding_agent_model = text; + } + frontend::view_model::SettingsTextField::CodingAgentWorkdir => { + preferences.coding_agent_workdir = if text.trim().is_empty() { + None + } else { + Some(text.trim().to_string()) + }; + + self.frontend_vm.settings.coding_agent_workdir = text; + } + frontend::view_model::SettingsTextField::CodingAgentExe => { + preferences.coding_agent_exe = if text.trim().is_empty() { + None + } else { + Some(text.trim().to_string()) + }; + + self.frontend_vm.settings.coding_agent_exe = text; + } + frontend::view_model::SettingsTextField::HistoryMaxEntries => { + preferences.history_max_entries = text + .trim() + .parse::() + .ok() + .map(|value| value.clamp(5, 200)); + + self.frontend_vm.settings.history_max_entries = text; + } + } + self.save_settings_if_dirty(); + } + + /// Apply a settings action button from the frontend. + pub(super) fn apply_settings_action( + &mut self, + field: frontend::view_model::SettingsActionField, + ) { + match field { + frontend::view_model::SettingsActionField::RetrySave => { + self.settings_save.error = None; + self.save_settings_if_dirty(); + } + 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::CopyCertFingerprint => { + let fingerprint = self + .remote_access + .as_ref() + .and_then(|(status, _)| status.ca_fingerprint_sha256.clone()); + match fingerprint { + Some(fingerprint) => match fcitx5_copy_to_clipboard(&fingerprint) { + Ok(()) => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "status.copied").to_string()); + } + Err(error) => { + self.frontend_vm.settings_notice = + Some(fmt_l10n(self.lang, "status.copy_failed", &[&error])); + } + }, + None => { + self.frontend_vm.settings_notice = Some( + tr_l10n( + self.lang, + "settings.remote_input.cert_fingerprint_unavailable", + ) + .to_string(), + ); + } + } + } + frontend::view_model::SettingsActionField::PreviewAudioCue => { + // 与真实录音开始时同一段合成提示音(Tauri `playRecordStartCue`)。 + openless_linux_egui::play_cue_start(); + } + frontend::view_model::SettingsActionField::OpenGitHub => { + let _ = open_external("https://github.com/earendil-works/openless"); + } + frontend::view_model::SettingsActionField::OpenHelp => { + let _ = open_external("https://github.com/earendil-works/openless"); + } + frontend::view_model::SettingsActionField::OpenReleaseNotes => { + let _ = open_external("https://github.com/earendil-works/openless/releases"); + } + frontend::view_model::SettingsActionField::OpenFeedback => { + let _ = open_external("https://github.com/earendil-works/openless/issues"); + } + frontend::view_model::SettingsActionField::CopyQQ => { + match fcitx5_copy_to_clipboard("1078960553") { + Ok(()) => { + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "status.copied").to_string()); + } + Err(error) => { + self.frontend_vm.settings_notice = Some(fmt_l10n( + self.lang, + "status.copy_failed", + &[&error.to_string()], + )); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn queue_keeps_later_edits_external_changes_and_failed_drafts() { + let mut draft = UserPreferences::default(); + let mut queue = SettingsSave::new(&draft); + draft.remote_input_port = 9001; + queue.capture(&draft); + let (first, _) = queue.begin().unwrap(); + draft.remote_input_port = 9002; + draft.show_capsule = !draft.show_capsule; + queue.capture(&draft); + assert!(queue.begin().is_none()); + let latest = UserPreferences { + microphone_device_name: "external".into(), + ..Default::default() + }; + let merged = queue.rebase(&latest); + assert_eq!(merged.remote_input_port, 9002); + assert_eq!(merged.show_capsule, draft.show_capsule); + assert_eq!(merged.microphone_device_name, "external"); + assert!(!queue.finish(first + 1, &Ok(()))); + assert!(queue.finish(first, &Ok(()))); + let (second, edits) = queue.begin().unwrap(); + assert_eq!(edits["/remoteInputPort"], 9002); + assert!(!queue.finish(first, &Ok(()))); + assert!(queue.finish(second, &Err("registration failed".into()))); + assert!(queue.begin().is_none()); + assert_eq!(queue.rebase(&latest).remote_input_port, 9002); + queue.error = None; + let (retry, _) = queue.begin().unwrap(); + assert!(queue.finish(retry, &Ok(()))); + assert!(queue.pending.is_empty()); + assert!(queue.error.is_none()); + } + + #[test] + fn every_previously_omitted_field_becomes_an_independent_patch() { + let initial = UserPreferences::default(); + let mut draft = initial.clone(); + draft.streaming_insert_save_clipboard = !initial.streaming_insert_save_clipboard; + draft.show_capsule = !initial.show_capsule; + draft.capsule_style = openless_core::shared_types::CapsuleStyle::Classic; + draft.selection_polish_output_mode = + openless_core::shared_types::SelectionPolishOutputMode::PreviewConfirm; + draft.polish_context_window_minutes = 17; + draft.audio_recording_max_entries = Some(19); + draft.coding_agent_enabled = !initial.coding_agent_enabled; + draft.coding_agent_provider = "codex-cli".into(); + draft.coding_agent_permission_mode = "plan".into(); + draft.coding_agent_model = Some("test-model".into()); + draft.coding_agent_workdir = Some("/tmp/work".into()); + draft.coding_agent_exe = Some("/bin/codex".into()); + draft.remote_input_default_mode = "hold".into(); + draft.working_languages = vec!["ja".into()]; + draft.translation_target_language = "ko".into(); + draft.selection_polish_style_pack_id = "selection-style".into(); + draft.style_pack_hotkeys = vec![openless_core::shared_types::StylePackHotkey { + pack_id: "style".into(), + binding: openless_core::shared_types::ShortcutBinding { + primary: "1".into(), + modifiers: vec!["alt".into()], + }, + }]; + draft.hotkey.mode = openless_core::shared_types::HotkeyMode::Hold; + let mut queue = SettingsSave::new(&initial); + queue.capture(&draft); + let (_, edits) = queue.begin().unwrap(); + let mut external = initial.clone(); + external.microphone_device_name = "external microphone".into(); + let merged = openless_linux_egui::patch_preferences(&external, &edits).unwrap(); + draft.microphone_device_name = external.microphone_device_name; + assert_eq!( + serde_json::to_value(merged).unwrap(), + serde_json::to_value(draft).unwrap() + ); + assert!(edits.contains_key("/hotkey/mode")); + } +} diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs new file mode 100644 index 000000000..8f83f4792 --- /dev/null +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -0,0 +1,711 @@ +use super::*; + +pub(super) struct WindowState { + pub(super) should_be_open: bool, + pub(super) error: Option, + pub(super) focus_requested: bool, + pub(super) child: Option, + pub(super) spawned_at: Option, + pub(super) last_snapshot_fingerprint: Option, + pub(super) last_snapshot_at: std::time::Instant, +} + +impl WindowState { + pub(super) fn new(should_be_open: bool) -> Self { + Self { + should_be_open, + error: None, + focus_requested: should_be_open, + child: None, + spawned_at: None, + last_snapshot_fingerprint: None, + last_snapshot_at: std::time::Instant::now(), + } + } +} + +impl Drop for WindowState { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + if child.try_wait().ok().flatten().is_none() { + let _ = child.kill(); + } + let _ = child.wait(); + } + } +} + +/// 主窗口的最小内尺寸(UI 进程创建窗口时用它,和 `with_min_inner_size` 同源)。 +pub(super) const MAIN_WINDOW_MIN_INNER_SIZE: egui::Vec2 = egui::vec2(960.0, 640.0); + +/// MSAA sample count of the main window (`NativeOptions::multisampling`). The +/// settings backdrop draws the page offscreen through eframe's renderer, so its +/// colour target has to match the pipelines eframe built. +pub(super) const MAIN_MSAA_SAMPLES: u16 = 4; + +/// 主窗口的初始尺寸。基准 = macOS(Tauri 的 main 窗口 1300×835); +/// 旧的 Linux 专用窗口配置不作为依据。 +pub(super) const MAIN_WINDOW_INNER_SIZE: [f32; 2] = [1300.0, 835.0]; + +/// 视图模型载荷指纹(FNV-1a 64)。够快,用来判断「要不要重发快照」: +/// 内容没变就不发,UI 慢的时候也不会被无意义的帧糊住。 +pub(super) fn snapshot_fingerprint(payload: &[u8]) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in payload { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +impl OpenLessEguiApp { + /// 与渲染无关的宿主心跳:原生事件、托盘命令、自动更新检查、泵心跳日志。 + /// + /// 宿主循环(`run_host`)每 50ms 调它一次。窗口是独立进程,所以热键消费 + /// 与弹窗拉起完全不依赖「窗口是否在绘制」——窗口关掉、最小化、压根没开, + /// 后台照样收键、照样把弹窗进程拉起来。 + pub(super) fn tick(&mut self, ctx: &egui::Context) { + self.poll(ctx); + self.drain_tray(ctx); + self.log_pump_heartbeat(ctx); + } + + /// 「显示主窗口」:宿主只记意图,由 `run_host` 拉起/抬起 UI 窗口进程。 + pub(super) fn request_main_window(&mut self) { + self.window.should_be_open = true; + self.window.focus_requested = true; + } + + pub(super) fn focus_main_window(&mut self, bridge: &mut UiBridgeHost) { + if self.window.focus_requested && bridge.is_connected() { + bridge.send(HostToWindow::FocusMain); + self.window.focus_requested = false; + log::info!("[ui-host] focus request delivered; compositor decides activation"); + } + } + + /// UI 窗口进程是否还活着(顺带回收已经退出的子进程)。 + pub(super) fn ui_window_alive(&mut self) -> bool { + let Some(child) = self.window.child.as_mut() else { + return false; + }; + match child.try_wait() { + Ok(None) => true, + Ok(Some(status)) => { + if !status.success() { + self.window.error = Some(format!("UI window process failed: {status}")); + } + log::info!("[ui-host] UI window process exited ({status}); host keeps running"); + self.window.child = None; + false + } + Err(error) => { + self.window.error = Some(format!("UI window process wait failed: {error}")); + log::warn!("[ui-host] UI window process wait failed: {error}"); + self.window.child = None; + false + } + } + } + + /// 拉起 UI 窗口进程。 + /// + /// 时序:调用方保证宿主已经 bind 好桥 socket(UI 进程连不上就直接报错退出, + /// 不会自己抢单实例锁或打开数据目录)。 + pub(super) fn spawn_ui_window(&mut self, socket: &std::path::Path) -> Result<(), String> { + let executable = std::env::current_exe().map_err(|error| error.to_string())?; + let mut command = std::process::Command::new(executable); + command + .arg(UI_CLIENT_FLAG) + .arg(UI_SOCKET_FLAG) + .arg(socket) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + // 让 panic / winit 警告走宿主自己的 stderr(终端或 journal), + // 否则「窗口没起来」会变成一条无声的失败。 + .stderr(std::process::Stdio::inherit()); + let child = command.spawn().map_err(|error| error.to_string())?; + log::info!( + "[ui-host] spawned UI window process pid={} socket={}", + child.id(), + socket.display() + ); + self.window.child = Some(child); + self.window.spawned_at = Some(std::time::Instant::now()); + Ok(()) + } + + /// 窗口当前是否需要一个 UI 进程:用户想开着,而且现在没有活着的窗口。 + /// 刚拉起的 800ms 内不重复拉起,避免连点托盘菜单拉出两个窗口。 + pub(super) fn should_spawn_ui_window(&mut self) -> bool { + let alive = self.ui_window_alive(); + if !self.window.should_be_open || alive || self.window.error.is_some() { + return false; + } + if let Some(spawned_at) = self.window.spawned_at { + if spawned_at.elapsed() < Duration::from_millis(800) { + return false; + } + } + true + } + + /// 处理 UI 进程发来的消息(含断连语义)。 + /// + /// 时序:`Bye`/断开只把窗口标记为关闭,**不动**后端、会话与弹窗; + /// 没有托盘时则连宿主一起退出 —— 否则用户再也找不到这个进程。 + pub(super) fn apply_window_messages( + &mut self, + messages: Vec, + tray_available: bool, + ) { + for message in messages { + match message { + WindowToHost::Hello { version } => { + if version != UI_BRIDGE_VERSION { + log::warn!( + "[ui-host] UI window speaks protocol {version}, host speaks {UI_BRIDGE_VERSION}" + ); + } else { + log::info!("[ui-host] UI window handshake ok (protocol {version})"); + } + // 窗口进程刚起来(可能是重启):热键配置必须无条件重发一份, + // 否则新窗口拿不到绑定,它自己就没法匹配本地热键。 + self.hotkeys_sent = None; + self.window.last_snapshot_fingerprint = None; + } + WindowToHost::Action { sequence, action } => { + log::debug!("[ui-host] UI action #{sequence}: {action:?}"); + self.pending_ui_actions.push(*action); + } + WindowToHost::Hotkey { sequence, edge } => { + log::info!("[hotkey] local edge from the UI window #{sequence}: {edge:?}"); + self.pending_local_hotkeys + .push((std::time::Instant::now(), edge)); + } + WindowToHost::Ping { sequence } => { + self.pending_ui_pongs.push(sequence); + } + WindowToHost::Bye => { + log::info!("[ui-host] UI window said goodbye; host keeps running"); + self.window.should_be_open = false; + } + } + } + if !self.window.should_be_open && !tray_available { + // 没有托盘就没有重新打开的入口,窗口退出等于应用退出。 + log::info!("[ui-host] no tray to reopen the window; exiting with it"); + self.exit_requested = true; + } + } + + /// 把当前视图模型发给 UI 进程:内容变过、或距上次超过 2s(保活)才发。 + pub(super) fn publish_view_model(&mut self, bridge: &mut UiBridgeHost) { + if !bridge.is_connected() { + // UI 不在:清掉指纹,等它回来时无条件发一份完整快照。 + self.window.last_snapshot_fingerprint = None; + return; + } + let payload = match serde_json::to_vec(&self.frontend_vm) { + Ok(payload) => payload, + Err(error) => { + log::warn!("[ui-host] view model serialization failed: {error}"); + return; + } + }; + let fingerprint = snapshot_fingerprint(&payload); + let keepalive = self.window.last_snapshot_at.elapsed() >= Duration::from_secs(2); + if Some(fingerprint) == self.window.last_snapshot_fingerprint && !keepalive { + return; + } + self.window.last_snapshot_fingerprint = Some(fingerprint); + self.window.last_snapshot_at = std::time::Instant::now(); + bridge.send_snapshot_encoded(&payload); + } +} + +/// UI 窗口进程入口:只渲染。 +/// +/// 它不构造 Core 后端、不打开数据目录、不抢单实例锁、不注册托盘与热键 —— +/// 关掉它等于「关掉一个窗口」,宿主与所有后台能力原地不动。 +pub(super) fn vulkan_options(mut options: eframe::NativeOptions) -> eframe::NativeOptions { + if let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut options.wgpu_options.wgpu_setup { + setup.instance_descriptor.backends = eframe::egui_wgpu::wgpu::Backends::VULKAN; + } + options +} + +pub(super) fn run_ui_client(socket: std::path::PathBuf) -> Result<(), String> { + // UI 进程不复用宿主的日志器对象,但写到同一个文件里, + // 排查「窗口进程怎么没了」时两端日志在同一处。 + if let Ok(data_dir) = openless_data_dir() { + if let Err(error) = openless_linux_egui::init_file_logger(&data_dir) { + eprintln!("OpenLess UI window logger unavailable: {error}"); + } + } + let client = UiBridgeClient::connect(&socket)?; + log::info!( + "[ui-client] connected to the host bridge at {}", + socket.display() + ); + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("OpenLess") + .with_inner_size(MAIN_WINDOW_INNER_SIZE) + .with_min_inner_size(MAIN_WINDOW_MIN_INNER_SIZE) + .with_decorations(false) + .with_transparent(true) + .with_resizable(true) + .with_visible(true), + renderer: eframe::Renderer::Wgpu, + multisampling: MAIN_MSAA_SAMPLES, + ..Default::default() + }; + let options = vulkan_options(options); + eframe::run_native( + "OpenLess", + options, + Box::new(move |cc| { + theme::install(&cc.egui_ctx); + Ok(Box::new(UiClientApp::new( + client, + cc.wgpu_render_state.as_ref(), + ))) + }), + ) + .map_err(|error| error.to_string()) +} + +/// 诊断开关:`OPENLESS_UI_DEBUG=1` 时 UI 进程把指针点击与动作记进日志。 +/// 用来分辨「按钮没反应」是没收到指针事件,还是动作没能送到宿主。 +pub(super) fn ui_debug_enabled() -> bool { + std::env::var("OPENLESS_UI_DEBUG").is_ok_and(|value| value == "1") +} + +/// 是否采纳这一份快照:序号必须**严格递增**(重复、乱序、回退统统丢弃), +/// 否则 UI 会把新状态画成旧状态。 +pub(super) fn snapshot_supersedes(last_sequence: u64, sequence: u64) -> bool { + sequence > last_sequence +} + +/// 三方合并:`base` = 宿主上一份快照,`local` = 本地(用户可能正在输入的) +/// 视图模型,`incoming` = 宿主新快照。 +/// +/// 用户可见文本全都直接绑在视图模型的字段上(`TextEdit::singleline(&mut +/// vm.…)×`),而宿主每 2s、以及任何状态变化时都会推一份完整快照;UI 侧原先 +/// 是整份替换,于是「打好字还没提交」的输入会被下一份快照抹掉 —— 用户看到的 +/// 就是「输入一秒后文字自己消失」。这里按字段判断:宿主相对上一份快照**改过** +/// 的字段以宿主为准(打开编辑器时 hydrate、提交后回写、宿主侧列表刷新都走这条), +/// **没改过**的字段保留本地值(用户正在输入的内容)。 +pub(super) fn merge_local_edits( + local: &serde_json::Value, + base: &serde_json::Value, + incoming: &serde_json::Value, +) -> serde_json::Value { + if let serde_json::Value::Object(incoming_fields) = incoming { + let mut merged = serde_json::Map::new(); + for (key, incoming_value) in incoming_fields { + let base_value = base.get(key).unwrap_or(&serde_json::Value::Null); + let value = match local.get(key) { + Some(local_value) => merge_local_edits(local_value, base_value, incoming_value), + None => incoming_value.clone(), + }; + merged.insert(key.clone(), value); + } + serde_json::Value::Object(merged) + } else if incoming == base { + // 宿主没动这个字段:本地值(可能是没提交的输入)留下。 + local.clone() + } else { + incoming.clone() + } +} + +/// UI 进程侧的 eframe 应用:收快照 → 渲染 → 把动作发回宿主。 +pub(super) struct UiClientApp { + client: UiBridgeClient, + connecting_since: std::time::Instant, + connection_error: Option, + pub(super) view_model: FrontendViewModel, + /// 已采纳的最大快照序号。 + last_sequence: u64, + /// 宿主上一份快照的 JSON:用来分辨「宿主改了这个字段」和「用户还没提交的 + /// 本地输入」。 + last_adopted: Option, + /// 已发出的动作序号(宿主可据此看出重复或丢失)。 + action_sequence: u64, + ping_sequence: u64, + ping_sent_at: Option, + last_ping_at: std::time::Instant, + latency_samples: Vec, + exited: bool, + /// 宿主下发的本地热键配置(窗口有焦点时插件收不到按键)。 + hotkeys: Option, + hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher, + /// 本地热键边沿的发送序号(与动作序号分开,便于日志区分)。 + hotkey_sequence: u64, + /// 设置页模糊背板;只有拿到 wgpu 渲染状态(正常 GUI 进程)时存在。 + backdrop: Option, +} + +impl UiClientApp { + pub(super) fn new( + client: UiBridgeClient, + render_state: Option<&eframe::egui_wgpu::RenderState>, + ) -> Self { + Self { + client, + connecting_since: std::time::Instant::now(), + connection_error: None, + // 设置页的磨砂背板:把遮罩下方的页面离屏重绘后做真实高斯模糊。 + backdrop: render_state.map(|state| { + crate::ui::backdrop::BackdropBlur::new(state, MAIN_MSAA_SAMPLES.into()) + }), + view_model: FrontendViewModel::default(), + last_sequence: 0, + last_adopted: None, + action_sequence: 0, + ping_sequence: 0, + ping_sent_at: None, + last_ping_at: std::time::Instant::now(), + latency_samples: Vec::new(), + exited: false, + hotkeys: None, + hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher::default(), + hotkey_sequence: 0, + } + } + + /// 采纳一份快照,但保留用户还没提交、而宿主也没有改动的输入。 + pub(super) fn adopt_snapshot(&mut self, sequence: u64, incoming: FrontendViewModel) { + self.last_sequence = sequence; + let incoming_json = serde_json::to_value(&incoming).ok(); + let adopted = match (&self.last_adopted, &incoming_json) { + (Some(base), Some(incoming_json)) => serde_json::to_value(&self.view_model) + .ok() + .and_then(|local| { + serde_json::from_value(merge_local_edits(&local, base, incoming_json)).ok() + }) + .unwrap_or(incoming), + // 第一份快照没有可比对的基准:整份采纳。 + _ => incoming, + }; + self.view_model = adopted; + self.last_adopted = incoming_json; + } + + /// 收宿主的帧。快照按序号采纳;`Shutdown` 与断连都表示「宿主走了」, + /// 此时 UI 必须自己退出(没有宿主就没有数据可渲染)。 + pub(super) fn drain_host(&mut self, ctx: &egui::Context) { + loop { + match self.client.try_recv() { + Ok(HostToWindow::Ready { version }) => { + log::info!("[ui-client] host ready (protocol {version})"); + } + Ok(HostToWindow::FocusMain) => { + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + Ok(HostToWindow::Rejected { reason }) => { + self.connection_error = Some(reason); + self.client.shutdown(); + break; + } + Ok(HostToWindow::Hotkeys { bindings, .. }) => { + // 窗口有焦点时 fcitx5 收不到按键,本地匹配全靠这份配置。 + log::info!("[ui-client] local hotkey bindings received"); + self.hotkeys = Some(*bindings); + } + Ok(HostToWindow::Snapshot { + sequence, + view_model, + }) => { + if snapshot_supersedes(self.last_sequence, sequence) { + self.adopt_snapshot(sequence, *view_model); + } else { + log::debug!("[ui-client] dropped stale snapshot #{sequence}"); + } + } + Ok(HostToWindow::Pong { sequence }) => { + if let Some(sent_at) = self.ping_sent_at.take() { + let rtt = sent_at.elapsed().as_millis(); + self.latency_samples.push(rtt); + if self.latency_samples.len() >= 20 { + let count = self.latency_samples.len(); + let max = self.latency_samples.iter().copied().max().unwrap_or(0); + let sum: u128 = self.latency_samples.iter().sum(); + log::info!( + "[ui-client] ipc round-trip avg={}ms max={max}ms over {count} probes (last #{sequence})", + sum / count as u128 + ); + self.latency_samples.clear(); + } + } + } + Ok(HostToWindow::Shutdown) => { + log::info!("[ui-client] host asked to shut down; closing the window"); + self.exited = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => { + log::warn!("[ui-client] host connection lost; closing the window"); + self.exited = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + break; + } + } + } + } + + /// 本窗口内命中的热键作为边沿报给宿主。 + /// + /// 只读 `InputState`(不消费事件),命中才发一帧;正在录制快捷键时跳过, + /// 否则用户在设置里录「Alt+A」会顺手触发一次听写。 + pub(super) fn poll_local_hotkeys(&mut self, ctx: &egui::Context) { + let Some(bindings) = self.hotkeys.as_ref() else { + return; + }; + if self.view_model.shortcut_recording.is_some() { + return; + } + let Some(edge) = self.hotkey_matcher.poll(ctx, bindings) else { + return; + }; + self.hotkey_sequence += 1; + if let Err(error) = self.client.send(WindowToHost::Hotkey { + sequence: self.hotkey_sequence, + edge, + }) { + log::warn!("[ui-client] cannot forward a local hotkey to the host: {error}"); + } + } + + pub(super) fn ping_if_due(&mut self) { + if self.last_ping_at.elapsed() < Duration::from_secs(2) { + return; + } + self.last_ping_at = std::time::Instant::now(); + self.ping_sequence += 1; + self.ping_sent_at = Some(std::time::Instant::now()); + let _ = self.client.send(WindowToHost::Ping { + sequence: self.ping_sequence, + }); + } + + /// 关窗 = 本进程退出。宿主仍在,会话、录音、弹窗都不受影响。 + pub(super) fn request_exit(&mut self, ctx: &egui::Context, reason: &str) { + if self.exited { + return; + } + log::info!("[ui-client] {reason}: exiting the window process (host keeps running)"); + self.exited = true; + let _ = self.client.send(WindowToHost::Bye); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + + /// 把渲染层产生的动作分发出去:窗口控制就地处理,其余发给宿主。 + pub(super) fn dispatch( + &mut self, + actions: Vec, + ctx: &egui::Context, + ) { + for action in actions { + match action { + frontend::view_model::FrontendAction::WindowClose => { + self.request_exit(ctx, "close button"); + } + frontend::view_model::FrontendAction::WindowMinimize => { + // 最小化在 Wayland 上是单向门(winit 明确拒绝取消最小化), + // 而宿主已经接管热键与弹窗,所以按「关窗回托盘」处理: + // 窗口进程退出,任务栏条目消失,托盘随时能再开一个。 + self.request_exit(ctx, "minimize button"); + } + frontend::view_model::FrontendAction::WindowMaximize => { + let maximized = ctx.input(|input| input.viewport().maximized.unwrap_or(false)); + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); + } + other => { + self.action_sequence += 1; + if let Err(error) = self.client.send(WindowToHost::Action { + sequence: self.action_sequence, + action: Box::new(other), + }) { + log::warn!("[ui-client] cannot forward action to the host: {error}"); + } + } + } + } + } +} + +impl Drop for UiClientApp { + fn drop(&mut self) { + // 关窗即退出:告别帧让宿主立刻作废窗口句柄,收尾读写线程 + // 以免宿主一直等到 EOF。 + let _ = self.client.send(WindowToHost::Bye); + self.client.shutdown(); + } +} + +impl eframe::App for UiClientApp { + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + egui::Color32::TRANSPARENT.to_normalized_gamma_f32() + } + + /// 合成器拖窗(标题栏按下即交给它)会吃掉释放事件、并且不在拖动期间发 motion, + /// egui 的按压/拖拽状态与指针坐标都会留在原地——页面因此滚不动,直到用户点一下。 + /// `raw_input_hook` 是唯一能赶在这一次 pass 之前修补事件的地方。 + fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) { + frontend::layout::route_pointer_before_pass(ctx, raw_input); + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); + if self.connection_error.is_none() { + self.drain_host(&ctx); + } + if !self.client.is_ready() || self.connection_error.is_some() { + if self.connection_error.is_none() + && self.connecting_since.elapsed() >= bridge::UI_CLIENT_CONNECT_TIMEOUT + { + self.connection_error = + Some("UI handshake timed out; restart the complete application".into()); + self.client.shutdown(); + } + ui.label( + self.connection_error + .as_deref() + .unwrap_or_else(|| tr_l10n(self.view_model.lang, "startup.connecting")), + ); + if ui + .button(tr_l10n(self.view_model.lang, "common.close")) + .clicked() + { + self.request_exit(&ctx, "connection dialog close"); + } + ctx.request_repaint_after(Duration::from_millis(50)); + return; + } + self.poll_local_hotkeys(&ctx); + theme::apply_visuals(&ctx, self.view_model.theme_mode); + if ctx.input(|input| input.viewport().close_requested()) { + self.request_exit(&ctx, "window manager close request"); + } + if self.exited { + return; + } + if ui_debug_enabled() { + let (pointer, clicked) = + ctx.input(|input| (input.pointer.interact_pos(), input.pointer.any_click())); + if clicked { + log::info!("[ui-client] pointer click at {pointer:?}"); + } + } + // 所有前端弹窗共用实时磨砂背板:先按当前窗口尺寸准备离屏目标, + // 把纹理 id 发布给遮罩;页面在这一帧稍后离屏重绘并模糊(都在 eframe 绘制之前完成, + // 所以遮罩采到的是当前帧的像素,不需要任何“等一帧截图”的补丁)。 + { + let open = self.view_model.settings_open + || self.view_model.style_editor_open + || self.view_model.marketplace_selected.is_some() + || self.view_model.marketplace_mine_open + || self.view_model.marketplace_upload_open + || self.view_model.marketplace_confirm_withdraw.is_some() + || self.view_model.history_confirm.is_some() + || (self.view_model.active_page == frontend::view_model::Page::Vocab + && frontend::vocab::new_word_overlay_open(&ctx)); + let texture = self + .backdrop + .as_mut() + .and_then(|backdrop| backdrop.prepare(&ctx, open)); + crate::ui::backdrop::publish(&ctx, texture); + } + let mut actions = Vec::new(); + frontend::render(&ctx, &mut self.view_model, &mut actions); + let modal_open = self.view_model.settings_open + || self.view_model.style_editor_open + || self.view_model.marketplace_selected.is_some() + || self.view_model.marketplace_mine_open + || self.view_model.marketplace_upload_open + || self.view_model.marketplace_confirm_withdraw.is_some() + || self.view_model.history_confirm.is_some() + || (self.view_model.active_page == frontend::view_model::Page::Vocab + && frontend::vocab::new_word_overlay_open(&ctx)); + if modal_open { + if let Some(backdrop) = self.backdrop.as_mut() { + backdrop.render_page(&ctx); + } + } + if ui_debug_enabled() && !actions.is_empty() { + log::info!("[ui-client] actions from the renderer: {actions:?}"); + } + self.dispatch(actions, &ctx); + self.ping_if_due(); + // Shortcut capture is sampled from egui input events; refresh its + // inline recorder promptly so capture/cancel closes in the same + // perceptual beat as the key press. Idle settings keep the 30ms cap. + let repaint_ms = if self.view_model.shortcut_recording.is_some() { + 12 + } else { + 30 + }; + ctx.request_repaint_after(Duration::from_millis(repaint_ms)); + } +} + +struct StartupErrorApp { + error: String, + lang: Lang, + broker: Arc, +} + +impl eframe::App for StartupErrorApp { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let mut focus = false; + self.broker.drain(|_| focus = true); + if focus { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Focus); + } + ui.heading(tr_l10n(self.lang, "status.startup_failed")); + ui.add_space(12.0); + ui.label(&self.error); + ui.add_space(12.0); + ui.label(tr_l10n(self.lang, "startup.fcitx_help")); + if ui.button(tr_l10n(self.lang, "common.close")).clicked() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::Close); + } + ui.ctx().request_repaint_after(Duration::from_millis(100)); + } +} + +pub(super) fn show_startup_error(error: &str, broker: Arc) { + eprintln!("OpenLess startup failed: {error}"); + if ["DISPLAY", "WAYLAND_DISPLAY"] + .iter() + .all(|name| std::env::var(name).unwrap_or_default().is_empty()) + { + return; + } + let lang = load_locale_pref().resolve(); + let error = error.to_string(); + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title(tr_l10n(lang, "status.startup_failed")) + .with_inner_size([560.0, 300.0]), + ..Default::default() + }; + if let Err(failure) = eframe::run_native( + "OpenLess", + options, + Box::new(move |cc| { + theme::install(&cc.egui_ctx); + Ok(Box::new(StartupErrorApp { + error, + lang, + broker, + })) + }), + ) { + eprintln!("OpenLess startup error window failed: {failure}"); + } +} diff --git a/openless-all/app/linux-egui/src/local_hotkeys.rs b/openless-all/app/linux-egui/src/local_hotkeys.rs new file mode 100644 index 000000000..6a89dd5f6 --- /dev/null +++ b/openless-all/app/linux-egui/src/local_hotkeys.rs @@ -0,0 +1,483 @@ +//! 我们自己的窗口有焦点时,fcitx5 插件收不到按键 —— 本地兜底匹配的**判定层**。 +//! +//! 插件把热键匹配挂在 fcitx5 的 `EventType::InputContextKeyEvent` 上 +//! (`scripts/linux-fcitx5-plugin/openless.cpp`),而 fcitx5 只有在聚焦的客户端 +//! 注册了 text-input 时才会收到那个客户端的按键。我们的 winit/egui 窗口从不注册 +//! (`ime_allowed` 默认 false → 不会调用 `zwp_text_input_v3.enable()`),于是 +//! **主窗口、选区助手、Less Computer 面板有焦点时插件一个信号都不会发**:用户 +//! 看到的就是「快捷键完全没反应」「选区助手开始录音后停不下来」。 +//! +//! 这个模块只放**不依赖 egui** 的部分:边沿类型、命中判定、与插件信号的去重。 +//! 读 egui 输入的那一半在窗口进程里(`main.rs` 的 `ui::local_hotkeys`),因为 +//! `ui/` 属于可执行文件而不是这个库。 +//! +//! 判定刻意复用既有通路,避免出现第二套键名表:命中用 +//! `crate::settings::shortcut_to_raw`(注册给插件的同一个 `(keysym, states)` 换算)。 + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use openless_core::shared_types::ShortcutBinding; +use openless_core::HotkeyRuntimeTarget; +use serde::{Deserialize, Serialize}; + +use crate::hotkeys::LinuxHotkeyEvent; + +/// 本地命中的热键。 +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum LocalHotkey { + Dictation, + Qa, + QuickNote, + Translation, + SwitchStyle, + SelectionPolish, + OpenApp, + LessComputer, + /// 风格包热键带 **pack id**(不是下标):窗口进程与宿主各自持有的 + /// `HotkeyRuntimeTarget` 可能来自不同时刻,用下标会指错包。 + StylePack(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum LocalHotkeyEdgeKind { + /// 组合键按下(与插件 `*KeyEvent` 的 press 同一语义)。 + Pressed, + /// 组合键松开;`press_id` 与对应的 `Pressed` 相同。 + Released, + /// 一次完整的按下+松开:裸修饰键绑定只能在松手时判定。 + Combined, + /// 裸修饰键按下后又按了其他键;取消这次修饰键热键。 + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalHotkeyEdge { + pub hotkey: LocalHotkey, + pub kind: LocalHotkeyEdgeKind, + pub press_id: u64, +} + +/// 本地 `press_id` 的起始值。 +/// +/// 宿主自己也用 `hotkeys::next_press_id()` 给插件信号配号,两者都从 1 开始会撞号 —— +/// Core 用 `press_id` 配对按下/松开,撞号会把 A 的松开记到 B 的按下上。把本地号段 +/// 抬到远高于一次会话可能产生的信号数,两个号段就永远不会重叠。 +pub const LOCAL_PRESS_ID_BASE: u64 = 1 << 40; + +static NEXT_LOCAL_PRESS_ID: std::sync::atomic::AtomicU64 = + std::sync::atomic::AtomicU64::new(LOCAL_PRESS_ID_BASE); + +/// 分配一个本地边沿号(窗口进程各自从 [`LOCAL_PRESS_ID_BASE`] 起递增)。 +pub fn next_local_press_id() -> u64 { + NEXT_LOCAL_PRESS_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} + +/// 本地命中与插件信号之间的去重窗口。 +/// +/// 两个来源可能对**同一次物理按键**各报一次:窗口是 XWayland 客户端时 fcitx5 +/// 同样收得到键。300ms 足够覆盖「同一次按键的两个来源」,又远小于用户有意连按 +/// 两次的间隔。 +pub const HOTKEY_DEDUPE_WINDOW: Duration = Duration::from_millis(300); + +/// 命中哪个热键。比较用注册给插件的同一套 `(keysym, states)`,因此「左/右修饰键」 +/// 「Shift 隐含的符号」「大小写」这些细节与插件判定完全一致。 +/// +/// 顺序即优先级:同一个组合被绑到多处时(Core 的冲突校验会拦,但配置可能来自旧 +/// 版本),听写优先,其次选区助手,其余按注册顺序。 +pub fn match_hotkey( + target: &HotkeyRuntimeTarget, + pressed: &ShortcutBinding, +) -> Option { + // 这次按键对应的 `(keysym, states)`;裸修饰键名(egui 只给类别,`primary_keysym` + // 未必认得每个拼写)允许为空,走下面的类别放宽。 + let pressed_raw = crate::settings::shortcut_to_raw(pressed) + .ok() + .filter(|raw| raw.0 != 0); + if pressed_raw.is_none() && bare_modifier_category(&pressed.primary).is_none() { + // 不支持的主键(egui 词表里有、X11 keysym 表里没有):交给插件那条路。 + return None; + } + let candidates: [(Option<&ShortcutBinding>, LocalHotkey); 8] = [ + (Some(&target.dictation), LocalHotkey::Dictation), + (target.qa.as_ref(), LocalHotkey::Qa), + (target.quick_note.as_ref(), LocalHotkey::QuickNote), + ( + target.selection_polish.as_ref(), + LocalHotkey::SelectionPolish, + ), + (Some(&target.translation), LocalHotkey::Translation), + (target.switch_style.as_ref(), LocalHotkey::SwitchStyle), + (target.open_app.as_ref(), LocalHotkey::OpenApp), + ( + target + .coding_agent_voice + .as_ref() + .filter(|_| target.coding_agent_enabled), + LocalHotkey::LessComputer, + ), + ]; + for (binding, hotkey) in candidates { + let Some(binding) = binding else { continue }; + if binding_matches(binding, pressed, pressed_raw) { + return Some(hotkey); + } + } + for pack in &target.style_packs { + if binding_matches(&pack.binding, pressed, pressed_raw) { + return Some(LocalHotkey::StylePack(pack.pack_id.clone())); + } + } + None +} + +/// 风格包热键的 `(keysym, states)`:Core 按这两个值认包,所以本地命中后要带上 +/// 与注册给插件时同源的换算结果。 +pub fn style_pack_raw(target: &HotkeyRuntimeTarget, pack_id: &str) -> Option<(u32, u32)> { + target + .style_packs + .iter() + .find(|pack| pack.pack_id == pack_id) + .and_then(|pack| crate::settings::shortcut_to_raw(&pack.binding).ok()) + .filter(|raw| raw.0 != 0) +} + +/// 这条配置与这次按键算不算同一个热键。 +fn binding_matches( + binding: &ShortcutBinding, + pressed: &ShortcutBinding, + pressed_raw: Option<(u32, u32)>, +) -> bool { + if let Some(pressed_raw) = pressed_raw { + if crate::settings::shortcut_to_raw(binding).ok() == Some(pressed_raw) { + return true; + } + } + // 裸修饰键绑定的本地放宽:egui 的 `Modifiers` 只有类别、分不出左右,所以 + // 「按住右 Option 说话」这类绑定在窗口里只能按类别命中(插件那条路仍然 + // 区分左右 —— 这正是需要本地兜底时才放宽的理由)。 + if !binding.modifiers.is_empty() || !pressed.modifiers.is_empty() { + return false; + } + match ( + bare_modifier_category(&binding.primary), + bare_modifier_category(&pressed.primary), + ) { + (Some(left), Some(right)) => left == right, + _ => false, + } +} + +/// 裸修饰键主键名的类别(`None` = 不是裸修饰键)。 +fn bare_modifier_category(primary: &str) -> Option<&'static str> { + Some(match primary.trim().to_ascii_lowercase().as_str() { + "alt" | "option" | "opt" | "leftalt" | "rightalt" | "altleft" | "altright" + | "leftoption" | "rightoption" => "alt", + "control" | "ctrl" | "leftcontrol" | "rightcontrol" | "ctrlleft" | "ctrlright" => "ctrl", + "shift" | "leftshift" | "rightshift" | "shiftleft" | "shiftright" => "shift", + "super" | "meta" | "win" | "cmd" | "command" | "leftsuper" | "rightsuper" | "leftmeta" + | "rightmeta" | "leftcmd" | "rightcmd" => "super", + _ => return None, + }) +} + +/// 插件信号对应哪个热键(去重按热键身份对齐,而不是按原始 sym/states)。 +pub fn plugin_event_hotkey( + event: &LinuxHotkeyEvent, + target: &HotkeyRuntimeTarget, +) -> Option { + Some(match event { + LinuxHotkeyEvent::DictationPressed { .. } + | LinuxHotkeyEvent::DictationReleased { .. } + | LinuxHotkeyEvent::DictationCombined { .. } => LocalHotkey::Dictation, + LinuxHotkeyEvent::QaPressed => LocalHotkey::Qa, + LinuxHotkeyEvent::QuickNotePressed => LocalHotkey::QuickNote, + LinuxHotkeyEvent::SelectionPolishPressed => LocalHotkey::SelectionPolish, + LinuxHotkeyEvent::TranslationPressed { symbol, states } => { + if !shortcut_event_matches(&target.translation, *symbol, *states) { + return None; + } + LocalHotkey::Translation + } + LinuxHotkeyEvent::SwitchStylePressed => LocalHotkey::SwitchStyle, + LinuxHotkeyEvent::OpenAppPressed => LocalHotkey::OpenApp, + LinuxHotkeyEvent::LessComputerPressed { .. } + | LinuxHotkeyEvent::LessComputerReleased { .. } + | LinuxHotkeyEvent::LessComputerCombined { .. } => LocalHotkey::LessComputer, + LinuxHotkeyEvent::StylePackPressed { symbol, states } => { + let raw = (*symbol, *states); + target + .style_packs + .iter() + .find(|pack| crate::settings::shortcut_to_raw(&pack.binding).ok() == Some(raw)) + .map(|pack| LocalHotkey::StylePack(pack.pack_id.clone()))? + } + }) +} + +/// Match a raw fcitx signal like `hotkey_match.h::matches`: fold letter case, +/// keep Ctrl/Alt/Super exact, and permit a shifted US symbol pair only when the +/// configured binding includes Shift. +fn shortcut_event_matches(binding: &ShortcutBinding, symbol: u32, states: u32) -> bool { + let Ok((expected_symbol, expected_states)) = crate::settings::shortcut_to_raw(binding) else { + return false; + }; + const MODIFIERS: u32 = 0x01 | 0x04 | 0x08 | 0x40; + let fold = |key: u32| { + if (u32::from(b'a')..=u32::from(b'z')).contains(&key) { + key - 32 + } else { + key + } + }; + let actual = fold(symbol); + let expected = fold(expected_symbol); + if actual == expected { + return states & MODIFIERS == expected_states & MODIFIERS; + } + if (0xffe1..=0xffee).contains(&actual) || (0xffe1..=0xffee).contains(&expected) { + return false; + } + let shifted_pair = [ + (b';', b':'), + (b',', b'<'), + (b'.', b'>'), + (b'/', b'?'), + (b'\\', b'|'), + (b'[', b'{'), + (b']', b'}'), + (b'\'', b'"'), + (b'`', b'~'), + (b'-', b'_'), + (b'=', b'+'), + (b'1', b'!'), + (b'2', b'@'), + (b'3', b'#'), + (b'4', b'$'), + (b'5', b'%'), + (b'6', b'^'), + (b'7', b'&'), + (b'8', b'*'), + (b'9', b'('), + (b'0', b')'), + ] + .iter() + .any(|(base, shifted)| { + (actual == u32::from(*base) && expected == u32::from(*shifted)) + || (actual == u32::from(*shifted) && expected == u32::from(*base)) + }); + shifted_pair + && expected_states & 0x01 != 0 + && states & (MODIFIERS & !0x01) == expected_states & (MODIFIERS & !0x01) +} + +/// Build the same raw translation event emitted by the fcitx5 signal adapter. +/// This keeps the standalone host binary from reaching into the library's +/// private shortcut-conversion module. +pub fn translation_hotkey_event(target: &HotkeyRuntimeTarget) -> Option { + let (symbol, states) = crate::settings::shortcut_to_raw(&target.translation).ok()?; + Some(LinuxHotkeyEvent::TranslationPressed { symbol, states }) +} + +/// 本地边沿与插件信号之间的去重记账。 +#[derive(Default)] +pub struct HotkeyDeduplicator { + seen: HashMap, +} + +#[derive(Default)] +struct SourceStamps { + local: Option, + signal: Option, +} + +impl HotkeyDeduplicator { + /// 采纳一条本地边沿?本地命中后 [`HOTKEY_DEDUPE_WINDOW`] 内到达的同键插件信号 + /// 会被丢弃,这里的判定方向相反:插件信号刚处理过,本地边沿不再重复触发。 + pub fn accept_local(&mut self, hotkey: &LocalHotkey, at: Instant) -> bool { + let stamps = self.seen.entry(hotkey.clone()).or_default(); + stamps.local = Some(at); + !stamps + .signal + .is_some_and(|signal| at.saturating_duration_since(signal) < HOTKEY_DEDUPE_WINDOW) + } + + /// 采纳一条插件信号?本地刚命中过(同一窗口还是 XWayland 客户端)就丢弃。 + pub fn accept_signal(&mut self, hotkey: &LocalHotkey, at: Instant) -> bool { + let stamps = self.seen.entry(hotkey.clone()).or_default(); + stamps.signal = Some(at); + !stamps + .local + .is_some_and(|local| at.saturating_duration_since(local) < HOTKEY_DEDUPE_WINDOW) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openless_core::shared_types::{HotkeyMode, StylePackHotkey}; + + fn binding(primary: &str, modifiers: &[&str]) -> ShortcutBinding { + ShortcutBinding { + primary: primary.to_string(), + modifiers: modifiers.iter().map(|tag| tag.to_string()).collect(), + } + } + + fn target() -> HotkeyRuntimeTarget { + HotkeyRuntimeTarget { + dictation: binding("A", &["alt"]), + dictation_mode: HotkeyMode::Toggle, + qa: Some(binding(";", &["ctrl", "shift"])), + quick_note: None, + translation: binding("Z", &["alt"]), + switch_style: Some(binding("S", &["ctrl", "shift"])), + open_app: None, + selection_polish: Some(binding("X", &["alt"])), + coding_agent_enabled: true, + coding_agent_voice: Some(binding("V", &["alt"])), + coding_agent_panel: None, + coding_agent_quick: None, + style_packs: vec![StylePackHotkey { + pack_id: "pack-1".to_string(), + binding: binding("1", &["ctrl", "alt"]), + }], + } + } + + #[test] + fn a_configured_combo_matches_its_own_keysym_pair() { + let target = target(); + assert_eq!( + match_hotkey(&target, &binding("A", &["alt"])), + Some(LocalHotkey::Dictation) + ); + assert_eq!( + match_hotkey(&target, &binding(";", &["ctrl", "shift"])), + Some(LocalHotkey::Qa) + ); + assert_eq!( + match_hotkey(&target, &binding("V", &["alt"])), + Some(LocalHotkey::LessComputer) + ); + // 没绑的组合不命中。 + assert_eq!(match_hotkey(&target, &binding("B", &["alt"])), None); + // 修饰键不同也不命中(Alt+Z 是翻译,不是听写)。 + assert_eq!( + match_hotkey(&target, &binding("Z", &["alt"])), + Some(LocalHotkey::Translation) + ); + assert_eq!(match_hotkey(&target, &binding("A", &["ctrl"])), None); + } + + #[test] + fn implied_shift_matches_regardless_of_the_recorded_shift_tag() { + // `primary_keysym(':')` 自己带 Shift 位,所以「: + Ctrl」与「: + Ctrl+Shift」 + // 会被算成同一个 (keysym, states) —— 与注册给插件的判定完全一致。 + let mut target = target(); + target.qa = Some(binding(":", &["ctrl"])); + assert_eq!( + match_hotkey(&target, &binding(":", &["ctrl"])), + Some(LocalHotkey::Qa) + ); + assert_eq!( + match_hotkey(&target, &binding(":", &["ctrl", "shift"])), + Some(LocalHotkey::Qa) + ); + } + + #[test] + fn a_bare_modifier_binding_matches_the_modifier_itself() { + let mut target = target(); + target.dictation = binding("AltRight", &[]); + assert_eq!( + match_hotkey(&target, &binding("AltRight", &[])), + Some(LocalHotkey::Dictation) + ); + // egui 分不出左右:右 Option 的配置在窗口里按「alt 类」命中。 + assert_eq!( + match_hotkey(&target, &binding("LeftAlt", &[])), + Some(LocalHotkey::Dictation) + ); + // 别的类别不算命中。 + assert_eq!(match_hotkey(&target, &binding("Shift", &[])), None); + assert_eq!(match_hotkey(&target, &binding("A", &["alt"])), None); + } + + #[test] + fn style_pack_bindings_match_by_pack_id() { + let target = target(); + assert_eq!( + match_hotkey(&target, &binding("1", &["ctrl", "alt"])), + Some(LocalHotkey::StylePack("pack-1".to_string())) + ); + } + + #[test] + fn a_disabled_less_computer_hotkey_is_not_matched() { + let mut target = target(); + target.coding_agent_enabled = false; + assert_eq!(match_hotkey(&target, &binding("V", &["alt"])), None); + } + + #[test] + fn plugin_events_map_to_the_same_hotkey_identity() { + let target = target(); + let event = |event| plugin_event_hotkey(&event, &target); + assert_eq!( + event(LinuxHotkeyEvent::DictationPressed { + symbol: 0x61, + states: 8, + press_id: 1, + at: Instant::now(), + }), + Some(LocalHotkey::Dictation) + ); + assert_eq!(event(LinuxHotkeyEvent::QaPressed), Some(LocalHotkey::Qa)); + let (symbol, states) = crate::settings::shortcut_to_raw(&binding("1", &["ctrl", "alt"])) + .expect("style pack hotkey converts"); + assert_eq!( + event(LinuxHotkeyEvent::StylePackPressed { symbol, states }), + Some(LocalHotkey::StylePack("pack-1".to_string())) + ); + // 不认识的 (keysym, states) 不冒充任何热键。 + assert_eq!( + event(LinuxHotkeyEvent::StylePackPressed { + symbol: 0xffff, + states: 0 + }), + None + ); + } + + #[test] + fn the_dedupe_window_swallows_the_other_source_in_both_directions() { + let hotkey = LocalHotkey::Dictation; + let start = Instant::now(); + let mut dedupe = HotkeyDeduplicator::default(); + + // 本地先命中:紧随其后的插件信号被丢弃。 + assert!(dedupe.accept_local(&hotkey, start)); + assert!(!dedupe.accept_signal(&hotkey, start + Duration::from_millis(50))); + // 窗口过后,插件信号重新被采纳。 + assert!(dedupe.accept_signal( + &hotkey, + start + HOTKEY_DEDUPE_WINDOW + Duration::from_millis(1) + )); + + // 反方向:插件信号先到,本地边沿被丢弃。 + let later = start + Duration::from_secs(5); + let mut dedupe = HotkeyDeduplicator::default(); + assert!(dedupe.accept_signal(&hotkey, later)); + assert!(!dedupe.accept_local(&hotkey, later + Duration::from_millis(10))); + assert!(dedupe.accept_local(&hotkey, later + HOTKEY_DEDUPE_WINDOW)); + } + + #[test] + fn local_press_ids_never_collide_with_plugin_signal_ids() { + let first = next_local_press_id(); + let second = next_local_press_id(); + assert!(first >= LOCAL_PRESS_ID_BASE); + assert!(second > first); + } +} diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index b716156bf..f9b694796 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -3,55 +3,73 @@ fn main() { eprintln!("openless-linux-egui is only available on Linux"); } +mod style_icon; #[cfg(target_os = "linux")] mod ui; #[cfg(target_os = "linux")] mod linux_app { + mod history; + use history::HistoryCache; + mod settings_save; + mod window; + use settings_save::SettingsSave; use std::future::Future; use std::sync::mpsc; use std::sync::Arc; use std::time::Duration; + use crate::ui::bridge::{ + self, HostToWindow, UiBridgeClient, UiBridgeHost, WindowToHost, UI_BRIDGE_VERSION, + }; use crate::ui::frontend::{self, view_model::FrontendViewModel}; use crate::ui::{shell, theme}; use chrono::Datelike; use eframe::egui; use openless_core::{ BackendConfig, BackendError, BackendEvent, BackendEventKind, BackendSnapshot, - DictationPhase, HistoryInsertStatus, HostAction, LessComputerEventKind, QaStateEvent, - QaStateKind, SelectionPhase, SelectionSnapshot, TranscriptAccumulator, UserPreferences, + DictationPhase, 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, + capsule_hide_delay, capsule_hide_is_still_current, capsule_needs_fallback_dismissal, + capsule_outcome, fmt_l10n, load_locale_pref, normalize_stop_result, phase_shows_capsule, + save_locale_pref, tr_l10n, CapsuleOutcome, Lang, LocalePref, }; use openless_linux_egui::{ - fmt_l10n, load_locale_pref, save_locale_pref, tr_l10n, Lang, LocalePref, LANGS, + drain_events, fcitx5_copy_to_clipboard, notify, open_external, write_jsonl, + EventDrainOutcome, Fcitx5HotkeyListener, FcitxPluginInstallPlan, HostToPopup, + LinuxBackendBuilder, LinuxCapabilitySnapshot, LinuxHotkeyEvent, LinuxLaunchIntent, + LinuxNativeRuntime, LinuxResourceLayout, Notification, PopupActionGuard, PopupChatMessage, + PopupKind, PopupState, PopupSupervisor, PopupSupervisorEvent, PopupToHost, + SingleInstanceBroker, SingleInstanceRole, POPUP_PROTOCOL_VERSION, }; + use window::*; enum UiResult { - HistoryTransform { + Message(String), + HistoryLoaded { generation: u64, + revision: u64, + result: Result, String>, + }, + HistoryRepolish { 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), + /// 终态在屏上停留结束:胶囊可以收起了(handler 会再核对会话与相位)。 + CapsuleDismissDue { + session_id: String, + }, Remote(Result<(openless_core::RemoteInputStatus, String), String>), Providers(Result), + /// Credential channels for the settings modal's AI-services tab. + SettingsChannels(Result, String>), + ChannelCreated { + kind: openless_core::ChannelKind, + result: Result, + }, + ServiceConfigured([bool; 2]), ProviderEditor { kind: openless_core::ChannelKind, channel_id: String, @@ -62,24 +80,32 @@ mod linux_app { channel_id: String, result: Result, String>, }, - ProviderMutation(Result), Library(Result), - StyleSaved(Result), - SettingsSaved(Box>), - Marketplace { - generation: u64, - result: Result<(Vec, Vec), String>, + LibraryRefresh, + SettingsSaved { + request: u64, + result: Result<(), String>, + }, + Marketplace(u64, Result, String>), + /// 安装结束(成功或失败):清掉「安装中…」状态并给出提示。 + MarketplaceInstallFinished { + id: String, + result: Result, }, - MarketplaceMutation(Result), - MarketplaceFlow(Result), - MarketplaceAuthPoll(Result), + MarketplaceLikes(Result, String>), + MarketplaceAuth(Result), + MarketplaceOAuthStarted(Result), + MarketplaceOAuthPoll { + flow_id: String, + result: Result, + }, + MarketplacePublish(Result), + MarketplaceUpload(Result), + MarketplaceWithdraw(Result), MarketplaceDetail(Result), MarketplaceMine(Result<(Vec, Vec), String>), Microphones(Result, String>), - Overview(u64, Result), - UpdateCheck(Result, String>), - UpdateProgress(openless_linux_egui::DownloadProgress), - UpdateInstalled(Result), + Overview(Result), } #[derive(Clone)] @@ -98,91 +124,43 @@ mod linux_app { 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, + /// One credential channel cached for the settings modal. + #[derive(Clone, Debug)] + struct SettingsChannelRow { + id: String, + name: String, + provider_type: String, + model: String, + enabled: bool, + last_ok: Option, + last_latency_ms: Option, + last_error: Option, } - 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(); + /// 追问编辑态:Core 只在变化时下发 `Some(..)`,所以逐字段合并。 + #[derive(Clone, Copy, Debug, Default)] + struct QaEditFlags { + instruction_mode: bool, + apply_available: bool, + revert_available: bool, + } + + /// 固定(图钉)后不再响应宿主的自动收起;✕/Esc 仍照常关闭。 + fn qa_hides_on_host_action(pinned: bool) -> bool { + !pinned + } + + impl QaEditFlags { + fn merge(&mut self, state: &openless_core::QaStateEvent) { + if let Some(value) = state.edit_instruction_mode { + self.instruction_mode = value; } - if self.appearance { - merged.theme_mode = draft.theme_mode; - merged.show_overview_activity_heatmap = draft.show_overview_activity_heatmap; + if let Some(value) = state.edit_apply_available { + self.apply_available = value; } - 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(); + if let Some(value) = state.edit_revert_available { + self.revert_available = value; } - merged } } @@ -208,16 +186,18 @@ mod linux_app { struct RecentEntry { created_at: String, final_text: String, + raw_transcript: String, + mode: openless_core::PolishMode, 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`. + /// One calendar day of activity. Used both by the trailing daily series + /// behind the period chart and by the calendar-year heatmap. #[derive(Clone, Debug, Default, PartialEq, Eq)] - struct ActivityAggregate { - active_days: usize, - segments: u64, + struct DailyActivity { + /// `YYYY-MM-DD` local date. + date: String, + count: u32, chars: u64, duration_ms: u64, } @@ -235,13 +215,14 @@ mod linux_app { 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, + /// Last 30 calendar days ending today, chronological (oldest first). + /// The 7-day view slices the tail. + activity_daily: Vec, + /// Calendar year rendered by the annual heatmap card. + heatmap_year: i32, + /// Every day of `heatmap_year`, chronological. Days without activity + /// are present with `count == 0` so the page can lay out the grid. + heatmap: Vec, } #[derive(Clone, Debug)] @@ -255,11 +236,11 @@ mod linux_app { enum ProvidersState { Loading, Loaded(ProviderPanel), - Failed(String), + Failed, } - /// Trailing annual window rendered by the Overview heatmap. - const OVERVIEW_HEATMAP_DAYS: i64 = 364; + /// Trailing window (days) covered by the period chart's daily series. + const OVERVIEW_DAILY_DAYS: i64 = 30; impl OverviewState { fn summary(&self, today: chrono::NaiveDate) -> Option { @@ -278,48 +259,49 @@ mod linux_app { .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( + /// Build the trailing daily series ending at `today` (inclusive), oldest + /// first. Days absent from the store render as zero. + fn build_daily_series( by_date: &std::collections::BTreeMap, today: chrono::NaiveDate, days: i64, - ) -> 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 + ) -> Vec { + let mut series = Vec::with_capacity(days as usize); + for offset in (0..days).rev() { + let date = today - chrono::Duration::days(offset); + series.push(daily_activity(by_date, date)); + } + series } - /// Build 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( + /// Build the full calendar-year heatmap for `year`: January 1st through + /// December 31st, chronological, inactive days included. + fn build_calendar_year_heatmap( by_date: &std::collections::BTreeMap, - 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); + year: i32, + ) -> Vec { + let mut days = Vec::with_capacity(366); + let Some(mut date) = chrono::NaiveDate::from_ymd_opt(year, 1, 1) else { + return days; + }; + while date.year() == year { + days.push(daily_activity(by_date, date)); date += chrono::Duration::days(1); } - weeks + days + } + + fn daily_activity( + by_date: &std::collections::BTreeMap, + date: chrono::NaiveDate, + ) -> DailyActivity { + let day = by_date.get(&date); + DailyActivity { + date: date.format("%Y-%m-%d").to_string(), + count: day.map(|day| day.count).unwrap_or(0), + chars: day.map(|day| day.chars).unwrap_or(0), + duration_ms: day.map(|day| day.duration_ms).unwrap_or(0), + } } /// Shape the fetched Core snapshot into the display summary. Pure and free of @@ -349,6 +331,8 @@ mod linux_app { .map(|session| RecentEntry { created_at: session.created_at.clone(), final_text: session.final_text.clone(), + raw_transcript: session.raw_transcript.clone(), + mode: session.mode, duration_ms: session.duration_ms, }) .collect(); @@ -376,199 +360,10 @@ mod linux_app { 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), - ); - } + activity_daily: build_daily_series(&by_date, today, OVERVIEW_DAILY_DAYS), + heatmap_year: today.year(), + heatmap: build_calendar_year_heatmap(&by_date, today.year()), } - 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)] @@ -591,12 +386,114 @@ mod linux_app { #[derive(Clone)] enum ProviderEditorState { Idle, - Loading { - kind: openless_core::ChannelKind, - channel_id: String, - }, + Loading, Loaded(Box), - Failed(String), + Failed, + } + + /// Draft of the open provider editor. It is the single source of truth for + /// the editor fields: pages push their input back here as actions and the + /// mirror writes it out each frame, so re-reading the channel list never + /// clobbers what the user is typing. + struct ProviderEditorForm { + channel_id: String, + provider_type: String, + label: String, + auth: frontend::view_model::SettingsProviderAuth, + name: String, + endpoint: String, + model: String, + resource_id: String, + auth_mode: String, + // Write-only secret drafts: they start empty on every load and are + // cleared as soon as they have been handed to Core. + primary_secret: String, + secondary_secret: String, + models: Vec, + models_loading: bool, + /// Core descriptor 的高阶字段:模型块的「预设 / 默认 / 文档页」全部由它决定。 + static_models: Vec, + default_model: String, + models_url: Option, + /// 「自定义模型…」手输模式(仅 UI 状态,Tauri `customModelMode`)。 + custom_model: bool, + } + + impl ProviderEditorForm { + fn from_editor(editor: &ProviderEditor, lang: Lang) -> Self { + Self { + channel_id: editor.channel.id.clone(), + provider_type: editor.descriptor.provider_type.as_str().to_string(), + label: localized_provider_label( + lang, + editor.kind, + editor.descriptor.provider_type.as_str(), + ), + auth: settings_provider_auth(editor.descriptor.auth_requirement), + name: editor.name.clone(), + endpoint: editor.endpoint.clone(), + model: editor.model.clone(), + resource_id: editor.resource_id.clone(), + auth_mode: if editor.auth_mode.is_empty() { + "app_id_token".to_string() + } else { + editor.auth_mode.clone() + }, + primary_secret: String::new(), + secondary_secret: String::new(), + models: Vec::new(), + models_loading: false, + static_models: editor.descriptor.static_models.clone(), + default_model: editor.descriptor.default_model.clone().unwrap_or_default(), + models_url: provider_models_url(&editor.descriptor, &editor.endpoint), + custom_model: false, + } + } + } + + /// 「崩溃即重开」的预算:同一个面板在 [`POPUP_RESTART_WINDOW`] 内最多重开 + /// 这么多次。 + /// + /// 面板崩溃后旧代码会立即 `show_*_popup()`,一个必崩的面板(例如渲染时 + /// panic)就会变成“弹窗一直反复弹出”:用户看到的是面板高频重现,日志里是 + /// 一串退出码。给重开加上预算,超了就停手并留一条可读日志。 + const POPUP_RESTART_LIMIT: usize = 2; + const POPUP_RESTART_WINDOW: Duration = Duration::from_secs(60); + /// 面板种类数(`PopupKind` 没有 index 方法,这里只用于数组下标)。 + const POPUP_KIND_COUNT: usize = 3; + + fn popup_kind_index(kind: PopupKind) -> usize { + match kind { + PopupKind::Qa => 0, + PopupKind::Capsule => 1, + PopupKind::LessComputer => 2, + } + } + + #[derive(Default, Clone, Copy)] + struct PopupRestartBudget { + attempts: usize, + window_start: Option, + } + + impl PopupRestartBudget { + /// 这次崩溃允许重开吗?窗口过期就重新计数。 + fn allow(&mut self, at: std::time::Instant) -> bool { + match self.window_start { + Some(start) if at.saturating_duration_since(start) < POPUP_RESTART_WINDOW => { + if self.attempts >= POPUP_RESTART_LIMIT { + return false; + } + self.attempts += 1; + true + } + _ => { + self.window_start = Some(at); + self.attempts = 1; + true + } + } + } } pub struct OpenLessEguiApp { @@ -605,14 +502,19 @@ mod linux_app { subscription: Option, snapshot: Option, preferences: Option, - settings_dirty: SettingsDirty, + settings_save: SettingsSave, + settings_channel_kind: openless_core::ChannelKind, + settings_channels: Vec, + settings_channels_loading: bool, + /// 语言模型 / 语音识别是否各自有启用的渠道(AI 服务页的状态点)。 + service_configured: [bool; 2], + /// 文本型设置行(端口/条数/路径…)只在偏好刚载入或外部变更时回灌, + /// 否则每帧覆盖会把用户正在输入的内容弹回去(表现为「输入框用不了」)。 + hydrate_text_fields: bool, overview: OverviewState, - overview_generation: std::sync::atomic::AtomicU64, microphones: Vec, - playback: Arc, - history_confirmation: Option>, - history_task: Option>, - history_generation: u64, + /// 麦克风枚举失败的原因(设置页据此显示 `microphoneLoadError`)。 + microphone_error: Option, transcript: String, transcript_state: TranscriptAccumulator, transcript_session: Option, @@ -622,11 +524,23 @@ mod linux_app { less_computer_output: String, less_computer_turn_start: usize, less_computer_session: Option, + /// Less Computer 面板要呈现的事件序列。宿主是唯一所有者,弹窗进程只负责画; + /// 每次重连都收到完整序列,窗口进程重启不丢历史。 + less_computer_entries: Vec, + /// 本轮尚未终结(面板显示「执行中…」)。 + less_computer_working: bool, + /// 已展示过的面板是否还在(托起面板时只推状态,不重复拉起进程)。 + less_computer_popup: Option, pending_approval: Option<(String, String)>, qa_visible: bool, - qa_input: String, + /// 划词追问的图钉:固定后 `HostAction::HideQa` 不再收起窗口。 + qa_pinned: bool, + /// 追问「编辑指令」三态(Core `QaStateEvent` 的部分更新)。 + qa_edit: QaEditFlags, qa_state: Option, - selection_preview_visible: bool, + /// 选区助手面板是否处于「润色结果」模式(独立预览窗口已下线, + /// 润色结果由这个面板承载:提问对话 / 润色结果是同一弹窗的两套 UI)。 + polish_result_visible: bool, selection_draft: String, selection: Option, remote_access: Option<(openless_core::RemoteInputStatus, String)>, @@ -634,78 +548,123 @@ mod linux_app { providers: ProvidersState, selected_channel_id: Option, provider_editor: ProviderEditorState, + /// Draft mirrored into the view model while the editor is open. + provider_editor_form: Option, provider_models: Vec, new_provider_type: String, - new_channel_name: String, pending_channel_delete: Option, vocabulary: Vec, correction_rules: Vec, style_packs: Vec, - vocabulary_phrase: String, - vocabulary_note: String, - correction_pattern: String, - correction_replacement: String, + /// `(pack id, icon path)` → data URL. Reading and base64-encoding the + /// icon every frame would be pure waste; entries are invalidated by + /// the path changing (Core writes a fresh filename per change). + style_icon_cache: std::collections::HashMap<(String, String), String>, vocab_preset_store: openless_core::VocabPresetStore, vocab_presets: Vec, - vocab_preset_name: String, - vocab_preset_phrases: String, - history_search: String, qa_popup: Option, - preview_popup: Option, capsule_popup: Option, popup_action_guard: PopupActionGuard, + /// 当前胶囊展示的会话 id(用于「会话消失但没收到终态」的兜底收起)。 + capsule_session: Option, + /// 已经为哪个会话排过收起计时,避免重复计时。 + capsule_dismissal_scheduled: Option, tray: Option, exit_requested: bool, - update_support: LinuxUpdateSupport, - update_schedule: UpdateSchedule, - update_started: std::time::Instant, - update_manifest: Option, - update_busy: bool, - update_progress: Option, - update_cancellation: Option, - restored_font_scale: Option, + /// 上次打「泵心跳」日志的时间。 + last_pump_heartbeat: std::time::Instant, + window: WindowState, + /// 本帧从 UI 收到、待宿主执行的动作(按到达顺序)。 + pending_ui_actions: Vec, + /// 本帧从 UI 窗口/面板收到、待处理的本地热键边沿(按到达时刻)。 + /// + /// 我们自己的窗口有焦点时插件收不到按键,热键只能由窗口自己认出来 + /// (见 `local_hotkeys` 模块文档),这里按顺序攒着与插件信号一起处理。 + pending_local_hotkeys: Vec<(std::time::Instant, openless_linux_egui::LocalHotkeyEdge)>, + /// 本地边沿与插件信号之间的去重(同一个物理按键可能两个来源都报)。 + hotkey_dedupe: openless_linux_egui::HotkeyDeduplicator, + /// 已在 60s 内重开过的面板次数(索引见 `popup_kind_index`)。 + popup_restarts: [PopupRestartBudget; POPUP_KIND_COUNT], + /// 最近一次下发给窗口/面板的热键配置;变了才重发。 + hotkeys_sent: Option, + /// 速记页快捷键卡片是否被收起(持久化在 linux-ui-state.json)。 + quick_note_shortcut_hidden: bool, + /// 本帧从 UI 收到、待回包的延迟探针序号。 + pending_ui_pongs: Vec, + /// Currently playing history recording (session id + player handle). + history: HistoryCache, + history_clip: Option<(String, openless_linux_egui::ClipPlayer)>, marketplace_items: Vec, + /// True once a marketplace list request has completed (ok or error), so + /// the page can leave its loading state even when the result is empty. + marketplace_attempted: bool, marketplace_query: String, - marketplace_ui: MarketplaceUi, - marketplace_flow: Option, marketplace_detail: Option, marketplace_my_packs: Vec, marketplace_my_likes: Vec, + /// Monotonic id for marketplace list requests: a response from a + /// superseded search is dropped instead of overwriting fresher data + /// (same intent as the Tauri page's `reqSeqRef` guard). + marketplace_seq: u64, + /// Debounce deadline for the search box. The Tauri page waits 300ms + /// after the last keystroke before hitting the API; without it every + /// character (and every IME composition update) was its own request. + marketplace_search_deadline: Option, + marketplace_oauth_poll_deadline: Option, + marketplace_oauth_interval_secs: u64, + /// Likes are fetched once per session (Tauri refreshes them when the + /// sign-in state changes), not on every search. + marketplace_likes_loaded: bool, 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, } + /// Dirty check between the style-editor draft and the last stored pack. + fn style_editor_is_dirty(vm: &frontend::view_model::FrontendViewModel) -> bool { + let Some(saved) = vm.style_editor_saved.as_ref() else { + return true; + }; + let tags: Vec = vm + .style_tags + .split([',', ',', '\n']) + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(ToOwned::to_owned) + .collect(); + vm.style_name.trim() != saved.name + || vm.style_description.trim() != saved.description + || vm.style_prompt != saved.prompt + || vm.style_selection_prompt != saved.selection_prompt + || vm.style_voice_edit_prompt != saved.voice_edit_prompt + || tags != saved.tags + || vm.style_author.trim() != saved.author.clone().unwrap_or_default() + || vm.style_version.trim() != saved.version + || vm.style_model.trim() != saved.recommended_model.clone().unwrap_or_default() + || vm.style_compatible_version.trim() + != saved.compatible_app_version.clone().unwrap_or_default() + || vm.style_examples != saved.examples + } + impl OpenLessEguiApp { fn new( tokio: Arc, native: Result, tray: Option, - update_support: LinuxUpdateSupport, + window_should_be_open: bool, ) -> Self { let (tx, rx) = mpsc::channel(); let locale_pref = load_locale_pref(); let lang = locale_pref.resolve(); + // The Quick Note shortcut card is dismissible and the choice + // survives restarts (upstream keeps it in localStorage). + let quick_note_shortcut_hidden = openless_linux_egui::load_quick_note_shortcut_hidden(); 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 @@ -717,21 +676,23 @@ mod linux_app { let backend = native.host().backend(); let snapshot = backend.snapshot(); let preferences = backend.get_preferences(); + let history = HistoryCache::new(&preferences); let subscription = backend.subscribe(); let app = Self { tokio, native: Some(native), subscription: Some(subscription), snapshot: Some(snapshot), + settings_save: SettingsSave::new(&preferences), preferences: Some(preferences), - settings_dirty: SettingsDirty::default(), + settings_channel_kind: openless_core::ChannelKind::Llm, + settings_channels: Vec::new(), + settings_channels_loading: false, + service_configured: [false; 2], + hydrate_text_fields: true, overview: OverviewState::Loading, - overview_generation: std::sync::atomic::AtomicU64::new(0), microphones: Vec::new(), - playback: Arc::default(), - history_confirmation: None, - history_task: None, - history_generation: 0, + microphone_error: None, transcript: String::new(), transcript_state: TranscriptAccumulator::default(), transcript_session: None, @@ -741,11 +702,15 @@ mod linux_app { less_computer_output: String::new(), less_computer_turn_start: 0, less_computer_session: None, + less_computer_entries: Vec::new(), + less_computer_working: false, + less_computer_popup: None, pending_approval: None, qa_visible: false, - qa_input: String::new(), + qa_pinned: false, + qa_edit: QaEditFlags::default(), qa_state: None, - selection_preview_visible: false, + polish_result_visible: false, selection_draft: String::new(), selection: None, remote_access: None, @@ -753,64 +718,52 @@ mod linux_app { providers: ProvidersState::Loading, selected_channel_id: None, provider_editor: ProviderEditorState::Idle, + provider_editor_form: None, provider_models: Vec::new(), new_provider_type: String::new(), - new_channel_name: String::new(), pending_channel_delete: None, 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(), + style_icon_cache: std::collections::HashMap::new(), vocab_preset_store: openless_core::VocabPresetStore::default(), vocab_presets: Vec::new(), - vocab_preset_name: String::new(), - vocab_preset_phrases: String::new(), - history_search: String::new(), qa_popup: None, - preview_popup: None, capsule_popup: None, popup_action_guard: PopupActionGuard::default(), + capsule_session: None, + capsule_dismissal_scheduled: None, tray, exit_requested: false, - update_support, - update_schedule: UpdateSchedule::new(Duration::ZERO), - update_started: std::time::Instant::now(), - update_manifest: None, - update_busy: false, - update_progress: None, - update_cancellation: None, - restored_font_scale: None, + last_pump_heartbeat: std::time::Instant::now(), + window: WindowState::new(window_should_be_open), + pending_ui_actions: Vec::new(), + pending_local_hotkeys: Vec::new(), + hotkey_dedupe: openless_linux_egui::HotkeyDeduplicator::default(), + quick_note_shortcut_hidden, + popup_restarts: [PopupRestartBudget::default(); POPUP_KIND_COUNT], + hotkeys_sent: None, + pending_ui_pongs: Vec::new(), + history, + history_clip: None, marketplace_items: Vec::new(), + marketplace_attempted: false, marketplace_query: String::new(), - marketplace_ui: MarketplaceUi::default(), - marketplace_flow: None, marketplace_detail: None, marketplace_my_packs: Vec::new(), marketplace_my_likes: Vec::new(), + marketplace_seq: 0, + marketplace_search_deadline: None, + marketplace_oauth_poll_deadline: None, + marketplace_oauth_interval_secs: 5, + marketplace_likes_loaded: false, 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, }; @@ -826,15 +779,16 @@ mod linux_app { native: None, subscription: None, snapshot: None, + hydrate_text_fields: true, preferences: None, - settings_dirty: SettingsDirty::default(), + settings_save: SettingsSave::default(), + settings_channel_kind: openless_core::ChannelKind::Llm, + settings_channels: Vec::new(), + settings_channels_loading: false, + service_configured: [false; 2], 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, + microphone_error: None, transcript: String::new(), transcript_state: TranscriptAccumulator::default(), transcript_session: None, @@ -844,11 +798,15 @@ mod linux_app { less_computer_output: String::new(), less_computer_turn_start: 0, less_computer_session: None, + less_computer_entries: Vec::new(), + less_computer_working: false, + less_computer_popup: None, pending_approval: None, qa_visible: false, - qa_input: String::new(), + qa_pinned: false, + qa_edit: QaEditFlags::default(), qa_state: None, - selection_preview_visible: false, + polish_result_visible: false, selection_draft: String::new(), selection: None, remote_access: None, @@ -856,64 +814,52 @@ mod linux_app { providers: ProvidersState::Loading, selected_channel_id: None, provider_editor: ProviderEditorState::Idle, + provider_editor_form: None, provider_models: Vec::new(), new_provider_type: String::new(), - new_channel_name: String::new(), pending_channel_delete: None, 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(), + style_icon_cache: std::collections::HashMap::new(), vocab_preset_store: openless_core::VocabPresetStore::default(), vocab_presets: Vec::new(), - vocab_preset_name: String::new(), - vocab_preset_phrases: String::new(), - history_search: String::new(), qa_popup: None, - preview_popup: None, capsule_popup: None, popup_action_guard: PopupActionGuard::default(), + capsule_session: None, + capsule_dismissal_scheduled: None, tray, exit_requested: false, - update_support, - update_schedule: UpdateSchedule::new(Duration::ZERO), - update_started: std::time::Instant::now(), - update_manifest: None, - update_busy: false, - update_progress: None, - update_cancellation: None, - restored_font_scale: None, + last_pump_heartbeat: std::time::Instant::now(), + window: WindowState::new(window_should_be_open), + pending_ui_actions: Vec::new(), + pending_local_hotkeys: Vec::new(), + hotkey_dedupe: openless_linux_egui::HotkeyDeduplicator::default(), + quick_note_shortcut_hidden, + popup_restarts: [PopupRestartBudget::default(); POPUP_KIND_COUNT], + hotkeys_sent: None, + pending_ui_pongs: Vec::new(), + history: HistoryCache::default(), + history_clip: None, marketplace_items: Vec::new(), + marketplace_attempted: false, marketplace_query: String::new(), - marketplace_ui: MarketplaceUi::default(), - marketplace_flow: None, marketplace_detail: None, marketplace_my_packs: Vec::new(), marketplace_my_likes: Vec::new(), + marketplace_seq: 0, + marketplace_search_deadline: None, + marketplace_oauth_poll_deadline: None, + marketplace_oauth_interval_secs: 5, + marketplace_likes_loaded: false, 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, }, @@ -926,11 +872,141 @@ mod linux_app { .map(|native| Arc::clone(native.host().backend())) } + /// Hide / restore the Quick Note shortcut card and persist the choice. + fn set_quick_note_shortcut_hidden(&mut self, hidden: bool) { + self.quick_note_shortcut_hidden = hidden; + self.frontend_vm.quick_note_shortcut_hidden = hidden; + if let Err(error) = openless_linux_egui::save_quick_note_shortcut_hidden(hidden) { + log::warn!("[ui] cannot persist quick-note shortcut visibility: {error}"); + } + } + + /// Load the editor's stored pack into the view model. `Revert` reuses + /// this so the drawer always shows Core's last persisted values. + fn hydrate_style_editor(&mut self, pack: &openless_core::StylePack, exists: bool) { + // Read the workflow-specific active id before borrowing `frontend_vm`. + let selection_active = self.selection_style_pack_id() == Some(pack.id.clone()); + let vm = &mut self.frontend_vm; + vm.style_editor_open = true; + vm.style_editor_id = pack.id.clone(); + vm.style_editor_builtin = pack.kind == openless_core::StylePackKind::Builtin; + vm.style_editor_active = if vm.style_selection_workflow { + selection_active + } else { + pack.active + }; + vm.style_editor_mode = polish_mode_label(self.lang, pack.base_mode).to_string(); + vm.style_prompt = pack.prompt.clone(); + vm.style_name = pack.name.clone(); + vm.style_description = pack.description.clone(); + vm.style_selection_prompt = pack.selection_prompt.clone(); + vm.style_voice_edit_prompt = pack.voice_edit_prompt.clone(); + vm.style_tags = pack.tags.join(", "); + vm.style_author = pack.author.clone().unwrap_or_default(); + vm.style_version = pack.version.clone(); + vm.style_model = pack.recommended_model.clone().unwrap_or_default(); + vm.style_compatible_version = pack.compatible_app_version.clone().unwrap_or_default(); + vm.style_examples = pack.examples.clone(); + vm.style_editor_dirty = !exists; + vm.style_editor_publishing = false; + vm.style_editor_saved = Some(pack.clone()); + } + + fn selection_style_pack_id(&self) -> Option { + self.preferences + .as_ref() + .map(|prefs| prefs.selection_polish_style_pack_id.clone()) + .filter(|id| !id.is_empty()) + } + + /// Data URLs for every style-pack icon, cached by `(id, icon_path)`. + fn style_icon_urls(&mut self) -> Vec> { + let backend = self.backend(); + let mut seen = Vec::new(); + let urls = self + .style_packs + .iter() + .map(|pack| { + let path = pack.icon_path.clone()?; + let key = (pack.id.clone(), path); + seen.push(key.clone()); + if let Some(url) = self.style_icon_cache.get(&key) { + return Some(url.clone()); + } + let url = backend + .as_ref()? + .read_style_pack_icon(&pack.id) + .ok() + .flatten(); + if let Some(url) = &url { + self.style_icon_cache.insert(key, url.clone()); + } + url + }) + .collect(); + // Drop entries whose icon was replaced or removed. + self.style_icon_cache.retain(|key, _| seen.contains(key)); + urls + } + + /// Rasterize an SVG picked by the user and hand the PNG to Core. + fn set_style_pack_icon_from_file(&mut self, id: String) { + let Some(backend) = self.backend() else { + return; + }; + let lang = self.lang; + self.spawn_reporting(async move { + let bytes = tokio::task::spawn_blocking(|| { + let path = rfd::FileDialog::new() + .add_filter("SVG icon", &["svg"]) + .pick_file()?; + std::fs::read(path).ok() + }) + .await + .map_err(|error| { + BackendError::new(openless_core::BackendErrorCode::Internal, error.to_string()) + })?; + // Dismissing the picker is not an error and needs no notice. + let Some(bytes) = bytes else { + return Ok(String::new()); + }; + let png = + tokio::task::spawn_blocking(move || crate::style_icon::rasterize_svg(&bytes)) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|reason| { + log::warn!("[style] rejected icon for {id}: {reason}"); + BackendError::new( + openless_core::BackendErrorCode::InvalidArgument, + tr_l10n(lang, "style.pack.iconInvalid"), + ) + })?; + backend.set_style_pack_icon(&id, Some(&png))?; + Ok(tr_l10n(lang, "style.pack.iconSaved").to_string()) + }); + } + + fn reset_style_pack_icon(&mut self, id: String) { + let Some(backend) = self.backend() else { + return; + }; + let lang = self.lang; + self.spawn(async move { + backend.set_style_pack_icon(&id, None)?; + Ok(tr_l10n(lang, "style.pack.resetIcon").to_string()) + }); + } + fn popup_slot(&mut self, kind: PopupKind) -> &mut Option { match kind { PopupKind::Qa => &mut self.qa_popup, - PopupKind::Preview => &mut self.preview_popup, PopupKind::Capsule => &mut self.capsule_popup, + PopupKind::LessComputer => &mut self.less_computer_popup, } } @@ -951,6 +1027,10 @@ mod linux_app { fn send_popup(&mut self, kind: PopupKind, message: HostToPopup) { let lang = self.lang; + // 记录胶囊当前承载的会话:兜底收起要靠它判断「会话是否还在快照里」。 + if let HostToPopup::Capsule { session_id, .. } = &message { + self.capsule_session = Some(session_id.clone()); + } let retry = message.clone(); if let Some(supervisor) = self.popup_slot(kind) { if let Err(error) = supervisor.try_send(message) { @@ -981,27 +1061,64 @@ mod linux_app { ); } + /// Less Computer 面板的当前快照:宿主是事件序列的唯一所有者,弹窗进程 + /// 每次重连都收到完整序列(重开窗口不丢历史)。 + fn less_computer_snapshot(&self, lang: Lang) -> HostToPopup { + let approval = self.pending_approval.as_ref().map(|(token, command)| { + openless_linux_egui::LessComputerApproval { + token: token.clone(), + command: command.clone(), + reason: tr_l10n(lang, "less_computer.approval_rerun_warning").to_string(), + } + }); + HostToPopup::LessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id: self + .less_computer_session + .map(|session| session.to_string()) + .unwrap_or_else(|| "less-computer".to_string()), + sequence: self.last_event_sequence.saturating_mul(2), + entries: self.less_computer_entries.clone(), + working: self.less_computer_working, + approval, + error: None, + } + } + + fn show_less_computer_popup(&mut self) { + self.ensure_popup(PopupKind::LessComputer); + let message = self.less_computer_snapshot(self.lang); + self.send_popup(PopupKind::LessComputer, message); + } + + /// ✕ 只收起面板:不动已完成的对话,也不结束进程。 + fn hide_less_computer_popup(&mut self) { + let session_id = self + .less_computer_session + .map(|session| session.to_string()) + .unwrap_or_else(|| "less-computer".to_string()); + let sequence = self.last_event_sequence.saturating_mul(2).saturating_add(1); + self.hide_popup(PopupKind::LessComputer, session_id, sequence); + } + fn expected_popup_session(&self, kind: PopupKind) -> Option { match kind { PopupKind::Qa => self .qa_state .as_ref() .map(|state| state.session_id.clone().unwrap_or_else(|| "qa".to_string())), - PopupKind::Preview => self - .selection - .as_ref() - .and_then(|selection| selection.session_id) - .map(|session_id| session_id.to_string()), PopupKind::Capsule => self .snapshot .as_ref() .and_then(|snapshot| snapshot.dictation.session_id) .map(|session_id| session_id.to_string()), + PopupKind::LessComputer => self + .less_computer_session + .map(|session_id| session_id.to_string()), } } fn show_qa_popup(&mut self) { - 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; @@ -1026,22 +1143,110 @@ mod linux_app { selection_preview: state.selection_preview, streaming_answer: state.chunk.unwrap_or_default(), error: state.error, + edit_instruction_mode: self.qa_edit.instruction_mode, + edit_apply_available: self.qa_edit.apply_available, + edit_revert_available: self.qa_edit.revert_available, + pinned: self.qa_pinned, + viewer_login: self.marketplace_login(), }, ); } + /// 当前 GitHub 登录名(设置里登录后写入偏好),用于追问头像。 + fn marketplace_login(&self) -> String { + self.preferences + .as_ref() + .map(|prefs| prefs.marketplace_dev_login.trim().to_string()) + .unwrap_or_default() + } + + /// 「预览并确认插入」:沿用 Tauri `confirm_selection_voice_preview` 的 + /// 四步(取 owner → 取预览文本 → 开 apply ticket → 原生落字 → finish), + /// Linux 的原生落字走 fcitx5 选区替换。 + fn spawn_qa_edit_apply( + &self, + backend: std::sync::Arc, + qa_session: openless_core::SessionId, + ) { + let lang = self.lang; + self.spawn(async move { + let unavailable = || { + BackendError::new( + openless_core::BackendErrorCode::InvalidState, + "qa edit unavailable", + ) + }; + let services = backend.services(); + let snapshot = services.qa.snapshot().await?; + let owner = snapshot.conversation_id.ok_or_else(unavailable)?; + let preview = services + .selection_voice + .preview(Some(owner)) + .await? + .ok_or_else(unavailable)?; + let text = preview.text.trim().to_string(); + if text.is_empty() { + return Err(unavailable()); + } + let ticket = services + .qa + .begin_edit_preview_apply(qa_session, text) + .await?; + let outcome = match openless_linux_egui::apply_selection_voice_target( + &ticket.session_id.to_string(), + &ticket.source_text, + &ticket.replacement_text, + ) { + Ok(()) => openless_core::SelectionVoiceApplyOutcome::Inserted, + Err(_) => openless_core::SelectionVoiceApplyOutcome::Failed, + }; + services + .selection_voice + .finish_preview_apply(ticket.ticket_id, outcome) + .await?; + if !outcome.may_have_applied() { + return Err(BackendError::new( + openless_core::BackendErrorCode::Platform, + "selectionVoiceInsertFailed", + )); + } + // Match the Tauri command: only dismiss this exact QA turn after + // Core has recorded a successful native apply receipt. A failed + // fcitx5 target check must leave the preview available to retry. + if let Err(error) = services.qa.dismiss_session(qa_session).await { + if error.code != openless_core::BackendErrorCode::Cancelled { + return Err(error); + } + } + Ok(tr_l10n(lang, "selection.replaced").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); + // 先确认有可送的润色负载,再拉起弹窗。旧实现先 `ensure_popup` 再 + // early-return,于是「没有选区 / 没有会话」时会凭空弹出一个**空的选区 + // 助手面板**(用户报的「alt+X 弹出错误弹窗」),还会留下 + // `polish_result_visible` 这个脏标志。 let Some(selection) = self.selection.clone() else { + log::warn!( + "[hotkey] selection polish finished without a captured selection; not showing the panel" + ); + self.polish_result_visible = false; return; }; let Some(session_id) = selection.session_id else { + log::warn!( + "[hotkey] selection polish finished without a session id; not showing the panel" + ); + self.polish_result_visible = false; return; }; + self.ensure_popup(PopupKind::Qa); self.send_popup( - PopupKind::Preview, - HostToPopup::Preview { + PopupKind::Qa, + HostToPopup::PolishPreview { version: POPUP_PROTOCOL_VERSION, session_id: session_id.to_string(), sequence: self.last_event_sequence.saturating_mul(2), @@ -1051,8 +1256,28 @@ mod linux_app { ); } + /// 胶囊是否允许显示(Tauri `showCapsule`;隐藏时提示音仍会响)。 + fn capsule_enabled(&self) -> bool { + self.preferences + .as_ref() + .map(|prefs| prefs.show_capsule) + .unwrap_or(true) + } + + /// 胶囊样式标签:协议里传字符串,弹窗进程不需要 Core 的类型。 + fn capsule_style_tag(&self) -> String { + match self.preferences.as_ref().map(|prefs| prefs.capsule_style) { + Some(openless_core::shared_types::CapsuleStyle::Classic) => "classic", + Some(openless_core::shared_types::CapsuleStyle::Typeless) => "typeless", + _ => "siri", + } + .to_string() + } + fn show_capsule_popup(&mut self) { - openless_linux_egui::desktop_bridge::place_popup("OpenLess Capsule", 340, 112, true); + if !self.capsule_enabled() { + return; + } self.ensure_popup(PopupKind::Capsule); let Some(snapshot) = self .snapshot @@ -1064,6 +1289,18 @@ mod linux_app { let Some(session_id) = snapshot.session_id else { return; }; + // 终态文案:Core 在失败时只给错误码名(`InvalidArgument`),成功时可能给 + // 内部状态词(`inserted`),都不能直接显示;分类规则在 dictation_feedback。 + let lang = self.lang; + let text = match capsule_outcome(snapshot.phase, snapshot.message.as_deref()) { + CapsuleOutcome::Inserted => { + frontend::popups::inserted_message(lang, self.transcript.chars().count()) + } + CapsuleOutcome::Cancelled => tr_l10n(lang, "capsule.cancelled").to_string(), + CapsuleOutcome::Failed => tr_l10n(lang, "capsule.error").to_string(), + CapsuleOutcome::Progress(text) => text, + }; + let style = self.capsule_style_tag(); self.send_popup( PopupKind::Capsule, HostToPopup::Capsule { @@ -1071,16 +1308,84 @@ mod linux_app { session_id: session_id.to_string(), sequence: self.last_event_sequence.saturating_mul(2), phase: format!("{:?}", snapshot.phase), - text: snapshot.message.unwrap_or_default(), + text, audio_level: Some(snapshot.level), + translation_active: snapshot.translation_active, + style, + }, + ); + self.schedule_capsule_dismissal(&session_id.to_string(), snapshot.phase); + } + + /// 选区润色(alt+X)发现「没有选中文字」时,按产品行为弹**语音输入窗** + /// (胶囊)报错后退出:文案用 Tauri 的 `capsule.selectionPolish.noSelection`, + /// 终态 2 秒后自动收起。以前这里什么都不做,用户只看到面板空转一圈(或 + /// 干脆没有反馈)。 + fn show_selection_polish_capsule_error(&mut self, session_id: &str) { + if !self.capsule_enabled() { + return; + } + let lang = self.lang; + self.ensure_popup(PopupKind::Capsule); + self.capsule_session = Some(session_id.to_string()); + 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: "failed".to_string(), + text: tr_l10n(lang, "capsule.selectionPolish.noSelection").to_string(), + audio_level: None, + translation_active: false, + style: self.capsule_style_tag(), }, ); + self.schedule_capsule_dismissal(session_id, DictationPhase::Failed); + } + + /// 终态后按 Tauri Host 的时序自动收起胶囊:成功/失败停留 2 秒、 + /// 取消立刻;进行中的相位不收。 + fn schedule_capsule_dismissal(&mut self, session_id: &str, phase: DictationPhase) { + // 诊断链路用(低噪声:一次听写一条):这条日志缺失 = 终态事件没到宿主。 + let Some(delay) = capsule_hide_delay(phase) else { + log::debug!("capsule: no dismissal for session {session_id} in {phase:?}"); + // 会话又回到进行中相位:旧计时作废。 + self.capsule_dismissal_scheduled = None; + return; + }; + self.capsule_dismissal_scheduled = Some(session_id.to_string()); + log::info!( + "capsule: dismissal scheduled in {}ms for session {session_id} ({phase:?})", + delay.as_millis() + ); + let session_id = session_id.to_string(); + let tx = self.tx.clone(); + self.tokio.spawn(async move { + tokio::time::sleep(delay).await; + let _ = tx.send(UiResult::CapsuleDismissDue { session_id }); + }); + } + + /// 收起胶囊。三条窗口路径里只有 eframe 的两条支持「隐藏但保留进程」, + /// layer surface 没有隐藏语义(只能销毁表面),所以统一结束弹窗进程: + /// 下一次录音会在按热键那一刻按需重新拉起,用户看不到延迟。 + fn dismiss_capsule(&mut self) { + let had_process = self.popup_slot(PopupKind::Capsule).is_some(); + if let Some(supervisor) = self.popup_slot(PopupKind::Capsule).as_ref() { + let _ = supervisor.request_shutdown(); + } + *self.popup_slot(PopupKind::Capsule) = None; + self.capsule_session = None; + self.capsule_dismissal_scheduled = None; + // 这条日志缺失 = 收起决定没走到「结束弹窗进程」这一环。 + log::info!("capsule: dismissal applied (popup process was running: {had_process})"); } fn poll_popup_supervisors(&mut self) { let lang = self.lang; let mut events = Vec::new(); - for kind in [PopupKind::Qa, PopupKind::Preview, PopupKind::Capsule] { + for kind in [PopupKind::Qa, PopupKind::Capsule] { if let Some(supervisor) = self.popup_slot(kind) { while let Ok(event) = supervisor.try_recv() { events.push((kind, event)); @@ -1143,6 +1448,11 @@ mod linux_app { .and_then(|state| state.session_id.as_deref()) == Some(session_id.as_str()) => { + // 面板 ✕:立刻清掉宿主侧的可见标志,不等 Core 的 HideQa 回环 + // (那条被图钉门禁拦着,pinned 时不清 → 标志残留会让录音热键 + // 之后又把面板弹出来)。 + self.qa_visible = false; + self.polish_result_visible = false; if let Some(backend) = self.backend() { self.spawn(async move { backend.services().qa.dismiss().await?; @@ -1150,13 +1460,87 @@ mod linux_app { }); } } - PopupSupervisorEvent::Message(PopupToHost::ConfirmPreview { + PopupSupervisorEvent::Message(PopupToHost::SetPinned { session_id, - text, + pinned, .. - }) => match session_id.parse::() { - Ok(session_id) => { - let session_id = openless_core::SessionId::from_uuid(session_id); + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + self.qa_pinned = pinned; + self.show_qa_popup(); + } + PopupSupervisorEvent::Message(PopupToHost::SetEditInstructionMode { + session_id, + enabled, + .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + self.spawn(async move { + backend + .services() + .qa + .set_edit_instruction_mode(enabled) + .await?; + Ok(String::new()) + }); + } + } + PopupSupervisorEvent::Message(PopupToHost::RevertEdit { + session_id, .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Ok(qa_session) = session_id.parse::() { + let qa_session = openless_core::SessionId::from_uuid(qa_session); + if let Some(backend) = self.backend() { + let lang = self.lang; + self.spawn(async move { + backend + .services() + .qa + .revert_edit_preview(qa_session) + .await?; + Ok(tr_l10n(lang, "selection.reverted").to_string()) + }); + } + } + } + PopupSupervisorEvent::Message(PopupToHost::ApplyEdit { + session_id, .. + }) if self + .qa_state + .as_ref() + .and_then(|state| state.session_id.as_deref()) + == Some(session_id.as_str()) => + { + if let Ok(qa_session) = session_id.parse::() { + let qa_session = openless_core::SessionId::from_uuid(qa_session); + if let Some(backend) = self.backend() { + self.spawn_qa_edit_apply(backend, qa_session); + } + } + } + PopupSupervisorEvent::Message(PopupToHost::ConfirmPolish { + session_id, + text, + .. + }) => match session_id.parse::() { + Ok(session_id) => { + // 润色结束:选区助手面板回到提问模式(同一个弹窗)。 + self.polish_result_visible = false; + let session_id = openless_core::SessionId::from_uuid(session_id); if let Some(backend) = self.backend() { self.spawn(async move { backend @@ -1172,11 +1556,12 @@ mod linux_app { self.status = fmt_l10n(lang, "popup.session_invalid", &[&error]) } }, - PopupSupervisorEvent::Message(PopupToHost::CancelPreview { - session_id, - .. + PopupSupervisorEvent::Message(PopupToHost::CancelPolish { + session_id, .. }) => match session_id.parse::() { Ok(session_id) => { + // 取消润色:同样退出润色模式。 + self.polish_result_visible = false; let session_id = openless_core::SessionId::from_uuid(session_id); if let Some(backend) = self.backend() { self.spawn(async move { @@ -1194,19 +1579,126 @@ mod linux_app { } }, PopupSupervisorEvent::Message(PopupToHost::Ready { .. }) => match kind { - PopupKind::Qa => self.show_qa_popup(), - PopupKind::Preview => self.show_selection_popup(), + PopupKind::Qa => { + // 面板自己也要匹配本地热键(面板有焦点时插件收不到按键), + // 所以先下发绑定,再送内容。 + self.send_popup_hotkeys(kind); + // 选区助手面板既可能是提问模式,也可能是润色结果模式。 + if self.polish_result_visible { + self.show_selection_popup(); + } else { + self.show_qa_popup(); + } + } PopupKind::Capsule => self.show_capsule_popup(), + PopupKind::LessComputer => { + self.send_popup_hotkeys(kind); + self.show_less_computer_popup(); + } }, + PopupSupervisorEvent::Message(PopupToHost::Hotkey { edge, .. }) => { + log::info!("[hotkey] local edge from the {kind:?} panel: {edge:?}"); + self.pending_local_hotkeys + .push((std::time::Instant::now(), edge)); + } PopupSupervisorEvent::Message(PopupToHost::DismissCapsule { .. }) => { if let Some(snapshot) = self.snapshot.as_mut() { snapshot.dictation.message = None; } } + PopupSupervisorEvent::Message(PopupToHost::CancelDictation { .. }) => { + // 胶囊 ✕:放弃这次听写。 + let session = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id); + if let (Some(backend), Some(session)) = (self.backend(), session) { + self.spawn(async move { + // 连点两次 ✕、会话已收尾之类的错误是预期内的, + // 归一掉,不要再弹成失败。 + normalize_stop_result( + backend.cancel_dictation(Some(session)).await, + )?; + Ok(String::new()) + }); + } + } + PopupSupervisorEvent::Message(PopupToHost::StopDictation { .. }) => { + // 胶囊 ✓:结束录音并落字。 + let session = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id); + if let (Some(backend), Some(session)) = (self.backend(), session) { + self.spawn(async move { + // 没说话(空音频 → InvalidArgument)也是预期内的终态: + // 胶囊会显示本地化文案并自动收起,这里不再报错误。 + normalize_stop_result( + backend.stop_dictation_session(session).await, + )?; + Ok(String::new()) + }); + } + } + PopupSupervisorEvent::Message(PopupToHost::SubmitLessComputer { + session_id, + text, + .. + }) if self + .less_computer_session + .map(|session| session.to_string()) + .as_deref() + == Some(session_id.as_str()) => + { + if let Some(backend) = self.backend() { + // Core 自己解析 provider / 模型 / 权限 / workdir; + // 宿主只负责把用户文本交给它(Tauri `lessComputerSubmitText`)。 + self.spawn(async move { + backend.submit_less_computer(text).await?; + Ok(String::new()) + }); + } + } + PopupSupervisorEvent::Message(PopupToHost::ApproveLessComputer { + token, + approved, + .. + }) => { + let backend = self.backend(); + self.spawn(async move { + if let Some(backend) = backend { + backend + .services() + .less_computer + .approve(token, approved) + .await?; + } + Ok(String::new()) + }); + } + PopupSupervisorEvent::Message(PopupToHost::CancelLessComputer { .. }) => { + let session = self.less_computer_session; + let backend = self.backend(); + self.spawn(async move { + if let Some(backend) = backend { + backend.cancel_less_computer(session).await?; + } + Ok(String::new()) + }); + } + PopupSupervisorEvent::Message(PopupToHost::DismissLessComputer { .. }) => { + // 只收起面板:已完成的一轮保留在宿主状态里,下次打开仍在。 + self.hide_less_computer_popup(); + } PopupSupervisorEvent::Message( PopupToHost::SubmitQa { .. } | PopupToHost::ToggleQaRecording { .. } - | PopupToHost::DismissQa { .. }, + | PopupToHost::DismissQa { .. } + | PopupToHost::SetPinned { .. } + | PopupToHost::SetEditInstructionMode { .. } + | PopupToHost::ApplyEdit { .. } + | PopupToHost::RevertEdit { .. } + | PopupToHost::SubmitLessComputer { .. }, ) => { self.status = tr_l10n(lang, "popup.ignore_late_qa").to_string(); } @@ -1222,12 +1714,26 @@ mod linux_app { self.status = fmt_l10n(lang, "popup.exited", &[&format!("{code:?}")]); } *self.popup_slot(kind) = None; + if !crashed { + // 面板进程正常退出(用户/合成器关掉窗口等):宿主这边的 + // 可见标志必须跟着清,否则「录音热键」会被错当成 + // 「向选区助手提问」,把面板又弹出来。 + self.forget_qa_panel_visibility(kind); + } if crashed { + // 必崩的面板不做无限重开:预算内重开,超了就停手(否则 + // 用户看到的是“弹窗一直反复弹出”)。 + let restarts = &mut self.popup_restarts[popup_kind_index(kind)]; + if !restarts.allow(std::time::Instant::now()) { + log::warn!( + "[popup] {kind:?} crashed {POPUP_RESTART_LIMIT} times within {}s; not restarting", + POPUP_RESTART_WINDOW.as_secs() + ); + self.forget_qa_panel_visibility(kind); + return; + } 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 @@ -1254,6 +1760,21 @@ mod linux_app { }); } + /// `spawn` variant for cancellable flows: an empty message means + /// "nothing happened", so no notice is posted. + fn spawn_reporting(&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()); + if !message.is_empty() { + let _ = tx.send(UiResult::Message(message)); + } + }); + } + fn load_remote_status(&self) { let Some(backend) = self.backend() else { return; @@ -1328,6 +1849,205 @@ mod linux_app { }); } + /// Refresh the required-service dots on the AI-services tabs. + fn load_service_configured(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let mut configured = [false; 2]; + for (index, kind) in [ + (0usize, openless_core::ChannelKind::Llm), + (1usize, openless_core::ChannelKind::Asr), + ] { + if let Ok(channels) = backend.list_channels(kind).await { + configured[index] = channels.iter().any(|channel| channel.enabled); + } + } + let _ = tx.send(UiResult::ServiceConfigured(configured)); + }); + } + + /// Load the credential channels for the settings modal's AI-services tab. + fn load_settings_channels(&mut self) { + let Some(backend) = self.backend() else { + return; + }; + let kind = self.settings_channel_kind; + self.settings_channels_loading = true; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + let channels = backend.list_channels(kind).await?; + let account = model_account(kind).to_string(); + let mut rows = Vec::with_capacity(channels.len()); + for channel in channels { + let model = read_provider_value(&backend, kind, &channel.id, &account) + .await? + .unwrap_or_default(); + rows.push(SettingsChannelRow { + id: channel.id.clone(), + name: channel.name.clone(), + provider_type: channel.provider_type.clone(), + model, + enabled: channel.enabled, + last_ok: channel.last_test.as_ref().map(|test| test.ok), + last_latency_ms: channel + .last_test + .as_ref() + .and_then(|test| test.latency_ms), + last_error: channel + .last_test + .as_ref() + .and_then(|test| test.error.clone()), + }); + } + Ok::<_, BackendError>(rows) + } + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::SettingsChannels(result)); + }); + } + + fn load_marketplace(&mut self) { + let Some(backend) = self.backend() else { + return; + }; + self.marketplace_attempted = false; + // An explicit fetch supersedes a queued debounced search. + self.marketplace_search_deadline = None; + self.marketplace_seq = self.marketplace_seq.wrapping_add(1); + let seq = self.marketplace_seq; + // Likes only power the 「我赞过的」 filter; mirror the Tauri page by + // fetching them once, then reusing the cached set for every search. + if !self.marketplace_likes_loaded { + self.marketplace_likes_loaded = true; + self.load_marketplace_likes(); + } + let query = self.marketplace_query.trim().to_string(); + // The backend only ranks by popular/new; 「我赞过的」 is a filter over + // the signed-in user's like list, exactly like the Tauri page. + let sort = match self.frontend_vm.marketplace_sort { + frontend::view_model::MarketplaceSort::Popular + | frontend::view_model::MarketplaceSort::Liked => "popular", + frontend::view_model::MarketplaceSort::New => "new", + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .list(openless_core::MarketplaceQuery { + query: (!query.is_empty()).then_some(query), + sort: Some(sort.to_string()), + limit: Some(50), + }) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::Marketplace(seq, result)); + }); + } + + /// Fetch the signed-in user's like ids. Kept separate from + /// [`Self::load_marketplace`] so searching never re-requests them. + fn load_marketplace_likes(&mut self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let likes = backend + .services() + .marketplace + .my_likes() + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceLikes(likes)); + }); + } + + /// Same 300ms pause the Tauri marketplace page applies to its search box. + const MARKETPLACE_SEARCH_DEBOUNCE: Duration = Duration::from_millis(300); + + fn schedule_marketplace_search(&mut self) { + self.marketplace_search_deadline = + Some(std::time::Instant::now() + Self::MARKETPLACE_SEARCH_DEBOUNCE); + } + + /// Runs from the host tick: fires the debounced search exactly once. + fn poll_marketplace_search(&mut self) { + let Some(deadline) = self.marketplace_search_deadline else { + return; + }; + if std::time::Instant::now() < deadline { + return; + } + self.load_marketplace(); + } + + fn load_marketplace_auth(&self) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .auth_status() + .await + .map(|status| status.signed_in) + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceAuth(result)); + }); + } + + fn start_marketplace_oauth(&mut self) { + let Some(backend) = self.backend() else { + return; + }; + self.frontend_vm.marketplace_oauth_loading = true; + self.frontend_vm.marketplace_oauth_error = None; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .start_device_flow() + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceOAuthStarted(result)); + }); + } + + fn poll_marketplace_oauth(&mut self) { + let Some(deadline) = self.marketplace_oauth_poll_deadline else { + return; + }; + if std::time::Instant::now() < deadline { + return; + } + self.marketplace_oauth_poll_deadline = None; + let Some(flow_id) = self.frontend_vm.marketplace_oauth_flow_id.clone() else { + return; + }; + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .poll_device_flow(flow_id.clone()) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceOAuthPoll { flow_id, result }); + }); + } + fn load_marketplace_mine(&self) { let Some(backend) = self.backend() else { return; @@ -1335,6 +2055,14 @@ mod linux_app { let tx = self.tx.clone(); self.tokio.spawn(async move { let result = async { + let auth = backend.services().marketplace.auth_status().await?; + if !auth.signed_in { + // Not being logged in is a normal empty state for this + // modal, not a page-level error/toast. + let _ = tx.send(UiResult::MarketplaceAuth(Ok(false))); + return Ok::<_, BackendError>((Vec::new(), Vec::new())); + } + let _ = tx.send(UiResult::MarketplaceAuth(Ok(true))); let packs = backend.services().marketplace.my_packs().await?; let likes = backend.services().marketplace.my_likes().await?; Ok::<_, BackendError>((packs, likes)) @@ -1368,25 +2096,11 @@ mod linux_app { 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 @@ -1398,71 +2112,17 @@ mod linux_app { })??; Ok::<_, BackendError>(OverviewData { credentials, - history, + history: Vec::new(), 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)); + let _ = tx.send(UiResult::Overview(result)); }); } - fn drain_tray(&mut self, ctx: &egui::Context) { + fn drain_tray(&mut self, _ctx: &egui::Context) { let lang = self.lang; let mut commands = Vec::new(); if let Some(tray) = &self.tray { @@ -1475,8 +2135,8 @@ mod linux_app { 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); + // 托盘是用户的显式动作:拉起窗口进程(已有窗口时由它自己抬起)。 + self.request_main_window(); } openless_linux_egui::TrayCommand::ActivatePreviousStyle => { if let Some(backend) = self.backend() { @@ -1492,61 +2152,73 @@ mod linux_app { } } 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])) - }); + if let Some(preferences) = &mut self.preferences { + preferences.microphone_device_name = name; + self.save_settings_if_dirty(); } } openless_linux_egui::TrayCommand::Quit => { + // 宿主退出前会给 UI 发 Shutdown(见 `run_host` 收尾)。 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 { + /// Payload Core needs for the current draft. Returns `None` while no + /// channel editor is loaded, so a stale frame cannot rename or re-\n /// credential the wrong channel. + fn editor_from_form(&self) -> Option { + let form = self.provider_editor_form.as_ref()?; + let ProviderEditorState::Loaded(loaded) = &self.provider_editor else { + return None; + }; + if loaded.channel.id != form.channel_id { + return None; + } + let mut editor = (**loaded).clone(); + editor.name = form.name.clone(); + editor.endpoint = form.endpoint.clone(); + editor.model = form.model.clone(); + editor.resource_id = form.resource_id.clone(); + editor.auth_mode = form.auth_mode.clone(); + editor.primary_secret = form.primary_secret.clone(); + editor.secondary_secret = form.secondary_secret.clone(); + Some(editor) + } + + /// Open a channel's provider editor. The descriptor comes from Core's + /// provider rules, so the UI never invents a field shape; without one + /// the editor stays closed instead of guessing. + fn open_provider_editor(&mut self, index: usize) { + let Some(channel_id) = self + .settings_channels + .get(index) + .map(|channel| channel.id.clone()) + else { return; }; - let 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), - }); - }); + let panel = match &self.providers { + ProvidersState::Loaded(panel) => panel.clone(), + _ => return, + }; + let Some((channel, descriptor)) = provider_channel_descriptor(&panel, &channel_id) + else { + return; + }; + let kind = panel.kind; + self.selected_channel_id = Some(channel_id.clone()); + self.provider_editor = ProviderEditorState::Loading; + self.provider_editor_form = None; + self.load_provider_editor(kind, channel, descriptor); } - 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 close_provider_editor(&mut self) { + self.provider_editor = ProviderEditorState::Idle; + self.provider_editor_form = None; + self.frontend_vm.provider_editor = None; } + /// Core owns the model catalog; the host only forwards the request. fn request_provider_models(&self, kind: openless_core::ChannelKind, channel_id: String) { let Some(backend) = self.backend() else { return; @@ -1572,6 +2244,29 @@ mod linux_app { }); } + fn load_provider_editor( + &self, + kind: openless_core::ChannelKind, + channel: openless_core::ChannelSummary, + descriptor: openless_core::ProviderDescriptor, + ) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + let channel_id = channel.id.clone(); + self.tokio.spawn(async move { + let result = load_provider_editor(backend, kind, channel, descriptor) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::ProviderEditor { + kind, + channel_id, + result: Box::new(result), + }); + }); + } + /// Play the native recording start/stop cue on a worker thread, gated by /// the `audio_cue_on_record` preference. The start cue is additionally /// suppressed while `mute_during_recording` is active: playing into a @@ -1613,23 +2308,6 @@ mod linux_app { 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` @@ -1647,12 +2325,35 @@ mod linux_app { self.transcript.clear(); self.transcript_session = state.session_id; } - self.status = fmt_l10n( - lang, - "status.dictation_phase", - &[&format!("{:?}", state.phase)], - ); + // 终态不写状态栏:`Failed` / `Completed` / `Cancelled` 是 Core 的 + // 内部词,用户已经能从胶囊看到本地化文案(Tauri 也只在那里显示)。 + if capsule_hide_delay(state.phase).is_some() { + log::debug!( + "dictation terminal phase {:?} (session {:?})", + state.phase, + state.session_id + ); + } else { + self.status = fmt_l10n( + lang, + "status.dictation_phase", + &[&format!("{:?}", state.phase)], + ); + } if let Some(session_id) = state.session_id { + // 上一轮胶囊被自动收起后进程已经不在了:进行中的相位必须按需 + // 重新拉起,否则 send_popup 会因为没有 supervisor 而静默丢弃; + // 终态则不拉,免得把刚收起的药丸又喊回来。 + if phase_shows_capsule(state.phase) && self.capsule_enabled() { + self.ensure_popup(PopupKind::Capsule); + } + // 进行中的 message 也要过一遍分类:Core 偶尔把内部错误码 + // 写在这里,不能当成文案直接显示。 + let text = match capsule_outcome(state.phase, state.message.as_deref()) { + CapsuleOutcome::Progress(text) => text, + _ => String::new(), + }; + let style = self.capsule_style_tag(); self.send_popup( PopupKind::Capsule, HostToPopup::Capsule { @@ -1660,10 +2361,14 @@ mod linux_app { session_id: session_id.to_string(), sequence: event_sequence.saturating_mul(2), phase: format!("{:?}", state.phase), - text: state.message.unwrap_or_default(), + text, audio_level: Some(state.level), + translation_active: state.translation_active, + style, }, ); + // 终态:按 Tauri 时序安排自动收起,否则药丸会一直贴在屏幕上。 + self.schedule_capsule_dismissal(&session_id.to_string(), state.phase); } } BackendEventKind::TranscriptDelta(delta) @@ -1688,10 +2393,14 @@ mod linux_app { self.spawn(async move { match request.action { openless_core::RecordingControlAction::Stop => { - backend.stop_dictation_session(request.session_id).await?; + normalize_stop_result( + backend.stop_dictation_session(request.session_id).await, + )?; } openless_core::RecordingControlAction::Cancel => { - backend.cancel_dictation(Some(request.session_id)).await?; + normalize_stop_result( + backend.cancel_dictation(Some(request.session_id)).await, + )?; } } Ok(tr_l10n(lang, "status.auto_stopped").to_string()) @@ -1715,6 +2424,14 @@ mod linux_app { } self.less_computer_turn_start = self.less_computer_output.len(); self.less_computer_input = text.clone(); + if *fresh { + self.less_computer_entries.clear(); + } + self.less_computer_entries + .push(openless_linux_egui::LessComputerEntry { + kind: "user".to_string(), + text: text.clone(), + }); } else if session_id != self.less_computer_session { return; } @@ -1724,23 +2441,64 @@ mod linux_app { LessComputerEventKind::User { .. } => {} LessComputerEventKind::Started => { self.status = tr_l10n(lang, "status.less_running").to_string(); + self.less_computer_working = true; } LessComputerEventKind::Delta { text } => { self.less_computer_output.push_str(&text); + append_assistant_entry(&mut self.less_computer_entries, &text); } LessComputerEventKind::Tool { name } => { self.status = fmt_l10n(lang, "status.less_tool", &[&name]); + self.less_computer_entries.push( + openless_linux_egui::LessComputerEntry { + kind: "tool".to_string(), + // 行内标记的文案在宿主侧本地化:面板只画文本。 + text: fmt_l10n(lang, "less_computer.tool", &[&name]), + }, + ); } LessComputerEventKind::Compaction => { self.status = tr_l10n(lang, "status.less_compacted").to_string(); + self.less_computer_entries.push( + openless_linux_egui::LessComputerEntry { + kind: "compaction".to_string(), + text: tr_l10n(lang, "less_computer.compaction").to_string(), + }, + ); } - LessComputerEventKind::Completed { text, .. } => { + LessComputerEventKind::Completed { text, cost_usd } => { // A terminal is authoritative even for final-only // providers or after a missed partial event. self.less_computer_output .truncate(self.less_computer_turn_start); self.less_computer_output.push_str(&text); self.pending_approval = None; + self.less_computer_working = false; + // 终局正文替换掉流式累积的那条助手条目。 + match self + .less_computer_entries + .iter_mut() + .rev() + .find(|entry| entry.kind == "assistant") + { + Some(entry) => entry.text = text.clone(), + None => self.less_computer_entries.push( + openless_linux_egui::LessComputerEntry { + kind: "assistant".to_string(), + text: text.clone(), + }, + ), + } + if let Some(cost) = cost_usd { + let cost_text = + fmt_l10n(lang, "less_computer.cost", &[&format!("{cost:.3}")]); + self.less_computer_entries.push( + openless_linux_egui::LessComputerEntry { + kind: "note".to_string(), + text: cost_text, + }, + ); + } self.status = tr_l10n(lang, "less_computer.done").to_string(); } LessComputerEventKind::Approval { token, command, .. } => { @@ -1749,32 +2507,44 @@ mod linux_app { } LessComputerEventKind::Error { message } => { self.pending_approval = None; + self.less_computer_working = false; + self.less_computer_entries.push( + openless_linux_egui::LessComputerEntry { + kind: "error".to_string(), + text: message.clone(), + }, + ); self.status = message; } LessComputerEventKind::Cancelled => { self.pending_approval = None; + self.less_computer_working = false; self.status = tr_l10n(lang, "less_computer.cancelled").to_string(); } } } + BackendEventKind::HistoryChanged(change) => { + self.history.observe(change.revision); + self.load_overview(); + } BackendEventKind::PreferencesChanged(_) => { + // 外部改动(Core 事件 / 托盘 / 另一窗口)要重新灌一次文本行。 + self.hydrate_text_fields = true; if let Some(backend) = self.backend() { let latest = backend.get_preferences(); - self.preferences = Some(match self.preferences.as_ref() { - Some(draft) if self.settings_dirty.any() => { - self.settings_dirty.merge(&latest, draft) - } - _ => latest, - }); + if self.history.policy_changed(&latest) { + self.history.request(backend.snapshot().history_revision); + } + self.preferences = Some(self.settings_save.rebase(&latest)); } self.load_remote_status(); self.load_library(); } - BackendEventKind::HistoryChanged(_) => self.load_overview(), BackendEventKind::VocabularyChanged(_) | BackendEventKind::StylePacksChanged(_) => { self.load_library() } BackendEventKind::QaState(state) => { + self.qa_edit.merge(&state); if state.kind == QaStateKind::AnswerDelta { if let Some(current) = self .qa_state @@ -1826,26 +2596,61 @@ mod linux_app { selection_preview: state.selection_preview, streaming_answer: state.chunk.unwrap_or_default(), error: state.error, + edit_instruction_mode: self.qa_edit.instruction_mode, + edit_apply_available: self.qa_edit.apply_available, + edit_revert_available: self.qa_edit.revert_available, + pinned: self.qa_pinned, + viewer_login: self.marketplace_login(), }, ); } } BackendEventKind::SelectionStateChanged(snapshot) => { - if snapshot.phase == SelectionPhase::Preview { - self.selection_draft = snapshot.preview_text.clone().unwrap_or_default(); - self.selection_preview_visible = true; + let preview_text = snapshot.preview_text.clone().unwrap_or_default(); + let source_text = snapshot.source_text.clone().unwrap_or_default(); + // 只有「拿到选区」并且「润色结果非空」才把面板弹出来。以前这里只看 + // `phase`:起手 `Capturing` 就 `ensure_popup` + 送一帧空结果,于是按 + // 一下 alt+X 先冒出一个**空的**润色结果面板(用户报的「弹窗不对」), + // 没有选区时更糟 —— 面板空转一圈再消失。 + let has_selection = !source_text.trim().is_empty(); + let has_preview = snapshot.phase == SelectionPhase::Preview + && !preview_text.trim().is_empty(); + let show_preview = has_preview && has_selection; + if show_preview { + self.selection_draft = preview_text.clone(); + self.polish_result_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(), - }, - ); + if show_preview { + // 选区助手面板:润色结果以「润色结果」帧送进去。 + self.ensure_popup(PopupKind::Qa); + self.send_popup( + PopupKind::Qa, + HostToPopup::PolishPreview { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.to_string(), + sequence: event_sequence.saturating_mul(2), + text: preview_text, + source: source_text, + }, + ); + } else if !has_selection + && matches!( + snapshot.phase, + SelectionPhase::Preview | SelectionPhase::Failed + ) + { + // 没选中文字:弹语音输入窗报错后退出,不弹面板。 + log::warn!( + "[hotkey] selection polish had no captured selection: showing the capsule error" + ); + self.show_selection_polish_capsule_error(&session_id.to_string()); + } else { + log::debug!( + "[hotkey] selection snapshot without a usable preview ({:?}): keeping the panel closed", + snapshot.phase + ); + } } self.selection = Some(snapshot); } @@ -1855,19 +2660,447 @@ mod linux_app { } } - fn poll(&mut self, ctx: &egui::Context) { + /// `update()` 是唯一 drain 原生事件(热键、单实例拉起意图)的地方,而 + /// 最小化/隐藏的窗口会让 eframe 的定时重绘停摆 —— 那样按热键什么都不会 + /// 发生(胶囊、QA 面板都不弹)。这里必须用**真线程**:`self.tokio` 是 + /// current-thread 运行时,`spawn` 的任务只在别处 `block_on` 时才被推进, + /// 当作后台泵用就是「写完看着对、最小化后照样死」(实测心跳会在窗口 + /// 收走的那一刻停)。线程只做一件事:`request_repaint()` 把事件循环戳醒。 + /// 泵心跳:宿主循环每 10s 一条。窗口关掉之后这条心跳**不能**停 —— + /// 停了就说明热键消费与弹窗拉起也没在跑(这正是当初「关窗后热键失效」 + /// 的判据),用户/支持可以直接看日志确认。 + fn log_pump_heartbeat(&mut self, ctx: &egui::Context) { + if self.last_pump_heartbeat.elapsed() < std::time::Duration::from_secs(10) { + return; + } + self.last_pump_heartbeat = std::time::Instant::now(); + // 宿主没有窗口,所以心跳只看「窗口进程还在不在」—— + // 这正是关窗后必须继续为 true 的那一项能力。 + let _ = ctx; + log::info!( + "[pump] heartbeat window_process={} window_wanted={} tray={} recording={}", + self.window.child.is_some(), + self.window.should_be_open, + self.tray.is_some(), + self.recording_phase_active, + ); + } + + /// 当前生效的热键配置(本地匹配与去重都用它)。 + fn hotkey_target(&self) -> Option { + self.preferences + .as_ref() + .map(openless_core::HotkeyRuntimeTarget::from) + } + + /// QA 热键 = **面板显隐**。 + /// + /// Tauri `coordinator/qa.rs::handle_qa_hotkey_pressed`:面板可见 → + /// `qa.dismiss()`,否则 `qa.show()`。egui 侧原来把 QA 热键接到 Core 的 + /// `CliIntent::ToggleQa`,而那条是**切换录音**(`qa.toggle_recording()`): + /// 于是「想打开面板」变成了「开一次录音」,用户看到的正是「选区助手弹出并 + /// 开始录音、还停不下来」。面板显隐属宿主状态(`qa_visible`),Core 只按 + /// ShowQa/HideQa 指令把窗口开合。 + fn toggle_qa_panel(&mut self) { + let lang = self.lang; + if self.qa_visible { + // 显式收起不受图钉门禁限制(Tauri 的 HostAction::HideQa 同样无条件收窗), + // 图钉只管“失焦自动收起”那条路径。 + log::info!("[hotkey] QA panel toggle: dismissing"); + 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), + ); + if let Some(backend) = self.backend() { + self.spawn(async move { + backend.services().qa.dismiss().await?; + Ok(tr_l10n(lang, "qa.closed").to_string()) + }); + } + } else { + log::info!("[hotkey] QA panel toggle: showing"); + if let Some(backend) = self.backend() { + // `show()` 只发 HostAction::ShowQa:Core 仍停在 Idle,不录音。 + self.spawn(async move { + backend.services().qa.show().await?; + Ok(String::new()) + }); + } + } + } + + /// 面板录音开关(Tauri `coordinator/qa.rs::handle_qa_option_edge`)。 + fn toggle_qa_recording(&mut self) { + let lang = self.lang; + 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()) + }); + } + } + + /// 听写是否空闲(Tauri 的 QA 门禁要求 `DictationPhase::Idle`)。 + fn dictation_is_idle(&self) -> bool { + match self.snapshot.as_ref() { + Some(snapshot) => snapshot.dictation.phase == DictationPhase::Idle, + None => true, + } + } + + /// 采纳一条插件热键信号?(与窗口报上来的本地边沿去重。) + fn accept_plugin_hotkey(&mut self, event: &LinuxHotkeyEvent) -> bool { + let Some(target) = self.hotkey_target() else { + return !matches!(event, LinuxHotkeyEvent::TranslationPressed { .. }); + }; + match openless_linux_egui::plugin_event_hotkey(event, &target) { + Some(hotkey) => self + .hotkey_dedupe + .accept_signal(&hotkey, std::time::Instant::now()), + // Translation signals carry their raw key identity; a mismatched + // signal must be discarded instead of forwarded to Core. + None => !matches!(event, LinuxHotkeyEvent::TranslationPressed { .. }), + } + } + + /// Start/finish a permanent quick note, shared by the button and hotkeys. + fn toggle_quick_note(&mut self) { + if let Some(backend) = self.backend() { + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = if backend.dictation_output_target() + == Some(openless_core::DictationOutputTarget::QuickNote) + { + match backend.snapshot().dictation.session_id { + Some(id) => backend.stop_dictation_session(id).await.map(|_| ()), + None => Err(openless_core::BackendError::new( + openless_core::BackendErrorCode::InvalidState, + "quick note has no active session", + )), + } + } else { + backend + .start_dictation_with_options(openless_core::DictationStartOptions { + output_target: openless_core::DictationOutputTarget::QuickNote, + insert_text: false, + ..Default::default() + }) + .await + .map(|_| ()) + }; + if let Err(error) = result { + let _ = tx.send(UiResult::Message(error.to_string())); + } + }); + } + } + + /// 宿主自己处理掉的热键(不发往 Core)。返回 true 表示已处理。 + fn intercept_hotkey(&mut self, event: &LinuxHotkeyEvent) -> bool { + match event { + LinuxHotkeyEvent::OpenAppPressed => { + self.request_main_window(); + true + } + LinuxHotkeyEvent::QuickNotePressed => { + self.toggle_quick_note(); + true + } + LinuxHotkeyEvent::QaPressed => { + log::info!("[hotkey] selection-ask hotkey: toggling the panel"); + self.toggle_qa_panel(); + true + } + // Tauri `coordinator/dictation_core.rs::handle_pressed_edge`:面板可见 + // 且听写空闲时,听写热键**按下**先切面板录音,而不是开始一次听写。 + // 这条缺失正是「选区助手里开始录音后,按语音热键完全没反应」的成因: + // 原来一律送 Core 听写,Core 因 QA 正忙而拒绝,界面自然没反应。 + LinuxHotkeyEvent::DictationPressed { .. } + if self.qa_visible && self.dictation_is_idle() => + { + log::info!( + "[hotkey] dictation hotkey while the QA panel is visible: toggling QA recording" + ); + self.toggle_qa_recording(); + true + } + _ => false, + } + } + + /// 处理窗口/面板报上来的本地热键边沿,返回需要发往 Core 的事件。 + /// + /// 与插件信号共用同一套门禁(QA 显隐、听写热键的面板录音切换),因此两条 + /// 通路的行为逐字一致;去重保证同一个物理按键只生效一次。 + /// 面板进程不在了:把宿主的可见性标志清干净(只对选区助手有意义)。 + /// + /// 不清的后果:`qa_visible` 残留为 true 时,下一次「录音热键」会被 + /// `apply_local_hotkey_edges` 当成「向选区助手提问」,于是面板又被弹出来。 + fn forget_qa_panel_visibility(&mut self, kind: PopupKind) { + if kind != PopupKind::Qa { + return; + } + if self.qa_visible || self.polish_result_visible { + log::info!("[hotkey] QA panel is gone: clearing qa_visible/polish_result_visible"); + } + self.qa_visible = false; + self.polish_result_visible = false; + } + + fn apply_local_hotkey_edges( + &mut self, + edges: Vec<(std::time::Instant, openless_linux_egui::LocalHotkeyEdge)>, + ) -> Vec { + use openless_linux_egui::{LocalHotkey, LocalHotkeyEdgeKind}; + let Some(target) = self.hotkey_target() else { + return Vec::new(); + }; + // 标志残留(弹窗进程已经不在了)时先清干净:否则这次「录音热键」会被 + // 下面那条 qa_visible 分支当成「向选区助手提问」,把面板又弹出来。 + if self.qa_visible && self.popup_slot(PopupKind::Qa).is_none() { + log::info!("[hotkey] stale QA panel flag (no popup process): clearing"); + self.qa_visible = false; + self.polish_result_visible = false; + } + let mut events = Vec::new(); + for (at, edge) in edges { + if !self.hotkey_dedupe.accept_local(&edge.hotkey, at) { + log::debug!( + "[hotkey] local {:?} ignored: a plugin signal just handled it", + edge.hotkey + ); + continue; + } + // 单发事件(翻译/切换风格/划词润色/打开应用/风格包)只在按下或 + // 一次完整单击时触发;松开不再重复发一次。 + let single_shot = matches!( + edge.kind, + LocalHotkeyEdgeKind::Pressed | LocalHotkeyEdgeKind::Combined + ); + match &edge.hotkey { + LocalHotkey::QuickNote => { + if single_shot { + self.toggle_quick_note(); + } + } + LocalHotkey::Qa => { + log::info!("[hotkey] local selection-ask hotkey: toggling the panel"); + self.toggle_qa_panel(); + } + LocalHotkey::Dictation + if edge.kind == LocalHotkeyEdgeKind::Pressed + && self.qa_visible + && self.qa_state.is_some() + && self.dictation_is_idle() => + { + log::info!( + "[hotkey] local dictation hotkey while the QA panel is visible: toggling QA recording" + ); + self.toggle_qa_recording(); + } + LocalHotkey::Dictation => events.push(match edge.kind { + LocalHotkeyEdgeKind::Pressed => LinuxHotkeyEvent::DictationPressed { + symbol: 0, + states: 0, + press_id: edge.press_id, + at, + }, + LocalHotkeyEdgeKind::Released => LinuxHotkeyEvent::DictationReleased { + symbol: 0, + states: 0, + press_id: edge.press_id, + at, + }, + LocalHotkeyEdgeKind::Combined => LinuxHotkeyEvent::DictationCombined { + symbol: 0, + states: 0, + press_id: edge.press_id, + at, + }, + LocalHotkeyEdgeKind::Cancelled => LinuxHotkeyEvent::DictationCombined { + symbol: 0, + states: 0, + press_id: edge.press_id, + at, + }, + }), + LocalHotkey::LessComputer => { + if !single_shot { + continue; + } + events.push(match edge.kind { + LocalHotkeyEdgeKind::Pressed => LinuxHotkeyEvent::LessComputerPressed { + symbol: 0, + states: 0, + press_id: edge.press_id, + at, + }, + LocalHotkeyEdgeKind::Released => continue, + LocalHotkeyEdgeKind::Combined => { + LinuxHotkeyEvent::LessComputerCombined { + symbol: 0, + states: 0, + press_id: edge.press_id, + at, + } + } + LocalHotkeyEdgeKind::Cancelled => { + LinuxHotkeyEvent::LessComputerCombined { + symbol: 0, + states: 0, + press_id: edge.press_id, + at, + } + } + }); + } + LocalHotkey::Translation => { + if single_shot { + if let Some(event) = + openless_linux_egui::translation_hotkey_event(&target) + { + events.push(event); + } + } + } + LocalHotkey::SwitchStyle => { + if single_shot { + events.push(LinuxHotkeyEvent::SwitchStylePressed); + } + } + LocalHotkey::SelectionPolish => { + if single_shot { + events.push(LinuxHotkeyEvent::SelectionPolishPressed); + } + } + LocalHotkey::OpenApp => { + if single_shot { + self.request_main_window(); + } + } + LocalHotkey::StylePack(pack_id) => { + if !single_shot { + continue; + } + // Core 按 (keysym, states) 认包,所以带上与注册插件同源的换算。 + if let Some((symbol, states)) = + openless_linux_egui::style_pack_raw(&target, pack_id) + { + events.push(LinuxHotkeyEvent::StylePackPressed { symbol, states }); + } + } + } + } + events + } + + /// 把热键配置发给一个面板(面板有焦点时也要自己匹配本地热键)。 + fn send_popup_hotkeys(&mut self, kind: PopupKind) { + let Some(bindings) = self.hotkey_target() else { + return; + }; + let session_id = match kind { + PopupKind::Qa => self + .qa_state + .as_ref() + .and_then(|state| state.session_id.clone()) + .unwrap_or_else(|| "qa".to_string()), + PopupKind::LessComputer => self + .less_computer_session + .map(|session| session.to_string()) + .unwrap_or_else(|| "less-computer".to_string()), + // 胶囊不接受键盘焦点,本地匹配对它没有意义。 + PopupKind::Capsule => return, + }; + self.send_popup( + kind, + HostToPopup::Hotkeys { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence: self.last_event_sequence.saturating_mul(2).saturating_add(1), + bindings: Box::new(bindings), + }, + ); + } + + /// 配置变化后把热键下发给窗口与面板(窗口进程重启由 Hello 强制重发)。 + fn sync_hotkey_bindings(&mut self, bridge: &mut UiBridgeHost) { + let Some(bindings) = self.hotkey_target() else { + return; + }; + if self.hotkeys_sent.as_ref() == Some(&bindings) { + return; + } + if bridge.is_connected() { + bridge.send(HostToWindow::Hotkeys { + version: UI_BRIDGE_VERSION, + bindings: Box::new(bindings.clone()), + }); + } + self.send_popup_hotkeys(PopupKind::Qa); + self.send_popup_hotkeys(PopupKind::LessComputer); + self.hotkeys_sent = Some(bindings); + } + + // 宿主没有窗口:ctx 只为保持调用形状(事件泵不再依赖任何视口状态)。 + fn poll(&mut self, _ctx: &egui::Context) { let lang = self.lang; + // 用户再次启动应用(桌面图标 / 命令行 / 单实例转发)是**显式**的 + // 打开窗口意图;Core 的 HostAction::ShowMain 不能拿来当这个用 + // (弹窗流程里也会发,会让弹一次面板冒出一个主窗口)。 + let mut launch_intent_window_requested = false; if let Some(native) = &self.native { let (launch_intents, hotkey_events, errors) = native.drain_native_events(); let host = native.host_arc(); + // 原生动作先收下来:`native` 的借用到此为止,后面的 `&mut self` + // 调用(本地热键处理)才不会和它冲突。 + let mut actions = Vec::new(); + native.host_actions().drain(|action| actions.push(action)); for intent in launch_intents { + // CLI 的 ToggleQa 与「按一次 QA 热键」等价(Tauri + // `dispatch_cli_intent` 把 ToggleQa 直接转给 `handle_qa_hotkey_pressed`), + // 所以它既不拉起主窗口,也不走 Core 的 ToggleQa(那条是“切换录音”)。 + if matches!( + intent, + LinuxLaunchIntent::Cli(openless_core::CliIntent::ToggleQa) + ) { + log::info!("[ui-host] CLI intent toggles the QA panel"); + self.toggle_qa_panel(); + continue; + } + log::info!("[ui-host] launch intent from the user: {intent:?}"); + launch_intent_window_requested = true; let host = Arc::clone(&host); self.spawn(async move { host.dispatch_launch_intent(intent).await?; Ok(tr_l10n(lang, "status.launch_handled").to_string()) }); } + // 本地边沿先于插件信号处理:它们来自我们自己的窗口(有焦点时 + // 插件一个信号都不会发),两者共用同一张去重表。 + let local_edges = std::mem::take(&mut self.pending_local_hotkeys); + for event in self.apply_local_hotkey_edges(local_edges) { + let host = Arc::clone(&host); + self.spawn(async move { + host.dispatch_hotkey_event(event).await?; + Ok(tr_l10n(lang, "status.hotkey_handled").to_string()) + }); + } for event in hotkey_events { + if !self.accept_plugin_hotkey(&event) { + log::debug!("[hotkey] plugin event ignored: a local edge just handled it"); + continue; + } + if self.intercept_hotkey(&event) { + continue; + } let host = Arc::clone(&host); self.spawn(async move { host.dispatch_hotkey_event(event).await?; @@ -1878,19 +3111,26 @@ mod linux_app { 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)); + // Core 的 ShowMain 是「把主窗口推到前面」的提示(弹窗流程里也会发), + // 不是用户动作:宿主不因此拉起窗口进程,否则弹一次面板就可能 + // 冒出一个主窗口。真正的用户动作是托盘「显示主窗口」。 + } + HostAction::ShowLessComputer => { + // Core 在每次 Less Computer 轮次开始前发这个动作(Tauri 里 + // 它显示 `less-computer` 窗口)。宿主是序列所有者,这里 + // 拉起/刷新面板即可。 + self.show_less_computer_popup(); } HostAction::FocusMain => { - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + // 宿主没有窗口可聚焦;已有窗口的聚焦由 UI 进程自己处理。 + // 这里刻意什么都不做:把它当成「用户想打开主窗口」会让 + // 弹窗一出现就冒出主窗口。 } HostAction::Notify(message) => { self.status = message.clone(); @@ -1916,28 +3156,39 @@ mod linux_app { self.status = tr_l10n(lang, "status.request_restart").to_string(); } HostAction::ShowSelectionPreview => { - self.selection_preview_visible = true; + // 核心照旧发这个动作;现在它只负责把**选区助手面板** + // 拉到「润色结果」模式(独立预览窗口已下线)。 + self.polish_result_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), - ); + // 核心在润色流程收尾(直接覆盖 / 会话结束)时照旧发这个 + // 动作。面板此刻若在提问对话中,不能被它关掉——只撤掉 + // 润色结果模式。 + if self.polish_result_visible { + self.polish_result_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::Qa, + session_id, + self.last_event_sequence.saturating_mul(2).saturating_add(1), + ); + } } HostAction::ShowQa => { + log::info!("[hotkey] QA panel show requested by the host action"); self.qa_visible = true; self.show_qa_popup(); } HostAction::HideQa => { + if !qa_hides_on_host_action(self.qa_pinned) { + continue; + } self.qa_visible = false; let session_id = self .qa_state @@ -1967,6 +3218,9 @@ mod linux_app { } } } + if launch_intent_window_requested { + self.request_main_window(); + } self.poll_popup_supervisors(); let mut events = Vec::new(); let drain = self @@ -1984,6 +3238,7 @@ mod linux_app { let replay = backend.replay_events_after(self.last_event_sequence); let snapshot = backend.snapshot(); if replay.truncated { + self.history.request(snapshot.history_revision); // The bounded tail cannot reconstruct derived text/UI // state. Reset it before applying the authoritative tail // so no stale transcript, approval or preview survives. @@ -1999,7 +3254,7 @@ mod linux_app { self.qa_visible = false; self.selection = None; self.selection_draft.clear(); - self.selection_preview_visible = false; + self.polish_result_visible = false; } self.snapshot = Some(snapshot); for event in replay.events { @@ -2014,101 +3269,48 @@ mod linux_app { } while let Ok(result) = self.rx.try_recv() { match result { - UiResult::HistoryTransform { + UiResult::HistoryLoaded { generation, - id, - repolish, + revision, 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(); + if self.history.finish(generation, revision, result) { + if let OverviewState::Loaded(data) = &mut self.overview { + data.history = self.history.entries.clone(); } - Err(error) => self.status = error, } } - UiResult::Omni(result) => { - self.omni_loading = false; + UiResult::Message(message) => self.status = message, + UiResult::HistoryRepolish { id, result } => { + self.frontend_vm.history_repolish_running = 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(); + Ok(text) => { + self.frontend_vm.history_repolish_error = None; + self.frontend_vm.history_repolish_result = Some((id, text)); } + Err(error) => self.frontend_vm.history_repolish_error = Some(error), } } - 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::CapsuleDismissDue { session_id } => { + let current = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id) + .map(|id| id.to_string()); + let phase = self + .snapshot + .as_ref() + .map(|snapshot| snapshot.dictation.phase) + .unwrap_or(DictationPhase::Idle); + if capsule_hide_is_still_current(current.as_deref(), &session_id, phase) { + self.dismiss_capsule(); + } else { + log::info!( + "capsule: dismissal skipped for session {session_id} — \ + a newer session is active (snapshot {current:?}, {phase:?})" + ); } } - UiResult::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)) => { @@ -2146,21 +3348,29 @@ mod linux_app { 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); + // A refresh (enable toggle, save, validation) must not + // throw away the editor draft the user is editing: only + // a different channel re-reads the descriptor. + let already_loaded = matches!( + &self.provider_editor, + ProviderEditorState::Loaded(editor) + if editor.channel.id == channel_id + ); + if !already_loaded { + if let Some((channel, descriptor)) = + provider_channel_descriptor(&panel, &channel_id) + { + self.provider_editor = ProviderEditorState::Loading; + self.provider_editor_form = None; + self.load_provider_editor(panel.kind, channel, descriptor); + } } } else { self.provider_editor = ProviderEditorState::Idle; } } UiResult::Providers(Err(error)) => { - self.providers = ProvidersState::Failed(error.clone()); + self.providers = ProvidersState::Failed; self.status = error; } UiResult::ProviderEditor { @@ -2178,11 +3388,27 @@ mod linux_app { // Reads race with channel switching and mutation // refreshes. Only the still-selected channel may install // its editor, otherwise late credential data is ignored. + self.provider_editor_form = + Some(ProviderEditorForm::from_editor(&editor, self.lang)); self.provider_editor = ProviderEditorState::Loaded(Box::new(editor)); + // Tauri `CatalogModelField` 在挂载时就拉一次目录(只有 + // OrcaRouter 这种没有固定清单的服务商才需要),否则用户 + // 得自己找到「拉取模型」才知道可选什么。 + if self + .provider_editor_form + .as_ref() + .is_some_and(|form| form.provider_type == "orcarouter") + { + if let Some(form) = self.provider_editor_form.as_mut() { + form.models_loading = true; + } + self.request_provider_models(kind, channel_id); + } } Err(error) => { - self.provider_editor = ProviderEditorState::Failed(error.clone()); + self.provider_editor = ProviderEditorState::Failed; + self.provider_editor_form = None; self.status = error; } } @@ -2197,27 +3423,34 @@ mod linux_app { { match result { Ok(models) => { - self.status = fmt_l10n( - lang, - "status.provider_models_loaded", - &[&models.len()], - ); + // Tauri `loadModels`:空清单也要明说(「鉴权成功, + // 但没有返回可用模型。」),否则和拉取失败长得一样。 + self.status = if models.is_empty() { + tr_l10n(lang, "settings.providers.modelsEmpty").to_string() + } else { + fmt_l10n( + lang, + "settings.providers.modelsLoaded", + &[&models.len()], + ) + }; + if let Some(form) = self.provider_editor_form.as_mut() { + form.models = models.clone(); + form.models_loading = false; + } self.provider_models = models; } - Err(error) => self.status = error, + Err(error) => { + if let Some(form) = self.provider_editor_form.as_mut() { + form.models.clear(); + form.models_loading = false; + } + 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::LibraryRefresh => self.load_library(), UiResult::Library(Ok(library)) => { self.vocabulary = library.vocabulary; self.correction_rules = library.correction_rules; @@ -2226,122 +3459,202 @@ mod linux_app { 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 { request, result } => { + self.finish_settings_save(request, result); + } + UiResult::Marketplace(seq, result) => { + // Latest request wins: a slow earlier response must not + // replace the results of the query the user sees now. + if seq == self.marketplace_seq { + match result { + Ok(items) => { + self.status = fmt_l10n( + lang, + "status.marketplace_loaded", + &[&items.len()], + ); + self.marketplace_items = items; + } + Err(error) => self.status = error, } + self.marketplace_attempted = true; + } else { + log::debug!("dropping stale marketplace response (seq {seq})"); } } - 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()) - }); - } + UiResult::MarketplaceInstallFinished { id, result } => { + // Only clear the flag for the pack the UI is still showing. + if self.frontend_vm.marketplace_installing.as_deref() == Some(id.as_str()) { + self.frontend_vm.marketplace_installing = None; + } + match result { + Ok(message) => self.status = message, + Err(error) => self.status = error, + } + } + UiResult::MarketplaceLikes(Ok(likes)) => self.marketplace_my_likes = likes, + UiResult::MarketplaceLikes(Err(error)) => { + // Not signed in / offline: keep the previous like set. + log::debug!("marketplace likes unavailable: {error}"); + } + UiResult::MarketplaceAuth(result) => { + self.frontend_vm.marketplace_signed_in = result.unwrap_or(false); + if !self.frontend_vm.marketplace_signed_in { + self.frontend_vm.marketplace_login.clear(); + } + } + UiResult::MarketplaceOAuthStarted(result) => match result { + Ok(flow) => { + self.frontend_vm.marketplace_oauth_loading = false; + self.frontend_vm.marketplace_oauth_open = true; + self.frontend_vm.marketplace_oauth_flow_id = Some(flow.flow_id); + self.frontend_vm.marketplace_oauth_user_code = flow.user_code; + self.frontend_vm.marketplace_oauth_uri = flow.verification_uri; + self.frontend_vm.marketplace_oauth_error = None; + self.marketplace_oauth_interval_secs = flow.interval_secs.max(1); + self.marketplace_oauth_poll_deadline = Some( + std::time::Instant::now() + + Duration::from_secs(self.marketplace_oauth_interval_secs), + ); } Err(error) => { - self.status = error; - if let Some(backend) = self.backend() { - self.preferences = Some(backend.get_preferences()); - } - self.settings_dirty = SettingsDirty::default(); + self.frontend_vm.marketplace_oauth_loading = false; + self.frontend_vm.marketplace_oauth_open = true; + self.frontend_vm.marketplace_oauth_error = Some(error); } }, - UiResult::Marketplace { generation, result } => { - if generation != self.marketplace_ui.generation { + UiResult::MarketplaceOAuthPoll { flow_id, result } => { + if self.frontend_vm.marketplace_oauth_flow_id.as_deref() + != Some(flow_id.as_str()) + { 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; + Ok(openless_core::OAuthPollResult::Authorized { login }) => { + self.frontend_vm.marketplace_signed_in = true; + self.frontend_vm.marketplace_login = login; + self.frontend_vm.marketplace_oauth_open = false; + self.frontend_vm.marketplace_oauth_flow_id = None; + self.frontend_vm.marketplace_oauth_error = None; + self.marketplace_oauth_poll_deadline = None; + self.frontend_vm.marketplace_mine_loading = true; + self.load_marketplace_mine(); + } + Ok(openless_core::OAuthPollResult::Pending) => { + self.marketplace_oauth_poll_deadline = Some( + std::time::Instant::now() + + Duration::from_secs(self.marketplace_oauth_interval_secs), + ); + } + Ok(openless_core::OAuthPollResult::SlowDown) => { + self.marketplace_oauth_poll_deadline = + Some(std::time::Instant::now() + Duration::from_secs(5)); + } + Ok(openless_core::OAuthPollResult::Error { message }) => { + self.marketplace_oauth_poll_deadline = None; + self.frontend_vm.marketplace_oauth_flow_id = None; + self.frontend_vm.marketplace_oauth_error = Some(message); } Err(error) => { - self.frontend_vm.marketplace_notice = Some(error.clone()); - self.status = error; + self.marketplace_oauth_poll_deadline = None; + self.frontend_vm.marketplace_oauth_flow_id = None; + self.frontend_vm.marketplace_oauth_error = Some(error); } } } - 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(); + UiResult::MarketplacePublish(result) => { + self.frontend_vm.style_editor_publishing = false; + match result { + Ok(message) => self.status = message, + Err(error) => self.frontend_vm.style_notice = Some(error), } } - UiResult::MarketplaceFlow(Ok(flow)) => { - self.status = fmt_l10n(lang, "status.device_code", &[&flow.user_code]); - self.marketplace_flow = Some(flow); - } - UiResult::MarketplaceFlow(Err(error)) => self.status = error, - UiResult::MarketplaceAuthPoll(Ok(result)) => match result { - openless_core::OAuthPollResult::Authorized { login } => { - self.marketplace_flow = None; - self.status = fmt_l10n(lang, "status.logged_in", &[&login]); - } - openless_core::OAuthPollResult::Pending => { - self.status = tr_l10n(lang, "status.oauth_pending").to_string(); - } - openless_core::OAuthPollResult::SlowDown => { - self.status = tr_l10n(lang, "status.oauth_slowdown").to_string(); + UiResult::MarketplaceUpload(result) => { + self.frontend_vm.marketplace_upload_submitting = false; + match result { + Ok(uploaded) => { + self.frontend_vm.marketplace_upload_open = false; + self.frontend_vm.marketplace_upload_selected = None; + self.frontend_vm.marketplace_notice = None; + self.status = uploaded.message; + self.frontend_vm.marketplace_mine_loading = true; + self.load_marketplace_mine(); + } + Err(error) => { + self.frontend_vm.marketplace_notice = Some(error); + } } - openless_core::OAuthPollResult::Error { message } => { + } + UiResult::MarketplaceWithdraw(result) => match result { + Ok(message) => { + self.frontend_vm.marketplace_mine_loading = true; self.status = message; + self.load_marketplace_mine(); } + Err(error) => self.frontend_vm.marketplace_notice = Some(error), + }, + UiResult::SettingsChannels(Ok(rows)) => { + self.settings_channels = rows; + self.settings_channels_loading = false; + } + UiResult::ServiceConfigured(configured) => { + self.service_configured = configured; + } + UiResult::SettingsChannels(Err(error)) => { + self.settings_channels_loading = false; + self.frontend_vm.settings_notice = Some(error); + } + UiResult::ChannelCreated { kind, result } => match result { + Ok(channel_id) => { + self.settings_channel_kind = kind; + self.provider_kind = kind; + self.selected_channel_id = Some(channel_id); + self.close_provider_editor(); + self.load_settings_channels(); + self.load_service_configured(); + self.load_providers(kind); + } + Err(error) => self.frontend_vm.settings_notice = Some(error), }, - 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); - } + self.frontend_vm.marketplace_detail_prompt = Some(detail.prompt.clone()); + self.marketplace_detail = Some(detail); } UiResult::MarketplaceDetail(Err(error)) => self.status = error, UiResult::MarketplaceMine(Ok((packs, likes))) => { - self.status = fmt_l10n( - lang, - "status.my_publish_likes", - &[&packs.len(), &likes.len()], - ); + self.frontend_vm.marketplace_mine_loading = false; + self.frontend_vm.marketplace_mine_packs = packs + .iter() + .map(|pack| frontend::view_model::MarketplaceMinePack { + pack: frontend::view_model::MarketplacePack { + id: pack.summary.id.clone(), + name: pack.summary.name.clone(), + version: pack.summary.version.clone(), + description: pack.summary.description.clone(), + mode: pack.summary.base_mode.clone(), + author: pack.summary.author_login.clone(), + origin_author_login: pack.summary.origin_author_login.clone(), + tags: pack.summary.tags.clone(), + likes: pack.summary.like_count.max(0) as u32, + downloads: pack.summary.download_count.max(0) as u32, + liked: false, + }, + state: pack.state.clone(), + updated_at: pack.summary.updated_at.clone(), + }) + .collect(); self.marketplace_my_packs = packs; self.marketplace_my_likes = likes; } - UiResult::MarketplaceMine(Err(error)) => self.status = error, + UiResult::MarketplaceMine(Err(error)) => { + self.frontend_vm.marketplace_mine_loading = false; + self.frontend_vm.marketplace_notice = Some(error.clone()); + self.status = error; + } UiResult::Microphones(Ok(devices)) => { + self.microphone_error = None; self.microphones = devices.clone(); let selected = self .preferences @@ -2363,554 +3676,177 @@ mod linux_app { } } } - 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); - } + UiResult::Microphones(Err(error)) => { + self.microphone_error = Some(error.clone()); + self.status = error; } - UiResult::Overview(generation, Err(error)) => { - if generation - != self - .overview_generation - .load(std::sync::atomic::Ordering::Acquire) - { - continue; - } + UiResult::Overview(Ok(mut data)) => { + data.history = self.history.entries.clone(); + self.overview = OverviewState::Loaded(data); + } + UiResult::Overview(Err(error)) => { self.status = error.clone(); self.overview = OverviewState::Failed(error); } - UiResult::UpdateCheck(Ok(Some(manifest))) => { - self.update_busy = false; - self.status = fmt_l10n(lang, "update.discovered", &[&manifest.version]); - self.update_manifest = Some(manifest); - } - UiResult::UpdateCheck(Ok(None)) => { - self.update_busy = false; - self.status = tr_l10n(lang, "update.up_to_date").to_string(); - } - UiResult::UpdateCheck(Err(error)) => { - self.update_busy = false; - self.status = fmt_l10n(lang, "update.check_failed", &[&error]); - } - UiResult::UpdateProgress(progress) => self.update_progress = Some(progress), - UiResult::UpdateInstalled(Ok(installed)) => { - self.update_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]); - } } } if let Some(backend) = self.backend() { self.snapshot = Some(backend.snapshot()); + self.history.observe(backend.snapshot().history_revision); } + self.reconcile_capsule_liveness(); + self.load_history(); } - 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.spawn(async move { - backend.submit_less_computer(prompt).await?; - Ok(tr_l10n(lang, "less_computer.done").to_string()) - }); - } - } - 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()) - }); - } - } - }); - 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()) - }); - } - } - } - }); + /// 兜底:Core 有错误路径只 reset 会话、不发布终态事件,宿主就永远等不到 + /// 「终态 → 收起」,药丸会一直贴在屏上。这里每帧按快照判断会话是否已经 + /// 消失,消失且没排过收起就补一次(时长按失败终态,文案仍由胶囊自己决定)。 + fn reconcile_capsule_liveness(&mut self) { + let live = self + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.dictation.session_id) + .map(|session_id| session_id.to_string()); + let Some(session) = capsule_needs_fallback_dismissal( + self.capsule_session.as_deref(), + live.as_deref(), + self.capsule_dismissal_scheduled.as_deref(), + ) else { + return; + }; + log::info!( + "capsule: session {session} vanished without a terminal event — \ + scheduling the fallback dismissal" + ); + self.schedule_capsule_dismissal(&session, DictationPhase::Failed); + } + + /// Apply a newly chosen UI locale immediately: persist it as Linux-UI + /// state (never Core business truth), resolve it to a concrete language + /// and let the next frame re-render every localized surface. Persistence + /// is offloaded off the egui frame so the write can never stall a repaint. + fn apply_locale_pref(&mut self, pref: LocalePref) { + if pref == self.locale_pref { + return; } - ui.label(if self.less_computer_output.is_empty() { - tr_l10n(lang, "less_computer.no_output") - } else { - &self.less_computer_output + self.locale_pref = pref; + self.lang = pref.resolve(); + if let Some(tray) = &self.tray { + let _ = tray.set_lang(self.lang); + } + let runtime = self.tokio.clone(); + runtime.spawn_blocking(move || { + let _ = save_locale_pref(pref); }); } - fn provider_management_ui(&mut self, ui: &mut egui::Ui) { + // ── Frontend bridge ───────────────────────────────────────────────── + + /// Sync backend state into the frontend view model each frame before + /// rendering. Only fields that have real data sources are populated; + /// unwired fields remain in their default empty / loading state. + fn sync_view_model(&mut self) { + self.frontend_vm.settings_saving = self.settings_save.in_flight.is_some(); + self.frontend_vm.settings_save_error = self.settings_save.error.clone(); + // 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; - 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); + let permissions = self.permission_snapshot(); + // Style-pack icons must be read before `vm` borrows `frontend_vm`. + let style_icon_urls = self.style_icon_urls(); + // The drawer's runtime card previews the *draft*, using Core's own + // prompt composer, so it must be built before `vm` is borrowed. + let style_runtime = if self.frontend_vm.style_editor_open { + match (backend.as_ref(), self.style_editor.as_ref()) { + (Some(backend), Some(stored)) => { + let mut draft = stored.clone(); + draft.prompt = self.frontend_vm.style_prompt.clone(); + draft.selection_prompt = self.frontend_vm.style_selection_prompt.clone(); + draft.voice_edit_prompt = self.frontend_vm.style_voice_edit_prompt.clone(); + let diagnostics = backend.preview_style_pack_runtime(&draft); + Some(frontend::view_model::StyleRuntimePreview { + context_active: diagnostics.includes_context_premise, + hotword_active: diagnostics.includes_hotword_block, + history_active: diagnostics.includes_history_instruction, + omits_front_app: diagnostics.preview_omits_front_app, + }) } + _ => None, } - if ui.button(tr_l10n(lang, "btn.refresh_channel")).clicked() { - self.providers = ProvidersState::Loading; - self.load_providers(self.provider_kind); - } - }); + } else { + None + }; + // 文本型设置行只在偏好载入 / 外部变更时回灌一次,避免把输入中的 + // 内容每帧弹回旧值。 + let hydrate_text = std::mem::replace(&mut self.hydrate_text_fields, false); - 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, + let vm = &mut self.frontend_vm; + + // Map shell::Page to frontend::Page. + vm.active_page = match self.active_page { + shell::Page::Overview => frontend::view_model::Page::Overview, + shell::Page::History => frontend::view_model::Page::History, + shell::Page::QuickNote => frontend::view_model::Page::QuickNote, + 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.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 - .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()) - }); - } - } - }); - ui.small(tr_l10n(lang, "providers.core_note")); + vm.status = self.status.clone(); + vm.quick_note_recording = backend.as_ref().is_some_and(|backend| { + backend.dictation_output_target() + == Some(openless_core::DictationOutputTarget::QuickNote) }); - - if panel.channels.is_empty() { - ui.label(tr_l10n(lang, "providers.empty")); - return; + vm.version = env!("OPENLESS_APP_VERSION").to_string(); + vm.lang = lang; + // UI 进程没有 Core 偏好,明暗主题必须随视图模型一起过去。 + vm.theme_mode = self + .preferences + .as_ref() + .map(|preferences| preferences.theme_mode) + .unwrap_or_default(); + if let Some(prefs) = &self.preferences { + vm.dictation_hotkey = prefs.dictation_hotkey.display_label(); + vm.qa_hotkey = prefs + .qa_hotkey + .as_ref() + .map(|binding| binding.display_label()) + .unwrap_or_default(); + vm.quick_note_hotkey = prefs + .quick_note_hotkey + .as_ref() + .map(|binding| binding.display_label()) + .unwrap_or_default(); + vm.quick_note_shortcut_hidden = self.quick_note_shortcut_hidden; + vm.translation_hotkey = prefs.translation_hotkey.display_label(); + // 这几行一直在界面上,但宿主以前只填了前四个:没填的行无论 Core 里 + // 有没有绑定都显示成空键帽,看起来像「未设置」。一律从偏好取真值。 + vm.switch_style_hotkey = prefs + .switch_style_hotkey + .as_ref() + .map(|binding| binding.display_label()) + .unwrap_or_default(); + vm.open_app_hotkey = prefs + .open_app_hotkey + .as_ref() + .map(|binding| binding.display_label()) + .unwrap_or_default(); + vm.coding_agent_hotkey = prefs + .coding_agent_voice_hotkey + .as_ref() + .map(|binding| binding.display_label()) + .unwrap_or_default(); } - 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, 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); - } - } - 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()) - }); - } - } - 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()) - }); - } - } - 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()) - }); - } - } - 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()) - }); - } - } - 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()) - }); - } - } - 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()); - } - }); - } - - match self.provider_editor.clone() { - ProviderEditorState::Idle => {} - ProviderEditorState::Loading { kind, channel_id } => { - ui.label(fmt_l10n( - lang, - "providers.reading_channel", - &[&format!("{kind:?}"), &channel_id], - )); - } - ProviderEditorState::Failed(error) => { - ui.colored_label(egui::Color32::RED, error); - } - 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(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); - }); - 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 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 let Some(url) = editor - .descriptor - .endpoint_presets - .iter() - .find(|preset| { - openless_core::provider_rules::matches_endpoint_preset( - &editor.endpoint, - &preset.endpoint, - ) - }) - .and_then(|preset| preset.models_url.as_deref()) - { - self.provider_models.clear(); - ui.hyperlink_to(tr_l10n(lang, "btn.list_models"), url); - } else 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)); - } - } - } - - /// Apply a newly chosen UI locale immediately: persist it as Linux-UI - /// state (never Core business truth), resolve it to a concrete language - /// and let the next frame re-render every localized surface. Persistence - /// is offloaded off the egui frame so the write can never stall a repaint. - fn apply_locale_pref(&mut self, pref: LocalePref) { - if pref == self.locale_pref { - return; - } - self.locale_pref = pref; - self.lang = pref.resolve(); - if let Some(tray) = &self.tray { - let _ = tray.set_lang(self.lang); - } - let runtime = self.tokio.clone(); - runtime.spawn_blocking(move || { - let _ = save_locale_pref(pref); - }); - } - - /// The language selector row shown in Settings. Changing it re-renders - /// the whole window immediately (shell, headings, labels, popups later - /// pick it up from the persisted UI state on their next launch). - fn language_selector_ui(&mut self, ui: &mut egui::Ui) { - let mut chosen: Option = None; - ui.horizontal(|ui| { - 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() - } - 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 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, - }; - - vm.status = self.status.clone(); - vm.history_audio_playing = self.playback.is_playing(); - vm.version = env!("CARGO_PKG_VERSION").to_string(); + // The drawer's Save button is enabled only while the draft differs + // from what Core last stored. + vm.style_editor_dirty = style_editor_is_dirty(vm); + vm.style_runtime = style_runtime; // Overview: wire real data when available. if let Some(summary) = self.overview.summary(chrono::Local::now().date_naive()) { @@ -2932,14 +3868,22 @@ mod linux_app { .map(|entry| frontend::view_model::OverviewRecentEntry { created_at: entry.created_at, final_text: entry.final_text, + raw_transcript: entry.raw_transcript, + mode: overview_mode(entry.mode), duration_ms: entry.duration_ms, }) .collect(), - 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, + activity_daily: summary + .activity_daily + .into_iter() + .map(overview_activity_day) + .collect(), + heatmap_year: summary.heatmap_year, + heatmap: summary + .heatmap + .into_iter() + .map(overview_heatmap_day) + .collect(), }); } else if let Some(error) = overview_err { vm.overview_loading = false; @@ -2955,469 +3899,436 @@ mod linux_app { if let Some(prefs) = &self.preferences { let s = &mut vm.settings; s.streaming_insert = prefs.streaming_insert; + s.stable_transcription = prefs.stable_transcription_enabled; 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(); + if hydrate_text { + s.remote_port = prefs.remote_input_port.to_string(); + } s.activity_heatmap = prefs.show_overview_activity_heatmap; + s.language = match self.locale_pref { + LocalePref::System => 0, + LocalePref::Lang(Lang::ZhCn) => 1, + LocalePref::Lang(Lang::ZhTw) => 2, + LocalePref::Lang(Lang::En) => 3, + LocalePref::Lang(Lang::Ja) => 4, + LocalePref::Lang(Lang::Ko) => 5, + }; 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. - { - 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 + vm.translation_target_language = prefs.translation_target_language.clone(); + // Tauri offers 切换式 / 按住说话 / 自动识别 — the legacy DoubleClick + // value stays untouched in the store, it just has no chip here. + s.recording_mode = match prefs.hotkey.mode { + openless_core::shared_types::HotkeyMode::Hold => 1, + openless_core::shared_types::HotkeyMode::Auto => 2, + _ => 0, + }; + s.restore_clipboard = prefs.restore_clipboard_after_paste; + s.silence_auto_stop = prefs.silence_auto_stop_enabled; + s.silence_seconds = + prefs.silence_auto_stop_seconds.round().clamp(1.0, 5.0) as usize; + s.microphone_name = prefs.microphone_device_name.clone(); + s.microphone_options = self + .microphones .iter() - .map(|rule| frontend::view_model::CorrectionRule { - pattern: rule.pattern.clone(), - replacement: rule.replacement.clone(), - enabled: rule.enabled, - learned: false, - }) + .map(|device| device.name.clone()) .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 + s.microphone_error = self.microphone_error.clone(); + // 胶囊开关与样式(Tauri `showCapsule` / `capsuleStyle`)。 + s.show_capsule = prefs.show_capsule; + s.capsule_style = match prefs.capsule_style { + openless_core::shared_types::CapsuleStyle::Classic => 1, + openless_core::shared_types::CapsuleStyle::Typeless => 2, + openless_core::shared_types::CapsuleStyle::Siri => 0, + }; + s.mute_while_recording = prefs.mute_during_recording; + s.audio_cue = prefs.audio_cue_on_record; + s.launch_at_login = prefs.launch_at_login; + s.streaming_save_clipboard = prefs.streaming_insert_save_clipboard; + s.record_audio_for_debug = prefs.record_audio_for_debug; + if hydrate_text { + s.history_max_entries = prefs + .history_max_entries + .map(|value| value.to_string()) + .unwrap_or_default(); + s.retention_days = prefs.history_retention_days.to_string(); + s.polish_context_window = prefs.polish_context_window_minutes.to_string(); + s.audio_recording_max_entries = prefs + .audio_recording_max_entries + .map(|value| value.to_string()) + .unwrap_or_default(); + } + s.remote_default_mode = usize::from(prefs.remote_input_default_mode == "hold"); + s.system_proxy = prefs.use_system_proxy; + s.multimodal = prefs.multimodal_pipeline_enabled; + s.less_computer = prefs.coding_agent_enabled; + s.coding_agent_provider = match prefs.coding_agent_provider.as_str() { + "opencode-cli" => 1, + "codex-cli" => 2, + "dsh-cli" => 3, + _ => 0, + }; + s.coding_agent_permission = match prefs.coding_agent_permission_mode.as_str() { + "plan" => 1, + "default" => 2, + "bypassPermissions" => 3, + _ => 0, + }; + if hydrate_text { + s.coding_agent_model = prefs.coding_agent_model.clone().unwrap_or_default(); + s.coding_agent_workdir = prefs.coding_agent_workdir.clone().unwrap_or_default(); + s.coding_agent_exe = prefs.coding_agent_exe.clone().unwrap_or_default(); + } + s.selection_polish_delivery = match prefs.selection_polish_output_mode { + openless_core::shared_types::SelectionPolishOutputMode::DirectReplace => 0, + openless_core::shared_types::SelectionPolishOutputMode::PreviewConfirm => 1, + }; + // 多模态 / 平台能力:决定 AI 服务页的视图与更新控件。 + // 远程输入的实时状态:配对码 / 访问网址 / 证书指纹。 + if let Some((status, pin)) = &self.remote_access { + vm.remote_running = status.running; + vm.remote_urls_stale = status.urls_stale; + vm.remote_pin = pin.clone(); + vm.remote_urls = status.urls.clone(); + vm.remote_cert_fingerprint = status.ca_fingerprint_sha256.clone(); + } else { + vm.remote_running = false; + vm.remote_urls_stale = false; + vm.remote_pin = String::new(); + vm.remote_urls = Vec::new(); + vm.remote_cert_fingerprint = None; + } + // 必配服务的状态点由 `load_service_configured` 异步刷新。 + vm.service_configured = self.service_configured; + vm.multimodal_view = prefs.multimodal_pipeline_enabled; + vm.pipeline_multimodal = + prefs.pipeline_mode == openless_core::shared_types::PipelineMode::Multimodal; + // Linux 宿主没有本地推理引擎。 + vm.supports_local_asr = false; + // 热键后端是否真的起来了:没有 fcitx5 监听器时隐藏「快捷键」分区, + // 与 Tauri 的 `visibleSettingsSections(supportsDesktopHotkey)` 一致。 + vm.hotkeys_supported = self + .native + .as_ref() + .is_some_and(LinuxNativeRuntime::hotkeys_available); + vm.permissions = permissions; + vm.selection_polish_hotkey = prefs + .selection_polish_hotkey + .as_ref() + .map(|binding| binding.display_label()) + .unwrap_or_default(); + vm.qa_save_history = prefs.qa_save_history; + // 风格包直选:只展示已经录过的快捷键(录制器尚未实现)。 + s.style_pack_hotkeys = prefs + .style_pack_hotkeys .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 - }, + .map(|entry| { + let pack = self + .style_packs + .iter() + .find(|pack| pack.id == entry.pack_id); + frontend::view_model::StylePackHotkeyRow { + pack_id: entry.pack_id.clone(), + name: pack + .map(|pack| pack.name.clone()) + .unwrap_or_else(|| entry.pack_id.clone()), + hotkey: entry.binding.display_label(), + } }) .collect(); + // 草稿行的默认风格包:第一个还没绑定快捷键的(Tauri 的「+添加」下拉)。 + if vm.style_hotkey_draft_pack >= vm.style_packs.len() { + vm.style_hotkey_draft_pack = vm.style_packs.len().saturating_sub(1); + } } - vm.style_selected = vm - .style_packs + // AI services tab: provider picker plus the cached channel list. + vm.channel_providers = openless_core::provider_rules::provider_descriptors( + provider_kind(self.settings_channel_kind), + ) + .into_iter() + .map(|descriptor| frontend::view_model::SettingsChannelProvider { + provider_type: descriptor.provider_type.as_str().to_string(), + label: localized_provider_label( + lang, + self.settings_channel_kind, + descriptor.provider_type.as_str(), + ), + }) + .collect(); + vm.channels_loading = self.settings_channels_loading; + let active_channel = self + .settings_channels .iter() - .position(|p| p.is_active) + .position(|channel| channel.enabled) .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}"); - } - } - - /// 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; - } - frontend::view_model::SettingsField::StartMinimized => { - preferences.start_minimized = !preferences.start_minimized; - self.settings_dirty.start_minimized = true; - } - frontend::view_model::SettingsField::AutoUpdate => { - preferences.auto_update_check = !preferences.auto_update_check; - self.settings_dirty.auto_update_check = true; - } - frontend::view_model::SettingsField::RemoteInput => { - preferences.remote_input_enabled = !preferences.remote_input_enabled; - self.settings_dirty.remote_input_enabled = true; - } - frontend::view_model::SettingsField::ActivityHeatmap => { - preferences.show_overview_activity_heatmap = - !preferences.show_overview_activity_heatmap; - self.settings_dirty.appearance = true; - } - frontend::view_model::SettingsField::RealtimeMode => { - preferences.hotkey.mode = match preferences.hotkey.mode { - openless_core::shared_types::HotkeyMode::Hold => { - openless_core::shared_types::HotkeyMode::Toggle + vm.channels = self + .settings_channels + .iter() + .enumerate() + .map(|(index, channel)| frontend::view_model::SettingsChannel { + name: channel.name.clone(), + provider: localized_provider_label( + lang, + self.settings_channel_kind, + &channel.provider_type, + ), + provider_type: channel.provider_type.clone(), + model: channel.model.clone(), + is_active: index == active_channel, + enabled: channel.enabled, + last_check: match ( + channel.last_ok, + channel.last_latency_ms, + channel.last_error.as_deref(), + ) { + (Some(true), Some(ms), _) => Some(format!( + "{} · {}", + tr_l10n(lang, "settings.channels.passed"), + fmt_l10n(lang, "settings.channels.elapsed", &[&ms]), + )), + (Some(true), None, _) => { + Some(tr_l10n(lang, "settings.channels.passed").to_string()) } - _ => 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(); - } - - /// Apply a settings combo change from the frontend. - fn apply_settings_combo( - &mut self, - field: frontend::view_model::SettingsComboField, - index: usize, - ) { - let Some(preferences) = self.preferences.as_mut() else { - return; - }; - match field { - frontend::view_model::SettingsComboField::Theme => { - preferences.theme_mode = match index { - 0 => openless_core::shared_types::ThemeMode::System, - 1 => openless_core::shared_types::ThemeMode::Light, - 2 => openless_core::shared_types::ThemeMode::Dark, - _ => return, - }; - self.settings_dirty.appearance = true; - self.frontend_vm.settings.theme = index; - } - frontend::view_model::SettingsComboField::Language => { - let pref = match index { - 0 => LocalePref::System, - 1 => LocalePref::Lang(Lang::ZhCn), - 2 => LocalePref::Lang(Lang::ZhTw), - 3 => LocalePref::Lang(Lang::En), - 4 => LocalePref::Lang(Lang::Ja), - 5 => LocalePref::Lang(Lang::Ko), - _ => return, - }; - self.apply_locale_pref(pref); - self.frontend_vm.settings.language = index; - } - frontend::view_model::SettingsComboField::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()); - } - } - self.save_settings_if_dirty(); - } + (Some(false), _, error) => Some(fmt_l10n( + lang, + "settings.channels.failed", + &[&error.unwrap_or_default()], + )), + _ => None, + }, + }) + .collect(); - /// 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; + // Provider editor: mirrored from the host draft each frame. The page + // stays a pure renderer; Core still owns the credential schema. + vm.provider_editor = self.provider_editor_form.as_ref().map(|form| { + frontend::view_model::SettingsProviderEditor { + channel_id: form.channel_id.clone(), + provider: form.label.clone(), + provider_type: form.provider_type.clone(), + name: form.name.clone(), + endpoint: form.endpoint.clone(), + model: form.model.clone(), + resource_id: form.resource_id.clone(), + auth_mode: form.auth_mode.clone(), + auth: form.auth, + primary_secret: form.primary_secret.clone(), + secondary_secret: form.secondary_secret.clone(), + models: form.models.clone(), + models_loading: form.models_loading, + static_models: form.static_models.clone(), + default_model: form.default_model.clone(), + has_models_url: form.models_url.is_some(), + custom_model: form.custom_model, + busy: matches!(self.provider_editor, ProviderEditorState::Loading), } - 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(); - } + }); - /// 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()) - }); - } - } - 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) + // Rebuild display labels from the shared in-memory history cache. + let selected_id = vm + .history_entries + .get(vm.history_selected) + .map(|entry| entry.id.clone()); + let history = self.history.entries.clone(); + vm.history_entries = history + .into_iter() + .map(|item| { + let has_audio = item.has_audio_recording.unwrap_or(false); + frontend::view_model::HistoryEntry { + quick_note: item.source == openless_core::HistorySource::QuickNote, + error_code: item.error_code, + id: item.id, + created_at: item.created_at, + mode: overview_mode(item.mode), + // History carries the exact style-pack id. Prefer the + // catalog name so imported packs do not collapse into the + // four base-mode labels; old records without an id still + // use the mode fallback. + style_label: item + .style_pack_id + .as_deref() + .and_then(|id| self.style_packs.iter().find(|pack| pack.id == id)) + .map(|pack| { + let builtin_default = + openless_core::builtin_style_pack_for_mode(pack.base_mode).name; + if pack.kind == openless_core::StylePackKind::Builtin + && pack.id + == openless_core::builtin_style_pack_id(pack.base_mode) + && pack.name == builtin_default + { + polish_mode_label(lang, pack.base_mode).to_string() + } else { + pack.name.clone() + } }) - .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}")); - } + .unwrap_or_else(|| polish_mode_label(lang, item.mode).to_string()), + raw_transcript: item.raw_transcript, + final_text: item.final_text, + duration_ms: item.duration_ms, + has_audio, + asr_provider: item.asr_provider, + asr_model: item.asr_model, + asr_ms: item.asr_ms, + llm_provider: item.llm_provider, + app_name: item.app_name, + dictionary_count: item.dictionary_entry_count, } + }) + .collect(); + vm.history_loading = self.history.loading(); + vm.history_error = self.history.error.clone(); + vm.history_selected = selected_id + .and_then(|id| vm.history_entries.iter().position(|entry| entry.id == id)) + .unwrap_or(0); + + // In-app playback progress (dropped once the clip finishes). + if self + .history_clip + .as_ref() + .is_some_and(|(_, player)| player.is_finished()) + { + self.history_clip = None; + } + vm.history_playback = self.history_clip.as_ref().map(|(id, player)| { + frontend::view_model::HistoryPlayback { + id: id.clone(), + position_ms: player.position_ms(), + total_ms: player.total_ms(), + paused: player.is_paused(), } - 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()); - } + }); + + // Vocabulary + correction rules: the library path is always wired, so + // an empty store is an empty list — never an "unsupported" page. + vm.vocab_unsupported = false; + vm.vocab_entries = self + .vocabulary + .iter() + .map(|entry| frontend::view_model::VocabEntry { + phrase: entry.phrase.clone(), + hits: entry.hits as usize, + enabled: entry.enabled, + learned: false, + }) + .collect(); + vm.vocab_rules = self + .correction_rules + .iter() + .map(|rule| frontend::view_model::CorrectionRule { + pattern: rule.pattern.clone(), + replacement: rule.replacement.clone(), + enabled: rule.enabled, + learned: false, + }) + .collect(); + vm.vocab_saved_presets = self + .vocab_presets + .iter() + .map(|preset| frontend::view_model::SavedVocabPreset { + name: preset.name.clone(), + phrases: preset.phrases.join("、"), + }) + .collect(); + + // Style packs: wired too; an empty list is a valid state. + vm.style_unsupported = false; + vm.style_packs = self + .style_packs + .iter() + .enumerate() + .map(|(index, pack)| frontend::view_model::StylePack { + id: pack.id.clone(), + icon_path: pack.icon_path.clone(), + icon_data_url: style_icon_urls.get(index).cloned().flatten(), + base_mode: match pack.base_mode { + openless_core::PolishMode::Raw => "raw", + openless_core::PolishMode::Light => "light", + openless_core::PolishMode::Structured => "structured", + openless_core::PolishMode::Formal => "formal", + } + .to_string(), + name: pack.name.clone(), + description: pack.description.clone(), + // Localized mode label (Core's display_name is zh-only). + tags: vec![polish_mode_label(lang, pack.base_mode).to_string()], + is_builtin: pack.kind == openless_core::StylePackKind::Builtin, + enabled: pack.enabled, + is_active: pack.active, + selection_active: self + .preferences + .as_ref() + .is_some_and(|prefs| prefs.selection_polish_style_pack_id == pack.id), + }) + .collect(); + + // Translation and selection-ask are always wired through Core; the + // pages only render state that is already loaded. + vm.translation_unsupported = false; + vm.selection_unsupported = false; + + // Marketplace: wired through Core; the list loads lazily on first visit. + vm.marketplace_unsupported = false; + vm.marketplace_loading = !self.marketplace_attempted; + if !self.marketplace_items.is_empty() { + vm.marketplace_loading = false; + vm.marketplace_packs = self + .marketplace_items + .iter() + .map(|item| frontend::view_model::MarketplacePack { + id: item.id.clone(), + name: item.name.clone(), + version: item.version.clone(), + description: item.description.clone(), + mode: item.base_mode.clone(), + author: item.author_login.clone(), + origin_author_login: item.origin_author_login.clone(), + tags: item.tags.clone(), + likes: item.like_count as u32, + downloads: item.download_count as u32, + liked: self.marketplace_my_likes.contains(&item.id), + }) + .collect(); + } + + // Startup error. + if let Some(error) = &self.startup_error { + vm.status = format!("启动失败: {error}"); } } - /// Persist dirty settings if any fields have been changed. - fn save_settings_if_dirty(&mut self) { - if !self.settings_dirty.any() { - return; + /// 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, } - 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))); - }); + } + + /// Apply a settings toggle from the frontend to the live preferences. + /// 隐私分区的真实状态:Linux 没有系统级授权弹窗,能列出的设备 / 已启动的 + /// 热键适配器就是「已授权」,macOS 才有的辅助功能 / 本地网络一律「不适用」。 + fn permission_snapshot(&self) -> frontend::view_model::SettingsPermissions { + use frontend::view_model::PermissionState; + frontend::view_model::SettingsPermissions { + microphone: if self.microphones.is_empty() { + PermissionState::Unknown + } else { + PermissionState::Granted + }, + accessibility: PermissionState::Unsupported, + network: PermissionState::Unsupported, + hotkey: if self.native.is_some() { + PermissionState::Granted + } else { + PermissionState::Unknown + }, } } @@ -3425,27 +4336,17 @@ mod linux_app { fn apply_frontend_actions( &mut self, actions: Vec, - ctx: &egui::Context, + // 宿主没有窗口:窗口类动作由 UI 进程就地处理,这里保留参数是为了 + // 让调用点保持「渲染层 → 动作 → 宿主」的形状。 + _ctx: &egui::Context, ) { for action in actions { match action { - frontend::view_model::FrontendAction::AcceptCorrection(id) => { - if let Some(backend) = self.backend() { - self.spawn(async move { - backend.accept_pending_correction(&id)?; - Ok("已接受纠错建议".into()) - }); - } - } - frontend::view_model::FrontendAction::RejectCorrection(id) => { - if let Some(backend) = self.backend() { - backend.reject_pending_correction(&id); - } - } 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::QuickNote => shell::Page::QuickNote, frontend::view_model::Page::Vocab => shell::Page::Vocabulary, frontend::view_model::Page::Style => shell::Page::Styles, frontend::view_model::Page::Marketplace => shell::Page::Marketplace, @@ -3454,57 +4355,270 @@ mod linux_app { frontend::view_model::Page::Corrections => shell::Page::Corrections, frontend::view_model::Page::Settings => shell::Page::Providers, }; + if page == frontend::view_model::Page::Marketplace { + // The list is fetched lazily; entering the page is what + // triggers the first load. + self.load_marketplace(); + } } frontend::view_model::FrontendAction::ToggleSettings => { self.frontend_vm.settings_open = !self.frontend_vm.settings_open; + if self.frontend_vm.settings_open { + self.frontend_vm.active_page = frontend::view_model::Page::Settings; + // 每次打开设置都刷新「必配服务」状态点。 + self.load_service_configured(); + self.provider_kind = self.settings_channel_kind; + self.load_providers(self.settings_channel_kind); + // 也重新枚举麦克风:设备可能在启动后才插上(Tauri 在 + // 下拉打开时同样重查)。 + self.load_microphones(); + if self.settings_channels.is_empty() { + self.load_settings_channels(); + } + } } frontend::view_model::FrontendAction::CloseSettings => { self.frontend_vm.settings_open = false; + self.frontend_vm.active_page = frontend::view_model::Page::Overview; } 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::OverviewRefresh => { + self.overview = OverviewState::Loading; + self.load_overview(); } - frontend::view_model::FrontendAction::WindowClose => { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + frontend::view_model::FrontendAction::OverviewPeriod(period) => { + self.frontend_vm.overview_period = period.min(1); } - 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::OverviewMetric(metric) => { + self.frontend_vm.overview_metric = metric.min(2); } - frontend::view_model::FrontendAction::WindowMinimize => { - ctx.send_viewport_cmd(egui::ViewportCommand::Minimized(true)); + frontend::view_model::FrontendAction::SidebarToggleTools => { + self.frontend_vm.tools_open = !self.frontend_vm.tools_open; + } + frontend::view_model::FrontendAction::WindowClose + | frontend::view_model::FrontendAction::WindowMinimize + | frontend::view_model::FrontendAction::WindowMaximize => { + // 窗口控制由 UI 进程就地处理(只有它有窗口);宿主收到说明 + // 某个 UI 分支忘了拦,记一条即可,不影响任何状态。 + log::debug!("[ui-host] window action reached the host: {action:?}"); } frontend::view_model::FrontendAction::MarketplaceRefresh => { self.load_marketplace(); } frontend::view_model::FrontendAction::MarketplaceMyPacks => { - self.marketplace_ui.mine_open = true; + self.frontend_vm.marketplace_mine_open = true; + self.frontend_vm.marketplace_mine_loading = true; + self.frontend_vm.marketplace_notice = None; + self.frontend_vm.marketplace_mine_packs.clear(); self.load_marketplace_mine(); } + frontend::view_model::FrontendAction::MarketplaceCloseMine => { + self.frontend_vm.marketplace_mine_open = false; + } + frontend::view_model::FrontendAction::MarketplaceUploadOpen { + origin_pack_id, + target_name, + } => { + if !self.frontend_vm.marketplace_signed_in { + continue; + } + let target = target_name + .as_deref() + .unwrap_or_default() + .trim() + .to_lowercase(); + let mut packs: Vec<_> = self + .style_packs + .iter() + .filter(|pack| pack.kind != openless_core::StylePackKind::Builtin) + .cloned() + .collect(); + packs.sort_by(|left, right| { + let left_match = + !target.is_empty() && left.name.trim().to_lowercase() == target; + let right_match = + !target.is_empty() && right.name.trim().to_lowercase() == target; + right_match.cmp(&left_match).then_with(|| { + left.name.to_lowercase().cmp(&right.name.to_lowercase()) + }) + }); + let selected = if target.is_empty() { + None + } else { + packs + .iter() + .position(|pack| pack.name.trim().to_lowercase() == target) + }; + self.frontend_vm.marketplace_upload_open = true; + self.frontend_vm.marketplace_upload_origin_pack_id = origin_pack_id; + self.frontend_vm.marketplace_upload_target_name = target_name; + self.frontend_vm.marketplace_upload_packs = packs; + self.frontend_vm.marketplace_upload_selected = selected; + self.frontend_vm.marketplace_upload_submitting = false; + self.frontend_vm.marketplace_notice = None; + } + frontend::view_model::FrontendAction::MarketplaceUploadSelect(index) => { + if index < self.frontend_vm.marketplace_upload_packs.len() + && !self.frontend_vm.marketplace_upload_submitting + { + self.frontend_vm.marketplace_upload_selected = Some(index); + } + } + frontend::view_model::FrontendAction::MarketplaceUploadCancel => { + self.frontend_vm.marketplace_upload_open = false; + self.frontend_vm.marketplace_upload_origin_pack_id = None; + self.frontend_vm.marketplace_upload_target_name = None; + self.frontend_vm.marketplace_upload_packs.clear(); + self.frontend_vm.marketplace_upload_selected = None; + self.frontend_vm.marketplace_upload_submitting = false; + } + frontend::view_model::FrontendAction::MarketplaceUploadConfirm => { + if !self.frontend_vm.marketplace_signed_in + || self.frontend_vm.marketplace_upload_submitting + { + continue; + } + let Some(index) = self.frontend_vm.marketplace_upload_selected else { + continue; + }; + let Some(pack) = self + .frontend_vm + .marketplace_upload_packs + .get(index) + .cloned() + else { + continue; + }; + let origin_pack_id = + self.frontend_vm.marketplace_upload_origin_pack_id.clone(); + let Some(backend) = self.backend() else { + continue; + }; + self.frontend_vm.marketplace_upload_submitting = true; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .upload(pack.id, origin_pack_id) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceUpload(result)); + }); + } + frontend::view_model::FrontendAction::MarketplaceWithdrawRequest(index) => { + if self.frontend_vm.marketplace_signed_in + && index < self.frontend_vm.marketplace_mine_packs.len() + { + self.frontend_vm.marketplace_confirm_withdraw = Some(index); + } + } + frontend::view_model::FrontendAction::MarketplaceWithdrawCancel => { + self.frontend_vm.marketplace_confirm_withdraw = None; + } + frontend::view_model::FrontendAction::MarketplaceWithdrawConfirm => { + let Some(index) = self.frontend_vm.marketplace_confirm_withdraw.take() + else { + continue; + }; + let Some(pack) = self.marketplace_my_packs.get(index) else { + continue; + }; + let Some(backend) = self.backend() else { + continue; + }; + let id = pack.summary.id.clone(); + let lang = self.lang; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .services() + .marketplace + .delete(id) + .await + .map(|_| tr_l10n(lang, "marketplace.withdraw.success").to_string()) + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::MarketplaceWithdraw(result)); + }); + } + frontend::view_model::FrontendAction::MarketplaceAuthStart => { + self.frontend_vm.marketplace_oauth_open = true; + self.frontend_vm.marketplace_oauth_loading = true; + self.frontend_vm.marketplace_oauth_error = None; + self.start_marketplace_oauth(); + } + frontend::view_model::FrontendAction::MarketplaceAuthOpenBrowser => { + if !self.frontend_vm.marketplace_oauth_uri.is_empty() { + if let Err(error) = + open_external(&self.frontend_vm.marketplace_oauth_uri) + { + self.frontend_vm.marketplace_oauth_error = Some(error.to_string()); + } + } + } + frontend::view_model::FrontendAction::MarketplaceAuthCopyCode => { + if !self.frontend_vm.marketplace_oauth_user_code.is_empty() { + if let Err(error) = fcitx5_copy_to_clipboard( + &self.frontend_vm.marketplace_oauth_user_code, + ) { + self.frontend_vm.marketplace_oauth_error = Some(error.to_string()); + } + } + } + frontend::view_model::FrontendAction::MarketplaceAuthCancel => { + self.marketplace_oauth_poll_deadline = None; + self.marketplace_oauth_interval_secs = 5; + let flow_id = self.frontend_vm.marketplace_oauth_flow_id.take(); + self.frontend_vm.marketplace_oauth_open = false; + self.frontend_vm.marketplace_oauth_loading = false; + if let Some(backend) = self.backend() { + self.tokio.spawn(async move { + let _ = backend + .services() + .marketplace + .cancel_device_flow(flow_id) + .await; + }); + } + } 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)); + self.marketplace_query = query.clone(); + // Echo it back so the field never reverts while typing. + self.frontend_vm.marketplace_query = query; + // Debounced: `poll_marketplace_search` issues the request + // once typing pauses, exactly like the Tauri page. + self.schedule_marketplace_search(); } frontend::view_model::FrontendAction::MarketplaceCloseDetail => { self.frontend_vm.marketplace_selected = None; + self.frontend_vm.marketplace_detail_prompt = 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], - )) + // 详情里的安装按钮在完成后才恢复:把「安装中」状态 + // 记在宿主里,任务结束时无论成败都清掉。 + self.frontend_vm.marketplace_installing = Some(id.clone()); + let tx = self.tx.clone(); + let finished_id = id.clone(); + self.tokio.spawn(async move { + let result = + match backend.services().marketplace.install(id).await { + Ok(pack) => Ok(fmt_l10n( + lang, + "status.marketplace_installed", + &[&pack.name], + )), + Err(error) => Err(error.to_string()), + }; + let _ = tx.send(UiResult::MarketplaceInstallFinished { + id: finished_id, + result, + }); }); } } @@ -3573,38 +4687,224 @@ mod linux_app { } } } - frontend::view_model::FrontendAction::MarketplaceToggleLike(index) => { - if let Some(item) = self.marketplace_items.get(index) { - self.mutate_marketplace(MarketplaceMutation::Like(item.id.clone())); - } - } - 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::MarketplaceToggleLike(index) => { + if let Some(item) = self.marketplace_items.get(index) { + if let Some(backend) = self.backend() { + let id = item.id.clone(); + // Optimistic flip so the star reacts immediately. + let was_liked = self.marketplace_my_likes.contains(&id); + if was_liked { + self.marketplace_my_likes.retain(|liked| liked != &id); + } else { + self.marketplace_my_likes.push(id.clone()); + } + if let Some(pack) = + self.frontend_vm.marketplace_packs.get_mut(index) + { + pack.liked = !was_liked; + pack.likes = if was_liked { + pack.likes.saturating_sub(1) + } else { + pack.likes.saturating_add(1) + }; + } + let lang = self.lang; + let restore_id = id.clone(); + self.spawn(async move { + let result = + backend.services().marketplace.toggle_like(id).await?; + Ok(fmt_l10n( + lang, + "status.marketplace_like", + &[&result.like_count], + )) + }); + let _ = restore_id; + } + } + } + frontend::view_model::FrontendAction::MarketplaceSort(sort) => { + self.frontend_vm.marketplace_sort = sort; + self.load_marketplace(); + } + frontend::view_model::FrontendAction::QuickNoteToggle => { + self.toggle_quick_note(); + } + frontend::view_model::FrontendAction::QuickNoteShortcutHidden(hidden) => { + self.set_quick_note_shortcut_hidden(hidden); + } + frontend::view_model::FrontendAction::HistoryRefresh => { + self.history.request( + self.snapshot + .as_ref() + .map_or(0, |snapshot| snapshot.history_revision), + ); + self.load_history(); + self.frontend_vm.history_confirm = None; + } + frontend::view_model::FrontendAction::HistorySelect(index) => { + self.frontend_vm.history_selected = index; + } + frontend::view_model::FrontendAction::HistoryRequestClear => { + self.frontend_vm.history_confirm = + Some(frontend::view_model::HistoryConfirm::Clear); + } + frontend::view_model::FrontendAction::HistoryRequestDelete(id) => { + self.frontend_vm.history_confirm = + Some(frontend::view_model::HistoryConfirm::Delete(id)); + } + frontend::view_model::FrontendAction::HistoryCancelConfirm => { + self.frontend_vm.history_confirm = None; + } + frontend::view_model::FrontendAction::HistoryConfirmAction => { + match self.frontend_vm.history_confirm.take() { + Some(frontend::view_model::HistoryConfirm::Clear) => { + if let Some(backend) = self.backend() { + let lang = self.lang; + self.spawn(async move { + backend.clear_history()?; + Ok(tr_l10n(lang, "status.history_cleared").to_string()) + }); + } + } + Some(frontend::view_model::HistoryConfirm::Delete(id)) => { + if let Some(backend) = self.backend() { + let lang = self.lang; + self.spawn(async move { + backend.delete_history(&id)?; + Ok(tr_l10n(lang, "status.history_deleted").to_string()) + }); + } + } + None => {} + } + } + frontend::view_model::FrontendAction::HistoryPlay(index) => { + let Some(entry) = self.frontend_vm.history_entries.get(index) else { + return; + }; + let id = entry.id.clone(); + // Same clip again -> stop; otherwise start the new one. + let same = self + .history_clip + .as_ref() + .is_some_and(|(playing, _)| playing == &id); + self.history_clip = None; + if same { + return; + } + if let Some(backend) = self.backend() { + let data_dir = backend.config().data_dir.clone(); + match openless_linux_egui::read_recording_wav(&data_dir, &id) + .and_then(|wav| { + openless_linux_egui::recording_pcm(&wav).map(|pcm| pcm.to_vec()) + }) + .map_err(|error| error.to_string()) + .and_then(|pcm| openless_linux_egui::ClipPlayer::play(&pcm)) + { + Ok(player) => self.history_clip = Some((id, player)), + Err(error) => self.status = error, + } + } + } + frontend::view_model::FrontendAction::HistoryPauseToggle => { + if let Some((_, player)) = self.history_clip.as_ref() { + player.toggle_pause(); + } + } + frontend::view_model::FrontendAction::HistorySeek(ms) => { + if let Some((_, player)) = self.history_clip.as_ref() { + player.seek_ms(ms); + } + } + frontend::view_model::FrontendAction::HistoryRepolishOpen(index) => { + self.frontend_vm.history_selected = index; + self.frontend_vm.history_repolish_open = true; + } + frontend::view_model::FrontendAction::HistoryRepolishClose => { + self.frontend_vm.history_repolish_open = false; + } + frontend::view_model::FrontendAction::HistoryRepolish(index, pack_index) => { + if let (Some(backend), Some(entry)) = + (self.backend(), self.frontend_vm.history_entries.get(index)) + { + if !entry.raw_transcript.trim().is_empty() { + let request = openless_core::auxiliary::RepolishRequest { + raw_text: entry.raw_transcript.clone(), + style_pack_id: pack_index + .and_then(|i| self.style_packs.get(i)) + .map(|p| p.id.clone()), + front_app: entry.app_name.clone(), + }; + let id = entry.id.clone(); + let tx = self.tx.clone(); + self.frontend_vm.history_repolish_running = true; + self.frontend_vm.history_repolish_error = None; + self.tokio.spawn(async move { + let result = backend + .services() + .auxiliary + .repolish(request) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::HistoryRepolish { id, result }); + }); + } + } + } + frontend::view_model::FrontendAction::HistoryRetranscribe(index) => { + if let Some(backend) = self.backend() { + if let Some(entry) = self.frontend_vm.history_entries.get(index) { + let id = entry.id.clone(); + let data_dir = backend.config().data_dir.clone(); + let lang = self.lang; + self.spawn(async move { + let recording_id = id.clone(); + let wav = tokio::task::spawn_blocking(move || { + openless_linux_egui::read_recording_wav( + &data_dir, + &recording_id, + ) + }) + .await + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Internal, + error.to_string(), + ) + })? + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Persistence, + error.to_string(), + ) + })?; + let pcm = openless_linux_egui::recording_pcm(&wav) + .map_err(|error| { + BackendError::new( + openless_core::BackendErrorCode::Persistence, + error.to_string(), + ) + })? + .to_vec(); + let started = std::time::Instant::now(); + let result = backend + .services() + .auxiliary + .retranscribe_pcm(pcm) + .await + .map_err(|failure| failure.error)?; + let entry = backend.apply_history_retranscription( + &id, + result.text, + &result.asr, + started.elapsed().as_millis() as u64, + )?; + Ok(fmt_l10n(lang, "status.retranscribed", &[&entry.final_text])) + }); + } + } + } frontend::view_model::FrontendAction::HistoryExport(index) => { if let Some(backend) = self.backend() { if let Some(entry) = self.frontend_vm.history_entries.get(index) { @@ -3673,56 +4973,11 @@ mod linux_app { } } } - 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::VocabFilter(index) => { + self.frontend_vm.vocab_filter = index.min(2); } - 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::VocabSearch(query) => { + self.frontend_vm.vocab_query = query; } frontend::view_model::FrontendAction::VocabAddPhrase(phrase) => { if let Some(backend) = self.backend() { @@ -3758,15 +5013,28 @@ mod linux_app { } } } + frontend::view_model::FrontendAction::VocabRefresh => { + self.load_library(); + } 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()) + let tx = self.tx.clone(); + self.tokio.spawn(async move { + match backend.add_correction_rule(pattern, replacement) { + Ok(_) => { + let _ = tx.send(UiResult::Message( + tr_l10n(lang, "status.correction_saved").to_string(), + )); + let _ = tx.send(UiResult::LibraryRefresh); + } + Err(error) => { + let _ = tx.send(UiResult::Message(error.to_string())); + } + } }); } } @@ -3795,100 +5063,75 @@ mod linux_app { } } } - 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()))?; + if let Some(backend) = self.backend() { + if let Some(preset) = self.vocab_presets.get(index) { + let phrases = preset.phrases.clone(); + let name = preset.name.clone(); + let lang = self.lang; + self.spawn(async move { + for phrase in phrases { + backend.add_vocabulary( + phrase, + Some(fmt_l10n(lang, "status.from_preset", &[&name])), + )?; } - } - Ok("词汇预设已应用".into()) - }); + Ok(tr_l10n(lang, "status.preset_updated").to_string()) + }); + } } } - frontend::view_model::FrontendAction::VocabCreatePreset { - id, - name, - phrases, - } => { + frontend::view_model::FrontendAction::VocabCreatePreset { name, phrases } => { if let Some(backend) = self.backend() { + let lang = self.lang; self.spawn(async move { - let mut phrases: Vec = phrases - .split([',', ',', '、', '\n']) + let mut phrase_list: Vec = phrases + .split([',', ',', '\n']) .map(str::trim) .filter(|p| !p.is_empty()) .map(ToOwned::to_owned) .collect(); - phrases.sort(); - phrases.dedup(); + phrase_list.sort(); + phrase_list.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); + store.custom.push(openless_core::VocabPreset { + id: uuid::Uuid::new_v4().to_string(), + name: name.trim().to_string(), + phrases: phrase_list, + }); backend.save_vocabulary_presets(&store)?; - Ok("预设已保存".into()) + Ok(tr_l10n(lang, "status.preset_updated").to_string()) }); } } - frontend::view_model::FrontendAction::VocabDeletePreset(index) => { - if let (Some(backend), Some(preset)) = - (self.backend(), self.vocab_presets.get(index)) - { - let id = preset.id.clone(); + frontend::view_model::FrontendAction::StyleActivate(index) => { + let Some(pack) = self.style_packs.get(index) else { + return; + }; + let id = pack.id.clone(); + if self.frontend_vm.style_selection_workflow { + // Selection polish keeps its own active pack + // (`prefs.selection_polish_style_pack_id`). + if let Some(preferences) = self.preferences.as_mut() { + preferences.selection_polish_style_pack_id = id; + } + self.save_settings_if_dirty(); + } else if let Some(backend) = self.backend() { + let lang = self.lang; 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()) + backend.activate_style_pack(&id)?; + Ok(tr_l10n(lang, "status.style_updated").to_string()) }); } } - frontend::view_model::FrontendAction::StyleActivate(index) => { - self.activate_style_v2(index) + frontend::view_model::FrontendAction::StyleChooseIcon(index) => { + if let Some(pack) = self.style_packs.get(index) { + self.set_style_pack_icon_from_file(pack.id.clone()); + } } - 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()); + frontend::view_model::FrontendAction::StyleResetIcon(index) => { + if let Some(pack) = self.style_packs.get(index) { + self.reset_style_pack_icon(pack.id.clone()); } } frontend::view_model::FrontendAction::StyleExport(index) => { @@ -3938,107 +5181,631 @@ mod linux_app { } } } - frontend::view_model::FrontendAction::StyleEdit(index) => { - if let Some(pack) = self.style_packs.get(index).cloned() { - self.open_style_v2(pack); + frontend::view_model::FrontendAction::StyleEdit(index) => { + if let Some(pack) = self.style_packs.get(index).cloned() { + self.style_editor = Some(pack.clone()); + self.hydrate_style_editor(&pack, true); + self.load_marketplace_auth(); + } + } + frontend::view_model::FrontendAction::StylePublishMarketplace => { + if self.frontend_vm.style_editor_publishing + || self.frontend_vm.style_editor_builtin + || !self.frontend_vm.marketplace_signed_in + { + return; + } + let Some(mut pack) = self.style_editor.clone() else { + return; + }; + let needs_save = self.frontend_vm.style_editor_dirty; + if needs_save { + pack.name = self.frontend_vm.style_name.trim().to_string(); + pack.description = + self.frontend_vm.style_description.trim().to_string(); + pack.prompt = self.frontend_vm.style_prompt.clone(); + pack.selection_prompt = self.frontend_vm.style_selection_prompt.clone(); + pack.voice_edit_prompt = + self.frontend_vm.style_voice_edit_prompt.clone(); + pack.author = Some(self.frontend_vm.style_author.trim().to_string()) + .filter(|value| !value.is_empty()); + pack.version = self.frontend_vm.style_version.trim().to_string(); + pack.recommended_model = + Some(self.frontend_vm.style_model.trim().to_string()) + .filter(|value| !value.is_empty()); + pack.compatible_app_version = + Some(self.frontend_vm.style_compatible_version.trim().to_string()) + .filter(|value| !value.is_empty()); + pack.tags = self + .frontend_vm + .style_tags + .split([',', ',', '\n']) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect(); + pack.examples = self.frontend_vm.style_examples.clone(); + } + let exists = self.style_packs.iter().any(|item| item.id == pack.id); + let Some(backend) = self.backend() else { + return; + }; + self.frontend_vm.style_editor_publishing = true; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = async { + if needs_save { + if exists { + backend + .update_style_pack(pack.clone()) + .map_err(|error| error.to_string())?; + } else { + backend + .create_style_pack(pack.clone()) + .map_err(|error| error.to_string())?; + } + } + backend + .services() + .marketplace + .upload(pack.id.clone(), pack.origin_pack_id.clone()) + .await + .map(|uploaded| uploaded.message) + .map_err(|error| error.to_string()) + } + .await; + let _ = tx.send(UiResult::MarketplacePublish(result)); + }); + } + frontend::view_model::FrontendAction::StyleRevertDraft => { + if let Some(pack) = self.style_editor.clone() { + self.hydrate_style_editor(&pack, true); + } + } + frontend::view_model::FrontendAction::StyleResetBuiltin => { + let id = self.frontend_vm.style_editor_id.clone(); + if id.is_empty() { + return; + } + if let Some(backend) = self.backend() { + let lang = self.lang; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + match backend.reset_builtin_style_pack(&id) { + // Core publishes `StylePacksChanged`, so the host + // reloads the list (and the drawer) by itself. + Ok(_) => { + let _ = tx.send(UiResult::Message( + tr_l10n(lang, "style.pack.resetBuiltin").to_string(), + )); + } + Err(error) => { + let _ = tx.send(UiResult::Message(error.to_string())); + } + } + }); + } + } + frontend::view_model::FrontendAction::StyleDeleteImported => { + let id = self.frontend_vm.style_editor_id.clone(); + if id.is_empty() { + return; + } + if let Some(backend) = self.backend() { + let lang = self.lang; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + match backend.remove_style_pack(&id) { + Ok(_) => { + let _ = tx.send(UiResult::Message( + tr_l10n(lang, "style.pack.deleteImported").to_string(), + )); + } + Err(error) => { + let _ = tx.send(UiResult::Message(error.to_string())); + } + } + }); + } + self.style_editor = None; + self.frontend_vm.style_editor_open = false; + } + frontend::view_model::FrontendAction::StyleSaveEditor { + name, + description, + prompt, + selection_prompt, + voice_edit_prompt, + tags, + author, + version, + model, + compatible_version, + examples, + } => { + if let Some(mut pack) = self.style_editor.take() { + pack.name = name.trim().to_string(); + pack.description = description.trim().to_string(); + pack.prompt = prompt; + pack.selection_prompt = selection_prompt; + pack.voice_edit_prompt = voice_edit_prompt; + pack.author = Some(author.trim().to_string()).filter(|v| !v.is_empty()); + if !version.trim().is_empty() { + pack.version = version.trim().to_string(); + } + pack.recommended_model = + Some(model.trim().to_string()).filter(|v| !v.is_empty()); + pack.compatible_app_version = + Some(compatible_version.trim().to_string()) + .filter(|v| !v.is_empty()); + pack.examples = examples; + pack.tags = tags + .split([',', ',', '\n']) + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if let Some(backend) = self.backend() { + let exists = self.style_packs.iter().any(|p| p.id == pack.id); + let lang = self.lang; + // Keep the stored pack so the drawer stays open and + // can report dirty again after the next edit. + self.style_editor = Some(pack.clone()); + self.frontend_vm.style_editor_saved = Some(pack.clone()); + self.frontend_vm.style_editor_dirty = false; + self.spawn_reporting(async move { + let saved = if exists { + backend.update_style_pack(pack)? + } else { + backend.create_style_pack(pack)? + }; + Ok(fmt_l10n(lang, "status.style_saved", &[&saved.name])) + }); + } + } + } + frontend::view_model::FrontendAction::StyleCloseEditor => { + self.style_editor = None; + self.frontend_vm.style_editor_open = false; + } + frontend::view_model::FrontendAction::StyleNewPack => { + let pack = openless_core::StylePack { + id: uuid::Uuid::new_v4().to_string(), + name: tr_l10n(self.lang, "lbl.new_style_default").to_string(), + version: "1.0.0".to_string(), + ..Default::default() + }; + self.style_editor = Some(pack.clone()); + self.hydrate_style_editor(&pack, false); + self.frontend_vm.style_editor_saved = None; + self.frontend_vm.style_editor_dirty = true; + } + 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])) + }); + } + } + frontend::view_model::FrontendAction::SelectionAskToggleHistory => { + if let Some(preferences) = self.preferences.as_mut() { + preferences.qa_save_history = !preferences.qa_save_history; + + self.frontend_vm.qa_save_history = preferences.qa_save_history; + self.save_settings_if_dirty(); + } + } + frontend::view_model::FrontendAction::TranslationToggleLanguage(language) => { + if let Some(preferences) = self.preferences.as_mut() { + match preferences + .working_languages + .iter() + .position(|value| value == &language) + { + Some(index) => { + preferences.working_languages.remove(index); + } + None => preferences.working_languages.push(language), + } + } + self.save_settings_if_dirty(); + } + frontend::view_model::FrontendAction::TranslationSetTarget(language) => { + if let Some(preferences) = self.preferences.as_mut() { + preferences.translation_target_language = language; + } + self.save_settings_if_dirty(); + } + frontend::view_model::FrontendAction::SettingsToggle(field) => { + self.apply_settings_toggle(field); + } + frontend::view_model::FrontendAction::SettingsCombo(field, index) => { + self.apply_settings_combo(field, index); + } + frontend::view_model::FrontendAction::SettingsText(field, text) => { + self.apply_settings_text(field, text); + } + frontend::view_model::FrontendAction::SettingsAction(field) => { + self.apply_settings_action(field); + } + frontend::view_model::FrontendAction::SettingsSection(section) => { + self.frontend_vm.settings_section = section; + } + frontend::view_model::FrontendAction::SettingsServicesView(view) => { + self.frontend_vm.services_view = view.min(3); + let kind = if view == 1 { + openless_core::ChannelKind::Asr + } else { + openless_core::ChannelKind::Llm + }; + if self.settings_channel_kind != kind { + self.settings_channel_kind = kind; + self.provider_kind = kind; + // The editor belongs to one channel kind: switching the + // AI-services tab must not carry it across. + self.close_provider_editor(); + self.selected_channel_id = None; + self.load_settings_channels(); + self.load_service_configured(); + self.load_providers(kind); + } else if self.settings_channels.is_empty() { + self.provider_kind = kind; + self.load_settings_channels(); + self.load_service_configured(); + self.load_providers(kind); + } + } + frontend::view_model::FrontendAction::SettingsChannelFormOpen(open) => { + self.frontend_vm.channel_form_open = open; + if open { + self.frontend_vm.channel_form_name.clear(); + self.frontend_vm.channel_provider_index = 0; + } + } + frontend::view_model::FrontendAction::SettingsChannelProvider(index) => { + self.frontend_vm.channel_provider_index = index; + } + frontend::view_model::FrontendAction::SettingsChannelName(name) => { + self.frontend_vm.channel_form_name = name; + } + frontend::view_model::FrontendAction::SettingsChannelCreate => { + let kind = self.settings_channel_kind; + let provider_type = self + .frontend_vm + .channel_providers + .get(self.frontend_vm.channel_provider_index) + .map(|provider| provider.provider_type.clone()); + let name = self.frontend_vm.channel_form_name.trim().to_string(); + if let (Some(backend), Some(provider_type)) = + (self.backend(), provider_type) + { + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = backend + .create_channel(kind, provider_type, name) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::ChannelCreated { kind, result }); + }); + self.frontend_vm.channel_form_open = false; + } + } + frontend::view_model::FrontendAction::ShortcutMenu(field) => { + self.frontend_vm.shortcut_menu = field; + if field.is_some() { + // 打开菜单即退出录制(Tauri 点「录制快捷键」时同时收起菜单)。 + self.frontend_vm.shortcut_recording = None; + } + } + frontend::view_model::FrontendAction::ShortcutRecording(field) => { + self.frontend_vm.shortcut_pending_modifier = None; + self.frontend_vm.shortcut_recording = field; + if field.is_some() { + self.frontend_vm.shortcut_menu = None; + } + self.frontend_vm.settings_notice = None; + } + frontend::view_model::FrontendAction::ShortcutCaptured( + field, + primary, + modifiers, + ) => { + self.apply_shortcut_captured(field, primary, modifiers); + } + frontend::view_model::FrontendAction::ShortcutDisable(field) => { + self.apply_shortcut_disable(field); + } + frontend::view_model::FrontendAction::StyleHotkeyDraft(open) => { + self.frontend_vm.style_hotkey_draft_open = open; + if open { + let used: Vec = self + .frontend_vm + .settings + .style_pack_hotkeys + .iter() + .map(|row| row.pack_id.clone()) + .collect(); + self.frontend_vm.style_hotkey_draft_pack = self + .frontend_vm + .style_packs + .iter() + .position(|pack| !used.contains(&pack.id)) + .unwrap_or(0); + } else { + self.frontend_vm.shortcut_recording = None; + } + } + frontend::view_model::FrontendAction::StyleHotkeyDraftPack(index) => { + self.frontend_vm.style_hotkey_draft_pack = index; + } + frontend::view_model::FrontendAction::StyleHotkeyRemove(index) => { + self.apply_style_hotkey_remove(index); + } + frontend::view_model::FrontendAction::StyleHotkeyRepack(index, pack_index) => { + self.apply_style_hotkey_repack(index, pack_index); + } + frontend::view_model::FrontendAction::SettingsChannelSelect(index) => { + self.open_provider_editor(index); + } + frontend::view_model::FrontendAction::SettingsChannelMove { index, delta } => { + let kind = self.settings_channel_kind; + let mut ids: Vec = self + .settings_channels + .iter() + .map(|channel| channel.id.clone()) + .collect(); + let target = index as isize + delta; + if target >= 0 && (target as usize) < ids.len() { + ids.swap(index, target as usize); + if let Some(backend) = self.backend() { + let lang = self.lang; + self.spawn(async move { + backend.reorder_channels(kind, ids).await?; + Ok(tr_l10n(lang, "status.channel_reordered").to_string()) + }); + self.load_settings_channels(); + } + } + } + frontend::view_model::FrontendAction::SettingsChannelProviderType { + index, + provider_type, + } => { + let kind = self.settings_channel_kind; + let id = self + .settings_channels + .get(index) + .map(|channel| channel.id.clone()); + if let (Some(backend), Some(id)) = (self.backend(), id) { + let lang = self.lang; + self.spawn(async move { + backend + .set_channel_provider_type(kind, id, provider_type) + .await?; + Ok(tr_l10n(lang, "status.provider_type_updated").to_string()) + }); + // The descriptor changed with the provider type: the + // editor must re-read it instead of keeping old fields. + self.close_provider_editor(); + self.selected_channel_id = None; + self.load_settings_channels(); + self.load_providers(kind); + } + } + frontend::view_model::FrontendAction::SettingsChannelActivate(index) => { + let kind = self.settings_channel_kind; + let id = self + .settings_channels + .get(index) + .map(|channel| channel.id.clone()); + if let (Some(backend), Some(id)) = (self.backend(), id) { + let lang = self.lang; + self.spawn(async move { + backend.set_active_provider(provider_slot(kind), id).await?; + Ok(tr_l10n(lang, "status.channel_active").to_string()) + }); + self.load_settings_channels(); + self.load_service_configured(); } } - frontend::view_model::FrontendAction::StyleSaveEditor(prompt) => { - self.save_style_v2(prompt) + frontend::view_model::FrontendAction::SettingsProviderField(field, value) => { + if let Some(form) = self.provider_editor_form.as_mut() { + match field { + frontend::view_model::SettingsProviderField::Name => { + form.name = value + } + frontend::view_model::SettingsProviderField::Endpoint => { + form.endpoint = value + } + frontend::view_model::SettingsProviderField::Model => { + form.model = value + } + frontend::view_model::SettingsProviderField::ResourceId => { + form.resource_id = value + } + frontend::view_model::SettingsProviderField::AuthMode => { + form.auth_mode = value + } + frontend::view_model::SettingsProviderField::PrimarySecret => { + form.primary_secret = value + } + frontend::view_model::SettingsProviderField::SecondarySecret => { + form.secondary_secret = value + } + } + } } - frontend::view_model::FrontendAction::StyleCloseEditor => { - self.style_editor = None; - self.frontend_vm.style_editor_open = false; + frontend::view_model::FrontendAction::SettingsProviderSave => { + if let Some(editor) = self.editor_from_form() { + if let Some(backend) = self.backend() { + let lang = self.lang; + self.spawn(async move { + save_provider_editor(backend, editor).await?; + Ok(tr_l10n(lang, "status.channel_saved").to_string()) + }); + // Secrets are write-only: drop the drafts once Core + // has them so they are not kept in egui state. + if let Some(form) = self.provider_editor_form.as_mut() { + form.primary_secret.clear(); + form.secondary_secret.clear(); + } + self.load_settings_channels(); + self.load_service_configured(); + } + } } - 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() - }) + frontend::view_model::FrontendAction::SettingsProviderClearSecrets => { + if let Some(editor) = self.editor_from_form() { + if let Some(backend) = self.backend() { + let lang = self.lang; + self.spawn(async move { + clear_provider_secrets(Arc::clone(&backend), &editor).await?; + Ok(tr_l10n(lang, "status.secret_cleared").to_string()) + }); + if let Some(form) = self.provider_editor_form.as_mut() { + form.primary_secret.clear(); + form.secondary_secret.clear(); + } + self.load_service_configured(); + } + } } - frontend::view_model::FrontendAction::StyleImport => { - if let Some(backend) = self.backend() { + frontend::view_model::FrontendAction::SettingsProviderModels => { + let kind = self.settings_channel_kind; + let channel_id = self + .provider_editor_form + .as_ref() + .map(|form| form.channel_id.clone()); + if let (Some(form), Some(channel_id)) = + (self.provider_editor_form.as_mut(), channel_id) + { + form.models_loading = true; + self.request_provider_models(kind, channel_id); + } + } + frontend::view_model::FrontendAction::SettingsProviderModelSelected(model) => { + // Tauri `applyModel`:选中即写入模型字段并立刻落地凭据,不需要 + // 再按一次保存;失败时错误直接顶到状态栏。 + let kind = self.settings_channel_kind; + let channel_id = self + .provider_editor_form + .as_ref() + .map(|form| form.channel_id.clone()); + if let Some(form) = self.provider_editor_form.as_mut() { + form.model = model.clone(); + form.custom_model = false; + } + if let (Some(backend), Some(channel_id)) = (self.backend(), channel_id) { 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])) + write_or_remove_provider_value( + &backend, + kind, + &channel_id, + model_account(kind), + &model, + ) + .await?; + Ok(fmt_l10n(lang, "settings.providers.modelSaved", &[&model])) }); } } - 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 { - languages.push(language); + frontend::view_model::FrontendAction::SettingsProviderModelCustom(custom) => { + if let Some(form) = self.provider_editor_form.as_mut() { + form.custom_model = custom; } - self.save_field_edits(std::collections::BTreeMap::from([( - "/workingLanguages".into(), - serde_json::json!(languages), - )])); - } - frontend::view_model::FrontendAction::TranslationSetTarget(language) => { - self.save_field_edits(std::collections::BTreeMap::from([( - "/translationTargetLanguage".into(), - serde_json::json!(language), - )])); - } - frontend::view_model::FrontendAction::SettingsToggle(field) => { - self.apply_settings_toggle(field); } - frontend::view_model::FrontendAction::SettingsCombo(field, index) => { - self.apply_settings_combo(field, index); + frontend::view_model::FrontendAction::SettingsProviderModelsUrl => { + if let Some(form) = self.provider_editor_form.as_ref() { + if let Some(url) = form.models_url.clone() { + if let Err(error) = open_external(&url) { + log::warn!("provider models url: {error}"); + } + } + } } - frontend::view_model::FrontendAction::SettingsText(field, text) => { - self.apply_settings_text(field, text); + frontend::view_model::FrontendAction::SettingsProviderClose => { + self.close_provider_editor(); } - frontend::view_model::FrontendAction::SettingsAction(field) => { - self.apply_settings_action(field); + frontend::view_model::FrontendAction::SettingsChannelToggle(index) => { + let kind = self.settings_channel_kind; + let target = self + .settings_channels + .get(index) + .map(|channel| (channel.id.clone(), channel.enabled)); + if let (Some(backend), Some((id, enabled))) = (self.backend(), target) { + let lang = self.lang; + self.spawn(async move { + backend.set_channel_enabled(kind, id, !enabled).await?; + Ok(tr_l10n(lang, "status.channel_enabled").to_string()) + }); + self.load_settings_channels(); + self.load_service_configured(); + } } - frontend::view_model::FrontendAction::SettingsSection(section) => { - self.frontend_vm.settings_section = section; + frontend::view_model::FrontendAction::SettingsChannelValidate(index) => { + let kind = self.settings_channel_kind; + let id = self + .settings_channels + .get(index) + .map(|channel| channel.id.clone()); + if let (Some(backend), Some(id)) = (self.backend(), id) { + let lang = self.lang; + self.spawn(async move { + validate_provider_channel(lang, backend, kind, id).await + }); + self.load_settings_channels(); + self.load_service_configured(); + } } - frontend::view_model::FrontendAction::SettingsNotice(msg) => { - self.frontend_vm.settings_notice = Some(msg); + frontend::view_model::FrontendAction::SettingsChannelDelete(index) => { + let kind = self.settings_channel_kind; + let id = self + .settings_channels + .get(index) + .map(|channel| channel.id.clone()); + if let (Some(backend), Some(id)) = (self.backend(), id) { + let lang = self.lang; + self.spawn(async move { + backend.delete_channel(kind, id).await?; + Ok(tr_l10n(lang, "status.channel_deleted").to_string()) + }); + self.load_settings_channels(); + self.load_service_configured(); + } } 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); + self.frontend_vm.marketplace_detail_prompt = None; // Load real detail from backend, not just index. if let Some(item) = self.marketplace_items.get(index) { if let Some(backend) = self.backend() { @@ -4061,81 +5828,42 @@ mod linux_app { } } - impl eframe::App for OpenLessEguiApp { - fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { - egui::Color32::TRANSPARENT.to_normalized_gamma_f32() - } + /// UI 窗口进程的地址开关。 + const UI_CLIENT_FLAG: &str = "--ui-client"; + const UI_SOCKET_FLAG: &str = "--ui-socket"; - 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()) - }); - } - } + /// 解析 `--ui-client --ui-socket `。普通启动(宿主)返回 `None`。 + fn ui_client_socket(args: &[String]) -> Option { + if !args.iter().any(|arg| arg == UI_CLIENT_FLAG) { + return None; + } + let index = args.iter().position(|arg| arg == UI_SOCKET_FLAG)?; + args.get(index + 1).map(std::path::PathBuf::from) + } - // 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); - } + /// 单实例锁的获取结果。 + enum BrokerAcquisition { + Primary(SingleInstanceBroker), + /// 已有实例接管了本次启动意图,本进程应当直接退出。 + Forwarded, + } - ctx.request_repaint_after(Duration::from_millis(50)); + /// 常规启动抢锁(抢不到就把意图转发给已有实例 —— 由它把主窗口推出来)。 + fn acquire_broker( + runtime_dir: &std::path::Path, + args: &[String], + ) -> Result { + let lock = runtime_dir.join("openless.lock"); + let socket = runtime_dir.join("openless.sock"); + match SingleInstanceBroker::acquire_or_forward( + &lock, + &socket, + LinuxLaunchIntent::from_args(args), + ) + .map_err(|error| error.to_string())? + { + SingleInstanceRole::Primary(broker) => Ok(BrokerAcquisition::Primary(broker)), + SingleInstanceRole::Forwarded => Ok(BrokerAcquisition::Forwarded), } } @@ -4147,27 +5875,41 @@ 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 overview_activity_day(day: DailyActivity) -> frontend::view_model::OverviewActivityDay { + frontend::view_model::OverviewActivityDay { + date: day.date, + count: day.count, + chars: day.chars, + duration_ms: day.duration_ms, + } + } + + fn overview_heatmap_day(day: DailyActivity) -> frontend::view_model::OverviewHeatmapDay { + frontend::view_model::OverviewHeatmapDay { + date: day.date, + count: day.count, + } + } + + /// Core polish mode -> frontend display enum. + fn overview_mode(mode: openless_core::PolishMode) -> frontend::view_model::OverviewMode { + match mode { + openless_core::PolishMode::Raw => frontend::view_model::OverviewMode::Raw, + openless_core::PolishMode::Light => frontend::view_model::OverviewMode::Light, + openless_core::PolishMode::Structured => frontend::view_model::OverviewMode::Structured, + openless_core::PolishMode::Formal => frontend::view_model::OverviewMode::Formal, + } + } + + /// Localized label for a polish mode (used as the history pill fallback). + fn polish_mode_label(lang: Lang, mode: openless_core::PolishMode) -> &'static str { + match mode { + openless_core::PolishMode::Raw => tr_l10n(lang, "overview.mode_raw"), + openless_core::PolishMode::Light => tr_l10n(lang, "overview.mode_light"), + openless_core::PolishMode::Structured => tr_l10n(lang, "overview.mode_structured"), + openless_core::PolishMode::Formal => tr_l10n(lang, "overview.mode_formal"), } } @@ -4199,6 +5941,31 @@ mod linux_app { } } + /// Localized provider name from `settings.providers.presets.`. + /// Falls back to the raw label id when the catalog has no entry, so a + /// missing translation never leaks an i18n key into the UI. + fn localized_provider_label( + lang: Lang, + kind: openless_core::ChannelKind, + provider_type: &str, + ) -> String { + let label_key = provider_label_key(kind, provider_type); + let key = format!("settings.providers.presets.{label_key}"); + let text = fmt_l10n(lang, &key, &[]); + if text == key { + label_key + } else { + text + } + } + + /// i18n lookup id for a provider type (falls back to the raw type id). + fn provider_label_key(kind: openless_core::ChannelKind, provider_type: &str) -> String { + openless_core::provider_rules::provider_descriptor(provider_kind(kind), provider_type) + .map(|descriptor| descriptor.label_key) + .unwrap_or_else(|| provider_type.to_string()) + } + fn model_account(kind: openless_core::ChannelKind) -> &'static str { match kind { openless_core::ChannelKind::Asr => openless_core::credentials::ASR_MODEL_ACCOUNT, @@ -4206,6 +5973,27 @@ mod linux_app { } } + /// 「可用模型」按钮的目标:当前 endpoint 命中某个预设、且该预设带文档页时返回它 + /// (Tauri `descriptor.endpointPresets.find(matchesEndpointPreset)?.modelsUrl`)。 + /// 有文档页的服务商(例如火山方舟)不提供 `/models`,只能引导用户去看文档。 + fn provider_models_url( + descriptor: &openless_core::ProviderDescriptor, + endpoint: &str, + ) -> Option { + let endpoint = if endpoint.trim().is_empty() { + descriptor.default_endpoint.as_deref().unwrap_or("") + } else { + endpoint + }; + descriptor + .endpoint_presets + .iter() + .find(|preset| { + openless_core::provider_rules::matches_endpoint_preset(endpoint, &preset.endpoint) + }) + .and_then(|preset| preset.models_url.clone()) + } + fn api_key_account(kind: openless_core::ChannelKind) -> &'static str { match kind { openless_core::ChannelKind::Asr => openless_core::credentials::ASR_API_KEY_ACCOUNT, @@ -4213,6 +6001,25 @@ mod linux_app { } } + /// Core's `AuthRequirement` decides which inputs the editor renders. The + /// host maps it to a render hint and keeps validating through Core. + fn settings_provider_auth( + requirement: openless_core::AuthRequirement, + ) -> frontend::view_model::SettingsProviderAuth { + use frontend::view_model::SettingsProviderAuth as Ui; + use openless_core::AuthRequirement as Core; + match requirement { + Core::None => Ui::None, + Core::Volcengine => Ui::Volcengine, + Core::Xfyun => Ui::Xfyun, + Core::OAuth => Ui::OAuth, + Core::TencentCloud => Ui::Other, + Core::ApiKey | Core::EndpointModelOptionalApiKey | Core::ApiKeyUnlessCustomEndpoint => { + Ui::ApiKey + } + } + } + fn provider_credential_key( kind: openless_core::ChannelKind, channel_id: &str, @@ -4225,35 +6032,6 @@ mod linux_app { ) } - fn provider_descriptor_label(descriptor: &openless_core::ProviderDescriptor) -> String { - format!( - "{} ({})", - descriptor.label_key, - descriptor.provider_type.as_str() - ) - } - - 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 => { - "auth.endpoint_model_optional" - } - openless_core::AuthRequirement::ApiKeyUnlessCustomEndpoint => { - "auth.api_key_unless_custom" - } - 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( panel: &ProviderPanel, channel_id: &str, @@ -4276,234 +6054,25 @@ mod linux_app { provider_kind(panel.kind), &channel.provider_type, ) - })?; - Some((channel, descriptor)) - } - - async fn read_provider_value( - backend: &openless_core::OpenLessBackend, - kind: openless_core::ChannelKind, - channel_id: &str, - account: &str, - ) -> Result, BackendError> { - backend - .read_credential(provider_credential_key(kind, channel_id, account)?) - .await - .map(|value| value.map(openless_core::SecretValue::into_exposed)) - } - - async fn load_provider_editor( - backend: Arc, - kind: openless_core::ChannelKind, - channel: openless_core::ChannelSummary, - descriptor: openless_core::ProviderDescriptor, - ) -> Result { - let endpoint = read_provider_value(&backend, kind, &channel.id, endpoint_account(kind)) - .await? - .or_else(|| descriptor.default_endpoint.clone()) - .unwrap_or_default(); - let model = read_provider_value(&backend, kind, &channel.id, model_account(kind)) - .await? - .or_else(|| descriptor.default_model.clone()) - .unwrap_or_default(); - let volcengine_service = - if descriptor.auth_requirement == openless_core::AuthRequirement::Volcengine { - read_provider_value( - &backend, - kind, - &channel.id, - openless_core::credentials::VOLCENGINE_SERVICE_ACCOUNT, - ) - .await? - .unwrap_or_else(|| "standard".to_string()) - } else { - String::new() - }; - let (auth_mode, resource_id) = - if descriptor.auth_requirement == openless_core::AuthRequirement::Volcengine { - ( - read_provider_value( - &backend, - kind, - &channel.id, - openless_core::credentials::VOLCENGINE_AUTH_MODE_ACCOUNT, - ) - .await? - .unwrap_or_else(|| "app_id_token".to_string()), - read_provider_value( - &backend, - kind, - &channel.id, - openless_core::credentials::VOLCENGINE_RESOURCE_ID_ACCOUNT, - ) - .await? - .unwrap_or_default(), - ) - } else { - (String::new(), String::new()) - }; - Ok(ProviderEditor { - kind, - name: channel.name.clone(), - channel, - descriptor, - endpoint, - model, - volcengine_service, - auth_mode, - resource_id, - primary_secret: String::new(), - secondary_secret: String::new(), - }) - } - - fn secret_edit(ui: &mut egui::Ui, label: &str, value: &mut String) { - ui.horizontal(|ui| { - ui.label(label); - ui.add(egui::TextEdit::singleline(value).password(true)); - }); + })?; + Some((channel, descriptor)) } - 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(tr_l10n(lang, "providers.no_cloud_note")); - } - openless_core::AuthRequirement::OAuth => { - ui.label(tr_l10n(lang, "providers.oauth_note")); - } - openless_core::AuthRequirement::Volcengine => { - let previous_api_key = - editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key"; - egui::ComboBox::from_id_salt("volcengine-service") - .selected_text(if editor.volcengine_service == "agent_plan" { - "Agent Plan" - } else { - "普通服务" - }) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut editor.volcengine_service, - "standard".to_string(), - "普通服务", - ); - ui.selectable_value( - &mut editor.volcengine_service, - "agent_plan".to_string(), - "Agent Plan", - ); - }); - if editor.volcengine_service != "agent_plan" { - egui::ComboBox::from_id_salt("volcengine-auth-mode") - .selected_text(&editor.auth_mode) - .show_ui(ui, |ui| { - ui.selectable_value( - &mut editor.auth_mode, - "app_id_token".to_string(), - "APP ID + Access Token", - ); - ui.selectable_value( - &mut editor.auth_mode, - "api_key".to_string(), - "API Key", - ); - }); - } - let api_key = - editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key"; - if api_key != previous_api_key { - // Input buffers change meaning; persisted credential slots remain untouched. - editor.primary_secret.clear(); - editor.secondary_secret.clear(); - } - if editor.volcengine_service == "agent_plan" { - ui.label("使用 Agent Plan 专属 API Key;普通服务请使用单独渠道。"); - } - if editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key" { - secret_edit(ui, "API Key", &mut editor.primary_secret); - } else { - secret_edit(ui, "APP ID", &mut editor.primary_secret); - secret_edit(ui, "Access Token", &mut editor.secondary_secret); - } - ui.horizontal(|ui| { - ui.label("Resource ID"); - ui.text_edit_singleline(&mut editor.resource_id); - }); - ui.horizontal(|ui| { - ui.label("Model"); - ui.text_edit_singleline(&mut editor.model); - }); - } - openless_core::AuthRequirement::Xfyun => { - secret_edit(ui, "AppID", &mut editor.primary_secret); - secret_edit(ui, "API Key", &mut editor.secondary_secret); - } - _ => { - secret_edit( - ui, - tr_l10n(lang, "providers.api_key_hint"), - &mut editor.primary_secret, - ); - let mut endpoint_read_only = false; - if editor.kind == openless_core::ChannelKind::Llm - && editor.channel.provider_type == "ark" - { - let presets = editor - .descriptor - .default_endpoint - .as_deref() - .map(|endpoint| ("火山方舟", endpoint)) - .into_iter() - .chain( - editor - .descriptor - .endpoint_presets - .iter() - .map(|preset| (preset.name.as_str(), preset.endpoint.as_str())), - ) - .collect::>(); - let selected = presets - .iter() - .find(|(_, endpoint)| { - openless_core::provider_rules::matches_endpoint_preset( - &editor.endpoint, - endpoint, - ) - }) - .map(|(label, _)| *label); - endpoint_read_only = selected.is_some(); - egui::ComboBox::from_id_salt("ark-service") - .selected_text(selected.unwrap_or("自定义")) - .show_ui(ui, |ui| { - for (label, endpoint) in presets { - if ui - .selectable_label(selected == Some(label), label) - .clicked() - { - editor.endpoint = endpoint.to_string(); - endpoint_read_only = true; - } - } - }); - } - ui.horizontal(|ui| { - ui.label("Endpoint"); - ui.add( - egui::TextEdit::singleline(&mut editor.endpoint) - .interactive(!endpoint_read_only), - ); - }); - ui.horizontal(|ui| { - ui.label("Model"); - ui.text_edit_singleline(&mut editor.model); - }); - } - } + async fn read_provider_value( + backend: &openless_core::OpenLessBackend, + kind: openless_core::ChannelKind, + channel_id: &str, + account: &str, + ) -> Result, BackendError> { + backend + .read_credential(provider_credential_key(kind, channel_id, account)?) + .await + .map(|value| value.map(openless_core::SecretValue::into_exposed)) } + /// Write a non-secret value (endpoint/model/resource id/auth mode), or drop + /// it when the field was cleared: an empty string must not be stored as a + /// credential that then reads back as "configured". async fn write_or_remove_provider_value( backend: &openless_core::OpenLessBackend, kind: openless_core::ChannelKind, @@ -4522,6 +6091,8 @@ mod linux_app { Ok(()) } + /// Secrets are write-only: an empty input means "keep the stored key", not + /// "erase it" — erasing has its own explicit action. async fn write_secret_if_entered( backend: &openless_core::OpenLessBackend, kind: openless_core::ChannelKind, @@ -4542,13 +6113,13 @@ mod linux_app { Ok(()) } + /// Persist the editor through Core: rename, then the credential schema that + /// matches the selected `ProviderDescriptor`. Account names are Core's wire + /// schema; which of them is required stays in Core, never in this form. async fn save_provider_editor( backend: Arc, editor: ProviderEditor, ) -> Result<(), BackendError> { - // Account names are the stable credential wire schema exported by - // Core. Defaults and required/optional semantics stay in the selected - // ProviderDescriptor and ProviderService, never in this Host form. let channel_id = editor.channel.id.as_str(); backend .rename_channel(editor.kind, channel_id.to_string(), editor.name) @@ -4560,24 +6131,24 @@ mod linux_app { &backend, editor.kind, channel_id, - openless_core::credentials::VOLCENGINE_SERVICE_ACCOUNT, - &editor.volcengine_service, + openless_core::credentials::VOLCENGINE_AUTH_MODE_ACCOUNT, + &editor.auth_mode, ) .await?; write_or_remove_provider_value( &backend, editor.kind, channel_id, - openless_core::credentials::VOLCENGINE_AUTH_MODE_ACCOUNT, - &editor.auth_mode, + openless_core::credentials::VOLCENGINE_RESOURCE_ID_ACCOUNT, + &editor.resource_id, ) .await?; write_or_remove_provider_value( &backend, editor.kind, channel_id, - openless_core::credentials::VOLCENGINE_RESOURCE_ID_ACCOUNT, - &editor.resource_id, + openless_core::credentials::VOLCENGINE_SERVICE_ACCOUNT, + &editor.volcengine_service, ) .await?; write_or_remove_provider_value( @@ -4588,7 +6159,7 @@ mod linux_app { &editor.model, ) .await?; - if editor.volcengine_service == "agent_plan" || editor.auth_mode == "api_key" { + if editor.auth_mode == "api_key" { write_secret_if_entered( &backend, editor.kind, @@ -4664,6 +6235,7 @@ mod linux_app { Ok(()) } + /// Drop every credential of the selected descriptor shape for one channel. async fn clear_provider_secrets( backend: Arc, editor: &ProviderEditor, @@ -4693,6 +6265,71 @@ mod linux_app { Ok(()) } + async fn load_provider_editor( + backend: Arc, + kind: openless_core::ChannelKind, + channel: openless_core::ChannelSummary, + descriptor: openless_core::ProviderDescriptor, + ) -> Result { + let endpoint = read_provider_value(&backend, kind, &channel.id, endpoint_account(kind)) + .await? + .or_else(|| descriptor.default_endpoint.clone()) + .unwrap_or_default(); + let model = read_provider_value(&backend, kind, &channel.id, model_account(kind)) + .await? + .or_else(|| descriptor.default_model.clone()) + .unwrap_or_default(); + let volcengine_service = + if descriptor.auth_requirement == openless_core::AuthRequirement::Volcengine { + read_provider_value( + &backend, + kind, + &channel.id, + openless_core::credentials::VOLCENGINE_SERVICE_ACCOUNT, + ) + .await? + .unwrap_or_else(|| "standard".to_string()) + } else { + String::new() + }; + let (auth_mode, resource_id) = + if descriptor.auth_requirement == openless_core::AuthRequirement::Volcengine { + ( + read_provider_value( + &backend, + kind, + &channel.id, + openless_core::credentials::VOLCENGINE_AUTH_MODE_ACCOUNT, + ) + .await? + .unwrap_or_else(|| "app_id_token".to_string()), + read_provider_value( + &backend, + kind, + &channel.id, + openless_core::credentials::VOLCENGINE_RESOURCE_ID_ACCOUNT, + ) + .await? + .unwrap_or_default(), + ) + } else { + (String::new(), String::new()) + }; + Ok(ProviderEditor { + kind, + name: channel.name.clone(), + channel, + descriptor, + endpoint, + model, + volcengine_service, + auth_mode, + resource_id, + primary_secret: String::new(), + secondary_secret: String::new(), + }) + } + async fn validate_provider_channel( lang: Lang, backend: Arc, @@ -4732,165 +6369,370 @@ mod linux_app { } } - 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()); - } + /// OpenLess 数据目录。宿主写数据,UI 进程只用它定位日志文件。 + fn openless_data_dir() -> Result { + std::env::var_os("XDG_DATA_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(|home| std::path::PathBuf::from(home).join(".local/share")) + }) + .map(|base| base.join("OpenLess")) + .ok_or_else(|| "HOME/XDG_DATA_HOME is unavailable".to_string()) + } + + fn backend_config() -> Result { + let home = std::env::var_os("HOME").map(std::path::PathBuf::from); + let data_dir = openless_data_dir()?; + let cache_dir = std::env::var_os("XDG_CACHE_HOME") + .map(std::path::PathBuf::from) + .or_else(|| home.as_ref().map(|home| home.join(".cache"))) + .ok_or_else(|| "HOME/XDG_CACHE_HOME is unavailable".to_string())? + .join("OpenLess"); + std::fs::create_dir_all(&data_dir).map_err(|error| error.to_string())?; + std::fs::create_dir_all(&cache_dir).map_err(|error| error.to_string())?; + 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: Default::default(), + 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())?; + openless_linux_egui::prepare_fcitx5(&plan, &config.data_dir) + .map_err(|error| error.to_string()) + } + + /// 划词追问头像:登录名变化时后台取 `github.com/{login}.png`,解码后上传成 + /// egui 贴图(Tauri `UserAvatar`)。取图失败保持 GitHub 图标兜底。 + #[derive(Default)] + struct QaAvatar { + login: String, + texture: Option, + pending: Option>>, + } + + impl QaAvatar { + fn sync(&mut self, ctx: &egui::Context, login: &str) { + if login != self.login { + self.login = login.to_string(); + self.texture = None; + self.pending = None; + if !login.trim().is_empty() { + self.pending = Some(spawn_github_avatar_fetch(login.trim().to_string())); } } - }); - changed + let Some(receiver) = self.pending.as_ref() else { + return; + }; + match receiver.try_recv() { + Ok(Ok(image)) => { + self.texture = Some(ctx.load_texture( + "openless-qa-user-avatar", + image, + egui::TextureOptions::LINEAR, + )); + self.pending = None; + } + Ok(Err(error)) => { + log::debug!("avatar unavailable: {error}"); + self.pending = None; + } + Err(mpsc::TryRecvError::Empty) => { + // 取图在别的线程:保持重绘直到结果回来。 + ctx.request_repaint_after(std::time::Duration::from_millis(150)); + } + Err(mpsc::TryRecvError::Disconnected) => self.pending = None, + } + } } - fn 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 spawn_github_avatar_fetch( + login: String, + ) -> mpsc::Receiver> { + let (tx, rx) = mpsc::channel(); + std::thread::Builder::new() + .name("openless-avatar".into()) + .spawn(move || { + let _ = tx.send(fetch_github_avatar(&login)); + }) + .ok(); + rx } - 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, - }); - } + /// GitHub 公开头像接口(无需登录;Tauri 用的是同一个 URL 形状)。 + fn fetch_github_avatar(login: &str) -> Result { + let encoded: String = login + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + character.to_string() + } else { + let mut buffer = [0u8; 4]; + character + .encode_utf8(&mut buffer) + .bytes() + .map(|byte| format!("%{byte:02X}")) + .collect() + } + }) + .collect(); + let url = format!("https://github.com/{encoded}.png?size=64"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| error.to_string())?; + let bytes = runtime.block_on(async { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(8)) + .build() + .map_err(|error| error.to_string())?; + let response = client + .get(&url) + .send() + .await + .map_err(|error| error.to_string())?; + if !response.status().is_success() { + return Err(format!("avatar http {}", response.status())); + } + response.bytes().await.map_err(|error| error.to_string()) + })?; + let decoded = image::load_from_memory(&bytes).map_err(|error| error.to_string())?; + let rgba = decoded.to_rgba8(); + Ok(egui::ColorImage::from_rgba_unmultiplied( + [rgba.width() as usize, rgba.height() as usize], + rgba.as_raw(), + )) } - fn package_kind() -> LinuxPackageKind { - if std::env::var_os("APPDIR").is_some() { - LinuxPackageKind::AppImage - } else if cfg!(debug_assertions) { - LinuxPackageKind::Development - } else { - LinuxPackageKind::SystemPackage + /// X11 overlay placement for the capsule popup. + /// + /// The capsule must land at the bottom centre of the work area and must + /// never take the keyboard: on macOS Tauri gets the same guarantee from + /// `orderFrontRegardless` ("visible but not the key window"). In a native + /// X11 session the equivalent is `WM_HINTS.input = False` (see + /// [`openless_linux_egui::popup_command`]). + #[cfg(all(target_os = "linux", feature = "x11-overlay"))] + mod popup_overlay { + use super::*; + use openless_linux_egui::{ + place_overlay, popup_position, OverlayEnvironment, OverlayPlacement, OverlayX11, + X11Overlay, + }; + + pub struct PopupOverlay { + kind: PopupKind, + connection: X11Overlay, + environment: OverlayEnvironment, + /// The pre-map pass (hints + geometry) ran. + placed: bool, + /// The post-map pass (EWMH states, once the window is managed). + reasserted: bool, + attempts: u8, + focus_window: Option, + focus_watch_remaining: u8, + } + + impl PopupOverlay { + pub fn probe(kind: PopupKind) -> Option { + // 原生 Wayland 不做任何 X11 处理。 + if !openless_linux_egui::x11_available(std::env::var("DISPLAY").ok().as_deref()) { + log::debug!("popup x11: no DISPLAY, keeping the compositor placement"); + return None; + } + let connection = match X11Overlay::connect() { + Ok(connection) => connection, + Err(error) => { + log::warn!( + "capsule x11: connect failed, staying with the compositor: {error}" + ); + return None; + } + }; + let environment = match connection.probe() { + Ok(environment) => environment, + Err(error) => { + log::warn!("capsule x11: geometry probe failed: {error}"); + OverlayEnvironment::default() + } + }; + log::info!( + "capsule x11: work_area={:?} monitors={} cursor={:?} active_window={:?}", + environment.work_area, + environment.monitors.len(), + environment.cursor, + environment.active_window + ); + Some(Self { + kind, + connection, + environment, + placed: false, + reasserted: false, + attempts: 0, + focus_window: None, + focus_watch_remaining: 0, + }) + } + + /// Position handed to `ViewportBuilder::with_position`, so the pill + /// is already in place the first time it is shown. + pub fn initial_position(&self) -> Option<(i32, i32)> { + popup_position(&self.environment, self.kind) + } + + fn apply(&mut self, reason: &str) -> OverlayPlacement { + let placement = place_overlay( + &mut self.connection, + std::process::id(), + &self.environment, + self.kind, + ); + self.focus_window = placement.window.or(self.focus_window); + if placement.applied() { + log::info!( + "capsule x11 ({reason}): window={:?} matched={} moved_to={:?} focus_was_stolen={} focus_restored={} warnings={:?}", + placement.window, + placement + .matched + .map(openless_linux_egui::WindowMatch::as_str) + .unwrap_or("none"), + placement.moved_to, + placement.focus_was_stolen, + placement.focus_restored, + placement.warnings + ); + } + // Milestone line for real-machine verification: the popup + // process installs no logger, but it inherits stderr from the + // host, so this is the one place the fallback is observable + // (`journalctl --user -f | grep 'OpenLess capsule'`). + eprintln!( + "OpenLess capsule: x11 {reason} window={:?} matched={} moved_to={:?} \ +focus_was_stolen={} focus_restored={} warnings={:?}", + placement.window, + placement + .matched + .map(openless_linux_egui::WindowMatch::as_str) + .unwrap_or("none"), + placement.moved_to, + placement.focus_was_stolen, + placement.focus_restored, + placement.warnings + ); + placement + } + + pub fn place(&mut self, ctx: &egui::Context, visible: bool) { + // 只有胶囊需要「永不聚焦 + 置顶 + 不进任务栏」;两个面板要键盘输入, + // 位置已经由 `with_position` 在创建时给过,X11 变更一概不做。 + if self.kind != PopupKind::Capsule { + self.placed = true; + return; + } + if !self.placed { + self.attempts = self.attempts.saturating_add(1); + if self.apply("pre-map").applied() { + self.placed = true; + } else if self.attempts >= 100 { + // The window never showed up in the tree: stop asking but + // keep the pill working with the compositor's placement. + log::warn!( + "capsule x11: own window not found, keeping the compositor placement" + ); + self.placed = true; + } else { + // The window is created a frame or two after the app + // starts; try again on the next tick. + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + return; + } + if visible && !self.reasserted { + // Now that the window is managed, (re)assert above + + // skip-taskbar and the geometry the manager may have moved. + self.reasserted = true; + self.apply("post-map"); + // Some X11 window managers deliver activation after the + // first map notification. Watch briefly so a late focus + // race cannot leave Chromium/Electron-style apps blurred. + self.focus_watch_remaining = 10; + ctx.request_repaint_after(std::time::Duration::from_millis(60)); + } else if visible && self.focus_watch_remaining > 0 { + if let (Some(window), Some(previous)) = + (self.focus_window, self.environment.active_window) + { + if previous != window + && matches!(self.connection.active_window(), Ok(Some(active)) if active == window) + { + if let Err(error) = self.connection.restore_focus(previous) { + log::warn!("capsule x11: delayed focus restore failed: {error}"); + } else { + eprintln!( + "OpenLess capsule: restored delayed focus to previous window {previous}" + ); + } + } + } + self.focus_watch_remaining -= 1; + if self.focus_watch_remaining > 0 { + ctx.request_repaint_after(std::time::Duration::from_millis(60)); + } + } + } } } - 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()), - }) - } + /// Pure Wayland build: the capsule keeps the compositor's placement. + #[cfg(not(all(target_os = "linux", feature = "x11-overlay")))] + mod popup_overlay { + use super::*; - 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) - } + pub struct PopupOverlay; - /// 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(()) + impl PopupOverlay { + pub fn probe(_kind: PopupKind) -> Option { + None } - FcitxPluginStatus::Missing => { - Err("未找到 OpenLess fcitx5 插件;请重新安装当前软件包".to_string()) + + pub fn initial_position(&self) -> Option<(i32, i32)> { + None } + + pub fn place(&mut self, _ctx: &egui::Context, _visible: bool) {} } } + use popup_overlay::PopupOverlay; + struct NativePopupApp { kind: PopupKind, state: PopupState, incoming: mpsc::Receiver, outgoing: mpsc::Sender, qa_input: String, + /// Less Computer 面板的输入框(与 QA 的 composer 各自独立)。 + less_computer_input: String, outgoing_sequence: u64, ready_sent: bool, - preview_focus_requested: bool, + avatar: QaAvatar, lang: Lang, + /// X11 overlay placement for the capsule (bottom-centre, never focus). + overlay: Option, + /// 面板有焦点时插件收不到按键,所以面板自己也要匹配本地热键。 + hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher, } impl NativePopupApp { @@ -4922,12 +6764,12 @@ mod linux_app { session_id, sequence, }, - PopupKind::Preview => PopupToHost::CancelPreview { + PopupKind::Capsule => PopupToHost::DismissCapsule { version, session_id, sequence, }, - PopupKind::Capsule => PopupToHost::DismissCapsule { + PopupKind::LessComputer => PopupToHost::DismissLessComputer { version, session_id, sequence, @@ -4938,353 +6780,407 @@ mod linux_app { } } - 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(); + impl NativePopupApp { + /// Drain every host message queued since the last frame. `ctx` is + /// `None` on the layer-shell path, which has no viewport to command: + /// visibility and shutdown are handled by the runner instead. + /// + /// Returns true when the process should exit. + fn pump(&mut self, ctx: Option<&egui::Context>) -> bool { + loop { + let message = match self.incoming.try_recv() { + Ok(message) => message, + Err(std::sync::mpsc::TryRecvError::Empty) => return false, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + // 宿主进程没了(stdin 到 EOF → 读线程结束 → 发送端析构)。 + // 之前这里把 Empty 和 Disconnected 一起当成「没有消息」, + // 于是胶囊会在宿主崩溃/被杀后永久贴在屏幕上(layer surface + // 不能隐藏,只能随进程销毁)。宿主不在了就该自己退场。 + log::warn!( + "openless popup ({:?}): host pipe closed — closing the popup", + self.kind + ); + if let Some(ctx) = ctx { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + return true; + } + }; + if message + .content_kind() + .is_some_and(|message_kind| message_kind != self.kind) + { + continue; + } + let shutdown = matches!(message, HostToPopup::Shutdown { .. }); + let outcome = self.state.apply(message); + if outcome == openless_linux_egui::PopupApplyOutcome::Applied { + if let Some(ctx) = ctx { + ctx.send_viewport_cmd(egui::ViewportCommand::Visible(self.state.visible)); + } + } + if shutdown || self.state.shutdown_requested { + if let Some(ctx) = ctx { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + return true; } - 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; + } + + /// 面板内的本地热键:命中就报给宿主,由宿主按与插件信号同一套规则处理。 + /// + /// 胶囊不接受键盘焦点(layer surface 也拿不到),所以只对需要打字的面板生效。 + fn poll_local_hotkeys(&mut self, ctx: &egui::Context) { + if self.kind == PopupKind::Capsule { + return; } - 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() + let Some(bindings) = self.state.hotkeys.as_ref() else { + return; + }; + let Some(edge) = self.hotkey_matcher.poll(ctx, bindings) else { + return; }; - render_popup_inline(ui, &display, size, strong, italics); + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + log::info!( + "openless popup ({:?}): local hotkey edge {edge:?}", + self.kind + ); + self.send(PopupToHost::Hotkey { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + edge, + }); + } } - if in_code && !code.is_empty() { - render_popup_code(ui, code.trim_end()); + + /// Tell the host which session this window is serving. The host drops + /// every message that carries another session id, so this must happen + /// before the first content arrives. + fn send_ready_if_needed(&mut self) { + if self.ready_sent { + return; + } + 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; + } } - } - 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()); + /// One capsule frame on a layer surface: same view, same protocol and + /// same send / exit rules as the eframe window, minus viewport + /// commands (a layer surface is sized by the compositor). + fn layer_frame( + &mut self, + ctx: &egui::Context, + raw: egui::RawInput, + first: bool, + ) -> openless_linux_egui::LayerFrame { + if first { + theme::install(ctx); + } + let mut exit = self.pump(None); + self.send_ready_if_needed(); + let animated = matches!( + self.state.capsule.phase.to_ascii_lowercase().as_str(), + "starting" | "recording" | "transcribing" | "polishing" | "inserting" + ); + let capsule = self.state.capsule.clone(); + let lang = self.lang; + let mut action = frontend::popups::CapsuleAction::None; + let output = ctx.run_ui(raw, |ui| { + action = frontend::popups::dictation_capsule(ui, &capsule, lang); }); - } - - 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; + match action { + frontend::popups::CapsuleAction::None => {} + frontend::popups::CapsuleAction::Cancel + | frontend::popups::CapsuleAction::Confirm => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + let message = if matches!(action, frontend::popups::CapsuleAction::Cancel) { + PopupToHost::CancelDictation { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + } + } else { + PopupToHost::StopDictation { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + } + }; + self.send(message); } + exit = true; } } - if matched { - continue; + openless_linux_egui::LayerFrame { + output, + exit, + // Same cadence as the windowed popup: animate fast, idle slowly. + repaint_after: Duration::from_millis(if self.state.visible && animated { + 33 + } else { + 100 + }), } - 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; - } + fn clear_color(&self, _visuals: &egui::Visuals) -> [f32; 4] { + egui::Color32::TRANSPARENT.to_normalized_gamma_f32() + } + + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + let ctx = ui.ctx().clone(); + // Overlay placement first: it must run before the window is shown so + // the compositor never gives the capsule the keyboard. + if let Some(overlay) = self.overlay.as_mut() { + overlay.place(&ctx, self.state.visible); } - 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 self.pump(Some(&ctx)) { + return; } + self.send_ready_if_needed(); + self.poll_local_hotkeys(&ctx); if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { - self.dismiss(ctx); + 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), + // 只有「有动画」的状态需要 30fps 连续重绘:录音音量条、思考光环/光点、 + // 头像取图中。静止或隐藏时降到 10fps(stdin 轮询延迟 ≤100ms,肉眼无感), + // 避免透明置顶窗口长期白跑帧。 + let animated; + match self.kind { + PopupKind::Qa => { + self.avatar.sync(&ctx, &self.state.qa.viewer_login); + let qa_phase = self.state.qa.phase.to_ascii_lowercase(); + animated = self.avatar.pending.is_some() + || matches!( + qa_phase.as_str(), + "loading" | "thinking" | "recording" | "answerdelta" ); - if !self.preview_focus_requested { - editor.request_focus(); - self.preview_focus_requested = true; + let action = frontend::popups::selection_ask( + ui, + &self.state.qa, + &mut self.qa_input, + lang, + self.avatar.texture.as_ref(), + ); + match action { + frontend::popups::QaAction::Dismiss => self.dismiss(&ctx), + frontend::popups::QaAction::ConfirmPolish(text) => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::ConfirmPolish { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + text, + }); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } } - ui.horizontal(|ui| { - if ui.button(tr_l10n(lang, "btn.cancel")).clicked() { - self.dismiss(ctx); + frontend::popups::QaAction::CancelPolish => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::CancelPolish { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); } - 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); - } + self.dismiss(&ctx); + } + frontend::popups::QaAction::ToggleRecording => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::ToggleQaRecording { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); } - }); + } + frontend::popups::QaAction::SetPinned(pinned) => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::SetPinned { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + pinned, + }); + } + } + frontend::popups::QaAction::SetEditInstructionMode(enabled) => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::SetEditInstructionMode { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + enabled, + }); + } + } + frontend::popups::QaAction::ApplyEdit => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::ApplyEdit { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); + } + } + frontend::popups::QaAction::RevertEdit => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::RevertEdit { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); + } + } + frontend::popups::QaAction::Submit(text) => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::SubmitQa { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + text, + }); + } + } + frontend::popups::QaAction::None => {} } - PopupKind::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()), - ); + } + PopupKind::LessComputer => { + // 运行中的一轮需要连续重绘(「执行中…」标记 + 滚动到底)。 + animated = self.state.less_computer.working; + let action = frontend::popups::less_computer( + ui, + &self.state.less_computer, + &mut self.less_computer_input, + lang, + ); + match action { + frontend::popups::LessComputerAction::None => {} + frontend::popups::LessComputerAction::Dismiss => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::DismissLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } } - 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); + frontend::popups::LessComputerAction::Cancel => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::CancelLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + }); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); } - 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, - }); - } + } + frontend::popups::LessComputerAction::Submit(text) => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::SubmitLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + text, + }); } - 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, - }); - } + } + frontend::popups::LessComputerAction::Approve { token, approved } => { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + self.send(PopupToHost::ApproveLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + token, + approved, + }); } - }); + } } - PopupKind::Capsule => { - let response = ui - .horizontal(|ui| { - ui.spinner(); - ui.strong(&self.state.capsule.phase); + } + PopupKind::Capsule => { + let capsule_phase = self.state.capsule.phase.to_ascii_lowercase(); + animated = matches!( + capsule_phase.as_str(), + "starting" | "recording" | "transcribing" | "polishing" | "inserting" + ); + let action = frontend::popups::dictation_capsule(ui, &self.state.capsule, lang); + let message = match action { + frontend::popups::CapsuleAction::Cancel => { + Some(PopupToHost::CancelDictation { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 0, }) - .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); + frontend::popups::CapsuleAction::Confirm => { + Some(PopupToHost::StopDictation { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 0, + }) } - if let Some(level) = self.state.capsule.audio_level { - ui.add(egui::ProgressBar::new(level.clamp(0.0, 1.0))); + frontend::popups::CapsuleAction::None => None, + }; + if let Some(mut message) = message { + if let Some(session_id) = self.session_id() { + let sequence = self.next_sequence(); + match &mut message { + PopupToHost::CancelDictation { + session_id: id, + sequence: seq, + .. + } + | PopupToHost::StopDictation { + session_id: id, + sequence: seq, + .. + } => { + *id = session_id; + *seq = sequence; + } + _ => {} + } + self.send(message); + ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } - }); - ctx.request_repaint_after(Duration::from_millis(33)); + } + } + ctx.request_repaint_after(Duration::from_millis(if self.state.visible && animated { + 33 + } else { + 100 + })); } } @@ -5294,16 +7190,34 @@ mod linux_app { } if args.iter().any(|arg| arg == "--qa") { Some(PopupKind::Qa) - } else if args.iter().any(|arg| arg == "--preview") { - Some(PopupKind::Preview) } else if args.iter().any(|arg| arg == "--capsule") { Some(PopupKind::Capsule) + } else if args.iter().any(|arg| arg == "--less-computer") { + Some(PopupKind::LessComputer) } else { None } } - fn run_popup_process(kind: PopupKind) -> Result<(), String> { + /// Append a streaming delta to the trailing assistant entry, creating it on + /// the first delta of a turn. Keeps one assistant bubble per turn instead of + /// one per delta, matching the Tauri panel's message list. + fn append_assistant_entry( + entries: &mut Vec, + delta: &str, + ) { + match entries.last_mut() { + Some(entry) if entry.kind == "assistant" => entry.text.push_str(delta), + _ => entries.push(openless_linux_egui::LessComputerEntry { + kind: "assistant".to_string(), + text: delta.to_string(), + }), + } + } + + /// stdin/stdout JSONL plumbing shared by the eframe popup window and the + /// layer-shell capsule: both talk to the host through the same protocol. + fn popup_stdio() -> Result<(mpsc::Receiver, mpsc::Sender), String> { let (tx, rx) = mpsc::sync_channel(256); std::thread::Builder::new() .name("openless-popup-input".into()) @@ -5331,24 +7245,176 @@ mod linux_app { } }) .map_err(|error| error.to_string())?; + Ok((rx, outgoing_tx)) + } + + /// 胶囊在实现了 `zwlr_layer_shell_v1` 的合成器上跑原生 layer surface + /// (贴底居中、键盘焦点不可能、不占工作区);协议缺失、EGL 起不来或 + /// configure 超时都会返回 Err;不会回退到普通焦点窗口或 XWayland。 + fn run_capsule_layer_process() -> Result<(), LayerCapsuleFailure> { + let geometry = openless_linux_egui::capsule_geometry( + openless_linux_egui::CAPSULE_WINDOW_SIZE.0, + openless_linux_egui::CAPSULE_WINDOW_SIZE.1, + openless_linux_egui::CAPSULE_BOTTOM_GAP, + ); + // The host pipe is opened lazily, on the first frame the runner asks + // for: the runner only calls back once the layer surface is configured + // and EGL is live, so a preflight failure leaves stdin untouched for the + // fallback window. `started` records that the pipe is in use, which + // makes a late failure fatal instead of a (broken) second attempt. + let mut app: Option = None; + let started = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let started_in_frame = Arc::clone(&started); + let result = openless_linux_egui::run_layer_capsule(geometry, move |ctx, raw, first| { + if app.is_none() { + match popup_stdio() { + Ok((incoming, outgoing)) => { + started_in_frame.store(true, std::sync::atomic::Ordering::SeqCst); + app = Some(NativePopupApp { + kind: PopupKind::Capsule, + state: PopupState::default(), + incoming, + outgoing, + qa_input: String::new(), + less_computer_input: String::new(), + outgoing_sequence: 0, + ready_sent: false, + avatar: QaAvatar::default(), + lang: load_locale_pref().resolve(), + overlay: None, + hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher::default(), + }); + } + Err(error) => { + log::error!("popup pipe unavailable: {error}"); + let output = ctx.run_ui(raw, |_| {}); + return openless_linux_egui::LayerFrame { + output, + exit: true, + repaint_after: std::time::Duration::from_millis(0), + }; + } + } + } + let app = app.as_mut().expect("popup app is created above"); + app.layer_frame(ctx, raw, first) + }); + match result { + Ok(()) => Ok(()), + Err(error) if started.load(std::sync::atomic::Ordering::SeqCst) => { + // The capsule was already on screen: the host pipe is in use, so + // there is nothing to fall back to (a second window would fight + // this process for the same stdin). Report and let it die. + Err(LayerCapsuleFailure { + message: format!("layer-shell capsule stopped after startup: {error}"), + started: true, + }) + } + Err(error) => Err(LayerCapsuleFailure { + message: error, + started: false, + }), + } + } + + /// Why the layer-shell capsule gave up, plus whether it had already taken + /// over the host pipe — a live capsule cannot fall back to a second window. + struct LayerCapsuleFailure { + message: String, + started: bool, + } + + fn run_popup_process(kind: PopupKind) -> Result<(), String> { + let capsule_path = + (kind == PopupKind::Capsule).then(openless_linux_egui::detect_capsule_path); + // A regular xdg-shell toplevel can be activated by the compositor. That + // is unacceptable for a recording indicator, and Wayland has no client + // API to force a non-activating, positioned overlay without layer-shell. + // Keep recording functional but omit the capsule instead of stealing + // focus or silently routing the surface through XWayland. + if capsule_path == Some(openless_linux_egui::CapsulePath::PlainWindow) + && std::env::var_os("WAYLAND_DISPLAY").is_some() + { + return Err( + "native Wayland capsule requires zwlr_layer_shell_v1; refusing a focusable xdg-shell fallback".to_string(), + ); + } + // The capsule uses a native layer surface on Wayland and an X11 overlay + // only in a native X11 session. A layer-shell startup failure is fatal: + // do not create a second, focusable Wayland toplevel. + if capsule_path == Some(openless_linux_egui::CapsulePath::LayerShell) { + match run_capsule_layer_process() { + Ok(()) => return Ok(()), + Err(failure) if failure.started => return Err(failure.message), + Err(failure) => { + return Err(format!( + "native layer-shell capsule unavailable: {}", + failure.message + )) + } + } + } + let (rx, outgoing_tx) = popup_stdio()?; + // 胶囊窗口贴着药丸尺寸(Tauri 经典药丸 176×42),并用透明背景让圆角 + // 真正透出桌面;QA / 预览是实心卡片窗口。 let size = match kind { - PopupKind::Qa => [520.0, 520.0], - PopupKind::Preview => [480.0, 300.0], - PopupKind::Capsule => [340.0, 112.0], + // 面板尺寸一律取自共享常量(Tauri:qa/less-computer 420×540)。 + PopupKind::Qa => [ + openless_linux_egui::QA_WINDOW_SIZE.0 as f32, + openless_linux_egui::QA_WINDOW_SIZE.1 as f32, + ], + PopupKind::LessComputer => [ + openless_linux_egui::LESS_COMPUTER_WINDOW_SIZE.0 as f32, + openless_linux_egui::LESS_COMPUTER_WINDOW_SIZE.1 as f32, + ], + // 经典药丸 176×42 + 16px 下边距 + 8px 间距 + 「正在翻译」徽章 + // (Tauri `getCapsuleHostMetrics(.., 'classic')` 的 100 高度)。 + PopupKind::Capsule => [ + openless_linux_egui::CAPSULE_WINDOW_SIZE.0 as f32, + openless_linux_egui::CAPSULE_WINDOW_SIZE.1 as f32, + ], }; + // Popup cards paint their own rounded surface. A transparent native + // surface lets the rounded corners reveal the desktop instead of the + // rectangular X11 window backing showing through around the card. + let transparent = true; + // 原生 X11 会话下先读一次几何,让胶囊在**创建时**就落在工作区底部居中, + // 并在映射前把 WM_HINTS.input 关掉(窗口管理器不会再给它焦点)。 + // 面板窗(QA/预览)需要键盘输入,只借 `with_position` 定位。 + let capsule_on_x11 = kind == PopupKind::Capsule + && openless_linux_egui::detect_capsule_path() + == openless_linux_egui::CapsulePath::X11Overlay; + let overlay = if kind == PopupKind::Capsule && !capsule_on_x11 { + None + } else { + PopupOverlay::probe(kind) + }; + let initial_position = overlay + .as_ref() + .and_then(|overlay| overlay.initial_position()); + let mut viewport = egui::ViewportBuilder::default() + .with_title("OpenLess") + .with_inner_size(size) + .with_decorations(false) + .with_always_on_top() + .with_transparent(transparent) + .with_has_shadow(false) + .with_visible(false); + if let Some(position) = initial_position { + viewport = viewport.with_position([position.0 as f32, position.1 as f32]); + } + if kind == PopupKind::Capsule { + // 不主动要激活:Wayland 下由合成器决定,X11 下就是「可见但不是 key + // window」,与 Tauri 的 `orderFrontRegardless` 同语义。 + viewport = viewport.with_active(false); + } 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), + viewport, + renderer: eframe::Renderer::Wgpu, + multisampling: 4, ..Default::default() }; + let options = vulkan_options(options); eframe::run_native( "OpenLess Popup", options, @@ -5360,54 +7426,178 @@ mod linux_app { incoming: rx, outgoing: outgoing_tx, qa_input: String::new(), + less_computer_input: String::new(), outgoing_sequence: 0, ready_sent: false, - preview_focus_requested: false, + avatar: QaAvatar::default(), // The popup is a separate process, so it re-reads the // persisted UI-locale preference rather than sharing state. lang: load_locale_pref().resolve(), + overlay, + hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher::default(), })) }), ) .map_err(|error| error.to_string()) } + /// 无窗口宿主:进程里没有 eframe 窗口,事件泵、托盘与弹窗由这个循环驱动。 + /// + /// 关掉主窗口后进程会切到这个形态:窗口(以及它在任务栏/窗口列表里的条目) + /// 因此真的消失(Wayland 下 winit 无法隐藏窗口,只有真退出窗口进程才算数), + /// 而热键、弹窗、托盘继续工作。托盘「显示主窗口」时再拉起带窗口的进程, + /// 本进程退出,把单实例锁让出去。 + /// 宿主主循环的节拍。50ms:UI 动作最坏等一个节拍再进行,加上 UI 侧 30ms + /// 的重绘间隔,端到端仍在 100ms 预算内。 + const HOST_TICK_INTERVAL: Duration = Duration::from_millis(50); + + /// 常驻宿主的运行循环:**本进程没有窗口**。 + /// + /// 它持有后端 / 数据目录 / 单实例锁 / 热键监听 / 托盘 / 弹窗监督器,并通过 + /// `UiBridgeHost` 与独立的 UI 窗口进程通信。时序: + /// 1. 单实例锁(`run()` 里已拿到)→ 托盘与热键(native)→ **bind 桥 socket**; + /// 2. socket 就绪后才拉 UI 进程,UI 连不上宿主就直接报错退出; + /// 3. 每轮先收 UI 消息、再跑宿主心跳、再按需拉窗口、最后推视图模型; + /// 4. 退出前先给 UI 发 `Shutdown`,再由 `run()` 的 drop 释放单实例锁。 + fn run_host( + runtime_dir: &std::path::Path, + tokio: Arc, + native: LinuxNativeRuntime, + tray: Option, + start_minimized: bool, + ) -> Result<(), String> { + let socket = bridge::ui_socket_path(runtime_dir); + let tray_available = tray.is_some(); + // 没有托盘时必须开窗,否则关掉就再也找不回来。 + let window_should_be_open = !start_minimized || !tray_available; + let ctx = egui::Context::default(); + let mut app = OpenLessEguiApp::new(tokio, Ok(native), tray, window_should_be_open); + let mut ui_bridge = UiBridgeHost::bind(socket.clone()) + .map_err(|error| format!("UI bridge bind failed: {error}"))?; + log::info!( + "[ui-host] host started (no window in this process); bridge={} window_should_be_open={window_should_be_open} tray={tray_available}", + ui_bridge.path().display() + ); + loop { + ui_bridge.accept_pending(); + let messages = ui_bridge.drain(); + if !messages.is_empty() { + app.apply_window_messages(messages, tray_available); + } + // Debounced marketplace search and OAuth polling fire from the host tick. + app.poll_marketplace_search(); + app.poll_marketplace_oauth(); + app.tick(&ctx); + if app.should_spawn_ui_window() { + app.spawn_ui_window(&socket)?; + } + if let Some(error) = app.window.error.take() { + return Err(error); + } + let actions = std::mem::take(&mut app.pending_ui_actions); + if !actions.is_empty() { + app.apply_frontend_actions(actions, &ctx); + } + for sequence in std::mem::take(&mut app.pending_ui_pongs) { + ui_bridge.send(HostToWindow::Pong { sequence }); + } + app.sync_view_model(); + app.sync_hotkey_bindings(&mut ui_bridge); + app.publish_view_model(&mut ui_bridge); + app.focus_main_window(&mut ui_bridge); + if app.exit_requested { + break; + } + std::thread::sleep(HOST_TICK_INTERVAL); + } + log::info!("[ui-host] host exiting; asking the UI window to close"); + ui_bridge.shutdown(); + Ok(()) + } + + /// 只需要打印就退出的参数。以前 `--help` 会被当成普通启动参数,结果是 + /// `openless --help` 把应用拉起来且不退出(用户报的 bug)。 + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum EarlyExit { + Help, + Version, + } + + /// `--help` / `--version` 的识别(纯函数,便于单测)。`--help` 优先于其它 + /// 参数:`--openless-egui-popup --help` 也必须只打印帮助。 + fn early_exit(args: &[String]) -> Option { + if args.iter().any(|arg| arg == "--help" || arg == "-h") { + return Some(EarlyExit::Help); + } + if args.iter().any(|arg| arg == "--version" || arg == "-V") { + return Some(EarlyExit::Version); + } + None + } + + /// 命令行帮助(英文:CLI 约定俗成,也避免往本地化目录里塞一次性文案)。 + const USAGE: &str = "\ +OpenLess for Linux (egui frontend) + +Usage: + openless Launch OpenLess (tray + main window) + openless --minimized Launch without showing the main window + openless --help, -h Print this help and exit + openless --version, -V Print the version and exit + +Internal flags (set by OpenLess itself, not for regular use): + --openless-egui-popup --qa | --capsule | --less-computer + --ui-client --ui-socket +"; + pub fn run() -> Result<(), String> { let args = std::env::args().collect::>(); + match early_exit(&args) { + Some(EarlyExit::Help) => { + print!("{USAGE}"); + return Ok(()); + } + Some(EarlyExit::Version) => { + println!("OpenLess {}", env!("OPENLESS_APP_VERSION")); + return Ok(()); + } + None => {} + } if let Some(kind) = popup_kind(&args) { return run_popup_process(kind); } + if let Some(socket) = ui_client_socket(&args) { + // UI 窗口进程:只渲染,不碰后端、数据目录、单实例锁与热键。 + return run_ui_client(socket); + } let start_minimized = args.iter().any(|arg| arg == "--minimized"); let tokio = Arc::new(tokio::runtime::Runtime::new().map_err(|error| error.to_string())?); - let 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 broker = match SingleInstanceBroker::acquire_or_forward( - &runtime_dir.join("openless.lock"), - &runtime_dir.join("openless.sock"), - LinuxLaunchIntent::from_args(&args), - ) - .map_err(|error| error.to_string())? - { - SingleInstanceRole::Primary(broker) => broker, - SingleInstanceRole::Forwarded => return Ok(()), + .or_else(|| { + std::env::var_os("XDG_CACHE_HOME") + .map(std::path::PathBuf::from) + .or_else(|| { + std::env::var_os("HOME") + .map(|home| std::path::PathBuf::from(home).join(".cache")) + }) + .map(|cache| cache.join("OpenLess/runtime")) + }) + .ok_or_else(|| "HOME/XDG_RUNTIME_DIR is unavailable".to_string())?; + let broker = match acquire_broker(&runtime_dir, &args)? { + BrokerAcquisition::Primary(broker) => Arc::new(broker), + BrokerAcquisition::Forwarded => return Ok(()), }; + let tray = openless_linux_egui::LinuxTray::start().ok(); + let tray_available = tray.is_some(); 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. + let mut config = backend_config()?; + if let Err(error) = openless_linux_egui::init_file_logger(&config.data_dir) { + eprintln!("OpenLess file logger unavailable: {error}"); + } ensure_fcitx5_ready(&config)?; - let hotkeys = Fcitx5HotkeyListener::start().map_err(|error| error.to_string())?; + let hotkeys = Some(Fcitx5HotkeyListener::start().map_err(|error| error.to_string())?); + config.platform = LinuxCapabilitySnapshot::detect(tray_available).capabilities; let backend = { // Construction captures the existing executor for cpal/native // callbacks. The GUI thread leaves its context before block_on; @@ -5421,36 +7611,25 @@ mod linux_app { tokio .block_on(LinuxNativeRuntime::start( backend, - Some(broker), - Some(hotkeys), + Some(Arc::clone(&broker)), + hotkeys, )) .map_err(|error| error.to_string()) })(); - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .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() + // 常驻宿主:本进程不再创建窗口,窗口交给独立的 UI 进程。 + let native = match native { + Ok(native) => native, + Err(error) => { + drop(tray); + window::show_startup_error(&error, Arc::clone(&broker)); + return Err(error); + } }; - eframe::run_native( - "OpenLess", - options, - 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()) + if let Err(error) = run_host(&runtime_dir, tokio, native, tray, start_minimized) { + window::show_startup_error(&error, Arc::clone(&broker)); + return Err(error); + } + Ok(()) } #[cfg(test)] @@ -5458,35 +7637,567 @@ mod linux_app { use super::*; #[test] - fn running_remote_status_shows_ca_fingerprint_or_unavailable_warning() { - let mut app = disconnected_app(); - let fingerprint = "ab".repeat(32); - app.remote_access = Some(( - openless_core::RemoteInputStatus { - enabled: true, - running: true, - starting: false, - port: 8443, - urls: vec!["https://phone.example.invalid".into()], - urls_stale: false, - ca_fingerprint_sha256: Some(fingerprint.clone()), - locale: "zh-CN".into(), - connection_count: 0, - active_session_id: None, - }, - "fixture-pin".into(), - )); - let text = rendered_text(|ui| app.remote_ui(ui)); - assert!(text.contains("本机根证书 SHA-256"), "{text}"); + fn style_example_changes_mark_the_editor_dirty() { + let saved = openless_core::StylePack::default(); + let mut vm = frontend::view_model::FrontendViewModel { + style_name: saved.name.clone(), + style_description: saved.description.clone(), + style_prompt: saved.prompt.clone(), + style_selection_prompt: saved.selection_prompt.clone(), + style_voice_edit_prompt: saved.voice_edit_prompt.clone(), + style_tags: saved.tags.join(", "), + style_author: saved.author.clone().unwrap_or_default(), + style_version: saved.version.clone(), + style_model: saved.recommended_model.clone().unwrap_or_default(), + style_compatible_version: saved.compatible_app_version.clone().unwrap_or_default(), + style_examples: saved.examples.clone(), + style_editor_saved: Some(saved), + ..Default::default() + }; + assert!(!style_editor_is_dirty(&vm)); + vm.style_examples.push(openless_core::StylePackExample { + title: Some("demo".into()), + input: "raw".into(), + output: "polished".into(), + }); + assert!(style_editor_is_dirty(&vm)); + } + + #[test] + fn the_ui_client_flag_carries_the_bridge_socket() { + let args = vec![ + "openless".to_string(), + UI_CLIENT_FLAG.to_string(), + UI_SOCKET_FLAG.to_string(), + "/run/user/1000/openless-ui.sock".to_string(), + ]; + assert_eq!( + ui_client_socket(&args), + Some(std::path::PathBuf::from("/run/user/1000/openless-ui.sock")) + ); + // 普通启动(宿主)不是 UI 进程。 + assert_eq!(ui_client_socket(&["openless".to_string()]), None); + // 带了开关却没给路径:当作普通启动,不要装作是 UI 进程。 + assert_eq!(ui_client_socket(&[UI_CLIENT_FLAG.to_string()]), None); + } + + #[test] + fn stale_snapshots_never_replace_newer_state() { + // 严格递增才采纳:重复、乱序、回退的快照都必须丢掉, + // 否则 UI 会把新状态画成旧状态。 + assert!(snapshot_supersedes(0, 1)); + assert!(snapshot_supersedes(7, 8)); + assert!(!snapshot_supersedes(7, 7), "duplicate must be dropped"); + assert!(!snapshot_supersedes(7, 3), "out-of-order must be dropped"); + } + + #[test] + fn the_snapshot_fingerprint_notices_any_change() { + let a = br#"{"active_page":"Overview"}"#.to_vec(); + let b = br#"{"active_page":"History"}"#.to_vec(); + assert_eq!(snapshot_fingerprint(&a), snapshot_fingerprint(&a)); + assert_ne!(snapshot_fingerprint(&a), snapshot_fingerprint(&b)); + } + + #[test] + fn the_host_keeps_running_when_the_window_says_goodbye() { + let mut app = fixture_app(true); + app.apply_window_messages(vec![WindowToHost::Bye], true); + // 关窗只关窗口:宿主不退出、后端与会话不动。 + assert!(!app.window.should_be_open); + assert!(!app.exit_requested); + } + + #[test] + fn a_window_is_reopened_only_when_the_user_asks_for_it() { + let mut app = fixture_app(false); + // 用户已经关窗:宿主不会自己把窗口拉回来。 + app.pending_ui_actions + .push(frontend::view_model::FrontendAction::Navigate( + frontend::view_model::Page::History, + )); + assert!(!app.should_spawn_ui_window()); + // 托盘「显示主窗口」是显式意图,必须重新拉起一个窗口进程。 + app.request_main_window(); + assert!(app.window.should_be_open); + assert!(app.should_spawn_ui_window()); + } + + #[test] + fn a_freshly_spawned_window_is_not_spawned_twice() { + let mut app = fixture_app(false); + app.request_main_window(); + // 模拟「刚拉起过」:防抖窗口内不得再拉第二个窗口进程。 + app.window.spawned_at = Some(std::time::Instant::now()); + assert!(!app.should_spawn_ui_window()); + // 防抖过期且没有活着的子进程时,允许重拉。 + app.window.spawned_at = Some(std::time::Instant::now() - Duration::from_secs(5)); + assert!(app.should_spawn_ui_window()); + } + + #[test] + fn a_host_without_a_tray_exits_with_its_only_window() { + // 没有托盘就没有重新打开的入口:窗口退出后宿主必须跟着退出, + // 否则用户留下一个看得见进程、点不开窗口的僵尸。 + let mut app = fixture_app(false); + app.apply_window_messages(vec![WindowToHost::Bye], false); + assert!(app.exit_requested); + } + + #[test] + fn the_host_actions_that_used_to_raise_a_window_no_longer_do() { + // Core 的 ShowMain/FocusMain 在弹窗流程里也会发,宿主若照做就会 + // 「弹一次面板冒出一个主窗口」,所以它们必须不改变窗口意图。 + let mut app = fixture_app(true); + app.apply_window_messages( + vec![WindowToHost::Action { + sequence: 1, + action: Box::new(frontend::view_model::FrontendAction::WindowClose), + }], + true, + ); + // 窗口控制由 UI 进程处理;即便漏到宿主,也只是入队后由 + // apply_frontend_actions 记一条日志,不改变窗口意图。 + assert!(app.window.should_be_open); + } + + #[test] + fn reconnect_resends_state_and_focus_waits_for_the_handshake() { + let dir = std::env::temp_dir() + .join(format!("openless-window-focus-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).unwrap(); + let mut bridge = UiBridgeHost::bind(dir.join("ui.sock")).unwrap(); + let mut app = fixture_app(false); + app.preferences = Some(UserPreferences::default()); + app.transcript = "ongoing session".into(); + let pid = std::process::id(); + let mut previous_sequence = 0; + for _ in 0..2 { + app.request_main_window(); + let mut client = UiBridgeClient::connect(bridge.path()).unwrap(); + bridge.accept_pending(); + app.focus_main_window(&mut bridge); + assert!( + app.window.focus_requested, + "focus must survive an unfinished handshake" + ); + let deadline = std::time::Instant::now() + Duration::from_secs(3); + while !bridge.is_connected() { + app.apply_window_messages(bridge.drain(), true); + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(2)); + } + app.sync_hotkey_bindings(&mut bridge); + app.publish_view_model(&mut bridge); + app.focus_main_window(&mut bridge); + let (mut ready, mut hotkeys, mut snapshot, mut focus) = + (false, false, false, false); + while !(ready && hotkeys && snapshot && focus) { + match client.try_recv() { + Ok(HostToWindow::Ready { .. }) => ready = true, + Ok(HostToWindow::Hotkeys { .. }) => { + assert!(ready); + hotkeys = true; + } + Ok(HostToWindow::Snapshot { sequence, .. }) => { + assert!(ready && sequence > previous_sequence); + previous_sequence = sequence; + snapshot = true; + } + Ok(HostToWindow::FocusMain) => { + assert!(ready); + focus = true; + } + Err(mpsc::TryRecvError::Empty) => { + std::thread::sleep(Duration::from_millis(2)) + } + other => panic!("unexpected frame: {other:?}"), + } + assert!(std::time::Instant::now() < deadline); + } + assert!(!app.window.focus_requested); + app.apply_window_messages(vec![WindowToHost::Bye], true); + drop(client); + while bridge.is_connected() { + bridge.drain(); + assert!(std::time::Instant::now() < deadline); + std::thread::sleep(Duration::from_millis(2)); + } + assert_eq!(app.transcript, "ongoing session"); + assert_eq!(std::process::id(), pid); + assert!(!app.exit_requested); + } + bridge.shutdown(); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn history_refresh_preserves_selection_and_delete_identity() { + let mut app = fixture_app(false); + let mut selected = session_entry("2026-09-27T12:00:00Z", "selected", None); + selected.id = "selected-id".into(); + app.history.entries = vec![selected.clone()]; + app.sync_view_model(); + app.apply_frontend_actions( + vec![frontend::view_model::FrontendAction::HistoryRequestDelete( + selected.id.clone(), + )], + &egui::Context::default(), + ); + let mut newest = selected.clone(); + newest.id = "newest-id".into(); + app.history.entries.insert(0, newest); + app.sync_view_model(); + assert_eq!(app.frontend_vm.history_selected, 1); + assert_eq!( + app.frontend_vm.history_confirm, + Some(frontend::view_model::HistoryConfirm::Delete(selected.id)) + ); + } + + /// 润色与语音快捷键的补充说明必须出现在快捷键页。 + #[test] + fn shortcut_hints_come_from_the_catalog() { + let zh = Lang::ZhCn; + let ctx = egui::Context::default(); + let viewport = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1280.0, 2400.0)); + let mut time = 0.0_f64; + let mut render = |vm: &mut FrontendViewModel, events: Vec| { + time += 1.0; + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(viewport), + events, + time: Some(time), + ..Default::default() + }, + |ui| { + let mut actions = Vec::new(); + frontend::settings::test_render_shortcuts(ui, vm, &mut actions); + }, + ); + painted_text_of(&output) + }; + + let mut vm = FrontendViewModel { + lang: zh, + active_page: frontend::view_model::Page::Settings, + settings_open: true, + settings_section: frontend::view_model::SettingsSection::Shortcuts, + ..Default::default() + }; + let _ = render(&mut vm, Vec::new()); + let painted = render(&mut vm, Vec::new()); + for key in [ + "settings.selection_workspace.polish_hotkey_desc", + "settings.coding_agent.voice_hotkey_desc", + ] { + assert!( + painted.contains(tr_l10n(zh, key)), + "{key} must be rendered on the shortcuts page:\n{painted}" + ); + } + } + + /// 输入框回归:宿主推新快照时,用户还没提交的输入不能被抹掉。 + /// 之前的实现是整份替换视图模型,界面上表现为「打完字一秒后文字消失」。 + #[test] + fn a_pending_edit_survives_the_next_snapshot() { + let base = FrontendViewModel { + style_name: "原始名字".into(), + ..Default::default() + }; + let mut local = base.clone(); + // 用户正在输入:改了搜索框和风格名,都还没提交(未失焦/未回车)。 + local.settings_query = "润色上下文".into(); + local.style_name = "原始名字".into(); + + // 宿主这份快照什么都没改(保活推送)。 + let merged = merge_local_edits( + &serde_json::to_value(&local).unwrap(), + &serde_json::to_value(&base).unwrap(), + &serde_json::to_value(&base).unwrap(), + ); + let merged: FrontendViewModel = serde_json::from_value(merged).unwrap(); + assert_eq!(merged.settings_query, "润色上下文"); + + // 宿主改了别的字段(例如状态栏文案):本地输入仍然保留,宿主的改动生效。 + let mut incoming = base.clone(); + incoming.status = "已保存".into(); + let merged = merge_local_edits( + &serde_json::to_value(&local).unwrap(), + &serde_json::to_value(&base).unwrap(), + &serde_json::to_value(&incoming).unwrap(), + ); + let merged: FrontendViewModel = serde_json::from_value(merged).unwrap(); + assert_eq!(merged.settings_query, "润色上下文"); + assert_eq!(merged.status, "已保存"); + + // 宿主改了同一个字段(打开编辑器 hydrate / 提交后回写):以宿主为准。 + let mut incoming = base.clone(); + incoming.style_name = "宿主 hydrate 的名字".into(); + let merged = merge_local_edits( + &serde_json::to_value(&local).unwrap(), + &serde_json::to_value(&base).unwrap(), + &serde_json::to_value(&incoming).unwrap(), + ); + let merged: FrontendViewModel = serde_json::from_value(merged).unwrap(); + assert_eq!(merged.style_name, "宿主 hydrate 的名字"); + } + + /// 嵌套对象与列表同样按字段判断:宿主换过的列表用宿主的,没换的保留本地。 + #[test] + fn only_fields_the_host_touched_are_taken_from_the_snapshot() { + let base = serde_json::json!({ + "settings": { "remote_input_port": "8765", "retention_days": "7" }, + "history_entries": [], + }); + let local = serde_json::json!({ + // 用户在输入框里把端口改成了半截内容,还没提交。 + "settings": { "remote_input_port": "8", "retention_days": "7" }, + "history_entries": [], + }); + let incoming = serde_json::json!({ + // 宿主只刷新了历史列表与保留时长。 + "settings": { "remote_input_port": "8765", "retention_days": "30" }, + "history_entries": [{ "id": "a" }], + }); + let merged = merge_local_edits(&local, &base, &incoming); + assert_eq!(merged["settings"]["remote_input_port"], "8"); + assert_eq!(merged["settings"]["retention_days"], "30"); + assert_eq!(merged["history_entries"].as_array().unwrap().len(), 1); + } + + #[test] + fn the_host_heartbeat_runs_without_any_window() { + // tick() 不依赖 eframe 的帧循环:用一个没有窗口的 egui Context + // 连续跑两次也不会 panic(热键消费/弹窗拉起就在这条路径上)。 + let ctx = egui::Context::default(); + let mut app = fixture_app(true); + app.tick(&ctx); + app.tick(&ctx); + } + + fn painted_text_of(output: &egui::FullOutput) -> String { + fn collect(shape: &egui::Shape, out: &mut String) { + match shape { + egui::Shape::Text(text) => { + out.push_str(text.galley.text()); + out.push('\n'); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, out); + } + } + _ => {} + } + } + let mut out = String::new(); + for clipped in &output.shapes { + collect(&clipped.shape, &mut out); + } + out + } + + fn fixture_app(window_should_be_open: bool) -> OpenLessEguiApp { + OpenLessEguiApp::new( + Arc::new(tokio::runtime::Runtime::new().unwrap()), + Err("fixture".into()), + None, + window_should_be_open, + ) + } + + /// 快捷键卡片上的每一行都必须从 Core 偏好取真值。宿主以前只填了 + /// 听写/翻译/QA/速记四行,其余行无论偏好里有没有绑定都画成空键帽 —— + /// 看起来是「未设置」,录制却实实在在地写进了偏好。 + #[test] + fn shortcut_rows_show_the_bindings_core_holds() { + let mut app = fixture_app(true); + app.preferences = Some(UserPreferences { + switch_style_hotkey: Some(openless_core::shared_types::ShortcutBinding { + primary: "1".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }), + open_app_hotkey: Some(openless_core::shared_types::ShortcutBinding { + primary: "o".into(), + modifiers: vec!["ctrl".into(), "alt".into()], + }), + coding_agent_voice_hotkey: Some(openless_core::shared_types::ShortcutBinding { + primary: "v".into(), + modifiers: vec!["ctrl".into(), "super".into()], + }), + ..Default::default() + }); + app.sync_view_model(); + let vm = &app.frontend_vm; + for (label, value) in [ + ("switch_style", &vm.switch_style_hotkey), + ("open_app", &vm.open_app_hotkey), + ("coding_agent", &vm.coding_agent_hotkey), + ] { + assert!( + !value.trim().is_empty(), + "{label} shortcut row must show the binding Core holds" + ); + } + } + + #[test] + fn assistant_deltas_accumulate_into_one_entry_per_turn() { + // 一个轮次里流式增量只应形成一条助手条目;工具标记之后的新增量属于 + // 新一轮正文,要另起一条(否则工具行会被并进正文里)。 + let mut entries = Vec::new(); + append_assistant_entry(&mut entries, "he"); + append_assistant_entry(&mut entries, "llo"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].kind, "assistant"); + assert_eq!(entries[0].text, "hello"); + entries.push(openless_linux_egui::LessComputerEntry { + kind: "tool".to_string(), + text: "Used bash".to_string(), + }); + append_assistant_entry(&mut entries, "done"); + assert_eq!(entries.len(), 3); + assert_eq!(entries[2].text, "done"); + } + + #[test] + fn help_and_version_exit_before_anything_launches() { + let args = |extra: &str| vec!["openless".to_string(), extra.to_string()]; + assert_eq!( + super::early_exit(&args("--help")), + Some(super::EarlyExit::Help) + ); + assert_eq!(super::early_exit(&args("-h")), Some(super::EarlyExit::Help)); + assert_eq!( + super::early_exit(&args("--version")), + Some(super::EarlyExit::Version) + ); + assert_eq!( + super::early_exit(&args("-V")), + Some(super::EarlyExit::Version) + ); + assert_eq!(super::early_exit(&args("--minimized")), None); + // `--help` 必须压过弹窗/UI-client 参数,否则帮助会被静默吞掉。 + let popup_help = vec![ + "openless".to_string(), + "--openless-egui-popup".to_string(), + "--help".to_string(), + ]; + assert_eq!(super::early_exit(&popup_help), Some(super::EarlyExit::Help)); + } + + #[test] + fn a_crashing_popup_stops_restarting_after_the_budget() { + let start = std::time::Instant::now(); + let mut budget = PopupRestartBudget::default(); + for _ in 0..POPUP_RESTART_LIMIT { + assert!(budget.allow(start), "预算内的重开必须放行"); + } assert!( - text.contains("AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB AB"), - "{text}" + !budget.allow(start + Duration::from_secs(1)), + "超出预算后不再重开" ); - assert!(!text.contains("完整指纹不可用"), "{text}"); + // 窗口过期后重新计数:偶尔崩一次的面板不该被永久关掉。 + assert!(budget.allow(start + POPUP_RESTART_WINDOW + Duration::from_secs(1))); + } + + /// 最小可用的弹窗实例:只为了驱动 `pump` 这条退出链路。 + fn popup_app(kind: PopupKind, incoming: mpsc::Receiver) -> NativePopupApp { + let (outgoing, _outgoing_rx) = mpsc::channel(); + NativePopupApp { + kind, + state: PopupState::default(), + incoming, + outgoing, + qa_input: String::new(), + less_computer_input: String::new(), + outgoing_sequence: 0, + ready_sent: false, + avatar: QaAvatar::default(), + lang: Lang::ZhCn, + overlay: None, + hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher::default(), + } + } + + #[test] + fn a_closed_host_pipe_exits_the_popup() { + // 宿主进程崩溃/被杀时 stdin 到 EOF、发送端析构。以前 Empty 与 + // Disconnected 被一起当成「没有消息」,胶囊就会永久贴在屏幕上 + // (真机验证过:layer surface 不会自己消失,只能随进程销毁)。 + let (tx, rx) = mpsc::channel(); + let mut app = popup_app(PopupKind::Capsule, rx); + drop(tx); + assert!(app.pump(None), "a closed host pipe must end the popup"); + } + + #[test] + fn a_live_host_pipe_keeps_the_popup_running() { + let (tx, rx) = mpsc::channel(); + let mut app = popup_app(PopupKind::Capsule, rx); + tx.send(HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: "s1".into(), + sequence: 1, + phase: "Recording".into(), + text: String::new(), + audio_level: Some(0.2), + translation_active: false, + style: "siri".into(), + }) + .expect("channel is open"); + assert!(!app.pump(None), "a progress frame must not exit"); + // 宿主仍然活着(发送端还在)→ 不能因为消息读空就退出。 + assert!(!app.pump(None)); + drop(tx); + assert!(app.pump(None), "losing the host must exit"); + } + + #[test] + fn a_shutdown_frame_exits_the_popup() { + let (tx, rx) = mpsc::channel(); + let mut app = popup_app(PopupKind::Capsule, rx); + tx.send(HostToPopup::Shutdown { + version: POPUP_PROTOCOL_VERSION, + session_id: "s1".into(), + sequence: 2, + }) + .expect("channel is open"); + assert!(app.pump(None), "the host shutdown must end the popup"); + } + + #[test] + fn pinned_qa_ignores_the_automatic_hide_action() { + assert!(qa_hides_on_host_action(false)); + assert!(!qa_hides_on_host_action(true)); + } - app.remote_access.as_mut().unwrap().0.ca_fingerprint_sha256 = None; - let text = rendered_text(|ui| app.remote_ui(ui)); - assert!(text.contains("完整指纹不可用。请勿安装或信任下载的证书。"), "{text}"); + #[test] + fn qa_edit_flags_merge_partial_core_updates() { + let mut flags = QaEditFlags::default(); + // Core 只在变化时下发 Some(..):未下发的字段必须保持原值。 + let mut event = openless_core::QaStateEvent::simple(openless_core::QaStateKind::Idle); + event.edit_apply_available = Some(true); + flags.merge(&event); + assert!(flags.apply_available); + assert!(!flags.instruction_mode); + assert!(!flags.revert_available); + + event.edit_instruction_mode = Some(true); + event.edit_revert_available = Some(true); + flags.merge(&event); + assert!(flags.instruction_mode); + assert!(flags.revert_available); + assert!(flags.apply_available); + + event.edit_apply_available = Some(false); + flags.merge(&event); + assert!(!flags.apply_available); + assert!(flags.revert_available); } #[test] @@ -5495,9 +8206,7 @@ mod linux_app { Arc::new(tokio::runtime::Runtime::new().unwrap()), Err("fixture".into()), None, - LinuxUpdateSupport::ManualOnly { - releases_url: openless_linux_egui::RELEASES_URL, - }, + false, ); let first = openless_core::SessionId::new(); let second = openless_core::SessionId::new(); @@ -5573,9 +8282,7 @@ mod linux_app { Arc::new(tokio::runtime::Runtime::new().unwrap()), Err("fixture".into()), None, - LinuxUpdateSupport::ManualOnly { - releases_url: openless_linux_egui::RELEASES_URL, - }, + false, ); let session = openless_core::SessionId::new(); let mut thinking = QaStateEvent::simple(QaStateKind::Thinking); @@ -5605,27 +8312,24 @@ mod linux_app { 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); + /// 仅测试用:生产路径(设置页的 style-pack 快捷键行)直接改 + /// `preferences.style_pack_hotkeys`,见 `main.rs` 的 `StyleHotkey` 分支。 + 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, + }); + } } #[test] @@ -5648,78 +8352,6 @@ mod linux_app { 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( @@ -5833,26 +8465,24 @@ mod linux_app { 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); + // Last-7/last-30 aggregates were folded into the daily series: + // the UI slices the tail of `activity_daily` for its period chart. + + // The daily series is the trailing 30 days ending today. + assert_eq!(summary.activity_daily.len(), 30); + assert_eq!(summary.activity_daily.last().unwrap().date, "2026-01-15"); + assert_eq!(summary.activity_daily.last().unwrap().count, 5); + + // The heatmap now covers the whole calendar year (Jan 1 – Dec 31), + // so every 2026 day counts and the 2025 day drops out. + assert_eq!(summary.heatmap_year, 2026); + assert_eq!(summary.heatmap.len(), 365); + let heat_total: u32 = summary.heatmap.iter().map(|day| day.count).sum(); + assert_eq!(heat_total, 10); } #[test] - fn overview_heatmap_excludes_days_outside_trailing_window() { + fn overview_heatmap_excludes_days_outside_the_calendar_year() { let today = chrono::NaiveDate::from_ymd_opt(2026, 1, 15).unwrap(); let far = (today - chrono::Duration::days(400)) .format("%Y-%m-%d") @@ -5866,16 +8496,11 @@ mod linux_app { today, ); - let heat_total: u32 = summary - .heatmap_weeks - .iter() - .flat_map(|week| week.iter()) - .sum(); + let heat_total: u32 = summary.heatmap.iter().map(|day| day.count).sum(); assert_eq!( heat_total, 3, - "days older than the trailing window must not appear in the heatmap" + "days outside the calendar year must not appear in the heatmap" ); - assert_eq!(summary.activity_days_total, 2); } } } diff --git a/openless-all/app/linux-egui/src/marketplace.rs b/openless-all/app/linux-egui/src/marketplace.rs deleted file mode 100644 index c25738182..000000000 --- a/openless-all/app/linux-egui/src/marketplace.rs +++ /dev/null @@ -1,89 +0,0 @@ -use std::io::Write; -use std::path::Path; - -use openless_core::{BackendError, BackendErrorCode}; - -pub(crate) fn write_archive(target: &Path, bytes: &[u8]) -> Result<(), BackendError> { - if !target.is_absolute() { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "Linux Marketplace archive target must be an absolute filesystem path", - )); - } - let parent = target.parent().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "Linux Marketplace archive target has no parent directory", - ) - })?; - if !parent.is_dir() { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "Linux Marketplace archive parent directory does not exist", - )); - } - if target.file_name().is_none() { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "Linux Marketplace archive target has no file name", - )); - } - - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(target) - .map_err(|error| archive_error("create", error))?; - if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) { - drop(file); - let _ = std::fs::remove_file(target); - return Err(archive_error("write", error)); - } - Ok(()) -} - -fn archive_error(operation: &str, error: std::io::Error) -> BackendError { - BackendError::new( - BackendErrorCode::Persistence, - format!("failed to {operation} Linux Marketplace archive: {error}"), - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn filesystem_archive_sink_preserves_bytes_and_refuses_overwrite() { - let root = std::env::temp_dir().join(format!( - "openless-linux-marketplace-archive-{}", - uuid::Uuid::new_v4().simple() - )); - std::fs::create_dir_all(&root).unwrap(); - let target = root.join("downloaded.zip"); - - write_archive(&target, b"validated core archive").unwrap(); - assert_eq!(std::fs::read(&target).unwrap(), b"validated core archive"); - - let error = write_archive(&target, b"replacement") - .expect_err("the host sink must not overwrite an existing user file"); - assert_eq!(error.code, BackendErrorCode::Persistence); - assert_eq!(std::fs::read(&target).unwrap(), b"validated core archive"); - let _ = std::fs::remove_dir_all(root); - } - - #[test] - fn filesystem_archive_sink_rejects_relative_or_missing_parent_paths() { - let relative = write_archive(Path::new("downloaded.zip"), b"bytes") - .expect_err("relative paths must not be interpreted against process cwd"); - assert_eq!(relative.code, BackendErrorCode::InvalidArgument); - - let root = std::env::temp_dir().join(format!( - "openless-linux-marketplace-missing-parent-{}", - uuid::Uuid::new_v4().simple() - )); - let missing_parent = write_archive(&root.join("missing").join("downloaded.zip"), b"bytes") - .expect_err("the UI must select an existing destination directory"); - assert_eq!(missing_parent.code, BackendErrorCode::InvalidArgument); - } -} diff --git a/openless-all/app/linux-egui/src/popup.rs b/openless-all/app/linux-egui/src/popup.rs index 8e9a1c508..22f71097c 100644 --- a/openless-all/app/linux-egui/src/popup.rs +++ b/openless-all/app/linux-egui/src/popup.rs @@ -18,27 +18,50 @@ use tokio::process::Command; use tokio::runtime::Handle; use tokio::sync::mpsc as tokio_mpsc; -pub const POPUP_PROTOCOL_VERSION: u16 = 1; +pub const POPUP_PROTOCOL_VERSION: u16 = 7; pub const MAX_JSONL_LINE_BYTES: usize = 1024 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PopupKind { + /// 选区助手面板:提问对话 + 润色结果编辑(合并后唯一的选区弹窗)。 Qa, - Preview, Capsule, + LessComputer, } impl PopupKind { pub fn argument(self) -> &'static str { match self { Self::Qa => "--qa", - Self::Preview => "--preview", Self::Capsule => "--capsule", + Self::LessComputer => "--less-computer", } } } +/// One rendered Less Computer turn entry. +/// +/// Mirrors Core's `LessComputerEventKind` presentation: the panel prints the +/// entries in order and never re-derives product intent, so a new Core event +/// variant only needs a host-side translation into `kind` + display text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LessComputerEntry { + /// `user` / `assistant` / `tool` / `compaction` / `error` / `note`. + pub kind: String, + #[serde(default)] + pub text: String, +} + +/// A blocked command waiting for the user's decision. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct LessComputerApproval { + pub token: String, + pub command: String, + #[serde(default)] + pub reason: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PopupChatMessage { pub role: String, @@ -55,7 +78,8 @@ pub struct PopupChatMessage { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum HostToPopup { - Preview { + /// 润色结果:送进**选区助手面板**的「润色结果」模式(不再有独立预览窗口)。 + PolishPreview { version: u16, session_id: String, sequence: u64, @@ -74,6 +98,21 @@ pub enum HostToPopup { streaming_answer: String, #[serde(default, skip_serializing_if = "Option::is_none")] error: Option, + /// 「编辑指令」勾选框状态(Core `QaSnapshot.edit_instruction_mode`)。 + #[serde(default)] + edit_instruction_mode: bool, + /// 预览可用:底部出现「预览并确认插入」。 + #[serde(default)] + edit_apply_available: bool, + /// 可一键回退:额外出现「保留上一版本」。 + #[serde(default)] + edit_revert_available: bool, + /// 固定(不自动关闭)。Tauri `qa.pinTooltip` / `qa.unpinTooltip`。 + #[serde(default)] + pinned: bool, + /// GitHub 登录名,用于 `https://github.com/{login}.png` 头像。 + #[serde(default)] + viewer_login: String, }, Capsule { version: u16, @@ -84,12 +123,44 @@ pub enum HostToPopup { text: String, #[serde(default, skip_serializing_if = "Option::is_none")] audio_level: Option, + /// 正在翻译:药丸上方显示「正在翻译」徽章(Tauri `capsule.translating`)。 + #[serde(default)] + translation_active: bool, + /// 胶囊样式:`siri` / `classic` / `typeless`(Tauri `capsuleStyle`)。 + #[serde(default)] + style: String, }, Hide { version: u16, session_id: String, sequence: u64, }, + /// Less Computer 面板状态(Tauri `LessComputerPanel.tsx`)。 + /// + /// `entries` 是已发生的事件序列(用户指令 / 工具 / 压缩 / 助手正文 / 错误), + /// `working` 表示本轮尚未终结,`approval` 是等待用户批准的阻塞命令。 + LessComputer { + version: u16, + session_id: String, + sequence: u64, + entries: Vec, + #[serde(default)] + working: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + approval: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + error: Option, + }, + /// 本地热键匹配所需的配置(见 `crate::local_hotkeys` 的模块文档)。 + /// + /// 面板有焦点时 fcitx5 收不到按键,所以面板自己按这份配置匹配,命中后 + /// 用 [`PopupToHost::Hotkey`] 报回宿主。 + Hotkeys { + version: u16, + session_id: String, + sequence: u64, + bindings: Box, + }, Shutdown { version: u16, session_id: String, @@ -100,9 +171,11 @@ pub enum HostToPopup { impl HostToPopup { pub fn version(&self) -> u16 { match self { - Self::Preview { version, .. } + Self::PolishPreview { version, .. } | Self::QaSnapshot { version, .. } | Self::Capsule { version, .. } + | Self::LessComputer { version, .. } + | Self::Hotkeys { version, .. } | Self::Hide { version, .. } | Self::Shutdown { version, .. } => *version, } @@ -110,9 +183,11 @@ impl HostToPopup { pub fn session_id(&self) -> &str { match self { - Self::Preview { session_id, .. } + Self::PolishPreview { session_id, .. } | Self::QaSnapshot { session_id, .. } | Self::Capsule { session_id, .. } + | Self::LessComputer { session_id, .. } + | Self::Hotkeys { session_id, .. } | Self::Hide { session_id, .. } | Self::Shutdown { session_id, .. } => session_id, } @@ -120,9 +195,11 @@ impl HostToPopup { pub fn sequence(&self) -> u64 { match self { - Self::Preview { sequence, .. } + Self::PolishPreview { sequence, .. } | Self::QaSnapshot { sequence, .. } | Self::Capsule { sequence, .. } + | Self::LessComputer { sequence, .. } + | Self::Hotkeys { sequence, .. } | Self::Hide { sequence, .. } | Self::Shutdown { sequence, .. } => *sequence, } @@ -130,9 +207,14 @@ impl HostToPopup { pub fn content_kind(&self) -> Option { match self { - Self::Preview { .. } => Some(PopupKind::Preview), + // 润色结果现在由选区助手面板承载。 + Self::PolishPreview { .. } => Some(PopupKind::Qa), Self::QaSnapshot { .. } => Some(PopupKind::Qa), Self::Capsule { .. } => Some(PopupKind::Capsule), + Self::LessComputer { .. } => Some(PopupKind::LessComputer), + // 配置不属于任何一种面板内容, + // 不能拿它当“这个帧是给谁的”。 + Self::Hotkeys { .. } => None, Self::Hide { .. } | Self::Shutdown { .. } => None, } } @@ -148,13 +230,22 @@ pub enum PopupToHost { sequence: u64, kind: PopupKind, }, - ConfirmPreview { + /// 本面板内命中的热键(面板有焦点时 fcitx5 收不到按键)。 + Hotkey { + version: u16, + session_id: String, + sequence: u64, + edge: crate::LocalHotkeyEdge, + }, + /// 确认用编辑后的文本替换选区(由选区助手面板发出)。 + ConfirmPolish { version: u16, session_id: String, sequence: u64, text: String, }, - CancelPreview { + /// 取消本次润色(由选区助手面板发出)。 + CancelPolish { version: u16, session_id: String, sequence: u64, @@ -180,53 +271,164 @@ pub enum PopupToHost { session_id: String, sequence: u64, }, + /// 胶囊上的 ✕:放弃这次听写(Tauri `cancelDictation`)。 + CancelDictation { + version: u16, + session_id: String, + sequence: u64, + }, + /// 胶囊上的 ✓:结束录音并落字(Tauri `stopDictation`)。 + StopDictation { + version: u16, + session_id: String, + sequence: u64, + }, + /// 划词追问头部图钉:固定后宿主不再自动收起(Tauri `qa.pinTooltip`)。 + SetPinned { + version: u16, + session_id: String, + sequence: u64, + pinned: bool, + }, + /// 输入组左下角「编辑指令」勾选框。 + SetEditInstructionMode { + version: u16, + session_id: String, + sequence: u64, + enabled: bool, + }, + /// 「预览并确认插入」:把编辑结果写回选区(Tauri `qa.editApplyReplace`)。 + ApplyEdit { + version: u16, + session_id: String, + sequence: u64, + }, + /// 「保留上一版本」:回退这一轮的编辑预览(Tauri `qa.editRevertPrevious`)。 + RevertEdit { + version: u16, + session_id: String, + sequence: u64, + }, + /// Less Computer 输入框:提交一条指令(Tauri `lessComputerSubmitText`)。 + SubmitLessComputer { + version: u16, + session_id: String, + sequence: u64, + text: String, + }, + /// 批准/拒绝被阻塞的命令(Tauri `lessComputerApprove`)。 + ApproveLessComputer { + version: u16, + session_id: String, + sequence: u64, + token: String, + approved: bool, + }, + /// 停止当前这一轮(Esc / 关闭时的收尾,Tauri `less_computer_window_dismiss`)。 + CancelLessComputer { + version: u16, + session_id: String, + sequence: u64, + }, + /// ✕:只收起面板,不动已完成的对话(Tauri `cancel()` / `minimize()` 语义)。 + DismissLessComputer { + version: u16, + session_id: String, + sequence: u64, + }, } impl PopupToHost { pub fn version(&self) -> u16 { match self { Self::Ready { version, .. } - | Self::ConfirmPreview { version, .. } - | Self::CancelPreview { version, .. } + | Self::Hotkey { version, .. } + | Self::ConfirmPolish { version, .. } + | Self::CancelPolish { version, .. } | Self::SubmitQa { version, .. } | Self::ToggleQaRecording { version, .. } | Self::DismissQa { version, .. } - | Self::DismissCapsule { version, .. } => *version, + | Self::DismissCapsule { version, .. } + | Self::CancelDictation { version, .. } + | Self::StopDictation { version, .. } + | Self::SetPinned { version, .. } + | Self::SetEditInstructionMode { version, .. } + | Self::ApplyEdit { version, .. } + | Self::RevertEdit { version, .. } + | Self::SubmitLessComputer { version, .. } + | Self::ApproveLessComputer { version, .. } + | Self::CancelLessComputer { version, .. } + | Self::DismissLessComputer { version, .. } => *version, } } pub fn session_id(&self) -> &str { match self { Self::Ready { session_id, .. } - | Self::ConfirmPreview { session_id, .. } - | Self::CancelPreview { session_id, .. } + | Self::Hotkey { session_id, .. } + | Self::ConfirmPolish { session_id, .. } + | Self::CancelPolish { session_id, .. } | Self::SubmitQa { session_id, .. } | Self::ToggleQaRecording { session_id, .. } | Self::DismissQa { session_id, .. } - | Self::DismissCapsule { session_id, .. } => session_id, + | Self::DismissCapsule { session_id, .. } + | Self::CancelDictation { session_id, .. } + | Self::StopDictation { session_id, .. } + | Self::SetPinned { session_id, .. } + | Self::SetEditInstructionMode { session_id, .. } + | Self::ApplyEdit { session_id, .. } + | Self::RevertEdit { session_id, .. } + | Self::SubmitLessComputer { session_id, .. } + | Self::ApproveLessComputer { session_id, .. } + | Self::CancelLessComputer { session_id, .. } + | Self::DismissLessComputer { session_id, .. } => session_id, } } pub fn sequence(&self) -> u64 { match self { Self::Ready { sequence, .. } - | Self::ConfirmPreview { sequence, .. } - | Self::CancelPreview { sequence, .. } + | Self::Hotkey { sequence, .. } + | Self::ConfirmPolish { sequence, .. } + | Self::CancelPolish { sequence, .. } | Self::SubmitQa { sequence, .. } | Self::ToggleQaRecording { sequence, .. } | Self::DismissQa { sequence, .. } - | Self::DismissCapsule { sequence, .. } => *sequence, + | Self::DismissCapsule { sequence, .. } + | Self::CancelDictation { sequence, .. } + | Self::StopDictation { sequence, .. } + | Self::SetPinned { sequence, .. } + | Self::SetEditInstructionMode { sequence, .. } + | Self::ApplyEdit { sequence, .. } + | Self::RevertEdit { sequence, .. } + | Self::SubmitLessComputer { sequence, .. } + | Self::ApproveLessComputer { sequence, .. } + | Self::CancelLessComputer { sequence, .. } + | Self::DismissLessComputer { sequence, .. } => *sequence, } } pub fn kind(&self) -> PopupKind { match self { Self::Ready { kind, .. } => *kind, - Self::ConfirmPreview { .. } | Self::CancelPreview { .. } => PopupKind::Preview, - Self::SubmitQa { .. } | Self::ToggleQaRecording { .. } | Self::DismissQa { .. } => { - PopupKind::Qa - } - Self::DismissCapsule { .. } => PopupKind::Capsule, + Self::ConfirmPolish { .. } | Self::CancelPolish { .. } => PopupKind::Qa, + // 本地热键边沿只由接受键盘的面板发出;`kind()` 的调用方(宿主) + // 已先按发消息的面板分支处理,这里给 Qa 只是让类型上有个确定值。 + Self::Hotkey { .. } => PopupKind::Qa, + Self::SubmitQa { .. } + | Self::ToggleQaRecording { .. } + | Self::DismissQa { .. } + | Self::SetPinned { .. } + | Self::SetEditInstructionMode { .. } + | Self::ApplyEdit { .. } + | Self::RevertEdit { .. } => PopupKind::Qa, + Self::DismissCapsule { .. } + | Self::CancelDictation { .. } + | Self::StopDictation { .. } => PopupKind::Capsule, + Self::SubmitLessComputer { .. } + | Self::ApproveLessComputer { .. } + | Self::CancelLessComputer { .. } + | Self::DismissLessComputer { .. } => PopupKind::LessComputer, } } } @@ -242,16 +444,16 @@ struct PopupActionSlot { #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct PopupActionGuard { qa: PopupActionSlot, - preview: PopupActionSlot, capsule: PopupActionSlot, + less_computer: PopupActionSlot, } impl PopupActionGuard { fn slot_mut(&mut self, kind: PopupKind) -> &mut PopupActionSlot { match kind { PopupKind::Qa => &mut self.qa, - PopupKind::Preview => &mut self.preview, PopupKind::Capsule => &mut self.capsule, + PopupKind::LessComputer => &mut self.less_computer, } } @@ -442,7 +644,7 @@ where } #[derive(Debug, Clone, Default, PartialEq)] -pub struct PreviewPopupState { +pub struct QaPolishState { pub text: String, pub source: String, } @@ -454,6 +656,13 @@ pub struct QaPopupState { pub selection_preview: Option, pub streaming_answer: String, pub error: Option, + pub edit_instruction_mode: bool, + /// 润色结果模式:Some = 正在编辑润色结果(替代原来的独立预览窗口)。 + pub polish: Option, + pub edit_apply_available: bool, + pub edit_revert_available: bool, + pub pinned: bool, + pub viewer_login: String, } #[derive(Debug, Clone, Default, PartialEq)] @@ -461,6 +670,17 @@ pub struct CapsulePopupState { pub phase: String, pub text: String, pub audio_level: Option, + pub translation_active: bool, + /// 胶囊样式(`siri` / `classic` / `typeless`)。 + pub style: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct LessComputerPopupState { + pub entries: Vec, + pub working: bool, + pub approval: Option, + pub error: Option, } #[derive(Debug, Clone, Default, PartialEq)] @@ -469,9 +689,11 @@ pub struct PopupState { pub last_sequence: u64, pub visible: bool, pub shutdown_requested: bool, - pub preview: PreviewPopupState, pub qa: QaPopupState, pub capsule: CapsulePopupState, + pub less_computer: LessComputerPopupState, + /// 宿主下发的本地热键配置;没有它就不做本地匹配。 + pub hotkeys: Option, retired_sessions: HashSet, } @@ -486,6 +708,12 @@ 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 { + // 热键配置不是“会话内容”:它不推进会话、不参与序号排序(宿主可能在面板 + // 刚重连、还没收到第一条内容帧时先发它),所以在这里提前收下就返回。 + if let HostToPopup::Hotkeys { bindings, .. } = message { + self.hotkeys = Some(*bindings); + return ApplyOutcome::Applied; + } let session_id = message.session_id().to_owned(); let sequence = message.sequence(); if let Some(current) = self.session_id.as_deref() { @@ -496,9 +724,10 @@ impl PopupState { } else { let starts_session = matches!( message, - HostToPopup::Preview { .. } + HostToPopup::PolishPreview { .. } | HostToPopup::QaSnapshot { .. } | HostToPopup::Capsule { .. } + | HostToPopup::LessComputer { .. } ); if !starts_session || self.retired_sessions.contains(&session_id) { return ApplyOutcome::Stale; @@ -513,8 +742,9 @@ impl PopupState { self.session_id = Some(session_id); self.last_sequence = sequence; match message { - HostToPopup::Preview { text, source, .. } => { - self.preview = PreviewPopupState { text, source }; + HostToPopup::PolishPreview { text, source, .. } => { + // 润色结果落到选区助手面板的「润色结果」模式。 + self.qa.polish = Some(QaPolishState { text, source }); self.visible = true; } HostToPopup::QaSnapshot { @@ -523,14 +753,25 @@ impl PopupState { selection_preview, streaming_answer, error, + edit_instruction_mode, + edit_apply_available, + edit_revert_available, + pinned, + viewer_login, .. } => { self.qa = QaPopupState { + polish: None, phase, messages, selection_preview, streaming_answer, error, + edit_instruction_mode, + edit_apply_available, + edit_revert_available, + pinned, + viewer_login, }; self.visible = true; } @@ -538,12 +779,31 @@ impl PopupState { phase, text, audio_level, + translation_active, + style, .. } => { self.capsule = CapsulePopupState { phase, text, audio_level, + translation_active, + style, + }; + self.visible = true; + } + HostToPopup::LessComputer { + entries, + working, + approval, + error, + .. + } => { + self.less_computer = LessComputerPopupState { + entries, + working, + approval, + error, }; self.visible = true; } @@ -553,6 +813,8 @@ impl PopupState { self.shutdown_requested = true; return ApplyOutcome::Shutdown; } + // 上面已提前收下:热键配置不参与会话与序号排序。 + HostToPopup::Hotkeys { .. } => {} } ApplyOutcome::Applied } @@ -599,6 +861,47 @@ enum SupervisorCommand { Shutdown, } +/// Whether this popup must be pushed onto X11 (XWayland counts). +/// +/// The capsule may use X11 only in a native X11 session. A Wayland session must +/// never be routed through XWayland, even when DISPLAY is present. +pub fn force_x11_for( + kind: PopupKind, + display: Option<&str>, + wayland_display: Option<&str>, +) -> bool { + kind == PopupKind::Capsule + && !crate::popup_layer::wayland_display_available(wayland_display) + && display.is_some_and(|value| !value.trim().is_empty()) +} + +/// Build the popup child command, including the backend choice above. +pub fn popup_command( + executable: impl AsRef, + kind: PopupKind, + display: Option<&str>, + wayland_display: Option<&str>, + layer_shell: bool, +) -> Command { + let mut command = Command::new(executable.as_ref()); + command.arg("--openless-egui-popup").arg(kind.argument()); + if force_x11_for(kind, display, wayland_display) { + command.env_remove("WAYLAND_DISPLAY"); + command.env_remove("WAYLAND_SOCKET"); + } else if kind == PopupKind::Capsule + && crate::popup_layer::wayland_display_available(wayland_display) + { + // Pin the Wayland backend explicitly, even if the parent environment + // contains an X11 test override. Without layer-shell, use native + // xdg-shell and accept compositor-managed placement. + command.env( + crate::popup_layer::CAPSULE_PATH_ENV, + if layer_shell { "layer" } else { "plain" }, + ); + } + command +} + /// Non-blocking handle held by the main egui application. pub struct PopupSupervisor { commands: tokio_mpsc::Sender, @@ -607,9 +910,23 @@ pub struct PopupSupervisor { 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) + let display = std::env::var("DISPLAY").ok(); + let wayland_display = std::env::var("WAYLAND_DISPLAY").ok(); + let layer_shell = crate::popup_layer::layer_shell_available(); + log::debug!( + "popup spawn: kind={kind:?} x11={} layer_shell={layer_shell}", + display.as_deref().unwrap_or("none") + ); + Self::spawn_command( + runtime, + popup_command( + executable, + kind, + display.as_deref(), + wayland_display.as_deref(), + layer_shell, + ), + ) } /// Low-level construction seam used by tests and alternative launchers. @@ -861,11 +1178,229 @@ where #[cfg(test)] mod tests { use super::*; + + fn less_computer_snapshot(sequence: u64, text: &str) -> HostToPopup { + HostToPopup::LessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".to_string(), + sequence, + entries: vec![LessComputerEntry { + kind: "assistant".to_string(), + text: text.to_string(), + }], + working: true, + approval: None, + error: None, + } + } + + #[test] + fn less_computer_snapshots_drive_the_panel_state() { + // 面板只呈现宿主序列:应用快照后要能看到条目、working 与可见性。 + let mut state = PopupState::default(); + assert_eq!( + state.apply(less_computer_snapshot(1, "first")), + ApplyOutcome::Applied + ); + assert!(state.visible); + assert!(state.less_computer.working); + assert_eq!(state.less_computer.entries[0].text, "first"); + + // 单调序号:迟到的旧帧不得覆盖新正文。 + assert_eq!( + state.apply(less_computer_snapshot(2, "first+second")), + ApplyOutcome::Applied + ); + assert_eq!(state.less_computer.entries[0].text, "first+second"); + assert_eq!( + state.apply(less_computer_snapshot(1, "stale")), + ApplyOutcome::Stale + ); + assert_eq!(state.less_computer.entries[0].text, "first+second"); + + // Hide 只收起面板,不动对话内容(✕ 的语义)。 + assert_eq!( + state.apply(HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".to_string(), + sequence: 3, + }), + ApplyOutcome::Applied + ); + assert!(!state.visible); + assert_eq!(state.less_computer.entries[0].text, "first+second"); + } + + #[test] + fn less_computer_actions_are_routed_to_their_kind() { + let submit = PopupToHost::SubmitLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".to_string(), + sequence: 1, + text: "open the editor".to_string(), + }; + assert_eq!(submit.kind(), PopupKind::LessComputer); + assert_eq!(PopupKind::LessComputer.argument(), "--less-computer"); + let approve = PopupToHost::ApproveLessComputer { + version: POPUP_PROTOCOL_VERSION, + session_id: "session".to_string(), + sequence: 2, + token: "token".to_string(), + approved: false, + }; + assert_eq!(approve.kind(), PopupKind::LessComputer); + } + + #[test] + fn capsule_uses_x11_only_in_a_native_x11_session() { + assert!(force_x11_for(PopupKind::Capsule, Some(":0"), None)); + assert!(!force_x11_for(PopupKind::Capsule, None, None)); + assert!(!force_x11_for(PopupKind::Capsule, Some(" "), None)); + // DISPLAY can be XWayland: never switch away from native Wayland. + assert!(!force_x11_for( + PopupKind::Capsule, + Some(":0"), + Some("wayland-0") + )); + // The panels take keyboard input, so they keep their Wayland windows + // (选区助手面板现在也承担润色结果,同样是键盘输入的窗口)。 + assert!(!force_x11_for(PopupKind::Qa, Some(":0"), None)); + } + + #[test] + fn capsule_command_uses_x11_when_wayland_is_not_running() { + let command = popup_command( + "/usr/bin/openless-linux-egui", + PopupKind::Capsule, + Some(":0"), + None, + false, + ); + let envs: Vec<(String, Option)> = command + .as_std() + .get_envs() + .map(|(key, value)| { + ( + key.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect(); + assert!(envs.contains(&("WAYLAND_DISPLAY".to_string(), None))); + assert!(envs.contains(&("WAYLAND_SOCKET".to_string(), None))); + let args: Vec = command + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect(); + assert_eq!(args, vec!["--openless-egui-popup", "--capsule"]); + } + + /// A Wayland session with XWayland installed remains a native Wayland app: the + /// capsule keeps the session's own Wayland display and only gets the layer-shell + /// path hint. + #[test] + fn capsule_command_stays_on_native_wayland_when_layer_shell_is_available() { + let command = popup_command( + "/usr/bin/openless-linux-egui", + PopupKind::Capsule, + Some(":0"), + Some("wayland-0"), + true, + ); + let envs: Vec<_> = command.as_std().get_envs().collect(); + // `env_remove` would still show up here (with a `None` value), so "no entry at + // all" is what proves the child inherits the session's Wayland display. + assert!( + !envs + .iter() + .any(|(key, _)| *key == std::ffi::OsStr::new("WAYLAND_DISPLAY")), + "the capsule must keep the session's Wayland display: {envs:?}" + ); + assert!( + envs.iter().any(|(key, value)| { + *key == std::ffi::OsStr::new("OPENLESS_CAPSULE_PATH") + && *value == Some(std::ffi::OsStr::new("layer")) + }), + "layer-shell availability must reach the capsule: {envs:?}" + ); + } + + #[test] + fn qa_command_keeps_the_wayland_backend() { + let command = popup_command( + "/usr/bin/openless-linux-egui", + PopupKind::Qa, + Some(":0"), + Some("wayland-0"), + true, + ); + assert_eq!(command.as_std().get_envs().count(), 0); + } + + #[test] + fn capsule_command_keeps_wayland_without_an_x_server() { + let command = popup_command( + "/usr/bin/openless-linux-egui", + PopupKind::Capsule, + None, + None, + false, + ); + assert_eq!(command.as_std().get_envs().count(), 0); + } use std::io::Cursor; use std::time::{Duration, Instant}; - fn preview(text: String) -> HostToPopup { - HostToPopup::Preview { + #[test] + fn hotkey_bindings_round_trip_and_do_not_disturb_the_session_sequence() { + let bindings = + openless_core::HotkeyRuntimeTarget::from(&openless_core::UserPreferences::default()); + let message = HostToPopup::Hotkeys { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 7, + bindings: Box::new(bindings), + }; + let encoded = serde_json::to_string(&message).expect("encode hotkeys"); + let decoded: HostToPopup = serde_json::from_str(&encoded).expect("decode hotkeys"); + assert_eq!(decoded, message); + + let mut state = PopupState::default(); + assert_eq!(state.apply(message), ApplyOutcome::Applied); + assert!(state.hotkeys.is_some()); + // 配置不是会话内容:后面的内容帧序号照常从 1 开始被采纳。 + assert_eq!(state.session_id, None); + assert_eq!(state.last_sequence, 0); + let mut content = polish_preview("x".to_owned()); + if let HostToPopup::PolishPreview { sequence, .. } = &mut content { + *sequence = 1; + } + assert_eq!(state.apply(content), ApplyOutcome::Applied); + } + + #[test] + fn a_local_hotkey_edge_round_trips_from_the_popup() { + let message = PopupToHost::Hotkey { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 3, + edge: crate::LocalHotkeyEdge { + hotkey: crate::LocalHotkey::Dictation, + kind: crate::LocalHotkeyEdgeKind::Pressed, + press_id: crate::local_hotkeys::LOCAL_PRESS_ID_BASE + 1, + }, + }; + let encoded = serde_json::to_string(&message).expect("encode edge"); + let decoded: PopupToHost = serde_json::from_str(&encoded).expect("decode edge"); + assert_eq!(decoded, message); + assert_eq!(decoded.session_id(), "qa"); + assert_eq!(decoded.sequence(), 3); + assert_eq!(decoded.kind(), PopupKind::Qa); + } + + fn polish_preview(text: String) -> HostToPopup { + HostToPopup::PolishPreview { version: POPUP_PROTOCOL_VERSION, session_id: "session-一".to_owned(), sequence: 7, @@ -874,9 +1409,111 @@ mod tests { } } + #[test] + fn qa_snapshot_carries_pin_and_edit_state() { + let message = HostToPopup::QaSnapshot { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 11, + phase: "IDLE".to_owned(), + messages: Vec::new(), + selection_preview: None, + streaming_answer: String::new(), + error: None, + edit_instruction_mode: true, + edit_apply_available: true, + edit_revert_available: false, + pinned: true, + viewer_login: "octocat".to_owned(), + }; + let mut state = PopupState::default(); + assert_eq!(state.apply(message.clone()), ApplyOutcome::Applied); + assert!(state.qa.edit_instruction_mode); + assert!(state.qa.edit_apply_available); + assert!(!state.qa.edit_revert_available); + assert!(state.qa.pinned); + assert_eq!(state.qa.viewer_login, "octocat"); + + // 老宿主(协议 v2)没有这些字段时保持默认值,而不是解析失败。 + let legacy = r#"{"type":"qa_snapshot","version":2,"session_id":"qa","sequence":12,"phase":"IDLE","messages":[],"streaming_answer":""}"#; + let legacy: HostToPopup = serde_json::from_str(legacy).expect("legacy snapshot"); + let mut state = PopupState::default(); + assert_eq!(state.apply(legacy), ApplyOutcome::Applied); + assert!(!state.qa.pinned); + assert!(state.qa.viewer_login.is_empty()); + } + + #[test] + fn capsule_carries_translation_active_and_style() { + let message = HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: "dictation".to_owned(), + sequence: 3, + phase: "Recording".to_owned(), + text: String::new(), + audio_level: Some(0.5), + translation_active: true, + style: "typeless".to_owned(), + }; + let mut state = PopupState::default(); + assert_eq!(state.apply(message), ApplyOutcome::Applied); + assert!(state.capsule.translation_active); + assert_eq!( + state.capsule.style, "typeless", + "the capsule style must travel with the frame to the popup process" + ); + } + + #[test] + fn a_legacy_capsule_frame_without_a_style_still_applies() { + // 协议 v4 的帧没有 style 字段:必须按默认(siri)应用,而不是整帧丢弃。 + let legacy = r#"{"type":"capsule","version":4,"session_id":"dictation","sequence":7,"phase":"Recording","text":"","audio_level":0.3,"translation_active":false}"#; + let legacy: HostToPopup = serde_json::from_str(legacy).expect("legacy capsule frame"); + let mut state = PopupState::default(); + assert_eq!(state.apply(legacy), ApplyOutcome::Applied); + assert_eq!(state.capsule.style, ""); + } + + #[test] + fn qa_actions_are_scoped_to_the_qa_popup_and_accepted_once() { + for message in [ + PopupToHost::SetPinned { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 1, + pinned: true, + }, + PopupToHost::SetEditInstructionMode { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 2, + enabled: true, + }, + PopupToHost::ApplyEdit { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 3, + }, + PopupToHost::RevertEdit { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".to_owned(), + sequence: 4, + }, + ] { + assert_eq!(message.kind(), PopupKind::Qa); + let mut guard = PopupActionGuard::default(); + assert!(guard.accept(PopupKind::Qa, &message, "qa")); + // 同一个 sequence 不能重复执行。 + assert!(!guard.accept(PopupKind::Qa, &message, "qa")); + // 其它弹窗进程的同一 sequence 不受影响(各自独立)。 + let mut capsule = PopupActionGuard::default(); + assert!(!capsule.accept(PopupKind::Capsule, &message, "qa")); + } + } + #[test] fn jsonl_round_trip_escapes_quotes_backslashes_and_unicode() { - let expected = preview("他说:\"你好\" C:\\\\tmp\\\\文件".to_owned()); + let expected = polish_preview("他说:\"你好\" C:\\\\tmp\\\\文件".to_owned()); let mut bytes = Vec::new(); write_jsonl(&mut bytes, &expected).unwrap(); assert_eq!(bytes.last(), Some(&b'\n')); @@ -903,9 +1540,12 @@ mod tests { #[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 { + assert_eq!( + state.apply(polish_preview("new".into())), + ApplyOutcome::Applied + ); + let mut late = polish_preview("late".into()); + if let HostToPopup::PolishPreview { sequence, .. } = &mut late { *sequence = 6; } assert_eq!(state.apply(late), ApplyOutcome::Stale); @@ -915,10 +1555,18 @@ mod tests { sequence: 8, }; assert_eq!(state.apply(other), ApplyOutcome::Stale); - assert_eq!(state.preview.text, "new"); + assert_eq!( + state + .qa + .polish + .as_ref() + .map(|p| p.text.clone()) + .unwrap_or_default(), + "new" + ); - let mut next_session = preview("next".into()); - if let HostToPopup::Preview { + let mut next_session = polish_preview("next".into()); + if let HostToPopup::PolishPreview { session_id, sequence, .. @@ -928,12 +1576,20 @@ mod tests { *sequence = 1; } assert_eq!(state.apply(next_session), ApplyOutcome::Applied); - let mut retired = preview("retired".into()); - if let HostToPopup::Preview { sequence, .. } = &mut retired { + let mut retired = polish_preview("retired".into()); + if let HostToPopup::PolishPreview { sequence, .. } = &mut retired { *sequence = 99; } assert_eq!(state.apply(retired), ApplyOutcome::Stale); - assert_eq!(state.preview.text, "next"); + assert_eq!( + state + .qa + .polish + .as_ref() + .map(|p| p.text.clone()) + .unwrap_or_default(), + "next" + ); } #[test] @@ -954,7 +1610,7 @@ mod tests { sequence: 1, }; assert!(!guard.accept(PopupKind::Qa, &stale, "qa-session")); - assert!(!guard.accept(PopupKind::Preview, &submit, "qa-session")); + assert!(!guard.accept(PopupKind::Qa, &submit, "qa-session")); assert!(!guard.accept(PopupKind::Qa, &submit, "new-session")); } @@ -965,19 +1621,19 @@ mod tests { version: POPUP_PROTOCOL_VERSION, session_id: "session".into(), sequence: 1, - kind: PopupKind::Preview, + kind: PopupKind::Qa, }; - 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")); + assert!(guard.accept(PopupKind::Qa, &ready, "session")); + assert!(!guard.accept(PopupKind::Qa, &ready, "session")); + guard.reset(PopupKind::Qa); + assert!(guard.accept(PopupKind::Qa, &ready, "session")); } #[test] fn popup_messages_are_bound_to_their_process_kind() { assert_eq!( - preview("text".into()).content_kind(), - Some(PopupKind::Preview) + polish_preview("text".into()).content_kind(), + Some(PopupKind::Qa) ); let hide = HostToPopup::Hide { version: POPUP_PROTOCOL_VERSION, @@ -995,6 +1651,31 @@ mod tests { assert!(!guard.accept(PopupKind::Capsule, &wrong_version, "session")); } + #[tokio::test] + async fn shutdown_ends_the_popup_process_so_nothing_is_left_on_screen() { + // 自动收起(听写终态 2 秒/取消立即)靠的是结束弹窗进程:胶囊的 + // layer surface 只能随进程销毁,进程留着就会有一颗药丸永远贴屏。 + let mut command = Command::new("/bin/sh"); + command.arg("-c").arg("sleep 30"); + let supervisor = PopupSupervisor::spawn_command(&Handle::current(), command); + supervisor + .request_shutdown() + .expect("a fresh supervisor accepts shutdown"); + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match supervisor.try_recv() { + Ok(PopupSupervisorEvent::Exited { crashed, .. }) => { + assert!(!crashed, "a requested shutdown must not look like a crash"); + break; + } + Ok(_) | Err(mpsc::TryRecvError::Empty) if Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(10)).await; + } + result => panic!("popup process survived shutdown: {result:?}"), + } + } + } + #[tokio::test] async fn supervisor_reaps_a_crashed_child() { let mut command = Command::new("/bin/sh"); diff --git a/openless-all/app/linux-egui/src/popup_layer.rs b/openless-all/app/linux-egui/src/popup_layer.rs new file mode 100644 index 000000000..03e51f7c8 --- /dev/null +++ b/openless-all/app/linux-egui/src/popup_layer.rs @@ -0,0 +1,933 @@ +//! Native Wayland surface for the recording capsule: `zwlr_layer_shell_v1`. +//! +//! The capsule must sit at the bottom centre of the screen and must **never** +//! take the keyboard — the user is dictating into another window, and a focus +//! steal would send the insert to the wrong place. `xdg-shell` offers neither an +//! absolute position nor a focus opt-out, so Wayland-only compositors without +//! layer-shell do not show the capsule. Compositors that +//! implement `zwlr_layer_shell_v1` — KWin 6.7+ (verified against +//! `zwlr_layer_shell_v1` version 5), sway, Hyprland, labwc, … — can host the +//! capsule natively instead: +//! +//! * `Anchor::Bottom` + `margin.bottom` places the pill bottom-centre without +//! the client ever knowing the screen size (a surface with only one +//! horizontal anchor is centred by the compositor, and the input region stays +//! the pill's own box instead of the whole bottom strip), +//! * `KeyboardInteractivity::None` makes keyboard focus impossible, +//! * `exclusive_zone(0)` keeps the capsule non-exclusive while asking the +//! compositor to respect reserved panel/taskbar space. +//! +//! Rendering reuses the popup's existing egui view ([`crate::ui::frontend::popups::dictation_capsule`]): +//! the runner below owns the EGL context (glutin), the `egui_glow` painter and +//! the wayland event loop, and asks the caller for one egui frame at a time. +//! +//! Everything that can be decided without a compositor lives in pure functions +//! ([`has_layer_shell`], [`choose_capsule_path`], [`capsule_geometry`], +//! [`pointer_events`]) so the policy is unit-testable; the I/O half is a thin +//! shell around them and reports failures as `Err`. + +use std::ffi::c_void; +use std::num::NonZeroU32; +use std::ptr::NonNull; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use wayland_client::protocol::{wl_compositor, wl_pointer, wl_registry, wl_seat, wl_surface}; +use wayland_client::{ + globals::{registry_queue_init, GlobalListContents}, + Connection, Dispatch, Proxy, QueueHandle, WEnum, +}; +use wayland_protocols_wlr::layer_shell::v1::client::{zwlr_layer_shell_v1, zwlr_layer_surface_v1}; + +/// The layer-shell global this module needs. +pub const LAYER_SHELL_GLOBAL: &str = "zwlr_layer_shell_v1"; +/// Wayland namespace of the capsule surface (shows up in compositor logs and +/// `swaymsg -t get_tree`-style tooling). +pub const LAYER_NAMESPACE: &str = "openless-capsule"; +/// How long to wait for the first `configure` event before giving up and +/// falling back to the X11 overlay. +pub const CONFIGURE_TIMEOUT: Duration = Duration::from_secs(3); +/// Upper bound for one sleep between frames, so protocol events are never +/// starved even when the view asks for a long pause. +pub const MAX_FRAME_PAUSE: Duration = Duration::from_millis(100); + +// ── pure decision core ────────────────────────────────────────────────────── + +/// Whether the compositor advertised `zwlr_layer_shell_v1`. +pub fn has_layer_shell(globals: &[String]) -> bool { + globals.iter().any(|global| global == LAYER_SHELL_GLOBAL) +} + +/// Whether a Wayland session is present (`WAYLAND_DISPLAY` non-empty). +pub fn wayland_display_available(display: Option<&str>) -> bool { + display.is_some_and(|value| !value.trim().is_empty()) +} + +/// How the capsule window should be hosted. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CapsulePath { + /// Native layer surface (bottom-centre, keyboard-impossible). + LayerShell, + /// The existing native X11 overlay in [`crate::popup_window`]. + X11Overlay, + /// Wayland is active but layer-shell is unavailable. Do not use a focusable + /// xdg-shell toplevel or route the surface through XWayland. + PlainWindow, +} + +/// Pick the capsule host. Wayland uses layer-shell or no capsule; X11 is used +/// only when the whole desktop session is native X11. +pub fn choose_capsule_path( + wayland_display: Option<&str>, + x11_display: Option<&str>, + globals: &[String], +) -> CapsulePath { + if wayland_display_available(wayland_display) { + return if has_layer_shell(globals) { + CapsulePath::LayerShell + } else { + CapsulePath::PlainWindow + }; + } + if crate::popup_window::x11_available(x11_display) { + return CapsulePath::X11Overlay; + } + CapsulePath::PlainWindow +} + +/// Environment variable that pins the capsule host for local diagnostics. +pub const CAPSULE_PATH_ENV: &str = "OPENLESS_CAPSULE_PATH"; + +/// Parse [`CAPSULE_PATH_ENV`]. Unknown or empty values are ignored so a typo +/// cannot leave the capsule without a window. +pub fn capsule_path_override(value: Option<&str>) -> Option { + match value?.trim().to_ascii_lowercase().as_str() { + "layer" | "layer-shell" | "layer_shell" => Some(CapsulePath::LayerShell), + "x11" => Some(CapsulePath::X11Overlay), + "plain" | "none" => Some(CapsulePath::PlainWindow), + _ => None, + } +} + +/// Whether the compositor offers `zwlr_layer_shell_v1`, memoised: the capsule is +/// launched once per dictation and probing opens a Wayland connection. +/// +/// [`CAPSULE_PATH_ENV`] wins over the probe, and both this and +/// [`detect_capsule_path`] read it, so the parent (which decides the child's +/// backend) and the child (which decides how to host the window) always agree. +pub fn layer_shell_available() -> bool { + static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + *CACHE.get_or_init(|| { + if let Some(forced) = capsule_path_override(std::env::var(CAPSULE_PATH_ENV).ok().as_deref()) + { + return forced == CapsulePath::LayerShell; + } + probe_layer_shell(std::env::var("WAYLAND_DISPLAY").ok().as_deref()) + }) +} + +/// Decide how this process should host the capsule: an explicit override wins, +/// otherwise probe the live session and apply [`choose_capsule_path`]. Never +/// fails; a missing session, a failed connection or an absent global all end up +/// on the fallback path. +pub fn detect_capsule_path() -> CapsulePath { + let wayland = std::env::var("WAYLAND_DISPLAY").ok(); + let x11 = std::env::var("DISPLAY").ok(); + if wayland_display_available(wayland.as_deref()) { + if let Some(forced) = capsule_path_override(std::env::var(CAPSULE_PATH_ENV).ok().as_deref()) + { + if forced != CapsulePath::X11Overlay { + log::info!("capsule path forced by {CAPSULE_PATH_ENV}: {forced:?}"); + return forced; + } + log::warn!("ignoring X11 capsule override in a Wayland session"); + } + return match probe_globals() { + Ok(globals) if has_layer_shell(&globals) => CapsulePath::LayerShell, + Ok(_) => CapsulePath::PlainWindow, + Err(error) => { + log::warn!("layer-shell probe failed ({error}); not using XWayland"); + CapsulePath::PlainWindow + } + }; + } + if let Some(forced) = capsule_path_override(std::env::var(CAPSULE_PATH_ENV).ok().as_deref()) { + log::info!("capsule path forced by {CAPSULE_PATH_ENV}: {forced:?}"); + return forced; + } + choose_capsule_path(None, x11.as_deref(), &[]) +} + +/// Interface names the compositor advertises, or an error when there is no +/// usable Wayland connection. +fn probe_globals() -> Result, String> { + let connection = Connection::connect_to_env().map_err(|error| format!("wayland: {error}"))?; + // `registry_queue_init` already round-trips the registry and hands back the + // `GlobalList` it collected. Dispatching a second time into our own state + // collects nothing (the global events are already consumed), which would + // make every compositor look like it lacks layer-shell. + let (globals, _queue) = registry_queue_init::(&connection) + .map_err(|error| format!("wayland registry: {error}"))?; + Ok(globals + .contents() + .with_list(|list| list.iter().map(|global| global.interface.clone()).collect())) +} + +/// Layer-surface geometry: the surface is a fixed-size child of the compositor, +/// so only the size and the bottom margin matter. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct CapsuleGeometry { + pub width: u32, + pub height: u32, + pub bottom_gap: i32, +} + +impl CapsuleGeometry { + /// The buffer size the compositor is asked for. Zero would be rejected by + /// the protocol, so both axes clamp to at least one pixel. + pub fn buffer_size(self) -> (u32, u32) { + (self.width.max(1), self.height.max(1)) + } + + /// egui viewport for one frame, matching the requested buffer size. + pub fn rect(self) -> egui::Rect { + let (width, height) = self.buffer_size(); + egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, height as f32)) + } + + /// Ignore a compositor-suggested size of zero (the protocol allows it while + /// the surface is still unconfigured) and otherwise follow the compositor. + pub fn rect_for_configure(self, configure: (u32, u32)) -> egui::Rect { + let (width, height) = match configure { + (0, _) | (_, 0) => self.buffer_size(), + (width, height) => (width, height), + }; + egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width as f32, height as f32)) + } +} + +/// Capsule geometry from the shared window-size constants. +pub fn capsule_geometry(width: u32, height: u32, bottom_gap: i32) -> CapsuleGeometry { + CapsuleGeometry { + width: width.max(1), + height: height.max(1), + bottom_gap: bottom_gap.max(0), + } +} + +/// Translate pointer samples into egui events. Pure so the mapping is testable +/// without a compositor; `pressed` carries the button state at that point. +pub fn pointer_events( + position: Option, + buttons: &[(egui::Pos2, egui::PointerButton, bool)], + left: bool, +) -> Vec { + let mut events = Vec::new(); + if let Some(position) = position { + events.push(egui::Event::PointerMoved(position)); + } + for (position, button, pressed) in buttons { + events.push(egui::Event::PointerButton { + pos: *position, + button: *button, + pressed: *pressed, + modifiers: egui::Modifiers::default(), + }); + } + if left { + events.push(egui::Event::PointerGone); + } + events +} + +/// One frame produced by the caller: the egui output plus the loop's control +/// flow. +pub struct LayerFrame { + /// Result of `egui::Context::run` for this frame. + pub output: egui::FullOutput, + /// Ask the runner to stop (host requested shutdown, or the user pressed + /// cancel / confirm). + pub exit: bool, + /// When to draw the next frame. + pub repaint_after: Duration, +} + +// ── compositor probe ──────────────────────────────────────────────────────── + +/// Ask the running compositor whether it implements `zwlr_layer_shell_v1`. +/// +/// Returns `false` (never an error) when there is no Wayland session, when the +/// connection fails, or when the global is missing — the caller then falls back. +pub fn probe_layer_shell(wayland_display: Option<&str>) -> bool { + if !wayland_display_available(wayland_display) { + return false; + } + probe_globals().is_ok_and(|globals| has_layer_shell(&globals)) +} + +// ── wayland state ─────────────────────────────────────────────────────────── + +/// Everything the capsule surface needs from the compositor, plus the pointer +/// events collected between frames. +#[derive(Default)] +struct LayerState { + compositor: Option, + layer_shell: Option, + seat: Option, + pointer: Option, + pointer_position: Option, + /// Set when the pointer actually moved (or entered) since the last frame, so + /// a stationary pointer does not emit a motion event every frame. + pointer_moved: bool, + pressed: Vec<(egui::Pos2, egui::PointerButton, bool)>, + pointer_left: bool, + configure: Option<(u32, u32)>, + closed: bool, +} + +impl LayerState { + /// Take the pointer events collected since the last frame. + fn take_input(&mut self, rect: egui::Rect) -> egui::RawInput { + let events = pointer_events( + None, + &std::mem::take(&mut self.pressed), + std::mem::take(&mut self.pointer_left), + ); + // PointerMoved must lead so egui's interaction position is current + // before the button events are applied. + let mut all = Vec::with_capacity(events.len() + 1); + if std::mem::take(&mut self.pointer_moved) { + if let Some(position) = self.pointer_position { + all.push(egui::Event::PointerMoved(position)); + } + } + all.extend(events); + egui::RawInput { + screen_rect: Some(rect), + events: all, + focused: true, + ..Default::default() + } + } +} + +fn button_from_code(button: u32) -> Option { + match button { + 0x110 => Some(egui::PointerButton::Primary), + 0x111 => Some(egui::PointerButton::Secondary), + 0x112 => Some(egui::PointerButton::Middle), + _ => None, + } +} + +impl Dispatch for LayerState { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _data: &GlobalListContents, + _connection: &Connection, + qh: &QueueHandle, + ) { + let wl_registry::Event::Global { + name, + interface, + version, + } = event + else { + return; + }; + match interface.as_str() { + "wl_compositor" => { + state.compositor = Some(registry.bind(name, version.min(4), qh, ())); + } + // Layer shell is at version 4 in the widest-deployed compositors and + // 5 in KWin 6.7; everything this module sets exists since version 1. + LAYER_SHELL_GLOBAL => { + state.layer_shell = Some(registry.bind(name, version.min(4), qh, ())); + } + "wl_seat" => { + state.seat = Some(registry.bind(name, version.min(7), qh, ())); + } + _ => {} + } + } +} + +macro_rules! ignore_events { + ($interface:ty) => { + impl Dispatch<$interface, ()> for LayerState { + fn event( + _state: &mut Self, + _proxy: &$interface, + _event: <$interface as Proxy>::Event, + _data: &(), + _connection: &Connection, + _qh: &QueueHandle, + ) { + } + } + }; +} + +ignore_events!(wl_compositor::WlCompositor); +ignore_events!(wl_surface::WlSurface); + +impl Dispatch for LayerState { + fn event( + state: &mut Self, + seat: &wl_seat::WlSeat, + event: wl_seat::Event, + _data: &(), + _connection: &Connection, + qh: &QueueHandle, + ) { + if let wl_seat::Event::Capabilities { + capabilities: WEnum::Value(capabilities), + } = event + { + if capabilities.contains(wl_seat::Capability::Pointer) && state.pointer.is_none() { + state.pointer = Some(seat.get_pointer(qh, ())); + } + } + } +} + +impl Dispatch for LayerState { + fn event( + state: &mut Self, + _pointer: &wl_pointer::WlPointer, + event: wl_pointer::Event, + _data: &(), + _connection: &Connection, + _qh: &QueueHandle, + ) { + match event { + wl_pointer::Event::Enter { + surface_x, + surface_y, + .. + } + | wl_pointer::Event::Motion { + surface_x, + surface_y, + .. + } => { + state.pointer_position = Some(egui::pos2(surface_x as f32, surface_y as f32)); + state.pointer_moved = true; + } + wl_pointer::Event::Leave { .. } => { + state.pointer_position = None; + state.pointer_left = true; + } + wl_pointer::Event::Button { + button, + state: button_state, + .. + } => { + let WEnum::Value(button_state) = button_state else { + return; + }; + let Some(position) = state.pointer_position else { + return; + }; + let Some(button) = button_from_code(button) else { + return; + }; + let pressed = button_state == wl_pointer::ButtonState::Pressed; + state.pressed.push((position, button, pressed)); + } + _ => {} + } + } +} + +ignore_events!(zwlr_layer_shell_v1::ZwlrLayerShellV1); + +impl Dispatch for LayerState { + fn event( + state: &mut Self, + layer_surface: &zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, + event: zwlr_layer_surface_v1::Event, + _data: &(), + _connection: &Connection, + _qh: &QueueHandle, + ) { + match event { + zwlr_layer_surface_v1::Event::Configure { + serial, + width, + height, + } => { + layer_surface.ack_configure(serial); + state.configure = Some((width, height)); + } + zwlr_layer_surface_v1::Event::Closed => state.closed = true, + _ => {} + } + } +} + +// ── EGL + egui_glow plumbing ──────────────────────────────────────────────── + +struct GlSurface { + _display: glutin::display::Display, + surface: glutin::surface::Surface, + context: glutin::context::PossiblyCurrentContext, + gl: Arc, +} + +impl GlSurface { + fn new( + connection: &Connection, + wl_surface: &wl_surface::WlSurface, + size: (u32, u32), + ) -> Result { + let size = (size.0.max(1), size.1.max(1)); + use glutin::config::{Api, ConfigTemplateBuilder}; + use glutin::context::{ContextApi, ContextAttributesBuilder}; + use glutin::display::{Display, DisplayApiPreference, GlDisplay}; + use glutin::prelude::*; + use glutin::surface::{SurfaceAttributesBuilder, WindowSurface}; + use raw_window_handle::{ + RawDisplayHandle, RawWindowHandle, WaylandDisplayHandle, WaylandWindowHandle, + }; + + let display_ptr = connection.backend().display_ptr().cast::(); + // `ObjectId::as_ptr` hands back the underlying `wl_proxy`, which is the + // same C object as the `wl_surface` glutin wants. + let surface_ptr = wl_surface.id().as_ptr().cast::(); + + let raw_display = RawDisplayHandle::Wayland(WaylandDisplayHandle::new( + NonNull::new(display_ptr).ok_or("null wayland display")?, + )); + let raw_window = RawWindowHandle::Wayland(WaylandWindowHandle::new( + NonNull::new(surface_ptr).ok_or("null wayland surface")?, + )); + + let display = unsafe { Display::new(raw_display, DisplayApiPreference::Egl) } + .map_err(|error| format!("egl display: {error}"))?; + let template = ConfigTemplateBuilder::new() + .with_alpha_size(8) + .with_transparency(true) + .with_api(Api::OPENGL | Api::GLES2) + .build(); + let config = unsafe { display.find_configs(template) } + .map_err(|error| format!("egl configs: {error}"))? + .reduce(|best, candidate| { + let best_alpha = best.alpha_size(); + let candidate_alpha = candidate.alpha_size(); + if candidate_alpha > best_alpha { + candidate + } else { + best + } + }) + .ok_or("no suitable EGL config")?; + let attributes = SurfaceAttributesBuilder::::new().build( + raw_window, + NonZeroU32::new(size.0.max(1)).ok_or("zero width")?, + NonZeroU32::new(size.1.max(1)).ok_or("zero height")?, + ); + let surface = unsafe { display.create_window_surface(&config, &attributes) } + .map_err(|error| format!("egl window surface: {error}"))?; + let context_attributes = ContextAttributesBuilder::new() + .with_context_api(ContextApi::OpenGl(None)) + .build(Some(raw_window)); + let context = unsafe { display.create_context(&config, &context_attributes) } + .map_err(|error| format!("egl context: {error}"))? + .make_current(&surface) + .map_err(|error| format!("egl make current: {error}"))?; + let gl = unsafe { + glow::Context::from_loader_function(|symbol| { + let symbol = std::ffi::CString::new(symbol) + .map_err(|_| ()) + .unwrap_or_default(); + display.get_proc_address(symbol.as_c_str()) + }) + }; + Ok(Self { + _display: display, + surface, + context, + gl: Arc::new(gl), + }) + } + + fn paint( + &self, + painter: &mut egui_glow::Painter, + size: (u32, u32), + primitives: &[egui::ClippedPrimitive], + textures_delta: &mut egui::TexturesDelta, + scale: f32, + ) { + // The capsule is a transparent overlay: the painter clears the buffer + // itself, so no opaque clear colour is needed. + painter.paint_and_update_textures( + [size.0.max(1), size.1.max(1)], + scale, + primitives, + textures_delta, + ); + } +} + +// ── runner ────────────────────────────────────────────────────────────────── + +/// Host the capsule on a `wlr-layer-shell` surface until `frame` asks to exit or +/// the compositor closes it. +/// +/// Fails (with a human-readable reason) when there is no Wayland session, the +/// compositor lacks the protocol, the configure never arrives, or EGL could not +/// be initialised — the caller then uses the X11 overlay instead. The context is +/// created here; the caller installs fonts/visuals on the first frame (egui's +/// built-in fonts are enough for the pill, but a host font setup should run +/// once). +/// Bind the registry objects this module needs from the list +/// `registry_queue_init` collected. +/// +/// The global events are consumed during `registry_queue_init`, so a later +/// `roundtrip` into our own state never sees them: binding through the returned +/// `GlobalList` is the only way the compositor, layer-shell and seat objects +/// exist at all. +fn bind_globals( + globals: &wayland_client::globals::GlobalList, + qh: &QueueHandle, +) -> LayerState { + let registry = globals.registry(); + let mut state = LayerState::default(); + for global in globals.contents().clone_list() { + match global.interface.as_str() { + "wl_compositor" => { + state.compositor = Some(registry.bind(global.name, global.version.min(4), qh, ())); + } + // Layer shell is at version 4 in the widest-deployed compositors and + // 5 in KWin 6.7; everything this module sets exists since version 1. + LAYER_SHELL_GLOBAL => { + state.layer_shell = Some(registry.bind(global.name, global.version.min(4), qh, ())); + } + "wl_seat" => { + state.seat = Some(registry.bind(global.name, global.version.min(7), qh, ())); + } + _ => {} + } + } + state +} + +pub fn run_layer_capsule(geometry: CapsuleGeometry, mut frame: F) -> Result<(), String> +where + F: FnMut(&egui::Context, egui::RawInput, bool) -> LayerFrame, +{ + let connection = Connection::connect_to_env().map_err(|error| format!("wayland: {error}"))?; + let (globals, mut queue) = registry_queue_init::(&connection) + .map_err(|error| format!("wayland registry: {error}"))?; + let qh = queue.handle(); + let mut state = bind_globals(&globals, &qh); + // One roundtrip so the seat capabilities arrive and the pointer exists. + queue + .roundtrip(&mut state) + .map_err(|error| format!("wayland roundtrip: {error}"))?; + let compositor = state + .compositor + .clone() + .ok_or("no wl_compositor on this compositor")?; + let layer_shell = state + .layer_shell + .clone() + .ok_or("compositor has no zwlr_layer_shell_v1")?; + + let wl_surface = compositor.create_surface(&qh, ()); + let layer_surface = layer_shell.get_layer_surface( + &wl_surface, + None, + // Stay below true overlay surfaces such as desktop panels. + zwlr_layer_shell_v1::Layer::Top, + LAYER_NAMESPACE.to_string(), + &qh, + (), + ); + let (width, height) = geometry.buffer_size(); + layer_surface.set_size(width, height); + let _ = (width, height); + // Bottom only: a single horizontal anchor leaves the surface centred while + // keeping the input region at the pill instead of the whole bottom strip. + layer_surface.set_anchor(zwlr_layer_surface_v1::Anchor::Bottom); + layer_surface.set_keyboard_interactivity(zwlr_layer_surface_v1::KeyboardInteractivity::None); + // Zero does not reserve extra space, but keeps placement inside the + // compositor's usable area. -1 explicitly allows overlap with panels. + layer_surface.set_exclusive_zone(0); + layer_surface.set_margin(0, 0, geometry.bottom_gap, 0); + wl_surface.commit(); + + let deadline = Instant::now() + CONFIGURE_TIMEOUT; + while state.configure.is_none() { + if state.closed { + return Err("layer surface closed before configure".to_string()); + } + if Instant::now() >= deadline { + return Err("layer surface configure timed out".to_string()); + } + queue + .blocking_dispatch(&mut state) + .map_err(|error| format!("wayland dispatch: {error}"))?; + } + + // Milestone lines for real-machine verification: they land on the popup + // process' inherited stderr (terminal or journal), since the popup installs + // no logger of its own. + let (configured_width, configured_height) = state.configure.unwrap_or((width, height)); + eprintln!( + "OpenLess capsule: layer surface configured {configured_width}x{configured_height} \ + (layer=top, anchor=bottom, margin.bottom={}, keyboard-interactivity=none, exclusive-zone=0)", + geometry.bottom_gap + ); + let gl = GlSurface::new(&connection, &wl_surface, geometry.buffer_size())?; + use glutin::surface::GlSurface as _; + let mut painter = egui_glow::Painter::new(gl.gl.clone(), "", None, false) + .map_err(|error| format!("egui_glow painter: {error}"))?; + eprintln!("OpenLess capsule: EGL ready on the layer surface"); + let context = egui::Context::default(); + // This EGL layer surface is single-sample; feather egui geometry instead. + context.tessellation_options_mut(|options| { + options.feathering = true; + options.feathering_size_in_pixels = 1.0; + }); + let scale = 1.0; + let mut first = true; + + loop { + queue + .dispatch_pending(&mut state) + .map_err(|error| format!("wayland dispatch: {error}"))?; + if state.closed { + return Ok(()); + } + let _ = connection.flush(); + let rect = geometry.rect_for_configure(state.configure.unwrap_or((width, height))); + let input = state.take_input(rect); + let mut frame = frame(&context, input, first); + first = false; + let primitives = context.tessellate(frame.output.shapes.clone(), scale); + gl.paint( + &mut painter, + (rect.width().max(1.0) as u32, rect.height().max(1.0) as u32), + &primitives, + &mut frame.output.textures_delta, + scale, + ); + gl.surface + .swap_buffers(&gl.context) + .map_err(|error| format!("egl swap: {error}"))?; + if frame.exit { + return Ok(()); + } + std::thread::sleep(frame.repaint_after.min(MAX_FRAME_PAUSE)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn globals(names: &[&str]) -> Vec { + names.iter().map(|name| (*name).to_string()).collect() + } + + #[test] + fn detects_the_layer_shell_global() { + assert!(has_layer_shell(&globals(&[ + "wl_compositor", + LAYER_SHELL_GLOBAL, + "wl_seat", + ]))); + assert!(!has_layer_shell(&globals(&["wl_compositor", "wl_seat"]))); + assert!(!has_layer_shell(&[])); + } + + #[test] + fn layer_shell_wins_over_the_x11_overlay() { + let advertised = globals(&[LAYER_SHELL_GLOBAL]); + assert_eq!( + choose_capsule_path(Some("wayland-0"), Some(":0"), &advertised), + CapsulePath::LayerShell + ); + // Wayland without layer-shell must not use the XWayland display. + assert_eq!( + choose_capsule_path(Some("wayland-0"), Some(":0"), &globals(&["wl_compositor"])), + CapsulePath::PlainWindow + ); + // Wayland session without XWayland and without layer-shell. + assert_eq!( + choose_capsule_path(Some("wayland-0"), None, &globals(&["wl_compositor"])), + CapsulePath::PlainWindow + ); + // Pure X11 session. + assert_eq!( + choose_capsule_path(None, Some(":0"), &globals(&[LAYER_SHELL_GLOBAL])), + CapsulePath::X11Overlay + ); + } + + #[test] + fn a_blank_display_variable_is_not_a_wayland_session() { + assert!(!wayland_display_available(Some(""))); + assert!(!wayland_display_available(Some(" "))); + assert!(!wayland_display_available(None)); + assert!(wayland_display_available(Some("wayland-0"))); + } + + /// 真机验证用:在一台真的连着合成器的机器上跑 + /// `cargo test -p openless-linux-egui -- --ignored --nocapture capsule_decision` + /// 就能看到这台机器实际会走哪条路径。 + #[test] + #[ignore = "requires a live Wayland / X11 session"] + fn capsule_decision_on_this_machine() { + let wayland = std::env::var("WAYLAND_DISPLAY").ok(); + let x11 = std::env::var("DISPLAY").ok(); + let globals = probe_globals(); + println!("WAYLAND_DISPLAY={wayland:?} DISPLAY={x11:?}"); + match &globals { + Ok(globals) => println!( + "globals with layer-shell: {}", + globals + .iter() + .filter(|g| g.as_str() == LAYER_SHELL_GLOBAL) + .count() + ), + Err(error) => println!("globals unavailable: {error}"), + } + println!( + "probe_layer_shell={} layer_shell_available={} detect_capsule_path={:?}", + probe_layer_shell(wayland.as_deref()), + layer_shell_available(), + detect_capsule_path() + ); + } + + #[test] + fn capsule_path_override_parses_the_documented_values() { + assert_eq!( + capsule_path_override(Some("layer")), + Some(CapsulePath::LayerShell) + ); + assert_eq!( + capsule_path_override(Some(" Layer-Shell ")), + Some(CapsulePath::LayerShell) + ); + assert_eq!( + capsule_path_override(Some("x11")), + Some(CapsulePath::X11Overlay) + ); + assert_eq!(capsule_path_override(Some("XWayland")), None); + assert_eq!( + capsule_path_override(Some("plain")), + Some(CapsulePath::PlainWindow) + ); + // A typo must never take the capsule off every path. + assert_eq!(capsule_path_override(Some("")), None); + assert_eq!(capsule_path_override(Some("layerish")), None); + assert_eq!(capsule_path_override(None), None); + } + + /// The parent picks the child's backend from the same override the child + /// uses to pick its window, so the two can never disagree: an override of + /// `layer` is the only one that must keep the Wayland connection alive. + #[test] + fn the_override_keeps_the_parent_and_child_in_agreement() { + for (value, layer_shell, path) in [ + ("layer", true, CapsulePath::LayerShell), + ("x11", false, CapsulePath::X11Overlay), + ("plain", false, CapsulePath::PlainWindow), + ] { + let forced = capsule_path_override(Some(value)); + assert_eq!(forced, Some(path), "override {value}"); + // What `layer_shell_available` returns for that override… + assert_eq!(forced == Some(CapsulePath::LayerShell), layer_shell); + // …and what the child derives from the same value. + assert_eq!(forced, Some(path)); + } + } + + #[test] + fn capsule_geometry_clamps_to_a_paintable_surface() { + let geometry = capsule_geometry(200, 100, 12); + assert_eq!(geometry.buffer_size(), (200, 100)); + assert_eq!(geometry.bottom_gap, 12); + let degenerate = capsule_geometry(0, 0, -5); + assert_eq!(degenerate.buffer_size(), (1, 1)); + assert_eq!(degenerate.bottom_gap, 0); + assert_eq!(degenerate.rect().size(), egui::vec2(1.0, 1.0)); + } + + #[test] + fn a_zero_configure_keeps_the_requested_size() { + let geometry = capsule_geometry(200, 100, 12); + assert_eq!( + geometry.rect_for_configure((0, 0)).size(), + egui::vec2(200.0, 100.0) + ); + // A compositor that resizes the surface wins once it reports pixels. + assert_eq!( + geometry.rect_for_configure((240, 120)).size(), + egui::vec2(240.0, 120.0) + ); + } + + #[test] + fn pointer_samples_become_egui_events() { + let position = Some(egui::pos2(4.0, 5.0)); + let events = pointer_events( + position, + &[(egui::pos2(4.0, 5.0), egui::PointerButton::Primary, true)], + false, + ); + assert_eq!(events.len(), 2); + assert!(matches!(events[0], egui::Event::PointerMoved(_))); + match &events[1] { + egui::Event::PointerButton { + button, pressed, .. + } => { + assert_eq!(*button, egui::PointerButton::Primary); + assert!(pressed); + } + other => panic!("expected a button event, got {other:?}"), + } + } + + #[test] + fn leaving_the_surface_reports_a_gone_pointer() { + let events = pointer_events(None, &[], true); + assert_eq!(events.len(), 1); + assert!(matches!(events[0], egui::Event::PointerGone)); + } + + #[test] + fn mouse_buttons_map_to_egui_buttons() { + assert_eq!(button_from_code(0x110), Some(egui::PointerButton::Primary)); + assert_eq!( + button_from_code(0x111), + Some(egui::PointerButton::Secondary) + ); + assert_eq!(button_from_code(0x112), Some(egui::PointerButton::Middle)); + assert_eq!(button_from_code(0x113), None); + } + + #[test] + fn input_carries_the_viewport_and_pending_clicks() { + let mut state = LayerState { + pointer_position: Some(egui::pos2(1.0, 2.0)), + pointer_moved: true, + pressed: vec![(egui::pos2(1.0, 2.0), egui::PointerButton::Primary, true)], + ..Default::default() + }; + let rect = capsule_geometry(200, 100, 12).rect(); + let input = state.take_input(rect); + assert_eq!(input.screen_rect, Some(rect)); + assert_eq!(input.events.len(), 2); + // Drained: the next frame starts clean. + let next = state.take_input(rect); + assert!(next.events.is_empty()); + } +} diff --git a/openless-all/app/linux-egui/src/popup_window.rs b/openless-all/app/linux-egui/src/popup_window.rs new file mode 100644 index 000000000..a0f4a6b16 --- /dev/null +++ b/openless-all/app/linux-egui/src/popup_window.rs @@ -0,0 +1,1237 @@ +//! Overlay placement for the popup processes: bottom-centre, never take focus. +//! +//! The recording capsule is a pure overlay: it shows the recording state and +//! offers cancel / confirm, but it must **never** take the keyboard — the user +//! is dictating into *another* window, and stealing focus there would send the +//! insert to the wrong place. Tauri gets exactly this guarantee on macOS from +//! `NSWindow::orderFrontRegardless` ("visible but not the key window", see +//! the Tauri host's `show_qa_window`). Wayland's xdg-shell offers +//! neither an absolute position nor a focus opt-out, so this X11 path is used +//! only in a native X11 session, where: +//! +//! * `WM_HINTS.input = False` makes the window manager never assign focus +//! (pointer clicks still reach the pill's ✕ / ✓ buttons), +//! * `_NET_WM_WINDOW_TYPE_UTILITY` keeps KWin from applying its OSD-style +//! placement to the pill (the notification look is asked for explicitly with +//! `_NET_WM_STATE_ABOVE` + `SKIP_TASKBAR` instead), and +//! * an explicit `ConfigureWindow` places it at the bottom centre of the work +//! area, with the ICCCM `USPosition` hint so the manager keeps those +//! coordinates, mirroring the Tauri host's +//! `position_capsule_bottom_center_with_style`. +//! +//! The focus is only taken back when `_NET_ACTIVE_WINDOW` really is the pill: +//! re-reading it after the move keeps the overlay from yanking the keyboard +//! away from whatever window the user moved on to. +//! +//! The maths lives in [`OverlayEnvironment`] so it is unit-testable, and every +//! X11 mutation goes through the [`OverlayX11`] trait so the request sequence +//! can be asserted without an X server. + +/// Capsule window size (the 176×42 pill plus room for the translate badge). +pub const CAPSULE_WINDOW_SIZE: (u32, u32) = (200, 100); +/// Gap between the capsule pill and the bottom of the work area — Tauri's +/// `EDGE_GAP` for the classic / siri capsule styles. +pub const CAPSULE_BOTTOM_GAP: i32 = 12; +/// Selection-ask panel size (the chat panel) — Tauri `qa` window is 420×540. +pub const QA_WINDOW_SIZE: (u32, u32) = (420, 540); +/// Less Computer panel size — Tauri `less-computer` is 420×540, the same +/// footprint as the selection-ask panel. +pub const LESS_COMPUTER_WINDOW_SIZE: (u32, u32) = (420, 540); +// 旧的独立润色预览窗口(Tauri `selection-polish-preview` 640×440 / min 480×320) +// 连同 `PREVIEW_WINDOW_SIZE`、`PREVIEW_MIN_SIZE` 一起下线,Tauri 侧的独立窗口也已 +// 删除:润色结果现在由选区助手面板(420×540)承载,见 `HostToPopup::PolishPreview`。 + +/// Window size for one popup kind, in X11 pixels. +pub fn popup_size(kind: crate::popup::PopupKind) -> (u32, u32) { + use crate::popup::PopupKind; + match kind { + PopupKind::Capsule => CAPSULE_WINDOW_SIZE, + PopupKind::Qa => QA_WINDOW_SIZE, + PopupKind::LessComputer => LESS_COMPUTER_WINDOW_SIZE, + } +} + +/// Where one popup is placed inside the work area. +/// +/// The capsule hugs the bottom edge, centred: it is the transient overlay the +/// eyes track while dictating, and Tauri's `position_capsule_bottom_center_*` +/// puts it there too. The selection-ask panel and the polish preview are +/// **centred** instead of stacked above the pill — Tauri shows both as centred +/// cards, and stacking would overlap: a 520px panel sitting 50px above the +/// work-area bottom runs into the 100px capsule strip that starts 112px above +/// it. +pub fn popup_position( + environment: &OverlayEnvironment, + kind: crate::popup::PopupKind, +) -> Option<(i32, i32)> { + use crate::popup::PopupKind; + match kind { + PopupKind::Capsule => environment.position_for(CAPSULE_WINDOW_SIZE, CAPSULE_BOTTOM_GAP), + PopupKind::Qa => environment.centred_for(QA_WINDOW_SIZE), + PopupKind::LessComputer => environment.centred_for(LESS_COMPUTER_WINDOW_SIZE), + } +} + +/// A rectangle in root-window coordinates (pixels). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct X11Rect { + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +impl X11Rect { + pub fn contains(self, point: (i32, i32)) -> bool { + point.0 >= self.x + && point.0 < self.x + self.width as i32 + && point.1 >= self.y + && point.1 < self.y + self.height as i32 + } +} + +/// Tauri `bottom_center_position`: centre horizontally, sit `bottom_gap` above +/// the bottom edge, then pull the window back inside `area` if it overflows. +pub fn bottom_center(area: X11Rect, window: (u32, u32), bottom_gap: i32) -> (i32, i32) { + let x = area.x + (area.width.saturating_sub(window.0) / 2) as i32; + let y = area.y + (area.height as i32 - bottom_gap - window.1 as i32).max(0); + clamp_to_area(x, y, window, area) +} + +/// Tauri `clamp_to_monitor`: keep the whole window inside `area`, tolerating an +/// area that is smaller than the window. +pub fn clamp_to_area(x: i32, y: i32, window: (u32, u32), area: X11Rect) -> (i32, i32) { + let max_x = (area.x + area.width as i32 - window.0 as i32).max(area.x); + let max_y = (area.y + area.height as i32 - window.1 as i32).max(area.y); + (x.clamp(area.x, max_x), y.clamp(area.y, max_y)) +} + +/// Centre the window in `area` (both axes), then pull it back inside. +pub fn centered(area: X11Rect, window: (u32, u32)) -> (i32, i32) { + let x = area.x + (area.width.saturating_sub(window.0) / 2) as i32; + let y = area.y + (area.height.saturating_sub(window.1) / 2) as i32; + clamp_to_area(x, y, window, area) +} + +/// Search the monitor list for the one holding `point` (Tauri follows the +/// pointer on macOS and the foreground window on Windows; on X11 the pointer is +/// what we can read before we map our own window). +pub fn monitor_containing(monitors: &[X11Rect], point: Option<(i32, i32)>) -> Option { + let point = point?; + monitors + .iter() + .copied() + .find(|monitor| monitor.contains(point)) +} + +/// Everything read from X11 *before* the popup maps its own window: the work +/// area (taskbar excluded), the monitor list, the pointer position and whoever +/// held the focus at the time. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OverlayEnvironment { + pub work_area: Option, + pub monitors: Vec, + pub cursor: Option<(i32, i32)>, + /// `_NET_ACTIVE_WINDOW` before the popup appeared, used to put the focus + /// back if the compositor handed it to us anyway. + pub active_window: Option, +} + +impl OverlayEnvironment { + /// The rectangle to centre in: the work area of the monitor under the + /// pointer, else that monitor, else the first monitor. + pub fn placement_area(&self) -> Option { + let monitor = monitor_containing(&self.monitors, self.cursor) + .or(self.work_area) + .or_else(|| self.monitors.first().copied())?; + // `_NET_WORKAREA` is a single rectangle for the whole virtual desktop; + // intersect it with the chosen monitor so the pill lands on the screen + // the user is looking at, still above the taskbar. + match self.work_area { + Some(work) => Some(intersect(work, monitor).unwrap_or(monitor)), + None => Some(monitor), + } + } + + pub fn position_for(&self, window: (u32, u32), bottom_gap: i32) -> Option<(i32, i32)> { + self.placement_area() + .map(|area| bottom_center(area, window, bottom_gap)) + } + + /// Centre `window` in the same area `position_for` uses. + pub fn centred_for(&self, window: (u32, u32)) -> Option<(i32, i32)> { + self.placement_area().map(|area| centered(area, window)) + } +} + +/// Intersection of two rectangles, `None` when they do not overlap. +pub fn intersect(a: X11Rect, b: X11Rect) -> Option { + let left = a.x.max(b.x); + let top = a.y.max(b.y); + let right = (a.x + a.width as i32).min(b.x + b.width as i32); + let bottom = (a.y + a.height as i32).min(b.y + b.height as i32); + if right <= left || bottom <= top { + return None; + } + Some(X11Rect { + x: left, + y: top, + width: (right - left) as u32, + height: (bottom - top) as u32, + }) +} + +/// How the overlay window was identified in the X11 tree. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WindowMatch { + /// `_NET_WM_PID` matched our own pid — the reliable path. + Pid, + /// Fallback: a window publishing no `_NET_WM_PID` whose `WM_CLASS` + /// mentions OpenLess. + Class, + /// Fallback: same, matched on `_NET_WM_NAME` / `WM_NAME`. + Name, +} + +impl WindowMatch { + pub fn as_str(self) -> &'static str { + match self { + Self::Pid => "pid", + Self::Class => "wm_class", + Self::Name => "wm_name", + } + } +} + +/// One window found in the X11 tree, with the properties the selector needs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WindowCandidate { + pub window: u32, + pub pid: Option, + pub wm_class: Option, + pub name: Option, +} + +/// Pick our own popup window out of the tree. +/// +/// `_NET_WM_PID` is authoritative, but the window manager is free to keep it +/// off the client window (or the client may not have published it yet when the +/// pre-map pass runs), so `WM_CLASS` / `_NET_WM_NAME` are the documented +/// fallbacks. +/// +/// The fallback deliberately only looks at windows that carry **no** +/// `_NET_WM_PID` at all: in an X11 session the *main* OpenLess window is also +/// called "OpenLess" and does publish a pid, so restricting the fallback keeps +/// the overlay from ever grabbing the main window. +pub fn select_overlay_window( + candidates: &[WindowCandidate], + pid: u32, +) -> Option<(u32, WindowMatch)> { + if let Some(candidate) = candidates + .iter() + .find(|candidate| candidate.pid == Some(pid)) + { + return Some((candidate.window, WindowMatch::Pid)); + } + let unowned = || { + candidates + .iter() + .filter(|candidate| candidate.pid.is_none()) + }; + if let Some(candidate) = unowned().rfind(|candidate| { + candidate + .wm_class + .as_deref() + .is_some_and(|class| class.to_ascii_lowercase().contains("openless")) + }) + // The popup is created after every other OpenLess window, and the tree + // lists children in creation order, so the last match is ours. + { + return Some((candidate.window, WindowMatch::Class)); + } + if let Some(candidate) = unowned().rfind(|candidate| { + candidate + .name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case("openless")) + }) { + return Some((candidate.window, WindowMatch::Name)); + } + None +} + +/// The X11 mutations the overlay needs. Split out so the whole placement +/// sequence can be driven by a recording fake in tests. +pub trait OverlayX11 { + /// Find our own window: `_NET_WM_PID` first, then the class / name + /// fallbacks, reporting which strategy matched. + fn find_own_window(&mut self, pid: u32) -> Result, String>; + /// `WM_HINTS.input = False`: the window manager must never assign focus. + fn set_never_focus(&mut self, window: u32) -> Result<(), String>; + /// `_NET_WM_WINDOW_TYPE = _NET_WM_WINDOW_TYPE_UTILITY`. + fn set_window_type(&mut self, window: u32) -> Result<(), String>; + /// ICCCM `WM_NORMAL_HINTS` with `USPosition`: the position was chosen by the + /// program, so the window manager must not re-place the window. + fn mark_self_placed(&mut self, window: u32) -> Result<(), String>; + /// `_NET_WM_STATE_ABOVE` + `_NET_WM_STATE_SKIP_TASKBAR`. + fn set_overlay_states(&mut self, window: u32) -> Result<(), String>; + fn move_window(&mut self, window: u32, position: (i32, i32)) -> Result<(), String>; + /// `_NET_ACTIVE_WINDOW` right now; `None` when the root has no value. + fn active_window(&mut self) -> Result, String>; + /// Hand the focus back to `window` (the one that had it before we mapped). + fn restore_focus(&mut self, window: u32) -> Result<(), String>; +} + +/// What [`place_overlay`] managed to do; the caller logs it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct OverlayPlacement { + pub window: Option, + /// How the window was identified (`pid` / `wm_class` / `wm_name`). + pub matched: Option, + pub moved_to: Option<(i32, i32)>, + /// The compositor had handed us the keyboard and we took it back. + pub focus_was_stolen: bool, + pub focus_restored: bool, + /// Non-fatal problems, in the order they happened. + pub warnings: Vec, +} + +impl OverlayPlacement { + pub fn applied(&self) -> bool { + self.window.is_some() + } +} + +/// Point the popup's own X11 window at the bottom centre of the work area and +/// make sure it never holds the keyboard. +/// +/// Best effort by design: the pill must still appear if any single step fails, +/// so every failure is collected into [`OverlayPlacement::warnings`] instead of +/// aborting. +pub fn place_overlay( + x11: &mut dyn OverlayX11, + pid: u32, + environment: &OverlayEnvironment, + kind: crate::popup::PopupKind, +) -> OverlayPlacement { + let mut placement = OverlayPlacement::default(); + let (window, matched) = match x11.find_own_window(pid) { + Ok(Some(found)) => found, + Ok(None) => { + placement + .warnings + .push("own X11 window not found yet".to_string()); + return placement; + } + Err(error) => { + placement + .warnings + .push(format!("window lookup failed: {error}")); + return placement; + } + }; + placement.window = Some(window); + placement.matched = Some(matched); + + if let Err(error) = x11.set_never_focus(window) { + placement + .warnings + .push(format!("input hint failed: {error}")); + } + if let Err(error) = x11.set_window_type(window) { + placement + .warnings + .push(format!("window type failed: {error}")); + } + if let Err(error) = x11.mark_self_placed(window) { + placement + .warnings + .push(format!("position hint failed: {error}")); + } + if let Err(error) = x11.set_overlay_states(window) { + placement + .warnings + .push(format!("overlay states failed: {error}")); + } + if let Some(position) = popup_position(environment, kind) { + match x11.move_window(window, position) { + Ok(()) => placement.moved_to = Some(position), + Err(error) => placement.warnings.push(format!("move failed: {error}")), + } + } else { + placement + .warnings + .push("no usable work area or monitor".to_string()); + } + + // Only fight the compositor when it really handed us the keyboard: read + // `_NET_ACTIVE_WINDOW` again and check it is *our* window before taking the + // focus away from whatever the user is actually looking at now. + if let Some(previous) = environment.active_window { + if previous != window { + match x11.active_window() { + Ok(Some(current)) if current == window => match x11.restore_focus(previous) { + Ok(()) => { + placement.focus_was_stolen = true; + placement.focus_restored = true; + } + Err(error) => { + placement.focus_was_stolen = true; + placement + .warnings + .push(format!("focus restore failed: {error}")); + } + }, + // We never took the focus (the `input = False` hint did its job, + // or the user already moved on): leave the focus alone. + Ok(_) => {} + Err(error) => placement + .warnings + .push(format!("focus check failed: {error}")), + } + } + } + placement +} + +#[cfg(all(target_os = "linux", feature = "x11-overlay"))] +mod x11 { + //! The real connection. Kept behind a feature so the default build never + //! links X11 (the same binary also runs under pure Wayland). + use super::{OverlayEnvironment, OverlayX11, X11Rect}; + use x11rb::connection::Connection; + use x11rb::protocol::randr::ConnectionExt as _; + use x11rb::protocol::xproto::{ + AtomEnum, ClientMessageEvent, ConnectionExt as _, EventMask, PropMode, + }; + use x11rb::rust_connection::RustConnection; + use x11rb::wrapper::ConnectionExt as _; + + pub struct X11Overlay { + connection: RustConnection, + root: u32, + } + + impl X11Overlay { + pub fn connect() -> Result { + let (connection, screen) = RustConnection::connect(None).map_err(|e| e.to_string())?; + let root = connection.setup().roots[screen].root; + Ok(Self { connection, root }) + } + + pub fn probe(&self) -> Result { + Ok(OverlayEnvironment { + work_area: self.work_area()?, + monitors: self.monitors()?, + cursor: self.cursor()?, + active_window: self.active_window_property()?, + }) + } + + fn atom(&self, name: &[u8]) -> Result { + Ok(self + .connection + .intern_atom(false, name) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())? + .atom) + } + + fn work_area(&self) -> Result, String> { + let atom = self.atom(b"_NET_WORKAREA")?; + let reply = self + .connection + .get_property(false, self.root, atom, AtomEnum::CARDINAL, 0, 4) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + let values: Vec = reply.value32().map(|it| it.collect()).unwrap_or_default(); + if values.len() < 4 { + return Ok(None); + } + Ok(Some(X11Rect { + x: values[0] as i32, + y: values[1] as i32, + width: values[2], + height: values[3], + })) + } + + fn monitors(&self) -> Result, String> { + if let Ok(reply) = self + .connection + .randr_get_monitors(self.root, true) + .map_err(|e| e.to_string())? + .reply() + { + let rects: Vec = reply + .monitors + .iter() + .map(|monitor| X11Rect { + x: monitor.x as i32, + y: monitor.y as i32, + width: u32::from(monitor.width), + height: u32::from(monitor.height), + }) + .collect(); + if !rects.is_empty() { + return Ok(rects); + } + } + let screen = self + .connection + .setup() + .roots + .iter() + .find(|root| root.root == self.root) + .ok_or_else(|| "root screen missing".to_string())?; + Ok(vec![X11Rect { + x: 0, + y: 0, + width: u32::from(screen.width_in_pixels), + height: u32::from(screen.height_in_pixels), + }]) + } + + fn cursor(&self) -> Result, String> { + let reply = self + .connection + .query_pointer(self.root) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + Ok(Some((i32::from(reply.root_x), i32::from(reply.root_y)))) + } + + fn active_window_property(&self) -> Result, String> { + let atom = self.atom(b"_NET_ACTIVE_WINDOW")?; + let reply = self + .connection + .get_property(false, self.root, atom, AtomEnum::WINDOW, 0, 1) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + Ok(reply + .value32() + .and_then(|mut it| it.next()) + .filter(|window| *window != 0)) + } + + fn pid_of(&self, window: u32) -> Result, String> { + let atom = self.atom(b"_NET_WM_PID")?; + let reply = self + .connection + .get_property(false, window, atom, AtomEnum::CARDINAL, 0, 1) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + Ok(reply.value32().and_then(|mut it| it.next())) + } + + fn send_state(&self, window: u32, state: u32) -> Result<(), String> { + let atom = self.atom(b"_NET_WM_STATE")?; + let event = ClientMessageEvent::new( + 32, + window, + atom, + [ + 1, /* _NET_WM_STATE_ADD */ + state, 0, 1, /* application */ + 0, + ], + ); + self.connection + .send_event( + false, + self.root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + } + + impl X11Overlay { + /// Every window the overlay could plausibly be: the root's children and + /// one level deeper, because a window manager may have reparented the + /// client into a frame window. + fn own_candidates(&self) -> Result, String> { + let tree = self + .connection + .query_tree(self.root) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + let mut windows = Vec::with_capacity(tree.children.len()); + for &child in &tree.children { + windows.push(child); + if let Ok(inner) = self + .connection + .query_tree(child) + .map_err(|e| e.to_string())? + .reply() + { + windows.extend(inner.children.iter().copied()); + } + } + let mut candidates = Vec::with_capacity(windows.len()); + for window in windows { + candidates.push(super::WindowCandidate { + window, + pid: self.pid_of(window)?, + // Class / name are only read for the fallback path (see + // `own_candidate_details`), so they stay empty here. + wm_class: None, + name: None, + }); + } + Ok(candidates) + } + + /// `WM_CLASS` instance + class, or `_NET_WM_NAME` / `WM_NAME`. + fn own_candidate_details( + &self, + candidates: &mut [super::WindowCandidate], + ) -> Result<(), String> { + for candidate in candidates.iter_mut() { + if candidate.pid.is_some() { + continue; + } + candidate.wm_class = self.string_property(candidate.window, AtomEnum::WM_CLASS)?; + candidate.name = self + .utf8_property(candidate.window, b"_NET_WM_NAME")? + .or(self.string_property(candidate.window, AtomEnum::WM_NAME)?); + } + Ok(()) + } + + fn string_property( + &self, + window: u32, + property: impl Into, + ) -> Result, String> { + let reply = self + .connection + .get_property(false, window, property, AtomEnum::STRING, 0, 1024) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + Ok(decode_strings(&reply.value)) + } + + fn utf8_property(&self, window: u32, name: &[u8]) -> Result, String> { + let atom = self.atom(name)?; + let reply = self + .connection + .get_property(false, window, atom, AtomEnum::ANY, 0, 1024) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + if reply.value.is_empty() { + return Ok(None); + } + Ok(Some(String::from_utf8_lossy(&reply.value).to_string())) + } + } + + /// `WM_CLASS` holds two NUL separated strings (instance, class); join them + /// so the selector can look for "openless" in either one. + pub(super) fn decode_strings(bytes: &[u8]) -> Option { + let joined = bytes + .split(|byte| *byte == 0) + .filter(|part| !part.is_empty()) + .map(|part| String::from_utf8_lossy(part).to_string()) + .collect::>() + .join(" "); + (!joined.is_empty()).then_some(joined) + } + + impl OverlayX11 for X11Overlay { + fn find_own_window( + &mut self, + pid: u32, + ) -> Result, String> { + let mut candidates = self.own_candidates()?; + // Fast path: the pid is published, so no class / name round trips. + if let Some((window, matched)) = super::select_overlay_window(&candidates, pid) { + log::debug!( + "capsule x11: window {window:#x} matched by {}", + matched.as_str() + ); + return Ok(Some((window, matched))); + } + // Slow path: only the pid-less windows are of interest. + self.own_candidate_details(&mut candidates)?; + Ok(super::select_overlay_window(&candidates, pid)) + } + + fn set_never_focus(&mut self, window: u32) -> Result<(), String> { + let reply = self + .connection + .get_property(false, window, AtomEnum::WM_HINTS, AtomEnum::WM_HINTS, 0, 9) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + let mut hints: Vec = reply + .value32() + .map(|it| it.collect::>()) + .unwrap_or_default(); + hints.resize(9, 0); + const INPUT_HINT: u32 = 1 << 0; + hints[0] |= INPUT_HINT; // flags + hints[1] = 0; // input = False + self.connection + .change_property32( + PropMode::REPLACE, + window, + AtomEnum::WM_HINTS, + AtomEnum::WM_HINTS, + &hints, + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + + /// `_NET_WM_WINDOW_TYPE_UTILITY`. + /// + /// UTILITY rather than NOTIFICATION: KWin treats NOTIFICATION as a + /// special OSD-style window and applies its own placement / stacking + /// policy to it, which would fight the explicit geometry we ask for. + /// A utility window is an ordinary window as far as placement goes (it + /// honours the client position), while the two properties we *do* want + /// to inherit from the notification look — never focus, never in the + /// taskbar, always above — are set explicitly through `WM_HINTS` and + /// `_NET_WM_STATE` instead of relying on the window type. + fn set_window_type(&mut self, window: u32) -> Result<(), String> { + let property = self.atom(b"_NET_WM_WINDOW_TYPE")?; + let utility = self.atom(b"_NET_WM_WINDOW_TYPE_UTILITY")?; + self.connection + .change_property32( + PropMode::REPLACE, + window, + property, + AtomEnum::ATOM, + &[utility], + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + + /// ICCCM `WM_NORMAL_HINTS`: flag the position as program-specified + /// (`USPosition` | `PPosition`) so the window manager keeps the + /// coordinates instead of running its own placement. + fn mark_self_placed(&mut self, window: u32) -> Result<(), String> { + let reply = self + .connection + .get_property( + false, + window, + AtomEnum::WM_NORMAL_HINTS, + AtomEnum::WM_SIZE_HINTS, + 0, + 18, + ) + .map_err(|e| e.to_string())? + .reply() + .map_err(|e| e.to_string())?; + let mut hints: Vec = reply + .value32() + .map(|it| it.collect::>()) + .unwrap_or_default(); + hints.resize(18, 0); + const US_POSITION: u32 = 1 << 0; + const P_POSITION: u32 = 1 << 2; + hints[0] |= US_POSITION | P_POSITION; + self.connection + .change_property32( + PropMode::REPLACE, + window, + AtomEnum::WM_NORMAL_HINTS, + AtomEnum::WM_SIZE_HINTS, + &hints, + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + + fn active_window(&mut self) -> Result, String> { + self.active_window_property() + } + + fn set_overlay_states(&mut self, window: u32) -> Result<(), String> { + let above = self.atom(b"_NET_WM_STATE_ABOVE")?; + let skip = self.atom(b"_NET_WM_STATE_SKIP_TASKBAR")?; + self.send_state(window, above)?; + self.send_state(window, skip) + } + + fn move_window(&mut self, window: u32, position: (i32, i32)) -> Result<(), String> { + use x11rb::protocol::xproto::ConfigureWindowAux; + self.connection + .configure_window( + window, + &ConfigureWindowAux::new().x(position.0).y(position.1), + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + + fn restore_focus(&mut self, window: u32) -> Result<(), String> { + let atom = self.atom(b"_NET_ACTIVE_WINDOW")?; + let event = ClientMessageEvent::new( + 32, + window, + atom, + // source indication 2 = pager: the compositor may refuse to let + // a normal application move the focus around, but a pager + // request is the documented way to hand it back. + [2, 0, 0, 0, 0], + ); + self.connection + .send_event( + false, + self.root, + EventMask::SUBSTRUCTURE_REDIRECT | EventMask::SUBSTRUCTURE_NOTIFY, + event, + ) + .map_err(|e| e.to_string())?; + self.connection.flush().map_err(|e| e.to_string()) + } + } +} + +#[cfg(all(target_os = "linux", feature = "x11-overlay"))] +pub use x11::X11Overlay; + +/// Whether the host has an X server (XWayland counts) for the capsule to use. +pub fn x11_available(display: Option<&str>) -> bool { + display.is_some_and(|value| !value.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::popup::PopupKind; + + fn monitor(x: i32, y: i32, width: u32, height: u32) -> X11Rect { + X11Rect { + x, + y, + width, + height, + } + } + + #[test] + fn bottom_center_centres_on_a_1080p_screen() { + let area = monitor(0, 0, 1920, 1080); + // 1920 - 200 = 1720 / 2 = 860; 1080 - 12 - 100 = 968 + assert_eq!(bottom_center(area, (200, 100), 12), (860, 968)); + } + + #[test] + fn bottom_center_uses_the_monitor_offset_on_a_second_display() { + let area = monitor(1920, 0, 2560, 1440); + assert_eq!( + bottom_center(area, (200, 100), 12), + (1920 + 1180, 1440 - 112) + ); + } + + #[test] + fn bottom_center_keeps_a_window_wider_than_the_area_inside() { + let area = monitor(0, 0, 160, 120); + assert_eq!(bottom_center(area, (200, 100), 12), (0, 8)); + } + + #[test] + fn bottom_center_never_goes_negative_when_the_area_is_tiny() { + let area = monitor(100, 50, 40, 30); + assert_eq!(bottom_center(area, (200, 100), 12), (100, 50)); + } + + #[test] + fn placement_area_prefers_the_monitor_under_the_cursor() { + let environment = OverlayEnvironment { + work_area: Some(monitor(0, 0, 3840, 1080)), + monitors: vec![monitor(0, 0, 1920, 1080), monitor(1920, 0, 1920, 1080)], + cursor: Some((2500, 500)), + ..Default::default() + }; + // The work area is the whole desktop; intersecting it with the right + // hand monitor keeps the pill on that screen. + assert_eq!( + environment.placement_area(), + Some(monitor(1920, 0, 1920, 1080)) + ); + assert_eq!( + environment.position_for((200, 100), 12), + Some((1920 + 860, 968)) + ); + } + + #[test] + fn placement_area_falls_back_to_the_first_monitor_without_a_cursor() { + let environment = OverlayEnvironment { + monitors: vec![monitor(0, 0, 1920, 1080), monitor(1920, 0, 1920, 1080)], + ..Default::default() + }; + assert_eq!( + environment.placement_area(), + Some(monitor(0, 0, 1920, 1080)) + ); + } + + #[test] + fn placement_area_uses_the_work_area_when_there_are_no_monitors() { + let environment = OverlayEnvironment { + work_area: Some(monitor(0, 40, 1920, 1040)), + ..Default::default() + }; + assert_eq!( + environment.placement_area(), + Some(monitor(0, 40, 1920, 1040)) + ); + // A 40px taskbar at the top moves the work area's *top* edge only, so + // the pill keeps hugging the same bottom edge (1080 - 112). + assert_eq!(environment.position_for((200, 100), 12), Some((860, 968))); + } + + #[test] + fn placement_area_is_none_without_any_x11_geometry() { + assert_eq!(OverlayEnvironment::default().placement_area(), None); + assert_eq!( + OverlayEnvironment::default().position_for((200, 100), 12), + None + ); + } + + #[derive(Default)] + struct FakeX11 { + window: Option, + matched: Option, + fail_input: bool, + /// `_NET_ACTIVE_WINDOW` when the placement re-reads it after the move. + current_active: Option, + calls: Vec, + } + + impl OverlayX11 for FakeX11 { + fn find_own_window(&mut self, pid: u32) -> Result, String> { + self.calls.push(format!("find({pid})")); + Ok(self + .window + .map(|window| (window, self.matched.unwrap_or(WindowMatch::Pid)))) + } + fn set_never_focus(&mut self, window: u32) -> Result<(), String> { + self.calls.push(format!("never_focus({window})")); + if self.fail_input { + return Err("nope".to_string()); + } + Ok(()) + } + fn set_window_type(&mut self, window: u32) -> Result<(), String> { + self.calls.push(format!("window_type({window})")); + Ok(()) + } + fn mark_self_placed(&mut self, window: u32) -> Result<(), String> { + self.calls.push(format!("self_placed({window})")); + Ok(()) + } + fn set_overlay_states(&mut self, window: u32) -> Result<(), String> { + self.calls.push(format!("states({window})")); + Ok(()) + } + fn move_window(&mut self, window: u32, position: (i32, i32)) -> Result<(), String> { + self.calls + .push(format!("move({window},{},{})", position.0, position.1)); + Ok(()) + } + fn active_window(&mut self) -> Result, String> { + self.calls.push("active_window".to_string()); + Ok(self.current_active) + } + fn restore_focus(&mut self, window: u32) -> Result<(), String> { + self.calls.push(format!("focus({window})")); + Ok(()) + } + } + + fn environment() -> OverlayEnvironment { + OverlayEnvironment { + work_area: Some(monitor(0, 0, 1920, 1080)), + monitors: vec![monitor(0, 0, 1920, 1080)], + cursor: Some((10, 10)), + active_window: Some(0x40), + } + } + + #[test] + fn popup_size_maps_every_kind() { + assert_eq!(popup_size(PopupKind::Capsule), CAPSULE_WINDOW_SIZE); + assert_eq!(popup_size(PopupKind::Qa), QA_WINDOW_SIZE); + // Tauri 的 `less-computer` 窗口与 qa 同为 420×540。 + assert_eq!( + popup_size(PopupKind::LessComputer), + LESS_COMPUTER_WINDOW_SIZE + ); + assert_eq!(LESS_COMPUTER_WINDOW_SIZE, (420, 540)); + } + + /// The capsule hugs the bottom edge of the work area, centred. + #[test] + fn popup_position_puts_the_capsule_at_the_bottom_centre() { + assert_eq!( + popup_position(&environment(), PopupKind::Capsule), + Some((860, 968)) + ); + } + + /// The panels are centred dialogs, and centring is what keeps them from + /// running into the capsule strip: a 540px panel 50px above the bottom edge + /// would start at y=490 and end at 1030, i.e. inside the pill's 968..1068. + #[test] + fn popup_position_centres_the_panels_clear_of_the_capsule() { + // 尺寸取自 Tauri:qa 420×540(润色结果已并入这个面板,不再有独立预览窗口), + // 居中于 1920×1080 工作区。 + assert_eq!( + popup_position(&environment(), PopupKind::Qa), + Some((750, 270)) + ); + let area = monitor(0, 0, 1920, 1080); + let (_, capsule_y) = bottom_center(area, CAPSULE_WINDOW_SIZE, CAPSULE_BOTTOM_GAP); + { + let kind = PopupKind::Qa; + let (_, y) = popup_position(&environment(), kind).expect("centred"); + let height = popup_size(kind).1 as i32; + assert!( + y + height <= capsule_y, + "{kind:?} overlaps the capsule strip: {}..{} vs {}", + y, + y + height, + capsule_y + ); + } + } + + /// A work area smaller than the window keeps the window at its origin + /// instead of producing a negative position. + #[test] + fn popup_position_keeps_an_oversized_panel_inside_a_tiny_area() { + let environment = OverlayEnvironment { + work_area: Some(monitor(0, 0, 400, 300)), + monitors: vec![monitor(0, 0, 400, 300)], + cursor: None, + active_window: None, + }; + assert_eq!(popup_position(&environment, PopupKind::Qa), Some((0, 0)),); + assert_eq!( + popup_position(&environment, PopupKind::Capsule), + Some((100, 188)), + ); + } + + /// A capsule that did take the keyboard: everything is asserted on one + /// request sequence, including the order (input hint + window type + + /// position hint before the EWMH states and the move). + #[test] + fn place_overlay_never_focuses_moves_and_restores_the_previous_window() { + let mut x11 = FakeX11 { + window: Some(0x2a), + current_active: Some(0x2a), + ..Default::default() + }; + let placement = place_overlay(&mut x11, 4242, &environment(), PopupKind::Capsule); + assert_eq!( + x11.calls, + vec![ + "find(4242)", + "never_focus(42)", // 0x2a + "window_type(42)", + "self_placed(42)", + "states(42)", + "move(42,860,968)", + "active_window", + "focus(64)", // 0x40 + ] + ); + assert_eq!(placement.window, Some(0x2a)); + assert_eq!(placement.matched, Some(WindowMatch::Pid)); + assert_eq!(placement.moved_to, Some((860, 968))); + assert!(placement.focus_was_stolen); + assert!(placement.focus_restored); + assert!(placement.warnings.is_empty()); + assert!(placement.applied()); + } + + #[test] + fn place_overlay_keeps_going_when_the_input_hint_fails() { + let mut x11 = FakeX11 { + window: Some(0x2a), + fail_input: true, + ..Default::default() + }; + let placement = place_overlay(&mut x11, 1, &environment(), PopupKind::Capsule); + assert!(placement.applied()); + assert_eq!(placement.moved_to, Some((860, 968))); + assert_eq!(placement.warnings, vec!["input hint failed: nope"]); + } + + #[test] + fn place_overlay_reports_a_missing_window() { + let mut x11 = FakeX11::default(); + let placement = place_overlay(&mut x11, 7, &environment(), PopupKind::Capsule); + assert!(!placement.applied()); + assert_eq!(placement.warnings, vec!["own X11 window not found yet"]); + assert_eq!(x11.calls, vec!["find(7)"]); + } + + #[test] + fn place_overlay_does_not_restore_focus_when_we_already_had_it() { + let mut x11 = FakeX11 { + window: Some(0x40), + ..Default::default() + }; + let mut environment = environment(); + environment.active_window = Some(0x40); + let placement = place_overlay(&mut x11, 1, &environment, PopupKind::Capsule); + assert!(!placement.focus_was_stolen); + assert!(!placement.focus_restored); + assert!(!x11.calls.iter().any(|call| call.starts_with("focus("))); + } + + /// The `WM_HINTS.input = False` hint (or the user moving on) means the + /// focus never landed on us: the overlay must not yank it to a stale window. + #[test] + fn place_overlay_leaves_the_focus_alone_when_it_was_never_stolen() { + let mut x11 = FakeX11 { + window: Some(0x2a), + current_active: Some(0x99), + ..Default::default() + }; + let placement = place_overlay(&mut x11, 1, &environment(), PopupKind::Capsule); + assert!(placement.applied()); + assert!(!placement.focus_was_stolen); + assert!(!placement.focus_restored); + assert!(x11.calls.contains(&"active_window".to_string())); + assert!(!x11.calls.iter().any(|call| call.starts_with("focus("))); + assert!(placement.warnings.is_empty()); + } + + fn candidate(window: u32, pid: Option) -> WindowCandidate { + WindowCandidate { + window, + pid, + wm_class: None, + name: None, + } + } + + #[test] + fn select_overlay_window_prefers_the_pid() { + let candidates = vec![candidate(0x1, Some(7)), candidate(0x2, Some(4242))]; + assert_eq!( + select_overlay_window(&candidates, 4242), + Some((0x2, WindowMatch::Pid)) + ); + } + + #[test] + fn select_overlay_window_falls_back_to_the_wm_class() { + let candidates = vec![ + candidate(0x1, None), + WindowCandidate { + wm_class: Some("openless OpenLess".to_string()), + ..candidate(0x2, None) + }, + ]; + assert_eq!( + select_overlay_window(&candidates, 4242), + Some((0x2, WindowMatch::Class)) + ); + } + + #[test] + fn select_overlay_window_falls_back_to_the_window_name() { + let candidates = vec![WindowCandidate { + name: Some("OpenLess".to_string()), + ..candidate(0x5, None) + }]; + assert_eq!( + select_overlay_window(&candidates, 4242), + Some((0x5, WindowMatch::Name)) + ); + } + + /// The main window is also called "OpenLess" but publishes a pid, so the + /// class / name fallback must never claim it. + #[test] + fn select_overlay_window_ignores_windows_owned_by_another_process() { + let candidates = vec![ + WindowCandidate { + wm_class: Some("openless OpenLess".to_string()), + ..candidate(0x1, Some(99)) + }, + WindowCandidate { + name: Some("OpenLess".to_string()), + ..candidate(0x2, Some(99)) + }, + ]; + assert_eq!(select_overlay_window(&candidates, 4242), None); + } + + #[test] + fn select_overlay_window_takes_the_newest_class_match() { + let candidates = vec![ + WindowCandidate { + wm_class: Some("openless OpenLess".to_string()), + ..candidate(0x1, None) + }, + WindowCandidate { + wm_class: Some("openless OpenLess".to_string()), + ..candidate(0x2, None) + }, + ]; + assert_eq!( + select_overlay_window(&candidates, 4242), + Some((0x2, WindowMatch::Class)) + ); + } + + #[test] + fn select_overlay_window_reports_nothing_without_a_match() { + let candidates = vec![ + candidate(0x1, Some(7)), + WindowCandidate { + wm_class: Some("firefox Firefox".to_string()), + ..candidate(0x2, None) + }, + ]; + assert_eq!(select_overlay_window(&candidates, 4242), None); + } + + #[cfg(all(target_os = "linux", feature = "x11-overlay"))] + #[test] + fn wm_class_is_decoded_into_a_searchable_string() { + // `WM_CLASS` is two NUL separated strings: instance + class. + assert_eq!( + super::x11::decode_strings(b"openless\0OpenLess\0").as_deref(), + Some("openless OpenLess") + ); + // Some clients send only the class (or nothing at all). + assert_eq!( + super::x11::decode_strings(b"OpenLess\0").as_deref(), + Some("OpenLess") + ); + assert_eq!(super::x11::decode_strings(b""), None); + } + + #[test] + fn x11_available_follows_the_display_variable() { + assert!(x11_available(Some(":0"))); + assert!(!x11_available(Some(" "))); + assert!(!x11_available(None)); + } +} diff --git a/openless-all/app/linux-egui/src/preference_patch.rs b/openless-all/app/linux-egui/src/preference_patch.rs index c0e0001ea..4c8c7cc3f 100644 --- a/openless-all/app/linux-egui/src/preference_patch.rs +++ b/openless-all/app/linux-egui/src/preference_patch.rs @@ -1,8 +1,8 @@ -//! 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. +//! Apply field edits to the latest Core revision through its strict transaction. +use std::collections::BTreeMap; + use openless_core::{BackendError, BackendErrorCode, SettingsUpdateOutcome, UserPreferences}; use serde_json::Value; -use std::collections::BTreeMap; pub fn patch_preferences( current: &UserPreferences, @@ -22,76 +22,192 @@ fn invalid(error: impl std::fmt::Display) -> BackendError { BackendError::new(BackendErrorCode::InvalidArgument, error.to_string()) } +fn revision_conflict(error: &BackendError) -> bool { + error.code == BackendErrorCode::Busy + && error.details.as_ref().is_some_and(|details| { + details["expectedPreferencesRevision"].is_u64() + && details["actualPreferencesRevision"].is_u64() + }) +} + 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, - } + update_fields( + edits, + || { + ( + self.snapshot().preferences_revision, + self.backend().get_preferences(), + ) + }, + |draft, revision| self.update_settings_strict(draft, revision), + ) + } +} + +fn update_fields( + edits: &BTreeMap, + mut read: impl FnMut() -> (u64, UserPreferences), + mut save: impl FnMut(UserPreferences, u64) -> Result, +) -> Result { + for attempt in 0..3 { + let (revision, current) = read(); + let draft = patch_preferences(¤t, edits)?; + match save(draft, revision) { + Err(error) if attempt < 2 && revision_conflict(&error) => continue, + outcome => return outcome, } - Err(BackendError::new( - BackendErrorCode::Busy, - "settings changed while saving; please retry", - )) } + unreachable!("the final attempt always returns") } #[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))]), + fn patches_preserve_other_fields_and_reject_unknown_or_invalid_values() { + let current = UserPreferences { + active_style_pack_id: "external-style".into(), + ..Default::default() + }; + let next = patch_preferences( + ¤t, + &BTreeMap::from([ + ("/showCapsule".into(), Value::Bool(false)), + ("/hotkey/mode".into(), Value::String("hold".into())), + ]), ) .unwrap(); - assert_eq!(changed.active_style_pack_id, "newly-selected-style"); - assert!(!changed.show_capsule); + assert_eq!(next.active_style_pack_id, current.active_style_pack_id); + assert!(!next.show_capsule); + for (key, value) in [ + ("/typo", Value::Bool(false)), + ("/remoteInputPort", Value::from(100_000)), + ] { + assert!(patch_preferences(¤t, &BTreeMap::from([(key.into(), value)])).is_err()); + } } + #[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))]) + fn only_revision_conflicts_are_retried() { + let mut error = BackendError::new(BackendErrorCode::Busy, "native resource busy"); + assert!(!revision_conflict(&error)); + error.details = Some( + serde_json::json!({"expectedPreferencesRevision": 1, "actualPreferencesRevision": 2}), + ); + assert!(revision_conflict(&error)); + error.code = BackendErrorCode::Platform; + assert!(!revision_conflict(&error)); + } + #[test] + fn omitted_fields_persist_after_a_real_core_revision_conflict() { + use std::sync::Arc; + let dir = + std::env::temp_dir().join(format!("openless-field-patch-{}", uuid::Uuid::new_v4())); + let backend = Arc::new( + openless_core::OpenLessBackend::new( + openless_core::BackendConfig { + data_dir: dir.clone(), + ..Default::default() + }, + openless_core::BackendDependencies::unsupported(), + ) + .unwrap(), + ); + let host = crate::LinuxHost::with_settings_runtime( + backend.clone(), + Arc::new(openless_core::NoopSettingsRuntime), + ); + let selection = backend + .create_style_pack(openless_core::StylePack { + name: "Selection patch".into(), + prompt: "Polish".into(), + ..Default::default() + }) + .unwrap(); + let initial = backend.get_preferences(); + let mut changed = initial.clone(); + changed.streaming_insert_save_clipboard = !initial.streaming_insert_save_clipboard; + changed.show_capsule = !initial.show_capsule; + changed.capsule_style = openless_core::shared_types::CapsuleStyle::Classic; + changed.selection_polish_output_mode = + openless_core::shared_types::SelectionPolishOutputMode::PreviewConfirm; + changed.polish_context_window_minutes = 17; + changed.audio_recording_max_entries = Some(19); + changed.coding_agent_enabled = true; + changed.coding_agent_provider = "codex-cli".into(); + changed.coding_agent_permission_mode = "plan".into(); + changed.coding_agent_model = Some("fixture".into()); + changed.coding_agent_workdir = Some("/tmp/work".into()); + changed.coding_agent_exe = Some("/bin/codex".into()); + changed.remote_input_default_mode = "hold".into(); + changed.working_languages = vec!["ja".into()]; + changed.translation_target_language = "ko".into(); + changed.selection_polish_style_pack_id = selection.id.clone(); + changed.style_pack_hotkeys = vec![openless_core::shared_types::StylePackHotkey { + pack_id: selection.id, + binding: openless_core::shared_types::ShortcutBinding { + primary: "F10".into(), + modifiers: vec!["alt".into()], + }, + }]; + let initial = serde_json::to_value(initial).unwrap(); + let changed = serde_json::to_value(changed).unwrap(); + for (key, value) in changed.as_object().unwrap() { + if initial[key] == *value { + continue; + } + let patch = BTreeMap::from([(format!("/{key}"), value.clone())]); + let mut attempts = 0; + update_fields( + &patch, + || { + ( + host.snapshot().preferences_revision, + backend.get_preferences(), + ) + }, + |draft, revision| { + attempts += 1; + if attempts == 1 { + host.update_preference_fields(&BTreeMap::from([( + "/microphoneDeviceName".into(), + Value::String(key.clone()), + )]))?; + } + host.update_settings_strict(draft, revision) + }, + ) + .unwrap(); + assert_eq!( + attempts, 2, + "{key} must retry exactly the revision conflict" + ); + let saved = serde_json::to_value(backend.get_preferences()).unwrap(); + assert_eq!(saved[key], *value, "{key}"); + assert_eq!(saved["microphoneDeviceName"], key.as_str()); + } + drop(host); + drop(backend); + let reopened = openless_core::OpenLessBackend::new( + openless_core::BackendConfig { + data_dir: dir.clone(), + ..Default::default() + }, + openless_core::BackendDependencies::unsupported(), ) - .is_err()); + .unwrap(); + let saved = serde_json::to_value(reopened.get_preferences()).unwrap(); + for (key, value) in changed.as_object().unwrap() { + if initial[key] != *value { + assert_eq!(saved[key], *value, "persisted {key}"); + } + } + drop(reopened); + std::fs::remove_dir_all(dir).unwrap(); } } diff --git a/openless-all/app/linux-egui/src/recordings.rs b/openless-all/app/linux-egui/src/recordings.rs index da3e8fbc1..f4c90f8fe 100644 --- a/openless-all/app/linux-egui/src/recordings.rs +++ b/openless-all/app/linux-egui/src/recordings.rs @@ -3,61 +3,6 @@ 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, @@ -111,17 +56,6 @@ pub fn read_recording_wav(data_dir: &Path, session_id: &str) -> Result, 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" diff --git a/openless-all/app/linux-egui/src/remote_input.rs b/openless-all/app/linux-egui/src/remote_input.rs index 8b980a234..77a7e80a4 100644 --- a/openless-all/app/linux-egui/src/remote_input.rs +++ b/openless-all/app/linux-egui/src/remote_input.rs @@ -306,7 +306,7 @@ fn access_urls(port: u16) -> Vec { } #[cfg(target_os = "linux")] -#[path = "../../src-tauri/src/remote_server/tls_identity.rs"] +#[path = "../../shared/remote_tls/tls_identity.rs"] mod tls_identity; #[cfg(target_os = "linux")] diff --git a/openless-all/app/linux-egui/src/resources.rs b/openless-all/app/linux-egui/src/resources.rs index 7cb9c4bb1..ca14606af 100644 --- a/openless-all/app/linux-egui/src/resources.rs +++ b/openless-all/app/linux-egui/src/resources.rs @@ -4,7 +4,6 @@ use openless_core::{BackendError, BackendErrorCode, DirectoryResourceResolver, R pub const FCITX_PLUGIN_LIBRARY: &str = "linux-fcitx5-plugin/libopenless.so"; pub const FCITX_PLUGIN_CONFIG: &str = "linux-fcitx5-plugin/openless.conf"; -pub(crate) const QWEN_ASR_RUNTIME: &str = "qwen-asr/qwen_asr"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LinuxPackageKind { @@ -89,29 +88,6 @@ impl ResourceResolver for LinuxResourceResolver { } } -pub(crate) fn qwen_runtime_path( - layout: &LinuxResourceLayout, - explicit: Option, -) -> Result { - if let Some(path) = explicit { - if !path.is_absolute() { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "OPENLESS_QWEN_ASR_BIN must be an absolute path", - )); - } - return Ok(path); - } - layout.resolver()?.resolve(Path::new(QWEN_ASR_RUNTIME)) -} - -pub(crate) fn detect_qwen_runtime_path() -> Result { - qwen_runtime_path( - &LinuxResourceLayout::detect(None)?, - std::env::var_os("OPENLESS_QWEN_ASR_BIN").map(PathBuf::from), - ) -} - #[cfg(test)] mod tests { use super::*; @@ -142,28 +118,4 @@ mod tests { .resource_root .ends_with("bin/../lib/openless/resources")); } - - #[test] - fn qwen_runtime_uses_the_packaged_resource_or_an_absolute_dev_override() { - let layout = LinuxResourceLayout { - package_kind: LinuxPackageKind::SystemPackage, - resource_root: PathBuf::from("/usr/lib/openless/resources"), - }; - - assert_eq!( - qwen_runtime_path(&layout, None).unwrap(), - PathBuf::from("/usr/lib/openless/resources/qwen-asr/qwen_asr") - ); - let override_path = std::env::temp_dir().join("qwen_asr"); - assert_eq!( - qwen_runtime_path(&layout, Some(override_path.clone())).unwrap(), - override_path - ); - assert_eq!( - qwen_runtime_path(&layout, Some(PathBuf::from("qwen_asr"))) - .unwrap_err() - .code, - BackendErrorCode::InvalidArgument - ); - } } diff --git a/openless-all/app/linux-egui/src/runtime.rs b/openless-all/app/linux-egui/src/runtime.rs index c501e00ad..1f1fc0ae7 100644 --- a/openless-all/app/linux-egui/src/runtime.rs +++ b/openless-all/app/linux-egui/src/runtime.rs @@ -21,55 +21,72 @@ pub struct LinuxNativeRuntime { host: std::sync::Arc, startup: openless_core::StartupSnapshot, host_actions: std::sync::Arc, - broker: Option, + broker: Option>, hotkeys: Option, } impl LinuxNativeRuntime { pub async fn start( backend: LinuxBackendRuntime, - broker: Option, + broker: Option>, hotkeys: Option, ) -> Result { - // fcitx5 starts with no OpenLess shortcuts on a clean installation. - // Hydrate its native registrations from the same Core target used by - // settings transactions before accepting any input. Equal previous/next - // values intentionally force the effect without rewriting preferences. - let target = openless_core::HotkeyRuntimeTarget::from(&backend.backend.get_preferences()); - backend - .settings_runtime - .commit( - &openless_core::SettingsEffectPlan { - hotkeys: Some(openless_core::SettingsValueChange { - previous: target.clone(), - next: target, - }), - ..Default::default() - }, - &mut openless_core::SettingsEffectReceipt::default(), - ) - .map_err(|failure| failure.error)?; - let startup = backend.backend.start().await?; - openless_core::require_backend_contract_version(&startup.contract_version)?; - let preferences = backend.backend.get_preferences(); - backend - .backend - .services() - .remote_input - .set_locale( - crate::remote_input::remote_input_locale(&backend.backend.config().locale) - .to_string(), - ) - .await?; - backend - .backend - .services() - .remote_input - .configure(openless_core::RemoteInputConfig { - enabled: preferences.remote_input_enabled, - port: preferences.remote_input_port, - }) - .await?; + let initialized = async { + // fcitx5 starts with no OpenLess shortcuts on a clean installation. + // Hydrate its native registrations from the same Core target used by + // settings transactions before accepting any input. Equal previous/next + // values intentionally force the effect without rewriting preferences. + let target = + openless_core::HotkeyRuntimeTarget::from(&backend.backend.get_preferences()); + backend + .settings_runtime + .commit( + &openless_core::SettingsEffectPlan { + hotkeys: Some(openless_core::SettingsValueChange { + previous: target.clone(), + next: target, + }), + ..Default::default() + }, + &mut openless_core::SettingsEffectReceipt::default(), + ) + .map_err(|failure| failure.error)?; + let startup = backend.backend.start().await?; + openless_core::require_backend_contract_version(&startup.contract_version)?; + let preferences = backend.backend.get_preferences(); + backend + .backend + .services() + .remote_input + .set_locale( + crate::remote_input::remote_input_locale(&backend.backend.config().locale) + .to_string(), + ) + .await?; + backend + .backend + .services() + .remote_input + .configure(openless_core::RemoteInputConfig { + enabled: preferences.remote_input_enabled, + port: preferences.remote_input_port, + }) + .await?; + Ok::<_, BackendError>(startup) + } + .await; + let startup = match initialized { + Ok(startup) => startup, + Err(mut error) => { + drop(hotkeys); + if let Err(cleanup) = backend.backend.shutdown().await { + error + .message + .push_str(&format!("; startup cleanup failed: {cleanup}")); + } + return Err(error); + } + }; Ok(Self { host: std::sync::Arc::new(LinuxHost::with_settings_runtime( backend.backend, @@ -98,6 +115,15 @@ impl LinuxNativeRuntime { &self.host_actions } + /// Whether the fcitx5 global-hotkey listener came up. + /// + /// Tauri hides the 「快捷键」 settings section when the platform has no + /// desktop hotkey (`visibleSettingsSections(supportsDesktopHotkey)`); the + /// egui rail needs the same honest answer instead of always showing it. + pub fn hotkeys_available(&self) -> bool { + self.hotkeys.is_some() + } + pub fn drain_native_events( &self, ) -> ( @@ -108,9 +134,6 @@ 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() { @@ -118,20 +141,11 @@ impl LinuxNativeRuntime { } } if let Some(hotkeys) = &self.hotkeys { - hotkeys.drain(|event| { - if !crate::desktop_bridge::active() { - hotkey_events.push(event); - } - }); + hotkeys.drain(|event| 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) } @@ -186,7 +200,10 @@ mod tests { use super::*; #[derive(Default)] - struct StartupHotkeys(std::sync::Mutex>); + struct StartupHotkeys( + std::sync::Mutex>, + bool, + ); impl crate::LinuxSettingsEffects for StartupHotkeys { fn apply_hotkeys( @@ -194,6 +211,12 @@ mod tests { target: &openless_core::HotkeyRuntimeTarget, ) -> Result<(), BackendError> { self.0.lock().unwrap().push(target.clone()); + if self.1 { + return Err(BackendError::new( + BackendErrorCode::Platform, + "required hotkey registration failed", + )); + } Ok(()) } @@ -202,27 +225,14 @@ mod tests { } } - #[tokio::test] - async fn native_runtime_starts_pumps_and_shuts_down_without_ui() { - let data_dir = std::env::temp_dir().join(format!( - "openless-linux-native-runtime-{}-{}", - std::process::id(), - uuid::Uuid::new_v4().simple() - )); - let host_actions = Arc::new(LinuxHostActions::default()); - // LinuxNativeRuntime is a production shell: even when Remote Input is - // disabled it synchronizes locale/config through the real Core service. - // Supplying that boundary here keeps the test honest and prevents an - // Unsupported fallback from silently returning to production startup. - let mut services = BackendServices::unsupported(); - services.remote_input = Arc::new( - RemoteInputService::new(Arc::new(RecordingRemoteInputRuntime::default()), 8443, "en") - .unwrap(), - ); - let backend = Arc::new( + fn fixture_backend( + data_dir: &std::path::Path, + services: BackendServices, + ) -> Arc { + Arc::new( OpenLessBackend::new( BackendConfig { - data_dir: data_dir.clone(), + data_dir: data_dir.to_path_buf(), ..BackendConfig::default() }, BackendDependencies { @@ -244,7 +254,27 @@ mod tests { }, ) .unwrap(), + ) + } + + #[tokio::test] + async fn native_runtime_starts_pumps_and_shuts_down_without_ui() { + let data_dir = std::env::temp_dir().join(format!( + "openless-linux-native-runtime-{}-{}", + std::process::id(), + uuid::Uuid::new_v4().simple() + )); + let host_actions = Arc::new(LinuxHostActions::default()); + // LinuxNativeRuntime is a production shell: even when Remote Input is + // disabled it synchronizes locale/config through the real Core service. + // Supplying that boundary here keeps the test honest and prevents an + // Unsupported fallback from silently returning to production startup. + let mut services = BackendServices::unsupported(); + services.remote_input = Arc::new( + RemoteInputService::new(Arc::new(RecordingRemoteInputRuntime::default()), 8443, "en") + .unwrap(), ); + let backend = fixture_backend(&data_dir, services); let hotkeys = Arc::new(StartupHotkeys::default()); let runtime = LinuxNativeRuntime::start( LinuxBackendRuntime { @@ -281,4 +311,56 @@ mod tests { assert!(!backend.snapshot().running); let _ = std::fs::remove_dir_all(data_dir); } + #[cfg(target_os = "linux")] + #[tokio::test] + async fn startup_failures_stop_core_and_keep_the_outer_lock_until_the_error_window_closes() { + for fail_hotkeys in [true, false] { + let dir = std::env::temp_dir() + .join(format!("openless-startup-failure-{}", uuid::Uuid::new_v4())); + let backend = fixture_backend(&dir, BackendServices::unsupported()); + let lock = dir.join("openless.lock"); + let broker = match crate::SingleInstanceBroker::acquire_or_forward( + &lock, + &dir.join("openless.sock"), + crate::LinuxLaunchIntent::ShowMain, + ) + .unwrap() + { + crate::SingleInstanceRole::Primary(broker) => Arc::new(broker), + crate::SingleInstanceRole::Forwarded => panic!("isolated test must own its lock"), + }; + let mut events = backend.subscribe(); + let result = LinuxNativeRuntime::start( + LinuxBackendRuntime { + backend: backend.clone(), + host_actions: Arc::new(LinuxHostActions::default()), + settings_runtime: Arc::new(crate::LinuxSettingsRuntime::with_effects( + Arc::new(StartupHotkeys(Default::default(), fail_hotkeys)), + )), + }, + Some(broker.clone()), + None, + ) + .await; + assert!(result.is_err()); + assert!(!backend.snapshot().running); + let mut started = false; + while let Ok(event) = events.try_recv() { + started |= matches!(event.kind, openless_core::BackendEventKind::BackendStarted); + } + assert_eq!( + started, !fail_hotkeys, + "registration must precede Core startup" + ); + assert!(crate::SingleInstanceGuard::acquire(&lock) + .unwrap() + .is_none()); + drop(broker); + assert!(crate::SingleInstanceGuard::acquire(&lock) + .unwrap() + .is_some()); + drop(backend); + std::fs::remove_dir_all(dir).unwrap(); + } + } } diff --git a/openless-all/app/linux-egui/src/selection.rs b/openless-all/app/linux-egui/src/selection.rs index 504df15ff..f7deb043f 100644 --- a/openless-all/app/linux-egui/src/selection.rs +++ b/openless-all/app/linux-egui/src/selection.rs @@ -7,9 +7,6 @@ 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, @@ -24,14 +21,6 @@ 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()) } @@ -128,9 +117,20 @@ impl SelectionRuntimeAdapter for LinuxSelectionRuntime { source_text: text.clone(), replacement_text: None, }); + // Honest `source_app`: fcitx5 exposes the surrounding text via + // DBus but gives the host no reliable foreground-application + // identity that holds across both X11 and Wayland clients, and + // the PRIMARY/clipboard cannot prove the original control is + // still the current focus. There is no IBus/global-hotkey + // fallback by design (Linux supports fcitx5 only), so we report + // the app as unknown instead of faking an identity. Post-insertion + // edit observation for streamed dictation is likewise unsupported + // on Linux: Core's Noop HostContextAdapter/EditObservationAdapter + // (the factory does not inject real ones) keep those use-cases on + // the explicit `Unsupported` path rather than simulating edits. Ok(SelectionCapture { text, - source_app: bridge.source_app(session_id), + source_app: None, }) }) .await diff --git a/openless-all/app/linux-egui/src/selection_voice.rs b/openless-all/app/linux-egui/src/selection_voice.rs deleted file mode 100644 index a939eff67..000000000 --- a/openless-all/app/linux-egui/src/selection_voice.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! 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 7becc35db..4b16e354f 100644 --- a/openless-all/app/linux-egui/src/settings.rs +++ b/openless-all/app/linux-egui/src/settings.rs @@ -162,9 +162,23 @@ fn production_autostart() -> Result { impl LinuxSettingsEffects for Fcitx5SettingsEffects { fn apply_hotkeys(&self, target: &HotkeyRuntimeTarget) -> Result<(), BackendError> { - crate::desktop_bridge::bind_target(target)?; + // 一行摘要,用户可据此确认「到底注册了哪些键」;修饰键触发显示为 + // modifier(LeftControl) 这样的形态。 + log::info!( + "[fcitx] hotkey registration: {}", + registration_summary(target) + ); apply_dictation_hotkey(&target.dictation)?; + // 宿主只把热键注册给插件,**从不改动输入法自己的配置**。曾短暂加过 + // 「检测到引擎占用主键就去清空拼音的快速短语触发键」,实测证明那是假象: + // fcitx 的 `Key::check` 要求修饰位精确相等,分号与 `Ctrl+Shift+;` 并不 + // 冲突(见 tests/fcitx5_config_contract.rs 锁死的不变量)。 apply_action_hotkey("SetQaHotkeyRaw", target.qa.as_ref())?; + // Older installed plugins may not know the new Quick Note method. + tolerate_optional_fcitx_method(apply_action_hotkey( + "SetQuickNoteHotkeyRaw", + target.quick_note.as_ref(), + ))?; apply_action_hotkey( "SetSelectionPolishHotkeyRaw", target.selection_polish.as_ref(), @@ -178,37 +192,40 @@ impl LinuxSettingsEffects for Fcitx5SettingsEffects { "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 mut style_pack_hotkeys = Vec::with_capacity(target.style_packs.len()); + for hotkey in &target.style_packs { + let (symbol, states) = registerable_raw(&hotkey.binding)?; + // symbol 0 = 没有可注册的键(空绑定),不报给插件。 + if symbol == 0 { + continue; + } + style_pack_hotkeys.push((hotkey.pack_id.clone(), symbol, states)); } + if style_pack_hotkeys.is_empty() { + log::info!("[fcitx] registered SetStylePackHotkeys 0 entries"); + } else { + for (pack_id, symbol, states) in &style_pack_hotkeys { + log::info!( + "[fcitx] registered SetStylePackHotkeys pack={} sym=0x{:x} states=0x{:x}", + pack_id, + symbol, + states + ); + } + } + tolerate_optional_fcitx_method(crate::fcitx5::set_style_pack_hotkeys(style_pack_hotkeys))?; let (symbol, states) = target .coding_agent_voice .as_ref() // The configured binding survives a disabled feature, but the // native hook must be removed until the user enables it again. .filter(|_| target.coding_agent_enabled) - .map(shortcut_to_raw) + .map(registerable_raw) .transpose()? .unwrap_or((0, 0)); - tolerate_optional_fcitx_method(crate::fcitx5::set_less_computer_hotkey_raw(symbol, states)) + let result = crate::fcitx5::set_less_computer_hotkey_raw(symbol, states); + log_registration_result("SetLessComputerHotkeyRaw", (symbol, states), &result); + tolerate_optional_fcitx_method(result) } fn set_active_asr_provider(&self, provider_id: &str) -> Result<(), BackendError> { @@ -252,30 +269,160 @@ fn tolerate_optional_fcitx_method(result: Result<(), BackendError>) -> Result<() } fn apply_dictation_hotkey(binding: &ShortcutBinding) -> Result<(), BackendError> { + // 「按住某个修饰键说话」是 Core 允许的形态(macOS 默认就是它)。 + // Linux 侧的限制不在注册,而在**吞键**:插件只对非修饰键 + // `filterAndAccept()`,按住期间若又按下别的键则判定为组合键并放弃触发 + // (见 hotkey_match.h 的 shouldConsume)。所以这里照常注册。 if let Some(trigger) = legacy_modifier_trigger(binding) { let symbol = modifier_trigger_keysym(trigger)?; - return crate::fcitx5::set_raw_hotkey("SetHotkeyRaw", symbol, 0); + let result = crate::fcitx5::set_raw_hotkey("SetHotkeyRaw", symbol, 0); + log_registration_result("SetHotkeyRaw", (symbol, 0), &result); + return result; } - crate::fcitx5::set_custom_dictation_trigger(&binding_to_fcitx_key(binding)) + let key = binding_to_fcitx_key(binding); + log::info!( + "[fcitx] registered SetCustomDictationTrigger key={} sym=0x{:x} states=0x{:x}", + key, + shortcut_to_raw(binding).map(|raw| raw.0).unwrap_or(0), + shortcut_to_raw(binding).map(|raw| raw.1).unwrap_or(0) + ); + crate::fcitx5::set_custom_dictation_trigger(&key) } fn apply_action_hotkey( method: &str, binding: Option<&ShortcutBinding>, ) -> Result<(), BackendError> { - let (symbol, states) = binding.map(shortcut_to_raw).transpose()?.unwrap_or((0, 0)); - crate::fcitx5::set_raw_hotkey(method, symbol, states) + let raw = binding.map(registerable_raw).transpose()?.unwrap_or((0, 0)); + let result = crate::fcitx5::set_raw_hotkey(method, raw.0, raw.1); + log_registration_result(method, raw, &result); + result +} + +/// Record the exact `(sym, states)` pair handed to the addon and whether the +/// call landed. A registration that silently failed (unknown method on an older +/// addon, bad arguments) used to be swallowed by +/// [`tolerate_optional_fcitx_method`], so "the hotkey does nothing" had no +/// visible cause anywhere — this line is that cause. +fn log_registration_result(method: &str, raw: (u32, u32), result: &Result<(), BackendError>) { + match result { + Ok(()) => log::info!( + "[fcitx] registered {} sym=0x{:x} states=0x{:x}", + method, + raw.0, + raw.1 + ), + Err(error) => log::warn!( + "[fcitx] registration FAILED {} sym=0x{:x} states=0x{:x}: {}", + method, + raw.0, + raw.1, + error.message + ), + } +} + +/// Convert a binding into the `(keysym, states)` pair fcitx5 should grab. +/// +/// Modifier-only bindings (`LeftControl`, `Shift`, …) are registered as such: +/// the plugin never consumes a modifier key, so "hold this key to talk" works +/// without taking the modifier away from every other application. +pub(crate) fn registerable_raw(binding: &ShortcutBinding) -> Result<(u32, u32), BackendError> { + shortcut_to_raw(binding) +} + +/// True for `primary` = a bare modifier with no other modifier held. Used by the +/// registration summary so a modifier trigger is visible as such in the log. +pub fn is_bare_modifier_binding(binding: &ShortcutBinding) -> bool { + if !binding.modifiers.is_empty() { + return false; + } + if legacy_modifier_trigger(binding).is_some() { + return true; + } + matches!( + binding.primary.trim().to_ascii_lowercase().as_str(), + "shift" + | "control" + | "ctrl" + | "alt" + | "option" + | "opt" + | "super" + | "meta" + | "win" + | "cmd" + | "command" + | "fn" + | "function" + | "mediaplaypause" + | "mediaplay" + | "playpause" + ) +} + +/// One-line summary of what the next [`apply_hotkeys`] will register. +pub(crate) fn registration_summary(target: &HotkeyRuntimeTarget) -> String { + fn describe(binding: Option<&ShortcutBinding>) -> String { + match binding { + None => "-".to_string(), + Some(binding) if is_bare_modifier_binding(binding) => { + // 修饰键触发:注册的是修饰键本身,插件按住期间不吞键。 + format!("modifier({})", binding.primary) + } + Some(binding) => { + let mut parts: Vec = binding + .modifiers + .iter() + .map(|modifier| normalize_modifier_tag(modifier)) + .filter(|tag| !tag.is_empty()) + .collect(); + parts.push(binding.primary.clone()); + parts.join("+") + } + } + } + format!( + "dictation={} qa={} selection_polish={} translation={} switch_style={} open_app={} style_packs={} less_computer={}", + describe(Some(&target.dictation)), + describe(target.qa.as_ref()), + describe(target.selection_polish.as_ref()), + describe(Some(&target.translation)), + describe(target.switch_style.as_ref()), + describe(target.open_app.as_ref()), + target.style_packs.len(), + if target.coding_agent_enabled { + describe(target.coding_agent_voice.as_ref()) + } else { + "disabled".to_string() + }, + ) +} + +/// Canonical lowercase tag for a modifier; unknown tags become empty so the +/// caller can drop them instead of producing a meaningless combination. +pub(crate) fn normalize_modifier_tag(modifier: &str) -> String { + match modifier.trim().to_ascii_lowercase().as_str() { + "ctrl" | "control" => "ctrl".to_string(), + "alt" | "option" | "opt" => "alt".to_string(), + "shift" => "shift".to_string(), + "cmd" | "command" | "super" | "meta" | "win" => "super".to_string(), + other => { + log::warn!("[fcitx] dropping unknown hotkey modifier '{other}'"); + String::new() + } + } } fn binding_to_fcitx_key(binding: &ShortcutBinding) -> String { let mut parts = Vec::new(); for modifier in &binding.modifiers { - let normalized = match modifier.trim().to_ascii_lowercase().as_str() { - "ctrl" | "control" => "Control".to_string(), - "alt" | "option" | "opt" => "Alt".to_string(), + let normalized = match normalize_modifier_tag(modifier).as_str() { + "ctrl" => "Control".to_string(), + "alt" => "Alt".to_string(), "shift" => "Shift".to_string(), - "cmd" | "command" | "super" | "meta" | "win" => "Super".to_string(), - other => other.to_string(), + "super" => "Super".to_string(), + _ => continue, }; if !parts.contains(&normalized) { parts.push(normalized); @@ -304,17 +451,14 @@ pub(crate) fn shortcut_to_raw(binding: &ShortcutBinding) -> Result<(u32, u32), B let mut states = 0_u32; for modifier in &binding.modifiers { - states |= match modifier.trim().to_ascii_lowercase().as_str() { + // 未知修饰键只丢弃并告警:若直接返回 Err,整轮 apply_hotkeys 会中止, + // 后面的热键(含 QA)就全都注册不上——一个脏修饰键不该有这个后果。 + states |= match normalize_modifier_tag(modifier).as_str() { "shift" => 1, - "ctrl" | "control" => 4, - "alt" | "option" | "opt" => 8, - "cmd" | "command" | "super" | "meta" | "win" => 64, - other => { - return Err(BackendError::new( - BackendErrorCode::Unsupported, - format!("fcitx5 does not support modifier {other}"), - )); - } + "ctrl" => 4, + "alt" => 8, + "super" => 64, + _ => 0, }; } let (symbol, implied_shift) = primary_keysym(&binding.primary)?; @@ -428,6 +572,105 @@ mod tests { ); } + #[test] + fn modifier_only_bindings_register_the_modifier_keysym() { + // 「按住某个修饰键说话」是 Core 允许的形态(macOS 默认就是它)。Linux + // 侧的限制从注册挪到了插件:只观察不吞键(hotkey_match.h::shouldConsume)。 + for (primary, keysym) in [ + ("LeftControl", 0xffe3_u32), + ("RightControl", 0xffe4), + ("LeftShift", 0xffe1), + ("RightShift", 0xffe2), + ("LeftAlt", 0xffe9), + ("LeftSuper", 0xffeb), + ] { + let binding = ShortcutBinding { + primary: primary.into(), + modifiers: Vec::new(), + }; + assert!( + is_bare_modifier_binding(&binding), + "{primary} must be recognised as modifier-only" + ); + assert_eq!( + registerable_raw(&binding).unwrap(), + (keysym, 0), + "{primary} must register its own keysym with no modifier bits" + ); + } + // Core 里 primary 就是 "shift" 的形态也照常注册。 + let shift = ShortcutBinding { + primary: "Shift".into(), + modifiers: Vec::new(), + }; + assert_eq!(registerable_raw(&shift).unwrap(), (0xffe1, 0)); + // 真实组合键照常注册。 + let qa = ShortcutBinding { + primary: ":".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + assert!(!is_bare_modifier_binding(&qa)); + assert_eq!(registerable_raw(&qa).unwrap(), (b';' as u32, 5)); + // 有修饰位时 primary 是字母,绝不算裸修饰键。 + let dictation = ShortcutBinding { + primary: "A".into(), + modifiers: vec!["alt".into()], + }; + assert!(!is_bare_modifier_binding(&dictation)); + } + + #[test] + fn unknown_modifiers_are_dropped_instead_of_aborting() { + // "cmd"/"Command" 是 macOS 写法;"banana" 是彻底未知的脏值。 + let binding = ShortcutBinding { + primary: "Enter".into(), + modifiers: vec!["cmd".into(), "shift".into(), "banana".into()], + }; + assert_eq!(shortcut_to_raw(&binding).unwrap(), (0xff0d, 64 | 1)); + assert_eq!(binding_to_fcitx_key(&binding), "Super+Shift+enter"); + } + + #[test] + fn registration_summary_shows_modifier_triggers() { + let target = HotkeyRuntimeTarget { + dictation: ShortcutBinding { + primary: "A".into(), + modifiers: vec!["alt".into()], + }, + dictation_mode: openless_core::shared_types::HotkeyMode::Toggle, + quick_note: None, + qa: Some(ShortcutBinding { + primary: ":".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }), + translation: ShortcutBinding { + primary: "Z".into(), + modifiers: vec!["alt".into()], + }, + switch_style: None, + open_app: None, + selection_polish: Some(ShortcutBinding { + primary: "X".into(), + modifiers: vec!["alt".into()], + }), + coding_agent_enabled: true, + coding_agent_voice: Some(ShortcutBinding { + primary: "LeftControl".into(), + modifiers: Vec::new(), + }), + coding_agent_panel: None, + coding_agent_quick: None, + style_packs: Vec::new(), + }; + let summary = registration_summary(&target); + assert!(summary.contains("dictation=alt+A"), "{summary}"); + assert!(summary.contains("qa=ctrl+shift+:"), "{summary}"); + assert!( + summary.contains("less_computer=modifier(LeftControl)"), + "{summary}" + ); + } + #[test] fn shifted_printable_uses_base_keysym_and_shift_state() { let shortcut = ShortcutBinding { @@ -437,6 +680,19 @@ mod tests { assert_eq!(shortcut_to_raw(&shortcut).unwrap(), (b'/' as u32, 5)); } + #[test] + fn qa_default_binding_registers_the_base_key_with_ctrl_shift() { + // Core 的默认 QA 绑定写的是 ":",宿主必须把它折算成**物理键** `;`(0x3b) + // + Ctrl|Shift(0x5):插件侧按下时收到的是 level-applied 的 ':'(0x3a), + // 靠 hotkey_match.h 的 base/shifted 折叠才算命中。真机取证: + // registered SetQaHotkeyRaw sym=0x3b states=0x5 + let qa = ShortcutBinding { + primary: ":".into(), + modifiers: vec!["ctrl".into(), "shift".into()], + }; + assert_eq!(shortcut_to_raw(&qa).unwrap(), (b';' as u32, 0x5)); + } + #[test] fn legacy_addon_may_omit_optional_extended_hotkey_methods() { for message in [ diff --git a/openless-all/app/linux-egui/src/style_icon.rs b/openless-all/app/linux-egui/src/style_icon.rs new file mode 100644 index 000000000..0af29a0ef --- /dev/null +++ b/openless-all/app/linux-egui/src/style_icon.rs @@ -0,0 +1,105 @@ +//! SVG import boundary for style-pack icons. Core only accepts bounded PNGs. +use resvg::{tiny_skia, usvg}; + +const MAX_SVG_BYTES: usize = 256 * 1024; +const MAX_PNG_BYTES: usize = 64 * 1024; +const SIZE: u32 = 128; + +pub fn rasterize_svg(source: &[u8]) -> Result, &'static str> { + if source.is_empty() || source.len() > MAX_SVG_BYTES { + return Err("invalid SVG size"); + } + let text = std::str::from_utf8(source).map_err(|_| "invalid SVG encoding")?; + let lower = text.to_ascii_lowercase(); + if lower.contains(" MAX_PNG_BYTES { + return Err("PNG icon exceeds 64 KiB"); + } + Ok(png) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_svg_is_a_bounded_png() { + let png = rasterize_svg(br#""#).unwrap(); + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + assert!(png.len() <= MAX_PNG_BYTES); + } + + #[test] + fn rejects_executable_and_external_references() { + for source in [ + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + ] { + assert!(rasterize_svg(source.as_bytes()).is_err(), "{source}"); + } + } +} diff --git a/openless-all/app/linux-egui/src/tray.rs b/openless-all/app/linux-egui/src/tray.rs index 40ec96f8f..f325ab34a 100644 --- a/openless-all/app/linux-egui/src/tray.rs +++ b/openless-all/app/linux-egui/src/tray.rs @@ -567,13 +567,8 @@ mod tests { #[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, - ); + let snapshot = + crate::LinuxCapabilitySnapshot::from_environment(None, Some(":0"), true, false); assert!(!snapshot.capabilities.supports_tray); } } diff --git a/openless-all/app/linux-egui/src/ui/backdrop.rs b/openless-all/app/linux-egui/src/ui/backdrop.rs new file mode 100644 index 000000000..34102ef68 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/backdrop.rs @@ -0,0 +1,802 @@ +//! Live blurred backdrop for in-window modals (the settings sheet). +//! +//! macOS gets this for free: the Tauri window sits on a system vibrancy layer, so +//! a translucent mask reads as frosted glass. Linux has no such layer, and +//! `ViewportCommand::Screenshot` is not a substitute — the captured frame +//! contains the mask itself, so every frame would blur an already-blurred image +//! (the backdrop dissolves into a flat smear within a second) and it could never +//! follow the page. Reading back the framebuffer every frame is also a GPU stall. +//! +//! So the page is drawn a *second* time, offscreen, from the same tessellated +//! shapes, and blurred on the GPU: +//! +//! ```text +//! page shapes ──▶ egui renderer ──▶ MSAA resolve ──▶ blur H ──▶ blur V ──▶ egui image +//! (1/4 scale) (native texture) +//! ``` +//! +//! All of it happens inside `update`, before eframe paints the frame, so the +//! texture the overlay samples already holds the current page: no capture delay, +//! no stale frame, nothing to refresh by hand. + +use std::borrow::Cow; +use std::sync::Arc; + +use eframe::egui; +use eframe::egui_wgpu::{self, wgpu}; + +/// The offscreen page render is downscaled by this factor. Blurring a quarter +/// resolution copy is both cheaper than a full-resolution convolution and softer, +/// which is what a frosted panel wants. +const DOWNSCALE: f32 = 4.0; + +/// Where the overlay finds the blur it should sample. The value is the +/// `(texture, uv)` pair published by [`publish`]. +const IMAGE_KEY: &str = "openless-backdrop-image"; + +/// A separable Gaussian at the downscaled resolution: sigma ≈ 2 texels ≈ 8px on +/// screen, the same ballpark as the desktop `backdrop-filter: blur(6px)`. +/// Five taps with linear filtering equal a nine-tap convolution. +const BLUR_SHADER: &str = r#" +struct BlurParams { + // xy = source texel size, zw = blur direction. + texel_and_direction: vec4, +}; + +@group(0) @binding(0) var source: texture_2d; +@group(0) @binding(1) var source_sampler: sampler; +@group(0) @binding(2) var params: BlurParams; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) index: u32) -> VertexOutput { + // Full-screen triangle: cheaper than a quad, and it cannot leave a seam. + var corners = array, 3>( + vec2(-1.0, -3.0), + vec2(-1.0, 1.0), + vec2(3.0, 1.0), + ); + let corner = corners[index]; + var out: VertexOutput; + out.position = vec4(corner, 0.0, 1.0); + // Clip space is y-up; egui textures are y-down. + out.uv = vec2((corner.x + 1.0) * 0.5, (1.0 - corner.y) * 0.5); + return out; +} + +@fragment +fn fs_main(in: VertexOutput) -> @location(0) vec4 { + let step = params.texel_and_direction.xy * params.texel_and_direction.zw; + var color = textureSample(source, source_sampler, in.uv) * 0.2270270270; + color += textureSample(source, source_sampler, in.uv + step * 1.3846153846) * 0.3162162162; + color += textureSample(source, source_sampler, in.uv - step * 1.3846153846) * 0.3162162162; + color += textureSample(source, source_sampler, in.uv + step * 3.2307692308) * 0.0702702703; + color += textureSample(source, source_sampler, in.uv - step * 3.2307692308) * 0.0702702703; + return color; +} +"#; + +/// The texture the overlay samples. It covers the window's content rect; use +/// [`uv_for`] to crop any rect inside the window out of it. +pub type BackdropImage = egui::TextureId; + +/// Publish (or clear) this frame's blurred backdrop for the overlay. +pub fn publish(ctx: &egui::Context, image: Option) { + ctx.data_mut(|data| { + if let Some(image) = image { + data.insert_temp(egui::Id::new(IMAGE_KEY), image); + } else { + data.remove::(egui::Id::new(IMAGE_KEY)); + } + }); +} + +/// The blurred backdrop the overlay should sample, if this frame has one. +pub fn published(ctx: &egui::Context) -> Option { + ctx.data(|data| data.get_temp::(egui::Id::new(IMAGE_KEY))) +} + +/// Texture coordinates for `rect` (window points) inside the published backdrop. +pub fn uv_for(ctx: &egui::Context, rect: egui::Rect) -> egui::Rect { + let window = ctx.content_rect(); + let size = egui::vec2(window.width().max(1.0), window.height().max(1.0)); + egui::Rect::from_min_max( + ((rect.min - window.min) / size).to_pos2(), + ((rect.max - window.min) / size).to_pos2(), + ) +} + +/// `vec4` as bytes, without dragging in `bytemuck` for one uniform. +fn f32x4_bytes(values: [f32; 4]) -> [u8; 16] { + let mut bytes = [0u8; 16]; + for (index, value) in values.iter().enumerate() { + bytes[index * 4..index * 4 + 4].copy_from_slice(&value.to_ne_bytes()); + } + bytes +} + +/// Offscreen render targets, recreated whenever the window size changes. +struct Targets { + /// Offscreen page size in pixels (1/4 of the content rect). + size: [u32; 2], + /// Multisampled colour target the page is drawn into; `None` without MSAA. + msaa_view: Option, + /// The resolved (1 sample) page: the source of the horizontal blur pass. + page_view: wgpu::TextureView, + /// Intermediate target of the horizontal blur pass. + scratch_view: wgpu::TextureView, + /// Final blurred image, sampled by the overlay. + blurred_view: wgpu::TextureView, + #[cfg(test)] + blurred_texture: wgpu::Texture, + horizontal: wgpu::BindGroup, + vertical: wgpu::BindGroup, + texture_id: egui::TextureId, +} + +impl Targets { + #[allow(clippy::too_many_arguments)] + fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + renderer: &mut egui_wgpu::Renderer, + format: wgpu::TextureFormat, + msaa_samples: u32, + bind_layout: &wgpu::BindGroupLayout, + sampler: &wgpu::Sampler, + size: [u32; 2], + ) -> Self { + let extent = wgpu::Extent3d { + width: size[0], + height: size[1], + depth_or_array_layers: 1, + }; + let target = |label: &str| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::TEXTURE_BINDING + | wgpu::TextureUsages::COPY_SRC, + view_formats: &[format], + }) + }; + let msaa_view = (msaa_samples > 1).then(|| { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some("openless-backdrop-msaa"), + size: extent, + mip_level_count: 1, + sample_count: msaa_samples, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[format], + }) + .create_view(&wgpu::TextureViewDescriptor::default()) + }); + let page_view = target("openless-backdrop-page").create_view(&Default::default()); + let scratch_view = + target("openless-backdrop-blur-scratch").create_view(&Default::default()); + let blurred_texture = target("openless-backdrop-blurred"); + let blurred_view = blurred_texture.create_view(&Default::default()); + let texel = [1.0 / size[0] as f32, 1.0 / size[1] as f32]; + let uniform = |direction: [f32; 2]| { + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("openless-backdrop-blur-params"), + size: 16, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + queue.write_buffer( + &buffer, + 0, + &f32x4_bytes([texel[0], texel[1], direction[0], direction[1]]), + ); + buffer + }; + let bind = |texture: &wgpu::TextureView, buffer: &wgpu::Buffer| { + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("openless-backdrop-blur"), + layout: bind_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(texture), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(sampler), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: buffer.as_entire_binding(), + }, + ], + }) + }; + let horizontal = bind(&page_view, &uniform([1.0, 0.0])); + let vertical = bind(&scratch_view, &uniform([0.0, 1.0])); + let texture_id = + renderer.register_native_texture(device, &blurred_view, wgpu::FilterMode::Linear); + Self { + size, + msaa_view, + page_view, + scratch_view, + blurred_view, + #[cfg(test)] + blurred_texture, + horizontal, + vertical, + texture_id, + } + } +} + +/// GPU state for the blurred backdrop. Lives on the UI thread; every method is a +/// no-op until the first frame that needs a blur. +pub struct BackdropBlur { + device: wgpu::Device, + queue: wgpu::Queue, + renderer: Arc>, + format: wgpu::TextureFormat, + msaa_samples: u32, + sampler: wgpu::Sampler, + bind_layout: wgpu::BindGroupLayout, + pipeline: wgpu::RenderPipeline, + targets: Option, +} + +impl BackdropBlur { + /// Build from the running eframe render state. `msaa_samples` must match the + /// renderer's MSAA setting (`NativeOptions::multisampling`), because the + /// offscreen pass reuses eframe's pipelines. + pub fn new(state: &egui_wgpu::RenderState, msaa_samples: u32) -> Self { + Self::new_with( + state.device.clone(), + state.queue.clone(), + state.target_format, + msaa_samples, + Arc::clone(&state.renderer), + ) + } + + fn new_with( + device: wgpu::Device, + queue: wgpu::Queue, + format: wgpu::TextureFormat, + msaa_samples: u32, + renderer: Arc>, + ) -> Self { + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("openless-backdrop-sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("openless-backdrop-blur-layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + ], + }); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("openless-backdrop-blur"), + source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(BLUR_SHADER)), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("openless-backdrop-blur-pipeline-layout"), + bind_group_layouts: &[Some(&bind_layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("openless-backdrop-blur"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + Self { + device, + queue, + renderer, + format, + msaa_samples, + sampler, + bind_layout, + pipeline, + targets: None, + } + } + + /// Resize the offscreen targets and return the texture the overlay should + /// sample this frame. `None` when there is nothing to blur (modal closed). + pub fn prepare(&mut self, ctx: &egui::Context, open: bool) -> Option { + if !open { + self.release(); + return None; + } + let content = ctx.content_rect(); + if content.width() < 1.0 || content.height() < 1.0 { + return None; + } + let scale = ctx.pixels_per_point() / DOWNSCALE; + let size = [ + ((content.width() * scale).round() as u32).max(1), + ((content.height() * scale).round() as u32).max(1), + ]; + self.ensure(size); + self.targets.as_ref().map(|targets| targets.texture_id) + } + + /// Draw the page (everything under the modal) offscreen and blur it. Runs + /// before eframe paints, so the overlay samples this frame's pixels. + pub fn render_page(&mut self, ctx: &egui::Context) { + if self.targets.is_none() { + return; + } + let primitives = page_primitives(ctx); + if primitives.is_empty() { + return; + } + self.blur_primitives(ctx, &primitives); + } + + fn blur_primitives(&mut self, ctx: &egui::Context, primitives: &[egui::ClippedPrimitive]) { + let targets = self.targets.as_ref().expect("checked by render_page"); + let screen = egui_wgpu::ScreenDescriptor { + size_in_pixels: targets.size, + pixels_per_point: ctx.pixels_per_point() / DOWNSCALE, + }; + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("openless-backdrop"), + }); + let callbacks = { + let mut renderer = self.renderer.write(); + let callbacks = renderer.update_buffers( + &self.device, + &self.queue, + &mut encoder, + primitives, + &screen, + ); + { + let (view, resolve) = match &targets.msaa_view { + Some(msaa) => (msaa, Some(&targets.page_view)), + None => (&targets.page_view, None), + }; + let mut pass = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("openless-backdrop-page"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: resolve, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Discard, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }) + .forget_lifetime(); + renderer.render(&mut pass, primitives, &screen); + } + callbacks + }; + self.blur_pass(&mut encoder, &targets.scratch_view, &targets.horizontal); + self.blur_pass(&mut encoder, &targets.blurred_view, &targets.vertical); + if !callbacks.is_empty() { + self.queue.submit(callbacks); + } + self.queue.submit(std::iter::once(encoder.finish())); + } + + fn blur_pass( + &self, + encoder: &mut wgpu::CommandEncoder, + view: &wgpu::TextureView, + bind_group: &wgpu::BindGroup, + ) { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("openless-backdrop-blur"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, bind_group, &[]); + pass.draw(0..3, 0..1); + } + + fn ensure(&mut self, size: [u32; 2]) { + if self + .targets + .as_ref() + .is_some_and(|targets| targets.size == size) + { + return; + } + let mut renderer = self.renderer.write(); + if let Some(previous) = self.targets.take() { + renderer.free_texture(&previous.texture_id); + } + self.targets = Some(Targets::new( + &self.device, + &self.queue, + &mut renderer, + self.format, + self.msaa_samples, + &self.bind_layout, + &self.sampler, + size, + )); + } + + /// Drop the GPU targets (modal closed). The pipeline and layouts are cheap + /// and stay around, like eframe's own renderer. + pub fn release(&mut self) { + let Some(targets) = self.targets.take() else { + return; + }; + self.renderer.write().free_texture(&targets.texture_id); + } +} + +/// Every layer painted below the modal: the window surface, titlebar, sidebar and +/// the page itself. `Order::Foreground` is where the modal lives, so it is +/// excluded — the backdrop can never contain the mask it sits under. +fn backdrop_layers(ctx: &egui::Context) -> Vec { + // The window surface is painted through a bare `layer_painter`, so it is not + // registered as an area and `layer_ids` cannot see it. + let mut layers = vec![egui::LayerId::new( + egui::Order::Background, + egui::Id::new("openless-window-background"), + )]; + ctx.memory(|memory| { + for layer in memory.layer_ids() { + if layer.order < egui::Order::Foreground && !layers.contains(&layer) { + layers.push(layer); + } + } + }); + layers +} + +/// Tessellate the page for the offscreen pass. The shapes come from this frame's +/// paint lists, so the backdrop follows scrolling, resizing and page switches. +fn page_primitives(ctx: &egui::Context) -> Vec { + let layers = backdrop_layers(ctx); + let shapes = ctx.graphics(|graphics| { + let mut shapes = Vec::new(); + for layer in &layers { + if let Some(list) = graphics.get(*layer) { + shapes.extend(list.all_entries().cloned()); + } + } + shapes + }); + if shapes.is_empty() { + return Vec::new(); + } + ctx.tessellate(shapes, ctx.pixels_per_point()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 无头测试里没有渲染器消费 `TexturesDelta`,直接 drop 会 panic。 + fn end_pass(ctx: &egui::Context) { + let mut output = ctx.end_pass(); + output.textures_delta.clear(); + } + + /// A GPU is not guaranteed (CI has none), so every device test bows out when + /// no adapter answers instead of failing the suite. + fn test_device() -> Option<(wgpu::Device, wgpu::Queue)> { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + flags: wgpu::InstanceFlags::default(), + memory_budget_thresholds: Default::default(), + backend_options: Default::default(), + display: None, + }); + let runtime = tokio::runtime::Builder::new_current_thread().build().ok()?; + let adapter = runtime + .block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .ok()?; + runtime + .block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) + .ok() + } + + #[test] + fn backdrop_uv_crops_the_content_rect_out_of_the_full_window_texture() { + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(800.0, 600.0), + )), + ..Default::default() + }); + let uv = uv_for( + &ctx, + egui::Rect::from_min_max(egui::pos2(0.0, 40.0), egui::pos2(800.0, 600.0)), + ); + assert!((uv.min.x - 0.0).abs() < 1e-4, "{uv:?}"); + assert!((uv.min.y - 40.0 / 600.0).abs() < 1e-4, "{uv:?}"); + assert!((uv.max.x - 1.0).abs() < 1e-4, "{uv:?}"); + assert!((uv.max.y - 1.0).abs() < 1e-4, "{uv:?}"); + end_pass(&ctx); + } + + #[test] + fn publishing_and_clearing_the_backdrop_round_trips() { + let ctx = egui::Context::default(); + assert!(published(&ctx).is_none()); + publish(&ctx, Some(egui::TextureId::User(7))); + assert_eq!(published(&ctx), Some(egui::TextureId::User(7))); + publish(&ctx, None); + assert!(published(&ctx).is_none()); + } + + #[test] + fn the_backdrop_only_collects_layers_below_the_modal() { + let ctx = egui::Context::default(); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(800.0, 600.0), + )), + ..Default::default() + }); + for (order, id) in [ + (egui::Order::Middle, "page"), + (egui::Order::Foreground, "openless-settings-modal"), + (egui::Order::Tooltip, "tooltip"), + ] { + egui::Area::new(egui::Id::new(id)) + .order(order) + .fixed_pos(egui::pos2(10.0, 10.0)) + .show(&ctx, |ui| { + ui.label("layer"); + }); + } + let layers = backdrop_layers(&ctx); + assert!(layers.contains(&egui::LayerId::new( + egui::Order::Middle, + egui::Id::new("page") + ))); + assert!(!layers + .iter() + .any(|layer| layer.id == egui::Id::new("openless-settings-modal"))); + assert!(!layers + .iter() + .any(|layer| layer.id == egui::Id::new("tooltip"))); + end_pass(&ctx); + } + + /// End-to-end GPU check: the page pass, the MSAA resolve and both blur passes + /// must produce a smeared copy of the page, the right way up. Skipped without + /// an adapter, so CI without a GPU still passes. + #[test] + fn the_offscreen_page_is_blurred_without_flipping_it() { + let Some((device, queue)) = test_device() else { + eprintln!("no wgpu adapter available; skipping the GPU blur test"); + return; + }; + let format = wgpu::TextureFormat::Rgba8Unorm; + let msaa_samples = 4; + let renderer = Arc::new(egui::mutex::RwLock::new(egui_wgpu::Renderer::new( + &device, + format, + egui_wgpu::RendererOptions { + msaa_samples, + ..Default::default() + }, + ))); + let mut blur = BackdropBlur::new_with( + device.clone(), + queue.clone(), + format, + msaa_samples, + Arc::clone(&renderer), + ); + + // 256x256 points at 1 px/point -> a 64x64 backdrop (`DOWNSCALE` = 4). + let ctx = egui::Context::default(); + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(256.0, 256.0), + )), + ..Default::default() + }; + for frame in 0..2 { + ctx.begin_pass(raw.clone()); + // 黑色整页 + 右上象限一块白:翻转或转置过的背板不可能把白色留在同一处。 + // 用裸 `layer_painter` 而不是 `Area`:Area 的 `fade_in` 在无头测试里会 + // 把首帧画成 `Shape::Noop`,那样这里就什么都测不到了。 + let painter = ctx.layer_painter(egui::LayerId::new( + egui::Order::Background, + egui::Id::new("openless-window-background"), + )); + painter.rect_filled( + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(256.0, 256.0)), + egui::CornerRadius::ZERO, + egui::Color32::BLACK, + ); + painter.rect_filled( + egui::Rect::from_min_max(egui::pos2(128.0, 0.0), egui::pos2(256.0, 128.0)), + egui::CornerRadius::ZERO, + egui::Color32::WHITE, + ); + if frame == 1 { + assert!(blur.prepare(&ctx, true).is_some()); + assert_eq!( + blur.targets.as_ref().map(|targets| targets.size), + Some([64, 64]) + ); + blur.render_page(&ctx); + } + let mut output = ctx.end_pass(); + let mut guard = renderer.write(); + for (id, deltas) in &output.textures_delta.set { + for delta in deltas { + guard.update_texture(&device, &queue, *id, delta); + } + } + for id in &output.textures_delta.free { + guard.free_texture(id); + } + drop(guard); + output.textures_delta.clear(); + } + + let pixels = read_pixels(&device, &queue, &blur); + let at = |x: usize, y: usize| { + let index = (y * 64 + x) * 4; + pixels[index] + }; + // Inside the square: still white, so nothing moved or flipped. + assert!( + at(56, 8) > 200, + "top-right should stay white: {}", + at(56, 8) + ); + // The opposite corner: still black. + assert!( + at(8, 56) < 32, + "bottom-left should stay black: {}", + at(8, 56) + ); + // Straddling the square's left edge: smeared, not a hard step. + assert!( + (32..=224).contains(&at(32, 8)), + "the edge should be blurred: {}", + at(32, 8) + ); + } + + fn read_pixels(device: &wgpu::Device, queue: &wgpu::Queue, blur: &BackdropBlur) -> Vec { + let targets = blur.targets.as_ref().expect("blur targets"); + let [width, height] = targets.size; + let unpadded = width * 4; + let padded = unpadded.div_ceil(256) * 256; + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("openless-backdrop-readback"), + size: (padded * height) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("openless-backdrop-readback"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &targets.blurred_texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + queue.submit(std::iter::once(encoder.finish())); + let slice = buffer.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + let _ = device.poll(wgpu::PollType::wait_indefinitely()); + let mapped = slice.get_mapped_range().expect("readback buffer mapped"); + let mut pixels = Vec::with_capacity((unpadded * height) as usize); + for row in 0..height { + let start = (row * padded) as usize; + pixels.extend_from_slice(&mapped[start..start + unpadded as usize]); + } + drop(mapped); + buffer.unmap(); + pixels + } +} diff --git a/openless-all/app/linux-egui/src/ui/bridge.rs b/openless-all/app/linux-egui/src/ui/bridge.rs new file mode 100644 index 000000000..e61285d66 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/bridge.rs @@ -0,0 +1,983 @@ +//! 宿主进程 ⇄ UI 窗口进程之间的 IPC。 +//! +//! 形态:**常驻无窗口宿主**(后端 / 数据 / 单实例锁 / 热键 / 托盘 / 弹窗)与 +//! **可随时开关的 UI 窗口进程**(纯渲染,绝不碰后端与数据目录)。关窗只是窗口 +//! 进程退出(任务栏条目随之消失),宿主与后端状态原地不动。 +//! +//! 时序契约(两端都必须守): +//! 1. 宿主先抢到单实例锁、起好托盘与热键,**然后**才 bind 本 socket; +//! 2. socket 就绪后才拉 UI 进程 —— UI 必须连上宿主才渲染任何数据; +//! 3. 快照带单调递增 `sequence`,UI 只接受更大的序号,旧包直接丢,绝不回退; +//! 4. UI 断连(EOF)后宿主立即作废窗口句柄,但会话/录音/弹窗不受影响; +//! 5. 宿主退出时先给 UI 发 `Shutdown`,再释放单实例锁。 +//! +//! 帧格式与弹窗协议一致:一行一个 JSON 对象,便于用既有工具排查。 + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::thread::JoinHandle; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use super::frontend::view_model::{FrontendAction, FrontendViewModel}; + +/// 协议版本:宿主与 UI 进程对不上就直接拒绝启动 UI(避免半懂不懂地渲染)。 +pub const UI_BRIDGE_VERSION: u32 = 2; + +/// 单帧上限。视图模型快照含历史列表,比弹窗协议大得多。 +const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; + +/// UI 进程等待宿主 socket 出现的上限(宿主 bind 后才拉它,正常是毫秒级)。 +pub const UI_CLIENT_CONNECT_TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum HostToWindow { + /// 握手确认:UI 收到它才算真的接上,此前不渲染任何业务数据。 + Ready { + version: u32, + }, + Rejected { + reason: String, + }, + FocusMain, + /// 本地热键匹配所需的配置。 + /// + /// UI 窗口进程有焦点时 fcitx5 收不到按键(见 `crate::local_hotkeys` 的模块 + /// 文档),所以窗口自己按这份配置匹配。宿主在窗口连上后、以及配置变化后 + /// 各发一次;与视图模型快照分开,避免让含历史列表的大快照为几个绑定加宽。 + Hotkeys { + version: u32, + bindings: Box, + }, + /// 完整视图模型快照;`sequence` 单调递增。 + Snapshot { + sequence: u64, + view_model: Box, + }, + /// 延迟探针回包。 + Pong { + sequence: u64, + }, + /// 宿主退出,UI 自行关窗退出。 + Shutdown, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum WindowToHost { + /// UI 已连上,报告自己的协议版本。 + Hello { version: u32 }, + /// 用户动作,按发送顺序处理。 + Action { + sequence: u64, + action: Box, + }, + /// 延迟探针。 + Ping { sequence: u64 }, + /// 本窗口内命中的热键。 + /// + /// 插件只在其聚焦的客户端注册了 text-input 时才收得到按键,我们的窗口从不 + /// 注册 —— 所以窗口有焦点时热键只能由窗口自己认出来,作为边沿送回宿主, + /// 由宿主合成与插件信号等价的 `LinuxHotkeyEvent`(并按热键身份去重)。 + Hotkey { + sequence: u64, + edge: openless_linux_egui::LocalHotkeyEdge, + }, + /// UI 正常退出前的告别(宿主据此立即作废句柄,不必等 EOF)。 + Bye, +} + +/// 宿主与 UI 进程约定的 socket 路径(放在 XDG_RUNTIME_DIR 下)。 +pub fn ui_socket_path(runtime_dir: &Path) -> PathBuf { + runtime_dir.join("openless-ui.sock") +} + +/// 写一帧 JSONL。 +pub fn write_frame(writer: &mut impl Write, frame: &T) -> std::io::Result<()> { + let mut encoded = serde_json::to_vec(frame) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + if encoded.len() > MAX_FRAME_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("UI bridge frame is {} bytes", encoded.len()), + )); + } + encoded.push(b'\n'); + writer.write_all(&encoded)?; + writer.flush() +} + +/// 读一帧 JSONL;EOF 时返回 `Ok(None)`,便于把「对方退出」与「帧损坏」分开。 +pub fn read_frame Deserialize<'de>>( + reader: &mut impl BufRead, +) -> std::io::Result> { + let mut line = Vec::new(); + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + if line.is_empty() { + return Ok(None); + } + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "incomplete UI frame", + )); + } + let end = available.iter().position(|byte| *byte == b'\n'); + let count = end.map_or(available.len(), |index| index + 1); + if line.len() + count > MAX_FRAME_BYTES + 1 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "UI bridge frame too large", + )); + } + line.extend_from_slice(&available[..count]); + reader.consume(count); + if end.is_some() { + break; + } + } + let frame = serde_json::from_slice(&line[..line.len() - 1]) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + Ok(Some(frame)) +} + +/// 一个已连上的 UI 窗口。读线程负责 `incoming`,写线程负责 `outgoing`。 +struct UiConnection { + ready: bool, + deadline: std::time::Instant, + action_sequence: u64, + hotkey_sequence: u64, + incoming: Receiver, + outgoing: Sender, + reader: Option>, + writer: Option>, + /// socket 的一份副本:收尾时 `shutdown(Both)` 才能把阻塞在 read 上的 + /// 读线程叫醒,否则 join 会一直等下去。 + control: UnixStream, +} + +enum Outgoing { + /// 快照会合并:排在后面的覆盖前面还没写出去的,避免 UI 卡顿时堆一堆过期状态。 + Frame(HostToWindow), + /// UI 进程发往宿主的帧(动作、探针、告别)。 + ClientFrame(WindowToHost), + /// 已编码的快照帧(宿主为了算指纹已经序列化过,避免重复序列化)。 + Encoded(Vec), + Stop, +} + +impl Outgoing { + fn is_snapshot(&self) -> bool { + matches!( + self, + Outgoing::Frame(HostToWindow::Snapshot { .. }) | Outgoing::Encoded(_) + ) + } + + fn write_to(&self, stream: &mut UnixStream) -> std::io::Result<()> { + match self { + Outgoing::Stop => Ok(()), + Outgoing::Frame(frame) => write_frame(stream, frame), + Outgoing::ClientFrame(frame) => write_frame(stream, frame), + Outgoing::Encoded(bytes) => { + if bytes.len() > MAX_FRAME_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "UI bridge frame too large", + )); + } + stream.write_all(bytes)?; + stream.write_all(b"\n")?; + stream.flush() + } + } + } +} + +/// 宿主侧桥:监听 ≤1 个 UI 窗口连接,并暴露「发快照 / 收动作」的最小接口。 +pub struct UiBridgeHost { + listener: UnixListener, + path: PathBuf, + connection: Option, + /// 下一个要发出的快照序号(单调递增;UI 侧拒收更小的序号)。 + next_sequence: u64, + /// 上一个 UI 连接的整体标识,用于日志。 + connection_generation: u64, +} + +impl UiBridgeHost { + /// bind 监听 socket。必须在抢到单实例锁、起好托盘/热键之后、拉 UI 进程之前调用。 + pub fn bind(path: PathBuf) -> std::io::Result { + // 上一次异常退出可能留下 socket 文件;监听前先清掉,否则 bind 会 EADDRINUSE。 + match std::fs::remove_file(&path) { + Ok(()) => log::info!("[ui-host] removed stale UI bridge socket"), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => log::warn!("[ui-host] stale socket not removable: {error}"), + } + let listener = UnixListener::bind(&path)?; + listener.set_nonblocking(true)?; + Ok(Self { + listener, + path, + connection: None, + next_sequence: 1, + connection_generation: 0, + }) + } + + pub fn path(&self) -> &Path { + &self.path + } + + /// 收下新连接(若有)。同一时刻只服务一个窗口;重复连接时保留先到的那个。 + pub fn accept_pending(&mut self) { + if self.connection.is_some() { + return; + } + match self.listener.accept() { + Ok((stream, _)) => { + self.connection_generation += 1; + let generation = self.connection_generation; + log::info!("[ui-host] UI window connected (generation {generation})"); + match spawn_connection(stream) { + Ok(connection) => self.connection = Some(connection), + Err(error) => { + log::warn!("[ui-host] UI window connection setup failed: {error}") + } + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {} + Err(error) => log::warn!("[ui-host] accept failed: {error}"), + } + } + + pub fn is_connected(&self) -> bool { + self.connection + .as_ref() + .is_some_and(|connection| connection.ready) + } + + /// 取走 UI 发来的消息(非阻塞,保持到达顺序)。 + pub fn drain(&mut self) -> Vec { + let mut messages = Vec::new(); + let mut disconnected = false; + let mut rejection = None; + if let Some(connection) = self.connection.as_mut() { + if !connection.ready && std::time::Instant::now() >= connection.deadline { + rejection = + Some("UI handshake timed out; restart the complete application".to_string()); + } else { + loop { + match connection.incoming.try_recv() { + Ok(WindowToHost::Hello { version }) if !connection.ready => { + if version != UI_BRIDGE_VERSION { + rejection = Some(format!("UI protocol {version} does not match {UI_BRIDGE_VERSION}; restart the complete application")); + break; + } + connection.ready = true; + let _ = + connection + .outgoing + .send(Outgoing::Frame(HostToWindow::Ready { + version: UI_BRIDGE_VERSION, + })); + messages.push(WindowToHost::Hello { version }); + } + Ok(message) if connection.ready => { + let accepted = match &message { + WindowToHost::Action { sequence, .. } => { + advance_sequence(&mut connection.action_sequence, *sequence) + } + WindowToHost::Hotkey { sequence, .. } => { + advance_sequence(&mut connection.hotkey_sequence, *sequence) + } + WindowToHost::Hello { .. } => { + rejection = Some("duplicate UI handshake".into()); + break; + } + _ => true, + }; + if accepted { + messages.push(message); + } + } + Ok(_) => { + rejection = Some("Hello is required before UI actions".into()); + break; + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + disconnected = true; + break; + } + } + } + } + } + if let Some(reason) = rejection { + if let Some(mut connection) = self.connection.take() { + let _ = connection + .outgoing + .send(Outgoing::Frame(HostToWindow::Rejected { reason })); + let _ = connection.outgoing.send(Outgoing::Stop); + if let Some(writer) = connection.writer.take() { + let _ = writer.join(); + } + } + messages.clear(); + } else if disconnected { + self.connection = None; + } + messages + } + + /// 发送快照,`payload` 必须是 `view_model` 的 JSON 编码。 + /// + /// 宿主为了判断「视图模型变没变」已经序列化过一次,这里直接拼帧、不再二次 + /// 序列化;`sequence` 由桥推进,保证单调递增。 + pub fn send_snapshot_encoded(&mut self, payload: &[u8]) { + if !self.is_connected() { + return; + } + let sequence = self.next_sequence; + self.next_sequence += 1; + let mut frame = Vec::with_capacity(payload.len() + 64); + frame.extend_from_slice(b"{\"Snapshot\":{\"sequence\":"); + frame.extend_from_slice(sequence.to_string().as_bytes()); + frame.extend_from_slice(b",\"view_model\":"); + frame.extend_from_slice(payload); + frame.extend_from_slice(b"}}"); + if frame.len() > MAX_FRAME_BYTES { + log::error!("[ui-host] snapshot exceeds the UI frame limit"); + self.connection = None; + return; + } + if let Some(connection) = self.connection.as_ref() { + if connection.outgoing.send(Outgoing::Encoded(frame)).is_err() { + self.connection = None; + } + } + } + + pub fn send(&mut self, frame: HostToWindow) { + if !self.is_connected() { + return; + } + if let Some(connection) = self.connection.as_ref() { + if connection.outgoing.send(Outgoing::Frame(frame)).is_err() { + self.connection = None; + } + } + } + + /// 宿主退出前的收尾:通知 UI 关窗,然后断开。 + pub fn shutdown(&mut self) { + if let Some(mut connection) = self.connection.take() { + let _ = connection + .outgoing + .send(Outgoing::Frame(HostToWindow::Shutdown)); + if let Some(writer) = connection.writer.take() { + let _ = writer.join(); + } + } + let _ = std::fs::remove_file(&self.path); + } +} + +fn advance_sequence(last: &mut u64, sequence: u64) -> bool { + if sequence <= *last { + return false; + } + *last = sequence; + true +} + +impl Drop for UiConnection { + fn drop(&mut self) { + let _ = self.outgoing.send(Outgoing::Stop); + let _ = self.control.shutdown(std::net::Shutdown::Both); + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + if let Some(writer) = self.writer.take() { + let _ = writer.join(); + } + } +} + +impl Drop for UiBridgeHost { + fn drop(&mut self) { + self.shutdown(); + } +} + +fn write_outgoing(stream: &mut UnixStream, outgoing_rx: Receiver) { + // 待发快照只保留最新一份:UI 慢的时候宁可跳帧,也不能画过期状态。 + let mut pending: Option = None; + loop { + let received = outgoing_rx.recv_timeout(Duration::from_millis(20)); + match received { + Ok(Outgoing::Stop) => return, + Ok(outgoing) if outgoing.is_snapshot() => pending = Some(outgoing), + Ok(outgoing) => { + let terminal = matches!( + outgoing, + Outgoing::Frame(HostToWindow::Shutdown | HostToWindow::Rejected { .. }) + ); + // Control frames precede the unsent snapshot. Shutdown + // discards it, so stale state cannot follow a terminal frame. + if outgoing.write_to(stream).is_err() || terminal { + let _ = stream.shutdown(std::net::Shutdown::Both); + return; + } + } + Err(mpsc::RecvTimeoutError::Timeout) => { + if let Some(stale) = pending.take() { + if stale.write_to(stream).is_err() { + return; + } + } + } + Err(mpsc::RecvTimeoutError::Disconnected) => return, + } + } +} + +fn spawn_connection(stream: UnixStream) -> std::io::Result { + stream.set_write_timeout(Some(Duration::from_millis(250)))?; + let reader_stream = stream.try_clone()?; + let control = stream.try_clone()?; + let (incoming_tx, incoming_rx) = mpsc::channel(); + let (outgoing_tx, outgoing_rx) = mpsc::channel::(); + let reader_outgoing = outgoing_tx.clone(); + + let reader = std::thread::Builder::new() + .name("openless-ui-host-reader".into()) + .spawn(move || { + let mut reader = BufReader::new(reader_stream); + loop { + match read_frame::(&mut reader) { + Ok(Some(frame)) => { + if incoming_tx.send(frame).is_err() { + break; + } + } + // EOF:UI 进程退出(正常关闭或被杀)。 + Ok(None) => break, + Err(error) => { + log::warn!("[ui-host] UI bridge frame error: {error}"); + break; + } + } + } + let _ = reader_outgoing.send(Outgoing::Stop); + let _ = reader.get_ref().shutdown(std::net::Shutdown::Both); + })?; + + let writer = std::thread::Builder::new() + .name("openless-ui-host-writer".into()) + .spawn(move || { + let mut stream = stream; + write_outgoing(&mut stream, outgoing_rx); + let _ = stream.shutdown(std::net::Shutdown::Both); + }); + let writer = match writer { + Ok(writer) => writer, + Err(error) => { + let _ = control.shutdown(std::net::Shutdown::Both); + let _ = reader.join(); + return Err(error); + } + }; + + Ok(UiConnection { + ready: false, + deadline: std::time::Instant::now() + UI_CLIENT_CONNECT_TIMEOUT, + action_sequence: 0, + hotkey_sequence: 0, + incoming: incoming_rx, + outgoing: outgoing_tx, + reader: Some(reader), + writer: Some(writer), + control, + }) +} + +/// UI 进程侧连接:连上宿主、收快照、发动作。 +pub struct UiBridgeClient { + incoming: Receiver, + outgoing: Sender, + reader: Option>, + writer: Option>, + /// 见 `UiConnection::control`:收尾要用它叫醒读线程。 + control: UnixStream, + ready: bool, +} + +impl UiBridgeClient { + /// 连宿主的 socket。宿主 bind 之后才拉 UI,所以这里通常一次就成; + /// 仍做重试以覆盖「宿主刚 bind 就被调度器换出」的竞态。 + pub fn connect(path: &Path) -> Result { + let deadline = std::time::Instant::now() + UI_CLIENT_CONNECT_TIMEOUT; + let mut stream = loop { + match UnixStream::connect(path) { + Ok(stream) => break stream, + Err(error) => { + if std::time::Instant::now() >= deadline { + return Err(format!("UI bridge connect failed: {error}")); + } + std::thread::sleep(Duration::from_millis(100)); + } + } + }; + stream + .set_write_timeout(Some(Duration::from_millis(250))) + .map_err(|error| error.to_string())?; + stream + .set_read_timeout(Some(UI_CLIENT_CONNECT_TIMEOUT)) + .map_err(|error| error.to_string())?; + write_frame( + &mut stream, + &WindowToHost::Hello { + version: UI_BRIDGE_VERSION, + }, + ) + .map_err(|error| error.to_string())?; + let reader_stream = stream + .try_clone() + .map_err(|error| format!("UI bridge clone failed: {error}"))?; + let control = stream + .try_clone() + .map_err(|error| format!("UI bridge clone failed: {error}"))?; + let (incoming_tx, incoming_rx) = mpsc::channel(); + let (outgoing_tx, outgoing_rx) = mpsc::channel::(); + let reader_outgoing = outgoing_tx.clone(); + let reader = std::thread::Builder::new() + .name("openless-ui-client-reader".into()) + .spawn(move || { + let mut reader = BufReader::new(reader_stream); + match read_frame::(&mut reader) { + Ok(Some(HostToWindow::Ready { version })) if version == UI_BRIDGE_VERSION => { + let _ = reader.get_ref().set_read_timeout(None); + if incoming_tx.send(HostToWindow::Ready { version }).is_err() { + let _ = reader_outgoing.send(Outgoing::Stop); + let _ = reader.get_ref().shutdown(std::net::Shutdown::Both); + return; + } + } + Ok(Some(HostToWindow::Rejected { reason })) => { + let _ = incoming_tx.send(HostToWindow::Rejected { reason }); + let _ = reader_outgoing.send(Outgoing::Stop); + let _ = reader.get_ref().shutdown(std::net::Shutdown::Both); + return; + } + other => { + let _ = incoming_tx.send(HostToWindow::Rejected { + reason: format!( + "UI handshake failed: {other:?}; restart the complete application" + ), + }); + let _ = reader_outgoing.send(Outgoing::Stop); + let _ = reader.get_ref().shutdown(std::net::Shutdown::Both); + return; + } + } + loop { + match read_frame::(&mut reader) { + Ok(Some(frame)) => { + if incoming_tx.send(frame).is_err() { + break; + } + } + Ok(None) => break, + Err(error) => { + log::warn!("[ui-client] host frame error: {error}"); + break; + } + } + } + let _ = reader_outgoing.send(Outgoing::Stop); + let _ = reader.get_ref().shutdown(std::net::Shutdown::Both); + }) + .map_err(|error| format!("UI bridge reader thread failed: {error}"))?; + let writer = std::thread::Builder::new() + .name("openless-ui-client-writer".into()) + .spawn(move || { + let mut stream = stream; + write_outgoing(&mut stream, outgoing_rx); + let _ = stream.shutdown(std::net::Shutdown::Both); + }); + let writer = match writer { + Ok(writer) => writer, + Err(error) => { + let _ = control.shutdown(std::net::Shutdown::Both); + let _ = reader.join(); + return Err(format!("UI bridge writer thread failed: {error}")); + } + }; + Ok(Self { + incoming: incoming_rx, + outgoing: outgoing_tx, + reader: Some(reader), + writer: Some(writer), + control, + ready: false, + }) + } + + pub fn is_ready(&self) -> bool { + self.ready + } + + pub fn try_recv(&mut self) -> Result { + let frame = self.incoming.try_recv()?; + if matches!( + frame, + HostToWindow::Ready { + version: UI_BRIDGE_VERSION + } + ) { + self.ready = true; + } + Ok(frame) + } + + pub fn send(&mut self, frame: WindowToHost) -> Result<(), String> { + if !self.ready { + return Err("UI handshake is not ready".into()); + } + self.outgoing + .send(Outgoing::ClientFrame(frame)) + .map_err(|_| "UI bridge is closed".into()) + } + + /// 退出前收尾:停掉读写线程。 + pub fn shutdown(&mut self) { + let _ = self.outgoing.send(Outgoing::Stop); + std::thread::sleep(Duration::from_millis(20)); + // 关 socket 才能让阻塞在 read 上的读线程退出。 + let _ = self.control.shutdown(std::net::Shutdown::Both); + if let Some(writer) = self.writer.take() { + let _ = writer.join(); + } + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } + } +} + +impl Drop for UiBridgeClient { + fn drop(&mut self) { + self.shutdown(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::frontend::view_model::Page; + + /// 轮询等待一个非空结果(跨线程的帧到达有延迟,不能在测试里假设「立刻」)。 + fn wait_for(mut poll: impl FnMut() -> Option) -> T { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + loop { + if let Some(value) = poll() { + return value; + } + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for a frame" + ); + std::thread::sleep(Duration::from_millis(5)); + } + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("openless-ui-bridge-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn frames_round_trip_over_the_socket() { + let dir = temp_dir("roundtrip"); + let path = ui_socket_path(&dir); + let mut host = UiBridgeHost::bind(path.clone()).unwrap(); + let mut client = UiBridgeClient::connect(&path).unwrap(); + host.accept_pending(); + assert!(!host.is_connected()); + let received = wait_for(|| { + let messages = host.drain(); + (!messages.is_empty()).then_some(messages) + }); + assert!(matches!(received.as_slice(), [WindowToHost::Hello { .. }])); + assert!(matches!( + wait_for(|| client.try_recv().ok()), + HostToWindow::Ready { + version: UI_BRIDGE_VERSION + } + )); + assert!(host.is_connected()); + + let view_model = FrontendViewModel { + active_page: Page::History, + ..Default::default() + }; + let payload = serde_json::to_vec(&view_model).unwrap(); + host.send_snapshot_encoded(&payload); + let frame = loop { + match client.try_recv() { + Ok(frame) => break frame, + Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), + Err(TryRecvError::Disconnected) => panic!("client disconnected early"), + } + }; + match frame { + HostToWindow::Snapshot { + sequence, + view_model, + } => { + assert_eq!(sequence, 1, "first snapshot is sequence 1"); + assert_eq!(view_model.active_page, Page::History); + } + other => panic!("unexpected frame {other:?}"), + } + host.shutdown(); + client.shutdown(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn snapshots_are_monotonic_and_stale_frames_are_dropped() { + let dir = temp_dir("monotonic"); + let path = ui_socket_path(&dir); + let mut host = UiBridgeHost::bind(path.clone()).unwrap(); + let mut client = UiBridgeClient::connect(&path).unwrap(); + host.accept_pending(); + wait_for(|| { + host.drain(); + host.is_connected().then_some(()) + }); + assert!(matches!( + wait_for(|| client.try_recv().ok()), + HostToWindow::Ready { .. } + )); + for _ in 0..3 { + let payload = serde_json::to_vec(&FrontendViewModel::default()).unwrap(); + host.send_snapshot_encoded(&payload); + } + let mut sequences = Vec::new(); + let deadline = std::time::Instant::now() + Duration::from_secs(2); + // 连发三份快照:写线程可能合并掉中间那些(UI 卡顿时宁可跳帧), + // 但**到达 UI 的序号必须严格递增**,而且最后一份必然是最新的。 + while std::time::Instant::now() < deadline { + match client.try_recv() { + Ok(HostToWindow::Snapshot { sequence, .. }) => sequences.push(sequence), + Ok(_) => {} + Err(TryRecvError::Empty) => std::thread::sleep(Duration::from_millis(10)), + Err(TryRecvError::Disconnected) => break, + } + if sequences.last().copied() == Some(3) { + break; + } + } + assert!( + !sequences.is_empty(), + "at least the newest snapshot arrives" + ); + assert_eq!(sequences.last().copied(), Some(3)); + assert!( + sequences.windows(2).all(|pair| pair[0] < pair[1]), + "snapshot sequences must never go backwards: {sequences:?}" + ); + host.shutdown(); + client.shutdown(); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_dead_bridge_socket_is_replaced_on_bind() { + let dir = temp_dir("stale"); + let path = ui_socket_path(&dir); + std::fs::write(&path, b"junk").unwrap(); + let host = UiBridgeHost::bind(path.clone()).unwrap(); + assert_eq!(host.path(), path.as_path()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] + fn handshake_rejection_timeout_and_reconnect_release_the_slot() { + let dir = temp_dir("handshake"); + let path = ui_socket_path(&dir); + let mut host = UiBridgeHost::bind(path.clone()).unwrap(); + for frame in [ + WindowToHost::Hello { + version: UI_BRIDGE_VERSION - 1, + }, + WindowToHost::Action { + sequence: 1, + action: Box::new(FrontendAction::HistoryRefresh), + }, + ] { + let mut raw = UnixStream::connect(&path).unwrap(); + raw.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + host.accept_pending(); + host.send_snapshot_encoded(b"{}"); + assert!(!host.is_connected()); + write_frame(&mut raw, &frame).unwrap(); + wait_for(|| { + assert!(host.drain().is_empty()); + host.connection.is_none().then_some(()) + }); + assert!(matches!( + read_frame::(&mut BufReader::new(raw)).unwrap(), + Some(HostToWindow::Rejected { .. }) + )); + } + let idle = UnixStream::connect(&path).unwrap(); + host.accept_pending(); + host.connection.as_mut().unwrap().deadline = std::time::Instant::now(); + assert!(host.drain().is_empty()); + assert!(host.connection.is_none()); + drop(idle); + for _ in 0..2 { + let mut client = UiBridgeClient::connect(&path).unwrap(); + assert!(client.send(WindowToHost::Ping { sequence: 1 }).is_err()); + host.accept_pending(); + wait_for(|| { + host.drain(); + host.is_connected().then_some(()) + }); + assert!(matches!( + wait_for(|| client.try_recv().ok()), + HostToWindow::Ready { .. } + )); + drop(client); + wait_for(|| { + host.drain(); + host.connection.is_none().then_some(()) + }); + } + } + + #[test] + fn action_and_hotkey_sequences_are_independent_and_reset_per_connection() { + let dir = temp_dir("sequences"); + let path = ui_socket_path(&dir); + let mut host = UiBridgeHost::bind(path.clone()).unwrap(); + for _ in 0..2 { + let mut client = UiBridgeClient::connect(&path).unwrap(); + host.accept_pending(); + wait_for(|| { + host.drain(); + host.is_connected().then_some(()) + }); + wait_for(|| client.try_recv().ok()); + for sequence in [2, 2, 1, 3] { + client + .send(WindowToHost::Action { + sequence, + action: Box::new(FrontendAction::HistoryRefresh), + }) + .unwrap(); + client + .send(WindowToHost::Hotkey { + sequence, + edge: openless_linux_egui::LocalHotkeyEdge { + hotkey: openless_linux_egui::LocalHotkey::OpenApp, + kind: openless_linux_egui::LocalHotkeyEdgeKind::Pressed, + press_id: sequence, + }, + }) + .unwrap(); + } + client.send(WindowToHost::Ping { sequence: 99 }).unwrap(); + let mut messages = Vec::new(); + wait_for(|| { + messages.extend(host.drain()); + messages + .iter() + .any(|message| matches!(message, WindowToHost::Ping { sequence: 99 })) + .then_some(()) + }); + assert_eq!( + messages + .iter() + .filter(|message| matches!(message, WindowToHost::Action { .. })) + .count(), + 2 + ); + assert_eq!( + messages + .iter() + .filter(|message| matches!(message, WindowToHost::Hotkey { .. })) + .count(), + 2 + ); + drop(client); + wait_for(|| { + host.drain(); + host.connection.is_none().then_some(()) + }); + } + } + + #[test] + fn frame_limits_apply_before_an_unterminated_line_is_fully_read() { + let bytes = vec![b'x'; MAX_FRAME_BYTES * 2]; + let mut reader = std::io::Cursor::new(bytes); + assert!(read_frame::(&mut reader).is_err()); + assert!(reader.position() <= (MAX_FRAME_BYTES + 1) as u64); + assert!(write_frame(&mut Vec::new(), &"x".repeat(MAX_FRAME_BYTES)).is_err()); + let (mut writer, _reader) = UnixStream::pair().unwrap(); + assert!(Outgoing::Encoded(vec![b'x'; MAX_FRAME_BYTES + 1]) + .write_to(&mut writer) + .is_err()); + } + #[test] + fn control_frames_precede_and_shutdown_discards_the_unsent_snapshot() { + let (mut writer, reader) = UnixStream::pair().unwrap(); + let (tx, rx) = mpsc::channel(); + tx.send(Outgoing::Encoded(b"discarded snapshot".to_vec())) + .unwrap(); + tx.send(Outgoing::Frame(HostToWindow::FocusMain)).unwrap(); + tx.send(Outgoing::Frame(HostToWindow::Shutdown)).unwrap(); + write_outgoing(&mut writer, rx); + drop(writer); + let mut reader = BufReader::new(reader); + assert!(matches!( + read_frame::(&mut reader).unwrap(), + Some(HostToWindow::FocusMain) + )); + assert!(matches!( + read_frame::(&mut reader).unwrap(), + Some(HostToWindow::Shutdown) + )); + assert!(read_frame::(&mut reader).unwrap().is_none()); + } + #[test] + fn eof_stops_both_client_workers_before_the_client_is_dropped() { + let dir = temp_dir("eof-workers"); + let path = ui_socket_path(&dir); + let listener = UnixListener::bind(&path).unwrap(); + let client = UiBridgeClient::connect(&path).unwrap(); + let (mut stream, _) = listener.accept().unwrap(); + write_frame( + &mut stream, + &HostToWindow::Ready { + version: UI_BRIDGE_VERSION, + }, + ) + .unwrap(); + drop(stream); + wait_for(|| { + (client.reader.as_ref().unwrap().is_finished() + && client.writer.as_ref().unwrap().is_finished()) + .then_some(()) + }); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/corrections.rs b/openless-all/app/linux-egui/src/ui/frontend/corrections.rs new file mode 100644 index 000000000..f376ef4c4 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/corrections.rs @@ -0,0 +1,243 @@ +//! Correction rules (纠错规则) page — port of the Tauri `pages/Corrections.tsx`. +//! +//! Fixes common ASR misrecognitions: `pattern → replacement`, with one `{num}` +//! digit wildcard. Rules collected automatically from user edits are badged and +//! can be reviewed or removed like any other rule. + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n}; + +use super::layout; +use super::pages::correction_chip; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel}; + +const GAP: f32 = 14.0; +const CARD_PADDING: f32 = 20.0; + +pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + if vm.vocab_unsupported { + layout::unsupported_page(ui, lang, tr_l10n(lang, "nav.corrections")); + return; + } + + let header = layout::page_header( + ui, + width, + tr_l10n(lang, "nav.corrections"), + tr_l10n(lang, "vocab.corrections_title"), + Some(tr_l10n(lang, "vocab.corrections_tip")), + ); + let refresh = tr_l10n(lang, "common.refresh"); + let refresh_width = layout::text_width(ui, refresh, 12.5) + 40.0; + let refresh_rect = egui::Rect::from_min_size( + egui::pos2(header.right() - refresh_width, header.top() + 22.0), + egui::vec2(refresh_width, 30.0), + ); + if layout::action_button( + ui, + refresh_rect, + refresh, + Some(super::icons::IconName::Refresh), + layout::ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::VocabRefresh); + } + ui.add_space(GAP); + + // ── Add a rule ────────────────────────────────────────────────────────── + card(ui, width, |ui| { + ui.horizontal(|ui| { + let add_width = 72.0; + let arrow_width = 24.0; + let spacing = ui.spacing().item_spacing.x; + let input_width = + ((ui.available_width() - add_width - arrow_width - spacing * 3.0) / 2.0).max(60.0); + input( + ui, + input_width, + &mut vm.vocab_pattern, + tr_l10n(lang, "vocab.corrections_pattern_placeholder"), + ); + ui.add_sized( + [arrow_width, 32.0], + egui::Label::new(egui::RichText::new("→").color(theme::INK_4)) + .wrap_mode(egui::TextWrapMode::Extend), + ); + input( + ui, + input_width, + &mut vm.vocab_replacement, + tr_l10n(lang, "vocab.corrections_replacement_placeholder"), + ); + let pattern = vm.vocab_pattern.trim().to_string(); + let replacement = vm.vocab_replacement.trim().to_string(); + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "btn.add")) + .color(theme::SURFACE) + .size(12.0), + ) + .fill(theme::INK) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(add_width, 32.0)), + ) + .clicked() + && !pattern.is_empty() + { + actions.push(FrontendAction::VocabAddRule { + pattern, + replacement, + }); + vm.vocab_pattern.clear(); + vm.vocab_replacement.clear(); + } + }); + // 自动收集的筛选与「一键清空」:Tauri 把两控件放在同一行。 + let learned = vm.vocab_rules.iter().filter(|rule| rule.learned).count(); + if learned > 0 { + ui.add_space(10.0); + ui.horizontal(|ui| { + let mut only_learned = vm.vocab_rules_only_learned; + if ui + .checkbox( + &mut only_learned, + egui::RichText::new(fmt_l10n( + lang, + "vocab.corrections_only_learned", + &[&learned], + )) + .size(12.0) + .color(theme::INK_3), + ) + .changed() + { + vm.vocab_rules_only_learned = only_learned; + } + ui.add_space(4.0); + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n( + lang, + "vocab.corrections_remove_all_learned", + )) + .size(11.5), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 28.0)), + ) + .clicked() + { + // 逐条删(Tauri 同样逐条调用),倒序保证索引不战。 + let indices: Vec = vm + .vocab_rules + .iter() + .enumerate() + .filter(|(_, rule)| rule.learned) + .map(|(index, _)| index) + .rev() + .collect(); + for index in indices { + actions.push(FrontendAction::VocabRemoveRule(index)); + } + } + }); + } + if let Some(error) = vm.vocab_error.clone() { + ui.add_space(8.0); + ui.label(egui::RichText::new(error).size(11.5).color(theme::ERR)); + } + + // 规则标签始终在同一张卡片内;为空时用占位说明。 + ui.add_space(10.0); + let visible: Vec = vm + .vocab_rules + .iter() + .enumerate() + .filter(|(_, rule)| !vm.vocab_rules_only_learned || rule.learned) + .map(|(index, _)| index) + .collect(); + if visible.is_empty() { + ui.label( + egui::RichText::new(tr_l10n(lang, "vocab.corrections_empty")) + .size(12.0) + .color(theme::INK_4), + ); + } else { + ui.horizontal_wrapped(|ui| { + ui.set_min_height(20.0); + ui.spacing_mut().item_spacing = egui::vec2(8.0, 8.0); + let mut remove_index = None; + for index in visible { + let rule = &vm.vocab_rules[index]; + let label = if rule.learned { + format!( + "{} → {} {}", + rule.pattern, + rule.replacement, + tr_l10n(lang, "vocab.corrections_learned_badge") + ) + } else { + format!("{} → {}", rule.pattern, rule.replacement) + }; + let (toggle, remove) = correction_chip(ui, &label, rule.enabled); + if remove { + remove_index = Some(index); + break; + } + if toggle { + actions.push(FrontendAction::VocabToggleRule(index)); + } + } + if let Some(index) = remove_index { + actions.push(FrontendAction::VocabRemoveRule(index)); + } + }); + } + }); +} + +/// A single-line input with the shared rounded surface style. +fn input(ui: &mut egui::Ui, width: f32, value: &mut String, hint: &str) { + let content_width = (width - 20.0).max(1.0); + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.set_width(content_width); + ui.add_sized( + [content_width, 20.0], + egui::TextEdit::singleline(value) + .desired_width(content_width) + .hint_text(hint) + .frame(egui::Frame::NONE), + ); + }); +} + +/// Full-width card that sizes itself to its contents. +fn card(ui: &mut egui::Ui, width: f32, contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(CARD_PADDING as i8)) + .show(ui, |ui| { + ui.set_width((width - CARD_PADDING * 2.0).max(1.0)); + contents(ui); + }); +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/format.rs b/openless-all/app/linux-egui/src/ui/frontend/format.rs new file mode 100644 index 000000000..627411b61 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/format.rs @@ -0,0 +1,68 @@ +//! Locale-aware value formatting shared by the frontend pages. +//! +//! Kept separate from the pages so the same numbers render identically in the +//! overview dashboard and the history detail panel. + +use chrono::{Datelike, Timelike}; +use openless_linux_egui::{fmt_l10n, Lang}; + +/// Recording length as shown on history rows and the `录音 …` label. +/// `—` for unknown/zero, seconds below a minute, minutes above. +pub fn history_duration(ms: Option, lang: Lang) -> String { + let Some(ms) = ms else { + return "—".to_string(); + }; + if ms == 0 { + return "—".to_string(); + } + let seconds = ms as f64 / 1000.0; + if seconds < 60.0 { + return fmt_l10n(lang, "dur.sec", &[&format!("{seconds:.1}")]); + } + fmt_l10n( + lang, + "common.duration_minutes", + &[&format!("{:.1}", seconds / 60.0)], + ) +} + +/// One pipeline step's duration. Sub-second steps stay in integer milliseconds +/// (streaming ASR tails are tens of ms, so 0.1s rounding would hide differences). +pub fn step_duration(ms: u64, lang: Lang) -> String { + if ms < 1000 { + return fmt_l10n(lang, "dur.ms", &[&ms]); + } + history_duration(Some(ms), lang) +} + +/// RFC3339 history timestamp → compact local label. +/// +/// Today renders as `HH:MM`, the current year as `M/D HH:MM`, older entries as +/// `Y/M/D HH:MM`, mirroring the Tauri `formatHistoryTime`. +pub fn time_label(created_at: &str) -> String { + let Ok(instant) = chrono::DateTime::parse_from_rfc3339(created_at) else { + return created_at.to_string(); + }; + let local = instant.with_timezone(&chrono::Local); + let now = chrono::Local::now(); + if local.date_naive() == now.date_naive() { + format!("{:02}:{:02}", local.hour(), local.minute()) + } else if local.year() == now.year() { + format!( + "{}/{} {:02}:{:02}", + local.month(), + local.day(), + local.hour(), + local.minute() + ) + } else { + format!( + "{}/{}/{} {:02}:{:02}", + local.year(), + local.month(), + local.day(), + local.hour(), + local.minute() + ) + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/history.rs b/openless-all/app/linux-egui/src/ui/frontend/history.rs new file mode 100644 index 000000000..6217d462c --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/history.rs @@ -0,0 +1,1438 @@ +//! History page — list + detail, ported from the Tauri `pages/History.tsx`. +//! +//! Layout: +//! +//! ```text +//! ┌ 历史记录 (kicker/title/desc) [刷新] [清空] ┐ +//! │ ┌ list ───────────┐ ┌ detail ─────────────────────┐ │ +//! │ │ 🔍 search │ │ time pill 录音 3.1 秒 ⋯ │ │ +//! │ │ row / row / … │ │ [播放录音] │ │ +//! │ │ │ │ 识别 provider · model 465ms│ │ +//! │ │ │ │ 插入 App · 0 字 插入失败│ │ +//! │ │ │ │ [原文] [样式] [复制] │ │ +//! │ └─────────────────┘ └─────────────────────────────┘ │ +//! └──────────────────────────────────────────────────────┘ +//! ``` +//! +//! The page is a single-screen layout: the list and detail cards split the +//! height the shell gives them, and each scrolls independently. Only when both +//! columns cannot fit do they stack. All strings come from the localization +//! catalog; nothing is hardcoded. + +use std::sync::Arc; + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; + +use super::format; +use super::icons::{self, IconName}; +use super::layout::{self, ButtonKind, PillTone}; +use super::settings; +use super::theme; +use super::view_model::{ + FrontendAction, FrontendViewModel, HistoryConfirm, HistoryEntry, OverviewMode, ShortcutField, +}; + +const GAP: f32 = 14.0; +const LIST_WIDTH: f32 = 300.0; +// Keep the 300px list and a usable 280px detail side by side at the Linux +// window minimum (960px outer, ~706px page width). The old 760px breakpoint +// stacked both cards there and hid the detail below the clipped viewport. +const STACK_WIDTH: f32 = LIST_WIDTH + GAP + 280.0; +const CARD_PADDING: f32 = 20.0; +const DETAIL_PADDING: f32 = 12.0; +const LINE_SOFT: egui::Color32 = theme::LINE_SOFT; +const MONO_SMALL: f32 = 11.0; + +/// Faint hover wash for unselected rows. +fn hover_fill() -> egui::Color32 { + theme::SURFACE_2 +} + +// ── Entry point ───────────────────────────────────────────────────────────── + +pub fn page( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, + quick_notes_only: bool, +) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + // Tauri `QuickNote.tsx` puts the dismissible shortcut card above the list. + if quick_notes_only { + quick_note_shortcut_card(ui, width, vm, actions); + } + + header(ui, width, lang, quick_notes_only, actions); + ui.add_space(GAP); + + let body_height = ui.available_height().max(260.0); + let (body, _) = ui.allocate_exact_size(egui::vec2(width, body_height), egui::Sense::hover()); + + let (list_rect, detail_rect) = if width < STACK_WIDTH { + let list_height = (body_height * 0.45).clamp(150.0, 300.0); + ( + egui::Rect::from_min_size(body.min, egui::vec2(width, list_height)), + egui::Rect::from_min_size( + egui::pos2(body.left(), body.top() + list_height + GAP), + egui::vec2(width, (body_height - list_height - GAP).max(140.0)), + ), + ) + } else { + ( + egui::Rect::from_min_size(body.min, egui::vec2(LIST_WIDTH, body_height)), + egui::Rect::from_min_size( + egui::pos2(body.left() + LIST_WIDTH + GAP, body.top()), + egui::vec2((width - LIST_WIDTH - GAP).max(280.0), body_height), + ), + ) + }; + + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("history-column-rects"), + (list_rect, detail_rect), + ) + }); + let filtered = filtered_indices(vm, quick_notes_only); + list_card(ui, list_rect, vm, &filtered, lang, actions); + detail_card(ui, detail_rect, vm, &filtered, lang, actions); + + if vm.history_confirm.is_some() { + confirm_overlay(ui.ctx(), body, vm, lang, actions); + } +} + +/// Dismissible shortcut card for the Quick Note page. Hidden state persists in +/// `linux-ui-state.json`; when hidden a ghost button brings it back. +fn quick_note_shortcut_card( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + if vm.quick_note_shortcut_hidden { + let show = tr_l10n(lang, "quickNote.showShortcut"); + let button_width = layout::text_width(ui, show, 12.0) + 40.0; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 32.0), egui::Sense::hover()); + let button = egui::Rect::from_min_size( + egui::pos2(rect.right() - button_width, rect.top()), + egui::vec2(button_width, 28.0), + ); + if layout::action_button(ui, button, show, Some(IconName::Bolt), ButtonKind::Ghost) + .clicked() + { + actions.push(FrontendAction::QuickNoteShortcutHidden(false)); + } + ui.add_space(GAP); + return; + } + + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::symmetric(18, 18)) + .show(ui, |ui| { + ui.set_width((width - 36.0).max(1.0)); + let (row, _) = ui + .allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::hover()); + ui.painter().text( + egui::pos2(row.left(), row.center().y), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "quickNote.shortcutTitle"), + egui::FontId::proportional(13.0), + theme::INK, + ); + let close = egui::Rect::from_min_size( + egui::pos2(row.right() - 20.0, row.top()), + egui::vec2(20.0, 20.0), + ); + icons::draw_icon(ui, close.center(), IconName::Close, theme::INK_3); + if ui + .interact( + close, + ui.id().with("quick-note-shortcut-hide"), + egui::Sense::click(), + ) + .on_hover_text(tr_l10n(lang, "common.hide")) + .clicked() + { + actions.push(FrontendAction::QuickNoteShortcutHidden(true)); + } + ui.label( + egui::RichText::new(tr_l10n(lang, "quickNote.shortcutDesc")) + .size(11.0) + .color(theme::INK_4), + ); + ui.add_space(8.0); + let row = settings::ShortcutRow::new( + ShortcutField::QuickNote, + "", + vm.quick_note_hotkey.clone(), + true, + String::new(), + ); + settings::shortcut_control(ui, vm, actions, &row); + settings::shortcut_menu(ui, vm, actions, &row); + }); + ui.add_space(GAP); +} + +// ── Header ────────────────────────────────────────────────────────────────── + +fn header( + ui: &mut egui::Ui, + width: f32, + lang: Lang, + quick_note: bool, + actions: &mut Vec, +) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 84.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left(), rect.top() + 2.0), + egui::Align2::LEFT_TOP, + tr_l10n( + lang, + if quick_note { + "quickNote.kicker" + } else { + "history.kicker" + }, + ), + egui::FontId::proportional(11.0), + theme::INK_4, + ); + painter.text( + egui::pos2(rect.left(), rect.top() + 18.0), + egui::Align2::LEFT_TOP, + tr_l10n( + lang, + if quick_note { + "quickNote.title" + } else { + "history.title" + }, + ), + egui::FontId::proportional(26.0), + theme::INK, + ); + painter.text( + egui::pos2(rect.left(), rect.top() + 56.0), + egui::Align2::LEFT_TOP, + tr_l10n( + lang, + if quick_note { + "quickNote.desc" + } else { + "history.desc" + }, + ), + egui::FontId::proportional(13.0), + theme::INK_3, + ); + + let clear = tr_l10n(lang, "common.clear"); + let refresh = tr_l10n(lang, "common.refresh"); + let clear_width = if quick_note { + 0.0 + } else { + layout::text_width(ui, clear, 12.5) + 40.0 + }; + let refresh_width = layout::text_width(ui, refresh, 12.5) + 40.0; + let top = rect.top() + 22.0; + let refresh_rect = egui::Rect::from_min_size( + egui::pos2( + rect.right() - refresh_width - if quick_note { 0.0 } else { clear_width + 8.0 }, + top, + ), + egui::vec2(refresh_width, 30.0), + ); + if layout::action_button( + ui, + refresh_rect, + refresh, + Some(IconName::Refresh), + ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::HistoryRefresh); + } + // Tauri's QuickNote history has only Refresh. Recording is controlled by + // the configured hotkey, not a Start/Finish button in this page header. + if !quick_note { + let clear_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - clear_width, top), + egui::vec2(clear_width, 30.0), + ); + if layout::action_button( + ui, + clear_rect, + clear, + Some(IconName::Trash), + ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::HistoryRequestClear); + } + } +} + +// ── List ──────────────────────────────────────────────────────────────────── + +/// Indices into `history_entries` that match the current search query. +fn filtered_indices(vm: &FrontendViewModel, quick_notes_only: bool) -> Vec { + let query = vm.history_query.trim().to_lowercase(); + vm.history_entries + .iter() + .enumerate() + .filter(|(_, entry)| { + (!quick_notes_only || entry.quick_note) + && (query.is_empty() + || entry.raw_transcript.to_lowercase().contains(&query) + || entry.final_text.to_lowercase().contains(&query)) + }) + .map(|(index, _)| index) + .collect() +} + +fn selected_index(vm: &FrontendViewModel, filtered: &[usize]) -> Option { + if filtered.contains(&vm.history_selected) { + Some(vm.history_selected) + } else { + filtered.first().copied() + } +} + +fn list_card( + ui: &mut egui::Ui, + rect: egui::Rect, + vm: &mut FrontendViewModel, + filtered: &[usize], + lang: Lang, + actions: &mut Vec, +) { + paint_card(ui.painter(), rect); + layout::fixed_ui(ui, rect, ("openless-history-list-card",), |ui| { + // Sticky search box. + let padding = 14.0; + let search_rect = egui::Rect::from_min_size( + egui::pos2(rect.left() + padding, rect.top() + 12.0), + egui::vec2((rect.width() - padding * 2.0).max(1.0), 34.0), + ); + let painter = ui.painter().with_clip_rect(search_rect); + painter.rect_filled(search_rect, egui::CornerRadius::same(8), theme::SURFACE_2); + painter.rect_stroke( + search_rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + let search_id = egui::Id::new("openless-history-search"); + if ui.input(|input| input.modifiers.command && input.key_pressed(egui::Key::K)) { + ui.memory_mut(|memory| memory.request_focus(search_id)); + } + let inner = search_rect.shrink2(egui::vec2(10.0, 5.0)); + layout::fixed_ui(ui, inner, ("openless-history-search-inner",), |ui| { + ui.horizontal(|ui| { + let (icon_rect, _) = + ui.allocate_exact_size(egui::vec2(14.0, 24.0), egui::Sense::hover()); + icons::draw_icon(ui, icon_rect.center(), IconName::Search, theme::INK_3); + ui.add_space(6.0); + let hint = fmt_l10n(lang, "history.search_placeholder", &[&"Ctrl+K"]); + ui.add_sized( + [ui.available_width(), 24.0], + egui::TextEdit::singleline(&mut vm.history_query) + .id(search_id) + .hint_text(hint) + .text_color(theme::INK) + .font(egui::FontId::proportional(12.5)) + .vertical_align(egui::Align::Center) + .frame(egui::Frame::NONE), + ); + }); + }); + + // Independently scrolling list below the search box. + let list_rect = egui::Rect::from_min_max( + egui::pos2(rect.left() + 6.0, search_rect.bottom() + 6.0), + egui::pos2(rect.right() - 6.0, rect.bottom() - 6.0), + ); + layout::fixed_ui(ui, list_rect, ("openless-history-list-scroll",), |ui| { + let scroll_output = egui::ScrollArea::vertical() + .id_salt("openless-history-list") + .auto_shrink([false, false]) + .show(ui, |ui| { + let width = ui.available_width(); + if vm.history_loading { + hint(ui, width, tr_l10n(lang, "common.loading")); + return; + } + if let Some(error) = vm.history_error.as_deref() { + let message = fmt_l10n(lang, "history.load_failed", &[&error]); + if hint_with_action(ui, width, &message, tr_l10n(lang, "common.retry")) { + actions.push(FrontendAction::HistoryRefresh); + } + return; + } + if filtered.is_empty() { + let query = vm.history_query.trim(); + let message = if query.is_empty() { + tr_l10n(lang, "history.empty").to_string() + } else { + fmt_l10n(lang, "history.search_no_match", &[&query]) + }; + hint(ui, width, &message); + return; + } + for &index in filtered { + let selected = Some(index) == selected_index(vm, filtered); + row( + ui, + &vm.history_entries[index], + index, + selected, + lang, + actions, + ); + } + }); + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("history-list-scroll-measure"), + ( + scroll_output.content_size.y, + scroll_output.inner_rect.height(), + scroll_output.state.offset.y, + ), + ) + }); + #[cfg(not(test))] + let _ = scroll_output; + }); + }); +} + +fn hint(ui: &mut egui::Ui, width: f32, text: &str) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 56.0), egui::Sense::hover()); + ui.painter().with_clip_rect(rect).text( + rect.center(), + egui::Align2::CENTER_CENTER, + text, + egui::FontId::proportional(12.0), + theme::INK_4, + ); +} + +/// Hint with a trailing action button; returns whether the button was clicked. +fn hint_with_action(ui: &mut egui::Ui, width: f32, text: &str, action: &str) -> bool { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 96.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + let galley = layout_text(ui, text, theme::INK_4, 12.0, (width - 12.0).max(1.0), 4); + painter.galley( + egui::pos2(rect.left() + 6.0, rect.top() + 12.0), + galley.clone(), + theme::INK_4, + ); + let button_width = layout::text_width(ui, action, 12.5) + 30.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2( + rect.left() + 6.0, + rect.top() + 12.0 + galley.size().y + 10.0, + ), + egui::vec2(button_width, 28.0), + ); + layout::action_button(ui, button_rect, action, None, ButtonKind::Ghost).clicked() +} + +fn row( + ui: &mut egui::Ui, + entry: &HistoryEntry, + index: usize, + selected: bool, + lang: Lang, + actions: &mut Vec, +) { + let width = ui.available_width(); + let preview_text = entry.final_text.split('\n').next().unwrap_or(""); + let preview_text = if preview_text.trim().is_empty() { + entry.raw_transcript.split('\n').next().unwrap_or("") + } else { + preview_text + }; + let preview_text = if preview_text.is_empty() && entry.quick_note { + tr_l10n( + lang, + if entry.error_code.as_deref() == Some("recording") { + "quickNote.recording" + } else { + "quickNote.noTranscript" + }, + ) + } else { + preview_text + }; + let preview = if preview_text.is_empty() { + None + } else { + Some(layout_text( + ui, + preview_text, + theme::INK_2, + 12.0, + (width - 24.0).max(1.0), + 2, + )) + }; + let preview_height = preview.as_ref().map(|g| g.size().y).unwrap_or(0.0); + let row_height = 10.0 + + 15.0 + + if preview_height > 0.0 { + 4.0 + preview_height + } else { + 0.0 + } + + 6.0 + + 18.0 + + 10.0; + + let (rect, response) = + ui.allocate_exact_size(egui::vec2(width, row_height), egui::Sense::click()); + let painter = ui.painter().with_clip_rect(rect); + if selected { + painter.rect_filled(rect, egui::CornerRadius::same(10), theme::SURFACE_2); + painter.rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + } else if response.hovered() { + painter.rect_filled(rect, egui::CornerRadius::same(10), hover_fill()); + } + + let header_y = rect.top() + 10.0 + 7.5; + painter.text( + egui::pos2(rect.left() + 12.0, header_y), + egui::Align2::LEFT_CENTER, + format::time_label(&entry.created_at), + egui::FontId::monospace(MONO_SMALL), + theme::INK_3, + ); + painter.text( + egui::pos2(rect.right() - 12.0, header_y - 0.5), + egui::Align2::RIGHT_CENTER, + format::history_duration(entry.duration_ms, lang), + egui::FontId::monospace(10.0), + theme::INK_4, + ); + let mut y = rect.top() + 10.0 + 15.0; + if let Some(preview) = preview { + y += 4.0; + painter.galley(egui::pos2(rect.left() + 12.0, y), preview, theme::INK_2); + y += preview_height + 6.0; + } else { + y += 6.0; + } + let pill = layout::pill_size(ui, &entry.style_label); + let pill_rect = egui::Rect::from_min_size(egui::pos2(rect.left() + 12.0, y), pill); + layout::paint_pill( + &painter, + pill_rect, + &entry.style_label, + if entry.mode == OverviewMode::Raw { + PillTone::Outline + } else { + PillTone::Gray + }, + ); + + if response.clicked() { + actions.push(FrontendAction::HistorySelect(index)); + } + ui.add_space(4.0); +} + +// ── Detail ────────────────────────────────────────────────────────────────── + +fn detail_card( + ui: &mut egui::Ui, + rect: egui::Rect, + vm: &FrontendViewModel, + filtered: &[usize], + lang: Lang, + actions: &mut Vec, +) { + paint_card(ui.painter(), rect); + let selected = selected_index(vm, filtered); + layout::fixed_ui( + ui, + rect.shrink(CARD_PADDING), + ("openless-history-detail",), + |ui| { + let scroll_output = egui::ScrollArea::vertical() + .id_salt("openless-history-detail-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + let width = ui.available_width(); + if vm.history_loading && vm.history_entries.is_empty() { + hint(ui, width, tr_l10n(lang, "common.loading")); + return; + } + let Some(index) = selected else { + let message = if let Some(error) = vm.history_error.as_deref() { + fmt_l10n(lang, "history.load_failed", &[&error]) + } else { + tr_l10n(lang, "history.select_hint").to_string() + }; + hint(ui, width, &message); + return; + }; + detail_body( + ui, + &vm.history_entries[index], + index, + vm.history_playback.as_ref(), + lang, + actions, + ); + if vm.history_repolish_open { + ui.add_space(12.0); + ui.separator(); + ui.label( + egui::RichText::new(tr_l10n(lang, "history.repolish.title")).strong(), + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "history.repolish.hint")) + .size(11.5) + .color(theme::INK_4), + ); + if let Some(error) = &vm.history_repolish_error { + ui.colored_label(theme::ERR, error); + } + ui.horizontal_wrapped(|ui| { + if ui + .add_enabled( + !vm.history_repolish_running, + egui::Button::new(tr_l10n(lang, "history.repolish.retry")), + ) + .clicked() + { + actions.push(FrontendAction::HistoryRepolish(index, None)); + } + for (pack_index, pack) in vm.style_packs.iter().enumerate() { + if pack.enabled + && pack.id != "builtin.raw" + && ui + .add_enabled( + !vm.history_repolish_running, + egui::Button::new(&pack.name), + ) + .clicked() + { + actions.push(FrontendAction::HistoryRepolish( + index, + Some(pack_index), + )); + } + } + if ui.button(tr_l10n(lang, "common.close")).clicked() { + actions.push(FrontendAction::HistoryRepolishClose); + } + }); + if vm.history_repolish_running { + ui.spinner(); + } + if let Some((id, text)) = &vm.history_repolish_result { + if id == &vm.history_entries[index].id { + ui.label( + egui::RichText::new(tr_l10n( + lang, + "history.repolish.retryResultTitle", + )) + .strong(), + ); + ui.label(if text.trim().is_empty() { + tr_l10n(lang, "history.repolish.empty") + } else { + text + }); + } + } + } + }); + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("history-detail-scroll-measure"), + ( + scroll_output.content_size.y, + scroll_output.inner_rect.height(), + scroll_output.state.offset.y, + ), + ) + }); + #[cfg(not(test))] + let _ = scroll_output; + }, + ); +} + +fn detail_body( + ui: &mut egui::Ui, + entry: &HistoryEntry, + index: usize, + playback: Option<&super::view_model::HistoryPlayback>, + lang: Lang, + actions: &mut Vec, +) { + let width = ui.available_width(); + + // Header: time · style pill · recording length, actions on the right. + let (top, _) = ui.allocate_exact_size(egui::vec2(width, 30.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(top); + let time = format::time_label(&entry.created_at); + let mut x = top.left(); + painter.text( + egui::pos2(x, top.center().y), + egui::Align2::LEFT_CENTER, + &time, + egui::FontId::monospace(13.0), + theme::INK_3, + ); + x += layout::text_width(ui, &time, 13.0) + 10.0; + let pill = layout::pill_size(ui, &entry.style_label); + let pill_rect = egui::Rect::from_min_size(egui::pos2(x, top.center().y - pill.y / 2.0), pill); + layout::paint_pill(&painter, pill_rect, &entry.style_label, PillTone::Gray); + x += pill.x + 10.0; + let recorded = fmt_l10n( + lang, + "history.recorded", + &[&format::history_duration(entry.duration_ms, lang)], + ); + painter.text( + egui::pos2(x, top.center().y), + egui::Align2::LEFT_CENTER, + &recorded, + egui::FontId::proportional(11.0), + theme::INK_4, + ); + + // Right-aligned: 「…」录音操作菜单。Tauri 把删除/重新转录/导出/播放收进一个 + // `HistoryActionMenu`(右对齐浮层、向下展开),不再平铺一排按钮。 + let more_size = 30.0; + let more_rect = egui::Rect::from_min_size( + egui::pos2(top.right() - more_size, top.center().y - more_size / 2.0), + egui::vec2(more_size, more_size), + ); + let menu_id = egui::Id::new(("openless-history-menu", entry.id.as_str())); + let mut menu_open = ui + .ctx() + .data(|data| data.get_temp::(menu_id).unwrap_or(false)); + if layout::action_button(ui, more_rect, "", Some(IconName::More), ButtonKind::Ghost).clicked() { + menu_open = !menu_open; + ui.ctx() + .data_mut(|data| data.insert_temp(menu_id, menu_open)); + } + + // Start/stop remains in the action menu. Once started, both History and Quick Note + // share this pause/resume button, draggable seek bar and live elapsed clock. + if entry.has_audio && playback.is_some_and(|clip| clip.id == entry.id) { + ui.add_space(10.0); + let (play_row, _) = ui.allocate_exact_size(egui::vec2(width, 32.0), egui::Sense::hover()); + if let Some(playback) = playback.filter(|clip| clip.id == entry.id) { + let button = egui::Rect::from_min_size( + egui::pos2(play_row.left(), play_row.center().y - 14.0), + egui::vec2(28.0, 28.0), + ); + let toggle = ui.interact( + button, + ui.id().with(("playback-toggle", &entry.id)), + egui::Sense::click(), + ); + if toggle.hovered() { + ui.painter() + .rect_filled(button, egui::CornerRadius::same(6), theme::BLUE_SOFT); + } + if playback.paused { + icons::draw_icon(ui, button.center(), IconName::Play, theme::BLUE); + } else { + // Two filled bars are more legible than a 14px outline icon. + for dx in [-4.0, 2.0] { + ui.painter().rect_filled( + egui::Rect::from_min_size( + button.center() + egui::vec2(dx, -6.0), + egui::vec2(3.0, 12.0), + ), + egui::CornerRadius::same(1), + theme::BLUE, + ); + } + } + if toggle + .on_hover_text(tr_l10n( + lang, + if playback.paused { + "history.resume" + } else { + "history.pause" + }, + )) + .clicked() + { + actions.push(FrontendAction::HistoryPauseToggle); + } + let clock = format!( + "{} / {}", + playback_clock(playback.position_ms), + playback_clock(playback.total_ms), + ); + let clock_width = layout::text_width(ui, &clock, 11.0) + 6.0; + let track = egui::Rect::from_min_max( + egui::pos2(play_row.left() + 34.0, play_row.center().y - 3.0), + egui::pos2( + play_row.right() - clock_width - 8.0, + play_row.center().y + 3.0, + ), + ); + ui.painter().text( + egui::pos2(play_row.right(), play_row.center().y), + egui::Align2::RIGHT_CENTER, + clock, + egui::FontId::monospace(11.0), + theme::INK_4, + ); + if track.width() > 20.0 { + let hit = track.expand2(egui::vec2(0.0, 10.0)); + let response = ui.interact( + hit, + ui.id().with(("playback-seek", &entry.id)), + egui::Sense::click_and_drag(), + ); + let seek = if response.dragged() || response.clicked() { + response + .interact_pointer_pos() + .map(|pos| seek_position_ms(pos.x, track, playback.total_ms)) + } else { + None + }; + if let Some(ms) = seek { + actions.push(FrontendAction::HistorySeek(ms)); + } + let displayed_ms = seek.unwrap_or(playback.position_ms); + let ratio = if playback.total_ms == 0 { + 0.0 + } else { + (displayed_ms as f32 / playback.total_ms as f32).clamp(0.0, 1.0) + }; + ui.painter() + .rect_filled(track, egui::CornerRadius::same(3), theme::SURFACE_2); + let filled = egui::Rect::from_min_max( + track.min, + egui::pos2(track.left() + track.width() * ratio, track.bottom()), + ); + ui.painter() + .rect_filled(filled, egui::CornerRadius::same(3), theme::BLUE); + ui.painter().circle_filled( + egui::pos2(track.left() + track.width() * ratio, track.center().y), + 5.0, + theme::BLUE, + ); + response.on_hover_cursor(egui::CursorIcon::PointingHand); + } + } + } + + ui.add_space(if playback.is_some_and(|clip| clip.id == entry.id) { + 12.0 + } else { + 4.0 + }); + separator(ui, width); + ui.add_space(12.0); + + // 流水线明细:默认只留「识别」一行(用来语音识别的模型)。润色/插入属于前台 + // 投递细节,不在这块展示;润色信息由下方润色卡片的「润色 · 风格」胶囊承担。 + let label_column = layout::text_width(ui, tr_l10n(lang, "history.step_asr"), 11.0) + 14.0; + let asr_detail = join_provider(&entry.asr_provider, &entry.asr_model); + if !asr_detail.is_empty() || entry.asr_ms.is_some() { + pipeline_row( + ui, + width, + label_column, + tr_l10n(lang, "history.step_asr"), + &asr_detail, + entry.asr_ms.map(|ms| format::step_duration(ms, lang)), + ); + } + + // 结果卡片:默认只显示**润色结果**,原文按需展开(Tauri:「默认只显示润色 + // 结果;原文仍可按需展开,避免用户每次都面对两栏重复内容」)。 + ui.add_space(16.0); + let styled_source = if entry.final_text.trim().is_empty() { + entry.raw_transcript.as_str() + } else { + entry.final_text.as_str() + }; + let styled_text = if styled_source.trim().is_empty() { + tr_l10n( + lang, + if entry.quick_note { + "quickNote.noTranscript" + } else { + "history.raw_empty" + }, + ) + } else { + styled_source + }; + // 复制按钮始终在:润色失败时回退到原文(Tauri 的 `onCopy` 也是这个回退)。 + let styled_copy = Some(("openless-history-copy-styled", styled_source.to_string())); + let raw_text = entry.raw_transcript.as_str(); + let raw_is_empty = raw_text.trim().is_empty(); + let raw_copy = (!raw_is_empty).then(|| ("openless-history-copy-raw", raw_text.to_string())); + let raw_override = raw_is_empty.then(|| tr_l10n(lang, "history.raw_empty").to_string()); + + // 「查看原文 / 隐藏原文」开关:状态挂在条目 id 上(Tauri 是组件 state, + // 切条目就重置;这里按条目记,行为等价且省一次跳变)。 + let raw_id = egui::Id::new(("openless-history-raw", entry.id.as_str())); + let mut raw_open = ui + .ctx() + .data(|data| data.get_temp::(raw_id).unwrap_or(false)); + + let text_width = (width - 12.0 - DETAIL_PADDING * 2.0).max(60.0); + let styled_galley = layout_text(ui, styled_text, theme::INK, 13.0, text_width, 60); + // 润色胶囊带上步骤名:Tauri 是「润色 · 风格名」。 + let styled_pill = format!( + "{} · {}", + tr_l10n(lang, "history.step_polish"), + entry.style_label + ); + let styled_card_height = 52.0 + styled_galley.size().y; + let (styled_card, _) = + ui.allocate_exact_size(egui::vec2(width, styled_card_height), egui::Sense::hover()); + let toggled = text_card_with_action( + ui, + styled_card, + &styled_pill, + PillTone::Blue, + styled_text, + None, + styled_copy, + lang, + (!raw_is_empty).then(|| { + if raw_open { + tr_l10n(lang, "history.hide_raw") + } else { + tr_l10n(lang, "history.show_raw") + } + }), + ); + if toggled { + raw_open = !raw_open; + ui.ctx().data_mut(|data| data.insert_temp(raw_id, raw_open)); + } + + if raw_open { + ui.add_space(12.0); + let raw_galley = layout_text( + ui, + raw_override.as_deref().unwrap_or(raw_text), + theme::INK_2, + 13.0, + text_width, + 60, + ); + let card_height = 52.0 + raw_galley.size().y; + let (raw_card, _) = + ui.allocate_exact_size(egui::vec2(width, card_height), egui::Sense::hover()); + text_card_with_action( + ui, + raw_card, + tr_l10n(lang, "history.step_asr"), + PillTone::Outline, + raw_text, + raw_override, + raw_copy, + lang, + None, + ); + } + + // 「…」菜单浮层:右对齐、向下展开(Tauri 的 `HistoryActionMenu`: + // `top: calc(100% + 6px); right: 0`)。 + if menu_open { + let menu_rect = history_action_menu(ui, more_rect, entry, index, lang, actions); + let clicked_outside = ui.input(|input| { + input.pointer.any_click() + && input + .pointer + .interact_pos() + .is_some_and(|pos| !menu_rect.contains(pos) && !more_rect.contains(pos)) + }); + if clicked_outside || ui.input(|input| input.key_pressed(egui::Key::Escape)) { + ui.ctx().data_mut(|data| data.insert_temp(menu_id, false)); + } + } +} + +/// 「…」菜单宽度 / 行高(Tauri:`width: min(260px, ...)`,行内边距 8×9)。 +const MENU_WIDTH: f32 = 208.0; +const MENU_ITEM_HEIGHT: f32 = 31.0; +const MENU_PADDING: f32 = 6.0; + +enum HistoryMenuItem { + Separator, + Action { + icon: IconName, + label: String, + danger: bool, + action: Box, + }, +} + +/// 详情头部右侧的「…」菜单:播放、导出、重新转录、重新润色、删除。 +fn history_action_menu( + ui: &mut egui::Ui, + button: egui::Rect, + entry: &HistoryEntry, + index: usize, + lang: Lang, + actions: &mut Vec, +) -> egui::Rect { + let mut items: Vec = Vec::new(); + if entry.has_audio { + items.push(HistoryMenuItem::Action { + icon: IconName::Play, + label: tr_l10n(lang, "history.play_recording").to_string(), + danger: false, + action: Box::new(FrontendAction::HistoryPlay(index)), + }); + items.push(HistoryMenuItem::Action { + icon: IconName::Download, + label: tr_l10n(lang, "history.export").to_string(), + danger: false, + action: Box::new(FrontendAction::HistoryExport(index)), + }); + items.push(HistoryMenuItem::Separator); + items.push(HistoryMenuItem::Action { + icon: IconName::Refresh, + label: tr_l10n(lang, "history.retranscribe").to_string(), + danger: false, + action: Box::new(FrontendAction::HistoryRetranscribe(index)), + }); + } + if !entry.raw_transcript.trim().is_empty() { + items.push(HistoryMenuItem::Action { + icon: IconName::Sparkle, + label: tr_l10n(lang, "history.repolish.title").to_string(), + danger: false, + action: Box::new(FrontendAction::HistoryRepolishOpen(index)), + }); + items.push(HistoryMenuItem::Separator); + } + items.push(HistoryMenuItem::Action { + icon: IconName::Trash, + label: tr_l10n(lang, "common.delete").to_string(), + danger: true, + action: Box::new(FrontendAction::HistoryRequestDelete(entry.id.clone())), + }); + + let height = MENU_PADDING * 2.0 + + items + .iter() + .map(|item| match item { + HistoryMenuItem::Separator => 13.0, + HistoryMenuItem::Action { .. } => MENU_ITEM_HEIGHT, + }) + .sum::(); + let rect = egui::Rect::from_min_size( + egui::pos2(button.right() - MENU_WIDTH, button.bottom() + 6.0), + egui::vec2(MENU_WIDTH, height), + ); + + egui::Area::new(egui::Id::new(( + "openless-history-menu-area", + entry.id.as_str(), + ))) + .order(egui::Order::Foreground) + .fixed_pos(rect.min) + .show(ui.ctx(), |ui| { + ui.set_min_size(rect.size()); + ui.set_max_size(rect.size()); + let painter = ui.painter().clone(); + painter.rect_filled(rect, egui::CornerRadius::same(10), theme::SURFACE); + painter.rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.8, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + let mut y = rect.top() + MENU_PADDING; + for item in items { + match item { + HistoryMenuItem::Separator => { + painter.line_segment( + [ + egui::pos2(rect.left() + 10.0, y + 6.0), + egui::pos2(rect.right() - 10.0, y + 6.0), + ], + egui::Stroke::new(1.0, theme::LINE_SOFT), + ); + y += 13.0; + } + HistoryMenuItem::Action { + icon, + label, + danger, + action, + } => { + let item_rect = egui::Rect::from_min_size( + egui::pos2(rect.left() + MENU_PADDING, y), + egui::vec2(MENU_WIDTH - MENU_PADDING * 2.0, MENU_ITEM_HEIGHT - 2.0), + ); + let response = ui.interact( + item_rect, + ui.id().with(label.as_str()), + egui::Sense::click(), + ); + if response.hovered() { + painter.rect_filled( + item_rect, + egui::CornerRadius::same(7), + theme::SURFACE_2, + ); + } + let color = if danger { theme::ERR } else { theme::INK_2 }; + icons::draw_icon( + ui, + egui::pos2(item_rect.left() + 15.0, item_rect.center().y), + icon, + color, + ); + painter.text( + egui::pos2(item_rect.left() + 30.0, item_rect.center().y), + egui::Align2::LEFT_CENTER, + &label, + egui::FontId::proportional(12.5), + color, + ); + if response.clicked() { + actions.push(*action); + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new(("openless-history-menu", entry.id.as_str())), + false, + ) + }); + } + y += MENU_ITEM_HEIGHT; + } + } + } + }); + rect +} + +fn join_provider(provider: &Option, model: &Option) -> String { + [provider.as_deref(), model.as_deref()] + .into_iter() + .flatten() + .filter(|part| !part.trim().is_empty()) + .collect::>() + .join(" · ") +} + +/// One `label | detail | status` row of the pipeline breakdown. +fn pipeline_row( + ui: &mut egui::Ui, + width: f32, + label_column: f32, + label: &str, + detail: &str, + status: Option, +) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 20.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left(), rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(11.0), + theme::INK_4, + ); + painter.text( + egui::pos2(rect.left() + label_column, rect.center().y), + egui::Align2::LEFT_CENTER, + detail, + egui::FontId::monospace(11.0), + theme::INK_2, + ); + if let Some(status) = status { + painter.text( + egui::pos2(rect.right(), rect.center().y), + egui::Align2::RIGHT_CENTER, + status, + egui::FontId::monospace(11.0), + theme::INK_4, + ); + } + ui.add_space(4.0); +} + +/// A `原文` / polished text card with an optional pill, copy button and an +/// optional secondary toggle (used for 查看原文 / 隐藏原文). Returns whether the +/// toggle was clicked. +#[allow(clippy::too_many_arguments)] +fn text_card_with_action( + ui: &mut egui::Ui, + rect: egui::Rect, + pill_text: &str, + pill_tone: PillTone, + body: &str, + empty_override: Option, + copy: Option<(&'static str, String)>, + lang: Lang, + toggle: Option<&str>, +) -> bool { + if rect == egui::Rect::NOTHING { + return false; + } + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(10), theme::SURFACE_2); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + let painter = ui.painter().with_clip_rect(rect); + let pill = layout::pill_size(ui, pill_text); + let pill_rect = egui::Rect::from_min_size( + egui::pos2(rect.left() + DETAIL_PADDING, rect.top() + DETAIL_PADDING), + pill, + ); + layout::paint_pill(&painter, pill_rect, pill_text, pill_tone); + + let mut toggled = false; + let mut right = rect.right() - DETAIL_PADDING; + if let Some((salt, text)) = copy { + let id = egui::Id::new(salt); + let now = ui.input(|input| input.time); + let copied = ui + .ctx() + .data(|data| data.get_temp::(id)) + .is_some_and(|at| now - at < 1.5); + let label = if copied { + tr_l10n(lang, "common.copied") + } else { + tr_l10n(lang, "common.copy") + }; + let button_width = layout::text_width(ui, label, 12.5) + 34.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(right - button_width, rect.top() + DETAIL_PADDING - 3.0), + egui::vec2(button_width, 26.0), + ); + right = button_rect.left() - 6.0; + if layout::action_button( + ui, + button_rect, + label, + Some(IconName::Copy), + ButtonKind::Ghost, + ) + .clicked() + { + ui.ctx().copy_text(text); + ui.ctx().data_mut(|data| data.insert_temp(id, now)); + } + } + if let Some(label) = toggle { + let button_width = layout::text_width(ui, label, 12.5) + 20.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(right - button_width, rect.top() + DETAIL_PADDING - 3.0), + egui::vec2(button_width, 26.0), + ); + toggled = layout::action_button(ui, button_rect, label, None, ButtonKind::Ghost).clicked(); + } + + let text = empty_override.as_deref().unwrap_or(body); + let color = if empty_override.is_some() { + theme::INK_4 + } else { + theme::INK_2 + }; + let galley = layout_text( + ui, + text, + color, + 13.0, + (rect.width() - DETAIL_PADDING * 2.0).max(1.0), + 60, + ); + painter.galley( + egui::pos2(rect.left() + DETAIL_PADDING, pill_rect.bottom() + 10.0), + galley, + color, + ); + toggled +} + +// ── Confirmation dialog ───────────────────────────────────────────────────── + +fn confirm_overlay( + ctx: &egui::Context, + body: egui::Rect, + vm: &FrontendViewModel, + lang: Lang, + actions: &mut Vec, +) { + egui::Area::new(egui::Id::new("openless-history-confirm")) + .order(egui::Order::Foreground) + // `click` 而不是 `hover`:遮罩必须**吃掉**点击,否则点遮罩会漏到下方页面(列表行 + // 会在确认框打开时被选中)。Tauri 这里用的是原生 `window.confirm()`——点外面 + // 不会关闭,所以只拦截、不关闭。 + .sense(egui::Sense::click()) + .fixed_pos(body.min) + .show(ctx, |ui| { + ui.set_min_size(body.size()); + ui.set_clip_rect(body); + layout::paint_blurred_overlay(ctx, ui, body, egui::CornerRadius::ZERO); + + let dialog = egui::Rect::from_center_size( + body.center(), + egui::vec2(body.width().min(400.0), 152.0), + ); + // 对话框矩形写进 memory 供测试查询(Area 覆盖整个 body,面积已不等于卡片)。 + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("openless-history-confirm-card-rect"), dialog) + }); + paint_card(ui.painter(), dialog); + let painter = ui.painter().with_clip_rect(dialog); + let message = match &vm.history_confirm { + Some(HistoryConfirm::Clear) => { + fmt_l10n(lang, "history.confirm_clear", &[&vm.history_entries.len()]) + } + Some(HistoryConfirm::Delete(_)) => { + tr_l10n(lang, "history.confirm_delete").to_string() + } + None => return, + }; + let galley = layout_text( + ui, + &message, + theme::INK_2, + 13.0, + (dialog.width() - 40.0).max(1.0), + 4, + ); + painter.galley( + egui::pos2(dialog.left() + 20.0, dialog.top() + 22.0), + galley, + theme::INK_2, + ); + + let confirm = tr_l10n(lang, "common.confirm"); + let cancel = tr_l10n(lang, "common.cancel"); + let confirm_width = layout::text_width(ui, confirm, 12.5) + 34.0; + let cancel_width = layout::text_width(ui, cancel, 12.5) + 34.0; + let button_y = dialog.bottom() - 20.0 - 30.0; + let confirm_rect = egui::Rect::from_min_size( + egui::pos2(dialog.right() - 20.0 - confirm_width, button_y), + egui::vec2(confirm_width, 30.0), + ); + let cancel_rect = egui::Rect::from_min_size( + egui::pos2(confirm_rect.left() - 8.0 - cancel_width, button_y), + egui::vec2(cancel_width, 30.0), + ); + if layout::action_button(ui, cancel_rect, cancel, None, ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::HistoryCancelConfirm); + } + if layout::action_button(ui, confirm_rect, confirm, None, ButtonKind::Blue).clicked() { + actions.push(FrontendAction::HistoryConfirmAction); + } + }); +} + +// ── Painting helpers ──────────────────────────────────────────────────────── + +/// Convert a pointer position (including drags beyond either end) to a clip timestamp. +fn seek_position_ms(pointer_x: f32, track: egui::Rect, total_ms: u64) -> u64 { + if track.width() <= 0.0 { + return 0; + } + let fraction = ((pointer_x - track.left()) / track.width()).clamp(0.0, 1.0); + (fraction as f64 * total_ms as f64).round() as u64 +} + +/// `m:ss` clock used by the in-app player bar. +fn playback_clock(ms: u64) -> String { + let seconds = ms / 1000; + format!("{}:{:02}", seconds / 60, seconds % 60) +} + +fn paint_card(painter: &egui::Painter, rect: egui::Rect) { + painter.rect_filled(rect, egui::CornerRadius::same(14), theme::SURFACE); + painter.rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); +} + +fn separator(ui: &mut egui::Ui, width: f32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 1.0), egui::Sense::hover()); + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, LINE_SOFT), + ); +} + +fn layout_text( + ui: &egui::Ui, + text: &str, + color: egui::Color32, + size: f32, + max_width: f32, + max_rows: usize, +) -> Arc { + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = max_width.max(1.0); + job.wrap.max_rows = max_rows; + job.append( + text, + 0.0, + egui::text::TextFormat { + font_id: egui::FontId::proportional(size), + color, + ..Default::default() + }, + ); + ui.fonts_mut(|fonts| fonts.layout_job(job)) +} + +#[cfg(test)] +mod playback_tests { + use super::*; + + #[test] + fn seek_uses_track_coordinates_and_clamps_drag_outside_it() { + let track = egui::Rect::from_min_max(egui::pos2(100.0, 10.0), egui::pos2(300.0, 16.0)); + assert_eq!(seek_position_ms(100.0, track, 90_000), 0); + assert_eq!(seek_position_ms(200.0, track, 90_000), 45_000); + assert_eq!(seek_position_ms(400.0, track, 90_000), 90_000); + assert_eq!(seek_position_ms(-50.0, track, 90_000), 0); + assert_eq!(playback_clock(45_900), "0:45"); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/icons.rs b/openless-all/app/linux-egui/src/ui/frontend/icons.rs index 5e79caf35..373917e7f 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/icons.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/icons.rs @@ -1,13 +1,15 @@ use eframe::egui; +use resvg::{tiny_skia, usvg}; -#[derive(Clone, Copy)] +use super::theme; + +#[derive(Clone, Copy, Debug)] pub enum IconName { Overview, History, Vocab, Style, SelectionAsk, - Translation, Settings, Mic, Sparkle, @@ -19,446 +21,220 @@ pub enum IconName { Trash, Refresh, Download, + Upload, + Plus, Play, - ChevronDown, + Stop, + Close, + Check, + Send, + Pin, + Chat, + // GitHub avatar fallback isn't a Lucide icon in the Tauri UI. + Github, + More, + ChevronRight, + Feather, + Layout, + Doc, + Pencil, + Cloud, + Shield, + Info, + Help, + External, + Monitor, +} + +// These masks are generated from the pinned lucide-react package by +// scripts/sync-egui-lucide-icons.mjs. Unlike the old hand-drawn approximations, +// History, Vocab, Style, SelectionAsk and Settings retain every SVG path. +fn lucide_svg(icon: IconName) -> Option<(&'static str, &'static str)> { + Some(match icon { + IconName::Overview => ( + "Overview", + include_str!("../../../assets/lucide/Overview.svg"), + ), + IconName::History => ( + "History", + include_str!("../../../assets/lucide/History.svg"), + ), + IconName::Vocab => ("Vocab", include_str!("../../../assets/lucide/Vocab.svg")), + IconName::Style => ("Style", include_str!("../../../assets/lucide/Style.svg")), + IconName::SelectionAsk => ( + "SelectionAsk", + include_str!("../../../assets/lucide/SelectionAsk.svg"), + ), + IconName::Settings => ( + "Settings", + include_str!("../../../assets/lucide/Settings.svg"), + ), + IconName::Mic => ("Mic", include_str!("../../../assets/lucide/Mic.svg")), + IconName::Sparkle => ( + "Sparkle", + include_str!("../../../assets/lucide/Sparkle.svg"), + ), + IconName::Hash => ("Hash", include_str!("../../../assets/lucide/Hash.svg")), + IconName::Clock => ("Clock", include_str!("../../../assets/lucide/Clock.svg")), + IconName::Bolt => ("Bolt", include_str!("../../../assets/lucide/Bolt.svg")), + IconName::Copy => ("Copy", include_str!("../../../assets/lucide/Copy.svg")), + IconName::Search => ("Search", include_str!("../../../assets/lucide/Search.svg")), + IconName::Trash => ("Trash", include_str!("../../../assets/lucide/Trash.svg")), + IconName::Refresh => ( + "Refresh", + include_str!("../../../assets/lucide/Refresh.svg"), + ), + IconName::Download => ( + "Download", + include_str!("../../../assets/lucide/Download.svg"), + ), + IconName::Upload => ("Upload", include_str!("../../../assets/lucide/Upload.svg")), + IconName::Plus => ("Plus", include_str!("../../../assets/lucide/Plus.svg")), + IconName::Play => ("Play", include_str!("../../../assets/lucide/Play.svg")), + IconName::Stop => ("Stop", include_str!("../../../assets/lucide/Stop.svg")), + IconName::Close => ("Close", include_str!("../../../assets/lucide/Close.svg")), + IconName::Check => ("Check", include_str!("../../../assets/lucide/Check.svg")), + IconName::Send => ("Send", include_str!("../../../assets/lucide/Send.svg")), + IconName::Pin => ("Pin", include_str!("../../../assets/lucide/Pin.svg")), + IconName::Chat => ("Chat", include_str!("../../../assets/lucide/Chat.svg")), + IconName::More => ("More", include_str!("../../../assets/lucide/More.svg")), + IconName::ChevronRight => ( + "ChevronRight", + include_str!("../../../assets/lucide/ChevronRight.svg"), + ), + IconName::Feather => ( + "Feather", + include_str!("../../../assets/lucide/Feather.svg"), + ), + IconName::Layout => ("Layout", include_str!("../../../assets/lucide/Layout.svg")), + IconName::Doc => ("Doc", include_str!("../../../assets/lucide/Doc.svg")), + IconName::Pencil => ("Pencil", include_str!("../../../assets/lucide/Pencil.svg")), + IconName::Cloud => ("Cloud", include_str!("../../../assets/lucide/Cloud.svg")), + IconName::Shield => ("Shield", include_str!("../../../assets/lucide/Shield.svg")), + IconName::Info => ("Info", include_str!("../../../assets/lucide/Info.svg")), + IconName::Help => ("Help", include_str!("../../../assets/lucide/Help.svg")), + IconName::External => ( + "External", + include_str!("../../../assets/lucide/External.svg"), + ), + IconName::Monitor => ( + "Monitor", + include_str!("../../../assets/lucide/Monitor.svg"), + ), + IconName::Github => return None, + }) +} + +const ICON_PIXELS: usize = 48; // 3x the 16-point sidebar/control icon. +const ICON_SIZE: f32 = 16.0; + +fn rasterize_mask(source: &str) -> Option { + let tree = usvg::Tree::from_str(source, &usvg::Options::default()).ok()?; + let mut pixmap = tiny_skia::Pixmap::new(ICON_PIXELS as u32, ICON_PIXELS as u32)?; + resvg::render( + &tree, + tiny_skia::Transform::from_scale(ICON_PIXELS as f32 / 24.0, ICON_PIXELS as f32 / 24.0), + &mut pixmap.as_mut(), + ); + // tiny-skia returns premultiplied white; ColorImage expects straight RGBA. + // SVGs contain only white strokes, so opacity is the entire mask. + let mut pixels = Vec::with_capacity(ICON_PIXELS * ICON_PIXELS * 4); + for pixel in pixmap.data().as_chunks::<4>().0 { + pixels.extend_from_slice(&[255, 255, 255, pixel[3]]); + } + Some(egui::ColorImage::from_rgba_unmultiplied( + [ICON_PIXELS, ICON_PIXELS], + &pixels, + )) } -/// Draw an icon centred at `center` with the given `color`. +/// Draw the same SVG outlines used by Tauri, recolored for this surface. +/// Texture handles live in egui's context and are reused across frames/windows. 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, + let Some((name, svg)) = lucide_svg(icon) else { + // Not part of Icon.tsx: the account avatar fallback is deliberately + // separate from the monochrome Lucide interface controls. + let p = ui.painter(); + p.circle_filled(center, 7.0, color.gamma_multiply(0.75)); + p.circle_filled(center + egui::vec2(0.0, 3.0), 3.4, theme::SURFACE_2); + return; + }; + let ctx = ui.ctx(); + let id = egui::Id::new(("lucide-icon", name)); + let texture = ctx + .data(|data| data.get_temp::(id)) + .or_else(|| { + let image = rasterize_mask(svg)?; + let texture = ctx.load_texture( + format!("lucide-{name}"), + image, + egui::TextureOptions::LINEAR, + ); + ctx.data_mut(|data| data.insert_temp(id, texture.clone())); + Some(texture) + }); + if let Some(texture) = texture { + ui.painter().image( + texture.id(), + egui::Rect::from_center_size(center, egui::vec2(ICON_SIZE, ICON_SIZE)), + egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), + color, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_lucide_icon_rasterizes_to_a_nonempty_mask() { + let icons = [ + IconName::Overview, + IconName::History, + IconName::Vocab, + IconName::Style, + IconName::SelectionAsk, + IconName::Settings, + IconName::Mic, + IconName::Sparkle, + IconName::Hash, + IconName::Clock, + IconName::Bolt, + IconName::Copy, + IconName::Search, + IconName::Trash, + IconName::Refresh, + IconName::Download, + IconName::Upload, + IconName::Plus, + IconName::Play, + IconName::Stop, + IconName::Close, + IconName::Check, + IconName::Send, + IconName::Pin, + IconName::Chat, + IconName::More, + IconName::ChevronRight, + IconName::Feather, + IconName::Layout, + IconName::Doc, + IconName::Pencil, + IconName::Cloud, + IconName::Shield, + IconName::Info, + IconName::Help, + IconName::External, + IconName::Monitor, + ]; + for icon in icons { + let (name, svg) = lucide_svg(icon).unwrap(); + let image = rasterize_mask(svg).unwrap_or_else(|| panic!("invalid SVG: {name}")); + assert!( + image.pixels.iter().any(|pixel| pixel.a() != 0), + "empty SVG: {name}" ); } - 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 index 7ac728e06..4d0b84fb8 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/layout.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/layout.rs @@ -1,18 +1,108 @@ use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n}; + use super::icons::{self, IconName}; use super::theme; use super::view_model::{FrontendAction, FrontendViewModel, Page}; -pub const SIDEBAR_WIDTH: f32 = 226.0; +pub const SIDEBAR_WIDTH: f32 = 188.0; +/// 自绘标题栏高度。对齐 Tauri 的 Linux 自绘标题栏(`LINUX_TITLEBAR_HEIGHT = 36`)。 pub const TITLEBAR_HEIGHT: f32 = 36.0; -const WINDOW_MARGIN: f32 = 0.0; +/// 页面顶部留白。Tauri 的页面容器是 `padding: 56px 28px ...`(非 mac), +/// 所以「今日概览」这类标题离客户区顶边约 56px。 +pub const PAGE_TOP_PADDING: f32 = 56.0; +/// 页面底部留白(Tauri:24px)。 +pub const PAGE_BOTTOM_PADDING: f32 = 24.0; +const WINDOW_MARGIN: f32 = 6.0; const WINDOW_RADIUS: u8 = 14; // ── Window geometry helpers ───────────────────────────────────────────────── +/// 窗口是不是铺满了屏幕(最大化/全屏/平铺)。无边框窗口平时四周留一条透明边、四角带 +/// 圆角来假装「浮」在桌面上;铺满屏幕时这条边就变成屏幕边上的一圈空白,必须收起来。 +pub fn window_fills_the_screen(ctx: &egui::Context) -> bool { + let surface = ctx.content_rect(); + ctx.input(|input| { + let viewport = input.viewport(); + if viewport.maximized.unwrap_or(false) || viewport.fullscreen.unwrap_or(false) { + return true; + } + // 平铺(拖到屏幕边缘吸附)时合成器不一定报 maximized,但窗口尺寸就等于屏幕 + // 尺寸:按尺寸兜底。留 1px 容差给缩放。 + viewport.monitor_size.is_some_and(|monitor| { + let size = viewport.inner_rect.unwrap_or(surface).size(); + size.x >= monitor.x - 1.0 && size.y >= monitor.y - 1.0 + }) + }) +} + +/// 窗口四周的透明边(只在不铺满屏幕时留)。 +pub fn window_margin(ctx: &egui::Context) -> f32 { + if window_fills_the_screen(ctx) { + 0.0 + } else { + WINDOW_MARGIN + } +} + +/// 整张窗口底板的圆角。 +pub fn window_corner_radius(ctx: &egui::Context) -> egui::CornerRadius { + if window_fills_the_screen(ctx) { + egui::CornerRadius::ZERO + } else { + egui::CornerRadius::same(WINDOW_RADIUS) + } +} + +/// 内容区(标题栏以下)的圆角:上面两角是直角(顶着标题栏),下面两角跟着窗口圆角。 +/// 遮罩要按这个形状画,否则模糊背板会以直角盖住窗口圆角。 +pub fn body_corner_radius(ctx: &egui::Context) -> egui::CornerRadius { + if window_fills_the_screen(ctx) { + egui::CornerRadius::ZERO + } else { + egui::CornerRadius { + nw: 0, + ne: 0, + sw: WINDOW_RADIUS, + se: WINDOW_RADIUS, + } + } +} + +/// Paint the live blurred page beneath an in-window modal, followed by the +/// shared macOS-style tint. Keeping this in one helper prevents secondary +/// overlays (marketplace, vocabulary, history) from silently falling back to +/// a flat dark rectangle while settings/style use the GPU backdrop. +pub fn paint_blurred_overlay( + ctx: &egui::Context, + ui: &egui::Ui, + rect: egui::Rect, + corners: egui::CornerRadius, +) { + if let Some(texture) = crate::ui::backdrop::published(ctx) { + ui.painter().add(egui::Shape::Rect( + egui::epaint::RectShape::filled(rect, corners, egui::Color32::WHITE) + .with_texture(texture, crate::ui::backdrop::uv_for(ctx, rect)), + )); + } + ui.painter().rect_filled(rect, corners, theme::OVERLAY); +} + +/// Corner shape for overlays scoped to the content pane rather than the full +/// window. The left edge is an internal sidebar seam, so it must remain square. +pub fn content_overlay_corner_radius(ctx: &egui::Context) -> egui::CornerRadius { + let outer = body_corner_radius(ctx); + egui::CornerRadius { + nw: 0, + ne: 0, + sw: 0, + se: outer.se, + } +} + pub fn window_rect(ctx: &egui::Context) -> egui::Rect { - ctx.content_rect().shrink(WINDOW_MARGIN) + ctx.content_rect().shrink(window_margin(ctx)) } pub fn body_rect(ctx: &egui::Context) -> egui::Rect { @@ -39,6 +129,42 @@ pub fn load_app_icon(ctx: &egui::Context) -> egui::TextureHandle { texture } +/// Decoded style-pack icon, cached in egui temp storage and keyed by the data +/// URL so a repaint never re-decodes the PNG (Core re-encodes only on change). +pub fn style_pack_icon_texture( + ctx: &egui::Context, + pack_id: &str, + data_url: &str, +) -> Option { + use base64::Engine as _; + let key = egui::Id::new(("openless-style-icon", pack_id)); + if let Some((cached_url, texture)) = + ctx.data(|data| data.get_temp::<(String, egui::TextureHandle)>(key)) + { + if cached_url == data_url { + return Some(texture); + } + } + let encoded = data_url.split_once(";base64,").map(|(_, value)| value)?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + let image = image::load_from_memory(&bytes).ok()?.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( + format!("openless-style-icon-{pack_id}"), + color, + egui::TextureOptions::LINEAR, + ); + ctx.data_mut(|data| { + data.insert_temp(key, (data_url.to_string(), texture.clone())); + }); + Some(texture) +} + pub fn paint_app_icon(ui: &egui::Ui, rect: egui::Rect, texture: &egui::TextureHandle) { ui.painter().image( texture.id(), @@ -57,25 +183,36 @@ pub fn paint_window_background(ctx: &egui::Context) { egui::Order::Background, egui::Id::new("openless-window-background"), )); - painter.rect_filled( - window, - egui::CornerRadius::same(WINDOW_RADIUS), - theme::surface(), + // 整张窗口底板:白。内容区就是这块白(Tauri 的 `ol-console-main` 是 + // `--ol-surface`),不要再用灰画 body——否则侧栏/内容的白灰层次正好搞反。 + painter.rect_filled(window, window_corner_radius(ctx), theme::SURFACE); + let titlebar = egui::Rect::from_min_max( + window.min, + egui::pos2(window.max.x, window.min.y + TITLEBAR_HEIGHT), ); + let radius = window_corner_radius(ctx); painter.rect_filled( - body, + titlebar, egui::CornerRadius { - nw: 0, - ne: 0, - sw: WINDOW_RADIUS, - se: WINDOW_RADIUS, + nw: radius.nw, + ne: radius.ne, + sw: 0, + se: 0, }, - theme::canvas(), + theme::TITLEBAR, + ); + painter.rect_filled(body, body_corner_radius(ctx), theme::SURFACE); + painter.line_segment( + [ + egui::pos2(titlebar.left(), titlebar.bottom()), + egui::pos2(titlebar.right(), titlebar.bottom()), + ], + egui::Stroke::new(1.0, theme::LINE), ); painter.rect_stroke( window, - egui::CornerRadius::same(WINDOW_RADIUS), - egui::Stroke::new(1.0, theme::line()), + window_corner_radius(ctx), + egui::Stroke::new(1.0, theme::LINE), egui::StrokeKind::Inside, ); } @@ -98,20 +235,60 @@ pub fn titlebar(ctx: &egui::Context, actions: &mut Vec) { .fixed_pos(window.min) .show(ctx, |ui| { ui.set_min_size(egui::vec2(window.width(), TITLEBAR_HEIGHT)); + + let button_width = 40.0; + let controls_left = titlebar.right() - button_width * 3.0; + // The drag zone stops before the window controls so a press on the + // buttons can never be claimed by the titlebar drag target. + let drag_rect = egui::Rect::from_min_max( + titlebar.min, + egui::pos2(controls_left, titlebar.bottom()), + ); let drag = ui.interact( - titlebar, + drag_rect, ui.id().with("titlebar-drag"), egui::Sense::click_and_drag(), ); - if drag.drag_started() { + // Ask the compositor to move the window on the *press* frame: on + // Wayland `xdg_toplevel.move` needs the pointer serial from that + // event, so deferring to `drag_started` silently does nothing. + let pressed_now = + drag.is_pointer_button_down_on() && ui.input(|input| input.pointer.any_pressed()); + // `ViewportCommand::StartDrag` 在 egui-winit 里带着 `window.has_focus()` + // 前置条件(egui-winit-0.33.3/src/lib.rs:1403-1409):窗口尚未拿到键盘 + // 焦点的那一帧请求会被**整帧丢掉**,而 Wayland 的 `move` 只认按下那一帧 + // 的 serial——一次丢失就等于整个「按住标题栏拖」的手势失效(用户报的 + // 「拖标题栏移不动窗口」)。窗口未聚焦时按帧补发:焦点一翻转,同一帧的 + // 请求就会被放行。`viewport().focused` 就是 winit `has_focus()` 的同源值 + // (egui-winit `update_viewport_info`),所以判断不会偏。 + let focused = ui.input(|input| input.viewport().focused.unwrap_or(false)); + let maximized = ui.input(|input| input.viewport().maximized.unwrap_or(false)); + let holding = drag.is_pointer_button_down_on(); + let wants_drag = drag.drag_started() || pressed_now; + if wants_drag && !focused { + // 先把键盘焦点要回来:`StartDrag` 在 egui-winit 里要求 `has_focus()`, + // 未聚焦时请求会被整帧丢掉(Wayland 的 `move` 只认按下那一帧的 + // serial,丢一次整个手势就废了)。 + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + } + if maximized && wants_drag { + // Wayland 上最大化窗口不接受 move 请求(合成器直接忽略),先还原再拖。 + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(false)); + } else if wants_drag || (holding && !focused) { ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag); + // 这次手势的释放大概率回不来(见 `note_window_gesture_handoff`)。 + note_window_gesture_handoff(ctx); } + if drag.double_clicked() { + actions.push(FrontendAction::WindowMaximize); + } + let texture = load_app_icon(ctx); paint_app_icon( ui, egui::Rect::from_center_size( window.min + egui::vec2(16.0, TITLEBAR_HEIGHT / 2.0), - egui::vec2(18.0, 18.0), + egui::vec2(26.0, 26.0), ), &texture, ); @@ -120,10 +297,9 @@ pub fn titlebar(ctx: &egui::Context, actions: &mut Vec) { egui::Align2::LEFT_CENTER, "OpenLess", egui::FontId::proportional(13.0), - theme::ink_2(), + 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(), @@ -147,14 +323,34 @@ pub fn titlebar(ctx: &egui::Context, actions: &mut Vec) { 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()); + .rect_filled(rect, egui::CornerRadius::same(6), theme::SURFACE_2); } } - let stroke = egui::Stroke::new(1.0, theme::ink_3()); + if close_response.hovered() { + let center = close.center(); + let arm = 5.0; + let stroke = egui::Stroke::new(1.0, theme::ERR); + ui.painter().line_segment( + [ + center + egui::vec2(-arm, -arm), + center + egui::vec2(arm, arm), + ], + stroke, + ); + ui.painter().line_segment( + [ + center + egui::vec2(arm, -arm), + center + egui::vec2(-arm, arm), + ], + stroke, + ); + } + + let stroke = egui::Stroke::new(1.0, theme::INK_3); + // Minimize: a single horizontal line. ui.painter().line_segment( [ minimize.center() - egui::vec2(5.0, 0.0), @@ -162,25 +358,58 @@ pub fn titlebar(ctx: &egui::Context, actions: &mut Vec) { ], stroke, ); - ui.painter().rect_stroke( - maximize.shrink(14.0), - egui::CornerRadius::ZERO, - stroke, - egui::StrokeKind::Inside, + // Maximize shows a square, restored windows show the two-square glyph. + let maximized = ctx.input(|input| input.viewport().maximized.unwrap_or(false)); + let center = maximize.center(); + if maximized { + let half = 4.0; + ui.painter().rect_stroke( + egui::Rect::from_min_max( + center + egui::vec2(-half - 2.0, -half), + center + egui::vec2(half - 2.0, half), + ), + egui::CornerRadius::ZERO, + stroke, + egui::StrokeKind::Inside, + ); + ui.painter().rect_stroke( + egui::Rect::from_min_max( + center + egui::vec2(-half + 2.0, -half + 2.0), + center + egui::vec2(half + 2.0, half + 2.0), + ), + egui::CornerRadius::ZERO, + stroke, + egui::StrokeKind::Inside, + ); + } else { + ui.painter().rect_stroke( + egui::Rect::from_center_size(center, egui::vec2(10.0, 10.0)), + egui::CornerRadius::ZERO, + stroke, + egui::StrokeKind::Inside, + ); + } + let close_stroke = egui::Stroke::new( + 1.0, + if close_response.hovered() { + theme::ERR + } else { + theme::INK_3 + }, ); ui.painter().line_segment( [ close.center() - egui::vec2(5.0, 5.0), close.center() + egui::vec2(5.0, 5.0), ], - stroke, + close_stroke, ); ui.painter().line_segment( [ close.center() + egui::vec2(5.0, -5.0), close.center() + egui::vec2(-5.0, 5.0), ], - stroke, + close_stroke, ); }); } @@ -189,7 +418,8 @@ pub fn titlebar(ctx: &egui::Context, actions: &mut Vec) { pub fn resize_handles(ctx: &egui::Context) { let window = window_rect(ctx); - let edge = 10.0; + // Keep the draggable titlebar band generous: only a thin strip resizes. + let edge = 6.0; let corner = 18.0; let left = window.left(); let right = window.right(); @@ -259,18 +489,45 @@ pub fn resize_handles(ctx: &egui::Context) { // 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) + // 条带要在设置遮罩(Foreground)之上:遮罩盖住了 body 范围内的外圈像素, + // 否则弹窗打开时拖边缘会被遮罩吃掉、窗口改不了大小(用户报「设置页面下 + // 无法修改窗口大小」)。Tooltip 只是层级,条带本身仍只占外圈 6px/18px。 + .order(egui::Order::Tooltip) .fixed_pos(rect.min) .default_size(rect.size()) .sense(egui::Sense::drag()) .show(ctx, |ui| ui.set_min_size(rect.size())) .response; + // 悬停到拖拽区要换成对应方向的拉伸光标:没有它用户看不出「这里能拉伸」 + // (无边框窗口唯一的提示就是光标形状)。这里直接按指针位置判定:Area 的 + // `hovered()` 依赖上一帧的命中信息,首帧不生效,而光标必须立刻对。 + let hovering = ctx + .input(|input| input.pointer.latest_pos()) + .is_some_and(|position| rect.contains(position)); + if hovering { + ctx.set_cursor_icon(resize_cursor(direction)); + } + let _ = &response; if response.drag_started() { ctx.send_viewport_cmd(egui::ViewportCommand::BeginResize(direction)); + // 拉伸和拖标题栏一样:按下那一帧就把这次手势交给合成器了,见 + // [`note_window_gesture_handoff`]。忘了登记的话,缩完窗口整页都滚不动。 + note_window_gesture_handoff(ctx); } } } +/// 拖拽方向 → 拉伸光标。 +fn resize_cursor(direction: egui::ResizeDirection) -> egui::CursorIcon { + use egui::ResizeDirection as D; + match direction { + D::North | D::South => egui::CursorIcon::ResizeVertical, + D::East | D::West => egui::CursorIcon::ResizeHorizontal, + D::NorthWest | D::SouthEast => egui::CursorIcon::ResizeNwSe, + D::NorthEast | D::SouthWest => egui::CursorIcon::ResizeNeSw, + } +} + // ── Sidebar ───────────────────────────────────────────────────────────────── pub fn sidebar(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Vec) { @@ -283,104 +540,247 @@ pub fn sidebar(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Ve .fixed_pos(body.min) .show(ctx, |ui| { ui.set_min_size(egui::vec2(SIDEBAR_WIDTH, body.height())); + // Constrain the max size too: without it `available_height()` is the + // whole screen and the pinned settings row lands off-window. + ui.set_max_size(egui::vec2(SIDEBAR_WIDTH, body.height())); ui.set_clip_rect(egui::Rect::from_min_size( body.min, egui::vec2(SIDEBAR_WIDTH, body.height()), )); - ui.painter() - .rect_filled(ui.max_rect(), 0.0, theme::sidebar_bg()); + // Paint the exact sidebar rect: `ui.max_rect()` can be the whole + // screen, which pushed the rounded bottom-left corner off-window. + let sidebar_rect = + egui::Rect::from_min_size(body.min, egui::vec2(SIDEBAR_WIDTH, body.height())); + ui.painter().rect_filled( + sidebar_rect, + egui::CornerRadius { + nw: 0, + ne: 0, + sw: WINDOW_RADIUS, + se: 0, + }, + theme::SIDEBAR, + ); ui.painter().line_segment( [ - egui::pos2(SIDEBAR_WIDTH, 0.0), - egui::pos2(SIDEBAR_WIDTH, body.height()), + egui::pos2(body.left() + SIDEBAR_WIDTH, body.top()), + egui::pos2(body.left() + SIDEBAR_WIDTH, body.bottom()), ], - egui::Stroke::new(1.0, theme::line()), + 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); + // 版本信息行:Tauri 把原「OpenLess」品牌位换成版本信息(BETA 徽章 + // 与版本号)。侧栏只有 188px,两者同行的结果是把版本号拦腰折断 + // (「版本 v2.0.0-」/「Beta.2+…」),所以徽章占一行、版本号另起一行。 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(10.0); + beta_badge(ui, vm.lang); }); - 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.horizontal(|ui| { 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()), - ); - }); - }); + ui.label( + egui::RichText::new(fmt_l10n(vm.lang, "shell.version", &[&vm.version])) + .size(12.0) + .color(theme::INK_4), + ); }); + ui.add_space(12.0); + // The nav scrolls when the window is short so the pinned + // settings row stays reachable. + const PINNED_SETTINGS_HEIGHT: f32 = 46.0; + let nav_height = (ui.available_height() - PINNED_SETTINGS_HEIGHT).max(80.0); + ui.allocate_ui_with_layout( + egui::vec2(SIDEBAR_WIDTH - 20.0, nav_height), + egui::Layout::top_down(egui::Align::Min), + |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-sidebar-nav") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_width(SIDEBAR_WIDTH - 20.0); + nav( + ui, + vm, + "nav.overview", + NavTarget::Page(Page::Overview), + IconName::Overview, + actions, + ); + nav( + ui, + vm, + "nav.history", + NavTarget::Page(Page::History), + IconName::History, + actions, + ); + nav( + ui, + vm, + "nav.vocab", + NavTarget::Page(Page::Vocab), + IconName::Vocab, + actions, + ); + ui.add_space(4.0); + group( + ui, + vm, + "nav.group_style", + IconName::Style, + vm.style_open, + FrontendAction::SidebarToggleStyle, + actions, + ); + if vm.style_open { + subnav( + ui, + vm, + "nav.polish_mode", + NavTarget::Page(Page::Style), + actions, + ); + subnav( + ui, + vm, + "nav.marketplace", + NavTarget::Page(Page::Marketplace), + actions, + ); + } + group( + ui, + vm, + "nav.group_tools", + IconName::SelectionAsk, + vm.tools_open, + FrontendAction::SidebarToggleTools, + actions, + ); + if vm.tools_open { + subnav( + ui, + vm, + "nav.translation", + NavTarget::Page(Page::Translation), + actions, + ); + subnav( + ui, + vm, + "nav.selection_ask", + NavTarget::Page(Page::SelectionAsk), + actions, + ); + subnav( + ui, + vm, + "nav.quickNote", + NavTarget::Page(Page::QuickNote), + actions, + ); + subnav( + ui, + vm, + "nav.corrections", + NavTarget::Page(Page::Corrections), + actions, + ); + } + }); + }, + ); + ui.add_space(4.0); + nav_with_icon( + ui, + vm, + "nav.settings", + NavTarget::Page(Page::Settings), + IconName::Settings, + actions, + ); }); }); } +/// Where a sidebar row navigates to. +#[derive(Clone, Copy, PartialEq, Eq)] +enum NavTarget { + Page(Page), +} + +fn nav_active(vm: &FrontendViewModel, target: NavTarget) -> bool { + match target { + // Settings is an overlay: it highlights while open instead of owning a page. + NavTarget::Page(Page::Settings) => vm.settings_open, + NavTarget::Page(page) => vm.active_page == page, + } +} + +fn nav_click(target: NavTarget, actions: &mut Vec) { + match target { + NavTarget::Page(Page::Settings) => { + // Keep the current page rendered behind the modal. + actions.push(FrontendAction::ToggleSettings); + } + NavTarget::Page(page) => { + actions.push(FrontendAction::Navigate(page)); + } + } +} + fn nav( ui: &mut egui::Ui, vm: &mut FrontendViewModel, - label: &str, - page: Page, + key: &'static str, + target: NavTarget, icon: IconName, actions: &mut Vec, ) { - nav_with_icon(ui, vm, label, page, icon, actions); + nav_with_icon(ui, vm, key, target, icon, actions); +} + +/// Tauri 的 BETA 徽章(`FloatingShell` 版本信息行):蓝字、透明底、0.5px 蓝描边、 +/// 圆角 5、内边距 1×6、字号 10 + 字重 600。区别于其它胶囊的灰/淡蓝实底。 +fn beta_badge(ui: &mut egui::Ui, lang: openless_linux_egui::Lang) { + let label = tr_l10n(lang, "shell.beta_tag"); + let font = theme::medium_font(10.0); + let galley = ui + .painter() + .layout_no_wrap(label.to_owned(), font, theme::BLUE); + let padding = egui::vec2(6.0, 1.0); + let (rect, _) = ui.allocate_exact_size(galley.size() + padding * 2.0, egui::Sense::hover()); + let painter = ui.painter(); + painter.rect_stroke( + rect, + egui::CornerRadius::same(5), + egui::Stroke::new(0.8, theme::BLUE_PILL_BORDER), + egui::StrokeKind::Inside, + ); + painter.galley(rect.min + padding, galley, theme::BLUE); } fn nav_with_icon( ui: &mut egui::Ui, vm: &mut FrontendViewModel, - label: &str, - page: Page, + key: &'static str, + target: NavTarget, icon: IconName, actions: &mut Vec, ) { - let active = vm.active_page == page; + let label = tr_l10n(vm.lang, key); + let active = nav_active(vm, target); let (rect, response) = ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 32.0), egui::Sense::click()); if active { ui.painter() - .rect_filled(rect, egui::CornerRadius::same(8), theme::surface_2()); + .rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); } - let color = if active { theme::ink() } else { theme::ink_3() }; + 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), @@ -390,52 +790,54 @@ fn nav_with_icon( color, ); if response.clicked() { - actions.push(FrontendAction::Navigate(page)); - if page == Page::Settings { - actions.push(FrontendAction::ToggleSettings); - } + nav_click(target, actions); } } fn subnav( ui: &mut egui::Ui, vm: &mut FrontendViewModel, - label: &str, - page: Page, + key: &'static str, + target: NavTarget, actions: &mut Vec, ) { - let active = vm.active_page == page; + let label = tr_l10n(vm.lang, key); + let active = nav_active(vm, target); let (rect, response) = ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 30.0), egui::Sense::click()); if active { ui.painter() - .rect_filled(rect, egui::CornerRadius::same(8), theme::surface_2()); + .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 active { theme::INK } else { theme::INK_3 }, ); if response.clicked() { - actions.push(FrontendAction::Navigate(page)); + nav_click(target, actions); } } +#[allow(clippy::too_many_arguments)] fn group( ui: &mut egui::Ui, vm: &mut FrontendViewModel, - label: &str, + key: &'static str, icon: IconName, + is_open: bool, + toggle: FrontendAction, actions: &mut Vec, ) { + let label = tr_l10n(vm.lang, key); let (rect, response) = ui.allocate_exact_size(egui::vec2(SIDEBAR_WIDTH - 20.0, 32.0), egui::Sense::click()); let color = if response.hovered() { - theme::ink_2() + theme::INK_2 } else { - theme::ink_3() + theme::INK_3 }; icons::draw_icon(ui, rect.min + egui::vec2(20.0, 16.0), icon, color); ui.painter().text( @@ -447,10 +849,6 @@ fn group( ); 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)], @@ -471,30 +869,148 @@ fn group( ); } if response.clicked() { - match icon { - IconName::Style => actions.push(FrontendAction::SidebarToggleStyle), - _ => actions.push(FrontendAction::SidebarToggleTools), + actions.push(toggle); + } +} + +// ── 合成器拖动留下的指针状态 ──────────────────────────────────────────────── + +/// 「指针已经交给合成器」的时间戳(见 [`note_window_gesture_handoff`])。 +const WINDOW_GESTURE_HANDOFF: &str = "openless-window-gesture-handoff"; + +/// 交给合成器后多久还没等到释放,就断定这次释放被合成器吃掉了。够长,不至于把 +/// 正常的「按住标题栏一下」误判成丢事件;够短,用户不会察觉到卡顿。 +const ORPHANED_PRESS_TIMEOUT: f64 = 0.4; + +/// 记录「这一帧把指针交给合成器拖动/拉伸」。 +/// +/// 标题栏和四角的拉伸条都在**按下那一帧**就得请合成器接管(Wayland 的 +/// `xdg_toplevel.move` / `xdg_toplevel.resize` 只认按下那一刻的 serial),代价是这次 +/// 手势的释放也归合成器,客户端往往**收不到释放**——合成器在整个移动/缩放过程中也不 +/// 会再发 motion。egui 于是永远以为自己还按着、`dragged_id` 留在标题栏或拉伸条上, +/// 而 `ScrollArea` 正好用 `ctx.dragged_id().is_none()` 当作吃滚轮的前置条件——「拖过 +/// 窗口/缩完窗口后整页滚不动,点一下页面(那一次点击的释放)才恢复」。 +pub fn note_window_gesture_handoff(ctx: &egui::Context) { + let id = egui::Id::new(WINDOW_GESTURE_HANDOFF); + let now = ctx.input(|input| input.time); + ctx.data_mut(|data| { + // 已经有一次等待中的交接就别把截止时间往后推(`holding && !focused` 那条 + // 分支可能连续几帧请求拖动)。 + if data.get_temp::(id).is_none() { + data.insert_temp(id, now); } + }); +} + +/// `raw_input_hook` 里每帧修一次指针状态,修的正是合成器接管手势留下的两件坏事: +/// +/// 1. **被吃掉的释放**:见 [`note_window_gesture_handoff`]。拖窗/拉伸已经由合成器 +/// 负责,egui 不需要保留这次按压,补一个释放把 `any_down`/`dragged_id` 清干净。 +/// 2. **失效的坐标**:窗口在指针底下被移动/缩放时,客户端**不会**收到 motion,egui +/// 手里还是按下那一刻的坐标(通常正落在标题栏上)。滚轮本身证明指针就在窗口里, +/// 所以坐标不可信时把它放到窗口主体中心;真实的指针事件一来就会立刻覆盖。 +/// 只修「指针压根不在主体里」这种情况:侧栏自己也有滚动区,猜错会把它的滚动抢走。 +pub fn route_pointer_before_pass(ctx: &egui::Context, raw_input: &mut egui::RawInput) { + release_orphaned_press(ctx, raw_input); + point_stale_pointer_at_the_body(ctx, raw_input); +} + +fn pointer_release_event(event: &egui::Event) -> bool { + matches!(event, egui::Event::PointerButton { pressed: false, .. }) +} + +fn release_orphaned_press(ctx: &egui::Context, raw_input: &mut egui::RawInput) { + let id = egui::Id::new(WINDOW_GESTURE_HANDOFF); + let Some(handed_off_at) = ctx.data(|data| data.get_temp::(id)) else { + return; + }; + let (down, now) = ctx.input(|input| (input.pointer.any_down(), input.time)); + if !down { + // 释放正常送到了,不用管。 + ctx.data_mut(|data| data.remove::(id)); + return; + } + if raw_input.events.iter().any(pointer_release_event) { + // 这一帧就有真实释放,别重复补。 + ctx.data_mut(|data| data.remove::(id)); + return; } + if now - handed_off_at < ORPHANED_PRESS_TIMEOUT { + return; + } + let pos = ctx + .input(|input| input.pointer.interact_pos()) + .unwrap_or_else(|| body_rect(ctx).center()); + raw_input.events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + ctx.data_mut(|data| data.remove::(id)); +} + +fn point_stale_pointer_at_the_body(ctx: &egui::Context, raw_input: &mut egui::RawInput) { + let wheel = raw_input + .events + .iter() + .any(|event| matches!(event, egui::Event::MouseWheel { .. })); + if !wheel { + return; + } + // 这一帧已经带着真实的指针事件,坐标就是准的,不要猜。 + if raw_input.events.iter().any(|event| { + matches!( + event, + egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } + ) + }) { + return; + } + let body = body_rect(ctx); + let known = ctx.input(|input| input.pointer.interact_pos()); + if known.is_some_and(|pos| body.contains(pos)) { + return; + } + raw_input + .events + .insert(0, egui::Event::PointerMoved(body.center())); } // ── Content panel ─────────────────────────────────────────────────────────── pub fn content_panel(ctx: &egui::Context, add_contents: impl FnOnce(&mut egui::Ui)) { let body = body_rect(ctx); + // 页面留白对齐 Tauri 的页面容器(`padding: 56px 28px 24px`):标题离标题栏 + // 下沿 56px,左右 28px,底部 24px。 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::pos2( + body.left() + SIDEBAR_WIDTH + 28.0, + body.top() + PAGE_TOP_PADDING, + ), + egui::pos2(body.right() - 2.0, body.bottom() - PAGE_BOTTOM_PADDING), ); 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()); + .show(ctx, |area| { + // Area starts each frame with its *previous* measured size. A resize + // from a large window therefore leaves its root Ui's minimum width + // and height too large; set_max_size cannot shrink an existing min + // rect. Build a fresh child with the CURRENT viewport instead of + // letting yesterday's geometry drive today's scroll/hit testing. + let mut ui = area.new_child( + egui::UiBuilder::new() + .id_salt("current-content-viewport") + .max_rect(content), + ); ui.set_clip_rect(content); + #[cfg(test)] + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("content-available-size"), ui.available_size()); + }); let scroll = &mut ui.style_mut().spacing.scroll; scroll.floating = true; scroll.bar_width = 8.0; @@ -503,191 +1019,558 @@ pub fn content_panel(ctx: &egui::Context, add_contents: impl FnOnce(&mut egui::U scroll.bar_outer_margin = 0.0; scroll.foreground_color = false; scroll.floating_width = 6.0; - scroll.floating_allocated_width = 0.0; + // 预留滚动条槽位:Tauri 用 `scrollbar-gutter: stable` 常驻 6px, + // 滚动条不会盖住右边那列文字(之前是 0.0 → 悬浮条压字)。 + scroll.floating_allocated_width = 8.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); + add_contents(&mut ui); + // Keep the Area's hit-test bounds aligned to the resized viewport; + // the child does not advance its parent cursor automatically. + area.set_min_size(content.size()); }); } // ── Shared helpers ────────────────────────────────────────────────────────── -pub fn icon_text_button( +/// A stable per-card salt derived from its position, so child widget ids stay +/// unique across the cards on a page without threading a name through. +pub fn card_salt(rect: egui::Rect) -> (i32, i32) { + (rect.left().round() as i32, rect.top().round() as i32) +} + +/// Run `contents` inside a child `Ui` pinned to `rect`, **without** moving the +/// parent cursor. +/// +/// `Ui::scope_builder` / `Ui::scope` advance the parent cursor to the child's +/// *used* rect (`scope_dyn` calls `advance_cursor_after_rect`). Cards on these +/// pages are positioned explicitly and mostly paint instead of allocating, so +/// a scope would rewind the cursor and make the next row overlap the card. +/// Use this (or `card_at`) for absolutely positioned content. +pub fn fixed_ui( + ui: &mut egui::Ui, + rect: egui::Rect, + id_salt: impl std::hash::Hash + std::fmt::Debug, + contents: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + let mut child = ui.new_child( + egui::UiBuilder::new() + .id_salt(id_salt) + .max_rect(rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + child.set_clip_rect(child.clip_rect().intersect(rect)); + contents(&mut child) +} + +/// Width of `text` at `size` points, measured with the live font atlas. +/// Needed wherever a control is laid out by hand rather than by egui's cursor. +pub fn mono_text_width(ui: &egui::Ui, text: &str, size: f32) -> f32 { + if text.is_empty() { + return 0.0; + } + ui.fonts_mut(|fonts| { + fonts + .layout_no_wrap( + text.to_owned(), + egui::FontId::monospace(size), + egui::Color32::PLACEHOLDER, + ) + .size() + .x + }) +} + +pub fn text_width(ui: &egui::Ui, text: &str, size: f32) -> f32 { + if text.is_empty() { + return 0.0; + } + ui.fonts_mut(|fonts| { + fonts + .layout_no_wrap( + text.to_owned(), + egui::FontId::proportional(size), + egui::Color32::PLACEHOLDER, + ) + .size() + .x + }) +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ButtonKind { + /// Transparent fill, hairline border, ink text (the default toolbar look). + Ghost, + /// Filled with the accent blue, white text. + Blue, + /// Shortcut recorder menu: pale blue fill, blue outline and blue text. + BlueSoft, + /// Greyed-out button that swallows clicks (Tauri's 置灰 停用). + Disabled, +} + +/// A bordered button drawn into an exact rectangle, with an optional leading +/// icon. Used by the pages that position their cards explicitly. +pub fn action_button( ui: &mut egui::Ui, + rect: egui::Rect, label: &str, - icon: IconName, - width: f32, + icon: Option, + kind: ButtonKind, ) -> 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() + let id = ui.id().with(( + "openless-action-button", + label, + rect.left().round() as i32, + rect.top().round() as i32, + )); + let response = ui.interact(rect, id, egui::Sense::click()); + let (fill, stroke, ink) = match kind { + ButtonKind::Ghost => ( + if response.hovered() { + theme::SURFACE_2 + } else { + egui::Color32::TRANSPARENT + }, + Some(egui::Stroke::new(0.8, theme::LINE)), + theme::INK_2, + ), + ButtonKind::Blue => ( + if response.hovered() { + theme::BLUE.linear_multiply(0.92) + } else { + theme::BLUE + }, + None, + egui::Color32::WHITE, + ), + ButtonKind::BlueSoft => ( + if response.hovered() { + theme::BLUE_SOFT.linear_multiply(0.92) + } else { + theme::BLUE_SOFT + }, + Some(egui::Stroke::new( + 1.0, + egui::Color32::from_rgba_unmultiplied(37, 99, 235, 64), + )), + theme::BLUE, + ), + ButtonKind::Disabled => ( + egui::Color32::TRANSPARENT, + Some(egui::Stroke::new(0.5, theme::LINE_SOFT)), + theme::INK_4.linear_multiply(0.6), + ), + }; + let painter = ui.painter().with_clip_rect(rect); + painter.rect_filled(rect, egui::CornerRadius::same(8), fill); + if let Some(stroke) = stroke { + painter.rect_stroke( + rect, + egui::CornerRadius::same(8), + stroke, + egui::StrokeKind::Inside, + ); + } + let label_width = text_width(ui, label, 12.5); + let icon_space = if icon.is_some() { 19.0 } else { 0.0 }; + let mut x = rect.center().x - (label_width + icon_space) / 2.0; + if let Some(icon) = icon { + // 只有图标的按钮(例如历史详情的「…」菜单)把图标居中。 + let icon_center = if label.is_empty() { + rect.center() } 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::pos2(x + 6.5, rect.center().y) + }; + icons::draw_icon(ui, icon_center, icon, ink); + x += icon_space; + } + painter.text( + egui::pos2(x, rect.center().y), egui::Align2::LEFT_CENTER, label, - egui::FontId::proportional(11.5), - theme::ink_2(), + egui::FontId::proportional(12.5), + ink, ); 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() - }, +/// Draw the standard page header (uppercase kicker, title, optional desc) and +/// return the row so the caller can place right-aligned actions on it. +pub fn page_header( + ui: &mut egui::Ui, + width: f32, + kicker: &str, + title: &str, + desc: Option<&str>, +) -> egui::Rect { + let height = if desc.is_some() { 84.0 } else { 60.0 }; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left(), rect.top() + 2.0), + egui::Align2::LEFT_TOP, + kicker, + egui::FontId::proportional(11.0), + theme::INK_4, ); - ui.painter().rect_stroke( + painter.text( + egui::pos2(rect.left(), rect.top() + 18.0), + egui::Align2::LEFT_TOP, + title, + egui::FontId::proportional(26.0), + theme::INK, + ); + if let Some(desc) = desc { + painter.text( + egui::pos2(rect.left(), rect.top() + 56.0), + egui::Align2::LEFT_TOP, + desc, + egui::FontId::proportional(13.0), + theme::INK_3, + ); + } + rect +} + +/// Paint the standard card background (white, hairline border, 14pt radius). +pub fn paint_card(painter: &egui::Painter, rect: egui::Rect) { + // Tauri `Card`: --ol-r-lg 圆角 + 0.5px --ol-line 边框 + --ol-shadow-sm。 + painter.rect_filled(rect, egui::CornerRadius::same(14), theme::SURFACE); + painter.rect_stroke( rect, - egui::CornerRadius::same(8), - egui::Stroke::new(0.8, theme::line()), + egui::CornerRadius::same(14), + egui::Stroke::new(0.5, 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( +/// A card with padded contents that never moves the parent layout cursor. +pub fn card( ui: &mut egui::Ui, - text: &str, - fill: egui::Color32, - border: egui::Color32, - color: egui::Color32, + rect: egui::Rect, + padding: f32, + contents: impl FnOnce(&mut egui::Ui, egui::Rect), +) { + paint_card(ui.painter(), rect); + let inner = rect.shrink(padding); + fixed_ui(ui, inner, card_salt(rect), |ui| contents(ui, inner)); +} + +/// iOS-style switch painted into an exact rectangle. +pub fn toggle( + ui: &mut egui::Ui, + rect: egui::Rect, + on: bool, + id_salt: impl std::hash::Hash + std::fmt::Debug, ) -> 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()); + let response = ui.interact(rect, ui.id().with(id_salt), egui::Sense::click()); + // Tauri `Toggle`: 开启 --ol-blue,关闭 --ol-toggle-off-bg。 + let track = if on { theme::BLUE } else { theme::TOGGLE_OFF }; ui.painter() - .rect_filled(rect, egui::CornerRadius::same(9), fill); - ui.painter().rect_stroke( - rect, - egui::CornerRadius::same(9), - egui::Stroke::new(0.7, border), - egui::StrokeKind::Inside, + .rect_filled(rect, egui::CornerRadius::same(10), track); + let knob_x = if on { + rect.right() - 10.0 + } else { + rect.left() + 10.0 + }; + ui.painter().circle_filled( + egui::pos2(knob_x, rect.center().y), + 8.0, + egui::Color32::WHITE, ); - ui.painter().text( - rect.center(), - egui::Align2::CENTER_CENTER, + response +} + +/// Lay out text into a galley with a width/row limit. Used by pages that paint +/// their content at explicit positions instead of with the layout cursor. +pub fn text_galley( + ui: &egui::Ui, + text: &str, + color: egui::Color32, + size: f32, + max_width: f32, + max_rows: usize, +) -> std::sync::Arc { + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = max_width.max(1.0); + job.wrap.max_rows = max_rows; + job.append( text, - egui::FontId::proportional(10.5), - color, + 0.0, + egui::text::TextFormat { + font_id: egui::FontId::proportional(size), + color, + ..Default::default() + }, ); - response + ui.fonts_mut(|fonts| fonts.layout_job(job)) +} + +/// Width of a segmented control for `options`. +pub fn segmented_width(ui: &egui::Ui, options: &[&str]) -> f32 { + let mut width = 4.0; + for (index, option) in options.iter().enumerate() { + if index > 0 { + width += 2.0; + } + width += text_width(ui, option, 12.0) + 18.0; + } + width } -pub fn card_at(ui: &mut egui::Ui, rect: egui::Rect, contents: impl FnOnce(&mut egui::Ui)) { +/// A segmented button group (the Tauri `ol-seg` control). Returns the index the +/// user clicked. Every segment is a real button, not a text label. +pub fn segmented( + ui: &mut egui::Ui, + rect: egui::Rect, + options: &[&str], + selected: usize, +) -> Option { + let painter = ui.painter().with_clip_rect(rect); + // Tauri track: rgba(0,0,0,0.04) with a 2px inset; the active chip is a white + // surface with a hairline + soft shadow, the label stays ink-colored. + painter.rect_filled(rect, egui::CornerRadius::same(8), theme::SEGMENTED_TRACK); + let mut x = rect.left() + 2.0; + let mut clicked = None; + for (index, option) in options.iter().enumerate() { + let width = text_width(ui, option, 12.0) + 18.0; + let option_rect = egui::Rect::from_min_size( + egui::pos2(x, rect.top() + 2.0), + egui::vec2(width, rect.height() - 4.0), + ); + let id = ui.id().with(( + "openless-segment", + index, + rect.left().round() as i32, + rect.top().round() as i32, + )); + let response = ui.interact(option_rect, id, egui::Sense::click()); + let is_selected = index == selected; + if is_selected { + // Tauri `--ol-segmented-active-shadow` = `0 1px 2px rgba(0,0,0,0.06), 0 0 0 0.5px rgba(0,0,0,0.06)`: + // 选中片是白底、无描边,靠向下 1px 的浅投影 + 0.5px 细环从轨道上"浮"起来。 + painter.rect_filled( + option_rect.expand(1.0).translate(egui::vec2(0.0, 1.0)), + egui::CornerRadius::same(7), + theme::SEGMENTED_ACTIVE_SHADOW, + ); + painter.rect_filled( + option_rect, + egui::CornerRadius::same(6), + theme::SEGMENTED_ACTIVE_BG, + ); + painter.rect_stroke( + option_rect, + egui::CornerRadius::same(6), + egui::Stroke::new(0.5, theme::SEGMENTED_ACTIVE_RING), + egui::StrokeKind::Inside, + ); + } else if response.hovered() { + painter.rect_filled( + option_rect, + egui::CornerRadius::same(6), + egui::Color32::from_white_alpha(140), + ); + } + painter.text( + option_rect.center(), + egui::Align2::CENTER_CENTER, + option, + theme::medium_font(12.0), + if is_selected { + theme::INK + } else { + theme::INK_3 + }, + ); + if response.clicked() { + clicked = Some(index); + } + x += width + 2.0; + } + clicked +} +/// A segmented button group (the Tauri `ol-seg` control). Returns the index the +/// user clicked. Every segment is a real button, not a text label. +/// Tauri `inputStyle`(`src/pages/settings/shared.tsx:243-256`):高度 32、字号 13.5、 +/// 左右 padding 10、radius 8、底色 `--ol-select-trigger-bg`(= `--ol-control-solid`)、 +/// 最大宽度 360。所有设置页的文本输入都走这里,不再逐处写尺寸。 +pub const INPUT_HEIGHT: f32 = 32.0; +pub const INPUT_FONT_SIZE: f32 = 13.5; +pub const INPUT_MAX_WIDTH: f32 = 360.0; + +/// A single-line text input styled like the Tauri settings rows. +/// +/// `password` maps to Tauri's `type="password"`(密钥/令牌字段)。 +pub fn text_input( + ui: &mut egui::Ui, + value: &mut String, + id: egui::Id, + hint: &str, + width: f32, + password: bool, +) -> egui::Response { + let width = width.clamp(80.0, INPUT_MAX_WIDTH); + let (outer, _) = ui.allocate_exact_size(egui::vec2(width, INPUT_HEIGHT), egui::Sense::hover()); ui.painter() - .rect_filled(rect, egui::CornerRadius::same(14), theme::surface()); + .rect_filled(outer, egui::CornerRadius::same(8), theme::SURFACE); ui.painter().rect_stroke( - rect, - egui::CornerRadius::same(14), - egui::Stroke::new(1.0, theme::line()), + outer, + egui::CornerRadius::same(8), + egui::Stroke::new(0.5, theme::LINE_STRONG), egui::StrokeKind::Inside, ); - let inner = rect.shrink(17.0); - ui.scope_builder( + // padding 0/10:左右缩进 10,纵向留 1 抵消 0.5px 描边,文字由 vertical_align 居中。 + let inner = outer.shrink2(egui::vec2(10.0, 1.0)); + let mut child = ui.new_child( egui::UiBuilder::new() + .id_salt(id) .max_rect(inner) - .layout(egui::Layout::top_down(egui::Align::Min)), - |ui| { - ui.set_clip_rect(ui.clip_rect().intersect(rect)); - contents(ui); - }, + .layout(egui::Layout::left_to_right(egui::Align::Center)), ); + let mut edit = egui::TextEdit::singleline(value) + .id(id) + .hint_text(hint) + .font(egui::FontId::proportional(INPUT_FONT_SIZE)) + .text_color(theme::INK) + .frame(egui::Frame::NONE) + .desired_width(inner.width()) + .vertical_align(egui::Align::Center); + if password { + edit = edit.password(true); + } + child.add(edit) } -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() - }, +/// A labelled section block: title plus optional smaller description line. +pub fn section_title(ui: &mut egui::Ui, width: f32, title: &str, desc: Option<&str>) { + let height = if desc.is_some() { 40.0 } else { 20.0 }; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left(), rect.top()), + egui::Align2::LEFT_TOP, + title, + // Tauri `SectionTitle`: 14 / 600。 + egui::FontId::proportional(14.0), + theme::INK, ); - ui.painter().text( + if let Some(desc) = desc { + painter.text( + egui::pos2(rect.left(), rect.top() + 20.0), + egui::Align2::LEFT_TOP, + desc, + egui::FontId::proportional(11.5), + theme::INK_4, + ); + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum PillTone { + /// Transparent fill with a hairline border (used for "raw"). + Outline, + /// Neutral `SURFACE_2` fill. + Gray, + /// Accent-tinted fill. + Blue, + /// Derived marketplace packs. + Green, +} + +/// Natural size of a small pill for `text`. +pub fn pill_size(ui: &egui::Ui, text: &str) -> egui::Vec2 { + egui::vec2(text_width(ui, text, 10.5) + 16.0, 18.0) +} + +/// Paint a small rounded pill into an exact rectangle. +pub fn paint_pill(painter: &egui::Painter, rect: egui::Rect, text: &str, tone: PillTone) { + let (fill, border, color) = match tone { + PillTone::Outline => (egui::Color32::TRANSPARENT, Some(theme::LINE), theme::INK_3), + PillTone::Gray => (theme::SURFACE_2, None, theme::INK_3), + PillTone::Blue => (theme::BLUE_SOFT, None, theme::BLUE), + PillTone::Green => (egui::Color32::from_rgb(235, 250, 239), None, theme::OK), + }; + painter.rect_filled(rect, egui::CornerRadius::same(9), fill); + if let Some(border) = border { + painter.rect_stroke( + rect, + egui::CornerRadius::same(9), + egui::Stroke::new(0.7, border), + egui::StrokeKind::Inside, + ); + } + painter.text( rect.center(), egui::Align2::CENTER_CENTER, text, - egui::FontId::proportional(10.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)), + egui::FontId::proportional(10.5), + color, ); } -pub fn unsupported_page(ui: &mut egui::Ui, title: &str) { +pub fn unsupported_page(ui: &mut egui::Ui, lang: openless_linux_egui::Lang, 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); + if !title.is_empty() { + ui.label( + egui::RichText::new(title) + .size(28.0) + .strong() + .color(theme::INK), + ); + ui.add_space(22.0); + } egui::Frame::new() - .fill(theme::surface()) - .stroke(egui::Stroke::new(1.0, theme::line())) + .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()), + egui::RichText::new(openless_linux_egui::tr_l10n( + lang, + "common.unsupported_title", + )) + .size(13.0) + .color(theme::INK_3), ); ui.add_space(4.0); ui.label( - egui::RichText::new(theme::text("数据桥接将在后续阶段完成")) - .size(11.0) - .color(theme::ink_4()), + egui::RichText::new(openless_linux_egui::tr_l10n( + lang, + "common.unsupported_hint", + )) + .size(11.0) + .color(theme::INK_4), ); }); }); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_input_metrics_match_the_tauri_input_style() { + // Tauri `inputStyle`(settings/shared.tsx:243-256):height 32 / fontSize 13.5 / + // padding 0 10 / maxWidth 360。这些取值被设置页所有文本输入共用,改动必须是有意的。 + assert_eq!(INPUT_HEIGHT, 32.0); + assert_eq!(INPUT_FONT_SIZE, 13.5); + assert_eq!(INPUT_MAX_WIDTH, 360.0); + } + + #[test] + fn the_selected_segment_uses_the_tauri_shadow_tokens() { + // Tauri `--ol-segmented-active-shadow` 的两段:细环 + 向下 1px 的浅投影。 + // 选中片没有描边(border: 0),所以环不能等于普通的 --ol-line。 + assert_ne!(theme::SEGMENTED_ACTIVE_RING, theme::LINE); + assert_eq!(theme::SEGMENTED_ACTIVE_BG, theme::SURFACE); + assert!(theme::SEGMENTED_ACTIVE_SHADOW.a() > 0); + assert!(theme::SEGMENTED_ACTIVE_SHADOW.a() < theme::SEGMENTED_ACTIVE_RING.a()); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs b/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs index beb1eb862..e1a8dd286 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs @@ -1,8 +1,117 @@ use eframe::egui; +use openless_linux_egui::{tr_l10n, Lang}; +use super::layout; use super::theme; use super::view_model::{FrontendAction, FrontendViewModel, MarketplaceSort}; +/// Every marketplace tile has the same height so rows line up. +const MARKETPLACE_TILE_HEIGHT: f32 = 176.0; + +/// GitHub device-flow dialog. Tauri's modal is `min(440px, 100%)`; the egui +/// card keeps the reference's left edge and extends 30px further right so the +/// browser hint and the授权 status row are not cramped. +const OAUTH_CARD_WIDTH: f32 = 470.0; +const OAUTH_CARD_SHIFT_X: f32 = 15.0; +/// Title size matches the Tauri heading (16px) rather than the 15px body step. +const OAUTH_TITLE_SIZE: f32 = 16.0; +/// Tauri's dialog close button is a fixed 28×28 tile. +const OAUTH_CLOSE_SIZE: f32 = 28.0; +/// `--ol-font-mono` at 22px, matching the Tauri `userCode` span. +const CODE_TEXT_SIZE: f32 = 22.0; +const CODE_COPY_GAP: f32 = 10.0; + +fn my_packs_button(ui: &mut egui::Ui, rect: egui::Rect, label: &str) -> egui::Response { + let response = ui.interact( + rect, + ui.id().with("marketplace-my-packs"), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(rect); + painter.rect_filled(rect, egui::CornerRadius::same(9), theme::SURFACE); + painter.rect_stroke( + rect, + egui::CornerRadius::same(9), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + let badge = egui::Rect::from_center_size( + egui::pos2(rect.left() + 21.0, rect.center().y), + egui::vec2(18.0, 18.0), + ); + painter.rect_filled(badge, egui::CornerRadius::same(9), theme::SURFACE_2); + // No verified account identity is exposed by the Linux view model yet; + // Tauri shows '?' in this exact badge when not signed in. + painter.text( + badge.center(), + egui::Align2::CENTER_CENTER, + "?", + egui::FontId::proportional(10.0), + theme::INK_2, + ); + let label_pos = egui::pos2(badge.right() + 8.0, rect.center().y); + painter.text( + label_pos, + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.0), + theme::INK_2, + ); + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("openless-mine-button-test-rects"), + (rect, badge, label_pos), + ) + }); + response +} + +/// A hand-painted `×` control. +/// +/// Tauri gives both close buttons an explicit `width`/`height`; a stock +/// `egui::Button` instead takes its size from the active font's metrics *and* +/// from egui's per-state padding (`button_padding + expansion - +/// bg_stroke.width`), so it visibly resized under the pointer. Painting into a +/// fixed rect removes that entire class of jitter. +fn close_control( + ui: &mut egui::Ui, + rect: egui::Rect, + glyph_size: f32, + radius: u8, +) -> egui::Response { + let id = ui.id().with(( + "openless-close-control", + rect.left().round() as i32, + rect.top().round() as i32, + )); + let response = ui.interact(rect, id, egui::Sense::click()); + let painter = ui.painter().with_clip_rect(rect); + painter.rect_filled( + rect, + egui::CornerRadius::same(radius), + if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + painter.rect_stroke( + rect, + egui::CornerRadius::same(radius), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + painter.text( + rect.center(), + egui::Align2::CENTER_CENTER, + "×", + egui::FontId::proportional(glyph_size), + theme::INK_2, + ); + response +} + /// 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( @@ -11,44 +120,56 @@ pub fn marketplace_page( 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); + let lang = vm.lang; + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + + let header = layout::page_header( + ui, + width, + tr_l10n(lang, "marketplace.kicker"), + tr_l10n(lang, "marketplace.title"), + Some(tr_l10n(lang, "marketplace.desc")), + ); + let mine = tr_l10n(lang, "marketplace.my_packs_button_label"); + // Tauri keeps the '?' avatar *inside* the My Publications button. + let mine_width = layout::text_width(ui, mine, 12.0) + 12.0 * 2.0 + 18.0 + 8.0; + let refresh = tr_l10n(lang, "marketplace.refresh_btn"); + let refresh_width = layout::text_width(ui, refresh, 12.5) + 34.0; + let refresh_rect = egui::Rect::from_min_size( + egui::pos2(header.right() - refresh_width, header.top() + 22.0), + egui::vec2(refresh_width, 30.0), + ); + let mine_rect = egui::Rect::from_min_size( + egui::pos2(refresh_rect.left() - 8.0 - mine_width, header.top() + 22.0), + egui::vec2(mine_width, 30.0), + ); + if my_packs_button(ui, mine_rect, mine) + .on_hover_text(tr_l10n(lang, "marketplace.myPacks.buttonTitleEmpty")) + .clicked() + { + actions.push(FrontendAction::MarketplaceMyPacks); + } + if layout::action_button( + ui, + refresh_rect, + refresh, + Some(super::icons::IconName::Refresh), + layout::ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::MarketplaceRefresh); + } + ui.add_space(14.0); // Search + sort ui.horizontal(|ui| { let search_width = (ui.available_width() - 250.0).max(180.0); egui::Frame::new() - .fill(theme::surface()) - .stroke(egui::Stroke::new(1.0, theme::line())) + .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| { @@ -57,7 +178,7 @@ pub fn marketplace_page( 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()); + 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( [ @@ -66,37 +187,49 @@ pub fn marketplace_page( ], icon_stroke, ); - let mut query = vm.marketplace_query.clone(); + // Bind the view-model field itself: a local clone loses every + // keystroke on the next frame (the host never echoed it back), + // so the search box looked like it ignored typing. let resp = ui.add( - egui::TextEdit::singleline(&mut query) - .hint_text(theme::text("搜索风格包")) - .frame(false) + egui::TextEdit::singleline(&mut vm.marketplace_query) + .id(egui::Id::new("openless-marketplace-search")) + .hint_text(tr_l10n(lang, "marketplace.search_placeholder")) + .text_color(theme::INK) + .frame(egui::Frame::NONE) .desired_width(search_width - 34.0), ); if resp.changed() { - actions.push(FrontendAction::MarketplaceSearch(query)); + actions.push(FrontendAction::MarketplaceSearch( + vm.marketplace_query.clone(), + )); } }); }); ui.add_space(10.0); for (mode, label) in [ - (MarketplaceSort::Popular, "热门"), - (MarketplaceSort::New, "最新"), - (MarketplaceSort::Liked, "我赞过的"), + ( + MarketplaceSort::Popular, + tr_l10n(lang, "marketplace.sort_popular"), + ), + (MarketplaceSort::New, tr_l10n(lang, "marketplace.sort_new")), + ( + MarketplaceSort::Liked, + tr_l10n(lang, "marketplace.sort_liked"), + ), ] { let selected = vm.marketplace_sort == mode; let response = ui.add( egui::Button::new(egui::RichText::new(label).size(12.0).color(if selected { - theme::blue() + theme::BLUE } else { - theme::ink_2() + theme::INK_2 })) .fill(if selected { - theme::blue_soft() + theme::BLUE_SOFT } else { - theme::surface() + theme::SURFACE }) - .stroke(egui::Stroke::new(1.0, theme::line())) + .stroke(egui::Stroke::new(1.0, theme::LINE)) .corner_radius(egui::CornerRadius::same(8)) .min_size(egui::vec2(64.0, 30.0)), ); @@ -110,11 +243,11 @@ pub fn marketplace_page( // Notice if let Some(notice) = &vm.marketplace_notice { egui::Frame::new() - .fill(theme::blue_soft()) + .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.label(egui::RichText::new(notice).size(11.5).color(theme::BLUE)); }); ui.add_space(10.0); } @@ -122,57 +255,72 @@ pub fn marketplace_page( if vm.marketplace_loading { ui.horizontal(|ui| { ui.spinner(); - ui.label(theme::text("正在加载风格市场…")); + ui.label(tr_l10n(lang, "common.loading")); }); return; } if vm.marketplace_unsupported { egui::Frame::new() - .fill(theme::surface()) - .stroke(egui::Stroke::new(1.0, theme::line())) - .corner_radius(egui::CornerRadius::same(12)) + .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("风格市场暂未接线")) + egui::RichText::new(tr_l10n(lang, "marketplace.kicker")) .size(13.0) - .color(theme::ink_3()), + .color(theme::INK_3), ); ui.add_space(4.0); ui.label( - egui::RichText::new(theme::text("市场后端桥接将在后续阶段完成")) + egui::RichText::new(tr_l10n(lang, "marketplace.desc")) .size(11.0) - .color(theme::ink_4()), + .color(theme::INK_4), ); }); }); return; } - if vm.marketplace_packs.is_empty() { + // 「我赞过的」 is a client-side filter over the signed-in user's like list. + let liked_only = vm.marketplace_sort == MarketplaceSort::Liked; + let visible: Vec = vm + .marketplace_packs + .iter() + .enumerate() + .filter(|(_, pack)| !liked_only || pack.liked) + .map(|(index, _)| index) + .collect(); + + if visible.is_empty() { + let (title, hint) = if liked_only { + ( + tr_l10n(lang, "marketplace.liked_empty"), + tr_l10n(lang, "marketplace.liked_empty_hint"), + ) + } else { + ( + tr_l10n(lang, "marketplace.empty"), + tr_l10n(lang, "marketplace.empty_hint"), + ) + }; egui::Frame::new() - .fill(theme::surface()) - .stroke(egui::Stroke::new(1.0, theme::line())) - .corner_radius(egui::CornerRadius::same(12)) + .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.label(egui::RichText::new(title).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()), - ); + ui.label(egui::RichText::new(hint).size(11.0).color(theme::INK_4)); }); }); } else { + // Fixed-size tiles, three per row on a wide window: every card is the + // same height so the grid stays aligned regardless of description length. let columns = if ui.available_width() >= 900.0 { 3 } else if ui.available_width() >= 600.0 { @@ -181,297 +329,1489 @@ pub fn marketplace_page( 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); - } + let width = ui.available_width(); + let card_width = (width - gap * (columns - 1) as f32) / columns as f32; + for chunk in visible.chunks(columns) { + let (row, _) = ui.allocate_exact_size( + egui::vec2(width, MARKETPLACE_TILE_HEIGHT), + egui::Sense::hover(), + ); + for (slot, index) in chunk.iter().enumerate() { + let Some(pack) = vm.marketplace_packs.get(*index) else { + continue; + }; + let rect = egui::Rect::from_min_size( + egui::pos2(row.left() + slot as f32 * (card_width + gap), row.top()), + egui::vec2(card_width, MARKETPLACE_TILE_HEIGHT), + ); + marketplace_card(ui, rect, pack, *index, vm, actions); } - }); + ui.add_space(gap); + } + } + + if vm.marketplace_mine_open { + marketplace_mine(ui.ctx(), vm, body_rect, actions); } // 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, - ); + marketplace_detail(ui.ctx(), vm, pack, index, body_rect, actions); } } + if vm.marketplace_upload_open { + marketplace_upload(ui.ctx(), vm, body_rect, actions); + } + if vm.marketplace_confirm_withdraw.is_some() { + marketplace_withdraw_confirm(ui.ctx(), vm, body_rect, actions); + } + if vm.marketplace_oauth_open { + let nested = vm.marketplace_selected.is_some() + || vm.marketplace_mine_open + || vm.marketplace_upload_open + || vm.marketplace_confirm_withdraw.is_some(); + marketplace_oauth(ui.ctx(), vm, body_rect, actions, nested); + } } fn marketplace_card( ui: &mut egui::Ui, - width: f32, + rect: egui::Rect, pack: &super::view_model::MarketplacePack, index: usize, - _vm: &FrontendViewModel, + vm: &FrontendViewModel, actions: &mut Vec, ) { - let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 156.0), egui::Sense::click()); + let lang = vm.lang; + let padding = 14.0; + let inner = rect.shrink(padding); + let (response_rect, response) = ( + rect, + ui.interact( + rect, + ui.id().with(("marketplace-card", index)), + egui::Sense::click(), + ), + ); + let _ = response_rect; let fill = if response.hovered() { - theme::surface_2() + theme::SURFACE_2 } else { - theme::surface() + theme::SURFACE }; ui.painter() - .rect_filled(rect, egui::CornerRadius::same(12), fill); + .rect_filled(rect, egui::CornerRadius::same(14), fill); ui.painter().rect_stroke( rect, - egui::CornerRadius::same(12), - egui::Stroke::new(1.0, theme::line()), + egui::CornerRadius::same(14), + 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 painter = ui.painter().with_clip_rect(rect); + + // Title row. + painter.text( + inner.left_top(), + egui::Align2::LEFT_TOP, + &pack.name, + egui::FontId::proportional(14.0), + theme::INK, ); - 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(), - ); - }, + painter.text( + egui::pos2(inner.right(), inner.top() + 2.0), + egui::Align2::RIGHT_TOP, + format!("v{}", pack.version), + egui::FontId::monospace(10.0), + theme::INK_4, ); - 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); + + // Description, clamped so every tile keeps the same height. + let description = + layout::text_galley(ui, &pack.description, theme::INK_3, 12.0, inner.width(), 3); + let description_top = inner.top() + 26.0; + painter.galley( + egui::pos2(inner.left(), description_top), + description.clone(), + theme::INK_3, + ); + + // Tags directly under the clamped description. + let mut x = inner.left(); + let tags_top = description_top + description.size().y + 8.0; + for (text, tone) in std::iter::once((pack.mode.as_str(), layout::PillTone::Outline)).chain( + pack.tags + .iter() + .take(2) + .map(|tag| (tag.as_str(), layout::PillTone::Gray)), + ) { + let size = layout::pill_size(ui, text); + if x + size.x > inner.right() { + break; } - }); - 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()), - ); - }); - }); - }); + layout::paint_pill( + &painter, + egui::Rect::from_min_size(egui::pos2(x, tags_top), size), + text, + tone, + ); + x += size.x + 6.0; + } + + if let Some(author) = pack + .origin_author_login + .as_ref() + .filter(|author| *author != &pack.author) + { + let badge = openless_linux_egui::fmt_l10n(lang, "marketplace.derivativeBadge", &[author]); + let size = layout::pill_size(ui, &badge); + layout::paint_pill( + &painter, + egui::Rect::from_min_size(egui::pos2(inner.left(), tags_top + 23.0), size), + &badge, + layout::PillTone::Green, + ); + } + + // Footer pinned to the bottom of the fixed tile. + let footer_center_y = rect.bottom() - padding - 12.0; + painter.text( + egui::pos2(inner.left(), footer_center_y), + egui::Align2::LEFT_CENTER, + format!("@{}", pack.author), + egui::FontId::proportional(11.0), + theme::INK_3, + ); + let download = tr_l10n(lang, "marketplace.download_zip_btn"); + let download_width = layout::text_width(ui, download, 11.5) + 26.0; + let download_rect = egui::Rect::from_min_size( + egui::pos2(inner.right() - download_width, footer_center_y - 12.0), + egui::vec2(download_width, 24.0), + ); + painter.text( + egui::pos2(download_rect.left() - 10.0, footer_center_y), + egui::Align2::RIGHT_CENTER, + format!("☆ {} · ↓ {}", pack.likes, pack.downloads), + egui::FontId::proportional(10.5), + theme::INK_4, + ); + if layout::action_button(ui, download_rect, download, None, layout::ButtonKind::Ghost).clicked() + { + actions.push(FrontendAction::MarketplaceDownload(index)); + } 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, + vm: &FrontendViewModel, 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")) + let lang = vm.lang; + let liked = pack.liked; + let prompt = vm.marketplace_detail_prompt.as_deref(); + let installing = vm + .marketplace_installing + .as_deref() + .is_some_and(|id| id == pack.id); + // The Tauri Modal uses a 560px card. Keep the same centred dimensions + // while content changes; only shrink to fit genuinely small windows. + let modal_width = (body_rect.width() - 40.0).min(560.0); + let card_height = (body_rect.height() - 40.0).min(470.0); + let card_rect = + egui::Rect::from_center_size(body_rect.center(), egui::vec2(modal_width, card_height)); + + let modal = egui::Area::new(egui::Id::new("openless-marketplace-detail-modal")) + // 与设置弹窗同一套层级策略:遮罩、点击拦截与卡片必须同属**一个** `Area`(同一个 + // LayerId)。各自独立 Area 时,egui 会在按下后把被点到的 Area 抬到同层最上 + // (`move_to_top`),遮罩一旦被抬起就会盖住卡片;同一图层里先画遮罩、再画卡片在 + // 结构上就不可能出现。这里用 `Foreground` 而不是 `Tooltip`:弹窗若占 Tooltip,会 + // 盖住同层弹出的下拉/菜单(跨 Order 是 Tooltip > Foreground)。 .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()); + layout::paint_blurred_overlay(ctx, ui, body_rect, layout::body_corner_radius(ctx)); + // 点击拦截:吃掉 body 上的点击,下方页面既看不到也点不到。 + let _ = ui.allocate_rect(body_rect, egui::Sense::click()); + ui.scope_builder(egui::UiBuilder::new().max_rect(card_rect), |ui| { + ui.set_clip_rect(body_rect.intersect(ui.clip_rect())); + 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(22)) + .show(ui, |ui| { + // egui Frame adds its own 3.8px on each side; keep + // the measured outer card centred at the target width. + ui.set_width(modal_width - 52.0); + ui.set_min_height(card_height - 44.0); + ui.set_max_height(card_height - 44.0); + // Tauri `Modal`: name + outline mode pill + ok-toned + // derivative pill + mono version, baseline-aligned in one + // wrapping row. + ui.horizontal(|ui| { + ui.add( + egui::Label::new( + egui::RichText::new(&pack.name).size(18.0).strong(), + ) + .truncate(), + ); + let (mode_rect, _) = ui.allocate_exact_size( + layout::pill_size(ui, &pack.mode), + egui::Sense::hover(), + ); + layout::paint_pill( + ui.painter(), + mode_rect, + &pack.mode, + layout::PillTone::Outline, + ); + if let Some(author) = pack + .origin_author_login + .as_ref() + .filter(|author| *author != &pack.author) + { + let badge = openless_linux_egui::fmt_l10n( + lang, + "marketplace.derivativeBadge", + &[author], + ); + let (rect, response) = ui.allocate_exact_size( + layout::pill_size(ui, &badge), + egui::Sense::hover(), + ); + layout::paint_pill( + ui.painter(), + rect, + &badge, + layout::PillTone::Green, + ); + response.on_hover_text(&badge); + } + ui.label( + egui::RichText::new(format!("v{}", pack.version)) + .size(11.0) + .monospace() + .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.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::same(10)) + .show(ui, |ui| { + ui.set_width((modal_width - 68.0).max(1.0)); + egui::ScrollArea::vertical() + .max_height((card_height - 225.0).max(60.0)) + .min_scrolled_height((card_height - 225.0).max(60.0)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(prompt.unwrap_or_default()) + .size(12.0) + .monospace() + .color(theme::INK_2), + ); + }); + }); + ui.add_space(14.0); + ui.horizontal(|ui| { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + // Two literal call sites: the i18n sync only + // registers keys written next to `tr_l10n(…,`. + let install_label = if installing { + tr_l10n(lang, "marketplace.installingBtn") + } else { + tr_l10n(lang, "marketplace.install_btn") + }; + let install_button = ui.add_enabled( + !installing, + egui::Button::new( + egui::RichText::new(install_label) + .color(egui::Color32::WHITE), + ) + .fill(theme::BLUE) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)), + ); + if install_button.clicked() { + actions.push(FrontendAction::MarketplaceInstall(index)); + actions.push(FrontendAction::MarketplaceCloseDetail); + } + if ui.button(tr_l10n(lang, "common.cancel")).clicked() { + actions.push(FrontendAction::MarketplaceCloseDetail); + } + if ui + .add( + egui::Button::new( + egui::RichText::new(format!( + "{} {}", + if liked { "★" } else { "☆" }, + pack.likes + )) + .size(12.0) + .color(if liked { + egui::Color32::from_rgb(239, 68, 68) + } else { + theme::INK_2 + }), + ) + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::NONE), + ) + .clicked() + { + actions.push(FrontendAction::MarketplaceToggleLike(index)); + } + }, + ); + }); + }) + .response + }) + .inner }); + ctx.data_mut(|data| { + data.insert_temp( + egui::Id::new("openless-marketplace-detail-card-rect"), + modal.inner.rect, + ); + }); +} - egui::Area::new(egui::Id::new("marketplace-detail-overlay")) +fn marketplace_oauth( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + body: egui::Rect, + actions: &mut Vec, + nested: bool, +) { + let lang = vm.lang; + // Keep the dialog's left edge stable while giving the right side a little + // more breathing room for the browser hint and the status row. + let size = egui::vec2((body.width() - 40.0).clamp(340.0, OAUTH_CARD_WIDTH), 330.0); + let card = + egui::Rect::from_center_size(body.center() + egui::vec2(OAUTH_CARD_SHIFT_X, 0.0), size); + #[cfg(test)] + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("openless-marketplace-oauth-card-rect"), card) + }); + egui::Area::new(egui::Id::new("openless-marketplace-oauth-modal")) .order(egui::Order::Tooltip) - .anchor(egui::Align2::CENTER_CENTER, body_center_offset) - .constrain_to(body_rect) + .fixed_pos(body.min) + .constrain(false) .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.set_min_size(body.size()); + if nested { + // Keep the already-open detail/mine card visible, like the + // stacked Tauri Modal, instead of replacing it with a second + // snapshot of the page. + ui.painter().rect_filled( + body, + layout::body_corner_radius(ctx), + egui::Color32::from_black_alpha(52), + ); + } else { + layout::paint_blurred_overlay(ctx, ui, body, layout::body_corner_radius(ctx)); + } + let _ = ui.allocate_rect(body, egui::Sense::click()); + ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| { + ui.set_clip_rect(body.intersect(ui.clip_rect())); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(16)) + .inner_margin(egui::Margin::same(22)) + .show(ui, |ui| { + ui.set_width((size.x - 44.0).max(1.0)); + ui.add_space(2.0); + ui.horizontal(|ui| { ui.label( - egui::RichText::new(format!("v{}", pack.version)) - .size(10.0) - .color(theme::ink_4()), + egui::RichText::new(tr_l10n(lang, "marketplace.oauth.title")) + .size(OAUTH_TITLE_SIZE) + .strong() + .color(theme::INK), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(OAUTH_CLOSE_SIZE, OAUTH_CLOSE_SIZE), + egui::Sense::hover(), + ); + let close = close_control(ui, rect, 16.0, 8); + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("openless-marketplace-oauth-close-rect"), + close.rect, + ) + }); + if close.clicked() { + actions.push(FrontendAction::MarketplaceAuthCancel); + } + }, ); }); - }); - 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.add_space(13.0); + if vm.marketplace_oauth_loading { + ui.vertical_centered(|ui| { + ui.spinner(); + ui.add_space(8.0); + ui.label(tr_l10n(lang, "marketplace.oauth.generating")); + }); + } else if let Some(error) = vm.marketplace_oauth_error.as_deref() { + ui.label(egui::RichText::new(error).size(12.0).color(theme::ERR)); + ui.add_space(14.0); + ui.horizontal(|ui| { + if ui + .button(tr_l10n(lang, "marketplace.oauth.retryBtn")) + .clicked() + { + actions.push(FrontendAction::MarketplaceAuthStart); + } + if ui + .button(tr_l10n(lang, "marketplace.oauth.cancelBtn")) + .clicked() + { + actions.push(FrontendAction::MarketplaceAuthCancel); + } + }); + } else { ui.label( - egui::RichText::new( - "本地占位预览\n将原始表达保留在上下文中,优化语气、结构和可读性。\n这段内容会由真实风格包提示词替换。", - ) + egui::RichText::new(openless_linux_egui::fmt_l10n( + lang, + "marketplace.oauth.browserHint", + &[&vm.marketplace_oauth_uri], + )) .size(12.0) - .color(theme::ink_2()) - .family(egui::FontFamily::Monospace), + .color(theme::INK_3), ); - }); - ui.add_space(14.0); - ui.horizontal(|ui| { - if ui - .add( - egui::Button::new( - egui::RichText::new(if liked { "★" } else { "☆" }), + ui.add_space(10.0); + // Tauri draws this as a full-width flex row with the + // code and its copy button centred (`justify-content: + // center`); an unbounded `egui::Frame` would instead + // hug the content and sit on the left edge. + let content_width = (size.x - 44.0).max(1.0); + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, |ui| { + ui.set_width((content_width - 24.0).max(1.0)); + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("openless-marketplace-oauth-code-box"), + ui.min_rect().expand2(egui::vec2(12.0, 10.0)), + ) + }); + ui.vertical_centered(|ui| { + let code = vm.marketplace_oauth_user_code.clone(); + let copy_label = tr_l10n(lang, "marketplace.oauth.copyBtn"); + let code_width = + layout::mono_text_width(ui, &code, CODE_TEXT_SIZE); + let copy_width = + layout::text_width(ui, copy_label, 11.0) + 20.0; + let (row, _) = ui.allocate_exact_size( + egui::vec2( + code_width + CODE_COPY_GAP + copy_width, + 30.0, + ), + egui::Sense::hover(), + ); + ui.painter().text( + egui::pos2(row.left(), row.center().y), + egui::Align2::LEFT_CENTER, + &code, + egui::FontId::monospace(CODE_TEXT_SIZE), + theme::BLUE, + ); + let copy_rect = egui::Rect::from_min_size( + egui::pos2( + row.left() + code_width + CODE_COPY_GAP, + row.center().y - 12.0, + ), + egui::vec2(copy_width, 24.0), + ); + let copy = ui.interact( + copy_rect, + ui.id().with("openless-oauth-copy-code"), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(copy_rect); + painter.rect_filled( + copy_rect, + egui::CornerRadius::same(7), + theme::SURFACE, + ); + painter.rect_stroke( + copy_rect, + egui::CornerRadius::same(7), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + painter.text( + copy_rect.center(), + egui::Align2::CENTER_CENTER, + copy_label, + egui::FontId::proportional(11.0), + theme::INK_2, + ); + if copy.clicked() { + actions.push(FrontendAction::MarketplaceAuthCopyCode); + } + }); + }); + ui.add_space(12.0); + ui.horizontal(|ui| { + let open = ui.add( + egui::Button::new( + egui::RichText::new(tr_l10n( + lang, + "marketplace.oauth.openBrowserBtn", + )) + .size(11.5) + .color(theme::INK_2), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(8)), + ); + if open.clicked() { + actions.push(FrontendAction::MarketplaceAuthOpenBrowser); + } + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + let cancel = ui.add( + egui::Button::new( + egui::RichText::new(tr_l10n( + lang, + "marketplace.oauth.cancelBtn", + )) + .size(11.5) + .color(theme::INK_2), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(8)), + ); + if cancel.clicked() { + actions.push(FrontendAction::MarketplaceAuthCancel); + } + }, + ); + }); + ui.add_space(11.0); + // Tauri centres the 「蓝色圆点 + 等待文案」 group inside the + // card (`justify-content: center`, 8px dot, 6px gap). + // `ui.horizontal` allocates the full width and would pin the + // group to the left edge, so the row is measured and + // allocated at its natural width; `vertical_centered` then + // centres that allocation on the card's axis. + ui.vertical_centered(|ui| { + let label = tr_l10n(lang, "marketplace.oauth.waiting"); + const DOT: f32 = 8.0; + const GAP: f32 = 6.0; + let width = DOT + GAP + layout::text_width(ui, label, 11.5); + let (row, _) = ui.allocate_exact_size( + egui::vec2(width, 16.0), + egui::Sense::hover(), + ); + ui.painter().circle_filled( + egui::pos2(row.left() + DOT / 2.0, row.center().y), + 3.5, + theme::BLUE, + ); + ui.painter().text( + egui::pos2(row.left() + DOT + GAP, row.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(11.5), + theme::INK_4, + ); + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("openless-marketplace-oauth-status-row"), + row, + ) + }); + }); + } + }); + }); + }); +} + +fn marketplace_upload( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + body: egui::Rect, + actions: &mut Vec, +) { + let lang = vm.lang; + let target_name = vm.marketplace_upload_target_name.clone(); + let card_width = (body.width() - 40.0).clamp(320.0, 560.0); + let card_height = (body.height() * 0.82).clamp(300.0, 560.0); + let card = egui::Rect::from_center_size(body.center(), egui::vec2(card_width, card_height)); + egui::Area::new(egui::Id::new("openless-marketplace-upload-modal")) + .order(egui::Order::Foreground) + .fixed_pos(body.min) + .constrain(false) + .show(ctx, |ui| { + ui.set_min_size(body.size()); + layout::paint_blurred_overlay(ctx, ui, body, layout::body_corner_radius(ctx)); + let _ = ui.allocate_rect(body, egui::Sense::click()); + ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| { + ui.set_clip_rect(body.intersect(ui.clip_rect())); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(16)) + .inner_margin(egui::Margin::same(22)) + .show(ui, |ui| { + ui.set_width((card_width - 44.0).max(1.0)); + ui.horizontal(|ui| { + let title = if let Some(name) = target_name.as_deref() { + openless_linux_egui::fmt_l10n( + lang, + "marketplace.upload.updateTitle", + &[&name], ) - .fill(theme::surface()) - .stroke(egui::Stroke::new(1.0, theme::line())) - .corner_radius(egui::CornerRadius::same(8)), - ) - .clicked() - { - actions.push(FrontendAction::MarketplaceToggleLike(index)); + } else { + tr_l10n(lang, "marketplace.upload.title").to_string() + }; + ui.label( + egui::RichText::new(title) + .size(16.0) + .strong() + .color(theme::INK), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui.button("×").clicked() { + actions.push(FrontendAction::MarketplaceUploadCancel); + } + }, + ); + }); + ui.add_space(6.0); + ui.label( + egui::RichText::new(if target_name.is_some() { + tr_l10n(lang, "marketplace.upload.updateHint") + } else { + tr_l10n(lang, "marketplace.upload.hint") + }) + .size(11.5) + .color(theme::INK_3), + ); + ui.add_space(12.0); + if vm.marketplace_upload_submitting { + ui.vertical_centered(|ui| { + ui.add_space(42.0); + ui.spinner(); + ui.add_space(8.0); + ui.label(tr_l10n(lang, "marketplace.upload.submitting")); + }); + } else if vm.marketplace_upload_packs.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(28.0); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "marketplace.upload.noLocal", + )) + .size(12.0) + .color(theme::INK_4), + ); + }); + } else { + egui::ScrollArea::vertical() + .max_height((card_height - 150.0).max(100.0)) + .show(ui, |ui| { + for (index, pack) in + vm.marketplace_upload_packs.iter().enumerate() + { + let selected = + vm.marketplace_upload_selected == Some(index); + let label = format!( + "{} · v{} · {}", + pack.name, + pack.version, + pack.base_mode.display_name(), + ); + let response = ui.add_sized( + [ui.available_width(), 46.0], + egui::Button::new( + egui::RichText::new(label) + .size(12.0) + .color(theme::INK), + ) + .fill(if selected { + theme::BLUE_SOFT + } else { + theme::SURFACE + }) + .stroke(egui::Stroke::new( + 0.5, + if selected { + theme::BLUE + } else { + theme::LINE_STRONG + }, + )) + .corner_radius(egui::CornerRadius::same(9)), + ); + if response.clicked() { + actions.push(FrontendAction::MarketplaceUploadSelect( + index, + )); + } + if !pack.description.is_empty() { + ui.label( + egui::RichText::new(&pack.description) + .size(10.5) + .color(theme::INK_4), + ); + } + ui.add_space(6.0); + } + }); } - ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if ui - .add( - egui::Button::new("安装到本地") - .fill(theme::blue()) + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + let confirm = ui.add_enabled( + vm.marketplace_upload_selected.is_some() + && !vm.marketplace_upload_submitting, + egui::Button::new(tr_l10n( + lang, + "marketplace.upload.confirm", + )) + .fill(theme::BLUE) .stroke(egui::Stroke::NONE) .corner_radius(egui::CornerRadius::same(8)), - ) - .clicked() - { - actions.push(FrontendAction::MarketplaceInstall(index)); - actions.push(FrontendAction::MarketplaceCloseDetail); - } + ); + if confirm.clicked() { + actions.push(FrontendAction::MarketplaceUploadConfirm); + } + if ui.button(tr_l10n(lang, "common.cancel")).clicked() { + actions.push(FrontendAction::MarketplaceUploadCancel); + } + }, + ); + }); + }); + }); + }); +} + +fn marketplace_withdraw_confirm( + ctx: &egui::Context, + vm: &FrontendViewModel, + body: egui::Rect, + actions: &mut Vec, +) { + let lang = vm.lang; + let Some(index) = vm.marketplace_confirm_withdraw else { + return; + }; + let Some(pack) = vm.marketplace_mine_packs.get(index) else { + return; + }; + let message = + openless_linux_egui::fmt_l10n(lang, "marketplace.withdraw.confirm", &[&pack.pack.name]); + let card = + egui::Rect::from_center_size(body.center(), egui::vec2(body.width().min(420.0), 170.0)); + egui::Area::new(egui::Id::new("openless-marketplace-withdraw-confirm")) + .order(egui::Order::Tooltip) + .fixed_pos(body.min) + .constrain(false) + .show(ctx, |ui| { + ui.set_min_size(body.size()); + layout::paint_blurred_overlay(ctx, ui, body, layout::body_corner_radius(ctx)); + let _ = ui.allocate_rect(body, egui::Sense::click()); + ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| { + ui.set_clip_rect(body.intersect(ui.clip_rect())); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(20)) + .show(ui, |ui| { + ui.label(egui::RichText::new(message).size(13.0).color(theme::INK_2)); + ui.add_space(20.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui - .add( - egui::Button::new("取消") - .fill(theme::surface()) - .stroke(egui::Stroke::new(1.0, theme::line())) - .corner_radius(egui::CornerRadius::same(8)), - ) + .button(tr_l10n(lang, "marketplace.withdraw.confirmBtn")) .clicked() { - actions.push(FrontendAction::MarketplaceCloseDetail); + actions.push(FrontendAction::MarketplaceWithdrawConfirm); + } + if ui.button(tr_l10n(lang, "common.cancel")).clicked() { + actions.push(FrontendAction::MarketplaceWithdrawCancel); } }); }); - }); + }); + }); +} + +fn market_state_label(lang: Lang, state: &str) -> String { + let key = match state { + "pending" => "marketplace.state.pending", + "approved" => "marketplace.state.approved", + "rejected" => "marketplace.state.rejected", + "withdrawn" => "marketplace.state.withdrawn", + "superseded" => "marketplace.state.superseded", + _ => "marketplace.state.unknown", + }; + if state.is_empty() + || !matches!( + state, + "pending" | "approved" | "rejected" | "withdrawn" | "superseded" + ) + { + if state.is_empty() { + return tr_l10n(lang, key).to_string(); + } + return state.to_string(); + } + tr_l10n(lang, key).to_string() +} + +fn marketplace_mine( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + body: egui::Rect, + actions: &mut Vec, +) { + let lang = vm.lang; + let signed_in = vm.marketplace_signed_in; + let pack_count = vm + .marketplace_mine_packs + .iter() + .filter(|entry| !matches!(entry.state.as_str(), "withdrawn" | "rejected")) + .count(); + let pending_count = vm + .marketplace_mine_packs + .iter() + .filter(|entry| entry.state == "pending") + .count(); + let modal_width = (body.width() - 40.0).clamp(320.0, 560.0); + let natural_height = if signed_in && pack_count > 0 { + 280.0 + pack_count as f32 * 144.0 + } else if signed_in { + 330.0 + } else { + 300.0 + }; + let modal_height = natural_height.min((body.height() * 0.85).max(260.0)); + let size = egui::vec2(modal_width, modal_height); + let card = egui::Rect::from_center_size(body.center(), size); + #[cfg(test)] + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("openless-marketplace-mine-card-rect"), card) + }); + egui::Area::new(egui::Id::new("openless-marketplace-mine-modal")) + .order(egui::Order::Foreground) + .fixed_pos(body.min) + .constrain(false) + .show(ctx, |ui| { + ui.set_min_size(body.size()); + layout::paint_blurred_overlay(ctx, ui, body, layout::body_corner_radius(ctx)); + let _ = ui.allocate_rect(body, egui::Sense::click()); + ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| { + ui.set_clip_rect(body.intersect(ui.clip_rect())); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(16)) + .inner_margin(egui::Margin::same(22)) + .show(ui, |ui| { + ui.set_width((size.x - 44.0).max(1.0)); + ui.set_min_height((size.y - 44.0).max(1.0)); + + // Tauri: search, identity chip and close are one compact + // header row rather than a title on its own line. + let row_width = ui.available_width(); + let close_width = 30.0; + let login_label = if signed_in && !vm.marketplace_login.is_empty() { + format!("@{}", vm.marketplace_login) + } else if signed_in { + tr_l10n(lang, "marketplace.modal.loggedInLabel").to_string() + } else { + tr_l10n(lang, "marketplace.oauth.loginBtn").to_string() + }; + let login_avatar = vm + .marketplace_login + .chars() + .next() + .map(|ch| ch.to_uppercase().to_string()) + .unwrap_or_else(|| "?".to_string()); + let login_width = (layout::text_width(ui, &login_label, 12.0) + 42.0) + .clamp(64.0, 132.0); + let search_width = + (row_width - close_width - login_width - 20.0).max(120.0); + ui.horizontal(|ui| { + ui.allocate_ui(egui::vec2(search_width, 30.0), |ui| { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(8, 5)) + .show(ui, |ui| { + ui.set_width((search_width - 16.0).max(1.0)); + ui.horizontal(|ui| { + let (icon_rect, _) = ui.allocate_exact_size( + egui::vec2(16.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.3, theme::INK_3); + ui.painter().circle_stroke( + icon_center, + 5.0, + icon_stroke, + ); + ui.painter().line_segment( + [ + icon_center + egui::vec2(3.5, 3.5), + icon_center + egui::vec2(7.0, 7.0), + ], + icon_stroke, + ); + ui.add( + egui::TextEdit::singleline( + &mut vm.marketplace_mine_query, + ) + .hint_text(tr_l10n( + lang, + "marketplace.myPacks.searchPlaceholder", + )) + .frame(egui::Frame::NONE) + .desired_width((search_width - 40.0).max(48.0)), + ); + }); + }); + }); + ui.add_space(6.0); + let (login_rect, login_response) = ui.allocate_exact_size( + egui::vec2(login_width, 30.0), + egui::Sense::click(), + ); + let login_painter = ui.painter().with_clip_rect(login_rect); + login_painter.rect_filled( + login_rect, + egui::CornerRadius::same(9), + if signed_in { + theme::BLUE_SOFT + } else { + theme::SURFACE + }, + ); + login_painter.rect_stroke( + login_rect, + egui::CornerRadius::same(9), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + let login_badge = egui::pos2( + login_rect.left() + 19.0, + login_rect.center().y, + ); + login_painter.circle_filled( + login_badge, + 9.0, + if signed_in { + egui::Color32::from_rgba_premultiplied(37, 99, 235, 28) + } else { + theme::SURFACE_2 + }, + ); + login_painter.text( + login_badge, + egui::Align2::CENTER_CENTER, + &login_avatar, + egui::FontId::proportional(10.0), + if signed_in { theme::BLUE } else { theme::INK_2 }, + ); + login_painter.text( + egui::pos2(login_badge.x + 17.0, login_rect.center().y), + egui::Align2::LEFT_CENTER, + &login_label, + egui::FontId::proportional(12.0), + if signed_in { theme::BLUE } else { theme::INK_2 }, + ); + if login_response.clicked() { + actions.push(FrontendAction::MarketplaceAuthStart); + } + ui.add_space(6.0); + let (close_rect, _) = ui.allocate_exact_size( + egui::vec2(close_width, 30.0), + egui::Sense::hover(), + ); + let close = close_control(ui, close_rect, 18.0, 9); + if close.clicked() { + actions.push(FrontendAction::MarketplaceCloseMine); + } + }); + ui.add_space(12.0); + + ui.horizontal(|ui| { + let summary = if signed_in { + let key = if pending_count > 0 { + "marketplace.myPacks.summaryPending" + } else { + "marketplace.myPacks.summary" + }; + if pending_count > 0 { + openless_linux_egui::fmt_l10n( + lang, + key, + &[&pack_count, &pending_count], + ) + } else { + openless_linux_egui::fmt_l10n(lang, key, &[&pack_count]) + } + } else { + tr_l10n(lang, "marketplace.myPacks.notLoggedIn").to_string() + }; + ui.label(egui::RichText::new(summary).size(11.5).color(theme::INK_3)); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + let upload_enabled = + signed_in && !vm.marketplace_upload_submitting; + let upload = ui.add_enabled( + upload_enabled, + egui::Button::new( + egui::RichText::new(tr_l10n( + lang, + "marketplace.upload_btn", + )) + .size(12.0) + .color(if upload_enabled { + egui::Color32::WHITE + } else { + theme::INK_4 + }), + ) + .fill(if upload_enabled { + theme::BLUE + } else { + theme::SURFACE_2 + }) + .stroke(egui::Stroke::new( + 0.5, + if upload_enabled { + theme::BLUE + } else { + theme::LINE_STRONG + }, + )) + .corner_radius(egui::CornerRadius::same(8)), + ); + if upload.clicked() { + actions.push(FrontendAction::MarketplaceUploadOpen { + origin_pack_id: None, + target_name: None, + }); + } + let refresh_enabled = signed_in && !vm.marketplace_mine_loading; + let refresh = ui.add_enabled( + refresh_enabled, + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "common.refresh")) + .size(12.0) + .color(if refresh_enabled { + theme::INK_2 + } else { + theme::INK_4 + }), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(8)), + ); + if refresh.clicked() { + actions.push(FrontendAction::MarketplaceMyPacks); + } + }, + ); + }); + ui.add_space(12.0); + + if vm.marketplace_mine_loading { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "marketplace.myPacks.loadingTitle", + )) + .size(13.0) + .color(theme::INK_3), + ); + ui.add_space(6.0); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "marketplace.myPacks.loadingHint", + )) + .size(11.5) + .color(theme::INK_4), + ); + }); + } else if let Some(error) = vm.marketplace_notice.as_deref() { + ui.vertical_centered(|ui| { + ui.add_space(16.0); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "marketplace.myPacks.loadErrorTitle", + )) + .size(13.0) + .color(theme::ERR), + ); + ui.add_space(6.0); + ui.label(egui::RichText::new(error).size(11.5).color(theme::INK_4)); + ui.add_space(10.0); + if ui + .button(tr_l10n(lang, "marketplace.myPacks.loadErrorRetry")) + .clicked() + { + actions.push(FrontendAction::MarketplaceMyPacks); + } + }); + } else if !signed_in { + ui.vertical_centered(|ui| { + ui.add_space(18.0); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "marketplace.myPacks.notLoggedIn", + )) + .size(13.0) + .color(theme::INK_3), + ); + }); + } else { + let query = vm.marketplace_mine_query.trim().to_lowercase(); + let visible: Vec<_> = vm + .marketplace_mine_packs + .iter() + .filter(|entry| { + if matches!(entry.state.as_str(), "withdrawn" | "superseded") { + return false; + } + query.is_empty() + || entry.pack.name.to_lowercase().contains(&query) + || entry.pack.description.to_lowercase().contains(&query) + || entry + .pack + .tags + .iter() + .any(|tag| tag.to_lowercase().contains(&query)) + }) + .collect(); + if visible.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(20.0); + let empty_key = if query.is_empty() { + "marketplace.myPacks.emptyTitle" + } else { + "marketplace.myPacks.noMatch" + }; + ui.label( + egui::RichText::new(tr_l10n(lang, empty_key)) + .size(13.0) + .color(theme::INK_3), + ); + if query.is_empty() { + ui.add_space(6.0); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "marketplace.myPacks.emptyHint", + )) + .size(11.5) + .color(theme::INK_4), + ); + } + }); + } else { + egui::ScrollArea::vertical() + .max_height((size.y - 150.0).max(80.0)) + .show(ui, |ui| { + for entry in visible { + let pack = &entry.pack; + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::same(14)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(&pack.name) + .size(14.0) + .strong() + .color(theme::INK), + ); + ui.label( + egui::RichText::new( + openless_linux_egui::fmt_l10n( + lang, + "marketplace.myPacks.versionDate", + &[&pack.version, &entry.updated_at], + ), + ) + .size(11.0) + .color(theme::INK_4), + ); + }); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Min), + |ui| { + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .stroke(egui::Stroke::new( + 0.5, + theme::LINE_STRONG, + )) + .corner_radius(egui::CornerRadius::same(7)) + .inner_margin(egui::Margin::symmetric(7, 3)) + .show(ui, |ui| { + ui.label( + egui::RichText::new( + market_state_label( + lang, + &entry.state, + ), + ) + .size(11.0) + .color(theme::INK_3), + ); + }); + }, + ); + }); + if !pack.description.is_empty() { + ui.add_space(4.0); + ui.label( + egui::RichText::new(&pack.description) + .size(12.0) + .color(theme::INK_3), + ); + } + ui.horizontal_wrapped(|ui| { + ui.label( + egui::RichText::new(&pack.mode) + .size(11.0) + .color(theme::INK_4), + ); + for tag in pack.tags.iter().take(3) { + ui.label( + egui::RichText::new(tag) + .size(11.0) + .color(theme::INK_4), + ); + } + }); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new( + openless_linux_egui::fmt_l10n( + lang, + "marketplace.myPacks.stats", + &[&pack.likes, &pack.downloads], + ), + ) + .size(11.0) + .color(theme::INK_4), + ); + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + if ui + .button(tr_l10n( + lang, + "marketplace.myPacks.actions.withdraw", + )) + .clicked() + { + if let Some(index) = vm + .marketplace_mine_packs + .iter() + .position(|candidate| { + candidate.pack.id == pack.id + }) + { + actions.push( + FrontendAction::MarketplaceWithdrawRequest( + index, + ), + ); + } + } + if ui + .button(tr_l10n( + lang, + "marketplace.myPacks.actions.update", + )) + .clicked() + { + actions.push( + FrontendAction::MarketplaceUploadOpen { + origin_pack_id: Some( + pack.id.clone(), + ), + target_name: Some( + pack.name.clone(), + ), + }, + ); + } + }, + ); + }); + }); + ui.add_space(10.0); + } + }); + } + } + }); + }); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn my_packs_avatar_is_inside_the_button_before_the_label() { + let ctx = egui::Context::default(); + let button = egui::Rect::from_min_size(egui::pos2(50.0, 60.0), egui::vec2(150.0, 30.0)); + let _ = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(500.0, 300.0), + )), + ..Default::default() + }, + |ui| { + my_packs_button(ui, button, "Mine"); + }, + ); + let (outer, avatar, text): (egui::Rect, egui::Rect, egui::Pos2) = ctx.data(|data| { + data.get_temp(egui::Id::new("openless-mine-button-test-rects")) + .unwrap() }); + assert!(outer.contains_rect(avatar)); + assert_eq!(avatar.size(), egui::vec2(18.0, 18.0)); + assert_eq!(text.x - avatar.right(), 8.0); + assert!(outer.right() > text.x); + } + + /// The GitHub device-flow dialog must keep Tauri's left edge, extend to the + /// right, use the 16px heading and centre the status row on the card axis. + #[test] + fn oauth_dialog_widens_right_and_centres_the_status_row() { + use super::super::view_model::Page; + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: Lang::ZhCn, + active_page: Page::Marketplace, + marketplace_loading: false, + marketplace_unsupported: false, + marketplace_oauth_open: true, + marketplace_oauth_user_code: "68A0-0EB0".into(), + marketplace_oauth_uri: "https://github.com/login/device".into(), + ..Default::default() + }; + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1240.0, 800.0)); + // Two frames: the modal `Area` needs one frame to publish its rect. + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(screen), + ..Default::default() + }); + crate::ui::frontend::render(&ctx, &mut vm, &mut Vec::new()); + let _ = crate::ui::frontend::end_pass(&ctx); + } + let card: egui::Rect = ctx + .data(|data| data.get_temp(egui::Id::new("openless-marketplace-oauth-card-rect"))) + .expect("the open dialog must publish its card rect"); + let body = layout::body_rect(&ctx); + assert_eq!(card.width(), OAUTH_CARD_WIDTH); + assert_eq!(card.center().x - body.center().x, OAUTH_CARD_SHIFT_X); + assert_eq!(card.top(), body.center().y - 165.0); + assert!(card.right() <= body.right(), "{card:?} in {body:?}"); + + let row: egui::Rect = ctx + .data(|data| data.get_temp(egui::Id::new("openless-marketplace-oauth-status-row"))) + .expect("the pending phase must paint the status row"); + assert!( + (row.center().x - card.center().x).abs() <= 2.0, + "the status row must sit on the card's axis: {row:?} in {card:?}" + ); + assert!(row.left() > card.left() + 22.0, "{row:?} in {card:?}"); + assert_eq!(OAUTH_TITLE_SIZE, 16.0); + + // The code tile spans the whole card (Tauri renders it as a full-width + // flex row) instead of hugging the code text on the left edge. + let code_box: egui::Rect = ctx + .data(|data| data.get_temp(egui::Id::new("openless-marketplace-oauth-code-box"))) + .expect("the pending phase must paint the code tile"); + assert_eq!(code_box.width(), card.width() - 44.0); + assert!( + (code_box.center().x - card.center().x).abs() <= 1.5, + "the code tile must be centred: {code_box:?} in {card:?}" + ); + + // The × is painted into a fixed 28×28 rect: a stock `egui::Button` would + // take its size from the font metrics and from egui's per-state padding. + let close: egui::Rect = ctx + .data(|data| data.get_temp(egui::Id::new("openless-marketplace-oauth-close-rect"))) + .expect("the dialog must publish its close control"); + assert_eq!(close.size(), egui::vec2(28.0, 28.0)); + } } diff --git a/openless-all/app/linux-egui/src/ui/frontend/mod.rs b/openless-all/app/linux-egui/src/ui/frontend/mod.rs index ee4cc67ab..88a17c578 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -1,8 +1,19 @@ +pub mod corrections; +pub mod format; +pub mod history; pub mod icons; pub mod layout; pub mod marketplace; +pub mod overview; pub mod pages; +pub mod popups; +pub mod selection_ask; +pub mod settings; +pub mod siri_gl; +pub mod style; +pub mod translation; pub mod view_model; +pub mod vocab; use eframe::egui; use view_model::{FrontendAction, FrontendViewModel, Page}; @@ -35,189 +46,3368 @@ pub fn render(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Vec 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); + // The Overview dashboard is a single-screen fixed page: it fills the + // height the shell gives it and manages its own internal scrolling, so + // it must not be wrapped in the shared page scroll area. Same for the + // style page (full-height card) and history (two independent columns). + // + // These are *branching* arms rather than early returns: the settings + // overlay below has to be painted on every page, and an early return + // used to skip it (设置按钮在概览/风格/历史页点了没反应). + match vm.active_page { + Page::Overview => overview::page(ui, vm, actions), + Page::Style => style::page(ui, vm, actions), + Page::History => history::page(ui, vm, actions, false), + Page::QuickNote => history::page(ui, vm, actions, true), + Page::Vocab => vocab::page(ui, vm, actions), + page => { + // Bound the viewport to the current window, not the previous + // frame's content size: after shrinking a large window the + // translation guide must remain reachable by scrolling. + let scroll_output = egui::ScrollArea::vertical() + // Each page owns its own scroll position. Sharing one id + // with other pages let a prior tab leave Translation at an + // unexpected offset after switching or resizing. + .id_salt(("openless-main-scroll", page)) + .max_height( + (body.height() - layout::PAGE_TOP_PADDING - layout::PAGE_BOTTOM_PADDING) + .max(1.0), + ) + .auto_shrink([false, false]) + .show(ui, |ui| { + match page { + Page::Marketplace => { + marketplace::marketplace_page(ui, vm, actions, body); + } + Page::SelectionAsk => { + selection_ask::page(ui, vm, actions); + } + Page::Translation => { + translation::page(ui, vm, actions); + } + Page::Corrections => { + corrections::page(ui, vm, actions); + } + Page::Overview + | Page::History + | Page::QuickNote + | Page::Style + | Page::Vocab + | Page::Settings => { + // Handled above or via overlay. + } + } + ui.add_space(32.0); + }); + #[cfg(test)] + ctx.data_mut(|data| { + data.insert_temp( + egui::Id::new("main-scroll-measure"), + ( + scroll_output.content_size.y, + scroll_output.inner_rect.height(), + scroll_output.state.offset.y, + ), + ) + }); + #[cfg(not(test))] + let _ = scroll_output; + } + } + + // Settings overlay (rendered on top of everything). + if vm.settings_open { + settings::settings_overlay(ctx, vm, actions, body); + } else if vm.settings_saving || vm.settings_save_error.is_some() { + egui::Area::new(egui::Id::new("preference-save-state")) + .order(egui::Order::Foreground) + .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-20.0, 52.0)) + .show(ctx, |ui| { + egui::Frame::popup(ui.style()).show(ui, |ui| { + ui.set_max_width(420.0); + settings::save_state(ui, vm, actions); + }); + }); + } + }); +} + +/// egui 0.36 起 `FullOutput` 里的 `TexturesDelta` 必须被消费,未应用就 drop 会 +/// panic(epaint 的 Drop 守卫:"Dropped TexturesDelta with N unapplied deltas")。 +/// 生产路径由渲染器消费(`popup_layer.rs` 交给 painter / eframe 自己处理), +/// 无头测试里没有渲染器,这里清掉即可。 +#[cfg(test)] +pub(crate) fn run_pass( + ctx: &egui::Context, + input: egui::RawInput, + body: impl FnMut(&mut egui::Ui), +) -> egui::FullOutput { + let mut output = ctx.run_ui(input, body); + output.textures_delta.clear(); + output +} + +/// 同上,用于 `begin_pass` / `end_pass` 形态的测试。 +#[cfg(test)] +pub(crate) fn end_pass(ctx: &egui::Context) -> egui::FullOutput { + let mut output = ctx.end_pass(); + output.textures_delta.clear(); + output +} + +#[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 _ = crate::ui::frontend::end_pass(ctx); + actions + } + + #[test] + fn failed_preference_saves_offer_retry_without_opening_settings() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::En, + active_page: Page::Translation, + settings_save_error: Some("fixture registration failed".into()), + ..Default::default() + }; + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let _ = end_pass(&ctx); + } + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let text = painted_text(&end_pass(&ctx)); + for expected in ["Unsaved", "Retry", "fixture registration failed"] { + assert!(text.contains(expected), "{text}"); + } + } + + #[test] + fn overview_page_renders_populated_dashboard_without_panicking() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + overview_loading: false, + ..Default::default() + }; + vm.settings.activity_heatmap = true; + let heatmap = (0..365) + .map(|index| super::view_model::OverviewHeatmapDay { + date: format!("2026-{:02}-{:02}", index / 31 + 1, index % 31 + 1), + count: (index % 4) as u32, + }) + .collect(); + let activity_daily = (0..30) + .map(|index| super::view_model::OverviewActivityDay { + date: format!("2026-01-{:02}", index + 1), + count: index as u32, + chars: (index * 12) as u64, + duration_ms: (index * 900) as u64, + }) + .collect(); + vm.overview = Some(super::view_model::OverviewSummary { + asr_provider: "volcengine".into(), + llm_provider: "ark".into(), + asr_configured: true, + llm_configured: true, + chars_today: 1234, + segments_today: 7, + duration_ms_today: 45_000, + avg_latency_ms: 6_400, + history_total: 9, + recent: (0..5) + .map(|index| super::view_model::OverviewRecentEntry { + created_at: "2026-01-15T12:34:00+00:00".into(), + final_text: format!("recent item {index}"), + raw_transcript: "raw transcript".into(), + mode: super::view_model::OverviewMode::Raw, + duration_ms: Some(3_100), + }) + .collect(), + activity_daily, + heatmap_year: 2026, + heatmap, + }); + + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + } + + // One more pass whose painted text we inspect: this is the end-to-end + // check that the localized dashboard chrome actually reaches the painter. + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + let painted = painted_text(&output); + // Expected labels are read back from the catalog so the test cannot + // drift from the keys the page actually uses (and stays free of raw + // CJK literals, as the localization contract requires). + for key in [ + "overview.title", + "overview.refresh", + "overview.stats_title", + "overview.metric_chars", + "overview.metric_duration", + "overview.metric_avg", + "overview.metric_total", + "overview.period_last7", + "overview.period_last30", + "overview.metric_count", + "overview.metric_chars_name", + "overview.metric_duration_name", + "overview.recent_title", + "overview.recent_all", + "overview.activity_title", + "overview.mode_raw", + "nav.overview", + "nav.history", + "nav.vocab", + "nav.group_style", + "nav.group_tools", + "nav.translation", + "nav.selection_ask", + "nav.corrections", + "nav.settings", + ] { + let expected = openless_linux_egui::tr_l10n(openless_linux_egui::Lang::ZhCn, key); + assert!( + painted.contains(expected), + "expected the overview dashboard to paint {key} ({expected:?})" + ); + } + let heatmap: egui::Rect = ctx.data(|data| { + data.get_temp(egui::Id::new("openless-overview-heatmap-rect")) + .expect("populated overview should show the heatmap") + }); + let content_bottom = layout::body_rect(&ctx).bottom() - layout::PAGE_BOTTOM_PADDING; + assert!(heatmap.bottom() <= content_bottom + 0.5, + "heatmap must not be obscured by the white bottom gutter: {heatmap:?}, content bottom={content_bottom}"); + + // With a pending provider the cards need more than the old fixed 600px + // cutoff. The yearly activity must remain reachable at this height. + vm.overview.as_mut().unwrap().asr_configured = false; + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(960.0, 800.0), + )), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let _ = end_pass(&ctx); + let (content, visible, _): (f32, f32, f32) = ctx.data(|data| { + data.get_temp(egui::Id::new("overview-scroll-measure")) + .expect("provider cards plus heatmap must enable page scrolling") + }); + assert!( + content > visible, + "overview did not offer scrolling: {content}/{visible}" + ); + } + + fn painted_text(output: &egui::FullOutput) -> String { + fn collect(shape: &egui::Shape, out: &mut String) { + match shape { + egui::Shape::Text(text) => { + out.push_str(text.galley.text()); + out.push('\n'); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, out); } - Page::Corrections => pages::corrections_page(ui, vm, actions), - Page::History | Page::Style | Page::Settings => { - // Handled above or via overlay. + } + _ => {} + } + } + let mut out = String::new(); + for clipped in &output.shapes { + collect(&clipped.shape, &mut out); + } + out + } + + #[test] + fn history_page_renders_populated_state_without_panicking() { + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::History, + history_loading: false, + ..Default::default() + }; + vm.history_entries = vec![ + super::view_model::HistoryEntry { + id: "a".into(), + quick_note: false, + error_code: None, + created_at: "2026-01-15T12:34:00+00:00".into(), + mode: super::view_model::OverviewMode::Raw, + style_label: "raw".into(), + raw_transcript: "raw transcript of the first entry".into(), + final_text: String::new(), + duration_ms: Some(3_100), + has_audio: true, + asr_provider: Some("zhipu".into()), + asr_model: Some("glm-asr-2512".into()), + asr_ms: Some(465), + llm_provider: None, + app_name: Some("OpenLess".into()), + dictionary_count: Some(2), + }, + super::view_model::HistoryEntry { + id: "b".into(), + created_at: "2026-01-15T11:00:00+00:00".into(), + mode: super::view_model::OverviewMode::Light, + style_label: "light".into(), + raw_transcript: "second raw".into(), + final_text: "second polished text".into(), + duration_ms: Some(2_400), + has_audio: false, + ..Default::default() + }, + ]; + + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + } + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + let painted = painted_text(&output); + let tr = |key: &'static str| openless_linux_egui::tr_l10n(zh, key); + for key in [ + "history.title", + "history.desc", + "common.refresh", + "common.clear", + "history.step_asr", + // 润色信息现在挂在润色卡片胶囊上(「润色 · 风格」)。 + "history.step_polish", + // 默认只看润色结果:原文靠这个开关展开。 + "history.show_raw", + "common.copy", + ] { + let expected = tr(key); + assert!( + painted.contains(expected), + "expected the history page to paint {key} ({expected:?})" + ); + } + // 菜单默认收起:导出/重新转录/删除都在「…」浮层里,不该直接出现在页面上。 + for key in [ + "history.play", + "history.export", + "history.retranscribe", + "common.delete", + ] { + let unexpected = tr(key); + assert!( + !painted.contains(unexpected), + "the collapsed action menu must not paint {key} ({unexpected:?})" + ); + } + // 步骤区按上游只保留识别/润色两步,插入行(字数/热词)已经不在详情区。 + assert!( + !painted.contains(tr("history.step_insert")), + "the insert step row must be gone" + ); + let placeholder = + openless_linux_egui::fmt_l10n(zh, "history.search_placeholder", &[&"Ctrl+K"]); + assert!(painted.contains(&placeholder), "expected {placeholder:?}"); + // The detail panel shows the ASR step and its millisecond timing. + let ms = openless_linux_egui::fmt_l10n(zh, "dur.ms", &[&465]); + assert!(painted.contains(&ms), "expected {ms:?}"); + + // Confirm dialog renders on top when a destructive action is pending. + vm.history_confirm = Some(super::view_model::HistoryConfirm::Clear); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + let painted = painted_text(&output); + let confirm_msg = openless_linux_egui::fmt_l10n( + zh, + "history.confirm_clear", + &[&vm.history_entries.len()], + ); + assert!(painted.contains(&confirm_msg), "expected {confirm_msg:?}"); + assert!(painted.contains(tr("common.cancel"))); + assert!(painted.contains(tr("common.confirm"))); + } + + #[test] + fn text_inputs_keep_and_show_what_the_user_types() { + // Two separate regressions live here: + // * the marketplace search bound a local clone, so the host never wrote + // the field back and every keystroke vanished on the next frame; + // * the settings text rows were re-hydrated from preferences every + // frame, so editing them snapped back to the stored value. + let zh = openless_linux_egui::Lang::ZhCn; + + // 1) marketplace search + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Marketplace, + ..Default::default() + }; + let id = egui::Id::new("openless-marketplace-search"); + // warm up: egui needs a frame before the widget exists / accepts focus + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + } + for step in ["a", "b", "c"] { + ctx.memory_mut(|m| m.request_focus(id)); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::Text(step.into())], + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + } + assert_eq!( + vm.marketplace_query, "abc", + "the marketplace search field must keep typed characters" + ); + + // 2) settings text row (历史条数上限 lives in 权限与数据 → 数据存储) + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Privacy, + ..Default::default() + }; + let label = + openless_linux_egui::tr_l10n(zh, "settings.recording.history_max_entries_label"); + let id = egui::Id::new(("openless-settings-text", label)); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + } + let mut painted = String::new(); + for step in ["7", "7"] { + ctx.memory_mut(|m| m.request_focus(id)); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::Text(step.into())], + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + } + assert_eq!( + vm.settings.history_max_entries, "77", + "settings text rows must keep typed characters" + ); + assert!( + painted.contains("77"), + "the typed value must actually be painted" + ); + + // 3) 添加渠道表单里的名称输入框(AI 服务与模型 → 语音识别) + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Services, + services_view: 1, + channel_form_open: true, + ..Default::default() + }; + let id = egui::Id::new("openless-settings-channel-name"); + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + } + for step in ["m", "y"] { + ctx.memory_mut(|m| m.request_focus(id)); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::Text(step.into())], + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + } + assert_eq!( + vm.channel_form_name, "my", + "the add-channel form must accept typed characters" + ); + assert!( + painted.contains("my"), + "the add-channel form must paint what was typed" + ); + } + + #[test] + fn settings_overlay_opens_from_every_page() { + // Regression: Overview / Style / History returned early from `render`, so + // the settings overlay at the end of the function never ran and the + // 设置 button did nothing on those pages. + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let rail_general = openless_linux_egui::tr_l10n(zh, "modal.sections.general"); + for page in [Page::Overview, Page::Style, Page::History, Page::Vocab] { + let mut vm = FrontendViewModel { + lang: zh, + active_page: page, + settings_open: true, + ..Default::default() + }; + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + } + assert!( + painted.contains(rail_general), + "the settings overlay must render on {page:?} too" + ); + } + } + + #[test] + fn settings_overlay_lists_every_section() { + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + for section in [ + super::view_model::SettingsSection::General, + super::view_model::SettingsSection::Shortcuts, + super::view_model::SettingsSection::Appearance, + super::view_model::SettingsSection::Services, + super::view_model::SettingsSection::Privacy, + super::view_model::SettingsSection::Advanced, + super::view_model::SettingsSection::About, + ] { + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: section, + ..Default::default() + }; + // The shortcuts section renders key caps for the live bindings. + vm.dictation_hotkey = "Ctrl+Shift+Z".to_string(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + } + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + let painted = painted_text(&output); + for key in [ + "modal.sections.general", + "modal.sections.shortcuts", + "modal.sections.appearance", + "modal.sections.services", + "modal.sections.privacy", + "modal.sections.advanced", + "modal.sections.about", + ] { + let expected = openless_linux_egui::tr_l10n(zh, key); + assert!( + painted.contains(expected), + "settings rail must paint {key} ({expected:?})" + ); + } + // Section blurb under the title, mirroring the Tauri modal. + let desc_key = match section { + super::view_model::SettingsSection::General => "modal.descriptions.general", + super::view_model::SettingsSection::Shortcuts => "modal.descriptions.shortcuts", + super::view_model::SettingsSection::Services => "modal.descriptions.services", + super::view_model::SettingsSection::Appearance => "modal.descriptions.appearance", + super::view_model::SettingsSection::Privacy => "modal.descriptions.privacy", + super::view_model::SettingsSection::Advanced => "modal.descriptions.advanced", + super::view_model::SettingsSection::About => "modal.descriptions.about", + }; + let desc = openless_linux_egui::tr_l10n(zh, desc_key); + assert!( + painted.contains(desc), + "settings section blurb must paint {desc_key} ({desc:?})" + ); + if section == super::view_model::SettingsSection::Shortcuts { + // Key caps: one painted chip per key in the binding. + for cap in ["Ctrl", "Shift", "Z"] { + assert!( + painted.contains(cap), + "shortcut rows must paint the {cap} key cap" + ); + } + } + } + } + + #[test] + fn sidebar_settings_row_stays_reachable_in_a_short_window() { + // Regression: the sidebar painted against `ui.max_rect()` (the whole + // screen), so the rounded bottom-left corner and the pinned settings row + // both landed off-window in a short window. + let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 520.0)); + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + ..Default::default() + }; + let mut painted: Vec<(String, egui::Rect)> = Vec::new(); + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(small), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + painted.clear(); + for clipped in &output.shapes { + if let egui::Shape::Text(text) = &clipped.shape { + painted.push(( + text.galley.text().to_string(), + text.visual_bounding_rect().intersect(clipped.clip_rect), + )); + } + } + } + let settings = + openless_linux_egui::tr_l10n(openless_linux_egui::Lang::ZhCn, "nav.settings"); + let (_, rect) = painted + .iter() + .find(|(text, _)| text == settings) + .expect("the sidebar must paint the settings row"); + assert!( + rect.height() > 0.0 && rect.bottom() <= small.bottom(), + "the settings row must be visible inside the window: {rect:?}" + ); + } + + #[test] + fn less_computer_rows_follow_the_enable_toggle() { + // Tauri `CodingAgentSection` shows 后端/权限/模型等配置行 only while the + // feature is enabled; a disabled section is just the toggle. + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let provider = openless_linux_egui::tr_l10n(zh, "settings.coding_agent.provider"); + for (enabled, expected) in [(false, false), (true, true)] { + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Advanced, + advanced_open: 0, + ..Default::default() + }; + vm.settings.less_computer = enabled; + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + } + assert_eq!( + painted.lines().any(|line| line.trim() == provider), + expected, + "Less Computer config rows must follow the enable toggle" + ); + } + } + + /// 渲染设置页并把这一帧画出的文字按行返回。 + fn painted_settings_lines( + section: super::view_model::SettingsSection, + vm: &mut FrontendViewModel, + ) -> Vec { + let ctx = egui::Context::default(); + let mut lines = Vec::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + vm.lang = openless_linux_egui::Lang::ZhCn; + vm.active_page = Page::Settings; + vm.settings_open = true; + vm.settings_section = section; + render(&ctx, vm, &mut actions); + lines = painted_text(&crate::ui::frontend::end_pass(&ctx)) + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect(); + } + lines + } + + #[test] + fn shortcut_menu_reveals_record_and_disable() { + let zh = openless_linux_egui::Lang::ZhCn; + let record = openless_linux_egui::tr_l10n(zh, "settings.recording.combo_record_btn"); + let disable = openless_linux_egui::tr_l10n(zh, "settings.shortcuts.disable"); + let mut vm = FrontendViewModel { + shortcut_menu: Some(super::view_model::ShortcutField::Qa), + ..Default::default() + }; + let open = painted_settings_lines(super::view_model::SettingsSection::Shortcuts, &mut vm); + assert!( + open.iter().any(|line| line == record), + "the record button must be painted" + ); + assert!( + open.iter().any(|line| line == disable), + "the disable button must be painted" + ); + // 收起菜单后两个按钮都要消失。 + vm.shortcut_menu = None; + let closed = painted_settings_lines(super::view_model::SettingsSection::Shortcuts, &mut vm); + assert!(!closed.iter().any(|line| line == record)); + assert!(!closed.iter().any(|line| line == disable)); + } + + #[test] + fn shortcut_rows_follow_the_video_order() { + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + dictation_hotkey: "Alt+Z".to_string(), + ..Default::default() + }; + let lines = painted_settings_lines(super::view_model::SettingsSection::Shortcuts, &mut vm); + let index_of = |key: &'static str| { + let label = openless_linux_egui::tr_l10n(zh, key); + lines + .iter() + .position(|line| line == label) + .unwrap_or_else(|| panic!("{key} ({label:?}) not painted in {lines:?}")) + }; + let start = index_of("settings.shortcuts.start_stop"); + let translation = index_of("hotkey.translation"); + let qa = index_of("selection_ask.hotkey_title"); + let switch_style = index_of("settings.shortcuts.switch_style"); + let style_pack = index_of("settings.shortcuts.style_pack_title"); + let open_app = index_of("settings.shortcuts.open_app"); + let cancel = index_of("settings.shortcuts.cancel"); + assert!( + start < translation + && translation < qa + && qa < switch_style + && switch_style < style_pack + && style_pack < open_app + && open_app < cancel, + "shortcut rows must keep the Tauri order, painted: {lines:?}" + ); + } + + #[test] + fn recording_captures_a_bare_modifier_after_release() { + // egui 没有修饰键的 Key 事件,所以「按住修饰键当热键」只能跨帧判断: + // 第一帧按住 Ctrl、第二帧松开且期间没有其它键 → 记为 LeftControl。 + use super::view_model::{FrontendAction, ShortcutField}; + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Shortcuts, + shortcut_recording: Some(ShortcutField::CodingAgentVoice), + ..Default::default() + }; + let held = egui::Modifiers { + ctrl: true, + command: true, + ..Default::default() + }; + let mut captured = Vec::new(); + for modifiers in [held, held, egui::Modifiers::default()] { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + ctx.input_mut(|i| i.modifiers = modifiers); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + for action in actions { + if let FrontendAction::ShortcutCaptured(field, primary, modifiers) = action { + captured.push((field, primary, modifiers)); + } + } + } + assert_eq!(captured.len(), 1, "exactly one capture after the release"); + assert_eq!(captured[0].0, ShortcutField::CodingAgentVoice); + assert_eq!(captured[0].1, "LeftControl"); + assert!(captured[0].2.is_empty(), "a bare modifier carries no tags"); + } + + #[test] + fn recording_ignores_a_modifier_combination_without_a_key() { + // Ctrl+Shift 同按后松手:不是有效的裸修饰键触发,不能录进去。 + use super::view_model::{FrontendAction, ShortcutField}; + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Shortcuts, + shortcut_recording: Some(ShortcutField::Qa), + ..Default::default() + }; + let both = egui::Modifiers { + ctrl: true, + command: true, + shift: true, + ..Default::default() + }; + let mut captured = Vec::new(); + for modifiers in [both, both, egui::Modifiers::default()] { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + ctx.input_mut(|i| i.modifiers = modifiers); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + captured.extend(actions.into_iter().filter_map(|action| match action { + FrontendAction::ShortcutCaptured(..) => Some(()), + _ => None, + })); + } + assert!(captured.is_empty(), "no capture for a modifier chord"); + } + + #[test] + fn recording_captures_the_pressed_combination() { + use super::view_model::{FrontendAction, ShortcutField}; + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Shortcuts, + shortcut_recording: Some(ShortcutField::Qa), + ..Default::default() + }; + let mut captured = None; + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::Key { + key: egui::Key::K, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers { + ctrl: true, + shift: true, + ..Default::default() + }, + }], + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + for action in actions { + if let FrontendAction::ShortcutCaptured(field, primary, modifiers) = action { + captured = Some((field, primary, modifiers)); + } + } + } + let (field, primary, modifiers) = captured.expect("a captured binding"); + assert_eq!(field, ShortcutField::Qa); + assert_eq!(primary, "K"); + assert!(modifiers.contains(&"ctrl".to_string())); + assert!(modifiers.contains(&"shift".to_string())); + } + + #[test] + fn style_pack_hotkey_rows_render_their_pack_and_keycaps() { + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + style_packs: vec![ + super::view_model::StylePack { + icon_path: None, + icon_data_url: None, + base_mode: "light".into(), + id: "builtin-polish".into(), + name: "Polish".into(), + description: String::new(), + tags: Vec::new(), + is_builtin: true, + enabled: true, + is_active: true, + selection_active: false, + }, + super::view_model::StylePack { + icon_path: None, + icon_data_url: None, + base_mode: "formal".into(), + id: "custom-legal".into(), + name: "Legal".into(), + description: String::new(), + tags: Vec::new(), + is_builtin: false, + enabled: false, + is_active: false, + selection_active: false, + }, + ], + ..Default::default() + }; + vm.settings.style_pack_hotkeys = vec![super::view_model::StylePackHotkeyRow { + pack_id: "custom-legal".into(), + name: "Legal".into(), + hotkey: "Ctrl+Shift+L".into(), + }]; + let lines = painted_settings_lines(super::view_model::SettingsSection::Shortcuts, &mut vm); + // 停用中的风格包在下拉里带「(已停用)」后缀(Tauri `stylePackDisabledSuffix`)。 + let disabled = format!( + "Legal{}", + openless_linux_egui::tr_l10n(zh, "settings.shortcuts.style_pack_disabled_suffix") + ); + assert!( + lines.iter().any(|line| line == &disabled), + "disabled pack suffix must be shown, painted: {lines:?}" + ); + // 键帽逐键渲染。 + assert!(lines.iter().any(|line| line == "Ctrl"), "modifier keycap"); + assert!(lines.iter().any(|line| line == "Shift"), "modifier keycap"); + assert!(lines.iter().any(|line| line == "L"), "primary keycap"); + } + + #[test] + fn style_pack_add_button_opens_the_draft_row() { + use super::view_model::SettingsSection; + let zh = openless_linux_egui::Lang::ZhCn; + let add = format!( + "+ {}", + openless_linux_egui::tr_l10n(zh, "settings.shortcuts.style_pack_add") + ); + let mut vm = FrontendViewModel::default(); + let closed = painted_settings_lines(SettingsSection::Shortcuts, &mut vm); + assert!(closed.iter().any(|line| line == &add), "add button shows"); + vm.style_hotkey_draft_open = true; + let open = painted_settings_lines(SettingsSection::Shortcuts, &mut vm); + assert!( + !open.iter().any(|line| line == &add), + "add button hides while drafting" + ); + } + + #[test] + fn ai_service_tabs_follow_the_host_capabilities() { + // Tauri gates the local-model view on `supports_local_asr`; the Linux + // host reports false, so the tab (and its "not supported" card) must + // disappear instead of being permanently visible. + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let models = openless_linux_egui::tr_l10n(zh, "modal.service_views.models"); + for (supported, expected) in [(false, false), (true, true)] { + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Services, + supports_local_asr: supported, + ..Default::default() + }; + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + } + // Compare whole painted lines: the section description also mentions + // 「本地模型」, so a substring check would always match. + assert_eq!( + painted.lines().any(|line| line.trim() == models), + expected, + "local-model tab visibility must follow supports_local_asr" + ); + } + } + + #[test] + fn empty_library_pages_render_their_empty_state_not_unsupported() { + // Regression: the library pages only cleared `*_unsupported` when the + // store was non-empty, so an empty dictionary/correction store rendered + // the "not wired up yet" placeholder instead of the empty state. + let zh = openless_linux_egui::Lang::ZhCn; + let unsupported = openless_linux_egui::tr_l10n(zh, "common.unsupported_title"); + for (label, page, empty_key) in [ + ("vocab", Page::Vocab, "vocab.empty"), + ("corrections", Page::Corrections, "vocab.corrections_empty"), + ] { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: zh, + active_page: page, + // What the host reports once the (empty) library has loaded. + vocab_unsupported: false, + ..Default::default() + }; + let mut painted = String::new(); + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + painted = painted_text(&output); + } + assert!( + !painted.contains(unsupported), + "{label} must not show the unsupported placeholder for an empty store" + ); + let empty = openless_linux_egui::tr_l10n(zh, empty_key); + assert!( + painted.contains(empty), + "{label} must show its empty-state hint ({empty:?})" + ); + } + } + + #[test] + fn overlays_stay_inside_a_small_window() { + // Regression: the style editor used to force a minimum card height, so a + // long prompt pushed the button row past the window edge. Every overlay + // must stay inside the viewport at a small window size. + let small = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 620.0)); + let long_prompt = "line\n".repeat(120); + + let cases: [(&str, FrontendViewModel); 2] = [ + ( + "style editor", + FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::Style, + style_editor_open: true, + style_prompt: long_prompt.clone(), + ..Default::default() + }, + ), + ( + "settings overlay", + FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::Settings, + settings_open: true, + ..Default::default() + }, + ), + ]; + + for (label, mut vm) in cases { + let ctx = egui::Context::default(); + let mut output = None; + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(small), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + output = Some(crate::ui::frontend::end_pass(&ctx)); + } + let output = output.expect("a frame was rendered"); + for clipped in &output.shapes { + // Only the part inside the shape's clip rect is actually drawn. + let bounds = clipped + .shape + .visual_bounding_rect() + .intersect(clipped.clip_rect); + if !bounds.is_finite() || bounds.width() <= 0.0 || bounds.height() <= 0.0 { + continue; + } + assert!( + bounds.bottom() <= small.bottom() + 2.0, + "{label} painted below the window: {bounds:?} (window {small:?})" + ); + assert!( + bounds.right() <= small.right() + 2.0, + "{label} painted right of the window: {bounds:?} (window {small:?})" + ); + } + } + } + + /// 设置页遮罩用的是离屏模糊背板,而不是一层暗色:发布的纹理必须真的落到弹窗图层 + /// 上,否则用户看到的还是旧的黑遮罩。 + #[test] + fn the_settings_overlay_paints_the_published_blurred_backdrop() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + settings_open: true, + ..Default::default() + }; + // Area 首帧只建立屏幕矩形,而且 `Area::fade_in` 会把刚出现的图层整个淡入: + // 淡入没走完时 painter 记下的是 `Shape::Noop`,看不到背板。所以推时间跑几帧。 + for frame in 0..4 { + crate::ui::backdrop::publish(&ctx, Some(egui::TextureId::User(9))); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(960.0, 640.0), + )), + time: Some(frame as f64 * 0.1), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let layer = egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("openless-settings-modal"), + ); + let backdrop_corners = ctx.graphics(|graphics| { + graphics.get(layer).and_then(|list| { + list.all_entries().find_map(|entry| match &entry.shape { + // 模糊背板是「带纹理的矩形」,只有这样圆角才跟着内容区走 + // (用整块的 `image` 会是直角,底部两角顶出窗口圆角)。 + egui::Shape::Rect(rect) + if rect.fill_texture_id() == egui::TextureId::User(9) => + { + Some(rect.corner_radius) + } + _ => None, + }) + }) + }); + if frame == 3 { + let corners = backdrop_corners.expect("the modal layer must sample the backdrop"); + assert_eq!( + corners, + layout::body_corner_radius(&ctx), + "the blurred backdrop must use the content corners" + ); + // macOS 一致:模糊之上还有一层 `--ol-overlay-bg` 压暗。 + let paints_tint = ctx.graphics(|graphics| { + graphics.get(layer).is_some_and(|list| { + list.all_entries().any(|entry| match &entry.shape { + egui::Shape::Rect(rect) => rect.fill == theme::OVERLAY, + _ => false, + }) + }) + }); + assert!(paints_tint, "the modal layer must tint above the blur"); + } + // 卡片矩形依然要写进 memory:遮罩换了材质,弹窗几何不能跟着变。 + assert!(ctx + .data(|data| data + .get_temp::(egui::Id::new("openless-settings-card-rect"))) + .is_some()); + let _ = end_pass(&ctx); + } + } + + #[test] + fn shrinking_without_clicking_rebuilds_the_content_viewport_immediately() { + for page in [ + Page::Overview, + Page::History, + Page::QuickNote, + Page::Translation, + ] { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + active_page: page, + ..Default::default() + }; + for (width, height) in [(1500.0, 950.0), (960.0, 640.0)] { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(width, height), + )), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let available: egui::Vec2 = ctx.data(|data| { + data.get_temp(egui::Id::new("content-available-size")) + .unwrap() + }); + let expected = layout::body_rect(&ctx).size() + - egui::vec2( + layout::SIDEBAR_WIDTH + 30.0, + layout::PAGE_TOP_PADDING + layout::PAGE_BOTTOM_PADDING, + ); + assert!((available.x - expected.x).abs() < 1.0, + "{page:?} still uses old width after resize: {available:?}, expected {expected:?}"); + assert!((available.y - expected.y).abs() < 1.0, + "{page:?} still uses old height after resize: {available:?}, expected {expected:?}"); + let _ = end_pass(&ctx); + } + } + } + + #[test] + fn history_and_quick_note_remain_side_by_side_at_the_window_minimum() { + for page in [Page::History, Page::QuickNote] { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + active_page: page, + ..Default::default() + }; + for size in [egui::vec2(1300.0, 835.0), egui::vec2(960.0, 640.0)] { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let _ = end_pass(&ctx); + } + let (list, detail): (egui::Rect, egui::Rect) = ctx.data(|data| { + data.get_temp(egui::Id::new("history-column-rects")) + .unwrap() + }); + assert!( + detail.left() >= list.right(), + "{page:?} stacked at minimum: {list:?} / {detail:?}" + ); + assert!( + detail.right() <= layout::body_rect(&ctx).right(), + "{page:?} detail clipped: {detail:?}" + ); + let visible_bottom = layout::body_rect(&ctx).bottom() - layout::PAGE_BOTTOM_PADDING; + assert!( + detail.bottom() <= visible_bottom + 1.0, + "{page:?} detail extends under bottom gutter: {detail:?}, bottom={visible_bottom}" + ); + } + } + + #[test] + fn history_and_quick_note_scroll_both_columns_at_minimum_window_height() { + for page in [Page::History, Page::QuickNote] { + for (pointer, measure) in [ + (egui::pos2(360.0, 400.0), "history-list-scroll-measure"), + (egui::pos2(670.0, 400.0), "history-detail-scroll-measure"), + ] { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + active_page: page, + quick_note_shortcut_hidden: true, + history_loading: false, + history_entries: (0..25) + .map(|n| super::view_model::HistoryEntry { + id: n.to_string(), + quick_note: page == Page::QuickNote, + created_at: "2026-01-15T12:34:00+00:00".into(), + raw_transcript: "long sentence for scroll testing ".repeat(90), + final_text: "final paragraph ".repeat(60), + ..Default::default() + }) + .collect(), + ..Default::default() + }; + for frame in 0..6 { + let size = if frame == 0 { + egui::vec2(1300.0, 835.0) + } else { + egui::vec2(960.0, 640.0) + }; + let mut events = vec![egui::Event::PointerMoved(pointer)]; + if frame >= 2 { + events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -180.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); } + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + events, + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let _ = end_pass(&ctx); + } + let (content, visible, offset): (f32, f32, f32) = + ctx.data(|data| data.get_temp(egui::Id::new(measure)).unwrap()); + assert!( + content > visible + 10.0, + "{page:?} {measure} no scroll extent: {content}/{visible}" + ); + assert!( + offset > 10.0, + "{page:?} {measure} did not scroll: {content}/{visible}, offset={offset}" + ); + } + } + } + + /// 复现「滚不动,点一下页面才行」:窗口被合成器拖动/缩放后,egui 手里的指针 + /// 坐标停在按下那一刻(Wayland 交互式移动/缩放期间客户端不再收到 motion), + /// 用户其实是在页面上滚轮,egui 却以为指针还在标题栏上 —— `ScrollArea` 的 + /// `rect_contains_pointer` 判定失败,滚轮整帧被丢掉。 + #[test] + fn a_stale_pointer_must_not_swallow_the_page_wheel() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + active_page: Page::Translation, + translation_unsupported: false, + ..Default::default() + }; + for frame in 0..12 { + let mut events = Vec::new(); + if frame < 2 { + events.push(egui::Event::PointerMoved(egui::pos2(550.0, 390.0))); + } + if frame == 2 { + // 按标题栏拖窗:app 在按下那一帧就把指针交给合成器(StartDrag)。 + events.push(egui::Event::PointerMoved(egui::pos2(400.0, 18.0))); + events.push(egui::Event::PointerButton { + pos: egui::pos2(400.0, 18.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + } + if frame >= 3 { + // 拖动结束后客户端再没收到指针事件,用户就地滚滚轮。 + events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -180.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + } + frame_with_pointer_routing( + &ctx, + egui::RawInput { + screen_rect: Some(viewport()), + events, + time: Some(frame as f64 / 10.0), + ..Default::default() + }, + &mut vm, + ); + } + let (content, visible, offset): (f32, f32, f32) = ctx.data(|data| { + data.get_temp(egui::Id::new("main-scroll-measure")) + .expect("the translation page must have a scroll container") + }); + assert!( + content > visible, + "the page must be scrollable: {content} / {visible}" + ); + assert!( + offset > 10.0, + "a stale pointer swallowed the wheel: {content} / {visible}, offset={offset}" + ); + } + + /// 跑一帧,并且像 `eframe::App::raw_input_hook` 那样先修一次指针状态 + /// (测试里没有 eframe,只能手动走同一条路径,否则修的东西测不到)。 + fn frame_with_pointer_routing( + ctx: &egui::Context, + mut raw_input: egui::RawInput, + vm: &mut FrontendViewModel, + ) { + layout::route_pointer_before_pass(ctx, &mut raw_input); + ctx.begin_pass(raw_input); + render(ctx, vm, &mut Vec::new()); + let _ = end_pass(ctx); + } + + /// 用户报的复现路径:打开概览页 → 从右下角把窗口拖到最小 → 不再动鼠标就地滚动。 + /// 四角的拉伸条是 app 自己画的(`BeginResize`),按下那一帧就把这次手势交给了 + /// 合成器:释放回不来、缩放期间也没有 motion,egui 于是留着「正在拖拉伸条」的状态, + /// `ScrollArea` 就不吃滚轮了。 + #[test] + fn resizing_from_the_corner_does_not_leave_the_page_unscrollable() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + active_page: Page::Overview, + overview_loading: false, + ..Default::default() + }; + vm.settings.activity_heatmap = true; + vm.overview = Some(super::view_model::OverviewSummary { + asr_provider: "asr".into(), + llm_provider: "llm".into(), + asr_configured: true, + llm_configured: true, + chars_today: 42, + segments_today: 3, + duration_ms_today: 12_000, + avg_latency_ms: 1200, + history_total: 20, + recent: Vec::new(), + activity_daily: Vec::new(), + heatmap_year: 2026, + heatmap: (0..365) + .map(|day| super::view_model::OverviewHeatmapDay { + date: format!("2026-01-{day}"), + count: 1, + }) + .collect(), + }); + let large = egui::vec2(1240.0, 800.0); + let small = egui::vec2(960.0, 640.0); + // 右下角拉伸条:`window_rect` 是客户区内缩 6px,角上 18px 属于 SouthEast。 + let grip = egui::pos2(large.x - 15.0, large.y - 15.0); + for frame in 0..18 { + let mut events = Vec::new(); + if frame < 3 { + events.push(egui::Event::PointerMoved(grip)); + } + if frame == 3 { + events.push(egui::Event::PointerButton { + pos: grip, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + } + if frame >= 4 { + // 用户缩完窗口就地滚:合成器既不还释放,也不再发 motion。 + events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -180.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + } + let size = if frame >= 4 { small } else { large }; + frame_with_pointer_routing( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + events, + time: Some(frame as f64 / 10.0), + ..Default::default() + }, + &mut vm, + ); + } + let (content, visible, offset): (f32, f32, f32) = ctx.data(|data| { + data.get_temp(egui::Id::new("overview-scroll-measure")) + .expect("the overview must offer page scrolling at the minimum size") + }); + assert!( + content > visible, + "overview did not offer scrolling: {content}/{visible}" + ); + assert!( + offset > 10.0, + "a corner resize swallowed the wheel: {content}/{visible}, offset={offset}" + ); + } + + #[test] + fn pointer_routing_only_guesses_when_the_pointer_is_not_in_the_body() { + let ctx = egui::Context::default(); + // 指针在主体里:滚轮照旧走 egui 自己的命中判定,不要猜。 + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::PointerMoved(egui::pos2(600.0, 400.0))], + ..Default::default() + }); + let _ = end_pass(&ctx); + let mut raw = egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -120.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }; + layout::route_pointer_before_pass(&ctx, &mut raw); + assert!( + !raw.events + .iter() + .any(|event| matches!(event, egui::Event::PointerMoved(_))), + "a pointer inside the body must be left alone" + ); + + // 指针停在标题栏上(合成器拖窗之后的典型状态):滚轮证明指针在窗口里, + // 补一个主体内的坐标,滚轮才有人接。 + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::PointerMoved(egui::pos2(400.0, 18.0))], + ..Default::default() + }); + let _ = end_pass(&ctx); + let mut raw = egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -120.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }; + layout::route_pointer_before_pass(&ctx, &mut raw); + let guessed = raw.events.iter().find_map(|event| match event { + egui::Event::PointerMoved(pos) => Some(*pos), + _ => None, + }); + let body = layout::body_rect(&ctx); + assert!( + guessed.is_some_and(|pos| body.contains(pos)), + "a stale pointer must be moved into the body: {guessed:?}" + ); + } + + #[test] + fn an_orphaned_press_is_released_once_the_window_manager_keeps_it() { + let ctx = egui::Context::default(); + // 按下标题栏:app 会把这次手势交给合成器。 + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + time: Some(1.0), + events: vec![ + egui::Event::PointerMoved(egui::pos2(400.0, 18.0)), + egui::Event::PointerButton { + pos: egui::pos2(400.0, 18.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }, + ], + ..Default::default() + }); + layout::note_window_gesture_handoff(&ctx); + let _ = end_pass(&ctx); + assert!(ctx.input(|input| input.pointer.any_down())); + + // 刚交出去就补释放会把「按住标题栏」也打断,所以先等一会儿。 + let mut early = egui::RawInput { + screen_rect: Some(viewport()), + time: Some(1.1), + ..Default::default() + }; + layout::route_pointer_before_pass(&ctx, &mut early); + assert!(!early + .events + .iter() + .any(|event| matches!(event, egui::Event::PointerButton { pressed: false, .. }))); + + // 合成器一直没把释放还回来:这次手势已经不属于 egui,补上释放。 + // (`raw_input_hook` 读的是上一帧的 `input().time`,所以先把时钟推过去。) + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + time: Some(2.0), + ..Default::default() + }); + let _ = end_pass(&ctx); + let mut late = egui::RawInput { + screen_rect: Some(viewport()), + time: Some(2.1), + ..Default::default() + }; + layout::route_pointer_before_pass(&ctx, &mut late); + assert!(late + .events + .iter() + .any(|event| matches!(event, egui::Event::PointerButton { pressed: false, .. }))); + } + + #[test] + fn overview_and_translation_scroll_at_minimum_window_height() { + for page in [Page::Overview, Page::Translation] { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + active_page: page, + translation_unsupported: false, + overview_loading: false, + ..Default::default() + }; + if page == Page::Overview { + vm.settings.activity_heatmap = true; + vm.overview = Some(super::view_model::OverviewSummary { + asr_provider: "asr".into(), + llm_provider: "llm".into(), + asr_configured: true, + llm_configured: true, + chars_today: 42, + segments_today: 3, + duration_ms_today: 12_000, + avg_latency_ms: 1200, + history_total: 20, + recent: Vec::new(), + activity_daily: Vec::new(), + heatmap_year: 2026, + heatmap: (0..365) + .map(|day| super::view_model::OverviewHeatmapDay { + date: format!("2026-01-{day}"), + count: 1, + }) + .collect(), + }); + } + let large = egui::vec2(1300.0, 835.0); + let small = egui::vec2(960.0, 640.0); + for frame in 0..6 { + let size = if frame == 0 { large } else { small }; + let events = if frame >= 2 { + vec![ + egui::Event::PointerMoved(egui::pos2(550.0, 390.0)), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -180.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }, + ] + } else { + vec![egui::Event::PointerMoved(egui::pos2(550.0, 390.0))] + }; + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + events, + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let _ = end_pass(&ctx); + if frame == 5 { + let id = if page == Page::Overview { + "overview-scroll-measure" + } else { + "main-scroll-measure" + }; + let (content, visible, offset): (f32, f32, f32) = ctx.data(|data| { + data.get_temp(egui::Id::new(id)) + .expect("scroll container must exist") + }); + assert!( + content > visible + 10.0, + "{page:?} has no scroll extent: {content} / {visible}" + ); + assert!( + offset > 10.0, + "{page:?} did not scroll: {content} / {visible}, offset={offset}" + ); } - ui.add_space(32.0); + } + } + } + + #[test] + fn translation_scroll_position_is_not_shared_with_other_pages() { + fn paint(ctx: &egui::Context, vm: &mut FrontendViewModel, events: Vec) -> f32 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(960.0, 640.0), + )), + events, + ..Default::default() }); - }); -} + render(ctx, vm, &mut Vec::new()); + let _ = end_pass(ctx); + ctx.data(|data| { + data.get_temp::<(f32, f32, f32)>(egui::Id::new("main-scroll-measure")) + .unwrap() + .2 + }) + } + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + active_page: Page::Translation, + translation_unsupported: false, + ..Default::default() + }; + paint( + &ctx, + &mut vm, + vec![egui::Event::PointerMoved(egui::pos2(530.0, 360.0))], + ); + let mut scrolled = 0.0; + for _ in 0..3 { + scrolled = paint( + &ctx, + &mut vm, + vec![ + egui::Event::PointerMoved(egui::pos2(530.0, 360.0)), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -130.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }, + ], + ); + } + assert!(scrolled > 1.0); + vm.active_page = Page::Marketplace; + paint(&ctx, &mut vm, Vec::new()); + let other = paint(&ctx, &mut vm, Vec::new()); + assert!( + other < 1.0, + "marketplace inherited translation scroll offset: {other}" + ); + vm.active_page = Page::Translation; + let restored = paint(&ctx, &mut vm, Vec::new()); + assert!( + restored > 10.0, + "translation lost its scroll position after switching pages: {scrolled} -> {restored}" + ); + } + + #[test] + fn translation_page_stays_inside_its_viewport_and_scrolls_after_a_shrink() { + // Regression for "翻译页缩放卡顿、使用方法被遮挡且无法滚动": the page pinned + // only `set_min_width`, so after shrinking the window egui kept laying the + // two columns (and the four-column guide) out at the previous, larger + // width — a growing-width feedback loop that pushed the guide out of the + // scrollable area. The page must fit the viewport it is given at every + // size, and the guide must stay reachable by scrolling. + use openless_linux_egui::Lang; + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: Lang::ZhCn, + active_page: Page::Translation, + translation_unsupported: false, + ..Default::default() + }; + + let mut scroll_needed = false; + for (index, screen) in [ + egui::vec2(1400.0, 900.0), + egui::vec2(1080.0, 700.0), + egui::vec2(900.0, 520.0), + ] + .into_iter() + .enumerate() + { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, screen)), + ..Default::default() + }); + let viewport = egui::Rect::from_min_size( + egui::pos2(220.0, 56.0), + egui::vec2((screen.x - 250.0).max(80.0), (screen.y - 80.0).max(80.0)), + ); + let mut measured = None; + egui::Area::new(egui::Id::new(("translation-layout", index))) + .fixed_pos(viewport.min) + .show(&ctx, |ui| { + ui.set_min_size(viewport.size()); + ui.set_max_size(viewport.size()); + let output = egui::ScrollArea::vertical() + .id_salt("translation-layout-scroll") + .max_height(viewport.height()) + .auto_shrink([false, false]) + .show(ui, |ui| { + translation::page(ui, &mut vm, &mut Vec::new()); + ui.add_space(32.0); + }); + measured = Some((output.content_size, output.inner_rect)); + }); + let (content, inner) = measured.expect("the page rendered into the area"); + let badge: egui::Rect = ctx.data(|data| { + data.get_temp(egui::Id::new("translation-style-badge")) + .expect("translation style has a badge") + }); + assert!( + badge.width() <= 180.0 && badge.height() <= 24.0, + "badge stretched: {badge:?}" + ); + assert!( + badge.right() <= viewport.right() && badge.left() >= viewport.left(), + "badge must remain inside the resized viewport: {badge:?} / {viewport:?}" + ); + assert!( + content.x <= inner.width() + 1.0, + "the page must not exceed its viewport: content {content:?}, inner {inner:?}" + ); + if content.y > inner.height() { + scroll_needed = true; + } + } + assert!( + scroll_needed, + "at the smallest window the page must be taller than the viewport so the \ + usage guide stays reachable by scrolling" + ); + } + + #[test] + fn quick_note_header_has_refresh_but_no_start_or_finish_recording_button() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + quick_note_recording: true, + ..Default::default() + }; + let output = run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }, + |ui| history::page(ui, &mut vm, &mut Vec::new(), true), + ); + let text = painted_text(&output); + assert!(text.contains(openless_linux_egui::tr_l10n(vm.lang, "common.refresh"))); + assert!(!text.contains(openless_linux_egui::tr_l10n(vm.lang, "quickNote.start"))); + assert!(!text.contains(openless_linux_egui::tr_l10n(vm.lang, "quickNote.finish"))); + assert!(!text.contains(openless_linux_egui::tr_l10n(vm.lang, "common.clear"))); + } + + #[test] + fn fixed_ui_keeps_the_parent_cursor_in_place() { + // Regression: `ui.scope_builder` rewinds the parent cursor to the + // child's used rect, which made each card in a row pull the next row up + // over itself. `layout::fixed_ui` must not move the parent cursor even + // when the card body paints instead of allocating. + let ctx = egui::Context::default(); + let _ = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }, + |ui| { + ui.allocate_exact_size(egui::vec2(100.0, 10.0), egui::Sense::hover()); + let before = ui.next_widget_position(); + let rect = egui::Rect::from_min_size(before, egui::vec2(240.0, 120.0)); + layout::fixed_ui(ui, rect, "test-card", |ui| { + ui.label("card body"); + }); + assert_eq!( + ui.next_widget_position(), + before, + "fixed_ui must leave the parent layout cursor untouched" + ); + }, + ); + } + + #[test] + fn shell_paints_a_gray_sidebar_over_white_content_and_a_tinted_titlebar() { + let ctx = egui::Context::default(); + frame(&ctx, Vec::new()); + frame(&ctx, Vec::new()); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut vm = FrontendViewModel::default(); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + + let mut fills: Vec<(egui::Rect, egui::Color32)> = Vec::new(); + for clipped in &output.shapes { + collect_rect_fills(&clipped.shape, &mut fills); + } + let window = layout::window_rect(&ctx); + let body = layout::body_rect(&ctx); + let sidebar_rect = + egui::Rect::from_min_size(body.min, egui::vec2(layout::SIDEBAR_WIDTH, body.height())); + let titlebar_rect = egui::Rect::from_min_max( + window.min, + egui::pos2(window.max.x, window.min.y + layout::TITLEBAR_HEIGHT), + ); + // 入场动画会把 UI 层的颜色整体乘一个 alpha(存的是预乘色),所以比「源色」的 + // RGB 而不是比字面值。 + let painted_with = |rect: egui::Rect, color: egui::Color32| { + let [want_r, want_g, want_b, _] = color.to_srgba_unmultiplied(); + fills.iter().any(|(painted, fill)| { + if *painted != rect { + return false; + } + let [r, g, b, _] = fill.to_srgba_unmultiplied(); + // 反预乘会有一两点取整误差,所以给 ±3 的容差。 + (r as i32 - want_r as i32).abs() <= 3 + && (g as i32 - want_g as i32).abs() <= 3 + && (b as i32 - want_b as i32).abs() <= 3 + }) + }; + // 侧栏(左列)= 灰底;内容区底板 = 白底。 + // 这两个搞反就是用户报的「左右两侧的底色搞翻了」。 + assert!( + painted_with(sidebar_rect, theme::SIDEBAR), + "the sidebar column must be painted with the gray sidebar surface" + ); + assert!( + painted_with(body, theme::SURFACE), + "the body must be painted white (content area), not canvas gray" + ); + // 自绘标题栏比纯白更灰(Tauri 的 Linux 标题栏不是纯白)。 + assert!( + painted_with(titlebar_rect, theme::TITLEBAR), + "the titlebar strip must use the tinted titlebar surface" + ); + assert_ne!(theme::TITLEBAR, theme::SURFACE); + assert_ne!(theme::SIDEBAR, theme::SURFACE); + } + + fn collect_rect_fills(shape: &egui::Shape, out: &mut Vec<(egui::Rect, egui::Color32)>) { + match shape { + egui::Shape::Rect(rect) => out.push((rect.rect, rect.fill)), + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect_rect_fills(shape, out); + } + } + _ => {} + } + } + + #[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()); + + // 侧栏顶部现在是两行版本信息(BETA 徽章 + 版本号),导航行的实际 y 会随 + // 字体度量漂移,所以扫一列而不是写死一个坐标:只要有一个位置能点出导航, + // 就说明最上层的 resize 层没吃掉侧栏的点击。 + let mut navigated = false; + let mut y = 80.0; + while y <= 320.0 && !navigated { + let pointer = egui::pos2(50.0, y); + if y == 80.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 over its own column" + ); + } + 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, + }], + ); + navigated = actions + .iter() + .any(|action| matches!(action, FrontendAction::Navigate(_))); + y += 4.0; + } + assert!( + navigated, + "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" + ); + } + + /// Tauri's pack card pins the icon picker to the *right* end of the header + /// row (`justify-content: space-between`), renders a 40×40 grey tile with a + /// pencil badge, and shows the four shipped packs with the *localized* mode + /// copy instead of the Chinese text stored in Core. + #[test] + fn style_cards_put_the_icon_picker_on_the_right_and_localize_builtin_copy() { + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Style, + style_unsupported: false, + ..Default::default() + }; + // `builtin.raw` is presented as the 「原文」 tab, never as a card. + vm.style_packs = [ + openless_core::PolishMode::Light, + openless_core::PolishMode::Structured, + openless_core::PolishMode::Formal, + ] + .into_iter() + .enumerate() + .map(|(index, mode)| { + let shipped = openless_core::builtin_style_pack_for_mode(mode); + super::view_model::StylePack { + id: shipped.id.clone(), + icon_path: None, + icon_data_url: None, + base_mode: match mode { + openless_core::PolishMode::Raw => "raw", + openless_core::PolishMode::Light => "light", + openless_core::PolishMode::Structured => "structured", + openless_core::PolishMode::Formal => "formal", + } + .into(), + name: shipped.name.clone(), + description: shipped.description.clone(), + tags: shipped.tags.clone(), + is_builtin: true, + enabled: true, + is_active: index == 0, + selection_active: false, + } + }) + .collect(); + + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + painted = painted_text(&output); + } + + // The shipped copy is translated; the raw Core text must not leak. + let light_desc = openless_linux_egui::tr_l10n(zh, "style.modes.light.desc"); + assert!( + painted.contains(light_desc), + "the builtin card must show the localized mode description {light_desc:?}" + ); + let shipped_light = + openless_core::builtin_style_pack_for_mode(openless_core::PolishMode::Light); + assert!( + !painted.contains(&shipped_light.description), + "the raw Core description must not be painted for a pristine builtin pack" + ); + // English proves the shipped tags are translated rather than echoed: + // in zh-CN the localized values happen to equal the stored ones. + vm.lang = openless_linux_egui::Lang::En; + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + painted = painted_text(&output); + } + let en = openless_linux_egui::Lang::En; + for key in [ + "style.modes.structured.desc", + "style.pack.builtinTags.aiCoding", + "overview.mode_structured", + ] { + let expected = openless_linux_egui::tr_l10n(en, key); + assert!( + painted.contains(expected), + "the card must paint the localized {key} ({expected:?})" + ); + } + let shipped_structured = + openless_core::builtin_style_pack_for_mode(openless_core::PolishMode::Structured); + assert!( + !painted.contains(&shipped_structured.description), + "the raw Core description must not survive translation" + ); + assert!( + !painted.contains(&shipped_structured.tags[0]), + "the raw Core tag must not survive translation" + ); + + // The tile is a 40×40 square flush with the card's right inner edge. + let published: Vec<(usize, egui::Rect, egui::Rect, String)> = ctx + .data(|data| data.get_temp(egui::Id::new("openless-style-card-icon-rects"))) + .expect("the style cards must publish their icon tiles"); + assert_eq!( + published.len(), + 3, + "every pack card must publish a tile: {published:?}" + ); + for (index, icon, inner, name) in published { + assert_eq!(icon.size(), egui::vec2(40.0, 40.0), "card {index} ({name})"); + assert_eq!( + icon.right(), + inner.right(), + "card {index} ({name}) must pin the tile to the right edge" + ); + assert_eq!(icon.top(), inner.top(), "card {index} ({name})"); + } + } + + #[test] + fn style_page_marks_only_the_active_pack_as_current() { + // Regression: the page used to treat its page-local `style_selected` + // index as "active" as well, so a stale index painted a second card in + // the active style. Only the pack the host reports as active may say + // "current" — one badge plus one primary button. + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let pack = |name: &str, is_active: bool| super::view_model::StylePack { + icon_path: None, + icon_data_url: None, + base_mode: "light".into(), + id: format!("pack-{name}"), + enabled: true, + name: name.to_string(), + description: "sample description".to_string(), + tags: vec!["light".to_string()], + is_builtin: true, + is_active, + selection_active: false, + }; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Style, + style_unsupported: false, + ..Default::default() + }; + vm.style_packs = vec![ + pack("first", false), + pack("second", true), + pack("third", false), + ]; + vm.style_selected = 0; + + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + } + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + let painted = painted_text(&output); -#[cfg(test)] -mod tests { - use super::*; + let current = openless_linux_egui::tr_l10n(zh, "style.pack.current"); + let activate = openless_linux_egui::tr_l10n(zh, "style.pack.activate"); + assert_eq!( + painted.matches(current).count(), + 2, + "exactly one pack (badge + primary button) may read as current" + ); + assert_eq!( + painted.matches(activate).count(), + 2, + "the two other packs offer an activate button" + ); + assert!( + painted.contains("first") && painted.contains("second") && painted.contains("third") + ); + } - fn viewport() -> egui::Rect { - egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1240.0, 800.0)) + /// 用户报告「缩放窗口时并不是始终居中」:模态卡片此前用 `Area::anchor` + /// 定位,垂直方向稳定偏下 19.5px,且卡片被内容撑宽 22px(横向偏 11px)。 + /// 现在位置由 `body.center()` 显式算出,两种偏差都必须消失,并且**改变窗口 + /// 尺寸后的第一帧**就要居中(不能靠后续帧收敛)。 + #[test] + fn the_settings_modal_stays_centred_across_window_resizes() { + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + ..Default::default() + }; + for (width, height) in [ + (1240.0, 800.0), + (900.0, 620.0), + (1600.0, 1000.0), + (1100.0, 900.0), + ] { + let viewport = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, height)); + // 等一帧让内容成型,下一帧断言(尺寸变化不接受「过渡帧」偏差)。 + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + } + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + let body = layout::body_rect(&ctx); + // 卡片矩形现在由叠层自己写入 memory(Area 覆盖整个 body,面积已不等于卡片)。 + let modal = ctx + .data(|data| { + data.get_temp::(egui::Id::new("openless-settings-card-rect")) + }) + .expect("the settings card rect must be published while the overlay is open"); + assert!( + (modal.center().x - body.center().x).abs() <= 1.5, + "modal must be horizontally centred at {width}x{height}: modal={modal:?} body={body:?}" + ); + assert!( + (modal.center().y - body.center().y).abs() <= 1.5, + "modal must be vertically centred at {width}x{height}: modal={modal:?} body={body:?}" + ); + // 卡片不会被内容撑宽(撑宽就会把居中算歪)。 + let expected_width = (body.width() - 40.0).clamp(320.0, 960.0); + assert!( + (modal.width() - expected_width).abs() <= 2.0, + "the content must fit the requested modal width {expected_width}: {modal:?}" + ); + } } - fn frame(ctx: &egui::Context, events: Vec) -> Vec { + /// 渲染一帧**指定 vm** 的前端(`frame()` 用的是默认 vm,遮罩类弹窗需要打开状态)。 + fn overlay_frame( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + 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(); + render(ctx, vm, &mut actions); + let _ = crate::ui::frontend::end_pass(ctx); 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()); + /// 遮罩类弹窗的结构性回归(用户报「点阴影后阴影上移、设置没法用」): + /// 遮罩、点击拦截与卡片必须在**同一个 `Area`**(同一个 LayerId)里,先画遮罩再画卡片。 + /// 各自独立容器时 egui 会在按下后把被点到的那个 `move_to_top`,遮罩一旦被抬起来就会 + /// 盖住卡片。三处弹窗(市场详情 / 风格编辑器 / 历史确认)都必须满足: + /// * 卡片落在弹窗自己的图层上、且在**自己的遮罩区**里居中(三处弹窗的遮罩区 + /// 都取自各自的页面 body,历史页的遮罩区是其页面内部分配的 body,故用 + /// egui memory 里的 Area 矩形作基准,而不是 `layout::body_rect`); + /// * 遮罩上的点也落在弹窗自己的图层(点击不会漏到下方页面); + /// * 点一下遮罩之后,卡片既不移位、也不会被抬起的遮罩盖住。 + /// + /// 弹窗卡片相对遮罩区的位置。上游 Beta.2 里市场详情/历史确认是居中的 + /// `Modal`,风格编辑器是贴右边的抽屉(`top/right/bottom: 16`, + /// `width: min(760px, 100vw - 32px)`)。 + #[derive(Clone, Copy, PartialEq, Eq)] + enum CardPlacement { + Centred, + RightDocked, + } - let pointer = egui::pos2(50.0, 142.0); + fn assert_overlay_keeps_the_card_on_top( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + area_id: &str, + card_rect_key: &str, + placement: CardPlacement, + ) { + let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::new(area_id)); + // Areas 用第一帧建立屏幕矩形,第二帧才可查询。 + for _ in 0..2 { + overlay_frame(ctx, vm, Vec::new()); + } + let body = layout::body_rect(ctx); + // 遮罩区就是弹窗 `Area` 自己的矩形(遮罩 + 点击拦截 + 卡片同属它)。 + let overlay = ctx + .memory(|mem| mem.area_rect(egui::Id::new(area_id))) + .unwrap_or_else(|| panic!("{area_id} must exist while the overlay is open")); + let card = ctx + .data(|data| data.get_temp::(egui::Id::new(card_rect_key))) + .unwrap_or_else(|| { + panic!("{card_rect_key} must be published while the overlay is open") + }); + assert!(card.width() > 0.0 && card.height() > 0.0, "{card:?}"); + assert!( + body.contains_rect(overlay), + "the mask {overlay:?} must stay inside the content area {body:?}" + ); + assert!( + overlay.contains_rect(card), + "the card {card:?} must sit inside its mask {overlay:?}" + ); + match placement { + CardPlacement::Centred => { + assert!( + (card.center().x - overlay.center().x).abs() <= 1.5, + "card must be horizontally centred: {card:?} in {overlay:?}" + ); + assert!( + (card.center().y - overlay.center().y).abs() <= 1.5, + "card must be vertically centred: {card:?} in {overlay:?}" + ); + } + CardPlacement::RightDocked => { + assert!( + (card.right() - (overlay.right() - 16.0)).abs() <= 1.5, + "the drawer must dock 16px from the right edge: {card:?} in {overlay:?}" + ); + assert!( + (card.left() - (overlay.right() - 16.0 - 760.0)).abs() <= 1.5, + "the drawer must be min(760px, …) wide: {card:?} in {overlay:?}" + ); + assert!( + (card.top() - (overlay.top() + 16.0)).abs() <= 1.5 + && (card.bottom() - (overlay.bottom() - 16.0)).abs() <= 1.5, + "the drawer must keep a 16px inset vertically: {card:?} in {overlay:?}" + ); + } + } 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" + ctx.layer_id_at(card.center()), + Some(layer), + "{area_id}: the card must live in the modal's own layer" ); - frame( - &ctx, + // 遮罩探针:选一个肯定落在遮罩上、不落在卡片上的点。 + let mask = match placement { + CardPlacement::Centred => egui::pos2(overlay.center().x, overlay.top() + 6.0), + // 抽屉贴右,遮罩左缘 20px 处一定在遮罩上。 + CardPlacement::RightDocked => egui::pos2(overlay.left() + 20.0, overlay.center().y), + }; + assert!( + !card.contains(mask) && overlay.contains(mask), + "probe {mask:?} must be on the mask, not on the card {card:?}" + ); + assert_eq!( + ctx.layer_id_at(mask), + Some(layer), + "{area_id}: the mask must swallow input instead of letting it reach the page below" + ); + // 点一下遮罩:遮罩被抬到卡片之上就会失败。 + overlay_frame( + ctx, + vm, vec![ - egui::Event::PointerMoved(pointer), + egui::Event::PointerMoved(mask), egui::Event::PointerButton { - pos: pointer, + pos: mask, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE, }, ], ); - let actions = frame( - &ctx, + overlay_frame( + ctx, + vm, vec![egui::Event::PointerButton { - pos: pointer, + pos: mask, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE, }], ); + let after = ctx + .data(|data| data.get_temp::(egui::Id::new(card_rect_key))) + .unwrap_or_else(|| panic!("{card_rect_key} must stay published")); + assert_eq!( + after, card, + "{area_id}: clicking the mask must not move the card" + ); + assert_eq!( + ctx.layer_id_at(card.center()), + Some(layer), + "{area_id}: clicking the mask must not raise it above the card" + ); + } + + #[test] + fn marketplace_detail_overlay_is_one_layer() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::Marketplace, + // 默认是 `loading = true` / `unsupported = true`(宿主还没送到列表),两者都会 + // 让页面提前 return。 + marketplace_loading: false, + marketplace_unsupported: false, + ..Default::default() + }; + vm.marketplace_packs = vec![super::view_model::MarketplacePack { + id: "overlay-fixture-id".to_string(), + name: "overlay-fixture".to_string(), + version: "1.0.0".to_string(), + description: "fixture for the marketplace detail overlay test".to_string(), + mode: "dictation".to_string(), + author: "tester".to_string(), + origin_author_login: None, + tags: Vec::new(), + likes: 1, + downloads: 2, + liked: false, + }]; + vm.marketplace_selected = Some(0); + assert_overlay_keeps_the_card_on_top( + &ctx, + &mut vm, + "openless-marketplace-detail-modal", + "openless-marketplace-detail-card-rect", + CardPlacement::Centred, + ); + } + + #[test] + fn marketplace_mine_modal_matches_the_tauri_header_geometry() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::Marketplace, + marketplace_loading: false, + marketplace_unsupported: false, + marketplace_mine_open: true, + marketplace_mine_loading: false, + marketplace_signed_in: false, + ..Default::default() + }; + for _ in 0..2 { + overlay_frame(&ctx, &mut vm, Vec::new()); + } + let body = layout::body_rect(&ctx); + let card = ctx + .data(|data| { + data.get_temp::(egui::Id::new("openless-marketplace-mine-card-rect")) + }) + .expect("the my-packs card rect must be published while open"); + assert!(body.contains_rect(card), "card={card:?} body={body:?}"); + assert!((card.width() - 560.0).abs() <= 2.0); + assert!((card.height() - 300.0).abs() <= 2.0); + assert_eq!( + ctx.layer_id_at(card.center()), + Some(egui::LayerId::new( + egui::Order::Foreground, + egui::Id::new("openless-marketplace-mine-modal"), + )) + ); + } + + #[test] + fn style_editor_overlay_is_one_layer() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::Style, + // 默认是 `unsupported = true`(宿主还没报能力),那样页面会走 unsupported 分支。 + style_unsupported: false, + style_editor_open: true, + ..Default::default() + }; + assert_overlay_keeps_the_card_on_top( + &ctx, + &mut vm, + "openless-style-editor-modal", + "openless-style-editor-card-rect", + CardPlacement::RightDocked, + ); + } + #[test] + fn style_editor_uses_the_published_live_blur_under_its_tint() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + active_page: Page::Style, + style_unsupported: false, + style_editor_open: true, + ..Default::default() + }; + let mut output = None; + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + crate::ui::backdrop::publish(&ctx, Some(egui::TextureId::User(42))); + render(&ctx, &mut vm, &mut Vec::new()); + output = Some(end_pass(&ctx)); + } + let output = output.unwrap(); + let body = layout::body_rect(&ctx); + fn collect_textured(shape: &egui::Shape, out: &mut Vec<(egui::Rect, egui::TextureId)>) { + match shape { + egui::Shape::Rect(rect) => { + if let Some(brush) = &rect.brush { + out.push((rect.rect, brush.fill_texture_id)); + } + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect_textured(shape, out); + } + } + _ => {} + } + } + let mut textured = Vec::new(); + for clipped in &output.shapes { + collect_textured(&clipped.shape, &mut textured); + } assert!( - actions + textured.iter().any(|(rect, texture)| { + *texture == egui::TextureId::User(42) + && *rect + == egui::Rect::from_min_max( + egui::pos2(body.left() + layout::SIDEBAR_WIDTH, body.top()), + body.max, + ) + }), + "style editor should draw the current-frame blurred backdrop: {textured:?}" + ); + } + + #[test] + fn style_editor_form_starts_below_header_and_inside_drawer() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + active_page: Page::Style, + style_unsupported: false, + style_editor_open: true, + style_name: "GEOMETRY_NAME_SENTINEL".into(), + ..Default::default() + }; + let mut output = None; + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + output = Some(end_pass(&ctx)); + } + let output = output.unwrap(); + let card: egui::Rect = ctx.data(|data| { + data.get_temp(egui::Id::new("openless-style-editor-card-rect")) + .unwrap() + }); + let scroll: egui::Rect = ctx.data(|data| { + data.get_temp(egui::Id::new("style-editor-scroll-rect")) + .unwrap() + }); + assert!( + scroll.top() >= card.top() + 78.0 - 1.0, + "form overlaps the fixed header: {scroll:?}, {card:?}" + ); + assert!( + scroll.bottom() <= card.bottom() + 1.0, + "form extends beyond drawer: {scroll:?}, {card:?}" + ); + fn collect(shape: &egui::Shape, out: &mut Vec<(String, egui::Pos2)>) { + match shape { + egui::Shape::Text(text) => out.push((text.galley.text().to_string(), text.pos)), + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, out); + } + } + _ => {} + } + } + let mut text = Vec::new(); + for clipped in &output.shapes { + collect(&clipped.shape, &mut text); + } + let label = |key| openless_linux_egui::tr_l10n(vm.lang, key); + let name = label("style.pack.fieldName"); + let description = label("style.pack.fieldDescription"); + let prompt = label("style.pack.dictation_prompt_title"); + let targets = [ + name, + label("style.pack.fieldAuthor"), + label("style.pack.fieldVersion"), + label("style.pack.fieldTags"), + description, + label("style.pack.fieldModel"), + prompt, + "GEOMETRY_NAME_SENTINEL", + ]; + let positions: Vec<_> = text + .iter() + .filter(|(s, _)| targets.contains(&s.as_str())) + .collect(); + assert!(positions.len() >= 7, "missing editor fields: {positions:?}"); + assert!( + positions .iter() - .any(|action| matches!(action, FrontendAction::Navigate(Page::History))), - "the foreground resize layer must not consume sidebar clicks" + .all(|(_, p)| p.x >= card.left() + 16.0 && p.y >= scroll.top()), + "fields escape inset or overlap header: {positions:?}; {card:?}, {scroll:?}" + ); + let position = |label: &str| positions.iter().find(|(s, _)| s == label).unwrap().1; + assert!(position(name).y < scroll.top() + 120.0); + assert!((position(name).y - position(label("style.pack.fieldAuthor")).y).abs() < 2.0); + assert!((position(name).y - position(label("style.pack.fieldVersion")).y).abs() < 2.0); + assert!(position(label("style.pack.fieldTags")).y > position(name).y + 40.0); + assert!(position(description).y < scroll.top() + 290.0); + assert!( + position(prompt).y < scroll.bottom(), + "prompt should be visible without scrolling past a blank header" + ); + fn collect_inputs(shape: &egui::Shape, borders: &mut Vec<(egui::Rect, egui::Stroke)>) { + match shape { + egui::Shape::Rect(rect) + if rect.rect.width() > 180.0 && rect.rect.height() >= 30.0 => + { + borders.push((rect.rect, rect.stroke)); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect_inputs(shape, borders); + } + } + _ => {} + } + } + let mut borders = Vec::new(); + for clipped in &output.shapes { + collect_inputs(&clipped.shape, &mut borders); + } + let editor_borders: Vec<_> = borders + .iter() + .filter(|(rect, stroke)| { + rect.left() >= card.left() + 16.0 + && rect.width() < card.width() - 20.0 + && card.contains(rect.center()) + && stroke.width >= 0.5 + && stroke.color.a() >= 50 + }) + .collect(); + assert!( + editor_borders.len() >= 7, + "editor inputs and textareas need visible outlines, got {borders:?}" + ); + assert!( + editor_borders + .iter() + .all(|(rect, _)| rect.right() <= card.right() + 1.0), + "editor content must keep a right inset: {editor_borders:?}, card={card:?}" ); } #[test] - fn titlebar_close_control_receives_pointer_clicks() { + fn style_editor_long_text_scrolls_within_fixed_height_inputs() { + fn measure(selection: bool, long: bool) -> (f32, Vec<(f32, f32, f32)>) { + let ctx = egui::Context::default(); + let paragraphs = (0..100) + .map(|i| format!("paragraph {i}: some long text to wrap across the field width\n")) + .collect::(); + let mut vm = FrontendViewModel { + active_page: Page::Style, + style_unsupported: false, + style_editor_open: true, + style_selection_workflow: selection, + style_description: if long { + paragraphs.clone() + } else { + String::new() + }, + style_prompt: if long { + paragraphs.clone() + } else { + String::new() + }, + style_selection_prompt: if long { + paragraphs.clone() + } else { + String::new() + }, + style_voice_edit_prompt: if long { paragraphs } else { String::new() }, + ..Default::default() + }; + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let _ = end_pass(&ctx); + } + let content = ctx.data(|data| { + data.get_temp::(egui::Id::new("style-editor-scroll-content-height")) + .unwrap() + }); + let fields = if selection { + ["description", "selection-prompt", "voice-edit-prompt"].to_vec() + } else { + ["description", "dictation-prompt"].to_vec() + }; + let measures = fields + .iter() + .map(|id| { + ctx.data(|data| { + data.get_temp::<(f32, f32, f32)>(egui::Id::new(( + "style-editor-textarea-measure", + id, + ))) + .unwrap() + }) + }) + .collect(); + (content, measures) + } + for selection in [false, true] { + let (empty_height, empty) = measure(selection, false); + let (long_height, long) = measure(selection, true); + assert!( + (long_height - empty_height).abs() < 10.0, + "long text stretched the drawer: {empty_height} -> {long_height}" + ); + let expected = if selection { + vec![64.0, 128.0, 128.0] + } else { + vec![64.0, 188.0] + }; + for ((empty_field, long_field), expected_height) in + empty.iter().zip(&long).zip(expected) + { + assert!( + (long_field.1 - expected_height).abs() < 2.0, + "wrong fixed field viewport: {long_field:?}" + ); + assert!((empty_field.1 - long_field.1).abs() < 1.0); + assert!( + long_field.0 > long_field.1 + 100.0, + "long text must remain scrollable inside input: {long_field:?}" + ); + } + } + } + + #[test] + fn style_editor_wheel_over_long_prompt_scrolls_input_not_drawer() { let ctx = egui::Context::default(); - frame(&ctx, Vec::new()); - frame(&ctx, Vec::new()); + let mut vm = FrontendViewModel { + active_page: Page::Style, + style_unsupported: false, + style_editor_open: true, + style_prompt: "long prompt line\n".repeat(100), + ..Default::default() + }; + for frame in 0..6 { + let rect: egui::Rect = ctx + .data(|data| { + data.get_temp(egui::Id::new(( + "style-editor-textarea-rect", + "dictation-prompt", + ))) + }) + .unwrap_or(egui::Rect::from_min_size( + egui::pos2(750.0, 650.0), + egui::vec2(200.0, 100.0), + )); + let events = if frame >= 2 { + vec![ + egui::Event::PointerMoved(rect.center()), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -100.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }, + ] + } else { + Vec::new() + }; + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events, + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let _ = end_pass(&ctx); + } + let (_, visible, offset): (f32, f32, f32) = ctx.data(|data| { + data.get_temp(egui::Id::new(( + "style-editor-textarea-measure", + "dictation-prompt", + ))) + .unwrap() + }); + assert!( + offset > 20.0, + "wheel did not scroll the bounded prompt: visible={visible}, offset={offset}" + ); + let outer: f32 = ctx.data(|data| { + data.get_temp(egui::Id::new("style-editor-scroll-offset")) + .unwrap() + }); + assert!( + outer < 2.0, + "wheel over the prompt scrolled the drawer: {outer}" + ); + } - 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, - }], + /// Editor parity: upstream renders a right-hand drawer whose body follows the + /// workflow switch (dictation prompt vs. the two selection prompts) and whose + /// destructive button is "reset built-in" or "delete imported" depending on + /// the pack kind. Both must reach Core, so they are distinct actions. + #[test] + fn style_editor_drawer_follows_the_workflow_and_the_pack_kind() { + let zh = openless_linux_egui::Lang::ZhCn; + let ctx = egui::Context::default(); + + let mut dictation = FrontendViewModel { + lang: zh, + active_page: Page::Style, + style_unsupported: false, + style_editor_open: true, + style_editor_id: "builtin-light".to_string(), + style_editor_builtin: true, + style_editor_dirty: true, + ..Default::default() + }; + let mut lines = Vec::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut dictation, &mut actions); + lines = painted_text(&crate::ui::frontend::end_pass(&ctx)) + .lines() + .map(|line| line.trim().to_string()) + .collect(); + } + let has = |key: &'static str| { + let label = openless_linux_egui::tr_l10n(zh, key); + assert!( + lines.iter().any(|line| line == label), + "{key} ({label:?}) must be painted in {lines:?}" + ); + }; + has("style.pack.editorTitle"); + has("style.pack.dictationPromptEditorDesc"); + has("style.pack.dictation_prompt_title"); + has("style.pack.resetBuiltin"); + has("style.pack.unsaved"); + assert!( + !lines.iter().any(|line| { + line == openless_linux_egui::tr_l10n(zh, "style.pack.voiceEditPromptTitle") + }), + "the dictation workflow must not show the selection-voice prompt" ); - let actions = frame( + assert!( + !lines.iter().any(|line| { + line == openless_linux_egui::tr_l10n(zh, "style.pack.deleteImported") + }), + "a built-in pack is reset, never deleted" + ); + + // Same drawer, selection workflow, imported pack. + let mut selection = FrontendViewModel { + lang: zh, + active_page: Page::Style, + style_unsupported: false, + style_editor_open: true, + style_editor_id: "custom-legal".to_string(), + style_editor_builtin: false, + style_selection_workflow: true, + ..Default::default() + }; + let mut selection_lines = Vec::new(); + for frame in 0..6 { + let events = if frame >= 2 { + vec![ + egui::Event::PointerMoved(egui::pos2(1000.0, 550.0)), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -220.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }, + ] + } else { + Vec::new() + }; + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events, + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut selection, &mut actions); + selection_lines.extend( + painted_text(&crate::ui::frontend::end_pass(&ctx)) + .lines() + .map(|line| line.trim().to_string()), + ); + } + lines = selection_lines; + let has_selection = |key: &'static str| { + let label = openless_linux_egui::tr_l10n(zh, key); + assert!( + lines.iter().any(|line| line == label), + "{key} ({label:?}) must be painted in {lines:?}" + ); + }; + has_selection("style.pack.selectionPromptEditorDesc"); + has_selection("style.pack.selectionPromptTitle"); + has_selection("style.pack.voiceEditPromptTitle"); + has_selection("style.pack.deleteImported"); + assert!( + !lines.iter().any(|line| { + line == openless_linux_egui::tr_l10n(zh, "style.pack.resetBuiltin") + }), + "an imported pack is deleted, never reset" + ); + } + + #[test] + fn history_confirm_overlay_is_one_layer() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::History, + ..Default::default() + }; + vm.history_confirm = Some(super::view_model::HistoryConfirm::Clear); + assert_overlay_keeps_the_card_on_top( &ctx, - vec![egui::Event::PointerButton { - pos: pointer, - button: egui::PointerButton::Primary, - pressed: false, - modifiers: egui::Modifiers::NONE, - }], + &mut vm, + "openless-history-confirm", + "openless-history-confirm-card-rect", + CardPlacement::Centred, + ); + } + + /// 无边框窗口的四个拖拽区必须给出对应方向的拉伸光标(否则用户看不出窗口 + /// 能拉伸)。指针放在左边缘中部时应当得到 ResizeHorizontal。 + #[test] + fn the_window_edges_show_a_resize_cursor_on_hover() { + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1240.0, 800.0)); + // 拖拽区贴在**窗口**边上,而窗口是屏幕内缩 6px 的圆角表面。 + let window = screen.shrink(6.0); + let probes = [ + ( + egui::pos2(window.left() + 2.0, window.center().y), + egui::CursorIcon::ResizeHorizontal, + ), + ( + egui::pos2(window.center().x, window.bottom() - 2.0), + egui::CursorIcon::ResizeVertical, + ), + ( + egui::pos2(window.right() - 4.0, window.bottom() - 4.0), + egui::CursorIcon::ResizeNwSe, + ), + ( + egui::pos2(window.right() - 4.0, window.top() + 4.0), + egui::CursorIcon::ResizeNeSw, + ), + ]; + for (position, expected) in probes { + let mut icon = egui::CursorIcon::Default; + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(screen), + events: vec![egui::Event::PointerMoved(position)], + ..Default::default() + }); + layout::resize_handles(&ctx); + // 光标在 platform_output 里,而 `end_pass` 会把 output 取走, + // 所以要读返回值而不是 `ctx.output(...)`。 + icon = crate::ui::frontend::end_pass(&ctx) + .platform_output + .cursor_icon; + } + assert_eq!( + icon, expected, + "hovering {position:?} must show {expected:?}, got {icon:?}" + ); + } + } + + /// 设置页顶部不该再有那一层「设置」标题栏:Tauri 的桌面端左栏顶端是搜索框、 + /// 右栏顶端才是「标题 + 修改后自动保存 + 关闭」。侧栏导航里那一个「设置」仍在。 + #[test] + fn the_settings_overlay_has_no_separate_title_bar() { + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let settings_label = openless_linux_egui::tr_l10n(zh, "nav.settings"); + let auto_save = openless_linux_egui::tr_l10n(zh, "modal.auto_save_hint"); + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + ..Default::default() + }; + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + } + // 只数「整行就是这个标签」的次数:侧栏导航那一个是正常的,多出来的那个 + // 就是被删掉的顶栏标题(子串匹配会把「查找设置分类…」也算进去)。 + let standalone_titles = painted + .lines() + .filter(|line| line.trim() == settings_label) + .count(); + assert_eq!( + standalone_titles, 1, + "the overlay must not draw its own settings title (only the sidebar entry may say it): {painted}" + ); + assert!( + painted.contains(openless_linux_egui::tr_l10n(zh, "modal.sections.general")), + "the content pane still owns the section title: {painted}" ); + assert!( + painted.contains(auto_save), + "the auto-save hint belongs to the content pane's own header row: {painted}" + ); + } + /// 录音分区必须真的带上用户点名的几行:可录制的录音快捷键、首选麦克风、 + /// 录音胶囊开关与胶囊样式、录音提示音的试听按钮。 + #[test] + fn the_recording_section_renders_the_rows_the_user_asked_for() { + let ctx = egui::Context::default(); + let zh = openless_linux_egui::Lang::ZhCn; + let mut vm = FrontendViewModel { + lang: zh, + active_page: Page::Settings, + settings_open: true, + ..Default::default() + }; + vm.dictation_hotkey = "Alt+A".to_string(); + vm.settings.microphone_options = vec!["USB microphone".to_string()]; + let mut painted = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + let mut actions = Vec::new(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + } + for key in [ + "settings.recording.hotkey_label", + "settings.recording.microphone_label", + "settings.recording.capsule_label", + "settings.recording.capsule_style_label", + "settings.recording.audio_cue_label", + "settings.recording.audio_cue_preview", + ] { + let label = openless_linux_egui::tr_l10n(zh, key); + assert!( + painted.contains(label), + "missing {key} ({label}) in {painted}" + ); + } assert!( - actions - .iter() - .any(|action| matches!(action, FrontendAction::WindowClose)), - "the titlebar container must not consume the close button click" + !painted.contains(openless_linux_egui::tr_l10n( + zh, + "settings.recording.microphone_load_error" + )), + "no microphone error line may be painted without an error: {painted}" + ); + } + + /// 回归排查:设置里可展开分组必须「点标题行即可开合」。用户报过「点不开」, + /// 真机(uinput 点击)与这条无头测试都表明交互是好的 —— 留着防止真回归。 + #[test] + fn the_collapsible_group_header_toggles_on_click() { + let ctx = egui::Context::default(); + let mut painted; + let mut states = Vec::new(); + for frame in 0..4 { + let events = if frame == 1 { + vec![egui::Event::PointerMoved(egui::pos2(120.0, 30.0))] + } else if frame == 2 { + vec![egui::Event::PointerButton { + pos: egui::pos2(120.0, 30.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }] + } else if frame == 3 { + vec![egui::Event::PointerButton { + pos: egui::pos2(120.0, 30.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + }] + } else { + Vec::new() + }; + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(800.0, 600.0), + )), + events, + ..Default::default() + }, + |ui| { + super::settings::test_group_toggle(ui); + }, + ); + painted = painted_text(&output); + states.push(painted.contains("GROUPCONTENT")); + } + assert!(states[0], "collapsible groups start expanded, like Tauri"); + assert!( + !states[3], + "the group must collapse after its header is clicked: states={states:?}" + ); + } + + /// 回归排查:按住标题栏必须发出 `ViewportCommand::StartDrag`(窗口能被拖走)。 + /// 用户报过「拖标题栏移不动窗口」,真机 uinput 拖拽实测窗口确实移动 + /// (340,118 → 490,218),这条锁住发出指令那一环。 + #[test] + fn the_titlebar_press_starts_a_window_drag() { + let ctx = egui::Context::default(); + let window = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1240.0, 800.0)); + let mut started = false; + for frame in 0..3 { + let events = if frame == 1 { + vec![egui::Event::PointerMoved(egui::pos2(600.0, 25.0))] + } else if frame == 2 { + vec![egui::Event::PointerButton { + pos: egui::pos2(600.0, 25.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }] + } else { + Vec::new() + }; + ctx.begin_pass(egui::RawInput { + screen_rect: Some(window), + events, + ..Default::default() + }); + let mut actions = Vec::new(); + layout::titlebar(&ctx, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + for commands in output.viewport_output.values() { + if commands + .commands + .iter() + .any(|command| matches!(command, egui::ViewportCommand::StartDrag)) + { + started = true; + } + } + } + assert!( + started, + "pressing the titlebar must emit ViewportCommand::StartDrag" + ); + } + + /// 回归排查:`StartDrag` 在 egui-winit 里带着 `window.has_focus()` 前置条件, + /// 未聚焦时会被丢掉(Wayland 的 `move` 又只认按下那一帧的 serial)。所以窗口 + /// 未聚焦时按住的每一帧都得继续补发;已聚焦且没新按下时不得每帧乱发。 + #[test] + fn the_titlebar_keeps_asking_while_the_window_is_unfocused() { + let window = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1240.0, 800.0)); + let press = egui::Event::PointerButton { + pos: egui::pos2(600.0, 25.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }; + + let run = |focused: Option, frames: usize| -> Vec { + let ctx = egui::Context::default(); + let mut per_frame = Vec::new(); + for frame in 0..frames { + // 第 1 帧先把指针移到标题栏上,第 2 帧按下,之后保持按住。 + let events = if frame == 1 { + vec![egui::Event::PointerMoved(egui::pos2(600.0, 25.0))] + } else if frame == 2 { + vec![press.clone()] + } else { + Vec::new() + }; + let mut raw = egui::RawInput { + screen_rect: Some(window), + events, + ..Default::default() + }; + raw.viewports.insert( + raw.viewport_id, + egui::ViewportInfo { + focused, + ..Default::default() + }, + ); + ctx.begin_pass(raw); + let mut actions = Vec::new(); + layout::titlebar(&ctx, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + per_frame.push(output.viewport_output.values().any(|commands| { + commands + .commands + .iter() + .any(|command| matches!(command, egui::ViewportCommand::StartDrag)) + })); + } + per_frame + }; + + // 未聚焦:按下帧之后(第 3、4 帧)必须继续补发。 + let unfocused = run(Some(false), 5); + assert!(unfocused[2], "the press frame must ask for the drag"); + assert!( + unfocused[3] && unfocused[4], + "while unfocused the titlebar must keep asking: {unfocused:?}" + ); + // 已聚焦:只在按下那一帧发一次,不能每帧刷。 + let focused = run(Some(true), 5); + assert!(focused[2], "the press frame must ask for the drag"); + assert!( + !focused[3] && !focused[4], + "a focused window must not re-ask every frame: {focused:?}" ); } } diff --git a/openless-all/app/linux-egui/src/ui/frontend/overview.rs b/openless-all/app/linux-egui/src/ui/frontend/overview.rs new file mode 100644 index 000000000..3965a6b2d --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/overview.rs @@ -0,0 +1,1380 @@ +//! Overview page — the 2.0 dashboard. +//! +//! This is a 1:1 port of the Tauri `pages/Overview.tsx` layout: +//! +//! ```text +//! ┌ title + refresh ───────────────────────────────────────────┐ +//! │ 当前语音服务 (only while a provider is unconfigured) │ +//! │ 使用记录 [chars] [duration] [avg] [total] │ +//! │ ┌ period chart (7/30d × count/chars/duration) ┐ ┌ recent ┐ │ +//! │ └───────────────────────────────────────────────┘ └────────┘ │ +//! │ 年度活动 heatmap │ +//! └─────────────────────────────────────────────────────────────┘ +//! ``` +//! +//! The page is a single-screen dashboard: it fills the height the shell gives +//! it, wraps its columns when the window is narrow, and never scrolls as a +//! whole (only the recent list scrolls internally). Every visible string comes +//! from the localization catalog through `vm.lang`; nothing is hardcoded. + +use eframe::egui; + +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; + +use super::icons::{self, IconName}; +use super::layout; +use super::theme; +use super::view_model::{ + FrontendAction, FrontendViewModel, OverviewActivityDay, OverviewMode, OverviewRecentEntry, + OverviewSummary, Page, +}; + +const GAP: f32 = 12.0; +const SECTION_GAP: f32 = 14.0; +/// 窄屏断点。Tauri 用的是**整个窗口**宽度(`useMobileLayout(720)`: +/// `window.innerWidth < 720`),不是内容区宽度;egui 这里换算回窗口宽度, +/// 否则主窗口缩到最小(960)时内容区 ≈748 < 原来的 760 阀值 → 两块卡片会错误地 +/// 上下堆叠(用户报的「窗口缩到最小后星期几张图被遮住」)。 +const MOBILE_BREAKPOINT: f32 = 720.0; +const METRIC_CARD_HEIGHT: f32 = 92.0; +const PROVIDER_CARD_HEIGHT: f32 = 104.0; +const CARD_PADDING: f32 = 14.0; +const BOTTOM_MIN_HEIGHT: f32 = 170.0; +const HEATMAP_MIN_HEIGHT: f32 = 90.0; +const LINE_SOFT: egui::Color32 = theme::LINE_SOFT; +const MONO: f32 = 12.0; + +// ── Entry point ───────────────────────────────────────────────────────────── + +/// Minimum height the single-screen dashboard needs before it starts +/// scrolling: header + stats row + chart row + heatmap. +const OVERVIEW_MIN_HEIGHT: f32 = 600.0; + +pub fn page(ui: &mut egui::Ui, vm: &FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + // `width` 是内容区宽度(还减了 24px 滚动条/内边距),换算回窗口宽度再比断点。 + let mobile = width + 24.0 + layout::SIDEBAR_WIDTH < MOBILE_BREAKPOINT; + + // 单屏仪表盘优先;窗口高度不够时整页滚动,而不是把底部的年度活动热力图裁掉 + // (裁掉就完全摸不到了)。 + let min_height = overview_min_height(ui, width, vm, mobile); + if ui.available_height() < min_height { + let scroll_output = egui::ScrollArea::vertical() + .id_salt("openless-overview-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_min_width(width); + ui.set_max_width(width); + body(ui, width, vm, actions, min_height, mobile); + }); + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("overview-scroll-measure"), + ( + scroll_output.content_size.y, + scroll_output.inner_rect.height(), + scroll_output.state.offset.y, + ), + ) + }); + #[cfg(not(test))] + let _ = scroll_output; + return; + } + body(ui, width, vm, actions, 0.0, mobile); +} + +/// Reserve enough room for the sections actually shown. A fixed 600px cutoff +/// did not account for unconfigured provider cards + the yearly heatmap: at +/// intermediate window heights the body shrank/dropped the heatmap without +/// ever entering its scrollable layout. +fn overview_min_height(ui: &egui::Ui, width: f32, vm: &FrontendViewModel, mobile: bool) -> f32 { + let base = OVERVIEW_MIN_HEIGHT + if mobile { BOTTOM_MIN_HEIGHT } else { 0.0 }; + let Some(summary) = vm + .overview + .as_ref() + .filter(|_| !vm.overview_loading && vm.overview_error.is_none()) + else { + return base; + }; + let mut needed = 34.0 + + SECTION_GAP + + 18.0 + + 8.0 + + METRIC_CARD_HEIGHT + + if mobile { + GAP + METRIC_CARD_HEIGHT + } else { + 0.0 + } + + SECTION_GAP + + BOTTOM_MIN_HEIGHT; + let pending = usize::from(!summary.asr_configured) + usize::from(!summary.llm_configured); + if pending > 0 { + let rows = if mobile { pending } else { 1 }; + needed += + SECTION_GAP + 20.0 + 8.0 + rows as f32 * PROVIDER_CARD_HEIGHT + (rows - 1) as f32 * GAP; + } + if vm.settings.activity_heatmap && summary.heatmap.iter().any(|day| day.count > 0) { + let columns = heatmap_columns(summary.heatmap_year, summary.heatmap.len()); + needed += SECTION_GAP + heatmap_card_height(width, columns); + } + needed += ui.spacing().item_spacing.y * 8.0; + base.max(needed) +} + +/// One dashboard layout. `forced_total` > 0 lays the page out for a scroll +/// container of that height instead of the current viewport. +fn body( + ui: &mut egui::Ui, + width: f32, + vm: &FrontendViewModel, + actions: &mut Vec, + forced_total: f32, + mobile: bool, +) { + let lang = vm.lang; + let start_y = ui.cursor().min.y; + + header(ui, width, lang, actions); + + if vm.overview_loading { + ui.add_space(SECTION_GAP); + placeholder_card( + ui, + width, + tr_l10n(lang, "loading.overview"), + false, + lang, + actions, + ); + return; + } + if let Some(error) = vm.overview_error.as_deref() { + ui.add_space(SECTION_GAP); + placeholder_card(ui, width, error, true, lang, actions); + return; + } + let Some(summary) = vm.overview.as_ref() else { + ui.add_space(SECTION_GAP); + placeholder_card( + ui, + width, + tr_l10n(lang, "overview.metric_no_data"), + true, + lang, + actions, + ); + return; + }; + + ui.add_space(SECTION_GAP); + + if !(summary.asr_configured && summary.llm_configured) { + providers_section(ui, width, summary, lang, actions, mobile); + ui.add_space(SECTION_GAP); + } + + stats_section(ui, width, summary, lang, mobile); + ui.add_space(SECTION_GAP); + + // The bottom row absorbs the leftover height; the heatmap keeps its + // natural size unless the window is too short, in which case it shrinks + // (and below `HEATMAP_MIN_HEIGHT` it is dropped) so nothing overflows. + // It is only shown when the preference is on and there is activity to draw + // (matching the Tauri `showOverviewActivityHeatmap` gate). + let available = if forced_total > 0.0 { + (forced_total - (ui.cursor().min.y - start_y)).max(BOTTOM_MIN_HEIGHT) + } else { + ui.available_height().max(0.0) + }; + let heatmap_cols = heatmap_columns(summary.heatmap_year, summary.heatmap.len()); + let ideal_heatmap = heatmap_card_height(width, heatmap_cols); + let mut heatmap_height = ideal_heatmap; + // egui advances the cursor by item_spacing after bottom_row's allocated + // widget. Without reserving it, the heatmap slips under the white bottom + // gutter (the bottom few pixels are clipped by content_panel). + let row_spacing = ui.spacing().item_spacing.y; + if available - heatmap_height - SECTION_GAP - row_spacing < BOTTOM_MIN_HEIGHT { + heatmap_height = (available - BOTTOM_MIN_HEIGHT - SECTION_GAP - row_spacing).max(0.0); + } + let has_activity = summary.heatmap.iter().any(|day| day.count > 0); + let show_heatmap = + heatmap_height >= HEATMAP_MIN_HEIGHT && vm.settings.activity_heatmap && has_activity; + let bottom_height = if show_heatmap { + (available - heatmap_height - SECTION_GAP - row_spacing).max(BOTTOM_MIN_HEIGHT) + } else { + available.max(BOTTOM_MIN_HEIGHT) + }; + + bottom_row(ui, width, bottom_height, vm, summary, actions, mobile); + + if show_heatmap { + ui.add_space(SECTION_GAP); + heatmap_card(ui, width, heatmap_height, heatmap_cols, summary, lang); + } +} + +// ── Header ────────────────────────────────────────────────────────────────── + +fn header(ui: &mut egui::Ui, width: f32, lang: Lang, actions: &mut Vec) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 34.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + rect.left_center(), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "overview.title"), + theme::medium_font(26.0), + theme::INK, + ); + let label = tr_l10n(lang, "overview.refresh"); + let button_width = layout::text_width(ui, label, 12.5) + 42.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - button_width, rect.center().y - 15.0), + egui::vec2(button_width, 30.0), + ); + if layout::action_button( + ui, + button_rect, + label, + Some(IconName::Refresh), + layout::ButtonKind::Ghost, + ) + .clicked() + { + actions.push(FrontendAction::OverviewRefresh); + } +} + +fn placeholder_card( + ui: &mut egui::Ui, + width: f32, + message: &str, + retry: bool, + lang: Lang, + actions: &mut Vec, +) { + let height = 132.0; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + paint_card(ui.painter(), rect); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2( + rect.center().x, + rect.center().y - if retry { 14.0 } else { 0.0 }, + ), + egui::Align2::CENTER_CENTER, + message, + egui::FontId::proportional(12.5), + theme::INK_3, + ); + if retry { + let label = tr_l10n(lang, "overview.retry"); + let button_width = layout::text_width(ui, label, 12.5) + 26.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(rect.center().x - button_width / 2.0, rect.center().y + 4.0), + egui::vec2(button_width, 28.0), + ); + if layout::action_button(ui, button_rect, label, None, layout::ButtonKind::Ghost).clicked() + { + actions.push(FrontendAction::OverviewRefresh); + } + } +} + +// ── Providers ─────────────────────────────────────────────────────────────── + +fn providers_section( + ui: &mut egui::Ui, + width: f32, + summary: &OverviewSummary, + lang: Lang, + actions: &mut Vec, + mobile: bool, +) { + let (heading, _) = ui.allocate_exact_size(egui::vec2(width, 20.0), egui::Sense::hover()); + ui.painter().with_clip_rect(heading).text( + heading.left_center(), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "overview.services_title"), + theme::medium_font(13.0), + theme::INK_2, + ); + ui.add_space(8.0); + + // Only providers still waiting for configuration are worth a card. + let mut pending: Vec<(&'static str, String, &'static str, IconName)> = Vec::new(); + if !summary.asr_configured { + pending.push(( + tr_l10n(lang, "overview.asr_kind"), + provider_name(&summary.asr_provider, lang), + tr_l10n(lang, "overview.provider_help_asr"), + IconName::Mic, + )); + } + if !summary.llm_configured { + pending.push(( + tr_l10n(lang, "overview.llm_kind"), + provider_name(&summary.llm_provider, lang), + tr_l10n(lang, "overview.provider_help_llm"), + IconName::Sparkle, + )); + } + if pending.is_empty() { + return; + } + + // Tauri:`mobile || pendingProviders.length < 2` → 单列,否则两列。 + let columns = if pending.len() < 2 || mobile { 1 } else { 2 }; + let mut index = 0; + while index < pending.len() { + let count = columns.min(pending.len() - index); + cards_row( + ui, + width, + PROVIDER_CARD_HEIGHT, + count, + GAP, + |ui, slot, rect| { + let (kind, name, help, icon) = &pending[index + slot]; + provider_card(ui, rect, kind, name, help, *icon, lang, actions); + }, + ); + index += count; + if index < pending.len() { + ui.add_space(GAP); + } + } +} + +fn provider_name(active: &str, lang: Lang) -> String { + if active.trim().is_empty() { + tr_l10n(lang, "overview.not_set").to_string() + } else { + active.to_string() + } +} + +#[allow(clippy::too_many_arguments)] +fn provider_card( + ui: &mut egui::Ui, + rect: egui::Rect, + kind: &str, + name: &str, + help: &str, + icon: IconName, + lang: Lang, + actions: &mut Vec, +) { + card_scope(ui, rect, 16.0, |ui, inner| { + let icon_rect = egui::Rect::from_min_size(inner.min, egui::vec2(38.0, 38.0)); + let painter = ui.painter().with_clip_rect(inner); + painter.rect_filled(icon_rect, egui::CornerRadius::same(10), theme::BLUE_SOFT); + icons::draw_icon(ui, icon_rect.center(), icon, theme::BLUE); + + let text_left = inner.left() + 50.0; + painter.text( + egui::pos2(text_left, inner.top() + 3.0), + egui::Align2::LEFT_TOP, + kind, + egui::FontId::proportional(12.5), + theme::INK_4, + ); + let pill = tr_l10n(lang, "overview.unconfigured"); + let pill_width = layout::text_width(ui, pill, 10.5) + 18.0; + let pill_rect = egui::Rect::from_min_size( + egui::pos2( + text_left + layout::text_width(ui, kind, 12.5) + 8.0, + inner.top() + 2.0, + ), + egui::vec2(pill_width, 18.0), + ); + painter.rect_stroke( + pill_rect, + egui::CornerRadius::same(9), + egui::Stroke::new(0.7, theme::LINE), + egui::StrokeKind::Inside, + ); + painter.text( + pill_rect.center(), + egui::Align2::CENTER_CENTER, + pill, + egui::FontId::proportional(10.5), + theme::INK_3, + ); + painter.text( + egui::pos2(text_left, inner.top() + 24.0), + egui::Align2::LEFT_TOP, + name, + egui::FontId::proportional(15.0), + theme::INK, + ); + + // Bottom row: help text on the left, configure action on the right. + let label = tr_l10n(lang, "overview.configure_provider"); + let button_width = layout::text_width(ui, label, 12.5) + 34.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(inner.right() - button_width, inner.bottom() - 28.0), + egui::vec2(button_width, 28.0), + ); + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = (button_rect.left() - inner.left() - 12.0).max(10.0); + job.wrap.max_rows = 2; + job.append( + help, + 0.0, + egui::text::TextFormat { + font_id: egui::FontId::proportional(12.5), + color: theme::INK_3, + ..Default::default() + }, + ); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + painter.galley( + egui::pos2(inner.left(), button_rect.center().y - galley.size().y / 2.0), + galley, + theme::INK_3, + ); + if layout::action_button(ui, button_rect, label, None, layout::ButtonKind::Ghost).clicked() + { + actions.push(FrontendAction::ToggleSettings); + } + }); +} + +// ── Usage metrics ─────────────────────────────────────────────────────────── + +fn stats_section( + ui: &mut egui::Ui, + width: f32, + summary: &OverviewSummary, + lang: Lang, + mobile: bool, +) { + let (heading, _) = ui.allocate_exact_size(egui::vec2(width, 18.0), egui::Sense::hover()); + ui.painter().with_clip_rect(heading).text( + heading.left_center(), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "overview.stats_title"), + theme::medium_font(13.0), + theme::INK_2, + ); + ui.add_space(8.0); + + if mobile { + cards_row(ui, width, METRIC_CARD_HEIGHT, 2, GAP, |ui, slot, rect| { + metric_card(ui, rect, slot, summary, lang); + }); + ui.add_space(GAP); + cards_row(ui, width, METRIC_CARD_HEIGHT, 2, GAP, |ui, slot, rect| { + metric_card(ui, rect, slot + 2, summary, lang); + }); + } else { + cards_row(ui, width, METRIC_CARD_HEIGHT, 4, GAP, |ui, slot, rect| { + metric_card(ui, rect, slot, summary, lang); + }); + } +} + +fn metric_card( + ui: &mut egui::Ui, + rect: egui::Rect, + index: usize, + summary: &OverviewSummary, + lang: Lang, +) { + let (icon, label, value, trend) = match index { + 0 => ( + IconName::Hash, + tr_l10n(lang, "overview.metric_chars"), + format_number(summary.chars_today), + fmt_l10n(lang, "overview.metric_segments", &[&summary.segments_today]), + ), + 1 => ( + IconName::Mic, + tr_l10n(lang, "overview.metric_duration"), + short_duration(summary.duration_ms_today, lang), + String::new(), + ), + 2 => ( + IconName::Clock, + tr_l10n(lang, "overview.metric_avg"), + short_duration(summary.avg_latency_ms, lang), + if summary.segments_today > 0 { + tr_l10n(lang, "overview.metric_avg_trend").to_string() + } else { + tr_l10n(lang, "overview.metric_no_data").to_string() + }, + ), + _ => ( + IconName::Bolt, + tr_l10n(lang, "overview.metric_total"), + format_number(summary.history_total as u64), + fmt_l10n( + lang, + "overview.metric_total_trend", + &[&openless_core::HISTORY_CAP], + ), + ), + }; + + card_scope(ui, rect, CARD_PADDING, |ui, inner| { + let painter = ui.painter().with_clip_rect(inner); + icons::draw_icon( + ui, + egui::pos2(inner.left() + 6.5, inner.top() + 7.0), + icon, + theme::INK_3, + ); + painter.text( + egui::pos2(inner.left() + 18.0, inner.top() + 7.0), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.5), + theme::INK_3, + ); + painter.text( + egui::pos2(inner.left(), inner.top() + 20.0), + egui::Align2::LEFT_TOP, + value, + egui::FontId::proportional(22.0), + theme::INK, + ); + painter.text( + egui::pos2(inner.left(), inner.bottom() - 16.0), + egui::Align2::LEFT_TOP, + trend, + egui::FontId::proportional(12.0), + theme::INK_4, + ); + }); +} + +// ── Bottom row: period chart + recent list ────────────────────────────────── + +fn bottom_row( + ui: &mut egui::Ui, + width: f32, + height: f32, + vm: &FrontendViewModel, + summary: &OverviewSummary, + actions: &mut Vec, + mobile: bool, +) { + let lang = vm.lang; + if !mobile { + let (row, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let left_width = ((width - GAP) / 2.4).max(220.0); + let right_width = (width - GAP - left_width).max(220.0); + period_card( + ui, + egui::Rect::from_min_size(row.min, egui::vec2(left_width, height)), + vm, + summary, + lang, + actions, + ); + recent_card( + ui, + egui::Rect::from_min_size( + egui::pos2(row.left() + left_width + GAP, row.top()), + egui::vec2(right_width, height), + ), + summary, + lang, + actions, + ); + } else { + let chart_height = 190.0; + let (chart, _) = + ui.allocate_exact_size(egui::vec2(width, chart_height), egui::Sense::hover()); + period_card(ui, chart, vm, summary, lang, actions); + ui.add_space(GAP); + let recent_height = (height - chart_height - GAP).max(150.0); + let (recent, _) = + ui.allocate_exact_size(egui::vec2(width, recent_height), egui::Sense::hover()); + recent_card(ui, recent, summary, lang, actions); + } +} + +fn period_card( + ui: &mut egui::Ui, + rect: egui::Rect, + vm: &FrontendViewModel, + summary: &OverviewSummary, + lang: Lang, + actions: &mut Vec, +) { + let period = vm.overview_period.min(1); + let metric = vm.overview_metric.min(2); + card_scope(ui, rect, 18.0, |ui, inner| { + let period_labels = [ + tr_l10n(lang, "overview.period_last7").to_string(), + tr_l10n(lang, "overview.period_last30").to_string(), + ]; + let metric_labels = [ + tr_l10n(lang, "overview.metric_count").to_string(), + tr_l10n(lang, "overview.metric_chars_name").to_string(), + tr_l10n(lang, "overview.metric_duration_name").to_string(), + ]; + let period_width = segmented_width(ui, &period_labels); + let metric_width = segmented_width(ui, &metric_labels); + let toggle_height = 26.0; + let mut y = inner.top(); + if period_width + metric_width + 8.0 <= inner.width() { + let period_rect = egui::Rect::from_min_size( + egui::pos2(inner.left(), y), + egui::vec2(period_width, toggle_height), + ); + if let Some(selected) = segmented(ui, period_rect, &period_labels, period) { + actions.push(FrontendAction::OverviewPeriod(selected)); + } + let metric_rect = egui::Rect::from_min_size( + egui::pos2(inner.right() - metric_width, y), + egui::vec2(metric_width, toggle_height), + ); + if let Some(selected) = segmented(ui, metric_rect, &metric_labels, metric) { + actions.push(FrontendAction::OverviewMetric(selected)); + } + y += toggle_height + 14.0; + } else { + let period_rect = egui::Rect::from_min_size( + egui::pos2(inner.left(), y), + egui::vec2(period_width, toggle_height), + ); + if let Some(selected) = segmented(ui, period_rect, &period_labels, period) { + actions.push(FrontendAction::OverviewPeriod(selected)); + } + y += toggle_height + 8.0; + let metric_rect = egui::Rect::from_min_size( + egui::pos2(inner.left(), y), + egui::vec2(metric_width, toggle_height), + ); + if let Some(selected) = segmented(ui, metric_rect, &metric_labels, metric) { + actions.push(FrontendAction::OverviewMetric(selected)); + } + y += toggle_height + 14.0; + } + + let buckets = period_buckets(summary, period); + let total: f64 = buckets + .iter() + .map(|(_, day)| metric_value(day, metric)) + .sum(); + let daily = if buckets.is_empty() { + 0.0 + } else { + total / buckets.len() as f64 + }; + + let painter = ui.painter().with_clip_rect(inner); + painter.text( + egui::pos2(inner.left(), y), + egui::Align2::LEFT_TOP, + format_metric_value(total, metric, lang), + egui::FontId::proportional(26.0), + theme::INK, + ); + painter.text( + egui::pos2(inner.left(), y + 34.0), + egui::Align2::LEFT_TOP, + fmt_l10n( + lang, + "overview.daily_avg", + &[&format_metric_value(daily, metric, lang)], + ), + egui::FontId::proportional(12.0), + theme::INK_4, + ); + let chart_rect = + egui::Rect::from_min_max(egui::pos2(inner.left(), y + 54.0), inner.right_bottom()); + if chart_rect.height() > 24.0 && chart_rect.width() > 24.0 { + period_chart(ui, chart_rect, &buckets, metric, lang); + } + }); +} + +fn period_buckets(summary: &OverviewSummary, period: usize) -> Vec<(String, OverviewActivityDay)> { + let days = if period == 0 { 7 } else { 30 }; + let start = summary.activity_daily.len().saturating_sub(days); + summary.activity_daily[start..] + .iter() + .map(|day| (day.date.clone(), day.clone())) + .collect() +} + +fn period_chart( + ui: &mut egui::Ui, + rect: egui::Rect, + buckets: &[(String, OverviewActivityDay)], + metric: usize, + lang: Lang, +) { + if buckets.is_empty() { + return; + } + let dense = buckets.len() > 7; + let gap = if dense { 2.0 } else { 8.0 }; + let max = buckets + .iter() + .map(|(_, day)| metric_value(day, metric)) + .fold(1.0_f64, f64::max); + let label_height = 14.0; + let bars_top = rect.top() + if dense { 0.0 } else { 14.0 }; + let bars_bottom = rect.bottom() - label_height; + let bars_height = (bars_bottom - bars_top).max(8.0); + let bar_width = + ((rect.width() - gap * (buckets.len() as f32 - 1.0)) / buckets.len() as f32).max(1.0); + let weekday_labels = split_labels(tr_l10n(lang, "overview.week_days")); + let painter = ui.painter().with_clip_rect(rect); + let last = buckets.len() - 1; + + for (index, (date, day)) in buckets.iter().enumerate() { + let value = metric_value(day, metric); + let left = rect.left() + index as f32 * (bar_width + gap); + let is_today = index == last; + let height = ((value / max) * bars_height as f64) as f32; + let bar = egui::Rect::from_min_max( + egui::pos2(left, bars_bottom - height.max(2.0)), + egui::pos2(left + bar_width, bars_bottom), + ); + let color = if is_today { + theme::BLUE + } else { + with_alpha(theme::INK_4, if value <= 0.0 { 0.15 } else { 0.85 }) + }; + painter.rect_filled( + bar, + egui::CornerRadius::same(if dense { 2 } else { 4 }), + color, + ); + if !dense { + painter.text( + egui::pos2(left + bar_width / 2.0, bars_top - 1.0), + egui::Align2::CENTER_BOTTOM, + format_metric_value(value, metric, lang), + egui::FontId::proportional(9.5), + if is_today { theme::BLUE } else { theme::INK_4 }, + ); + let weekday = weekday_label(date, &weekday_labels); + if !weekday.is_empty() { + painter.text( + egui::pos2(left + bar_width / 2.0, rect.bottom() - label_height + 2.0), + egui::Align2::CENTER_TOP, + weekday, + egui::FontId::proportional(9.5), + theme::INK_4, + ); + } + } + } + + if dense { + for (index, align) in [ + (0usize, egui::Align2::LEFT_TOP), + (last / 2, egui::Align2::CENTER_TOP), + (last, egui::Align2::RIGHT_TOP), + ] { + painter.text( + egui::pos2( + match align { + egui::Align2::LEFT_TOP => rect.left(), + egui::Align2::RIGHT_TOP => rect.right(), + _ => rect.center().x, + }, + rect.bottom() - label_height + 2.0, + ), + align, + short_date(&buckets[index].0), + egui::FontId::proportional(10.0), + theme::INK_4, + ); + } + } +} + +// ── Recent list ───────────────────────────────────────────────────────────── + +fn recent_card( + ui: &mut egui::Ui, + rect: egui::Rect, + summary: &OverviewSummary, + lang: Lang, + actions: &mut Vec, +) { + paint_card(ui.painter(), rect); + let header_height = 42.0; + let header = egui::Rect::from_min_size(rect.min, egui::vec2(rect.width(), header_height)); + let painter = ui.painter().with_clip_rect(rect); + painter.line_segment( + [header.left_bottom(), header.right_bottom()], + egui::Stroke::new(0.5, theme::LINE), + ); + painter.text( + egui::pos2(rect.left() + 18.0, header.center().y), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "overview.recent_title"), + egui::FontId::proportional(13.0), + theme::INK_2, + ); + let label = tr_l10n(lang, "overview.recent_all"); + let button_width = layout::text_width(ui, label, 12.0) + 20.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - 18.0 - button_width, header.center().y - 14.0), + egui::vec2(button_width, 28.0), + ); + if layout::action_button(ui, button_rect, label, None, layout::ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::Navigate(Page::History)); + } + + let list = egui::Rect::from_min_max(egui::pos2(rect.left(), header.bottom()), rect.max); + layout::fixed_ui(ui, list, layout::card_salt(rect), |ui| { + egui::ScrollArea::vertical() + .id_salt("overview-recent-list") + .auto_shrink([false, false]) + .show(ui, |ui| { + let width = ui.available_width(); + if summary.recent.is_empty() { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(width, 64.0), egui::Sense::hover()); + ui.painter().with_clip_rect(rect).text( + rect.center(), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "overview.recent_empty_hint"), + egui::FontId::proportional(12.0), + theme::INK_4, + ); + return; + } + for (index, entry) in summary.recent.iter().enumerate() { + recent_row(ui, width, entry, index, lang); + } + }); + }); +} + +fn recent_row( + ui: &mut egui::Ui, + width: f32, + entry: &OverviewRecentEntry, + index: usize, + lang: Lang, +) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 54.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.line_segment( + [rect.left_bottom(), rect.right_bottom()], + egui::Stroke::new(0.5, LINE_SOFT), + ); + + let padding = 18.0; + let time_label = history_time_label(&entry.created_at); + let mode_label = mode_label(lang, entry.mode); + painter.text( + egui::pos2(rect.left() + padding, rect.top() + 15.0), + egui::Align2::LEFT_CENTER, + &time_label, + egui::FontId::monospace(MONO), + theme::INK_3, + ); + let pill_width = layout::text_width(ui, mode_label, 10.5) + 16.0; + let pill = egui::Rect::from_min_size( + egui::pos2(rect.left() + padding, rect.top() + 25.0), + egui::vec2(pill_width, 17.0), + ); + painter.rect_filled(pill, egui::CornerRadius::same(9), theme::SURFACE_2); + painter.text( + pill.center(), + egui::Align2::CENTER_CENTER, + mode_label, + egui::FontId::proportional(10.5), + theme::INK_3, + ); + let column_width = layout::text_width(ui, &time_label, MONO) + .max(pill_width) + .max(60.0); + + let duration_label = entry + .duration_ms + .map(|ms| short_duration(ms, lang)) + .unwrap_or_else(|| "—".to_string()); + let duration_width = layout::text_width(ui, &duration_label, MONO); + let copy_label = tr_l10n(lang, "overview.copy"); + let copy_width = layout::text_width(ui, copy_label, 11.5) + 36.0; + let copy_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - padding - copy_width, rect.top() + 14.0), + egui::vec2(copy_width, 26.0), + ); + painter.text( + egui::pos2(copy_rect.left() - 7.0, rect.top() + 16.0), + egui::Align2::RIGHT_CENTER, + duration_label, + egui::FontId::monospace(11.5), + theme::INK_4, + ); + + let text = if entry.final_text.trim().is_empty() { + entry.raw_transcript.as_str() + } else { + entry.final_text.as_str() + }; + let first_line = text.split('\n').next().unwrap_or(""); + let text_left = rect.left() + padding + column_width + 12.0; + let text_right = copy_rect.left() - 7.0 - duration_width - 10.0; + if text_right > text_left && !first_line.is_empty() { + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = text_right - text_left; + job.wrap.max_rows = 2; + job.append( + first_line, + 0.0, + egui::text::TextFormat { + font_id: egui::FontId::proportional(13.5), + color: theme::INK_2, + ..Default::default() + }, + ); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + painter.galley( + egui::pos2(text_left, rect.top() + 14.0), + galley, + theme::INK_2, + ); + } + + let copied_id = egui::Id::new(("overview-recent-copied", index)); + let now = ui.input(|input| input.time); + let copied = ui + .ctx() + .data(|data| data.get_temp::(copied_id)) + .is_some_and(|at| now - at < 1.5); + let label = if copied { + tr_l10n(lang, "overview.copied") + } else { + tr_l10n(lang, "overview.copy") + }; + if layout::action_button( + ui, + copy_rect, + label, + Some(IconName::Copy), + layout::ButtonKind::Ghost, + ) + .clicked() + { + ui.ctx().copy_text(text.to_string()); + ui.ctx().data_mut(|data| data.insert_temp(copied_id, now)); + } +} + +fn mode_label(lang: Lang, mode: OverviewMode) -> &'static str { + match mode { + OverviewMode::Raw => tr_l10n(lang, "overview.mode_raw"), + OverviewMode::Light => tr_l10n(lang, "overview.mode_light"), + OverviewMode::Structured => tr_l10n(lang, "overview.mode_structured"), + OverviewMode::Formal => tr_l10n(lang, "overview.mode_formal"), + } +} + +// ── Annual activity heatmap ───────────────────────────────────────────────── + +fn heatmap_card_height(width: f32, columns: f32) -> f32 { + let inner_width = (width - CARD_PADDING * 2.0).max(1.0); + let (_, cell, gap) = heatmap_cell(inner_width, columns); + let grid_height = 7.0 * cell + 6.0 * gap + 16.0; + // The card must be tall enough that the height-constrained cell equals the + // width-constrained cell, otherwise the grid stops short of the right edge. + CARD_PADDING * 2.0 + 24.0 + grid_height +} + +/// Sunday-first week columns needed to lay out the calendar year. +fn heatmap_columns(year: i32, days: usize) -> f32 { + let offset = chrono::NaiveDate::from_ymd_opt(year, 1, 1) + .map(weekday_index) + .unwrap_or(0); + ((offset as f32 + days as f32) / 7.0).ceil().max(1.0) +} + +/// `(step, cell, gap)` for the heatmap grid given the inner card width. +fn heatmap_cell(inner_width: f32, columns: f32) -> (f32, f32, f32) { + let label_width = 30.0; + let available = (inner_width - label_width).max(60.0); + let step = available / columns.max(1.0); + let gap = if step >= 13.0 { 3.0 } else { 2.0 }; + (step, (step - gap).max(4.0), gap) +} + +fn heatmap_card( + ui: &mut egui::Ui, + width: f32, + height: f32, + columns: f32, + summary: &OverviewSummary, + lang: Lang, +) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + #[cfg(test)] + ui.ctx() + .data_mut(|data| data.insert_temp(egui::Id::new("openless-overview-heatmap-rect"), rect)); + card_scope(ui, rect, CARD_PADDING, |ui, inner| { + let painter = ui.painter().with_clip_rect(inner); + painter.text( + inner.left_top(), + egui::Align2::LEFT_TOP, + tr_l10n(lang, "overview.activity_title"), + egui::FontId::proportional(13.0), + theme::INK_2, + ); + let grid = egui::Rect::from_min_max( + egui::pos2(inner.left(), inner.top() + 24.0), + inner.right_bottom(), + ); + heatmap_grid(ui, grid, columns, summary, lang); + }); +} + +fn heatmap_grid( + ui: &mut egui::Ui, + rect: egui::Rect, + columns: f32, + summary: &OverviewSummary, + lang: Lang, +) { + if summary.heatmap.is_empty() || rect.height() < 20.0 { + return; + } + let weekdays = split_labels(tr_l10n(lang, "overview.week_days")); + let months = split_labels(tr_l10n(lang, "overview.months")); + let label_width = weekdays + .iter() + .map(|label| layout::text_width(ui, label, 9.0)) + .fold(0.0_f32, f32::max) + + 8.0; + let (_, width_cell, gap) = heatmap_cell(rect.width(), columns); + let month_row = 16.0; + let height_cell = ((rect.height() - month_row - gap * 6.0) / 7.0).max(3.0); + let cell = width_cell.min(height_cell); + let step = cell + gap; + let grid_left = rect.left() + label_width; + let grid_top = rect.top() + month_row; + + // Calendar-year grid, Sunday-first, like the Tauri Heatmap component. + let first = chrono::NaiveDate::from_ymd_opt(summary.heatmap_year, 1, 1); + let Some(first) = first else { + return; + }; + let offset = weekday_index(first) as f32; + let column_count = columns.ceil() as usize; + let max_count = summary + .heatmap + .iter() + .map(|day| day.count) + .max() + .unwrap_or(0); + + let mut month_label: Vec> = vec![None; column_count]; + let painter = ui.painter().with_clip_rect(rect); + for (index, day) in summary.heatmap.iter().enumerate() { + let position = offset as usize + index; + let column = position / 7; + let row = position % 7; + let x = grid_left + column as f32 * step; + let y = grid_top + row as f32 * step; + let color = if day.count == 0 || max_count == 0 { + theme::SURFACE_2 + } else { + heat_color(day.count, max_count) + }; + painter.rect_filled( + egui::Rect::from_min_size(egui::pos2(x, y), egui::vec2(cell, cell)), + egui::CornerRadius::same(2), + color, + ); + if let Ok(date) = chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") { + use chrono::Datelike; + if date.day() == 1 && month_label[column].is_none() { + month_label[column] = Some(date.month0() as usize); + } + } + } + + for (column, month) in month_label.iter().enumerate() { + if let Some(month) = month { + if let Some(label) = months.get(*month) { + painter.text( + egui::pos2(grid_left + column as f32 * step, rect.top()), + egui::Align2::LEFT_TOP, + *label, + egui::FontId::proportional(9.0), + theme::INK_4, + ); + } + } + } + for (row, label) in weekdays.iter().enumerate() { + if row % 2 == 1 { + painter.text( + egui::pos2(rect.left(), grid_top + row as f32 * step + cell / 2.0), + egui::Align2::LEFT_CENTER, + *label, + egui::FontId::proportional(9.0), + theme::INK_4, + ); + } + } +} + +fn heat_color(count: u32, max: u32) -> egui::Color32 { + let ratio = count as f64 / max.max(1) as f64; + let t = ratio.sqrt().min(1.0) as f32; + let min = (191.0, 219.0, 254.0); + let max = (29.0, 78.0, 216.0); + egui::Color32::from_rgb( + (min.0 + (max.0 - min.0) * t) as u8, + (min.1 + (max.1 - min.1) * t) as u8, + (min.2 + (max.2 - min.2) * t) as u8, + ) +} + +// ── Layout primitives ─────────────────────────────────────────────────────── + +fn cards_row( + ui: &mut egui::Ui, + width: f32, + height: f32, + count: usize, + gap: f32, + mut draw: impl FnMut(&mut egui::Ui, usize, egui::Rect), +) { + if count == 0 { + return; + } + let (row, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let card_width = ((width - gap * (count as f32 - 1.0)) / count as f32).max(1.0); + for slot in 0..count { + let rect = egui::Rect::from_min_size( + egui::pos2(row.left() + slot as f32 * (card_width + gap), row.top()), + egui::vec2(card_width, height), + ); + draw(ui, slot, rect); + } +} + +fn paint_card(painter: &egui::Painter, rect: egui::Rect) { + painter.rect_filled(rect, egui::CornerRadius::same(14), theme::SURFACE); + painter.rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); +} + +fn card_scope( + ui: &mut egui::Ui, + rect: egui::Rect, + padding: f32, + contents: impl FnOnce(&mut egui::Ui, egui::Rect), +) { + paint_card(ui.painter(), rect); + let inner = rect.shrink(padding); + // `fixed_ui` (not `scope_builder`) so painting card contents never rewinds + // the page cursor and overlaps the next row. + layout::fixed_ui(ui, inner, layout::card_salt(rect), |ui| contents(ui, inner)); +} + +fn segmented_width(ui: &egui::Ui, options: &[String]) -> f32 { + let mut width = 4.0; + for (index, option) in options.iter().enumerate() { + if index > 0 { + width += 2.0; + } + width += layout::text_width(ui, option, 12.0) + 18.0; + } + width +} + +fn segmented( + ui: &mut egui::Ui, + rect: egui::Rect, + options: &[String], + selected: usize, +) -> Option { + let painter = ui.painter().with_clip_rect(rect); + painter.rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + painter.rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + let mut x = rect.left() + 2.0; + let mut clicked = None; + for (index, option) in options.iter().enumerate() { + let width = layout::text_width(ui, option, 12.0) + 18.0; + let option_rect = egui::Rect::from_min_size( + egui::pos2(x, rect.top() + 2.0), + egui::vec2(width, rect.height() - 4.0), + ); + let id = ui.id().with(( + "overview-segment", + index, + rect.left().round() as i32, + rect.top().round() as i32, + )); + let response = ui.interact(option_rect, id, egui::Sense::click()); + let is_selected = index == selected; + if is_selected { + painter.rect_filled(option_rect, egui::CornerRadius::same(6), theme::BLUE); + } else if response.hovered() { + painter.rect_filled(option_rect, egui::CornerRadius::same(6), theme::SURFACE); + } + painter.text( + option_rect.center(), + egui::Align2::CENTER_CENTER, + option, + egui::FontId::proportional(12.0), + if is_selected { + egui::Color32::WHITE + } else { + theme::INK_3 + }, + ); + if response.clicked() { + clicked = Some(index); + } + x += width + 2.0; + } + clicked +} + +// ── Text / value helpers ──────────────────────────────────────────────────── + +fn split_labels(value: &str) -> Vec<&str> { + value.split('|').collect() +} + +fn with_alpha(color: egui::Color32, alpha: f32) -> egui::Color32 { + egui::Color32::from_rgba_unmultiplied( + color.r(), + color.g(), + color.b(), + (alpha.clamp(0.0, 1.0) * 255.0) as u8, + ) +} + +fn weekday_index(date: chrono::NaiveDate) -> u32 { + use chrono::Datelike; + date.weekday().num_days_from_sunday() +} + +fn weekday_label(date: &str, labels: &[&str]) -> String { + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") + .ok() + .map(weekday_index) + .and_then(|index| labels.get(index as usize).copied()) + .unwrap_or_default() + .to_string() +} + +fn short_date(date: &str) -> String { + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") + .map(|date| { + use chrono::Datelike; + format!("{}/{}", date.month(), date.day()) + }) + .unwrap_or_else(|_| date.to_string()) +} + +fn history_time_label(created_at: &str) -> String { + use chrono::{Datelike, Timelike}; + let Ok(instant) = chrono::DateTime::parse_from_rfc3339(created_at) else { + return created_at.to_string(); + }; + let local = instant.with_timezone(&chrono::Local); + let now = chrono::Local::now(); + if local.date_naive() == now.date_naive() { + format!("{:02}:{:02}", local.hour(), local.minute()) + } else if local.year() == now.year() { + format!( + "{}/{} {:02}:{:02}", + local.month(), + local.day(), + local.hour(), + local.minute() + ) + } else { + format!( + "{}/{}/{} {:02}:{:02}", + local.year(), + local.month(), + local.day(), + local.hour(), + local.minute() + ) + } +} + +fn format_number(value: u64) -> String { + let raw = value.to_string(); + let bytes = raw.as_bytes(); + let mut out = String::with_capacity(raw.len() + raw.len() / 3); + for (index, byte) in bytes.iter().enumerate() { + if index > 0 && (bytes.len() - index).is_multiple_of(3) { + out.push(','); + } + out.push(*byte as char); + } + out +} + +fn metric_value(day: &OverviewActivityDay, metric: usize) -> f64 { + match metric { + 1 => day.chars as f64, + 2 => day.duration_ms as f64, + _ => day.count as f64, + } +} + +fn format_metric_value(value: f64, metric: usize, lang: Lang) -> String { + if metric == 2 { + long_duration(value as u64, lang) + } else { + format_number(value.round() as u64) + } +} + +/// Seconds/minutes/hours for a period total (`formatLongDuration`). +fn long_duration(ms: u64, lang: Lang) -> String { + if ms == 0 { + return "0".to_string(); + } + let seconds = (ms as f64 / 1000.0).round() as u64; + if seconds < 60 { + return fmt_l10n(lang, "dur.sec", &[&seconds]); + } + let minutes = seconds / 60; + if minutes < 60 { + return fmt_l10n(lang, "overview.minutes", &[&minutes]); + } + fmt_l10n( + lang, + "overview.hours_minutes", + &[&(minutes / 60), &(minutes % 60)], + ) +} + +/// `formatDuration`: `—` / `3.1 秒` / `3:05`. +fn short_duration(ms: u64, lang: Lang) -> String { + if ms == 0 { + return "—".to_string(); + } + let seconds = ms as f64 / 1000.0; + if seconds < 60.0 { + return fmt_l10n(lang, "dur.sec", &[&format!("{seconds:.1}")]); + } + format!("{}:{:02}", (seconds / 60.0) as u64, (seconds % 60.0) as u64) +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/pages.rs b/openless-all/app/linux-egui/src/ui/frontend/pages.rs index bfd100e04..b1cad03ae 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/pages.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/pages.rs @@ -1,2222 +1,19 @@ +//! Shared page helpers that are still consumed by other page modules. +//! +//! The style page moved to `style.rs`; the correction chip is shared with the +//! corrections page. + use eframe::egui; -use super::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) { +pub fn correction_chip(ui: &mut egui::Ui, label: &str, enabled: bool) -> (bool, bool) { let fill = if enabled { - theme::surface() - } else { - theme::surface_2() - }; - let text_color = if enabled { - theme::ink() + theme::SURFACE } else { - theme::ink_4() + 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), @@ -2230,7 +27,7 @@ fn correction_chip(ui: &mut egui::Ui, label: &str, enabled: bool) -> (bool, bool painter.rect_stroke( rect, egui::CornerRadius::same(16), - egui::Stroke::new(0.6, theme::line()), + egui::Stroke::new(0.6, theme::LINE), egui::StrokeKind::Inside, ); painter.galley( @@ -2246,125 +43,14 @@ fn correction_chip(ui: &mut egui::Ui, label: &str, enabled: bool) -> (bool, bool 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_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()), + egui::Stroke::new(0.5, theme::LINE), ); let center = close_rect.center(); - let x_stroke = egui::Stroke::new(1.1, theme::ink_4()); + let x_stroke = egui::Stroke::new(1.1, theme::INK_4); painter.line_segment( [ center + egui::vec2(-3.0, -3.0), @@ -2391,311 +77,3 @@ fn vocab_chip(ui: &mut egui::Ui, entry: &super::view_model::VocabEntry) -> (bool } (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/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs new file mode 100644 index 000000000..eab3b32fe --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -0,0 +1,2672 @@ +//! Three auxiliary windows: the dictation capsule, the selection-ask panel and +//! the selection-polish preview. +//! +//! Like the page layer this module is a pure renderer: it reads the popup +//! snapshot and returns at most one action, which the popup host translates into +//! a `PopupToHost` message. Layout, spacing, colours and copy mirror the Tauri +//! windows — `src/components/Capsule.tsx` (classic pill) and `src/pages/QaPanel.tsx` +//! (shadcn chat card, which since the preview merge also hosts the read-only +//! polish result mode). +//! +//! The chat panel renders in the shadcn zinc palette, which maps onto the theme +//! tokens: white [`theme::SURFACE`], [`theme::INK`] foreground, [`theme::SURFACE_2`] +//! muted fill, [`theme::INK_3`] muted text, [`theme::LINE`] border. + +use eframe::egui; + +use super::{icons, layout, siri_gl, theme}; +use openless_linux_egui::{ + fmt_l10n, tr_l10n, CapsulePopupState, Lang, LessComputerPopupState, PopupChatMessage, + QaPolishState, QaPopupState, +}; + +/// Result of rendering the polish-result mode inside the selection-ask panel. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum PolishResultAction { + None, + /// ✕ / 取消 → the host sends `CancelPolish`. + Cancel, + /// ✓ 确认并替换(即「插入」:把结果写回原选区)→ the host sends `ConfirmPolish`. + Confirm(String), +} + +/// Result of rendering the selection-ask panel. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum QaAction { + None, + /// ✕ → the host sends `DismissQa`. + Dismiss, + /// Enter / 发送 → the host sends `SubmitQa`. + Submit(String), + /// 润色结果模式:✓ 确认并替换 → 宿主发 `ConfirmPolish`。 + ConfirmPolish(String), + /// 润色结果模式:取消 → 宿主发 `CancelPolish`。 + CancelPolish, + /// 麦克风按钮 → the host sends `ToggleQaRecording`. + ToggleRecording, + /// 图钉 → the host sends `SetPinned`(固定后不再自动收起)。 + SetPinned(bool), + /// 「编辑指令」勾选框 → the host sends `SetEditInstructionMode`. + SetEditInstructionMode(bool), + /// 「预览并确认插入」→ the host sends `ApplyEdit`. + ApplyEdit, + /// 「保留上一版本」→ the host sends `RevertEdit`. + RevertEdit, +} + +/// Result of rendering the dictation capsule. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CapsuleAction { + None, + /// ✕ → the host cancels the dictation. + Cancel, + /// ✓ → the host stops the dictation and inserts. + Confirm, +} + +/// Tauri `selection-polish-preview` 面板的边距(`padding: 18`)。 +/// **只服务旧的独立预览窗口**的常量已随窗口一起删除;合并后由选区助手 +/// 面板的 `CARD_SPACING` 承载。 +/// The QA card uses `--card-spacing` (14px) for its header/footer gutters. +const CARD_SPACING: f32 = 14.0; +/// Composer row height (Tauri `InputGroup`). +const COMPOSER_HEIGHT: f32 = 40.0; +/// Classic capsule pill metrics (Tauri `CLASSIC_PILL_METRICS`). +const PILL_WIDTH: f32 = 176.0; +const PILL_HEIGHT: f32 = 42.0; +/// Round icon buttons in the capsule / composer. +const ROUND_BUTTON: f32 = 28.0; +/// Tauri `getCapsuleHostMetrics(.., 'classic').bottomInset`。 +const CAPSULE_BOTTOM_INSET: f32 = 16.0; +/// Typeless 胶囊(Tauri `CapsuleStyles.css`:176×64、46×46 圆形按钮、11 根波形)。 +const TYPELESS_WIDTH: f32 = 176.0; +const TYPELESS_HEIGHT: f32 = 64.0; +const TYPELESS_BUTTON: f32 = 46.0; +/// `.ol-typeless-*` 调色板。 +const TYPELESS_BG: egui::Color32 = egui::Color32::from_rgb(0x18, 0x18, 0x1b); +const TYPELESS_BORDER: egui::Color32 = egui::Color32::from_rgb(0x52, 0x52, 0x5b); +const TYPELESS_INK: egui::Color32 = egui::Color32::from_rgb(0xfa, 0xfa, 0xfa); +const TYPELESS_BUTTON_BG: egui::Color32 = egui::Color32::from_rgb(0x3f, 0x3f, 0x46); +/// 徽章与药丸之间的间距(Tauri `badgeGap`)。 +const CAPSULE_BADGE_GAP: f32 = 8.0; + +// ── 润色结果模式(选区助手面板内的第二套 UI) ────────────────────────────── + +/// 润色结果:标题 + 副标题 + ✕、**只读**结果框、原文摘要、取消 / 确认并替换。 +/// +/// 视觉与 Tauri 选区助手面板(`src/pages/QaPanel.tsx`)一致 —— 原先那个独立 +/// 预览窗口已下线,两侧现在都把润色结果画在选区助手面板里(宿主侧的 +/// `HostAction::ShowSelectionPreview` 落到这里)。按用户要求, +/// 结果**只读**(不做就地编辑);「确认并替换」就是把结果写回原选区的「插入」。 +pub fn polish_result_mode( + ui: &mut egui::Ui, + state: &QaPolishState, + lang: Lang, +) -> PolishResultAction { + let mut action = PolishResultAction::None; + // 与选区助手面板(`selection_ask`)共用同一套外壳: + // CardHeader = 14px 横向 / 12px 纵向留白 + 底部 hairline(整条可拖) + // CardContent = 左右 14px 留白 + // CardFooter = 顶部 hairline + 14px / 12px 留白 + // 润色结果只把中间换成「只读结果 + 原文摘要」,头尾的尺寸、间距、分隔线与 + // 选区助手完全一致 —— 不再是自己一套排版(那看着就像旧的独立预览窗口)。 + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(CARD_SPACING as i8, 12)) + .show(ui, |ui| { + let row = ui + .horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "selection.polish_preview.title")) + .size(16.0) + .strong() + .color(theme::INK), + ); + ui.add_space(2.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "selection.polish_preview.subtitle")) + .size(12.0) + .color(theme::INK_4), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if icon_button(ui, icons::IconName::Close, theme::INK_3) + .on_hover_text(tr_l10n(lang, "selection.polish_preview.cancel")) + .clicked() + { + action = PolishResultAction::Cancel; + } + }); + }) + .response + .interact(egui::Sense::drag()); + if row.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + // 弹窗也是「按下即交给合成器」,同样会丢掉这次手势的释放。 + super::layout::note_window_gesture_handoff(ui.ctx()); + } + }); + hairline(ui, theme::LINE_SOFT); + + // 底部固定高度:与选区助手一样是「14px / 12px 留白 + 34px 按钮」。 + let footer_height = CARD_SPACING * 2.0 + 34.0; + let source_height = if state.source.is_empty() { 0.0 } else { 48.0 }; + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(CARD_SPACING as i8, 0)) + .show(ui, |ui| { + ui.add_space(CARD_SPACING); + // 只读结果框:撑满剩余高度(Tauri `flex: 1; min-height: 150`), + // 文本可选可滚动,但没有光标、不会回到宿主。 + let editor_height = (ui.available_height() - footer_height - source_height).max(150.0); + let width = ui.available_width(); + egui::Frame::new() + .fill(theme::CONTENT_BG) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(9)) + .inner_margin(egui::Margin::same(12)) + .show(ui, |ui| { + ui.set_min_size(egui::vec2(width - 24.0, editor_height - 24.0)); + egui::ScrollArea::vertical() + .id_salt("openless-polish-result") + .auto_shrink([false, false]) + // 上限卡在算好的框高上:`set_min_size` 不限制上限, + // 不限的话滚动区会把下面的原文摘要顶出可视区。 + .max_height((editor_height - 24.0).max(60.0)) + .show(ui, |ui| { + ui.set_min_width(width - 24.0); + ui.add( + egui::Label::new( + egui::RichText::new(&state.text) + .size(14.0) + .color(theme::INK), + ) + .wrap() + .selectable(true), + ); + }); + }); + + if !state.source.is_empty() { + ui.add_space(8.0); + ui.label( + egui::RichText::new(format!( + "{}{}", + tr_l10n(lang, "selection.polish_preview.source_prefix"), + truncate(&state.source, 200) + )) + .size(11.0) + .color(theme::INK_4), + ); + } + }); + + hairline(ui, theme::LINE_SOFT); + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(CARD_SPACING as i8, 12)) + .show(ui, |ui| { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let confirm = tr_l10n(lang, "selection.polish_preview.confirm_replace"); + let confirm_width = layout::text_width(ui, confirm, 13.0) + 46.0; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(confirm_width, 34.0), egui::Sense::click()); + let fill = if response.hovered() { + theme::BLUE.gamma_multiply(0.9) + } else { + theme::BLUE + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(7), fill); + icon_text( + ui, + rect, + Some(icons::IconName::Check), + confirm, + theme::SURFACE, + ); + if response.clicked() { + action = PolishResultAction::Confirm(state.text.clone()); + } + ui.add_space(8.0); + let cancel = tr_l10n(lang, "selection.polish_preview.cancel"); + let cancel_width = layout::text_width(ui, cancel, 13.0) + 30.0; + let (rect, response) = + ui.allocate_exact_size(egui::vec2(cancel_width, 34.0), egui::Sense::click()); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(7), + if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(7), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + icon_text(ui, rect, None, cancel, theme::INK_2); + if response.clicked() { + action = PolishResultAction::Cancel; + } + }); + }); + action +} + +// ── 划词追问 ──────────────────────────────────────────────────────────────── + +/// 划词追问面板:卡片头(标题 + 副行 + ✕)、消息流(空状态 / 对话 / 思考中 / +/// 出错)、底部输入组(选区条 + 输入框 + 麦克风 + 发送)。 +pub fn selection_ask( + root_ui: &mut egui::Ui, + state: &QaPopupState, + composer: &mut String, + lang: Lang, + avatar: Option<&egui::TextureHandle>, +) -> QaAction { + let mut action = QaAction::None; + let phase = state.phase.to_ascii_lowercase(); + let recording = phase == "recording"; + // Tauri 在 loading / thinking / awaiting_approval 以及流式增量期间都保持 + // 「思考中」:转圈不停,但已经有流式正文时不再重复显示思考行。 + let thinking = matches!( + phase.as_str(), + "loading" | "thinking" | "awaiting_approval" | "answerdelta" | "answer" + ); + let thinking_row = thinking && state.streaming_answer.is_empty(); + egui::CentralPanel::default() + .frame( + egui::Frame::NONE + .fill(theme::SURFACE) + .corner_radius(egui::CornerRadius::same(14)) + .stroke(egui::Stroke::new(0.5, theme::LINE)), + ) + .show(root_ui, |ui| { + // 首帧只编译不绘制地把三个程序编译好(进程内只排一次), + // 免得录音/思考的第一帧才发现要编译——那是按热键后「慢一拍」的来源。 + // ── 润色结果模式:同一个面板,第二套 UI(原独立预览窗口的同一套视觉)。 + if let Some(polish) = state.polish.as_ref() { + action = match polish_result_mode(ui, polish, lang) { + PolishResultAction::None => QaAction::None, + PolishResultAction::Cancel => QaAction::CancelPolish, + PolishResultAction::Confirm(text) => QaAction::ConfirmPolish(text), + }; + return; + } + // ── CardHeader:整条可拖,✕ 在右 ───────────────────────────── + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(CARD_SPACING as i8, 12)) + .show(ui, |ui| { + let row = ui + .horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "qa.title")) + .size(16.0) + .strong() + .color(theme::INK), + ); + ui.add_space(2.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "qa.header_hint")) + .size(12.0) + .color(theme::INK_4), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if icon_button(ui, icons::IconName::Close, theme::INK_3) + .on_hover_text(tr_l10n(lang, "qa.close_tooltip")) + .clicked() + { + action = QaAction::Dismiss; + } + // 图钉:固定后宿主不再自动收起(Tauri 的 + // qa.pinTooltip / qa.unpinTooltip)。 + let pin_color = if state.pinned { + theme::BLUE + } else { + theme::INK_4 + }; + let pin_tooltip = if state.pinned { + tr_l10n(lang, "qa.unpin_tooltip") + } else { + tr_l10n(lang, "qa.pin_tooltip") + }; + if icon_button(ui, icons::IconName::Pin, pin_color) + .on_hover_text(pin_tooltip) + .clicked() + { + action = QaAction::SetPinned(!state.pinned); + } + }); + }) + .response + .interact(egui::Sense::drag()); + if row.drag_started() { + ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag); + super::layout::note_window_gesture_handoff(ui.ctx()); + } + }); + hairline(ui, theme::LINE_SOFT); + + // ── CardContent ────────────────────────────────────────────── + // 底部高度必须把新增的「编辑指令」勾选框与「保留上一版本 / 预览并确认 + // 插入」按钮算进去,否则线程区会把它们挤出窗口底部。 + let edit_block = if state.edit_apply_available && phase == "idle" { + (if state.edit_revert_available { + 38.0 + } else { + 0.0 + }) + 38.0 + } else { + 0.0 + }; + let recording_selection_height = if recording && state.selection_preview.is_some() { + 28.0 + } else { + 0.0 + }; + let footer_height = CARD_SPACING * 2.0 + + COMPOSER_HEIGHT + + 12.0 + + 22.0 + + recording_selection_height + + edit_block; + // Never force an 80px thread area: on short work areas that + // minimum overlaps the fixed composer and clips its bottom edge. + let content_height = (ui.available_height() - footer_height).max(0.0); + let has_thread = !state.messages.is_empty() + || !state.streaming_answer.is_empty() + || thinking + || state.error.is_some(); + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(CARD_SPACING as i8, 0)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + if !has_thread { + empty_state(ui, lang, content_height); + } else { + egui::ScrollArea::vertical() + .id_salt("openless-qa-thread") + .max_height(content_height) + .auto_shrink([false, false]) + .stick_to_bottom(true) + .show(ui, |ui| { + let width = ui.available_width(); + for message in &state.messages { + message_row(ui, message, width, lang, avatar); + ui.add_space(10.0); + } + if !state.streaming_answer.is_empty() { + assistant_row(ui, |ui| { + render_markdown(ui, &state.streaming_answer) + }); + ui.add_space(10.0); + } + if thinking_row { + assistant_row(ui, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "qa.thinking")) + .size(12.0) + .color(theme::INK_3), + ); + }); + ui.add_space(10.0); + } + if let Some(error) = &state.error { + destructive_bubble(ui, |ui| { + ui.label( + egui::RichText::new(error).size(14.0).color(theme::ERR), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "qa.error_retry_hint", + )) + .size(11.5) + .color(theme::ERR.gamma_multiply(0.7)), + ); + }); + } + }); + } + }); + + // ── CardFooter:选区条 + 输入组 ────────────────────────────── + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(CARD_SPACING as i8, 12)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + // 编辑结果:底部出现「保留上一版本 / 预览并确认插入」 + // (只在轮到 idle 且预览可用时)。 + if state.edit_apply_available && phase == "idle" { + if state.edit_revert_available { + if wide_button(ui, tr_l10n(lang, "qa.edit_revert_previous")) { + action = QaAction::RevertEdit; + } + ui.add_space(6.0); + } + if wide_button_primary( + ui, + tr_l10n(lang, "qa.edit_apply_replace"), + icons::IconName::Check, + ) { + action = QaAction::ApplyEdit; + } + ui.add_space(8.0); + } + if recording { + if let Some(selection) = &state.selection_preview { + selection_chip(ui, selection, lang); + ui.add_space(8.0); + } + } + // 「编辑指令」勾选框(Tauri Composer 左下角,busy 时禁用)。 + let busy = thinking || recording; + let checkbox_label = tr_l10n(lang, "qa.edit_instruction_mode"); + let (checkbox_rect, checkbox_response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 20.0), + if busy { + egui::Sense::hover() + } else { + egui::Sense::click() + }, + ); + let box_rect = egui::Rect::from_center_size( + egui::pos2(checkbox_rect.left() + 7.0, checkbox_rect.center().y), + egui::vec2(14.0, 14.0), + ); + let checked = state.edit_instruction_mode; + ui.painter().rect_filled( + box_rect, + egui::CornerRadius::same(3), + if checked { theme::INK } else { theme::SURFACE }, + ); + ui.painter().rect_stroke( + box_rect, + egui::CornerRadius::same(3), + egui::Stroke::new(0.8, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + if checked { + icons::draw_icon( + ui, + box_rect.center(), + icons::IconName::Check, + theme::SURFACE, + ); + } + ui.painter().text( + egui::pos2(box_rect.right() + 6.0, checkbox_rect.center().y), + egui::Align2::LEFT_CENTER, + checkbox_label, + egui::FontId::proportional(11.5), + if busy { theme::INK_4 } else { theme::INK_3 }, + ); + if checkbox_response.clicked() && !busy { + action = QaAction::SetEditInstructionMode(!checked); + } + ui.add_space(2.0); + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size( + egui::vec2(width, COMPOSER_HEIGHT), + egui::Sense::hover(), + ); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(12), theme::SURFACE); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(12), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + // olchat-ring:录音红光 / 思考黑光绕输入组转圈。GPU 路径用 + // 圆角矩形 SDF 片元着色器(时间/尺寸/圆角/颜色 4 组 uniform), + // 驱动拒绝着色器时回落到 CPU 采样版。 + if recording || thinking { + let tint = if recording { + color_to_f32(theme::ERR) + } else { + color_to_f32(theme::INK) + }; + let drive = siri_gl::SiriDrive { + level: 0.0, + resolved: if recording { 1.0 } else { 0.0 }, + // 思考态转得更快,和 Tauri 的 state→speed 语义一致。 + speed: if recording { 1.0 } else { 1.45 }, + warming: false, + }; + let dt = ui.input(|input| input.stable_dt); + let clock = siri_gl::tick(ui.ctx(), "qa-composer-ring", drive, dt); + let glow = siri_gl::SiriGlow::ring( + clock.time, + 12.0, + if recording { 2.0 } else { 1.6 }, + ) + .with_tint(tint); + if !siri_gl::paint(ui, rect.expand(3.0), glow) { + spinner_ring(ui, rect, if recording { theme::ERR } else { theme::INK }); + } + } + let inner = rect.shrink2(egui::vec2(10.0, 6.0)); + let mic_rect = egui::Rect::from_center_size( + egui::pos2(inner.right() - ROUND_BUTTON / 2.0, rect.center().y), + egui::vec2(ROUND_BUTTON, ROUND_BUTTON), + ); + let send_rect = egui::Rect::from_center_size( + egui::pos2(inner.right() - ROUND_BUTTON * 1.5 - 4.0, rect.center().y), + egui::vec2(ROUND_BUTTON, ROUND_BUTTON), + ); + let input_rect = egui::Rect::from_min_max( + inner.min, + egui::pos2(send_rect.left() - 6.0, inner.bottom()), + ); + let mut child = ui.new_child( + egui::UiBuilder::new() + .id_salt("openless-qa-composer") + .max_rect(input_rect) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + child.set_clip_rect(child.clip_rect().intersect(input_rect)); + let response = child.add( + egui::TextEdit::singleline(composer) + .id(egui::Id::new("openless-qa-composer-input")) + .frame(egui::Frame::NONE) + .text_color(theme::INK) + .font(egui::FontId::proportional(13.5)) + .hint_text(tr_l10n(lang, "qa.composer_placeholder")) + .desired_width(input_rect.width()), + ); + if response.lost_focus() + && ui.input(|input| input.key_pressed(egui::Key::Enter)) + && !composer.trim().is_empty() + { + action = QaAction::Submit(std::mem::take(composer)); + } + let mic = ui.interact( + mic_rect, + ui.id().with("openless-qa-mic"), + egui::Sense::click(), + ); + if recording { + ui.painter().circle_filled( + mic_rect.center(), + ROUND_BUTTON / 2.0, + theme::ERR, + ); + } else if mic.hovered() { + ui.painter().circle_filled( + mic_rect.center(), + ROUND_BUTTON / 2.0, + theme::SURFACE_2, + ); + } + icons::draw_icon( + ui, + mic_rect.center(), + if recording { + icons::IconName::Stop + } else { + icons::IconName::Mic + }, + if recording { + theme::SURFACE + } else { + theme::INK_2 + }, + ); + if mic.clicked() && !thinking { + action = QaAction::ToggleRecording; + } + let can_send = !composer.trim().is_empty() && !thinking; + let send = ui.interact( + send_rect, + ui.id().with("openless-qa-send"), + egui::Sense::click(), + ); + ui.painter().circle_filled( + send_rect.center(), + ROUND_BUTTON / 2.0, + if can_send { + theme::INK + } else { + theme::SURFACE_2 + }, + ); + icons::draw_icon( + ui, + send_rect.center(), + icons::IconName::Send, + if can_send { + theme::SURFACE + } else { + theme::INK_4 + }, + ); + if send.clicked() && can_send { + action = QaAction::Submit(std::mem::take(composer)); + } + }); + }); + action +} + +/// 空状态:居中图标 + 标题 + 说明(Tauri ``)。 +fn empty_state(ui: &mut egui::Ui, lang: Lang, height: f32) { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), height), + egui::Sense::hover(), + ); + let center = rect.center(); + icons::draw_icon( + ui, + egui::pos2(center.x, center.y - 48.0), + icons::IconName::Chat, + theme::INK_4, + ); + ui.painter().text( + egui::pos2(center.x, center.y - 14.0), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "qa.empty_title"), + egui::FontId::proportional(14.0), + theme::INK, + ); + let galley = layout::text_galley( + ui, + tr_l10n(lang, "qa.empty_desc"), + theme::INK_4, + 12.0, + (rect.width() - 48.0).min(300.0), + 4, + ); + ui.painter().galley( + egui::pos2(center.x - galley.rect.width() / 2.0, center.y + 6.0), + galley, + theme::INK_4, + ); +} + +/// 一条对话消息:用户右侧深色气泡(带选区引用块)+ 头像;助手左侧头像 + Markdown。 +fn message_row( + ui: &mut egui::Ui, + message: &PopupChatMessage, + width: f32, + lang: Lang, + avatar: Option<&egui::TextureHandle>, +) { + if message.role.eq_ignore_ascii_case("user") { + let selection = message + .selection_text + .as_deref() + .map(|text| truncate(text, 120)) + .filter(|text| !text.is_empty() && *text != message.content); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + user_avatar(ui, avatar); + ui.add_space(8.0); + let max_width = (width - 56.0).max(120.0) * 0.8; + ui.allocate_ui_with_layout( + egui::vec2(max_width, 0.0), + egui::Layout::top_down(egui::Align::Max), + |ui| { + if let Some(selection) = selection { + bubble(ui, theme::SURFACE_2, theme::INK_3, |ui| { + ui.label( + egui::RichText::new(format!("“{selection}”")) + .size(12.0) + .italics() + .color(theme::INK_3), + ); + }); + ui.add_space(4.0); + } + bubble(ui, theme::INK, theme::SURFACE, |ui| { + ui.label( + egui::RichText::new(&message.content) + .size(14.0) + .color(theme::SURFACE), + ); + }); + }, + ); + }); + let _ = lang; + return; + } + assistant_row(ui, |ui| render_markdown(ui, &message.content)); +} + +/// 助手行:深色思考头像 + 内容(内容由调用方渲染在头像右侧)。 +fn assistant_row(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)) { + ui.horizontal_top(|ui| { + ai_avatar(ui); + ui.add_space(8.0); + ui.vertical(|ui| { + ui.set_max_width((ui.available_width() - 4.0).max(80.0)); + contents(ui); + }); + }); +} + +/// 一个聊天气泡:`rounded-3xl` = 24px,padding 12/10。 +fn bubble( + ui: &mut egui::Ui, + fill: egui::Color32, + ink: egui::Color32, + contents: impl FnOnce(&mut egui::Ui), +) { + let _ = ink; + egui::Frame::new() + .fill(fill) + .corner_radius(egui::CornerRadius::same(18)) + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, contents); +} + +/// 出错气泡(Tauri `variant="destructive"`):红底红字。 +fn destructive_bubble(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(theme::DANGER_SOFT) + .corner_radius(egui::CornerRadius::same(18)) + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, contents); +} + +/// 用户头像:已登录 GitHub 时画真实头像(`github.com/{login}.png`,圆形裁切), +/// 未登录 / 取图失败回落 GitHub 图标(Tauri `UserAvatar`)。 +fn user_avatar(ui: &mut egui::Ui, avatar: Option<&egui::TextureHandle>) { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ROUND_BUTTON + 4.0, ROUND_BUTTON + 4.0), + egui::Sense::hover(), + ); + let radius = (ROUND_BUTTON + 4.0) / 2.0; + match avatar { + Some(texture) => { + textured_circle(ui, rect.center(), radius, texture); + } + None => { + ui.painter() + .circle_filled(rect.center(), radius, theme::SURFACE_2); + icons::draw_icon(ui, rect.center(), icons::IconName::Github, theme::INK_2); + } + } +} + +/// 把一张方形贴图画成圆形:以中心为扇形顶点、UV 按圆周比例展开。 +fn textured_circle(ui: &egui::Ui, center: egui::Pos2, radius: f32, texture: &egui::TextureHandle) { + const SEGMENTS: usize = 48; + let mut mesh = egui::Mesh::with_texture(texture.id()); + mesh.vertices.push(egui::epaint::Vertex { + pos: center, + uv: egui::pos2(0.5, 0.5), + color: egui::Color32::WHITE, + }); + for index in 0..=SEGMENTS { + let angle = index as f32 / SEGMENTS as f32 * std::f32::consts::TAU; + let pos = center + egui::vec2(angle.cos(), angle.sin()) * radius; + mesh.vertices.push(egui::epaint::Vertex { + pos, + uv: egui::pos2(0.5 + angle.cos() * 0.5, 0.5 + angle.sin() * 0.5), + color: egui::Color32::WHITE, + }); + if index > 0 { + mesh.indices + .extend_from_slice(&[0, index as u32, index as u32 + 1]); + } + } + ui.painter().add(egui::Shape::mesh(mesh)); +} + +/// 输入区上方的整宽次级按钮(Tauri `Button variant="outline"`)。 +fn wide_button(ui: &mut egui::Ui, label: &str) -> bool { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 32.0), egui::Sense::click()); + let fill = if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), fill); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(13.0), + theme::INK_2, + ); + response.clicked() +} + +/// 输入区上方的整宽主按钮(Tauri `Button`,带 ✓)。 +fn wide_button_primary(ui: &mut egui::Ui, label: &str, icon: icons::IconName) -> bool { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 32.0), egui::Sense::click()); + let fill = if response.hovered() { + theme::INK_2 + } else { + theme::INK + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), fill); + icon_text(ui, rect, Some(icon), label, theme::SURFACE); + response.clicked() +} + +/// 助手头像:深色圆底 + 旋转的思考光点(Tauri 的 `OrbAvatar`)。 +fn ai_avatar(ui: &mut egui::Ui) { + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ROUND_BUTTON + 4.0, ROUND_BUTTON + 4.0), + egui::Sense::hover(), + ); + let radius = (ROUND_BUTTON + 4.0) / 2.0; + ui.painter() + .circle_filled(rect.center(), radius, theme::INK); + let time = ui.input(|input| input.time) as f32; + let mut previous: Option = None; + for step in 0..14 { + let angle = time * 1.6 + step as f32 * std::f32::consts::TAU / 14.0; + let alpha = (30.0 + 225.0 * (step as f32 / 13.0)).min(255.0) as u8; + let point = rect.center() + egui::vec2(angle.cos(), angle.sin()) * (radius * 0.42); + if let Some(previous) = previous { + ui.painter().line_segment( + [previous, point], + egui::Stroke::new( + 2.0, + egui::Color32::from_rgba_unmultiplied(150, 185, 255, alpha), + ), + ); + } + previous = Some(point); + } + ui.painter().circle_filled( + rect.center(), + 2.6, + egui::Color32::from_rgba_unmultiplied(150, 185, 255, 235), + ); +} + +/// 录音时的选区上下文条(Tauri `SelectionChip`)。 +fn selection_chip(ui: &mut egui::Ui, text: &str, lang: Lang) { + egui::Frame::new() + .fill(theme::SURFACE_2) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::symmetric(12, 6)) + .show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "qa.selection_preview")) + .size(11.5) + .color(theme::INK_3), + ); + ui.label( + egui::RichText::new(truncate(text, 60)) + .size(11.5) + .color(theme::INK_2), + ); + }); + }); +} + +/// 输入组外圈:Tauri 用 conic-gradient 假 border,这里按圆角矩形周长采样做出 +/// 同样的「转圈高光」(egui 没有锥形渐变)。 +fn spinner_ring(ui: &egui::Ui, rect: egui::Rect, color: egui::Color32) { + let time = ui.input(|input| input.time) as f32; + let points = rounded_rect_points(rect.expand(2.0), 10.0, 64); + let head = (time * 1.1).rem_euclid(1.0); + for (index, window) in points.windows(2).enumerate() { + let phase = index as f32 / points.len() as f32; + let distance = (phase - head).rem_euclid(1.0); + let intensity = if distance < 0.22 { + 1.0 - distance / 0.22 + } else { + 0.0 + }; + let alpha = (38.0 + intensity * 217.0).min(255.0) as u8; + ui.painter().line_segment( + [window[0], window[1]], + egui::Stroke::new( + 2.0, + egui::Color32::from_rgba_unmultiplied(color.r(), color.g(), color.b(), alpha), + ), + ); + } +} + +/// 圆角矩形的周长采样点(顺时针,从右下角弧开始)。 +fn rounded_rect_points(rect: egui::Rect, radius: f32, segments: usize) -> Vec { + let radius = radius.min(rect.width() / 2.0).min(rect.height() / 2.0); + let corners = [ + (rect.right() - radius, rect.bottom() - radius, 0.0_f32), + ( + rect.left() + radius, + rect.bottom() - radius, + std::f32::consts::FRAC_PI_2, + ), + ( + rect.left() + radius, + rect.top() + radius, + std::f32::consts::PI, + ), + ( + rect.right() - radius, + rect.top() + radius, + 3.0 * std::f32::consts::FRAC_PI_2, + ), + ]; + let per_corner = (segments / 4).max(2); + let mut points = Vec::with_capacity(per_corner * 4); + for (center_x, center_y, start) in corners { + for step in 0..=per_corner { + let angle = start + std::f32::consts::FRAC_PI_2 * (step as f32 / per_corner as f32); + points.push(egui::pos2( + center_x + radius * angle.cos(), + center_y + radius * angle.sin(), + )); + } + } + points +} + +/// egui color → the shader's `uTint` (linear 0..1, gamma-space value is fine +/// here because the glow is additive on a translucent window). +fn color_to_f32(color: egui::Color32) -> [f32; 3] { + [ + f32::from(color.r()) / 255.0, + f32::from(color.g()) / 255.0, + f32::from(color.b()) / 255.0, + ] +} + +fn hairline(ui: &mut egui::Ui, color: egui::Color32) { + let rect = ui + .allocate_exact_size(egui::vec2(ui.available_width(), 1.0), egui::Sense::hover()) + .0; + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, color), + ); +} + +// ── 录音胶囊 ──────────────────────────────────────────────────────────────── + +/// 药丸上方的「正在翻译」徽章(Tauri `ClassicCapsule` 的 `capsule.translating`): +/// 蓝点 + 蓝字、圆角胶囊、`--ol-capsule-badge-bg` 底、`--ol-capsule-badge-border` 边。 +fn translating_badge(ui: &mut egui::Ui, pill: egui::Rect, lang: Lang) { + let label = tr_l10n(lang, "capsule.translating"); + let text_width = layout::text_width(ui, label, 10.5); + let width = text_width + 5.0 + 5.0 + 20.0; + let height = 19.0; + let rect = egui::Rect::from_center_size( + egui::pos2( + pill.center().x, + pill.top() - CAPSULE_BADGE_GAP - height / 2.0, + ), + egui::vec2(width, height), + ); + let painter = ui.painter(); + painter.rect_filled( + rect, + egui::CornerRadius::same((height / 2.0) as u8), + theme::CAPSULE_BADGE_BG, + ); + painter.rect_stroke( + rect, + egui::CornerRadius::same((height / 2.0) as u8), + egui::Stroke::new(0.5, theme::CAPSULE_BADGE_BORDER), + egui::StrokeKind::Inside, + ); + let dot = egui::pos2(rect.left() + 10.0, rect.center().y); + painter.circle_filled(dot, 2.5, theme::BLUE); + painter.text( + egui::pos2(dot.x + 5.0 + 2.5, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(10.5), + theme::BLUE, + ); +} + +/// 录音胶囊:经典药丸(Tauri `ClassicPill`)—— 左 ✕、中间状态、右 ✓。 +pub fn dictation_capsule( + root_ui: &mut egui::Ui, + state: &CapsulePopupState, + lang: Lang, +) -> CapsuleAction { + let mut action = CapsuleAction::None; + let phase = state.phase.to_ascii_lowercase(); + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(root_ui, |ui| { + // 胶囊进程的首帧预热(同 QA 面板;录音环与 Siri 波都是 GPU 路径)。 + // Tauri `capsuleStyle`:siri = 流光药丸(GPU 波/环),classic = 经典药丸 + + // 五根音量条,typeless = 176×64 深色胶囊 + 11 根波形。 + let style = state.style.as_str(); + let typeless = style == "typeless"; + // 未知/空值走 siri(默认样式),只有显式选择 classic 才关掉 GPU 光效。 + let use_gpu = !typeless && style != "classic"; + let (pill_width, pill_height, button, bar_count) = if typeless { + (TYPELESS_WIDTH, TYPELESS_HEIGHT, TYPELESS_BUTTON, 11) + } else { + (PILL_WIDTH, PILL_HEIGHT, ROUND_BUTTON, 5) + }; + let siri = use_gpu; + let (pill_bg, pill_border, pill_ink) = if typeless { + (TYPELESS_BG, TYPELESS_BORDER, TYPELESS_INK) + } else if siri { + ( + egui::Color32::TRANSPARENT, + egui::Color32::TRANSPARENT, + theme::INK_2, + ) + } else { + (theme::SURFACE, theme::LINE, theme::INK_2) + }; + // Tauri 经典药丸宿主:窗口高 100,药丸水平居中、距底 16,徽章再上移 8。 + let available = ui.available_rect_before_wrap(); + let rect = egui::Rect::from_min_size( + egui::pos2( + available.center().x - pill_width / 2.0, + available.bottom() - CAPSULE_BOTTOM_INSET - pill_height, + ), + egui::vec2(pill_width, pill_height), + ); + let _ = ui.allocate_rect(rect, egui::Sense::hover()); + if state.translation_active { + translating_badge(ui, rect, lang); + } + // Tauri 的经典药丸只有「1px 中性描边」+「随音量轻微放大」两件事 + // (Capsule.tsx 的 ClassicPill:border 1px var(--ol-capsule-pill-border)、 + // transform scale(1 + ambient * 0.018)),**没有**任何外圈扫光/描边颜色变化。 + // 所以这里不再把录音相位画成红圈(那是本仓自己加的,用户报「有一个红边」); + // 运动感只保留药丸中心的音量波形。 + let ambient = if phase == "recording" { + state.audio_level.unwrap_or(0.0).clamp(0.0, 1.0) + } else { + 0.0 + }; + let pill = + egui::Rect::from_center_size(rect.center(), rect.size() * (1.0 + ambient * 0.018)); + if !siri { + ui.painter().rect_filled( + pill, + egui::CornerRadius::same((pill_height / 2.0) as u8), + pill_bg, + ); + ui.painter().rect_stroke( + pill, + egui::CornerRadius::same((pill_height / 2.0) as u8), + egui::Stroke::new(1.0, pill_border), + egui::StrokeKind::Inside, + ); + } + // Siri is a clean, transparent listening indicator. The cancel / + // confirm affordances belong to the classic and Typeless capsules. + let center = if siri { + rect.shrink(3.0) + } else { + let inset = if typeless { 7.0 } else { 8.0 }; + let cancel_rect = egui::Rect::from_center_size( + egui::pos2(rect.left() + inset + button / 2.0, rect.center().y), + egui::vec2(button, button), + ); + let cancel = ui.interact( + cancel_rect, + ui.id().with("openless-capsule-cancel"), + egui::Sense::click(), + ); + let cancel_fill = if typeless { + TYPELESS_BUTTON_BG + } else { + theme::SURFACE_2 + }; + round_button( + ui, + cancel_rect, + icons::IconName::Close, + cancel.hovered(), + cancel_fill, + pill_ink, + ); + if cancel.clicked() { + action = CapsuleAction::Cancel; + } + let confirm_rect = egui::Rect::from_center_size( + egui::pos2(rect.right() - inset - button / 2.0, rect.center().y), + egui::vec2(button, button), + ); + let confirm = ui.interact( + confirm_rect, + ui.id().with("openless-capsule-confirm"), + egui::Sense::click(), + ); + let (confirm_fill, confirm_ink) = if typeless { + (TYPELESS_INK, TYPELESS_BG) + } else { + (theme::SURFACE_2, theme::INK_2) + }; + round_button( + ui, + confirm_rect, + icons::IconName::Check, + confirm.hovered(), + confirm_fill, + confirm_ink, + ); + if confirm.clicked() { + action = CapsuleAction::Confirm; + } + egui::Rect::from_min_max( + egui::pos2(cancel_rect.right() + 4.0, rect.top() + 4.0), + egui::pos2(confirm_rect.left() - 4.0, rect.bottom() - 4.0), + ) + }; + let processing = matches!( + phase.as_str(), + "starting" | "transcribing" | "polishing" | "inserting" + ); + if phase == "recording" { + // Siri capsules are a transparent overlay. Draw the spectral + // ribbons with egui primitives so they work on the Vulkan path + // too (the legacy GL shader callback is unavailable there). + let drive = siri_gl::SiriDrive { + level: state.audio_level.unwrap_or_default(), + resolved: 1.0, + speed: 1.0, + warming: state.audio_level.is_none(), + }; + let dt = ui.input(|input| input.stable_dt); + let clock = siri_gl::tick(ui.ctx(), "capsule-siri-wave", drive, dt); + if siri { + let _ = siri_gl::paint( + ui, + center, + siri_gl::SiriGlow::wave(clock.time, clock.level), + ); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(16)); + } else { + audio_bars( + ui, + center, + state.audio_level.unwrap_or_default(), + bar_count, + pill_ink, + ); + } + } else if processing { + // 思考中:Siri 流体圆点(orb),从 wave 收拢的光点化开成环。 + let drive = siri_gl::SiriDrive { + level: 0.0, + resolved: 0.0, + speed: 1.3, + warming: false, + }; + let dt = ui.input(|input| input.stable_dt); + let clock = siri_gl::tick(ui.ctx(), "capsule-siri-orb", drive, dt); + // 0.3s 全聚圆心接住 wave 收拢的光点,再缓缓散开成环。 + let gather = (1.0 - (clock.time / 0.9).clamp(0.0, 1.0)).clamp(0.0, 1.0); + let glow = siri_gl::SiriGlow::orb(clock.time, gather); + if !use_gpu || !siri_gl::paint(ui, center, glow) { + ui.painter().text( + center.center(), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "capsule.thinking"), + egui::FontId::proportional(17.0), + theme::INK, + ); + } + } else if state.text.is_empty() { + let label = if processing { + tr_l10n(lang, "capsule.thinking") + } else if phase == "cancelled" { + tr_l10n(lang, "capsule.cancelled") + } else if phase == "failed" { + tr_l10n(lang, "capsule.error") + } else { + tr_l10n(lang, "capsule.thinking") + }; + let size = if processing { 17.0 } else { 11.0 }; + ui.painter().text( + center.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(size), + if phase == "failed" { + theme::ERR + } else if typeless { + TYPELESS_INK + } else { + theme::INK + }, + ); + } else { + // 11px/500 单行居中,超长省略(Tauri `getCapsuleMessageLayout`)。 + let galley = + layout::text_galley(ui, &state.text, pill_ink, 11.0, center.width(), 1); + ui.painter().galley( + egui::pos2( + center.center().x - galley.rect.width() / 2.0, + center.center().y - galley.rect.height() / 2.0, + ), + galley, + pill_ink, + ); + } + }); + action +} + +/// 28×28 圆形按钮(Tauri `CircleButton`)。 +fn round_button( + ui: &egui::Ui, + rect: egui::Rect, + icon: icons::IconName, + hovered: bool, + fill: egui::Color32, + ink: egui::Color32, +) { + ui.painter().circle_filled( + rect.center(), + rect.width() / 2.0, + if hovered { + fill.gamma_multiply(1.06) + } else { + fill + }, + ); + ui.painter().circle_stroke( + rect.center(), + rect.width() / 2.0, + egui::Stroke::new(0.8, theme::LINE), + ); + icons::draw_icon(ui, rect.center(), icon, ink); +} + +/// 音量条:Tauri `AudioBars`(5 根 3px 竖条,包络 0.55/0.85/1/0.85/0.55, +/// 过静音门限后按 0.42 次幂提亮)。 +fn audio_bars(ui: &egui::Ui, rect: egui::Rect, level: f32, bar_count: usize, ink: egui::Color32) { + const CLASSIC_ENVELOPE: [f32; 5] = [0.55, 0.85, 1.0, 0.85, 0.55]; + // Tauri `WAVE_ENVELOPE`(Typeless 的 11 根)。 + const TYPELESS_ENVELOPE: [f32; 11] = [ + 0.28, 0.44, 0.63, 0.82, 0.96, 1.0, 0.96, 0.82, 0.63, 0.44, 0.28, + ]; + let envelope: &[f32] = if bar_count > CLASSIC_ENVELOPE.len() { + &TYPELESS_ENVELOPE + } else { + &CLASSIC_ENVELOPE + }; + const BASE: f32 = 2.0; + let max = if bar_count > CLASSIC_ENVELOPE.len() { + 26.0 + } else { + 24.0 + }; + let voice = level.clamp(0.0, 1.0); + let gated = ((voice - 0.012) / (0.34 - 0.012)).clamp(0.0, 1.0); + let eased = gated * gated * (3.0 - 2.0 * gated); + let visual = eased.powf(0.42); + let bar_width = 3.0; + let gap = 3.0; + let total = envelope.len() as f32 * bar_width + (envelope.len() - 1) as f32 * gap; + let mut x = rect.center().x - total / 2.0; + for envelope in envelope { + let height = BASE + (max - BASE) * visual * envelope; + ui.painter().rect_filled( + egui::Rect::from_center_size( + egui::pos2(x + bar_width / 2.0, rect.center().y), + egui::vec2(bar_width, height), + ), + egui::CornerRadius::same(2), + ink, + ); + x += bar_width + gap; + } +} + +// ── 共享小件 ──────────────────────────────────────────────────────────────── + +/// 30×30 无底色图标按钮(Tauri `size-icon-sm` ghost)。 +fn icon_button(ui: &mut egui::Ui, icon: icons::IconName, color: egui::Color32) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(30.0, 30.0), egui::Sense::click()); + if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(7), theme::SURFACE_2); + } + icons::draw_icon(ui, rect.center(), icon, color); + response +} + +/// 在矩形内居中画「[图标] 文字」。 +fn icon_text( + ui: &egui::Ui, + rect: egui::Rect, + icon: Option, + text: &str, + color: egui::Color32, +) { + let text_width = layout::text_width(ui, text, 13.0); + let icon_width = if icon.is_some() { 16.0 } else { 0.0 }; + let gap = if icon.is_some() { 6.0 } else { 0.0 }; + let start = rect.center().x - (text_width + gap + icon_width) / 2.0; + if let Some(icon) = icon { + icons::draw_icon( + ui, + egui::pos2(start + icon_width / 2.0, rect.center().y), + icon, + color, + ); + } + ui.painter().text( + egui::pos2(start + icon_width + gap, rect.center().y), + egui::Align2::LEFT_CENTER, + text, + egui::FontId::proportional(13.0), + color, + ); +} + +/// 极简 Markdown:标题 / 列表 / 代码块 / `**粗体**` / `*斜体*` / `` `等宽` ``。 +/// 覆盖 Tauri `AssistantMarkdown` 会产出的块级结构;不做表格与引用块。 +pub fn render_markdown(ui: &mut egui::Ui, markdown: &str) { + let mut code = String::new(); + let mut in_code = false; + for line in markdown.lines() { + let trimmed = line.trim(); + if trimmed.starts_with("```") { + if in_code { + code_block(ui, code.trim_end()); + code.clear(); + } + in_code = !in_code; + continue; + } + if in_code { + code.push_str(line); + code.push('\n'); + continue; + } + if trimmed.is_empty() { + ui.add_space(6.0); + continue; + } + let (text, size, strong, bullet) = if let Some(value) = trimmed.strip_prefix("### ") { + (value, 14.0, true, false) + } else if let Some(value) = trimmed.strip_prefix("## ") { + (value, 15.0, true, false) + } else if let Some(value) = trimmed.strip_prefix("# ") { + (value, 16.0, true, false) + } else if trimmed.starts_with("- ") || trimmed.starts_with("* ") { + (&trimmed[2..], 14.0, false, true) + } else { + (trimmed, 14.0, false, false) + }; + if bullet { + ui.horizontal_top(|ui| { + ui.add_space(2.0); + ui.label(egui::RichText::new("•").size(size).color(theme::INK_3)); + ui.label(inline_job(ui, text, size, strong)); + }); + } else { + ui.label(inline_job(ui, text, size, strong)); + } + } + if !code.is_empty() { + code_block(ui, code.trim_end()); + } +} + +fn inline_job(ui: &egui::Ui, text: &str, size: f32, strong: bool) -> egui::text::LayoutJob { + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = ui.available_width().max(40.0); + append_inline(&mut job, text, size, strong, false, false); + job +} + +/// 行内样式:`**粗体**`、`*斜体*`、`` `等宽` ``。 +fn append_inline( + job: &mut egui::text::LayoutJob, + text: &str, + size: f32, + strong: bool, + italics: bool, + monospace: bool, +) { + let mut rest = text; + while !rest.is_empty() { + let mut matched = false; + for (open, close, next_strong, next_italics, next_monospace) in [ + ("**", "**", true, italics, monospace), + ("`", "`", strong, italics, true), + ("*", "*", strong, true, monospace), + ("_", "_", strong, true, monospace), + ] { + if let Some(after_open) = rest.strip_prefix(open) { + if let Some(end) = after_open.find(close) { + append_span( + job, + &after_open[..end], + size, + next_strong, + next_italics, + next_monospace, + ); + rest = &after_open[end + close.len()..]; + matched = true; + break; + } + } + } + if matched { + continue; + } + let next = ["**", "*", "`", "_"] + .iter() + .filter_map(|marker| rest.find(marker)) + .min() + .unwrap_or(rest.len()); + let length = if next == 0 { + rest.chars().next().map(char::len_utf8).unwrap_or(0) + } else { + next + }; + append_span(job, &rest[..length], size, strong, italics, monospace); + rest = &rest[length..]; + } +} + +fn append_span( + job: &mut egui::text::LayoutJob, + text: &str, + size: f32, + strong: bool, + italics: bool, + monospace: bool, +) { + job.append( + text, + 0.0, + egui::TextFormat { + font_id: egui::FontId::new( + size, + if monospace { + egui::FontFamily::Monospace + } else { + egui::FontFamily::Proportional + }, + ), + color: if strong { theme::INK } else { theme::INK_2 }, + background: if monospace { + theme::SURFACE_2 + } else { + egui::Color32::TRANSPARENT + }, + italics, + ..Default::default() + }, + ); +} + +fn code_block(ui: &mut egui::Ui, code: &str) { + egui::Frame::new() + .fill(theme::SURFACE_2) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::same(10)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(code) + .monospace() + .size(12.5) + .color(theme::INK_2), + ); + }); +} + +fn truncate(text: &str, max: usize) -> String { + let chars: Vec = text.chars().collect(); + if chars.len() <= max { + return text.to_string(); + } + let mut out: String = chars[..max].iter().collect(); + out.push('…'); + out +} + +/// 格式化「已插入 N」文案(宿主在 Completed 阶段缺少 Core message 时使用)。 +pub fn inserted_message(lang: Lang, chars: usize) -> String { + fmt_l10n(lang, "capsule.inserted", &[&chars]) +} + +// ── Less Computer 面板 ────────────────────────────────────────────────────── + +/// Less Computer 浮窗的动作(宿主转成 `PopupToHost` 消息)。 +pub enum LessComputerAction { + None, + /// ✕ → 只收起面板(已完成的一轮保留)。 + Dismiss, + /// Esc / 停止 → 取消当前这一轮。 + Cancel, + /// 输入框回车 / 发送。 + Submit(String), + /// 阻塞命令的批准或拒绝。 + Approve { + token: String, + approved: bool, + }, +} + +/// Less Computer 语音 Agent 浮窗(Tauri `LessComputerPanel.tsx`)。 +/// +/// 面板只呈现宿主推来的事件序列(`LessComputerPopupState::entries`),不解释产品意图: +/// 用户指令是右对齐气泡、工具调用与上下文压缩是行内标记、助手正文走 markdown, +/// 阻塞命令在输入框上方给出批准 / 拒绝。 +pub fn less_computer( + root_ui: &mut egui::Ui, + state: &LessComputerPopupState, + composer: &mut String, + lang: Lang, +) -> LessComputerAction { + let mut action = LessComputerAction::None; + egui::CentralPanel::default() + .frame( + egui::Frame::NONE + .fill(theme::SURFACE) + .corner_radius(egui::CornerRadius::same(14)) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .inner_margin(egui::Margin::same(CARD_SPACING as i8)), + ) + .show(root_ui, |ui| { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "less_computer.title")) + .size(16.0) + .strong() + .color(theme::INK), + ); + ui.add_space(3.0); + // 运行中显示「执行中…」,否则是那句「想让电脑做什么?」的副标题。 + let subtitle = if state.working { + tr_l10n(lang, "less_computer.working") + } else { + tr_l10n(lang, "less_computer.subtitle") + }; + ui.label(egui::RichText::new(subtitle).size(12.0).color(theme::INK_4)); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + if icon_button(ui, icons::IconName::Close, theme::INK_3).clicked() { + action = LessComputerAction::Dismiss; + } + }); + }); + ui.add_space(10.0); + + // 审批卡的实际高度随命令/警告文字行数变化(警告会换行到两行),预留值取 + // “单行标题 + 等宽命令 + 两行警告 + 按钮行 + 内边距”。取小了会把 + // 底部输入框挤出卡片外(越出窗口下缘),这是实测发现的。 + let approval_height = if state.approval.is_some() { 158.0 } else { 0.0 }; + let list_height = + (ui.available_height() - COMPOSER_HEIGHT - approval_height - 12.0).max(120.0); + ui.allocate_ui(egui::vec2(ui.available_width(), list_height), |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-less-computer") + .auto_shrink([false, false]) + .stick_to_bottom(true) + .show(ui, |ui| { + if state.entries.is_empty() && !state.working { + ui.add_space(24.0); + ui.vertical_centered(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "less_computer.subtitle")) + .size(13.0) + .color(theme::INK_4), + ); + }); + } + for entry in &state.entries { + match entry.kind.as_str() { + "user" => user_bubble(ui, &entry.text), + "assistant" => { + ui.add_space(2.0); + render_markdown(ui, &entry.text); + ui.add_space(2.0); + } + "tool" | "note" => marker_row(ui, &entry.text, theme::INK_3), + "compaction" => compaction_marker(ui, &entry.text), + "error" => { + ui.label( + egui::RichText::new(&entry.text) + .size(12.5) + .color(theme::ERR), + ); + } + _ => marker_row(ui, &entry.text, theme::INK_3), + } + } + if state.working { + ui.add_space(2.0); + marker_row(ui, tr_l10n(lang, "less_computer.working"), theme::INK_3); + } + }); + }); + + if let Some(approval) = &state.approval { + ui.add_space(6.0); + egui::Frame::new() + .fill(theme::WARN_SOFT) + .stroke(egui::Stroke::new(0.5, theme::WARN)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::same(10)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "less_computer.approval_title")) + .size(12.5) + .strong() + .color(theme::INK), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(&approval.command) + .font(egui::FontId::monospace(11.5)) + .color(theme::INK_2), + ); + if !approval.reason.is_empty() { + ui.add_space(3.0); + ui.label( + egui::RichText::new(&approval.reason) + .size(11.0) + .color(theme::INK_3), + ); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if small_action_button(ui, tr_l10n(lang, "less_computer.approve"), true) + .clicked() + { + action = LessComputerAction::Approve { + token: approval.token.clone(), + approved: true, + }; + } + if small_action_button(ui, tr_l10n(lang, "less_computer.deny"), false) + .clicked() + { + action = LessComputerAction::Approve { + token: approval.token.clone(), + approved: false, + }; + } + }); + }); + } + + ui.add_space(6.0); + let width = ui.available_width(); + let (rect, _) = + ui.allocate_exact_size(egui::vec2(width, COMPOSER_HEIGHT), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(12), theme::SURFACE); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(12), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + let send_rect = egui::Rect::from_center_size( + egui::pos2(rect.right() - 20.0, rect.center().y), + egui::vec2(28.0, 28.0), + ); + let send = ui + .interact( + send_rect, + egui::Id::new("less-computer-send"), + egui::Sense::click(), + ) + .on_hover_text(tr_l10n(lang, "less_computer.send")); + if !composer.trim().is_empty() { + ui.painter().rect_filled( + send_rect, + egui::CornerRadius::same(14), + if send.hovered() { + theme::INK_2 + } else { + theme::INK + }, + ); + icons::draw_icon( + ui, + send_rect.center(), + icons::IconName::Send, + theme::SURFACE, + ); + } + let text_rect = egui::Rect::from_min_max( + egui::pos2(rect.left() + 12.0, rect.top()), + egui::pos2(send_rect.left() - 6.0, rect.bottom()), + ); + let mut child = ui.new_child( + egui::UiBuilder::new() + .id_salt("less-computer-composer") + .max_rect(text_rect) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + let response = child.add( + egui::TextEdit::singleline(composer) + .id(egui::Id::new("less-computer-composer-input")) + .hint_text(tr_l10n(lang, "less_computer.input_placeholder")) + .font(egui::FontId::proportional(13.5)) + .text_color(theme::INK) + .frame(egui::Frame::NONE) + .desired_width(text_rect.width()) + .vertical_align(egui::Align::Center), + ); + let submitted = + response.lost_focus() && child.input(|input| input.key_pressed(egui::Key::Enter)); + if submitted || send.clicked() { + let text = composer.trim().to_string(); + if !text.is_empty() { + action = LessComputerAction::Submit(text); + } + } + // Esc 取消当前这一轮(Tauri 面板的 Esc 语义)。 + if ui.input(|input| input.key_pressed(egui::Key::Escape)) { + action = LessComputerAction::Cancel; + } + }); + action +} + +/// 右对齐的用户指令气泡(Tauri `Bubble align="end"`)。 +fn user_bubble(ui: &mut egui::Ui, text: &str) { + ui.add_space(2.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let max = (ui.available_width() * 0.82).max(80.0); + ui.set_max_width(max); + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(9, 6)) + .show(ui, |ui| { + ui.set_max_width(max - 18.0); + ui.label(egui::RichText::new(text).size(12.5).color(theme::INK)); + }); + }); + ui.add_space(2.0); +} + +/// 行内标记:小圆点 + 辅助色文字(Tauri `Marker`)。 +fn marker_row(ui: &mut egui::Ui, text: &str, color: egui::Color32) { + ui.horizontal(|ui| { + let (rect, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 2.0, color); + ui.label(egui::RichText::new(text).size(11.5).color(color)); + }); +} + +/// 上下文压缩标记(Tauri `Marker variant="separator"`)。 +fn compaction_marker(ui: &mut egui::Ui, text: &str) { + ui.add_space(2.0); + ui.horizontal(|ui| { + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 16.0), egui::Sense::hover()); + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, theme::LINE_SOFT), + ); + let galley = ui.painter().layout_no_wrap( + text.to_owned(), + egui::FontId::proportional(11.0), + theme::INK_4, + ); + let center = rect.center(); + ui.painter().rect_filled( + egui::Rect::from_center_size(center, galley.size() + egui::vec2(10.0, 0.0)), + egui::CornerRadius::same(7), + theme::SURFACE, + ); + ui.painter().galley( + egui::pos2( + center.x - galley.rect.width() / 2.0, + center.y - galley.rect.height() / 2.0, + ), + galley, + theme::INK_4, + ); + }); + ui.add_space(2.0); +} + +/// 批准 / 拒绝按钮(Tauri `Button`)。 +fn small_action_button(ui: &mut egui::Ui, label: &str, primary: bool) -> egui::Response { + let text = egui::RichText::new(label).size(11.5); + let button = if primary { + egui::Button::new(text.color(theme::SURFACE)).fill(theme::INK) + } else { + egui::Button::new(text.color(theme::INK_2)) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + }; + ui.add( + button + .corner_radius(egui::CornerRadius::same(7)) + .min_size(egui::vec2(56.0, 26.0)), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use openless_linux_egui::PopupChatMessage; + + fn painted_text(output: &egui::FullOutput) -> String { + let mut text = String::new(); + for clipped in output.shapes.iter() { + collect(&clipped.shape, &mut text); + } + text + } + + fn collect(shape: &egui::Shape, out: &mut String) { + match shape { + egui::Shape::Text(text) => { + for row in &text.galley.rows { + for glyph in &row.glyphs { + if glyph.chr != '\0' { + out.push(glyph.chr); + } + } + out.push('\n'); + } + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, out); + } + } + _ => {} + } + } + + /// Glyphs are collected row by row, so wrapped copy contains newlines. + /// Compare whitespace-insensitively. + fn flat(text: &str) -> String { + text.chars().filter(|c| !c.is_whitespace()).collect() + } + + /// Whether the painted output contains `needle`, ignoring line wrapping. + fn has(painted: &str, needle: &str) -> bool { + flat(painted).contains(&flat(needle)) + } + + /// Render one popup for two frames (egui sizes some widgets lazily) and + /// return everything it painted. + fn run(size: egui::Vec2, mut render: impl FnMut(&mut egui::Ui) -> String) -> String { + // Every popup test renders the same frontend as the GPU-state tests, so + // they share the process-global glow flags and must not run in parallel. + let ctx = egui::Context::default(); + let mut painted = String::new(); + for _ in 0..2 { + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + ..Default::default() + }, + |ui| { + let _ = render(ui); + }, + ); + painted = painted_text(&output); + } + painted + } + + /// 与 `run` 同款流程,但把最后一帧的 `FullOutput` 交出来(要按形状断言时用)。 + fn run_output( + size: egui::Vec2, + mut render: impl FnMut(&mut egui::Ui) -> String, + ) -> egui::FullOutput { + let ctx = egui::Context::default(); + let mut last = None; + for _ in 0..2 { + last = Some(crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + ..Default::default() + }, + |ui| { + let _ = render(ui); + }, + )); + } + last.expect("at least one pass") + } + + /// 面板画出的分隔线(`hairline` = 一条 `LINE_SOFT` 细线),返回 (y, x0, x1)。 + fn hairlines(output: &egui::FullOutput) -> Vec<(f32, f32, f32)> { + fn walk(shape: &egui::Shape, out: &mut Vec<(f32, f32, f32)>) { + match shape { + egui::Shape::LineSegment { points, stroke } + if stroke.color == theme::LINE_SOFT && stroke.width <= 1.0 => + { + out.push((points[0].y, points[0].x, points[1].x)); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + walk(shape, out); + } + } + _ => {} + } + } + let mut out = Vec::new(); + for clipped in &output.shapes { + walk(&clipped.shape, &mut out); + } + out + } + + /// 润色结果模式必须沿用选区助手面板的外壳:同一套头/尾分隔线。 + /// + /// 回归背景:合并之后 egui 侧曾自己一套排版(没有分隔线、没有 14px 内容 + /// 边距),看着就像已经下线的独立预览窗口。 + #[test] + fn the_polish_result_reuses_the_selection_ask_chrome() { + let polish = QaPopupState { + phase: "idle".to_string(), + polish: Some(QaPolishState { + text: "polished text".to_string(), + source: "source paragraph".to_string(), + }), + ..Default::default() + }; + let ask = QaPopupState { + phase: "idle".to_string(), + ..Default::default() + }; + let mut polish_composer = String::new(); + let mut ask_composer = String::new(); + let polish_out = run_output(egui::vec2(420.0, 540.0), |ctx| { + selection_ask(ctx, &polish, &mut polish_composer, Lang::ZhCn, None); + String::new() + }); + let ask_out = run_output(egui::vec2(420.0, 540.0), |ctx| { + selection_ask(ctx, &ask, &mut ask_composer, Lang::ZhCn, None); + String::new() + }); + let polish_lines = hairlines(&polish_out); + let ask_lines = hairlines(&ask_out); + assert!( + polish_lines.len() >= 2, + "the polish mode must paint header + footer separators, got {:?}", + polish_lines + ); + // 第一条(头部下方那条)必须与选区助手面板的位置与宽度一致。 + let (py, px0, px1) = polish_lines[0]; + let (ay, ax0, ax1) = ask_lines[0]; + assert!( + (py - ay).abs() < 0.5 && (px0 - ax0).abs() < 0.5 && (px1 - ax1).abs() < 0.5, + "the header separator must match the selection-ask panel: polish {:?} vs ask {:?}", + polish_lines[0], + ask_lines[0] + ); + } + + /// 合并后:润色结果就画在**选区助手面板**里(同一弹窗的第二套 UI), + /// 原独立预览窗口的文案与动作必须一模一样地出现。 + #[test] + fn the_polish_result_renders_inside_the_ask_panel() { + let state = QaPopupState { + phase: "idle".to_string(), + polish: Some(QaPolishState { + text: "polished text".to_string(), + source: "source paragraph".to_string(), + }), + ..Default::default() + }; + let mut composer = String::new(); + let painted = run(egui::vec2(420.0, 540.0), |ctx| { + let action = selection_ask(ctx, &state, &mut composer, Lang::ZhCn, None); + assert_eq!(action, QaAction::None); + String::new() + }); + for expected in [ + tr_l10n(Lang::ZhCn, "selection.polish_preview.title"), + tr_l10n(Lang::ZhCn, "selection.polish_preview.subtitle"), + tr_l10n(Lang::ZhCn, "selection.polish_preview.confirm_replace"), + tr_l10n(Lang::ZhCn, "selection.polish_preview.cancel"), + tr_l10n(Lang::ZhCn, "selection.polish_preview.source_prefix"), + ] { + assert!( + has(&painted, expected), + "the polish mode must paint {expected:?} inside the ask panel\n{painted}" + ); + } + } + + /// 润色结果**只读**:签名拿的是 `&QaPolishState`(根本改不动),框里必须 + /// 把结果原文画出来——「确认并替换」(即用户说的「插入」)写回的就是这段原文。 + #[test] + fn the_polish_result_is_read_only_and_paints_the_text() { + let state = QaPolishState { + text: "polished result text".to_string(), + source: "source paragraph".to_string(), + }; + let painted = run(egui::vec2(420.0, 540.0), |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + assert_eq!( + polish_result_mode(ui, &state, Lang::ZhCn), + PolishResultAction::None + ); + }); + String::new() + }); + assert!( + has(&painted, "polished result text"), + "the read-only result box must paint the polished text\n{painted}" + ); + assert!( + has(&painted, "source paragraph"), + "the source summary must stay visible\n{painted}" + ); + assert_eq!(state.text, "polished result text"); + } + + /// 润色结果模式下,面板的其余部分(提问对话、输入框、麦克风)不得出现: + /// 同一个面板的两套 UI 不能同时画。 + #[test] + fn the_polish_result_mode_replaces_the_ask_conversation() { + let state = QaPopupState { + phase: "idle".to_string(), + polish: Some(QaPolishState { + text: "polished text".to_string(), + source: String::new(), + }), + ..Default::default() + }; + let mut composer = String::new(); + let painted = run(egui::vec2(420.0, 540.0), |ctx| { + selection_ask(ctx, &state, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!( + !has(&painted, tr_l10n(Lang::ZhCn, "qa.composer_placeholder")), + "the ask composer must not be painted in polish mode\n{painted}" + ); + } + + #[test] + fn ask_panel_paints_empty_state_then_thread() { + let empty = QaPopupState { + phase: "idle".to_string(), + ..Default::default() + }; + let mut composer = String::new(); + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &empty, &mut composer, Lang::ZhCn, None); + String::new() + }); + for expected in [ + tr_l10n(Lang::ZhCn, "qa.title"), + tr_l10n(Lang::ZhCn, "qa.header_hint"), + tr_l10n(Lang::ZhCn, "qa.empty_title"), + tr_l10n(Lang::ZhCn, "qa.empty_desc"), + tr_l10n(Lang::ZhCn, "qa.composer_placeholder"), + ] { + assert!( + has(&painted, expected), + "empty ask panel must paint {expected:?}\n{painted}" + ); + } + + let thread = QaPopupState { + polish: None, + phase: "thinking".to_string(), + messages: vec![ + PopupChatMessage { + role: "user".to_string(), + content: "how should I read this?".to_string(), + selection_text: Some("selected source".to_string()), + }, + PopupChatMessage { + role: "assistant".to_string(), + content: "**key point** here.".to_string(), + selection_text: None, + }, + ], + selection_preview: Some("selected source".to_string()), + streaming_answer: String::new(), + error: Some("network error".to_string()), + edit_instruction_mode: false, + edit_apply_available: false, + edit_revert_available: false, + pinned: false, + viewer_login: String::new(), + }; + let mut composer = String::new(); + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &thread, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!(has(&painted, "how should I read this?"), "{painted}"); + assert!(has(&painted, "selected source"), "{painted}"); + assert!(has(&painted, "network error"), "{painted}"); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.thinking")), + "{painted}" + ); + } + + #[test] + fn ask_panel_recording_shows_selection_chip_and_ring() { + let state = QaPopupState { + phase: "recording".to_string(), + selection_preview: Some("selection shown while recording".to_string()), + ..Default::default() + }; + let mut composer = String::new(); + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &state, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.selection_preview")), + "{painted}" + ); + assert!( + has(&painted, "selection shown while recording"), + "{painted}" + ); + } + + /// 录音/思考的中心视觉现在由 CPU 画法承担(着色器路径已从渲染路径摘除,见 + /// `siri_gl` 文件头):这两种状态都不许再排 GPU 回调,但中心不能是空的; + /// 终态则一个光效都没有。 + /// + /// 以前录音还会多排一个**外圈红扫光**、思考多一个黑扫光。Tauri 的经典药丸只有 + /// 1px 中性描边(Capsule.tsx 的 `border: 1px var(--ol-capsule-pill-border)`), + /// 没有外圈扫光——用户报「语音输入弹窗有一个红边」就是它,所以这里继续锁死。 + #[test] + fn capsule_keeps_the_centre_glow_off_the_gpu_path() { + // The GPU state is process-global; take the shared test guard. + let frame = |state: CapsulePopupState| { + let ctx = egui::Context::default(); + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(200.0, 100.0), + )), + ..Default::default() + }, + |ui| { + let _ = dictation_capsule(ui, &state, Lang::ZhCn); + }, + ); + let callbacks = output + .shapes + .iter() + .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) + .count(); + let mut glow = 0; + for clipped in &output.shapes { + count_centre_glow(&clipped.shape, &mut glow); + } + (callbacks, glow) + }; + for (label, state, wants_centre) in [ + ( + "recording = CPU wave only, no perimeter ring", + CapsulePopupState { + phase: "recording".into(), + audio_level: Some(0.2), + ..Default::default() + }, + true, + ), + ( + "thinking = CPU orb only, no perimeter ring", + CapsulePopupState { + phase: "transcribing".into(), + ..Default::default() + }, + true, + ), + ( + "terminal capsule paints no glow", + CapsulePopupState { + phase: "inserted".into(), + text: "hello".into(), + ..Default::default() + }, + false, + ), + ] { + let (callbacks, glow) = frame(state); + assert_eq!( + callbacks, 0, + "{label}: the shader path is off, no GPU callback may be queued" + ); + if wants_centre { + assert!(glow > 0, "{label}: the centre glow must still be painted"); + } + } + } + + #[test] + fn capsule_shows_the_translating_badge_only_when_translating() { + let badge = tr_l10n(Lang::ZhCn, "capsule.translating"); + let idle = CapsulePopupState { + phase: "Recording".to_string(), + audio_level: Some(0.3), + translation_active: false, + ..Default::default() + }; + let painted = run(egui::vec2(200.0, 100.0), |ctx| { + dictation_capsule(ctx, &idle, Lang::ZhCn); + String::new() + }); + assert!( + !has(&painted, badge), + "badge must stay hidden while translating is off: {painted}" + ); + + let translating = CapsulePopupState { + translation_active: true, + ..idle + }; + let painted = run(egui::vec2(200.0, 100.0), |ctx| { + dictation_capsule(ctx, &translating, Lang::ZhCn); + String::new() + }); + assert!(has(&painted, badge), "{painted}"); + } + + #[test] + fn qa_panel_shows_edit_affordances_only_when_the_host_reports_them() { + let hidden = QaPopupState { + phase: "idle".to_string(), + ..Default::default() + }; + let mut composer = String::new(); + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &hidden, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!(!has(&painted, tr_l10n(Lang::ZhCn, "qa.edit_apply_replace"))); + assert!(!has( + &painted, + tr_l10n(Lang::ZhCn, "qa.edit_revert_previous") + )); + + let ready = QaPopupState { + phase: "idle".to_string(), + edit_apply_available: true, + edit_revert_available: true, + ..Default::default() + }; + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &ready, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.edit_apply_replace")), + "{painted}" + ); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.edit_revert_previous")), + "{painted}" + ); + // 「编辑指令」勾选框常驻在输入组左下角。 + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.edit_instruction_mode")), + "{painted}" + ); + + // 只有可回退时才出现「保留上一版本」。 + let apply_only = QaPopupState { + phase: "idle".to_string(), + edit_apply_available: true, + ..Default::default() + }; + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &apply_only, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.edit_apply_replace")), + "{painted}" + ); + assert!(!has( + &painted, + tr_l10n(Lang::ZhCn, "qa.edit_revert_previous") + )); + } + + #[test] + fn qa_panel_paints_the_pin_affordance_in_both_states() { + // 图钉是无文字的图标按钮:这里断言两种状态都能整帧渲染(含 tooltip 绑定), + // 动作本身由 popup.rs 的协议测试覆盖。 + for pinned in [false, true] { + let state = QaPopupState { + phase: "idle".to_string(), + pinned, + ..Default::default() + }; + let mut composer = String::new(); + let painted = run(egui::vec2(520.0, 520.0), |ctx| { + selection_ask(ctx, &state, &mut composer, Lang::ZhCn, None); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "qa.empty_title")), + "pinned={pinned}\n{painted}" + ); + } + } + + #[test] + fn qa_panel_renders_the_github_avatar_texture_when_present() { + let ctx = egui::Context::default(); + let image = egui::ColorImage::new([2, 2], vec![egui::Color32::RED; 4]); + let texture = ctx.load_texture("test-avatar", image, egui::TextureOptions::LINEAR); + let state = QaPopupState { + phase: "idle".to_string(), + messages: vec![PopupChatMessage { + role: "user".to_string(), + content: "hello".to_string(), + selection_text: None, + }], + ..Default::default() + }; + let mut composer = String::new(); + let mut painted = String::new(); + for _ in 0..2 { + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(520.0, 520.0), + )), + ..Default::default() + }, + |ui| { + selection_ask(ui, &state, &mut composer, Lang::ZhCn, Some(&texture)); + }, + ); + painted = painted_text(&output); + } + assert!(has(&painted, "hello"), "{painted}"); + } + + /// Every fill / stroke colour the frame painted, so a test can assert the + /// classic pill never grows a coloured outline again. + fn painted_colors(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Rect(rect) => { + out.push(rect.fill); + out.push(rect.stroke.color); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + painted_colors(shape, out); + } + } + _ => {} + } + } + + /// Render one capsule frame and collect the colours, the GPU callback count + /// and the number of centre glow strokes (the CPU wave lines). + fn capsule_frame(state: &CapsulePopupState) -> (Vec, usize, usize) { + let ctx = egui::Context::default(); + let mut colors = Vec::new(); + let mut callbacks = 0; + let mut glow = 0; + for _ in 0..2 { + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(200.0, 100.0), + )), + ..Default::default() + }, + |ui| { + let _ = dictation_capsule(ui, state, Lang::ZhCn); + }, + ); + colors.clear(); + callbacks = 0; + glow = 0; + for clipped in &output.shapes { + painted_colors(&clipped.shape, &mut colors); + count_centre_glow(&clipped.shape, &mut glow); + if matches!(clipped.shape, egui::Shape::Callback(_)) { + callbacks += 1; + } + } + } + (colors, callbacks, glow) + } + + /// 录音相位的「中心运动感」现在由 CPU 画法承担(着色器路径已从渲染路径摘除, + /// 见 `siri_gl` 文件头):波形是一串 49 点的折线,思考是流体圆点,两者都 + /// 算中心光效(CPU 竖条走的是小圆角矩形,不在这里)。 + fn count_centre_glow(shape: &egui::Shape, out: &mut usize) { + match shape { + egui::Shape::Path(path) if path.points.len() >= 8 => *out += 1, + egui::Shape::Circle(_) => *out += 1, + egui::Shape::Vec(shapes) => { + for shape in shapes { + count_centre_glow(shape, out); + } + } + _ => {} + } + } + + /// Whether a colour reads as the OpenLess error red (the old ring tint). + fn is_reddish(color: egui::Color32) -> bool { + color.a() > 40 && color.r() > 150 && color.g() < 110 && color.b() < 110 + } + + #[test] + fn recording_capsule_paints_no_coloured_outline() { + // Tauri 的经典药丸只有 1px 中性描边(Capsule.tsx:border 1px + // var(--ol-capsule-pill-border)),录音时只把药丸随音量放大 1.8%。 + // 外圈红/黑扫光是本仓自己加的,用户报「语音输入弹窗有一个红边」—— + // 这条测试锁死它不许回来。 + for phase in ["Recording", "Transcribing", "Polishing"] { + let state = CapsulePopupState { + phase: phase.to_string(), + text: String::new(), + audio_level: Some(0.6), + translation_active: false, + style: "classic".to_string(), + }; + let (colors, _, _) = capsule_frame(&state); + let reddish: Vec<_> = colors + .iter() + .copied() + .filter(|color| is_reddish(*color)) + .collect(); + assert!( + reddish.is_empty(), + "{phase} capsule must not paint a red outline, found {reddish:?}" + ); + } + } + + #[test] + fn recording_capsule_keeps_its_centre_visual() { + // 去掉外圈之后,录音相位的运动感来自药丸中心(GPU 波形,失败时回退成 + // Tauri 的 5 根音量竖条)——两者至少有一个必须在。 + let state = CapsulePopupState { + phase: "Recording".to_string(), + text: String::new(), + audio_level: Some(0.6), + translation_active: false, + style: "siri".to_string(), + }; + let (colors, callbacks, glow) = capsule_frame(&state); + // 音量竖条是 3px 宽的小圆角矩形:数一下细长条形的填充个数。 + let fills = colors.iter().filter(|color| color.a() > 0).count(); + assert!( + callbacks > 0 || fills >= 6 || glow > 0, + "recording capsule must keep the centre visual \ + (callbacks={callbacks}, fills={fills}, glow={glow})" + ); + } + + /// 胶囊在 layer-shell 表面上**只有指针输入**(层表面拿不到键盘与输入法), + /// 所以「两个圆钮必须可点」是它唯一能用的交互。这条测试把指针按到 ✕ / ✓ + /// 的圆心,必须分别返回 Cancel / Confirm。 + #[test] + fn the_capsule_buttons_report_cancel_and_confirm() { + let size = egui::vec2(460.0, 180.0); + // 与渲染同源的几何:药丸 176×42,水平居中、距底 16;圆钮 28、内缩 8。 + let pill_left = size.x / 2.0 - PILL_WIDTH / 2.0; + let pill_right = pill_left + PILL_WIDTH; + let centre_y = size.y - CAPSULE_BOTTOM_INSET - PILL_HEIGHT / 2.0; + let cancel = egui::pos2(pill_left + 8.0 + ROUND_BUTTON / 2.0, centre_y); + let confirm = egui::pos2(pill_right - 8.0 - ROUND_BUTTON / 2.0, centre_y); + for (position, expected) in [ + (cancel, CapsuleAction::Cancel), + (confirm, CapsuleAction::Confirm), + ] { + let ctx = egui::Context::default(); + let state = CapsulePopupState { + phase: "Recording".to_string(), + audio_level: Some(0.4), + style: "classic".to_string(), + ..Default::default() + }; + let mut action = CapsuleAction::None; + // 三帧:第一帧布局,第二帧按下,第三帧松开(egui 的点击需要成对事件)。 + for events in [ + vec![egui::Event::PointerMoved(position)], + vec![egui::Event::PointerButton { + pos: position, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::default(), + }], + vec![egui::Event::PointerButton { + pos: position, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::default(), + }], + ] { + let _ = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)), + events, + ..Default::default() + }, + |ui| { + action = dictation_capsule(ui, &state, Lang::ZhCn); + }, + ); + } + assert_eq!( + action, expected, + "clicking {position:?} must report {expected:?}" + ); + } + } + + #[test] + fn capsule_paints_state_specific_content() { + let recording = CapsulePopupState { + phase: "Recording".to_string(), + text: String::new(), + audio_level: Some(0.4), + translation_active: false, + style: "siri".to_string(), + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + dictation_capsule(ctx, &recording, Lang::ZhCn); + String::new() + }); + assert!( + !painted.contains(tr_l10n(Lang::ZhCn, "capsule.thinking")), + "recording capsule shows level bars, not the thinking label: {painted}" + ); + + // classic 样式:中心走 Tauri 的竖条/文字(`use_gpu` 为假)。 + let transcribing = CapsulePopupState { + phase: "Transcribing".to_string(), + style: "classic".to_string(), + ..Default::default() + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + dictation_capsule(ctx, &transcribing, Lang::ZhCn); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "capsule.thinking")), + "{painted}" + ); + + // siri 样式:中心交给 Siri 光效(现在由 CPU 画,见 `siri_gl` 文件头), + // 所以不再叠一行「思考中」文字——与 GPU 就绪后的终态一致。 + let transcribing_siri = CapsulePopupState { + phase: "Transcribing".to_string(), + style: "siri".to_string(), + ..Default::default() + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + dictation_capsule(ctx, &transcribing_siri, Lang::ZhCn); + String::new() + }); + assert!( + !has(&painted, tr_l10n(Lang::ZhCn, "capsule.thinking")), + "siri transcribing paints the orb instead of the label: {painted}" + ); + + let done = CapsulePopupState { + phase: "Completed".to_string(), + text: inserted_message(Lang::ZhCn, 12), + audio_level: None, + translation_active: false, + style: "siri".to_string(), + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + dictation_capsule(ctx, &done, Lang::ZhCn); + String::new() + }); + assert!(has(&painted, "12"), "{painted}"); + + let failed = CapsulePopupState { + phase: "Failed".to_string(), + text: String::new(), + audio_level: None, + translation_active: false, + style: "siri".to_string(), + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + dictation_capsule(ctx, &failed, Lang::ZhCn); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "capsule.error")), + "{painted}" + ); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/selection_ask.rs b/openless-all/app/linux-egui/src/ui/frontend/selection_ask.rs new file mode 100644 index 000000000..25378f94a --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/selection_ask.rs @@ -0,0 +1,253 @@ +//! Selection-ask (划词追问) page — port of the Tauri `pages/SelectionAsk.tsx`. +//! +//! The guide is a plain section with three columns (01 / 02 / 03) followed by a +//! footer row, then the save-history card with its switch. The shortcut-settings +//! entry sits under the title on the right. + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n}; + +use super::icons::{self, IconName}; +use super::layout; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, SettingsSection}; + +const GAP: f32 = 16.0; +const CARD_PADDING: f32 = 20.0; +const COLUMN_GAP: f32 = 18.0; +const GUIDE_HEIGHT: f32 = 96.0; + +pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + if vm.selection_unsupported { + layout::unsupported_page(ui, lang, tr_l10n(lang, "selection_ask.title")); + return; + } + + let header = layout::page_header( + ui, + width, + tr_l10n(lang, "nav.selection_ask"), + tr_l10n(lang, "selection_ask.title"), + Some(tr_l10n(lang, "selection_ask.desc")), + ); + + // Saved toast, right aligned above the shortcut-settings entry. + if let Some(notice) = vm.settings_notice.clone() { + let toast = format!("✓ {notice}"); + let size = layout::pill_size(ui, &toast); + let rect = egui::Rect::from_min_size( + egui::pos2(header.right() - size.x, header.top() + 4.0), + size, + ); + layout::paint_pill(ui.painter(), rect, &toast, layout::PillTone::Blue); + } + + let settings_label = tr_l10n(lang, "selection_ask.shortcut_settings"); + let settings_width = layout::text_width(ui, settings_label, 12.5) + 60.0; + let settings_rect = egui::Rect::from_min_size( + egui::pos2(header.right() - settings_width, header.top() + 28.0), + egui::vec2(settings_width, 30.0), + ); + let settings_clicked = layout::action_button( + ui, + settings_rect, + settings_label, + Some(IconName::Settings), + layout::ButtonKind::Ghost, + ) + .clicked(); + icons::draw_icon( + ui, + egui::pos2(settings_rect.right() - 13.0, settings_rect.center().y), + IconName::ChevronRight, + theme::INK_3, + ); + if settings_clicked { + actions.push(FrontendAction::ToggleSettings); + actions.push(FrontendAction::SettingsSection(SettingsSection::Shortcuts)); + } + ui.add_space(GAP); + + guide(ui, width, vm); + ui.add_space(GAP); + save_history_card(ui, width, vm, actions); +} + +/// Three-column usage guide plus its footer row. +fn guide(ui: &mut egui::Ui, width: f32, vm: &FrontendViewModel) { + let lang = vm.lang; + layout::section_title(ui, width, tr_l10n(lang, "selection_ask.howto_title"), None); + ui.add_space(8.0); + + let open_desc = if vm.qa_hotkey.trim().is_empty() { + tr_l10n(lang, "selection_ask.guide_unset_desc").to_string() + } else { + fmt_l10n(lang, "selection_ask.guide_open_desc", &[&vm.qa_hotkey]) + }; + let ask_desc = fmt_l10n( + lang, + "selection_ask.guide_ask_desc", + &[&vm.dictation_hotkey], + ); + let steps = [ + (tr_l10n(lang, "selection_ask.guide_open_title"), open_desc), + ( + tr_l10n(lang, "selection_ask.guide_select_title"), + tr_l10n(lang, "selection_ask.howto_step2").to_string(), + ), + (tr_l10n(lang, "selection_ask.guide_ask_title"), ask_desc), + ]; + + let (row, _) = ui.allocate_exact_size(egui::vec2(width, GUIDE_HEIGHT), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(row); + let column_width = ((width - COLUMN_GAP * 2.0) / 3.0).max(80.0); + for (index, (title, desc)) in steps.iter().enumerate() { + let x = row.left() + index as f32 * (column_width + COLUMN_GAP); + painter.text( + egui::pos2(x, row.top()), + egui::Align2::LEFT_TOP, + format!("{:02}", index + 1), + egui::FontId::monospace(11.0), + theme::BLUE, + ); + painter.text( + egui::pos2(x + 22.0, row.top() - 1.0), + egui::Align2::LEFT_TOP, + title, + egui::FontId::proportional(13.0), + theme::INK, + ); + let galley = layout::text_galley( + ui, + desc, + theme::INK_3, + 11.5, + (column_width - 22.0).max(1.0), + 3, + ); + painter.galley(egui::pos2(x + 22.0, row.top() + 20.0), galley, theme::INK_3); + } + + ui.add_space(10.0); + let (footer, _) = ui.allocate_exact_size(egui::vec2(width, 24.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(footer); + icons::draw_icon( + ui, + egui::pos2(footer.left() + 7.0, footer.center().y), + IconName::Refresh, + theme::INK_4, + ); + painter.text( + egui::pos2(footer.left() + 22.0, footer.center().y), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "selection_ask.guide_followup"), + egui::FontId::proportional(11.5), + theme::INK_3, + ); + + // Dismissal hint on the right: an Esc key chip plus its description. + let dismiss = tr_l10n(lang, "selection_ask.guide_dismiss"); + let dismiss_width = layout::text_width(ui, dismiss, 11.5); + let dismiss_left = footer.right() - dismiss_width; + painter.text( + egui::pos2(dismiss_left, footer.center().y), + egui::Align2::LEFT_CENTER, + dismiss, + egui::FontId::proportional(11.5), + theme::INK_3, + ); + let chip_text = "Esc"; + let chip_width = layout::text_width(ui, chip_text, 10.5) + 14.0; + let chip = egui::Rect::from_min_size( + egui::pos2(dismiss_left - 8.0 - chip_width, footer.center().y - 9.0), + egui::vec2(chip_width, 18.0), + ); + painter.rect_filled( + chip.translate(egui::vec2(0.0, 2.0)), + egui::CornerRadius::same(5), + egui::Color32::from_black_alpha(14), + ); + painter.rect_filled(chip, egui::CornerRadius::same(5), theme::SURFACE_2); + painter.rect_stroke( + chip, + egui::CornerRadius::same(5), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + painter.text( + chip.center(), + egui::Align2::CENTER_CENTER, + chip_text, + egui::FontId::proportional(10.5), + theme::INK_3, + ); +} + +/// Save-history card: icon, title, description and the switch on the right. +fn save_history_card( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 92.0), egui::Sense::hover()); + layout::card(ui, rect, CARD_PADDING, |ui, inner| { + let painter = ui.painter().with_clip_rect(inner); + // Tauri `.ol-selection-ask-history-icon`:40×40 圆角方块 + 居中的 20px 图标。 + let icon_box = egui::Rect::from_min_size( + egui::pos2(inner.left(), inner.center().y - 20.0), + egui::vec2(40.0, 40.0), + ); + painter.rect_filled(icon_box, egui::CornerRadius::same(10), theme::SURFACE_2); + icons::draw_icon(ui, icon_box.center(), IconName::History, theme::INK_3); + // gap 14(`.ol-selection-ask-history` 的 `gap`)。 + let text_left = icon_box.right() + 14.0; + let toggle_rect = egui::Rect::from_min_size( + egui::pos2(inner.right() - 40.0, inner.center().y - 12.0), + egui::vec2(40.0, 24.0), + ); + let text_width = (toggle_rect.left() - 14.0 - text_left).max(40.0); + let title = layout::text_galley( + ui, + tr_l10n(lang, "selection_ask.history_title"), + theme::INK, + 13.0, + text_width, + 2, + ); + let desc = layout::text_galley( + ui, + tr_l10n(lang, "selection_ask.history_desc"), + theme::INK_3, + 12.0, + text_width, + 3, + ); + // 标题在上、说明在下,整块在卡片内垂直居中(`align-items: center`)。 + let title_height = title.size().y; + let block_height = title_height + 5.0 + desc.size().y; + let block_top = inner.center().y - block_height / 2.0; + painter.galley(egui::pos2(text_left, block_top), title, theme::INK); + painter.galley( + egui::pos2(text_left, block_top + title_height + 5.0), + desc, + theme::INK_3, + ); + if layout::toggle( + ui, + toggle_rect, + vm.qa_save_history, + "selection-ask-history-toggle", + ) + .clicked() + { + actions.push(FrontendAction::SelectionAskToggleHistory); + } + }); +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs new file mode 100644 index 000000000..fb07a1d54 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -0,0 +1,4087 @@ +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; + +use super::icons::{self, IconName}; +use super::layout; +use super::theme; +use super::view_model::{ + FrontendAction, FrontendViewModel, SettingsActionField, SettingsChannelProvider, + SettingsComboField, SettingsField, SettingsProviderAuth, SettingsProviderEditor, + SettingsProviderField, SettingsSection, SettingsTextField, ShortcutField, StylePack, +}; + +const RAIL_WIDTH: f32 = 214.0; +const SIDEBAR_RAIL_INPUT: f32 = 150.0; + +#[derive(Clone, Copy)] +enum SettingsIcon { + Settings, + Mic, + Sparkle, + Monitor, + Cloud, + Shield, + Bolt, + Info, + Help, + Document, + External, +} + +impl SettingsSection { + fn label(self, lang: Lang) -> &'static str { + // Keys are spelled out per arm so the i18n sync script can see them. + match self { + Self::General => tr_l10n(lang, "modal.sections.general"), + Self::Shortcuts => tr_l10n(lang, "modal.sections.shortcuts"), + Self::Appearance => tr_l10n(lang, "modal.sections.appearance"), + Self::Services => tr_l10n(lang, "modal.sections.services"), + Self::Privacy => tr_l10n(lang, "modal.sections.privacy"), + Self::Advanced => tr_l10n(lang, "modal.sections.advanced"), + Self::About => tr_l10n(lang, "modal.sections.about"), + } + } + + fn description(self, lang: Lang) -> &'static str { + // Keys are spelled out per arm so the i18n sync script can see them. + match self { + Self::General => tr_l10n(lang, "modal.descriptions.general"), + Self::Shortcuts => tr_l10n(lang, "modal.descriptions.shortcuts"), + Self::Services => tr_l10n(lang, "modal.descriptions.services"), + Self::Appearance => tr_l10n(lang, "modal.descriptions.appearance"), + Self::Privacy => tr_l10n(lang, "modal.descriptions.privacy"), + Self::Advanced => tr_l10n(lang, "modal.descriptions.advanced"), + Self::About => tr_l10n(lang, "modal.descriptions.about"), + } + } + + fn icon(self) -> SettingsIcon { + match self { + Self::General => SettingsIcon::Mic, + Self::Shortcuts => SettingsIcon::Bolt, + Self::Appearance => SettingsIcon::Settings, + Self::Services => SettingsIcon::Cloud, + Self::Privacy => SettingsIcon::Shield, + Self::Advanced => SettingsIcon::Sparkle, + Self::About => SettingsIcon::Info, + } + } +} + +/// Paint the in-window settings modal. Actions are pushed into the provided vec. +pub fn settings_overlay( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + actions: &mut Vec, + body: egui::Rect, +) { + // Mask the content area (not the sidebar/titlebar) and centre the card in + // it — the same backdrop the marketplace detail uses. + let size = egui::vec2( + (body.width() - 40.0).clamp(320.0, 960.0), + (body.height() - 40.0).clamp(280.0, 680.0), + ); + + // 遮罩、点击拦截与卡片必须是**同一个 Area**。egui 在 Area 被按下时会把它抬到同层 + // 最上面(egui-0.33.3/src/containers/area.rs:549 的 `move_to_top`),所以遮罩只要 + // 是独立 Area,点一下遮罩就会盖住卡片(用户报「点阴影后阴影上移、设置没法用」)。 + // 同一个图层里先画遮罩、再画卡片,遮罩压住卡片在结构上就不可能发生。 + let card_rect = egui::Rect::from_center_size(body.center(), size); + // 卡片实际落点写进 memory,供测试查询(Area 现在覆盖整个 body,面积已不等于卡片)。 + ctx.data_mut(|data| data.insert_temp(egui::Id::new("openless-settings-card-rect"), card_rect)); + egui::Area::new(egui::Id::new("openless-settings-modal")) + // 用 Foreground 而不是 Tooltip:egui 的 `ComboBox` 下拉/弹出菜单是 + // `Order::Foreground`(egui-0.33.3/src/containers/popup.rs:150),而同层里 + // 后创建的 Area 在上层、跨 Order 则是 Tooltip > Foreground。弹窗若占着 + // Tooltip,设置里所有「点开才出现」的下拉都会被弹窗整个盖住(用户报 + // 「需要点开的控件都打不开」)。 + .order(egui::Order::Foreground) + .fixed_pos(body.min) + .constrain(false) + .show(ctx, |ui| { + // 磨砂遮罩:先铺一层离屏模糊后的同一帧页面(macOS 版靠窗口级 vibrancy, + // Linux 没有系统合成层,只能自己模糊),再压上和 macOS 同一层的 + // `--ol-overlay-bg`(`rgba(15,17,22,0.32)`,即 `theme::OVERLAY`): + // 只模糊不压暗的话,设置卡片和背板分不开。 + // 拿不到背板时(无 wgpu 渲染状态的回退路径)只压暗色,卡片仍然可读。 + // 两者都用**内容区的圆角**:遮罩是以直角矩形铺上去的,底部两角会顶出窗口 + // 圆角之外,看起来就像遮罩和窗口对不上。 + let mask_corners = layout::body_corner_radius(ctx); + layout::paint_blurred_overlay(ctx, ui, body, mask_corners); + let _ = ui.allocate_rect(body, egui::Sense::click()); + // 卡片:同图层内后画 → 永远在遮罩之上。位置用**显式矩形**而不是 anchor: + // anchor 按上一帧面积(含阴影偏移)定位,卡片会稳定偏下 19.5px,且窗口 + // 缩放的首帧会跳一下(用户报「缩放窗口时并不是始终居中」)。 + ui.scope_builder(egui::UiBuilder::new().max_rect(card_rect), |ui| { + ui.set_clip_rect(body.intersect(ui.clip_rect())); + egui::Frame::new() + // Tauri `--ol-settings-content-bg`:整块弹窗是浅灰底,卡片才是白色。 + .fill(theme::CONTENT_BG) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + // 学 macOS 的 `--ol-shadow-xl`(`0 54px 65px -29px rgba(15,17,22,0.39)`): + // 大而柔的落影。CSS 的 `-29px` 收边不搬进来——epaint 的 `spread` 会加到 + // 圆角上,负值会把 14px 圆角压成直角,反而露出方形阴影边。 + .shadow(egui::Shadow { + offset: [0, 36], + blur: 56, + spread: 0, + color: egui::Color32::from_black_alpha(96), + }) + .show(ui, |ui| { + ui.set_min_size(size); + ui.set_max_size(size); + // Tauri SettingsModal:桌面端没有横跨两栏的标题栏——左侧栏顶端就是 + // 搜索框,右侧内容区顶端才是标题 + 自动保存提示 + 关闭按钮。 + let body_height = size.y.max(120.0); + ui.horizontal(|ui| { + ui.allocate_ui_with_layout( + egui::vec2(RAIL_WIDTH, body_height), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.set_min_height(body_height); + ui.set_max_height(body_height); + egui::ScrollArea::vertical() + .id_salt("openless-settings-rail") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_width(RAIL_WIDTH - 20.0); + rail(ui, vm, actions); + }); + }, + ); + ui.separator(); + // 两列之间 egui 还会插入 item_spacing,分隔线自身也占宽:不减掉它们 + // 内容就比 size 宽十几像素,卡片会被撑宽、`fixed_pos` 居中就偏(实测 11px)。 + let gutters = ui.spacing().item_spacing.x * 3.0; + ui.allocate_ui_with_layout( + egui::vec2((size.x - RAIL_WIDTH - gutters).max(0.0), body_height), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.set_min_height(body_height); + panel(ui, vm, actions); + }, + ); + }); + }); + }); + }); +} + +fn rail(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + // Tauri `.ol-settings-surface aside`: 214px 宽的独立底色条带(左侧跟随弹窗圆角)。 + egui::Frame::new() + .fill(theme::RAIL_BG) + .corner_radius(egui::CornerRadius { + nw: 14, + sw: 14, + ne: 0, + se: 0, + }) + .inner_margin(egui::Margin::symmetric(12, 16)) + .show(ui, |ui| { + // Section search, like the Tauri rail. + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 5)) + .show(ui, |ui| { + ui.set_width(SIDEBAR_RAIL_INPUT); + let width = ui.available_width().max(40.0); + ui.add_sized( + [width, 20.0], + egui::TextEdit::singleline(&mut vm.settings_query) + .id(egui::Id::new("openless-settings-search")) + .hint_text(tr_l10n(lang, "modal.search_placeholder")) + // 明确文字颜色:默认的控件前景色在浅底上过淡, + // 看上去像「输入了但没有显示字符」。 + .text_color(theme::INK) + .frame(egui::Frame::NONE) + .vertical_align(egui::Align::Center), + ); + }); + ui.add_space(10.0); + + let query = vm.settings_query.trim().to_lowercase(); + // Tauri's rail order. + for section in [ + SettingsSection::General, + SettingsSection::Shortcuts, + SettingsSection::Services, + SettingsSection::Appearance, + SettingsSection::Privacy, + SettingsSection::Advanced, + SettingsSection::About, + ] { + if !rail_section_visible(section, vm.hotkeys_supported) { + continue; + } + let label = section.label(lang); + if !query.is_empty() && !label.to_lowercase().contains(&query) { + continue; + } + let response = rail_item(ui, label, section.icon(), vm.settings_section == section); + if response.clicked() { + actions.push(FrontendAction::SettingsSection(section)); + } + } + ui.add_space(14.0); + ui.separator(); + ui.add_space(7.0); + for (label, icon) in [ + ( + tr_l10n(lang, "modal.sections.help_center"), + SettingsIcon::Help, + ), + ( + tr_l10n(lang, "modal.sections.release_notes"), + SettingsIcon::Document, + ), + ] { + let response = rail_item(ui, label, icon, false); + let row = response.rect; + draw_rail_icon( + ui, + egui::pos2(row.right() - 14.0, row.center().y), + SettingsIcon::External, + theme::INK_4, + ); + if response.clicked() { + actions.push(FrontendAction::SettingsAction( + SettingsActionField::OpenHelp, + )); + } + } + }); +} + +/// Tauri `visibleSettingsSections(supportsDesktopHotkey)`:没有桌面热键后端时 +/// 整个「快捷键」分区不出现(其余分区与平台无关)。 +fn rail_section_visible(section: SettingsSection, hotkeys_supported: bool) -> bool { + section != SettingsSection::Shortcuts || hotkeys_supported +} + +fn rail_item(ui: &mut egui::Ui, label: &str, icon: SettingsIcon, active: bool) -> egui::Response { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 34.0), egui::Sense::click()); + // Tauri:激活项是 --ol-blue-soft 底 + --ol-blue 字(滑动块),悬停是 + // --ol-nav-hover-bg + --ol-ink。 + if active { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::BLUE_SOFT); + } else if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::NAV_HOVER); + } + let color = if active { + theme::BLUE + } else if response.hovered() { + theme::INK + } else { + theme::INK_2 + }; + let icon_center = egui::pos2(rect.left() + 17.0, rect.center().y); + draw_rail_icon(ui, icon_center, icon, color); + ui.painter().text( + egui::pos2(rect.left() + 34.0, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(13.0), + color, + ); + response +} + +fn draw_rail_icon(ui: &egui::Ui, center: egui::Pos2, icon: SettingsIcon, color: egui::Color32) { + let icon = match icon { + SettingsIcon::Settings => IconName::Settings, + SettingsIcon::Mic => IconName::Mic, + SettingsIcon::Sparkle => IconName::Sparkle, + SettingsIcon::Monitor => IconName::Monitor, + SettingsIcon::Cloud => IconName::Cloud, + SettingsIcon::Shield => IconName::Shield, + SettingsIcon::Bolt => IconName::Bolt, + SettingsIcon::Info => IconName::Info, + SettingsIcon::Help => IconName::Help, + SettingsIcon::Document => IconName::Doc, + SettingsIcon::External => IconName::External, + }; + icons::draw_icon(ui, center, icon, color); +} + +fn panel(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(24, 16)) + .show(ui, |ui| { + { + // 控件(下拉/输入框/按钮)统一成 Tauri 的 SelectLite / inputStyle: + // 白底、0.5px --ol-line-strong 描边、r8、高 30。 + let style = ui.style_mut(); + style.visuals.menu_corner_radius = egui::CornerRadius::same(10); + style.visuals.extreme_bg_color = theme::SURFACE; + style.spacing.interact_size.y = 30.0; + style.spacing.button_padding = egui::vec2(9.0, 5.0); + for widget in [ + &mut style.visuals.widgets.inactive, + &mut style.visuals.widgets.hovered, + &mut style.visuals.widgets.active, + &mut style.visuals.widgets.open, + ] { + widget.corner_radius = egui::CornerRadius::same(8); + widget.bg_stroke = egui::Stroke::new(0.5, theme::LINE_STRONG); + widget.bg_fill = theme::SURFACE; + widget.weak_bg_fill = theme::SURFACE; + } + } + // 实验与扩展的下钻页把标题换成子页标题,并在左侧给出返回箭头 + // (Tauri 的 `activeAdvancedPage` 顶栏)。 + let detail = if vm.settings_section == SettingsSection::Advanced { + advanced_page_title(vm, lang) + } else { + None + }; + ui.horizontal(|ui| { + if let Some((title, _description)) = detail { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(26.0, 26.0), egui::Sense::click()); + if response.hovered() { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + theme::SURFACE_2, + ); + } + let center = rect.center(); + let stroke = egui::Stroke::new(1.4, theme::INK_2); + ui.painter().line_segment( + [ + egui::pos2(center.x + 3.0, center.y - 5.0), + egui::pos2(center.x - 2.5, center.y), + ], + stroke, + ); + ui.painter().line_segment( + [ + egui::pos2(center.x - 2.5, center.y), + egui::pos2(center.x + 3.0, center.y + 5.0), + ], + stroke, + ); + if response.clicked() { + vm.advanced_open = usize::MAX; + } + ui.label( + egui::RichText::new(title) + .size(20.0) + .strong() + .color(theme::INK), + ); + } else { + ui.label( + egui::RichText::new(vm.settings_section.label(lang)) + .size(21.0) + .strong() + .color(theme::INK), + ); + } + // Tauri:自动保存提示与关闭按钮属于**右栏自己的顶栏**(跟标题同一行, + // 不再横跨左栏);标题占据剩余宽度,二者靠右。 + let close = ui + .with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let close = close_button(ui); + ui.add_space(8.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "modal.auto_save_hint")) + .size(12.0) + .color(theme::INK_3), + ); + close + }) + .inner; + if close { + actions.push(FrontendAction::CloseSettings); + } + }); + ui.add_space(4.0); + ui.label( + egui::RichText::new(match detail { + Some((_, description)) => description, + None => vm.settings_section.description(lang), + }) + .size(13.0) + .color(theme::INK_3), + ); + save_state(ui, vm, actions); + if let Some(notice) = &vm.settings_notice { + ui.add_space(4.0); + ui.label(egui::RichText::new(notice).size(11.0).color(theme::BLUE)); + } + ui.add_space(8.0); + egui::ScrollArea::vertical() + .id_salt("openless-settings-content") + .auto_shrink([false, false]) + .show(ui, |ui| match vm.settings_section { + SettingsSection::General => general(ui, vm, actions), + SettingsSection::Shortcuts => shortcuts(ui, vm, actions), + SettingsSection::Appearance => appearance(ui, vm, actions), + SettingsSection::Services => services(ui, vm, actions), + SettingsSection::Privacy => privacy(ui, vm, actions), + SettingsSection::Advanced => advanced(ui, vm, actions), + SettingsSection::About => about(ui, vm, actions), + }); + }); +} + +pub(super) fn save_state( + ui: &mut egui::Ui, + vm: &FrontendViewModel, + actions: &mut Vec, +) { + if vm.settings_saving { + ui.label(tr_l10n(vm.lang, "common.saving")); + } + if let Some(error) = &vm.settings_save_error { + ui.colored_label(theme::ERR, tr_l10n(vm.lang, "style.pack.unsaved")); + ui.label(error); + if ui.button(tr_l10n(vm.lang, "common.retry")).clicked() { + actions.push(FrontendAction::SettingsAction( + SettingsActionField::RetrySave, + )); + } + } +} + +/// Title + description of the open 实验与扩展 sub-page (`None` on the list page). +fn advanced_page_title(vm: &FrontendViewModel, lang: Lang) -> Option<(&'static str, &'static str)> { + match vm.advanced_open { + 0 => Some(( + tr_l10n(lang, "settings.coding_agent.title"), + tr_l10n(lang, "modal.advanced_pages.less_computer"), + )), + 1 => Some(( + tr_l10n(lang, "settings.advanced.multimodal_pipeline_title"), + tr_l10n(lang, "modal.advanced_pages.multimodal"), + )), + 2 => Some(( + tr_l10n(lang, "settings.debug.title"), + tr_l10n(lang, "modal.advanced_pages.debug"), + )), + _ => None, + } +} + +fn general(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + + // 录音与输入(Tauri RecordingInputSection) + card( + ui, + tr_l10n(lang, "settings.recording.title"), + tr_l10n(lang, "settings.recording.desc"), + |ui| { + // #2:这一行在 Tauri 里就是 ShortcutRecorder(可展开录制/停用),不是只读 + // 文本——用户反馈「第一个录音快捷键那里不能展开改快捷键选项」。 + shortcut_row( + ui, + vm, + actions, + &ShortcutRow { + field: ShortcutField::Dictation, + label: tr_l10n(lang, "settings.recording.hotkey_label"), + value: vm.dictation_hotkey.clone(), + can_disable: false, + hint: recording_mode_hint(lang, vm.settings.recording_mode), + }, + ); + let modes = [ + tr_l10n(lang, "settings.recording.mode_toggle"), + tr_l10n(lang, "settings.recording.mode_hold"), + tr_l10n(lang, "settings.recording.mode_auto"), + ]; + segmented_row( + ui, + tr_l10n(lang, "settings.recording.mode_label"), + "", + &modes, + vm.settings.recording_mode.min(2), + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::RecordingMode, + val, + )); + }, + ); + // 上游 #1082(稳定模式:先录音后识别):录音期间不连接 ASR,停止后提交整段 + // 音频;结果更晚,但录音不受建连延迟与网络抖动影响。 + toggle_row( + ui, + tr_l10n(lang, "settings.recording.stable_transcription_label"), + tr_l10n(lang, "settings.recording.stable_transcription_desc"), + vm.settings.stable_transcription, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::StableTranscription, + )); + }, + ); + // 「静音后自动停止」只在切换式模式下可用(Tauri 同样只在该模式渲染)。 + if vm.settings.recording_mode == 0 { + toggle_row( + ui, + tr_l10n(lang, "settings.recording.silence_auto_stop_label"), + tr_l10n(lang, "settings.recording.silence_auto_stop_desc"), + vm.settings.silence_auto_stop, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::SilenceAutoStop, + )); + }, + ); + if vm.settings.silence_auto_stop { + let seconds: Vec = [1usize, 2, 3, 4, 5] + .iter() + .map(|value| { + fmt_l10n( + lang, + "settings.recording.silence_auto_stop_seconds_value", + &[value], + ) + }) + .collect(); + let refs: Vec<&str> = seconds.iter().map(String::as_str).collect(); + combo_index_row( + ui, + tr_l10n(lang, "settings.recording.silence_auto_stop_seconds_label"), + "", + vm.settings.silence_seconds.saturating_sub(1), + &refs, + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::SilenceSeconds, + val, + )); + }, + ); + } + } + let mut microphones: Vec = + vec![tr_l10n(lang, "settings.recording.microphone_system_default").to_string()]; + microphones.extend(vm.settings.microphone_options.iter().cloned()); + let microphone_index = microphones + .iter() + .position(|name| name == &vm.settings.microphone_name) + .unwrap_or(0); + let microphone_refs: Vec<&str> = microphones.iter().map(String::as_str).collect(); + combo_index_row( + ui, + tr_l10n(lang, "settings.recording.microphone_label"), + tr_l10n(lang, "settings.recording.microphone_desc"), + microphone_index, + µphone_refs, + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Microphone, + val, + )); + }, + ); + // #3:设备列表读失败或一台设备都没有时必须说明(Tauri + // `microphoneLoadError`),否则「只有系统默认」看起来像功能没做。 + if let Some(error) = vm.settings.microphone_error.clone() { + hint_line( + ui, + &fmt_l10n(lang, "settings.recording.microphone_load_error", &[&error]), + ); + } else if vm.settings.microphone_options.is_empty() { + hint_line(ui, tr_l10n(lang, "onboarding.mic_no_device_hint")); + } + // #4:录音胶囊开关与样式(Tauri `capsuleLabel` / `capsuleStyleLabel`)。 + toggle_row( + ui, + tr_l10n(lang, "settings.recording.capsule_label"), + tr_l10n(lang, "settings.recording.capsule_desc"), + vm.settings.show_capsule, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::ShowCapsule)); + }, + ); + let capsule_styles = [ + tr_l10n(lang, "settings.recording.capsule_style_siri"), + tr_l10n(lang, "settings.recording.capsule_style_classic"), + tr_l10n(lang, "settings.recording.capsule_style_typeless"), + ]; + combo_index_row( + ui, + tr_l10n(lang, "settings.recording.capsule_style_label"), + "", + vm.settings.capsule_style.min(2), + &capsule_styles, + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::CapsuleStyle, + val, + )); + }, + ); + capsule_style_preview(ui, vm.settings.capsule_style.min(2)); + toggle_row( + ui, + tr_l10n(lang, "settings.recording.mute_during_recording_label"), + tr_l10n(lang, "settings.recording.mute_during_recording_desc"), + vm.settings.mute_while_recording, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::MuteWhileRecording, + )); + }, + ); + // #5:提示音开关旁的「试听」按钮(Tauri `audioCuePreview`)。 + row_desc( + ui, + tr_l10n(lang, "settings.recording.audio_cue_label"), + tr_l10n(lang, "settings.recording.audio_cue_desc"), + |ui| { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(36.0, 20.0), egui::Sense::hover()); + if layout::toggle(ui, rect, vm.settings.audio_cue, "audio-cue").clicked() { + actions.push(FrontendAction::SettingsToggle(SettingsField::AudioCue)); + } + ui.add_space(10.0); + let preview = tr_l10n(lang, "settings.recording.audio_cue_preview"); + let width = layout::text_width(ui, preview, 12.0) + 24.0; + let (rect, _) = + ui.allocate_exact_size(egui::vec2(width, 28.0), egui::Sense::hover()); + if layout::action_button(ui, rect, preview, None, layout::ButtonKind::Ghost) + .clicked() + { + actions.push(FrontendAction::SettingsAction( + SettingsActionField::PreviewAudioCue, + )); + } + }, + ); + }, + ); + + // 插入与剪贴板(Tauri:可折叠分组,含流式输入) + card_group( + ui, + tr_l10n(lang, "settings.recording.insert_group_title"), + |ui| { + toggle_row( + ui, + tr_l10n(lang, "settings.recording.restore_clipboard_label"), + tr_l10n(lang, "settings.recording.restore_clipboard_desc"), + vm.settings.restore_clipboard, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::RestoreClipboard, + )); + }, + ); + toggle_row( + ui, + tr_l10n(lang, "settings.advanced.streaming_insert_label"), + tr_l10n(lang, "settings.advanced.streaming_insert_desc"), + vm.settings.streaming_insert, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::StreamingInsert, + )); + }, + ); + toggle_row( + ui, + tr_l10n( + lang, + "settings.advanced.streaming_insert_save_clipboard_label", + ), + "", + vm.settings.streaming_save_clipboard, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::StreamingSaveClipboard, + )); + }, + ); + }, + ); + + // 启动(Tauri:可折叠分组) + card_group( + ui, + tr_l10n(lang, "settings.recording.startup_group_title"), + |ui| { + toggle_row( + ui, + tr_l10n(lang, "settings.recording.start_minimized_label"), + "", + vm.settings.start_minimized, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::StartMinimized, + )); + }, + ); + toggle_row( + ui, + tr_l10n(lang, "settings.recording.startup_at_boot"), + "", + vm.settings.launch_at_login, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::LaunchAtLogin)); + }, + ); + toggle_row( + ui, + tr_l10n(lang, "settings.recording.auto_update_check_label"), + "", + vm.settings.auto_update, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::AutoUpdate)); + }, + ); + }, + ); + + // 远程输入(Tauri RemoteInputSection) + card_group(ui, tr_l10n(lang, "settings.remote_input.title"), |ui| { + hint_line(ui, tr_l10n(lang, "settings.remote_input.security_hint")); + toggle_row( + ui, + tr_l10n(lang, "settings.remote_input.enable_label"), + tr_l10n(lang, "settings.remote_input.enable_desc"), + vm.settings.remote_input, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::RemoteInput)); + }, + ); + let port = vm.settings.remote_port.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.remote_input.port_label"), + "", + &mut vm.settings.remote_port, + "8765", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::RemotePort, + port, + )); + }, + ); + remote_mode_row(ui, lang, vm.settings.remote_default_mode, |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::RemoteDefaultMode, + val, + )); + }); + // 连接细节(配对码 / 网址 / 证书指纹)只在服务真的在监听、且地址未过期时 + // 展示:过期地址可能指向别的主机,展示它等于诱导用户在错误地址上配对。 + let cert_state = remote_cert_fingerprint_state( + vm.remote_running, + vm.remote_urls_stale, + vm.remote_cert_fingerprint.as_deref(), + ); + if vm.remote_running && !vm.remote_urls_stale { + if !vm.remote_pin.is_empty() { + text_row( + ui, + tr_l10n(lang, "settings.remote_input.pin_label"), + "", + &vm.remote_pin, + ); + } + if !vm.remote_urls.is_empty() { + text_row( + ui, + tr_l10n(lang, "settings.remote_input.url_label"), + tr_l10n(lang, "settings.remote_input.security_hint"), + &vm.remote_urls.join(" · "), + ); + } + if matches!(cert_state, RemoteCertFingerprintState::Available) { + action_row( + ui, + tr_l10n(lang, "settings.remote_input.cert_fingerprint_label"), + tr_l10n(lang, "settings.remote_input.cert_verify_hint"), + tr_l10n(lang, "settings.remote_input.cert_fingerprint_copy"), + SettingsActionField::CopyCertFingerprint, + actions, + ); + ui.label( + egui::RichText::new(vm.remote_cert_fingerprint.clone().unwrap_or_default()) + .size(10.5) + .color(theme::INK_4), + ); + } else { + // 拿不到可核验的完整指纹时必须显式告警:静默省略会让人以为 + // 「不用核对证书也能连」。 + ui.colored_label( + theme::WARN, + tr_l10n(lang, "settings.remote_input.cert_fingerprint_unavailable"), + ); + } + } + }); +} + +/// 证书指纹的展示决定(对齐 Tauri `RemoteInputSection`):服务未监听或地址已过期 +/// 时整块连接细节都不展示;服务在监听但没有可核验的完整指纹时必须显式告警。 +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum RemoteCertFingerprintState { + /// 没有可展示的连接细节。 + Hidden, + /// 在监听但拿不到完整指纹 → 必须显式告警。 + Unavailable, + /// 有完整指纹 → 展示并可复制。 + Available, +} + +fn remote_cert_fingerprint_state( + running: bool, + urls_stale: bool, + fingerprint: Option<&str>, +) -> RemoteCertFingerprintState { + if !running || urls_stale { + return RemoteCertFingerprintState::Hidden; + } + match fingerprint { + Some(value) if is_complete_sha256(value) => RemoteCertFingerprintState::Available, + _ => RemoteCertFingerprintState::Unavailable, + } +} + +/// 完整 SHA-256 指纹 = 64 个十六进制字符;截断/非十六进制的值不能当作可核验指纹。 +fn is_complete_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn shortcuts(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + // Tauri `ShortcutsSection` 的行序:开始/停止 → 翻译 → 弹出浮窗 → 切换风格 → + // 风格直达快捷键(子块)→ 打开 OpenLess → Less Computer → 取消本次录音。 + let dictation_hint = recording_mode_hint(lang, vm.settings.recording_mode); + let rows: [ShortcutRow; 7] = [ + ShortcutRow { + field: ShortcutField::Dictation, + label: tr_l10n(lang, "settings.shortcuts.start_stop"), + value: vm.dictation_hotkey.clone(), + can_disable: false, + hint: dictation_hint, + }, + ShortcutRow { + field: ShortcutField::Translation, + label: tr_l10n(lang, "hotkey.translation"), + value: vm.translation_hotkey.clone(), + can_disable: false, + hint: String::new(), + }, + ShortcutRow { + field: ShortcutField::Qa, + label: tr_l10n(lang, "selection_ask.hotkey_title"), + value: vm.qa_hotkey.clone(), + can_disable: true, + hint: String::new(), + }, + ShortcutRow { + field: ShortcutField::QuickNote, + label: tr_l10n(lang, "quickNote.shortcutTitle"), + value: vm.quick_note_hotkey.clone(), + can_disable: true, + hint: tr_l10n(lang, "quickNote.shortcutDesc").to_string(), + }, + ShortcutRow { + field: ShortcutField::SwitchStyle, + label: tr_l10n(lang, "settings.shortcuts.switch_style"), + value: vm.switch_style_hotkey.clone(), + can_disable: true, + hint: String::new(), + }, + ShortcutRow { + field: ShortcutField::OpenApp, + label: tr_l10n(lang, "settings.shortcuts.open_app"), + value: vm.open_app_hotkey.clone(), + can_disable: true, + hint: String::new(), + }, + ShortcutRow { + field: ShortcutField::CodingAgentVoice, + label: tr_l10n(lang, "settings.shortcuts.agent_voice"), + value: vm.coding_agent_hotkey.clone(), + can_disable: true, + hint: tr_l10n(lang, "settings.coding_agent.voice_hotkey_desc").to_string(), + }, + ]; + card( + ui, + tr_l10n(lang, "settings.shortcuts.title"), + tr_l10n(lang, "settings.shortcuts.desc_no_acc"), + |ui| { + for row in &rows[..5] { + shortcut_row(ui, vm, actions, row); + } + // 风格直达快捷键:Tauri 把它放在「切换到上一个风格」之后、打开 App 之前。 + style_pack_hotkey_block(ui, vm, actions); + for row in &rows[5..] { + shortcut_row(ui, vm, actions, row); + } + // 取消本次录音:Tauri 只展示 Esc,不可编辑(Windows/Linux 胶囊无确认键)。 + readonly_keycap_row(ui, tr_l10n(lang, "settings.shortcuts.cancel"), "", "Esc"); + }, + ); + // 选区工作区:划词润色快捷键(可录制/停用)+ 交付方式。 + card( + ui, + tr_l10n(lang, "settings.selection_workspace.title"), + tr_l10n(lang, "settings.selection_workspace.hint"), + |ui| { + shortcut_row( + ui, + vm, + actions, + &ShortcutRow { + field: ShortcutField::SelectionPolish, + label: tr_l10n(lang, "settings.selection_workspace.polish_hotkey"), + value: vm.selection_polish_hotkey.clone(), + can_disable: true, + hint: tr_l10n(lang, "settings.selection_workspace.polish_hotkey_desc") + .to_string(), + }, + ); + segmented_row( + ui, + tr_l10n(lang, "settings.selection_workspace.polish_delivery"), + "", + &[ + tr_l10n(lang, "settings.selection_polish.direct_replace"), + tr_l10n(lang, "settings.selection_polish.preview_confirm"), + ], + vm.settings.selection_polish_delivery.min(1), + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::SelectionPolishDelivery, + val, + )); + }, + ); + }, + ); +} + +/// 一行可编辑快捷键的展示数据。 +pub(super) struct ShortcutRow { + field: ShortcutField, + label: &'static str, + /// 已格式化的键帽文本(`Ctrl+Shift+;`),空串 = 未设置。 + value: String, + /// 核心热键(录音)不可停用,Tauri 用 `comboDisableHint` 说明原因。 + can_disable: bool, + /// 行下方的补充说明(录音行显示当前录音方式后缀)。 + hint: String, +} + +impl ShortcutRow { + /// 供设置页之外的页面(速记页的快捷键卡片)构造同一套控件。 + pub(super) fn new( + field: ShortcutField, + label: &'static str, + value: String, + can_disable: bool, + hint: String, + ) -> Self { + Self { + field, + label, + value, + can_disable, + hint, + } + } +} + +/// 快捷键行:标签(+「?」)→ 键帽 → 录制控件行最右的 chevron;点 chevron 展开 +/// 「录制快捷键 / 停用」菜单,进入录制后键帽位置换成「请按下快捷键组合…」面板。 +pub(super) fn shortcut_row( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, + row: &ShortcutRow, +) { + row_desc(ui, row.label, "", |ui| { + shortcut_control(ui, vm, actions, row) + }); + shortcut_menu(ui, vm, actions, row); +} + +/// 键帽在行首、chevron 在行尾(录制中则换成录制面板)。单独抽出供速记页 +/// 的快捷键卡片复用,那一页没有 200px 标签列。 +pub(super) fn shortcut_control( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, + row: &ShortcutRow, +) { + let recording = vm.shortcut_recording == Some(row.field); + let menu_open = vm.shortcut_menu == Some(row.field); + if recording { + recording_panel(ui, vm, actions, row.field); + return; + } + // Tauri `ShortcutRecorder`: 录制控件宽度上限 360px,值靠左,展开符贴**同一行** + // 的最右缘。过去先画箭头再画键帽,速记卡片里箭头挤在键帽左侧。 + let width = ui.available_width().clamp(26.0, 360.0); + let (line, _) = ui.allocate_exact_size(egui::vec2(width, 26.0), egui::Sense::hover()); + let arrow = egui::Rect::from_min_size( + egui::pos2(line.right() - 26.0, line.top()), + egui::vec2(26.0, 26.0), + ); + #[cfg(test)] + ui.ctx() + .data_mut(|data| data.insert_temp(egui::Id::new("shortcut-control-rects"), (line, arrow))); + let response = ui.interact( + arrow, + ui.id().with(("shortcut-menu", row.field)), + egui::Sense::click(), + ); + if response.hovered() { + ui.painter() + .rect_filled(arrow, egui::CornerRadius::same(6), theme::SURFACE_2); + } + draw_chevron_down(ui, arrow.center(), menu_open, theme::INK_4); + if response.clicked() { + actions.push(FrontendAction::ShortcutMenu(if menu_open { + None + } else { + Some(row.field) + })); + } + let mut x = line.left(); + for part in row + .value + .split('+') + .map(str::trim) + .filter(|part| !part.is_empty()) + { + let key_width = layout::text_width(ui, part, 11.0) + 16.0; + if x + key_width > arrow.left() - 6.0 { + break; + } + let key = egui::Rect::from_min_size( + egui::pos2(x, line.center().y - 11.0), + egui::vec2(key_width, 22.0), + ); + ui.painter() + .rect_filled(key, egui::CornerRadius::same(6), theme::SURFACE_2); + ui.painter().rect_stroke( + key, + egui::CornerRadius::same(6), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + ui.painter().text( + key.center(), + egui::Align2::CENTER_CENTER, + part, + egui::FontId::proportional(11.0), + theme::INK_2, + ); + x += key_width + 4.0; + } +} + +/// 行下方的补充说明 + 展开的「录制快捷键 / 停用」菜单。 +pub(super) fn shortcut_menu( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, + row: &ShortcutRow, +) { + let lang = vm.lang; + let recording = vm.shortcut_recording == Some(row.field); + let menu_open = vm.shortcut_menu == Some(row.field); + if !row.hint.is_empty() { + ui.label( + egui::RichText::new(row.hint.as_str()) + .size(11.0) + .color(theme::INK_4), + ); + } + if menu_open && !recording { + // Tauri 的展开菜单紧贴键帽**下方左侧**,录制按钮是浅蓝底/蓝边/蓝字, + // 而不是整行靠右的蓝底白字主按钮。 + ui.horizontal(|ui| { + ui.set_min_height(36.0); + let record = tr_l10n(lang, "settings.recording.combo_record_btn"); + let width = layout::text_width(ui, record, 12.0) + 24.0; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 28.0), egui::Sense::hover()); + #[cfg(test)] + ui.ctx() + .data_mut(|data| data.insert_temp(egui::Id::new("shortcut-record-button"), rect)); + if layout::action_button(ui, rect, record, None, layout::ButtonKind::BlueSoft).clicked() + { + actions.push(FrontendAction::ShortcutRecording(Some(row.field))); + } + ui.add_space(6.0); + let disable = tr_l10n(lang, "settings.shortcuts.disable"); + let (rect, _) = ui.allocate_exact_size(egui::vec2(58.0, 28.0), egui::Sense::hover()); + let kind = if row.can_disable { + layout::ButtonKind::Ghost + } else { + layout::ButtonKind::Disabled + }; + if layout::action_button(ui, rect, disable, None, kind).clicked() && row.can_disable { + actions.push(FrontendAction::ShortcutDisable(row.field)); + } + }); + if row.field == ShortcutField::Dictation { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.recording.combo_disable_hint")) + .size(10.5) + .color(theme::INK_4), + ); + } + ui.add_space(4.0); + } +} + +/// 「请按下快捷键组合…」面板:读本帧输入,Escape 取消、其它键即完成录入。 +fn recording_panel( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, + field: ShortcutField, +) { + let lang = vm.lang; + let (rect, _) = ui.allocate_exact_size(egui::vec2(240.0, 44.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::BLUE_SOFT); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(1.0, egui::Color32::from_rgba_unmultiplied(37, 99, 235, 60)), + egui::StrokeKind::Inside, + ); + ui.painter().text( + egui::pos2(rect.left() + 12.0, rect.center().y - 7.0), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "settings.recording.combo_record_hint"), + egui::FontId::proportional(12.0), + theme::BLUE, + ); + ui.painter().text( + egui::pos2(rect.left() + 12.0, rect.center().y + 9.0), + egui::Align2::LEFT_CENTER, + format!("Esc · {}", tr_l10n(lang, "common.cancel")), + egui::FontId::proportional(10.5), + theme::INK_4, + ); + if ui.input(|input| input.key_pressed(egui::Key::Escape)) { + actions.push(FrontendAction::ShortcutRecording(None)); + return; + } + if let Some((primary, modifiers)) = + captured_binding(ui.ctx(), &mut vm.shortcut_pending_modifier) + { + actions.push(FrontendAction::ShortcutCaptured(field, primary, modifiers)); + } +} + +/// 读本帧按下的第一个「真键」+ 当时按住的修饰键,转成 Core 的 +/// `ShortcutBinding` 形式(primary + modifiers)。 +/// +/// 修饰键自身在 egui 里没有 Key 事件(`Key` 枚举只有 `Colon`/`Semicolon` 这类 +/// 具体键,修饰键只在 `Modifiers` 里),所以「按住某个修饰键当热键」只能跨帧判断: +/// 按住期间没有按下任何真键 → 松开时记为修饰键触发。`pending` 就是这份挂起状态。 +/// egui 分不清左右修饰键,因此统一记左侧名(Core 的 legacy trigger 表接受 +/// LeftControl/LeftShift/LeftAlt/LeftSuper)。 +/// +/// 窗口进程的本地热键匹配(`crate::local_hotkeys`)复用同一个函数:那里没有 +/// `Ui`(帧由别处的面板渲染),所以这里取 `Context`。 +pub(crate) fn captured_binding( + ctx: &egui::Context, + pending: &mut Option, +) -> Option<(String, Vec)> { + // 用按键事件自带的修饰键(RawInput.modifiers 在某些输入法/后端下会滞后), + // 并跳过 egui 合成的剪贴板命令与 Escape(后者由调用方当取消处理)。 + if let Some((key, modifiers)) = ctx.input(|input| { + input.events.iter().find_map(|event| match event { + egui::Event::Key { + key, + pressed: true, + repeat: false, + modifiers, + .. + } if !matches!( + key, + egui::Key::Escape | egui::Key::Copy | egui::Key::Cut | egui::Key::Paste + ) => + { + Some((*key, *modifiers)) + } + _ => None, + }) + }) { + // 按下真键 = 组合键,之前挂起的修饰键作废。 + *pending = None; + let primary = shortcut_primary(key)?; + return Some((primary, modifier_tags(modifiers))); + } + + let modifiers = ctx.input(|input| input.modifiers); + match bare_modifier_name(modifiers) { + Some(name) => { + if pending.is_none() { + *pending = Some(name.to_string()); + } + None + } + None if modifiers.any() => { + // 多个修饰键同按:不当作修饰键热键(松手也不触发)。 + *pending = None; + None + } + None => pending.take().map(|name| (name, Vec::new())), + } +} + +pub(crate) fn modifier_tags(modifiers: egui::Modifiers) -> Vec { + let mut tags: Vec = Vec::new(); + if modifiers.ctrl || modifiers.command { + tags.push("ctrl".to_string()); + } + if modifiers.alt { + tags.push("alt".to_string()); + } + if modifiers.shift { + tags.push("shift".to_string()); + } + if modifiers.mac_cmd { + tags.push("super".to_string()); + } + tags +} + +/// 恰好按住「一个类别」的修饰键时返回它的 Core 主键名,否则 `None`。 +/// 顺序 ctrl → alt → shift → super:egui 在 Linux 上把 Ctrl 同时标成 +/// `command`,所以先判 ctrl。 +pub(crate) fn bare_modifier_name(modifiers: egui::Modifiers) -> Option<&'static str> { + let categories = [ + modifiers.ctrl || modifiers.command, + modifiers.alt, + modifiers.shift, + modifiers.mac_cmd, + ]; + if categories.iter().filter(|held| **held).count() != 1 { + return None; + } + if categories[0] { + Some("LeftControl") + } else if categories[1] { + Some("LeftAlt") + } else if categories[2] { + Some("LeftShift") + } else { + Some("LeftSuper") + } +} + +/// egui 的物理键 → Core 认可的主键名(见 `shortcut_types::validate_primary`)。 +fn shortcut_primary(key: egui::Key) -> Option { + use egui::Key; + let name = match key { + Key::Num0 => "0".to_string(), + Key::Num1 => "1".to_string(), + Key::Num2 => "2".to_string(), + Key::Num3 => "3".to_string(), + Key::Num4 => "4".to_string(), + Key::Num5 => "5".to_string(), + Key::Num6 => "6".to_string(), + Key::Num7 => "7".to_string(), + Key::Num8 => "8".to_string(), + Key::Num9 => "9".to_string(), + Key::A => "A".to_string(), + Key::B => "B".to_string(), + Key::C => "C".to_string(), + Key::D => "D".to_string(), + Key::E => "E".to_string(), + Key::F => "F".to_string(), + Key::G => "G".to_string(), + Key::H => "H".to_string(), + Key::I => "I".to_string(), + Key::J => "J".to_string(), + Key::K => "K".to_string(), + Key::L => "L".to_string(), + Key::M => "M".to_string(), + Key::N => "N".to_string(), + Key::O => "O".to_string(), + Key::P => "P".to_string(), + Key::Q => "Q".to_string(), + Key::R => "R".to_string(), + Key::S => "S".to_string(), + Key::T => "T".to_string(), + Key::U => "U".to_string(), + Key::V => "V".to_string(), + Key::W => "W".to_string(), + Key::X => "X".to_string(), + Key::Y => "Y".to_string(), + Key::Z => "Z".to_string(), + Key::F1 => "F1".to_string(), + Key::F2 => "F2".to_string(), + Key::F3 => "F3".to_string(), + Key::F4 => "F4".to_string(), + Key::F5 => "F5".to_string(), + Key::F6 => "F6".to_string(), + Key::F7 => "F7".to_string(), + Key::F8 => "F8".to_string(), + Key::F9 => "F9".to_string(), + Key::F10 => "F10".to_string(), + Key::F11 => "F11".to_string(), + Key::F12 => "F12".to_string(), + Key::F13 => "F13".to_string(), + Key::F14 => "F14".to_string(), + Key::F15 => "F15".to_string(), + Key::F16 => "F16".to_string(), + Key::F17 => "F17".to_string(), + Key::F18 => "F18".to_string(), + Key::F19 => "F19".to_string(), + Key::F20 => "F20".to_string(), + Key::ArrowUp => "ArrowUp".to_string(), + Key::ArrowDown => "ArrowDown".to_string(), + Key::ArrowLeft => "ArrowLeft".to_string(), + Key::ArrowRight => "ArrowRight".to_string(), + Key::Space => "Space".to_string(), + Key::Enter => "Enter".to_string(), + Key::Tab => "Tab".to_string(), + Key::Backspace => "Backspace".to_string(), + Key::Delete => "Delete".to_string(), + Key::Home => "Home".to_string(), + Key::End => "End".to_string(), + Key::PageUp => "PageUp".to_string(), + Key::PageDown => "PageDown".to_string(), + Key::Semicolon => ";".to_string(), + Key::Comma => ",".to_string(), + Key::Period => ".".to_string(), + Key::Slash => "/".to_string(), + Key::Backslash => "\\".to_string(), + Key::Minus => "-".to_string(), + Key::Equals => "=".to_string(), + Key::Plus => "+".to_string(), + Key::Quote => "'".to_string(), + Key::Backtick => "`".to_string(), + Key::OpenBracket => "[".to_string(), + Key::CloseBracket => "]".to_string(), + Key::Colon => ":".to_string(), + Key::Pipe => "|".to_string(), + Key::Questionmark => "?".to_string(), + Key::Exclamationmark => "!".to_string(), + _ => return None, + }; + Some(name) +} + +/// 只读键帽行(Tauri 的 `readonlyRows`:取消本次录音 = Esc)。 +fn readonly_keycap_row(ui: &mut egui::Ui, label: &str, desc: &str, combo: &str) { + row_desc(ui, label, desc, |ui| { + keycaps_in(ui, combo); + }); +} + +/// 把 `Ctrl+Shift+;` 这样的标签画成一枚枚键帽,供 right_to_left 布局使用 +/// (因此倒序绘制)。 +fn keycaps_in(ui: &mut egui::Ui, combo: &str) { + let parts: Vec<&str> = combo + .split('+') + .map(str::trim) + .filter(|part| !part.is_empty()) + .collect(); + for part in parts.iter().rev() { + let width = layout::text_width(ui, part, 11.0) + 16.0; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 22.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(6), theme::SURFACE_2); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(6), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + *part, + egui::FontId::proportional(11.0), + theme::INK_2, + ); + } +} + +/// 行右侧的 chevron(展开/收起快捷键菜单)。 +fn draw_chevron_down(ui: &egui::Ui, center: egui::Pos2, open: bool, color: egui::Color32) { + let stroke = egui::Stroke::new(1.4, color); + let dy = if open { -1.6 } else { 1.6 }; + ui.painter().line_segment( + [ + egui::pos2(center.x - 4.0, center.y - dy), + egui::pos2(center.x, center.y + dy), + ], + stroke, + ); + ui.painter().line_segment( + [ + egui::pos2(center.x, center.y + dy), + egui::pos2(center.x + 4.0, center.y - dy), + ], + stroke, + ); +} +/// 「风格直达快捷键」子块:小标题 + 说明 + 每行(风格选择器 + 键帽 + chevron)+ +/// 「+ 添加风格快捷键」。整块放在「快捷键设置」卡片内部(Tauri 的位置)。 +fn style_pack_hotkey_block( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + ui.add_space(10.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.shortcuts.style_pack_title")) + .size(13.0) + .strong() + .color(theme::INK), + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.shortcuts.style_pack_desc")) + .size(11.0) + .color(theme::INK_4), + ); + ui.add_space(6.0); + + let rows = vm.settings.style_pack_hotkeys.clone(); + let packs = vm.style_packs.clone(); + for (index, row) in rows.iter().enumerate() { + let recording = vm.shortcut_recording == Some(ShortcutField::StylePack(index)); + let menu_open = vm.shortcut_menu == Some(ShortcutField::StylePack(index)); + ui.horizontal(|ui| { + ui.set_min_height(40.0); + style_pack_picker( + ui, + &packs, + &row.pack_id, + &row.name, + Some(index), + actions, + lang, + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if recording { + recording_panel(ui, vm, actions, ShortcutField::StylePack(index)); + return; + } + // chevron → 录制 / 移除 + let (rect, _) = + ui.allocate_exact_size(egui::vec2(26.0, 26.0), egui::Sense::click()); + let response = ui.interact( + rect, + ui.id().with(("style-hotkey-menu", index)), + egui::Sense::click(), + ); + if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(6), theme::SURFACE_2); + } + draw_chevron_down(ui, rect.center(), menu_open, theme::INK_4); + if response.clicked() { + actions.push(FrontendAction::ShortcutMenu(if menu_open { + None + } else { + Some(ShortcutField::StylePack(index)) + })); + } + ui.add_space(4.0); + keycaps_in(ui, &row.hotkey); + }); + }); + separator_line(ui); + if menu_open && !recording { + ui.horizontal(|ui| { + ui.set_min_height(34.0); + let record = tr_l10n(lang, "settings.recording.combo_record_btn"); + let width = layout::text_width(ui, record, 12.0) + 24.0; + let (rect, _) = + ui.allocate_exact_size(egui::vec2(width, 28.0), egui::Sense::hover()); + if layout::action_button(ui, rect, record, None, layout::ButtonKind::BlueSoft) + .clicked() + { + actions.push(FrontendAction::ShortcutRecording(Some( + ShortcutField::StylePack(index), + ))); + } + ui.add_space(6.0); + let remove = tr_l10n(lang, "settings.shortcuts.style_pack_remove"); + let (rect, _) = + ui.allocate_exact_size(egui::vec2(58.0, 28.0), egui::Sense::hover()); + if layout::action_button(ui, rect, remove, None, layout::ButtonKind::Ghost) + .clicked() + { + actions.push(FrontendAction::StyleHotkeyRemove(index)); + } + }); + } + } + + // 草稿行:先选风格包、再录快捷键(Tauri 的 draft 行)。 + if vm.style_hotkey_draft_open { + let draft_id = packs + .get(vm.style_hotkey_draft_pack) + .map(|pack| pack.id.clone()) + .unwrap_or_default(); + let draft_recording = vm.shortcut_recording == Some(ShortcutField::StyleDraft); + ui.horizontal(|ui| { + ui.set_min_height(40.0); + style_pack_picker(ui, &packs, &draft_id, "", None, actions, lang); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(22.0, 22.0), egui::Sense::click()); + let cancel_response = ui.interact( + rect, + ui.id().with("style-hotkey-draft-cancel"), + egui::Sense::click(), + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "✕", + egui::FontId::proportional(12.0), + if cancel_response.hovered() { + theme::ERR + } else { + theme::INK_4 + }, + ); + if cancel_response.clicked() { + actions.push(FrontendAction::StyleHotkeyDraft(false)); + } + ui.add_space(6.0); + if draft_recording { + recording_panel(ui, vm, actions, ShortcutField::StyleDraft); + } else { + let record = tr_l10n(lang, "settings.recording.combo_record_btn"); + let width = layout::text_width(ui, record, 12.0) + 24.0; + let (rect, _) = + ui.allocate_exact_size(egui::vec2(width, 28.0), egui::Sense::hover()); + if layout::action_button(ui, rect, record, None, layout::ButtonKind::BlueSoft) + .clicked() + { + actions.push(FrontendAction::ShortcutRecording(Some( + ShortcutField::StyleDraft, + ))); + } + } + }); + }); + separator_line(ui); + } else { + // 「+ 添加风格快捷键」虚线按钮(Tauri 的 stylePackAdd)。 + let label = format!("+ {}", tr_l10n(lang, "settings.shortcuts.style_pack_add")); + let width = layout::text_width(ui, &label, 12.0) + 24.0; + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 28.0), egui::Sense::hover()); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(6), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + &label, + egui::FontId::proportional(12.0), + theme::INK_3, + ); + if ui + .interact(rect, ui.id().with("style-hotkey-add"), egui::Sense::click()) + .clicked() + { + actions.push(FrontendAction::StyleHotkeyDraft(true)); + } + } + ui.add_space(4.0); +} + +/// 选择器变更后要发的动作。 +/// +/// `row` 是已有风格包行的下标;草稿行(`None`)只能改「待新建的包」,绝不能写成 +/// `StyleHotkeyRepack`——那会把**别的**已有行的包换掉(曾经的真 bug:草稿行选包 +/// 会重绑第一行的风格包,而 `StyleHotkeyDraftPack` 从未被构造)。 +fn style_pack_pick_action(row: Option, next: usize) -> FrontendAction { + match row { + Some(index) => FrontendAction::StyleHotkeyRepack(index, next), + None => FrontendAction::StyleHotkeyDraftPack(next), + } +} + +/// 风格包选择器(Tauri 的 `SelectLite`):显示名 +「(已停用)」后缀,整表替换。 +#[allow(clippy::too_many_arguments)] +fn style_pack_picker( + ui: &mut egui::Ui, + packs: &[StylePack], + current_pack_id: &str, + fallback_name: &str, + row: Option, + actions: &mut Vec, + lang: Lang, +) { + let options: Vec = packs + .iter() + .map(|pack| { + if pack.enabled { + pack.name.clone() + } else { + format!( + "{}{}", + pack.name, + tr_l10n(lang, "settings.shortcuts.style_pack_disabled_suffix") + ) + } + }) + .collect(); + let selected = packs + .iter() + .position(|pack| pack.id == current_pack_id) + .unwrap_or(0); + let mut next = selected; + // 草稿行的选择器也要有稳定且互不冲突的 id。 + let picker_salt = match row { + Some(index) => format!("style-pack-hotkey-{index}"), + None => "style-pack-hotkey-draft".to_string(), + }; + egui::ComboBox::from_id_salt(picker_salt) + .width(170.0) + .selected_text( + options + .get(selected) + .cloned() + .unwrap_or_else(|| fallback_name.to_string()), + ) + .show_ui(ui, |ui| { + for (option_index, option) in options.iter().enumerate() { + if ui + .selectable_label(option_index == selected, option) + .clicked() + { + next = option_index; + ui.close(); + } + } + }); + if next != selected { + actions.push(style_pack_pick_action(row, next)); + } +} + +/// 设置行下方的细线(与 `row_desc` 同款)。 +/// 录音方式在行下方的补充说明(与快捷键分区用同一组 `hotkey.mode*_suffix`)。 +fn recording_mode_hint(lang: Lang, mode: usize) -> String { + match mode { + 1 => tr_l10n(lang, "hotkey.mode_hold_suffix"), + 2 => tr_l10n(lang, "hotkey.mode_auto_suffix"), + _ => tr_l10n(lang, "hotkey.mode_toggle_suffix"), + } + .to_string() +} + +/// 行下方的小字提示(可见文字,不靠悬停)。空串不占位。 +fn hint_line(ui: &mut egui::Ui, text: &str) { + if text.is_empty() { + return; + } + ui.label(egui::RichText::new(text).size(11.0).color(theme::INK_4)); +} + +fn separator_line(ui: &mut egui::Ui) { + let rect = ui + .allocate_exact_size(egui::vec2(ui.available_width(), 1.0), egui::Sense::hover()) + .0; + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, theme::LINE_SOFT), + ); +} + +fn appearance(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + card(ui, tr_l10n(lang, "settings.theme.title"), "", |ui| { + combo_index_row( + ui, + tr_l10n(lang, "settings.theme.label"), + "", + vm.settings.theme, + &[ + tr_l10n(lang, "settings.theme.system"), + tr_l10n(lang, "settings.theme.light"), + tr_l10n(lang, "settings.theme.dark"), + ], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Theme, + val, + )); + }, + ); + toggle_row( + ui, + tr_l10n(lang, "settings.theme.activity_heatmap_label"), + "", + vm.settings.activity_heatmap, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::ActivityHeatmap, + )); + }, + ); + }); + card( + ui, + tr_l10n(lang, "settings.language.title"), + tr_l10n(lang, "settings.language.desc"), + |ui| { + combo_index_row( + ui, + tr_l10n(lang, "settings.language.label"), + tr_l10n(lang, "settings.language.label_desc"), + vm.settings.language, + &[ + tr_l10n(lang, "settings.language.follow_system"), + tr_l10n(lang, "settings.language.zh"), + tr_l10n(lang, "settings.language.zh_tw"), + tr_l10n(lang, "settings.language.en"), + tr_l10n(lang, "settings.language.ja"), + tr_l10n(lang, "settings.language.ko"), + ], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::Language, + val, + )); + }, + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.language.restart_hint")) + .size(11.0) + .color(theme::INK_4), + ); + }, + ); +} + +/// AI-services sub-views. The list mirrors the Tauri `availableServiceViews` +/// gate: the multimodal view appears once the pipeline is enabled, the local +/// model view only when the host really has a local engine, and the tab strip +/// always ends with the connection settings. +#[derive(Clone, Copy, PartialEq, Eq)] +enum ServiceView { + Llm, + Asr, + Models, + Connections, + Omni, +} + +impl ServiceView { + fn id(self) -> usize { + match self { + Self::Llm => 0, + Self::Asr => 1, + Self::Models => 2, + Self::Connections => 3, + Self::Omni => 4, + } + } + + fn label(self, lang: Lang) -> &'static str { + match self { + Self::Llm => tr_l10n(lang, "modal.service_views.llm"), + Self::Asr => tr_l10n(lang, "modal.service_views.asr"), + Self::Models => tr_l10n(lang, "modal.service_views.models"), + Self::Connections => tr_l10n(lang, "modal.service_views.connections"), + Self::Omni => tr_l10n(lang, "modal.service_views.omni"), + } + } + + fn visible(vm: &FrontendViewModel) -> Vec { + let mut views = Vec::new(); + if vm.multimodal_view { + views.push(Self::Omni); + } + if !vm.pipeline_multimodal { + views.push(Self::Llm); + views.push(Self::Asr); + } + if vm.supports_local_asr { + views.push(Self::Models); + } + views.push(Self::Connections); + views + } +} + +fn services(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + let views = ServiceView::visible(vm); + let active = views + .iter() + .position(|view| view.id() == vm.services_view) + .unwrap_or(0); + let items: Vec<(&str, Option)> = views + .iter() + .map(|view| { + let dot = match view { + ServiceView::Llm | ServiceView::Asr => { + let configured = vm.service_configured[usize::from(*view == ServiceView::Asr)]; + Some(if configured { theme::WARN } else { theme::ERR }) + } + _ => None, + }; + (view.label(lang), dot) + }) + .collect(); + if let Some(index) = service_tabs(ui, &items, active) { + if let Some(view) = views.get(index) { + actions.push(FrontendAction::SettingsServicesView(view.id())); + } + } + ui.add_space(10.0); + + match views.get(active).copied().unwrap_or(ServiceView::Llm) { + ServiceView::Models => { + card( + ui, + tr_l10n(lang, "modal.service_views.models"), + tr_l10n(lang, "settings.advanced.local_asr_desc"), + |ui| { + ui.label( + egui::RichText::new(tr_l10n( + lang, + "settings.advanced.platform_not_supported", + )) + .size(11.5) + .color(theme::INK_4), + ); + }, + ); + } + ServiceView::Connections => { + card(ui, tr_l10n(lang, "settings.network.title"), "", |ui| { + toggle_row( + ui, + tr_l10n(lang, "settings.network.use_system_proxy_label"), + tr_l10n(lang, "settings.network.use_system_proxy_desc"), + vm.settings.system_proxy, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::SystemProxy)); + }, + ); + }); + card( + ui, + tr_l10n(lang, "settings.marketplace.title"), + tr_l10n(lang, "settings.marketplace.desc"), + |ui| { + action_row( + ui, + tr_l10n(lang, "settings.marketplace.github.sign_in"), + "", + tr_l10n(lang, "settings.marketplace.github.open_github"), + SettingsActionField::OpenGitHub, + actions, + ); + }, + ); + } + view => { + // 视图 → 渠道类型(0 = 语言模型,1 = 语音识别)。宿主按该类型取数。 + let kinds: &[usize] = match view { + ServiceView::Asr => &[1], + ServiceView::Omni => &[0, 1], + _ => &[0], + }; + for kind in kinds { + let asr = *kind == 1; + let title = if asr { + tr_l10n(lang, "settings.channels.asr_title") + } else { + tr_l10n(lang, "settings.channels.llm_title") + }; + let add = tr_l10n(lang, "settings.channels.add"); + let mut add_clicked = false; + // 卡片头:标题在左、+添加渠道在右(Tauri 的 ProvidersSection), + // 标题下方一行说明,再下面是渠道行。 + card_header( + ui, + title, + "", + |ui| { + let add_width = layout::text_width(ui, add, 12.0) + 30.0; + let (add_rect, _) = ui + .allocate_exact_size(egui::vec2(add_width, 26.0), egui::Sense::hover()); + add_clicked = layout::action_button( + ui, + add_rect, + add, + None, + layout::ButtonKind::Blue, + ) + .clicked(); + }, + |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.order_hint")) + .size(11.0) + .color(theme::INK_4), + ); + ui.add_space(6.0); + if vm.channels_loading { + ui.label( + egui::RichText::new(tr_l10n(lang, "common.loading")) + .size(11.5) + .color(theme::INK_4), + ); + } else if vm.channels.is_empty() { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.empty")) + .size(11.5) + .color(theme::INK_4), + ); + } else { + for (index, channel) in vm.channels.iter().enumerate() { + channel_row( + ui, + channel, + index, + &vm.channel_providers, + lang, + actions, + ); + } + } + if let Some(editor) = &vm.provider_editor { + ui.add_space(8.0); + provider_editor_panel(ui, editor, lang, actions); + } + if vm.channel_form_open { + ui.add_space(8.0); + add_channel_form(ui, vm, actions); + } + }, + ); + if add_clicked { + actions.push(FrontendAction::SettingsChannelFormOpen(true)); + } + } + ui.label( + egui::RichText::new(tr_l10n( + lang, + "settings.providers.credential_storage_notice", + )) + .size(11.0) + .color(theme::INK_4), + ); + } + } +} + +fn privacy(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.about.local_first")) + .strong() + .color(theme::BLUE), + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.about.privacy_desc")) + .size(11.5) + .color(theme::INK_3), + ); + }); + }); + ui.add_space(10.0); + + // 权限:状态全部来自宿主快照(Linux 没有系统级授权弹窗,标为「不适用」)。 + card( + ui, + tr_l10n(lang, "settings.permissions.title"), + tr_l10n(lang, "settings.permissions.desc_no_acc"), + |ui| { + permission_row( + ui, + tr_l10n(lang, "settings.permissions.mic_label"), + "", + vm.permissions.microphone, + lang, + ); + permission_row( + ui, + tr_l10n(lang, "settings.permissions.acc_label"), + "", + vm.permissions.accessibility, + lang, + ); + permission_row( + ui, + tr_l10n(lang, "settings.permissions.hotkey_label"), + "", + vm.permissions.hotkey, + lang, + ); + permission_row( + ui, + tr_l10n(lang, "settings.permissions.network_label"), + "", + vm.permissions.network, + lang, + ); + }, + ); + + // 数据存储(Tauri DataStorageSection:保留时长 / 上限 / 润色上下文 / 光标上下文)。 + card( + ui, + tr_l10n(lang, "settings.data_storage.title"), + tr_l10n(lang, "settings.data_storage.desc"), + |ui| { + let retention = vm.settings.retention_days.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.recording.history_retention_label"), + "", + &mut vm.settings.retention_days, + "0", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::RetentionDays, + retention, + )); + }, + ); + let entries = vm.settings.history_max_entries.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.recording.history_max_entries_label"), + "", + &mut vm.settings.history_max_entries, + "200", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::HistoryMaxEntries, + entries, + )); + }, + ); + let window = vm.settings.polish_context_window.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.recording.polish_context_window_label"), + tr_l10n(lang, "settings.recording.polish_context_window_desc"), + &mut vm.settings.polish_context_window, + "0", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::PolishContextWindow, + window, + )); + }, + ); + }, + ); +} + +fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + // The Tauri page is a list of drill-in rows; the panel swaps to the detail + // page (title + back button) while `advanced_open` is set. + let rows = [ + ( + SettingsIcon::Monitor, + tr_l10n(lang, "settings.coding_agent.title"), + tr_l10n(lang, "modal.advanced_pages.less_computer"), + ), + ( + SettingsIcon::Sparkle, + tr_l10n(lang, "settings.advanced.multimodal_pipeline_title"), + tr_l10n(lang, "modal.advanced_pages.multimodal"), + ), + ( + SettingsIcon::Bolt, + tr_l10n(lang, "settings.debug.title"), + tr_l10n(lang, "modal.advanced_pages.debug"), + ), + ]; + if vm.advanced_open < rows.len() { + let (icon, title, _) = rows[vm.advanced_open]; + let _ = icon; + // 多模态管线是实验性功能:Tauri 用 `ExperimentalSectionTitle`(标题 + 徽章 + // + 悬停说明),所以它不用普通卡片。 + if vm.advanced_open == 1 { + experimental_card( + ui, + title, + tr_l10n(lang, "common.experimental"), + // Tauri 的 `ExperimentalSectionTitle hint`:悬停在标题/徽章上的说明。 + tr_l10n(lang, "settings.advanced.multimodal_pipeline_title_hint"), + |ui| { + toggle_row( + ui, + tr_l10n(lang, "settings.advanced.multimodal_pipeline_label"), + tr_l10n(lang, "settings.advanced.multimodal_pipeline_hint"), + vm.settings.multimodal, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::Multimodal)); + }, + ); + }, + ); + return; + } + // Less Computer 与调试工具在 Tauri 里都是「无标题卡片」(标题只出现在 + // 右栏顶栏),只有多模态用 ExperimentalSectionTitle。 + card(ui, "", "", |ui| match vm.advanced_open { + 0 => { + toggle_row( + ui, + tr_l10n(lang, "settings.coding_agent.enable"), + tr_l10n(lang, "settings.coding_agent.hotkey_hint"), + vm.settings.less_computer, + || { + actions.push(FrontendAction::SettingsToggle(SettingsField::LessComputer)); + }, + ); + // Tauri `CodingAgentSection`:后端 / 模型等高级项只在启用后展开。 + if !vm.settings.less_computer { + return; + } + combo_index_row( + ui, + tr_l10n(lang, "settings.coding_agent.provider"), + tr_l10n(lang, "settings.coding_agent.coming_soon_note"), + vm.settings.coding_agent_provider.min(3), + &["Claude Code", "OpenCode", "Codex", "dsh"], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::CodingAgentProvider, + val, + )); + }, + ); + combo_index_row( + ui, + tr_l10n(lang, "settings.coding_console.permission_mode"), + "", + vm.settings.coding_agent_permission.min(3), + &[ + tr_l10n(lang, "settings.coding_console.mode.accept_edits"), + tr_l10n(lang, "settings.coding_console.mode.plan"), + tr_l10n(lang, "settings.coding_console.mode.default"), + tr_l10n(lang, "settings.coding_console.mode.bypass_permissions"), + ], + |val| { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::CodingAgentPermission, + val, + )); + }, + ); + let model = vm.settings.coding_agent_model.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.coding_agent.model"), + tr_l10n(lang, "settings.coding_agent.model_hint"), + &mut vm.settings.coding_agent_model, + tr_l10n(lang, "settings.coding_agent.model_placeholder"), + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::CodingAgentModel, + model, + )); + }, + ); + let workdir = vm.settings.coding_agent_workdir.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.coding_console.workdir"), + tr_l10n(lang, "settings.coding_console.workdir_desc"), + &mut vm.settings.coding_agent_workdir, + tr_l10n(lang, "settings.coding_console.workdir_placeholder"), + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::CodingAgentWorkdir, + workdir, + )); + }, + ); + let exe = vm.settings.coding_agent_exe.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.coding_agent.exe"), + "", + &mut vm.settings.coding_agent_exe, + "claude", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::CodingAgentExe, + exe, + )); + }, + ); + } + 1 => {} + _ => { + toggle_row( + ui, + tr_l10n(lang, "settings.recording.record_audio_for_debug_label"), + "", + vm.settings.record_audio_for_debug, + || { + actions.push(FrontendAction::SettingsToggle( + SettingsField::RecordAudioForDebug, + )); + }, + ); + let entries = vm.settings.audio_recording_max_entries.clone(); + text_edit_row( + ui, + tr_l10n(lang, "settings.recording.audio_recording_max_entries_label"), + tr_l10n(lang, "settings.recording.audio_recording_max_entries_desc"), + &mut vm.settings.audio_recording_max_entries, + "50", + || { + actions.push(FrontendAction::SettingsText( + SettingsTextField::AudioRecordingMaxEntries, + entries, + )); + }, + ); + action_row( + ui, + tr_l10n(lang, "settings.debug.title"), + "", + tr_l10n(lang, "btn.export_error_log"), + SettingsActionField::ExportDiagnostics, + actions, + ); + } + }); + return; + } + card(ui, "", "", |ui| { + for (index, (icon, title, description)) in rows.iter().enumerate() { + if drill_row(ui, *icon, title, description) { + vm.advanced_open = index; + } + } + }); +} + +/// A drill-in row: icon + bold title + description + chevron. Returns true when +/// the row was clicked (the panel then swaps to that detail page). +fn drill_row(ui: &mut egui::Ui, icon: SettingsIcon, title: &str, description: &str) -> bool { + let (rect, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 54.0), egui::Sense::click()); + if response.hovered() { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + egui::Color32::from_rgba_unmultiplied(244, 244, 245, 140), + ); + } + draw_rail_icon( + ui, + egui::pos2(rect.left() + 18.0, rect.center().y), + icon, + theme::INK_2, + ); + ui.painter().text( + egui::pos2(rect.left() + 40.0, rect.center().y - 9.0), + egui::Align2::LEFT_CENTER, + title, + egui::FontId::proportional(13.5), + theme::INK, + ); + ui.painter().text( + egui::pos2(rect.left() + 40.0, rect.center().y + 9.0), + egui::Align2::LEFT_CENTER, + description, + egui::FontId::proportional(11.5), + theme::INK_4, + ); + let chevron = egui::pos2(rect.right() - 12.0, rect.center().y); + let stroke = egui::Stroke::new(1.2, theme::INK_4); + ui.painter().line_segment( + [ + egui::pos2(chevron.x - 2.5, chevron.y - 4.5), + egui::pos2(chevron.x + 2.0, chevron.y), + ], + stroke, + ); + ui.painter().line_segment( + [ + egui::pos2(chevron.x + 2.0, chevron.y), + egui::pos2(chevron.x - 2.5, chevron.y + 4.5), + ], + stroke, + ); + response.clicked() +} + +fn about(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + let icon = layout::load_app_icon(ui.ctx()); + card(ui, "", "", |ui| { + let header_width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(header_width, 64.0), + egui::Layout::left_to_right(egui::Align::Center), + |row| { + let (icon_rect, _) = + row.allocate_exact_size(egui::vec2(56.0, 56.0), egui::Sense::hover()); + row.painter().rect_filled( + icon_rect.translate(egui::vec2(0.0, 2.0)), + egui::CornerRadius::same(13), + egui::Color32::from_black_alpha(28), + ); + row.painter() + .rect_filled(icon_rect, egui::CornerRadius::same(13), theme::SURFACE); + row.painter().rect_stroke( + icon_rect, + egui::CornerRadius::same(13), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + row.painter().image( + icon.id(), + icon_rect, + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + row.add_space(12.0); + row.vertical(|ui| { + ui.label(egui::RichText::new("OpenLess").size(17.0).strong()); + ui.label( + egui::RichText::new(format!( + "{} · v{}", + tr_l10n(lang, "settings.about.tagline"), + vm.version + )) + .size(12.0) + .color(theme::INK_3), + ); + }); + }, + ); + if let Some(notice) = &vm.settings_notice { + ui.label(egui::RichText::new(notice).size(11.0).color(theme::BLUE)); + } + }); + // The Tauri link card groups documentation links above a divider. + ui.add_space(-8.0); + card(ui, tr_l10n(lang, "settings.about.links_title"), "", |ui| { + link_row( + ui, + tr_l10n(lang, "settings.about.source"), + "GitHub", + SettingsActionField::OpenGitHub, + actions, + ); + link_row( + ui, + tr_l10n(lang, "settings.about.docs"), + tr_l10n(lang, "modal.about.docs_btn"), + SettingsActionField::OpenHelp, + actions, + ); + separator_line(ui); + link_row( + ui, + tr_l10n(lang, "modal.sections.help_center"), + tr_l10n(lang, "modal.sections.help_center"), + SettingsActionField::OpenHelp, + actions, + ); + link_row( + ui, + tr_l10n(lang, "modal.sections.release_notes"), + tr_l10n(lang, "modal.sections.release_notes"), + SettingsActionField::OpenReleaseNotes, + actions, + ); + link_row( + ui, + tr_l10n(lang, "settings.about.feedback"), + tr_l10n(lang, "modal.about.feedback_btn"), + SettingsActionField::OpenFeedback, + actions, + ); + link_row( + ui, + tr_l10n(lang, "settings.about.qq"), + "1078960553", + SettingsActionField::CopyQQ, + actions, + ); + }); +} + +/// One credential channel row: name + current marker, provider/model, actions. +fn channel_row( + ui: &mut egui::Ui, + channel: &super::view_model::SettingsChannel, + index: usize, + providers: &[SettingsChannelProvider], + lang: Lang, + actions: &mut Vec, +) { + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&channel.name) + .size(12.5) + .strong() + .color(theme::INK), + ); + if channel.is_active { + egui::Frame::new() + .fill(theme::BLUE_SOFT) + .corner_radius(egui::CornerRadius::same(9)) + .inner_margin(egui::Margin::symmetric(7, 2)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.current")) + .size(10.0) + .color(theme::BLUE), + ); + }); + } + if !channel.enabled { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.disabled")) + .size(10.5) + .color(theme::INK_4), + ); + } + }); + let detail = if channel.model.trim().is_empty() { + channel.provider.clone() + } else { + format!("{} · {}", channel.provider, channel.model) + }; + ui.label(egui::RichText::new(detail).size(11.0).color(theme::INK_3)); + let last_check = channel + .last_check + .clone() + .unwrap_or_else(|| tr_l10n(lang, "settings.channels.not_verified").to_string()); + ui.label( + egui::RichText::new(last_check) + .size(10.5) + .color(theme::INK_4), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // 编辑入口:选中渠道后由宿主向 Core 读回该渠道的描述符与凭据形态。 + if ui + .add( + egui::Button::new(egui::RichText::new(tr_l10n(lang, "btn.edit")).size(11.0)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 24.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelSelect(index)); + } + for (label, delta) in [("↑", -1isize), ("↓", 1isize)] { + if ui + .add( + egui::Button::new(egui::RichText::new(label).size(11.0)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(22.0, 24.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelMove { index, delta }); + } + } + // 渠道的 provider 类型就是 Core 的 `set_channel_provider_type`: + // 换类型等于换描述符,因此比编辑表单更早生效。 + if !providers.is_empty() { + let selected = providers + .iter() + .position(|provider| provider.provider_type == channel.provider_type) + .unwrap_or(0); + let mut picked = selected; + egui::ComboBox::from_id_salt(("settings-channel-provider", index)) + .selected_text(&channel.provider) + .width(150.0) + .show_ui(ui, |ui| { + for (option_index, provider) in providers.iter().enumerate() { + if ui + .selectable_label(option_index == selected, &provider.label) + .clicked() + { + picked = option_index; + ui.close(); + } + } + }); + if picked != selected { + actions.push(FrontendAction::SettingsChannelProviderType { + index, + provider_type: providers[picked].provider_type.clone(), + }); + } + } + // 当前生效的渠道由 Core 记录:界面只读「哪个是当前」(is_active), + // 并通过 Core 切换,不自己判定谁该生效。 + if !channel.is_active + && ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "btn.activate")).size(11.0), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 24.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelActivate(index)); + } + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "settings.channels.delete")).size(11.0), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 24.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelDelete(index)); + } + let (switch, _) = ui.allocate_exact_size(egui::vec2(36.0, 20.0), egui::Sense::hover()); + if layout::toggle(ui, switch, channel.enabled, ("settings-channel", index)).clicked() { + actions.push(FrontendAction::SettingsChannelToggle(index)); + } + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.enabled")) + .size(11.0) + .color(theme::INK_3), + ); + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "settings.channels.verify")).size(11.0), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 24.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelValidate(index)); + } + ui.add_space(6.0); + }); + }); + ui.separator(); +} + +/// Provider + name form used by "add channel". +/// One editor text row. The pushed value is the post-edit text: pushing the +/// pre-edit copy would make the host write the old value straight back into the +/// field on every keystroke. +/// 模型设置块:对应 Tauri `ProvidersSection` 里的「模型设置」分区。 +/// +/// - 模型字段(`ChannelModelField`):descriptor 带 `staticModels` 时是一个预设下拉, +/// 末尾附「自定义模型…」逃生口切到手输;没预设时是普通输入框。值为空且有 +/// `defaultModel` 时给一个「填入默认」。 +/// - 可用模型(`ProviderTools`):当前 endpoint 命中带文档页的预设时按钮是 +/// 「查看支持的模型」(打开文档);否则是「拉取模型」。拉到后下拉选择, +/// **选中即落地凭据**(Tauri `applyModel`)。 +fn provider_model_block( + ui: &mut egui::Ui, + editor: &SettingsProviderEditor, + lang: Lang, + actions: &mut Vec, +) { + ui.add_space(6.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.modelTitle")) + .font(theme::medium_font(12.0)) + .color(theme::INK), + ); + ui.label( + egui::RichText::new(if editor.has_models_url { + tr_l10n(lang, "settings.providers.planModelsHint") + } else { + tr_l10n(lang, "settings.channels.modelHint") + }) + .size(11.0) + .color(theme::INK_4), + ); + ui.add_space(4.0); + + // 模型字段:预设下拉 ⇄ 自定义输入。 + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.providers.modelLabel")) + .size(11.5) + .color(theme::INK_3), + ); + let show_presets = !editor.static_models.is_empty() && !editor.custom_model; + if show_presets { + // Tauri 会把当前值(不在清单里时)也塞进选项,否则下拉会显示空白。 + let mut options: Vec = editor.static_models.clone(); + let current = editor.model.trim(); + if !current.is_empty() && !options.iter().any(|option| option == current) { + options.push(current.to_string()); + } + let mut pick = editor.model.clone(); + egui::ComboBox::from_id_salt("settings-provider-model-preset") + .selected_text(current) + .show_ui(ui, |ui| { + for option in &options { + if ui.selectable_label(current == option, option).clicked() { + pick = option.clone(); + ui.close(); + } + } + if ui + .selectable_label( + false, + tr_l10n(lang, "settings.providers.customModelLabel"), + ) + .clicked() + { + actions.push(FrontendAction::SettingsProviderModelCustom(true)); + ui.close(); + } + }); + if pick != editor.model { + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::Model, + pick, + )); + } + } else { + let mut draft = editor.model.clone(); + let id = egui::Id::new("openless-settings-provider-model"); + if layout::text_input(ui, &mut draft, id, "", 220.0, false).changed() { + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::Model, + draft, + )); + } + if !editor.static_models.is_empty() + && provider_small_button(ui, lang, "settings.providers.presetListLabel", false) + { + actions.push(FrontendAction::SettingsProviderModelCustom(false)); + } + } + if editor.model.trim().is_empty() + && !editor.default_model.is_empty() + && provider_small_button(ui, lang, "settings.providers.fillDefault", false) + { + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::Model, + editor.default_model.clone(), + )); + } + }); + + // 可用模型:拉取(或看文档)→ 选择 → 写入模型字段并保存。 + ui.add_space(2.0); + if editor.has_models_url { + if provider_small_button(ui, lang, "settings.providers.viewModels", false) { + actions.push(FrontendAction::SettingsProviderModelsUrl); + } + return; + } + let fetch = if editor.models_loading { + tr_l10n(lang, "settings.providers.loadingModels") + } else { + tr_l10n(lang, "settings.providers.fetchModels") + }; + let fetch_width = layout::text_width(ui, fetch, 11.5) + 24.0; + let (rect, _) = ui.allocate_exact_size(egui::vec2(fetch_width, 26.0), egui::Sense::hover()); + if layout::action_button(ui, rect, fetch, None, layout::ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::SettingsProviderModels); + } + if editor.models.is_empty() { + return; + } + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.availableModels")) + .size(11.0) + .color(theme::INK_3), + ); + // Tauri 的「可用模型」是可搜索下拉(OrcaRouter 这类目录很大);这里同样在下拉里 + // 带一个搜索框,并把过滤词存在 egui 自己的临时存储里。 + let filter_id = egui::Id::new("openless-settings-provider-model-filter"); + let mut filter: String = ui.data(|data| data.get_temp(filter_id).unwrap_or_default()); + egui::ComboBox::from_id_salt("settings-provider-model-catalog") + .selected_text(tr_l10n(lang, "settings.providers.selectModel")) + .show_ui(ui, |ui| { + if ui + .add( + egui::TextEdit::singleline(&mut filter) + .hint_text(tr_l10n(lang, "settings.providers.searchModels")), + ) + .changed() + { + ui.data_mut(|data| data.insert_temp(filter_id, filter.clone())); + } + let needle = filter.trim().to_lowercase(); + let mut shown = 0; + for model in &editor.models { + if !needle.is_empty() && !model.to_lowercase().contains(&needle) { + continue; + } + shown += 1; + if ui.selectable_label(editor.model == *model, model).clicked() { + actions.push(FrontendAction::SettingsProviderModelSelected(model.clone())); + ui.close(); + } + } + if shown == 0 { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.providers.noMatchingModels")) + .size(11.0) + .color(theme::INK_4), + ); + } + }); +} + +fn provider_field( + ui: &mut egui::Ui, + label: &str, + value: &str, + field: SettingsProviderField, + password: bool, + actions: &mut Vec, +) { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label).size(11.5).color(theme::INK_3)); + let mut draft = value.to_string(); + let id = egui::Id::new(("openless-settings-provider-field", format!("{field:?}"))); + if layout::text_input(ui, &mut draft, id, "", 220.0, password).changed() { + actions.push(FrontendAction::SettingsProviderField(field, draft)); + } + }); +} + +fn provider_small_button(ui: &mut egui::Ui, lang: Lang, key: &'static str, primary: bool) -> bool { + let text = egui::RichText::new(tr_l10n(lang, key)).size(11.5); + let button = if primary { + egui::Button::new(text.color(theme::SURFACE)).fill(theme::INK) + } else { + egui::Button::new(text).fill(theme::SURFACE_2) + }; + ui.add( + button + .stroke(if primary { + egui::Stroke::NONE + } else { + egui::Stroke::new(0.8, theme::LINE) + }) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 26.0)), + ) + .clicked() +} + +/// Channel editor. Core's `AuthRequirement` decides which inputs exist, and every +/// write goes back through Core's provider/credential API: the UI never owns +/// endpoints, defaults or credential semantics. Secret inputs are write-only — +/// opening an editor never shows a stored key. +fn provider_editor_panel( + ui: &mut egui::Ui, + editor: &SettingsProviderEditor, + lang: Lang, + actions: &mut Vec, +) { + egui::Frame::new() + .fill(theme::SURFACE_2) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(&editor.provider) + .strong() + .size(12.0) + .color(theme::INK), + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.editing")) + .size(10.5) + .color(theme::INK_4), + ); + if editor.busy { + ui.label( + egui::RichText::new(tr_l10n(lang, "common.loading")) + .size(10.5) + .color(theme::INK_4), + ); + } + }); + provider_field( + ui, + tr_l10n(lang, "providers.name"), + &editor.name, + SettingsProviderField::Name, + false, + actions, + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.credentials")) + .size(11.0) + .color(theme::INK_4), + ); + match editor.auth { + SettingsProviderAuth::None => { + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.no_cloud_note")) + .size(11.0) + .color(theme::INK_4), + ); + } + SettingsProviderAuth::OAuth => { + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.oauth_note")) + .size(11.0) + .color(theme::INK_4), + ); + } + SettingsProviderAuth::Volcengine => { + let mut mode = editor.auth_mode.clone(); + ui.horizontal(|ui| { + ui.label(egui::RichText::new("Auth").size(11.5).color(theme::INK_3)); + egui::ComboBox::from_id_salt("settings-provider-auth-mode") + .selected_text(&mode) + .show_ui(ui, |ui| { + for option in ["app_id_token", "api_key"] { + if ui.selectable_label(mode == option, option).clicked() { + mode = option.to_string(); + ui.close(); + } + } + }); + }); + if mode != editor.auth_mode { + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::AuthMode, + mode.clone(), + )); + } + if mode == "api_key" { + provider_field( + ui, + "API Key", + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + } else { + provider_field( + ui, + "APP ID", + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + provider_field( + ui, + "Access Token", + &editor.secondary_secret, + SettingsProviderField::SecondarySecret, + true, + actions, + ); + } + provider_field( + ui, + "Resource ID", + &editor.resource_id, + SettingsProviderField::ResourceId, + false, + actions, + ); + } + SettingsProviderAuth::Xfyun => { + provider_field( + ui, + "AppID", + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + provider_field( + ui, + "API Key", + &editor.secondary_secret, + SettingsProviderField::SecondarySecret, + true, + actions, + ); + } + SettingsProviderAuth::Other => { + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.core_note")) + .size(11.0) + .color(theme::INK_4), + ); + } + SettingsProviderAuth::ApiKey => { + provider_field( + ui, + tr_l10n(lang, "providers.api_key_hint"), + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + actions, + ); + provider_field( + ui, + "Endpoint", + &editor.endpoint, + SettingsProviderField::Endpoint, + false, + actions, + ); + } + } + provider_model_block(ui, editor, lang, actions); + ui.add_space(4.0); + ui.horizontal(|ui| { + if provider_small_button(ui, lang, "btn.save_fields", true) { + actions.push(FrontendAction::SettingsProviderSave); + } + if provider_small_button(ui, lang, "btn.clear_secret", false) { + actions.push(FrontendAction::SettingsProviderClearSecrets); + } + if provider_small_button(ui, lang, "btn.close", false) { + actions.push(FrontendAction::SettingsProviderClose); + } + }); + }); +} + +fn add_channel_form( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + ui.horizontal(|ui| { + let options: Vec = vm + .channel_providers + .iter() + .map(|provider| provider.label.clone()) + .collect(); + let selected = vm + .channel_provider_index + .min(options.len().saturating_sub(1)); + let mut new_selection = selected; + egui::ComboBox::from_id_salt("settings-new-channel-provider") + .selected_text(options.get(selected).cloned().unwrap_or_default()) + .show_ui(ui, |ui| { + for (index, option) in options.iter().enumerate() { + if ui.selectable_label(index == selected, option).clicked() { + new_selection = index; + ui.close(); + } + } + }); + if new_selection != selected { + actions.push(FrontendAction::SettingsChannelProvider(new_selection)); + } + // 推的是编辑后的值:推编辑前的拷贝会让宿主把旧值写回字段,每敲一个字 + // 就被回灌一次(渠道名、下划线搜索框都踩过这个坑)。 + let response = layout::text_input( + ui, + &mut vm.channel_form_name, + egui::Id::new("openless-settings-channel-name"), + tr_l10n(lang, "settings.channels.name_placeholder"), + 200.0, + false, + ); + if response.changed() { + actions.push(FrontendAction::SettingsChannelName( + vm.channel_form_name.clone(), + )); + } + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "settings.channels.create")) + .color(theme::SURFACE) + .size(11.5), + ) + .fill(theme::INK) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 26.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelCreate); + } + if ui + .add( + egui::Button::new(egui::RichText::new(tr_l10n(lang, "common.cancel")).size(11.5)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 26.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsChannelFormOpen(false)); + } + }); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.name_hint")) + .size(11.0) + .color(theme::INK_4), + ); +} + +/// A row whose value is a button that opens a link / performs an action. +fn link_row( + ui: &mut egui::Ui, + label: &str, + button: &str, + field: SettingsActionField, + actions: &mut Vec, +) { + row(ui, label, |ui| { + if ui + .add( + egui::Button::new(egui::RichText::new(button).size(12.0).color(theme::INK_2)) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(6)) + .min_size(egui::vec2(0.0, 28.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsAction(field)); + } + }); +} + +// ── Card & row helpers ────────────────────────────────────────────────────── + +/// A settings card. Tauri hides every `SectionDesc` (the only visible +/// description is the one under the pane title), so the second argument is a +/// hover hint attached to a 「?」 next to the card title. +fn card(ui: &mut egui::Ui, title: &str, hint: &str, contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::symmetric(18, 18)) + .show(ui, |ui| { + if !title.is_empty() { + card_title(ui, title, hint); + } + contents(ui); + }); + ui.add_space(16.0); +} + +/// A card whose title carries the 「实验性」 badge (Tauri +/// `ExperimentalSectionTitle`): title + blue-soft pill + hover hint. +fn experimental_card( + ui: &mut egui::Ui, + title: &str, + badge: &str, + hint: &str, + contents: impl FnOnce(&mut egui::Ui), +) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::symmetric(18, 18)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(title) + .size(13.0) + .strong() + .color(theme::INK), + ); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(layout::text_width(ui, badge, 10.0) + 12.0, 16.0), + egui::Sense::hover(), + ); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::BLUE_SOFT); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + badge, + egui::FontId::proportional(10.0), + theme::BLUE, + ); + if !hint.is_empty() { + let _ = response.on_hover_text(hint); + } + }); + ui.add_space(6.0); + contents(ui); + }); + ui.add_space(16.0); +} + +/// A collapsible card group (Tauri wraps 插入与剪贴板 / 启动 in a `Collapsible`). +/// The open state lives in egui memory, keyed by the group title. +fn card_group(ui: &mut egui::Ui, title: &str, contents: impl FnOnce(&mut egui::Ui)) { + let id = egui::Id::new(("openless-settings-group", title)); + let mut open = ui.data(|data| data.get_temp::(id).unwrap_or(true)); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::symmetric(18, 18)) + .show(ui, |ui| { + let (rect, response) = ui + .allocate_exact_size(egui::vec2(ui.available_width(), 20.0), egui::Sense::click()); + ui.painter().text( + egui::pos2(rect.left(), rect.center().y), + egui::Align2::LEFT_CENTER, + title, + egui::FontId::proportional(13.0), + theme::INK, + ); + let chevron = egui::pos2(rect.right() - 8.0, rect.center().y); + let stroke = egui::Stroke::new(1.2, theme::INK_4); + let dy = if open { -2.0 } else { 2.0 }; + ui.painter().line_segment( + [egui::pos2(chevron.x - 4.0, chevron.y - dy), chevron], + stroke, + ); + ui.painter().line_segment( + [chevron, egui::pos2(chevron.x + 4.0, chevron.y - dy)], + stroke, + ); + if response.clicked() { + open = !open; + } + if open { + ui.add_space(4.0); + contents(ui); + } + }); + ui.data_mut(|data| data.insert_temp(id, open)); + ui.add_space(16.0); +} + +/// A card whose header carries an action on the right (AI-services lists). +fn card_header( + ui: &mut egui::Ui, + title: &str, + hint: &str, + action: impl FnOnce(&mut egui::Ui), + contents: impl FnOnce(&mut egui::Ui), +) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::symmetric(18, 18)) + .show(ui, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(title) + .size(13.0) + .strong() + .color(theme::INK), + ); + if !hint.is_empty() { + help_dot(ui, hint); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), action); + }); + ui.add_space(6.0); + contents(ui); + }); + ui.add_space(16.0); +} + +/// The AI-services tab strip: Tauri uses underline tabs (active = blue label + +/// blue underline, required services carry a red/yellow status dot). +fn service_tabs( + ui: &mut egui::Ui, + items: &[(&str, Option)], + active: usize, +) -> Option { + let height = 38.0; + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), height), + egui::Sense::hover(), + ); + ui.painter().line_segment( + [ + egui::pos2(rect.left(), rect.bottom() - 0.5), + egui::pos2(rect.right(), rect.bottom() - 0.5), + ], + egui::Stroke::new(1.0, theme::LINE), + ); + let mut x = rect.left(); + let mut clicked = None; + for (index, (label, dot)) in items.iter().enumerate() { + let width = + layout::text_width(ui, label, 13.0) + 28.0 + if dot.is_some() { 13.0 } else { 0.0 }; + let tab = egui::Rect::from_min_size(egui::pos2(x, rect.top()), egui::vec2(width, height)); + let response = ui.interact( + tab, + ui.id().with(("openless-service-tab", index)), + egui::Sense::click(), + ); + let selected = index == active; + let mut text_x = tab.left() + 14.0; + if let Some(color) = dot { + ui.painter() + .circle_filled(egui::pos2(text_x + 3.5, tab.center().y), 3.5, *color); + text_x += 13.0; + } + ui.painter().text( + egui::pos2(text_x, tab.center().y), + egui::Align2::LEFT_CENTER, + *label, + egui::FontId::proportional(13.0), + if selected { + theme::BLUE + } else if response.hovered() { + theme::INK + } else { + theme::INK_3 + }, + ); + if selected { + ui.painter().line_segment( + [ + egui::pos2(tab.left(), tab.bottom() - 1.0), + egui::pos2(tab.right(), tab.bottom() - 1.0), + ], + egui::Stroke::new(2.0, theme::BLUE), + ); + } + if response.clicked() { + clicked = Some(index); + } + x += width + 6.0; + } + clicked +} + +fn card_title(ui: &mut egui::Ui, title: &str, hint: &str) { + ui.horizontal(|ui| { + let title_response = ui.label( + // Tauri `SectionTitle`: font-size 14 / font-weight 600。 + egui::RichText::new(title) + .size(14.0) + .strong() + .color(theme::INK), + ); + if !hint.is_empty() { + let _ = title_response.on_hover_text(hint); + } + }); + ui.add_space(6.0); +} + +/// The small 「?」 Tauri renders next to a setting label: hover for the full +/// explanation instead of spending a permanent paragraph on it. +fn help_dot(ui: &mut egui::Ui, hint: &str) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + ui.painter().circle_stroke( + rect.center(), + 7.5, + egui::Stroke::new(0.5, theme::LINE_STRONG), + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "?", + egui::FontId::proportional(10.0), + theme::INK_4, + ); + response.on_hover_text(hint) +} + +/// Tauri `iconButtonStyle` 的关闭按钮(28×28 / r8 / 细边),用在设置顶栏。 +fn close_button(ui: &mut egui::Ui) -> bool { + let (rect, response) = ui.allocate_exact_size(egui::vec2(28.0, 28.0), egui::Sense::click()); + let fill = if response.hovered() { + theme::SURFACE_2.gamma_multiply(1.06) + } else { + theme::SURFACE_2 + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), fill); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + let center = rect.center(); + let stroke = egui::Stroke::new(1.3, theme::INK_3); + let arm = 5.0; + ui.painter().line_segment( + [ + egui::pos2(center.x - arm, center.y - arm), + egui::pos2(center.x + arm, center.y + arm), + ], + stroke, + ); + ui.painter().line_segment( + [ + egui::pos2(center.x + arm, center.y - arm), + egui::pos2(center.x - arm, center.y + arm), + ], + stroke, + ); + response.clicked() +} + +fn toggle_row(ui: &mut egui::Ui, label: &str, desc: &str, value: bool, on_toggle: impl FnOnce()) { + row_desc(ui, label, desc, |ui| { + let (rect, _) = ui.allocate_exact_size(egui::vec2(36.0, 20.0), egui::Sense::hover()); + if layout::toggle(ui, rect, value, label).clicked() { + on_toggle(); + } + }); +} + +fn combo_index_row( + ui: &mut egui::Ui, + label: &str, + desc: &str, + value: usize, + options: &[&str], + on_change: impl FnOnce(usize), +) { + row_desc(ui, label, desc, |ui| { + let mut selected = value; + egui::ComboBox::from_id_salt(("settings", label)) + .selected_text(options.get(value).copied().unwrap_or("")) + .show_ui(ui, |ui| { + for (index, option) in options.iter().enumerate() { + let response = ui.selectable_label(selected == index, *option); + if response.clicked() { + selected = index; + ui.close(); + } + } + }); + if selected != value { + on_change(selected); + } + }); +} + +/// A row whose control is a segmented picker (Tauri uses one for the recording +/// mode and the selection-polish delivery). +fn segmented_row( + ui: &mut egui::Ui, + label: &str, + desc: &str, + options: &[&str], + selected: usize, + on_select: impl FnOnce(usize), +) { + row_desc(ui, label, desc, |ui| { + let width = layout::segmented_width(ui, options); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 26.0), egui::Sense::hover()); + if let Some(index) = layout::segmented(ui, rect, options, selected) { + on_select(index); + } + }); +} + +fn remote_mode_row(ui: &mut egui::Ui, lang: Lang, selected: usize, on_select: impl FnOnce(usize)) { + row_desc( + ui, + tr_l10n(lang, "settings.remote_input.default_mode_label"), + "", + |ui| { + let mut changed = None; + for (index, key) in [ + "settings.remote_input.mode_toggle", + "settings.remote_input.mode_hold", + ] + .iter() + .enumerate() + { + let text = tr_l10n(lang, key); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(layout::text_width(ui, text, 12.0) + 26.0, 30.0), + egui::Sense::click(), + ); + let fill = if selected == index { + theme::BLUE + } else { + theme::SURFACE_2 + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), fill); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.7, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + let center = rect.left_center() + egui::vec2(13.0, 0.0); + if index == 0 { + ui.painter().circle_stroke( + center, + 4.0, + egui::Stroke::new( + 1.2, + if selected == index { + theme::SURFACE + } else { + theme::INK_2 + }, + ), + ); + ui.painter().circle_filled( + center, + 1.3, + if selected == index { + theme::SURFACE + } else { + theme::INK_2 + }, + ); + } else { + ui.painter().line_segment( + [ + center + egui::vec2(0.0, -4.0), + center + egui::vec2(0.0, 3.0), + ], + egui::Stroke::new( + 1.2, + if selected == index { + theme::SURFACE + } else { + theme::INK_2 + }, + ), + ); + ui.painter().line_segment( + [center, center + egui::vec2(-2.0, 2.0)], + egui::Stroke::new( + 1.2, + if selected == index { + theme::SURFACE + } else { + theme::INK_2 + }, + ), + ); + ui.painter().line_segment( + [center, center + egui::vec2(2.0, 2.0)], + egui::Stroke::new( + 1.2, + if selected == index { + theme::SURFACE + } else { + theme::INK_2 + }, + ), + ); + } + ui.painter().text( + rect.left_center() + egui::vec2(24.0, 0.0), + egui::Align2::LEFT_CENTER, + text, + theme::medium_font(12.0), + if selected == index { + theme::SURFACE + } else { + theme::INK + }, + ); + if response.clicked() { + changed = Some(index); + } + } + if let Some(index) = changed { + on_select(index); + } + }, + ); +} + +fn capsule_style_preview(ui: &mut egui::Ui, style: usize) { + ui.horizontal(|ui| { + ui.add_space(216.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(164.0, 42.0), egui::Sense::hover()); + match style { + 0 => { + // Match the transparent, drifting spectral ribbons used by the + // live Siri capsule instead of showing unrelated loading dots. + let _ = super::siri_gl::paint(ui, rect, super::siri_gl::SiriGlow::wave(1.35, 0.52)); + } + 1 => { + let pill = egui::Rect::from_center_size(rect.center(), egui::vec2(152.0, 34.0)); + ui.painter() + .rect_filled(pill, egui::CornerRadius::same(17), theme::SURFACE); + ui.painter().rect_stroke( + pill, + egui::CornerRadius::same(17), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + for i in 0..5 { + let h = [8.0, 16.0, 22.0, 12.0, 7.0][i]; + let x = pill.center().x + (i as f32 - 2.0) * 7.0; + ui.painter().line_segment( + [ + egui::pos2(x, pill.center().y - h / 2.0), + egui::pos2(x, pill.center().y + h / 2.0), + ], + egui::Stroke::new(2.0, theme::BLUE), + ); + } + } + _ => { + let pill = egui::Rect::from_center_size(rect.center(), egui::vec2(152.0, 34.0)); + ui.painter() + .rect_filled(pill, egui::CornerRadius::same(17), theme::INK); + for i in 0..7 { + let h = [5.0, 10.0, 17.0, 22.0, 15.0, 8.0, 5.0][i]; + let x = pill.center().x + (i as f32 - 3.0) * 7.0; + ui.painter().line_segment( + [ + egui::pos2(x, pill.center().y - h / 2.0), + egui::pos2(x, pill.center().y + h / 2.0), + ], + egui::Stroke::new(2.0, theme::SURFACE), + ); + } + } + } + }); +} + +fn text_row(ui: &mut egui::Ui, label: &str, desc: &str, value: &str) { + row_desc(ui, label, desc, |ui| { + ui.label(egui::RichText::new(value).size(12.0).color(theme::INK_2)); + }); +} + +fn text_edit_row( + ui: &mut egui::Ui, + label: &str, + desc: &str, + value: &mut String, + hint: &str, + on_change: impl FnOnce(), +) { + row_desc(ui, label, desc, |ui| { + let response = layout::text_input( + ui, + value, + egui::Id::new(("openless-settings-text", label)), + hint, + ui.available_width().min(300.0), + false, + ); + if response.changed() { + on_change(); + } + }); +} + +fn status_row(ui: &mut egui::Ui, label: &str, desc: &str, status: &str, color: egui::Color32) { + row_desc(ui, label, desc, |ui| { + ui.label(egui::RichText::new(status).size(12.0).color(color)); + }); +} + +/// A permission row: the host state is rendered as text, colored by severity. +fn permission_row( + ui: &mut egui::Ui, + label: &str, + desc: &str, + state: super::view_model::PermissionState, + lang: Lang, +) { + use super::view_model::PermissionState; + let (text, color) = match state { + PermissionState::Granted => (tr_l10n(lang, "settings.permissions.granted"), theme::OK), + PermissionState::Unsupported => ( + tr_l10n(lang, "settings.permissions.not_applicable"), + theme::INK_4, + ), + PermissionState::Unknown => ( + tr_l10n(lang, "settings.permissions.indeterminate"), + theme::INK_4, + ), + }; + status_row(ui, label, desc, text, color); +} + +/// A row whose right-hand control is an action button. +fn action_row( + ui: &mut egui::Ui, + label: &str, + desc: &str, + button: &str, + field: SettingsActionField, + actions: &mut Vec, +) { + row_desc(ui, label, desc, |ui| { + if ui + .add( + egui::Button::new(egui::RichText::new(button).size(11.5)) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 26.0)), + ) + .clicked() + { + actions.push(FrontendAction::SettingsAction(field)); + } + }); +} + +fn row(ui: &mut egui::Ui, label: &str, control: impl FnOnce(&mut egui::Ui)) { + row_desc(ui, label, "", control); +} + +fn row_desc(ui: &mut egui::Ui, label: &str, desc: &str, control: impl FnOnce(&mut egui::Ui)) { + // Tauri `SettingRow` 是 grid `minmax(0,200px) minmax(0,1fr)` + gap 16,控件在第二列里 + // **左对齐**(`justify-content: flex-start`),所以每一行的控件起点都固定在同一 x, + // 而不是各自贴右边缘 —— 贴右会让不同宽度的控件彼此错开。 + const LABEL_COLUMN: f32 = 200.0; + const COLUMN_GAP: f32 = 16.0; + let row_width = ui.available_width(); + ui.allocate_ui_with_layout( + egui::vec2(row_width, 46.0), + egui::Layout::left_to_right(egui::Align::Center), + |row| { + // 窄窗口下标签列最多占一半宽度,避免控件列被挤没。 + let label_width = LABEL_COLUMN.min(row_width * 0.5); + row.allocate_ui_with_layout( + egui::vec2(label_width, 46.0), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + // `allocate_ui_with_layout` 只按内容实际宽度推进光标:不显式撑开 + // 最小宽度,标签列就会塌缩成文字宽度,控件紧贴在标签后面(用户报 + // 「都被向左对齐了」)。Tauri 的 `minmax(0,200px)` 是硬列宽。 + ui.set_min_width(label_width); + if !label.is_empty() { + ui.label( + egui::RichText::new(label) + .font(theme::medium_font(14.0)) + .color(theme::INK), + ); + } + if !desc.is_empty() { + help_dot(ui, desc); + } + }, + ); + row.add_space(COLUMN_GAP); + control(row); + }, + ); + let rect = ui + .allocate_exact_size(egui::vec2(ui.available_width(), 1.0), egui::Sense::hover()) + .0; + ui.painter().line_segment( + [rect.left_center(), rect.right_center()], + egui::Stroke::new(0.5, theme::LINE_SOFT), + ); +} + +/// 仅供测试:直接渲染一个可折叠分组,用来验证「点标题行切换开合」。 +#[cfg(test)] +pub(crate) fn test_group_toggle(ui: &mut egui::Ui) { + card_group(ui, "group-toggle-probe", |ui| { + ui.label("GROUPCONTENT"); + }); +} + +#[cfg(test)] +pub(crate) fn test_render_shortcuts( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + shortcuts(ui, vm, actions); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn quick_note_shortcut_value_and_menu_align_left_with_arrow_at_row_end() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + shortcut_menu: Some(ShortcutField::QuickNote), + ..Default::default() + }; + let row = ShortcutRow::new( + ShortcutField::QuickNote, + "", + "Ctrl+Shift+S".into(), + true, + String::new(), + ); + let mut actions = Vec::new(); + let _ = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(520.0, 180.0), + )), + ..Default::default() + }, + |ui| { + ui.set_width(480.0); + shortcut_control(ui, &mut vm, &mut actions, &row); + shortcut_menu(ui, &mut vm, &mut actions, &row); + }, + ); + let (line, arrow): (egui::Rect, egui::Rect) = ctx.data(|data| { + data.get_temp(egui::Id::new("shortcut-control-rects")) + .unwrap() + }); + let record: egui::Rect = ctx.data(|data| { + data.get_temp(egui::Id::new("shortcut-record-button")) + .unwrap() + }); + assert!( + (line.width() - 360.0).abs() < 1.0, + "Tauri recorder max width: {line:?}" + ); + assert_eq!(arrow.right(), line.right()); + assert!(arrow.left() > line.left() + 300.0); + assert!( + (record.left() - line.left()).abs() < 1.0, + "record button must be below value, left-aligned: {record:?}, {line:?}" + ); + assert!(record.top() >= line.bottom()); + } + + #[test] + fn the_shortcut_section_hides_without_a_hotkey_backend() { + // Tauri 的 `visibleSettingsSections` 只在 supportsDesktopHotkey 为真时才列出 + // 「快捷键」;没有 fcitx5 监听器时要跟着隐藏,否则用户会进到一个改不动任何 + // 东西的分区。其余分区的可见性不受该能力影响。 + for section in [ + SettingsSection::General, + SettingsSection::Services, + SettingsSection::Appearance, + SettingsSection::Privacy, + SettingsSection::Advanced, + SettingsSection::About, + ] { + assert!(rail_section_visible(section, false)); + assert!(rail_section_visible(section, true)); + } + assert!(rail_section_visible(SettingsSection::Shortcuts, true)); + assert!(!rail_section_visible(SettingsSection::Shortcuts, false)); + } + + /// Tauri 的 `SettingRow` 是 grid `minmax(0,200px) minmax(0,1fr)`:每一行的控件 + /// 都从同一条竖直线开始(第一列固定 200px,第二列里左对齐)。egui 的 + /// `allocate_ui_with_layout` 只按内容实际宽度推进光标,标签列会塌缩成文字宽度, + /// 控件就紧贴在标签后面——用户看到的就是「都被向左对齐了」。 + #[test] + fn setting_rows_put_every_control_on_one_column() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: Lang::ZhCn, + active_page: crate::ui::frontend::view_model::Page::Settings, + settings_open: true, + settings_section: SettingsSection::General, + ..Default::default() + }; + let mut actions = Vec::new(); + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(760.0, 1600.0), + )), + ..Default::default() + }, + |ui| general(ui, &mut vm, &mut actions), + ); + let mut columns = Vec::new(); + for clipped in &output.shapes { + collect_toggle_columns(&clipped.shape, &mut columns); + } + assert!( + columns.len() >= 3, + "expected several toggles in the recording pane, got {columns:?}" + ); + let first = columns[0]; + assert!( + columns.iter().all(|x| (x - first).abs() < 0.5), + "every toggle must start on the same column: {columns:?}" + ); + // 固定 200px 标签列 + 16px 间距 ⇒ 控件不可能落在 200px 以内。 + assert!( + first > 200.0, + "the label column must be reserved: toggle at x={first}" + ); + } + + /// 「录音与输入」页上的开关都是 36×20 的圆角矩形,把它们的左边缘收出来。 + fn collect_toggle_columns(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Rect(rect) + if (rect.rect.width() - 36.0).abs() < 0.5 + && (rect.rect.height() - 20.0).abs() < 0.5 => + { + out.push(rect.rect.left()); + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect_toggle_columns(shape, out); + } + } + _ => {} + } + } + + #[test] + fn provider_editor_renders_every_auth_shape() { + // 描述符决定渲染哪组字段:六种形态都必须能画出来,且静态渲染不得产生任何 + // 动作(否则每帧都会往宿主推重复的写入)。 + let shapes = [ + SettingsProviderAuth::None, + SettingsProviderAuth::OAuth, + SettingsProviderAuth::Volcengine, + SettingsProviderAuth::Xfyun, + SettingsProviderAuth::Other, + SettingsProviderAuth::ApiKey, + ]; + for auth in shapes { + let editor = SettingsProviderEditor { + channel_id: "channel".to_string(), + provider: "volcengine".to_string(), + provider_type: "volcengine".to_string(), + name: "main".to_string(), + endpoint: "https://example.invalid".to_string(), + model: "model".to_string(), + resource_id: "resource".to_string(), + auth_mode: "app_id_token".to_string(), + auth, + primary_secret: String::new(), + secondary_secret: String::new(), + models: vec!["m1".to_string()], + models_loading: false, + static_models: Vec::new(), + default_model: String::new(), + has_models_url: false, + custom_model: false, + busy: false, + }; + let mut actions = Vec::new(); + let ctx = egui::Context::default(); + let _ = crate::ui::frontend::run_pass(&ctx, egui::RawInput::default(), |ui| { + provider_editor_panel(ui, &editor, Lang::ZhCn, &mut actions); + }); + assert!( + actions.is_empty(), + "渲染 {auth:?} 形态的编辑器时不应产生动作" + ); + } + } + + /// 模型块照 Core descriptor 走:有 `staticModels` 时是一个带当前值的预设下拉, + /// 有文档页时按钮变成「查看支持的模型」而不是拉取,自定义模式下能切回预设列表。 + #[test] + fn provider_model_block_follows_the_descriptor() { + let editor = |static_models: Vec, has_models_url: bool, custom_model: bool| { + SettingsProviderEditor { + channel_id: "channel".to_string(), + provider: "volcengine".to_string(), + provider_type: "volcengine".to_string(), + name: "main".to_string(), + endpoint: "https://example.invalid".to_string(), + model: "doubao-pro".to_string(), + resource_id: String::new(), + auth_mode: "app_id_token".to_string(), + auth: SettingsProviderAuth::Volcengine, + primary_secret: String::new(), + secondary_secret: String::new(), + models: vec!["doubao-lite".to_string()], + models_loading: false, + static_models, + default_model: "doubao-lite".to_string(), + has_models_url, + custom_model, + busy: false, + } + }; + let presets = vec!["doubao-lite".to_string(), "doubao-pro".to_string()]; + + // 预设模式:分区标题、提示、当前值、可用模型的选择入口都在。 + let painted = painted_provider_editor(&editor(presets.clone(), false, false)); + for expected in [ + tr_l10n(Lang::ZhCn, "settings.channels.modelTitle"), + tr_l10n(Lang::ZhCn, "settings.channels.modelHint"), + tr_l10n(Lang::ZhCn, "settings.providers.fetchModels"), + "doubao-pro", + ] { + assert!( + painted.contains(expected), + "missing {expected:?}: {painted}" + ); + } + + // 服务商只提供文档页(例如火山方舟):改成引导去看文档,说明也换成套餐提示。 + let painted = painted_provider_editor(&editor(Vec::new(), true, false)); + assert!( + painted.contains(tr_l10n(Lang::ZhCn, "settings.providers.viewModels")), + "{painted}" + ); + assert!( + painted.contains(tr_l10n(Lang::ZhCn, "settings.providers.planModelsHint")), + "{painted}" + ); + + // 「自定义模型…」模式:手输框旁边给出回到预设列表的入口。 + let painted = painted_provider_editor(&editor(presets, false, true)); + assert!( + painted.contains(tr_l10n(Lang::ZhCn, "settings.providers.presetListLabel")), + "{painted}" + ); + } + + fn painted_provider_editor(editor: &SettingsProviderEditor) -> String { + let ctx = egui::Context::default(); + let mut actions = Vec::new(); + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(560.0, 1200.0), + )), + ..Default::default() + }, + |ui| provider_editor_panel(ui, editor, Lang::ZhCn, &mut actions), + ); + let mut text = String::new(); + for clipped in &output.shapes { + collect_shape_text(&clipped.shape, &mut text); + } + text + } + + fn collect_shape_text(shape: &egui::Shape, out: &mut String) { + match shape { + egui::Shape::Text(text) => { + for row in &text.galley.rows { + for glyph in &row.glyphs { + if glyph.chr != '\0' { + out.push(glyph.chr); + } + } + out.push('\n'); + } + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect_shape_text(shape, out); + } + } + _ => {} + } + } + + #[test] + fn style_pack_draft_selection_only_touches_the_draft() { + // 草稿行的选择器必须走 StyleHotkeyDraftPack:以前传的是 draft_pack 下标, + // 于是「+ 添加风格快捷键 → 选另一个包」会把**已有行**的包换掉,而 + // StyleHotkeyDraftPack 从未被构造(编译告警)。 + assert!(matches!( + style_pack_pick_action(None, 3), + FrontendAction::StyleHotkeyDraftPack(3) + )); + assert!(matches!( + style_pack_pick_action(Some(2), 3), + FrontendAction::StyleHotkeyRepack(2, 3) + )); + } + + #[test] + fn remote_certificate_details_hide_when_stopped_or_stale_and_warn_without_a_full_fingerprint() { + let fingerprint = "ab".repeat(32); + assert_eq!( + remote_cert_fingerprint_state(true, false, Some(&fingerprint)), + RemoteCertFingerprintState::Available + ); + // 服务没在跑 / 地址已过期 → 配对码与旧地址都不能展示。 + assert_eq!( + remote_cert_fingerprint_state(false, false, Some(&fingerprint)), + RemoteCertFingerprintState::Hidden + ); + assert_eq!( + remote_cert_fingerprint_state(true, true, Some(&fingerprint)), + RemoteCertFingerprintState::Hidden + ); + // 在监听但指纹缺失 / 被截断 / 不是十六进制 → 必须走「不可用」告警。 + assert_eq!( + remote_cert_fingerprint_state(true, false, None), + RemoteCertFingerprintState::Unavailable + ); + assert_eq!( + remote_cert_fingerprint_state(true, false, Some("ab")), + RemoteCertFingerprintState::Unavailable + ); + assert_eq!( + remote_cert_fingerprint_state(true, false, Some(&"zz".repeat(32))), + RemoteCertFingerprintState::Unavailable + ); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs new file mode 100644 index 000000000..1c34512cd --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs @@ -0,0 +1,354 @@ +//! Siri-inspired audio visuals shared by the Vulkan eframe windows and the +//! manually-rendered Wayland capsule. Animation state is time- and level- +//! driven; the visible wave/orb/ring is drawn with renderer-independent egui +//! primitives, so a missing GPU path can never replace it with generic bars. +//! +//! The GL callback (with its shader sources and program cache) used to live +//! here as a parity reference; the render path is wgpu now and never called it, +//! so it is gone. The sources are in the git history if a wgpu port needs them. + +/// Which shader drives the glow. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SiriMode { + /// Spectrum sound wave: recording. `level` is the live voice level and + /// `resolved` 1 → 0 collapses the wave into the breathing point. + Wave, + /// Metaball fluid dots: thinking. `gather` 1 = all six merged in the middle + /// (catches the wave's collapsed point), 0 = spread into the rotating ring. + Orb, + /// Rounded-rect perimeter sweep: the ring that hugs the ask composer and + /// the recording capsule (red while recording, ink while thinking). + Ring, +} + +/// One frame of glow parameters. Everything is a scalar or a 3-vector: the +/// uniform upload is the only per-frame work besides the draw call. +#[derive(Clone, Copy, Debug)] +pub struct SiriGlow { + pub mode: SiriMode, + pub time: f32, + pub level: f32, + pub gather: f32, + /// Ring only: corner radius and band thickness in physical pixels. + pub radius: f32, + pub thickness: f32, + /// Per-call-site tint. `[1.0; 3]` keeps the spectral Siri colors (capsule); + /// the ask/composer ring uses a flat accent (red while recording, ink while + /// thinking) exactly like the previous CPU ring. + pub tint: [f32; 3], +} + +impl SiriGlow { + pub fn wave(time: f32, level: f32) -> Self { + Self { + mode: SiriMode::Wave, + time, + level, + gather: 0.0, + radius: 0.0, + thickness: 2.0, + tint: [1.0; 3], + } + } + + pub fn orb(time: f32, gather: f32) -> Self { + Self { + mode: SiriMode::Orb, + time, + level: 0.0, + gather, + radius: 0.0, + thickness: 2.0, + tint: [1.0; 3], + } + } + + /// Perimeter ring for a `rect` of `radius` (px) with a `thickness` (px) band. + pub fn ring(time: f32, radius: f32, thickness: f32) -> Self { + Self { + mode: SiriMode::Ring, + time, + level: 0.0, + gather: 0.0, + radius, + thickness, + tint: [1.0; 3], + } + } + + pub fn with_tint(mut self, tint: [f32; 3]) -> Self { + self.tint = tint; + self + } +} + +/// Voice level → visual amplitude (Tauri `SiriGL.tsx::visualVoice`): noise gate +/// at 0.012, ceiling 0.34, smoothstep, then a 0.42 power for the VU feel. +pub fn visual_voice(raw: f32) -> f32 { + const GATE: f32 = 0.012; + const CEILING: f32 = 0.34; + let gated = ((raw - GATE) / (CEILING - GATE)).clamp(0.0, 1.0); + let eased = gated * gated * (3.0 - 2.0 * gated); + eased.max(0.0).powf(0.42) +} + +/// What the caller wants to drive this frame. +#[derive(Clone, Copy, Debug)] +pub struct SiriDrive { + /// Raw RMS from the audio pipeline (`capsule:state.audio_level`). + pub level: f32, + /// 1 = wave expanded (recording), 0 = collapsed into the thinking point. + pub resolved: f32, + pub speed: f32, + /// Microphone not on yet: the wave breathes at a low, obviously-unready + /// amplitude instead of following the level. + pub warming: bool, +} + +impl Default for SiriDrive { + fn default() -> Self { + Self { + level: 0.0, + resolved: 1.0, + speed: 1.0, + warming: false, + } + } +} + +/// Smoothed animation state, kept in egui memory because egui only repaints +/// while something animates (`SiriGL.tsx` keeps the same values in refs). +#[derive(Clone, Copy, Debug)] +pub struct SiriClock { + pub time: f32, + pub level: f32, + pub resolved: f32, + pub speed: f32, +} + +/// Advance one call site's clock by `dt` (seconds). +pub fn tick(ctx: &egui::Context, id: &str, drive: SiriDrive, dt: f32) -> SiriClock { + let key = egui::Id::new(("openless-siri-clock", id)); + let dt = dt.clamp(0.0, 0.05); + let mut clock = ctx.data_mut(|data| { + data.get_temp::(key).unwrap_or(SiriClock { + time: 0.0, + level: 0.0, + resolved: drive.resolved, + speed: drive.speed, + }) + }); + // Speed is eased before it scales dt, so changing it mid-animation stays + // continuous (Tauri: `smoothSpeed += (speed - smoothSpeed) * (1-e^{-dt*2.5})`). + clock.speed += (drive.speed - clock.speed) * (1.0 - (-dt * 2.5).exp()); + clock.time += dt * clock.speed; + let target = if drive.warming { + 0.12 + 0.06 * (clock.time * 3.0).sin() + } else if drive.resolved < 0.5 { + 0.14 + 0.07 * (clock.time * 2.2).sin() + } else { + visual_voice(drive.level) + }; + // Fast attack, slow release — VU-meter feel. + let attack = if target > clock.level { 14.0 } else { 5.0 }; + clock.level += (target - clock.level) * (1.0 - (-dt * attack).exp()); + clock.resolved += (drive.resolved - clock.resolved) * (1.0 - (-dt * 3.0).exp()); + ctx.data_mut(|data| data.insert_temp(key, clock)); + clock +} + +/// Queue the GPU glow for `rect`. +/// +/// Returns `true` when the caller must *not* draw its CPU fallback: that is the +/// case only after the program has compiled and drawn successfully once, so the +/// first frames paint both (the callback is a no-op until it has a program, so +/// nothing is double-drawn) and a broken driver keeps the old look forever. +pub fn paint(ui: &egui::Ui, rect: egui::Rect, glow: SiriGlow) -> bool { + if !rect.is_positive() || !rect.is_finite() { + return false; + } + let painter = ui.painter().with_clip_rect(rect); + let color = |rgb: [f32; 3], alpha: f32| { + egui::Color32::from_rgba_unmultiplied( + (rgb[0].clamp(0.0, 1.0) * 255.0) as u8, + (rgb[1].clamp(0.0, 1.0) * 255.0) as u8, + (rgb[2].clamp(0.0, 1.0) * 255.0) as u8, + (alpha.clamp(0.0, 1.0) * 255.0) as u8, + ) + }; + match glow.mode { + SiriMode::Wave => { + let amplitude = + (rect.height() * (0.10 + glow.level * 0.36)).clamp(2.0, rect.height() * 0.48); + let center_y = rect.center().y; + let hues = [ + [0.35, 0.74, 1.0], + [0.45, 0.45, 1.0], + [0.95, 0.48, 0.94], + [1.0, 0.55, 0.72], + ]; + for (index, hue) in hues.into_iter().enumerate() { + let phase = index as f32 * 0.72; + let points = (0..=48) + .map(|step| { + let t = step as f32 / 48.0; + let envelope = (std::f32::consts::PI * t).sin().powf(0.7); + let y = center_y + + (glow.time * 2.1 + t * 10.0 + phase).sin() * amplitude * envelope; + egui::pos2(rect.left() + rect.width() * t, y) + }) + .collect::>(); + // Stack a broad, low-alpha halo beneath the crisp filament. + // A single opaque polyline reads as neon wire; Siri's reference + // has a soft colored bloom around a bright, thin wave. + painter.add(egui::Shape::line( + points.clone(), + egui::Stroke::new(7.0 + glow.level * 3.0, color(hue, 0.055)), + )); + painter.add(egui::Shape::line( + points.clone(), + egui::Stroke::new(3.8 + glow.level * 1.5, color(hue, 0.14)), + )); + painter.add(egui::Shape::line( + points, + egui::Stroke::new(if index == 1 { 1.8 } else { 1.2 }, color(hue, 0.72)), + )); + } + } + SiriMode::Orb => { + let gather = glow.gather.clamp(0.0, 1.0); + for index in 0..7 { + let angle = glow.time * 0.8 + index as f32 * std::f32::consts::TAU / 7.0; + let radius = rect.width().min(rect.height()) * (0.06 + (1.0 - gather) * 0.24); + let point = + rect.center() + egui::vec2(angle.cos() * radius, angle.sin() * radius * 0.42); + let hue = [[0.35, 0.78, 1.0], [0.63, 0.52, 1.0], [1.0, 0.49, 0.82]][index % 3]; + let dot_radius = 2.2 + (0.5 + (glow.time * 2.0 + index as f32).sin() * 0.5) * 1.8; + painter.circle_filled(point, dot_radius * 2.6, color(hue, 0.075)); + painter.circle_filled(point, dot_radius * 1.55, color(hue, 0.24)); + painter.circle_filled(point, dot_radius, color(hue, 0.92)); + } + } + SiriMode::Ring => { + let color = color(glow.tint, 0.9); + painter.rect_stroke( + rect, + egui::CornerRadius::same(glow.radius.round().clamp(0.0, 255.0) as u8), + egui::Stroke::new(glow.thickness.max(1.0), color), + egui::StrokeKind::Inside, + ); + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + /// 着色器路径已从渲染路径上摘除(见本文件第 7 行的 `allow(dead_code)`), + /// `paint` 现在直接用 CPU 形状画出每一种光效,并恒返回 `true`(调用方不得 + /// 再画自己的回退)。等 wgpu 光效落地后,这里要恢复成「GPU 回调 + 所有权」 + /// 的断言。 + #[test] + fn paint_draws_every_mode_on_the_cpu_and_claims_ownership() { + let ctx = egui::Context::default(); + let mut modes = 0; + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(320.0, 240.0), + )), + ..Default::default() + }, + |ui| { + for glow in [ + SiriGlow::wave(0.5, 0.3), + SiriGlow::orb(0.5, 1.0), + SiriGlow::ring(0.5, 12.0, 2.0), + ] { + assert!( + paint(ui, ui.max_rect(), glow), + "{:?} must paint its CPU fallback and claim the centre", + glow.mode + ); + modes += 1; + } + }, + ); + assert_eq!(modes, 3, "every mode must be exercised"); + assert!( + !output.shapes.is_empty(), + "the glow must still reach the frame as CPU shapes" + ); + let callbacks = output + .shapes + .iter() + .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) + .count(); + assert_eq!( + callbacks, 0, + "the shader path is off: nothing may queue a GPU callback" + ); + } + + #[test] + fn clock_smooths_level_time_and_speed() { + let ctx = egui::Context::default(); + let start = tick(&ctx, "test", SiriDrive::default(), 1.0 / 60.0); + assert!( + (start.time - 1.0 / 60.0).abs() < 1e-5, + "one frame of dt*1.0 speed: {}", + start.time + ); + assert_eq!(start.level, 0.0, "level starts from the stored clock"); + let mut clock = start; + for _ in 0..30 { + clock = tick( + &ctx, + "test", + SiriDrive { + level: 0.5, + ..Default::default() + }, + 1.0 / 60.0, + ); + } + assert!( + clock.time > 0.4 && clock.time < 0.6, + "0.5s of frames: {}", + clock.time + ); + assert!(clock.level > 0.0, "level follows the drive"); + assert!(clock.level <= visual_voice(0.5) + f32::EPSILON); + // A speed change is eased into the accumulated time (no jump). + let before = clock.time; + let after = tick( + &ctx, + "test", + SiriDrive { + speed: 3.0, + ..Default::default() + }, + 1.0 / 60.0, + ); + assert!( + after.time - before < 0.06, + "speed eased, not applied at once" + ); + } + + #[test] + fn visual_voice_gates_and_eases() { + assert_eq!(visual_voice(0.0), 0.0); + assert_eq!(visual_voice(0.012), 0.0, "noise gate"); + assert_eq!(visual_voice(0.34), 1.0, "ceiling maps to a full bar"); + let mid = visual_voice(0.18); + assert!( + mid > 0.4 && mid < 1.0, + "curve stays inside the unit range: {mid}" + ); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/style.rs b/openless-all/app/linux-egui/src/ui/frontend/style.rs new file mode 100644 index 000000000..9e9ec99d3 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/style.rs @@ -0,0 +1,1756 @@ +//! Style (润色模式) page — port of the Tauri `pages/Style.tsx`. +//! +//! One card holds the pack list: a header row with the raw-mode entry, the +//! dictation/selection workflow switch and a pack counter, followed by a grid +//! of style-pack cards plus a "new pack" tile. The pack editor opens as a +//! floating window. +//! +//! The card's highlighted state comes from `StylePack::is_active` only — the +//! pack the host reports as current. The page-local `style_selected` index is +//! used solely for the raw-mode tab, so a stale index can no longer paint a +//! second card as active. + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; + +use super::icons::{self, IconName}; +use super::layout::{self, ButtonKind, PillTone}; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, StylePack}; + +const GAP: f32 = 12.0; +const CARD_PADDING: f32 = 20.0; +const PACK_CARD_HEIGHT: f32 = 232.0; +const PACK_CARD_PADDING: f32 = 16.0; + +pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + if vm.style_unsupported { + layout::unsupported_page(ui, lang, tr_l10n(lang, "style.title")); + return; + } + + header(ui, width, vm, actions); + ui.add_space(GAP); + + // The list card swallows the remaining height; its grid scrolls inside. + let card_height = ui.available_height().max(PACK_CARD_HEIGHT + 96.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, card_height), egui::Sense::hover()); + layout::card(ui, rect, CARD_PADDING, |ui, inner| { + let grid_top = list_header(ui, inner, vm, actions); + pack_grid(ui, inner, grid_top, vm, actions); + }); + + notice(ui, width, vm); + editor_overlay(ui.ctx(), vm, actions); +} + +// ── Header ────────────────────────────────────────────────────────────────── + +fn header( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let rect = layout::page_header( + ui, + width, + tr_l10n(lang, "style.kicker"), + tr_l10n(lang, "style.title"), + Some(tr_l10n(lang, "style.desc")), + ); + + let import = tr_l10n(lang, "style.pack.import_zip"); + let import_width = layout::text_width(ui, import, 12.5) + 46.0; + let import_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - import_width, rect.top() + 22.0), + egui::vec2(import_width, 30.0), + ); + if layout::action_button( + ui, + import_rect, + import, + Some(IconName::Upload), + ButtonKind::Blue, + ) + .clicked() + { + actions.push(FrontendAction::StyleImport); + } + + let refresh = tr_l10n(lang, "common.refresh"); + let refresh_width = layout::text_width(ui, refresh, 12.5) + 40.0; + let refresh_rect = egui::Rect::from_min_size( + egui::pos2(import_rect.left() - 8.0 - refresh_width, rect.top() + 22.0), + egui::vec2(refresh_width, 30.0), + ); + if layout::action_button( + ui, + refresh_rect, + refresh, + Some(IconName::Refresh), + ButtonKind::Ghost, + ) + .clicked() + { + vm.style_notice = None; + } +} + +// ── List card ─────────────────────────────────────────────────────────────── + +/// Draws the card header (title, raw tab, workflow switch, counter) and returns +/// the y coordinate where the pack grid starts. +fn list_header( + ui: &mut egui::Ui, + inner: egui::Rect, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) -> f32 { + let lang = vm.lang; + let row = egui::Rect::from_min_size(inner.min, egui::vec2(inner.width(), 46.0)); + let painter = ui.painter().with_clip_rect(inner); + + painter.text( + egui::pos2(row.left(), row.top() + 11.0), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "style.pack.list_title"), + egui::FontId::proportional(15.0), + theme::INK, + ); + + painter.text( + egui::pos2(row.left(), row.top() + 31.0), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "style.pack.list_desc"), + egui::FontId::proportional(12.0), + theme::INK_3, + ); + + // The raw pack is adjacent to the left title, not in the right-hand toolbar. + let raw_index = vm + .style_packs + .iter() + .position(|pack| pack.id == "builtin.raw"); + let raw_active = !vm.style_selection_workflow + && raw_index.is_some_and(|index| vm.style_packs[index].is_active); + let raw_label = if raw_active { + format!( + "{} · {}", + tr_l10n(lang, "overview.mode_raw"), + tr_l10n(lang, "style.pack.current") + ) + } else { + tr_l10n(lang, "overview.mode_raw").to_string() + }; + let raw_width = layout::text_width(ui, &raw_label, 12.0) + 24.0; + let raw_rect = egui::Rect::from_min_size( + egui::pos2( + row.left() + + layout::text_width(ui, tr_l10n(lang, "style.pack.list_title"), 15.0) + + 14.0, + row.top(), + ), + egui::vec2(raw_width, 24.0), + ); + let raw_response = ui.interact( + raw_rect, + ui.id().with("style-raw-tab"), + egui::Sense::click(), + ); + painter.rect_filled( + raw_rect, + egui::CornerRadius::same(12), + if raw_active || raw_response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + painter.rect_stroke( + raw_rect, + egui::CornerRadius::same(12), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + painter.text( + raw_rect.center(), + egui::Align2::CENTER_CENTER, + &raw_label, + egui::FontId::proportional(12.0), + if raw_active { theme::INK } else { theme::INK_3 }, + ); + if raw_response.clicked() && !raw_active { + if let Some(index) = raw_index { + vm.style_selection_workflow = false; + actions.push(FrontendAction::StyleActivate(index)); + } + } + + // Workflow switch + pack counter, right aligned. + let count = fmt_l10n(lang, "style.pack.list_count", &[&vm.style_packs.len()]); + let count_size = layout::pill_size(ui, &count); + let count_rect = egui::Rect::from_min_size( + egui::pos2( + row.right() - count_size.x, + row.center().y - count_size.y / 2.0, + ), + count_size, + ); + layout::paint_pill(&painter, count_rect, &count, PillTone::Outline); + + let options = [ + tr_l10n(lang, "style.pack.dictation_tab"), + tr_l10n(lang, "style.pack.selection_tab"), + ]; + let tabs_width = layout::segmented_width(ui, &options); + let tabs_rect = egui::Rect::from_min_size( + egui::pos2(count_rect.left() - 10.0 - tabs_width, row.center().y - 13.0), + egui::vec2(tabs_width, 26.0), + ); + let selected = usize::from(vm.style_selection_workflow); + ui.painter().rect_filled( + tabs_rect.expand(3.0), + egui::CornerRadius::same(9), + theme::SURFACE_2, + ); + if let Some(index) = layout::segmented(ui, tabs_rect, &options, selected) { + vm.style_selection_workflow = index == 1; + } + + let separator_y = row.bottom() + 14.0; + painter.line_segment( + [ + egui::pos2(inner.left(), separator_y), + egui::pos2(inner.right(), separator_y), + ], + egui::Stroke::new(0.5, theme::LINE), + ); + separator_y + 14.0 +} + +/// Grid of pack cards plus the "new pack" tile, scrolling inside the card. +fn pack_grid( + ui: &mut egui::Ui, + inner: egui::Rect, + grid_top: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let grid = egui::Rect::from_min_max( + egui::pos2(inner.left(), grid_top), + egui::pos2(inner.right(), inner.bottom()), + ); + if grid.height() < 8.0 { + return; + } + layout::fixed_ui(ui, grid, ("openless-style-grid",), |ui| { + egui::ScrollArea::vertical() + .id_salt("style-packs-scroll") + .auto_shrink([false, false]) + .show(ui, |ui| { + let grid_width = ui.available_width(); + let columns = if grid_width >= 820.0 { + 3 + } else if grid_width >= 560.0 { + 2 + } else { + 1 + }; + let card_width = + ((grid_width - GAP * (columns - 1) as f32) / columns as f32).max(1.0); + let pack_indices: Vec = vm + .style_packs + .iter() + .enumerate() + .filter(|(_, pack)| pack.id != "builtin.raw") + .map(|(index, _)| index) + .collect(); + let tiles = pack_indices.len() + 1; + let mut row_start = 0; + while row_start < tiles { + let row_end = (row_start + columns).min(tiles); + let (row_rect, _) = ui.allocate_exact_size( + egui::vec2(grid_width, PACK_CARD_HEIGHT), + egui::Sense::hover(), + ); + for slot in row_start..row_end { + let rect = egui::Rect::from_min_size( + egui::pos2( + row_rect.left() + (slot - row_start) as f32 * (card_width + GAP), + row_rect.top(), + ), + egui::vec2(card_width, PACK_CARD_HEIGHT), + ); + if slot == pack_indices.len() { + new_pack_tile(ui, rect, lang, actions); + } else { + let index = pack_indices[slot]; + let pack = vm.style_packs[index].clone(); + style_pack_card( + ui, + rect, + &pack, + index, + vm.style_selection_workflow, + lang, + actions, + ); + } + } + ui.add_space(GAP); + row_start = row_end; + } + }); + }); +} + +/// Tauri `stylePackPresentation.ts`: the four shipped packs are displayed with +/// the *localized* mode copy instead of the Chinese text baked into Core. +/// +/// A pack only gets that treatment while it still carries the shipped name / +/// description / tags, so a user's rename, edit or tag change is never +/// overwritten by a translation. +fn is_shipped_builtin(pack: &StylePack) -> bool { + pack.is_builtin + && pack.id + == openless_core::builtin_style_pack_id(match pack.base_mode.as_str() { + "raw" => openless_core::PolishMode::Raw, + "light" => openless_core::PolishMode::Light, + "structured" => openless_core::PolishMode::Structured, + "formal" => openless_core::PolishMode::Formal, + _ => return false, + }) +} + +fn mode_of(pack: &StylePack) -> Option { + Some(match pack.base_mode.as_str() { + "raw" => openless_core::PolishMode::Raw, + "light" => openless_core::PolishMode::Light, + "structured" => openless_core::PolishMode::Structured, + "formal" => openless_core::PolishMode::Formal, + _ => return None, + }) +} + +/// Localized card title: the mode name for a pristine builtin pack, otherwise +/// the stored name. +fn pack_display_name(lang: Lang, pack: &StylePack) -> String { + if is_shipped_builtin(pack) { + if let Some(mode) = mode_of(pack) { + let shipped = openless_core::builtin_style_pack_for_mode(mode); + if pack.name == shipped.name { + return openless_linux_egui::tr_l10n( + lang, + match mode { + openless_core::PolishMode::Raw => "overview.mode_raw", + openless_core::PolishMode::Light => "overview.mode_light", + openless_core::PolishMode::Structured => "overview.mode_structured", + openless_core::PolishMode::Formal => "overview.mode_formal", + }, + ) + .to_string(); + } + } + } + pack.name.clone() +} + +/// Localized description, but only while the pack still carries the shipped one. +fn pack_display_description(lang: Lang, pack: &StylePack) -> String { + if is_shipped_builtin(pack) { + if let Some(mode) = mode_of(pack) { + let shipped = openless_core::builtin_style_pack_for_mode(mode); + if pack.description == shipped.description { + return openless_linux_egui::tr_l10n( + lang, + match mode { + openless_core::PolishMode::Raw => "style.modes.raw.desc", + openless_core::PolishMode::Light => "style.modes.light.desc", + openless_core::PolishMode::Structured => "style.modes.structured.desc", + openless_core::PolishMode::Formal => "style.modes.formal.desc", + }, + ) + .to_string(); + } + } + } + pack.description.clone() +} + +/// Localized tags: shipped tags map onto catalog keys, edited ones stay as-is. +fn pack_display_tags(lang: Lang, pack: &StylePack) -> Vec { + let Some(mode) = is_shipped_builtin(pack).then(|| mode_of(pack)).flatten() else { + return pack.tags.clone(); + }; + let shipped = openless_core::builtin_style_pack_for_mode(mode); + pack.tags + .iter() + .map(|tag| { + let Some(position) = shipped.tags.iter().position(|shipped| shipped == tag) else { + return tag.clone(); + }; + let key = match (mode, position) { + (openless_core::PolishMode::Raw, 0) => "overview.mode_raw", + (openless_core::PolishMode::Raw, _) => "style.pack.builtinTags.minimalEdits", + (openless_core::PolishMode::Light, 0) => "overview.mode_light", + (openless_core::PolishMode::Light, _) => "style.pack.builtinTags.strongCorrection", + (openless_core::PolishMode::Structured, 0) => "style.pack.builtinTags.aiCoding", + (openless_core::PolishMode::Structured, _) => { + "style.pack.builtinTags.technicalStructure" + } + (openless_core::PolishMode::Formal, 0) => "overview.mode_formal", + (openless_core::PolishMode::Formal, _) => "style.pack.builtinTags.strongCorrection", + }; + openless_linux_egui::tr_l10n(lang, key).to_string() + }) + .collect() +} + +/// One style-pack card. Highlighted only when the host reports it as active. +fn style_pack_card( + ui: &mut egui::Ui, + rect: egui::Rect, + pack: &StylePack, + index: usize, + selection_workflow: bool, + lang: Lang, + actions: &mut Vec, +) { + // The active pack depends on the workflow tab: + // dictation/ASR or selection polish. + let active = if selection_workflow { + pack.selection_active + } else { + pack.is_active + }; + let response = ui.interact( + rect, + ui.id().with(("style-pack-card", index)), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(rect); + let (fill, border, border_width) = if active { + (theme::BLUE_SOFT, theme::BLUE, 1.0) + } else if response.hovered() { + (theme::SURFACE_2, theme::LINE, 1.0) + } else { + (theme::SURFACE, theme::LINE, 1.0) + }; + painter.rect_filled(rect, egui::CornerRadius::same(14), fill); + painter.rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(border_width, border), + egui::StrokeKind::Inside, + ); + + let inner = rect.shrink(PACK_CARD_PADDING); + let lang_name = pack_display_name(lang, pack); + + // Icon picker (Tauri `StylePackIconPicker`). It is the *last* item of the + // header row — the row is `justify-content: space-between`, so the tile is + // pinned to the right edge and the text runs left, not the other way round. + let icon_size = 40.0; + let icon_rect = egui::Rect::from_min_size( + egui::pos2(inner.right() - icon_size, inner.top()), + egui::vec2(icon_size, icon_size), + ); + let icon_response = ui.interact( + icon_rect, + ui.id().with(("style-icon", index)), + egui::Sense::click(), + ); + // `.ol-style-icon-button`: 40×40, 1px `--ol-line-strong`, radius 11, and a + // grey fill; a stored icon turns the fill opaque. + painter.rect_filled( + icon_rect, + egui::CornerRadius::same(11), + if icon_response.hovered() { + theme::STYLE_CARD_ICON_BG_ACTIVE + } else if pack.icon_data_url.is_some() { + theme::SURFACE_2 + } else { + theme::STYLE_CARD_ICON_BG + }, + ); + painter.rect_stroke( + icon_rect, + egui::CornerRadius::same(11), + egui::Stroke::new(1.0, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + if let Some(texture) = pack + .icon_data_url + .as_deref() + .and_then(|url| layout::style_pack_icon_texture(ui.ctx(), &pack.id, url)) + { + painter.image( + texture.id(), + egui::Rect::from_center_size(icon_rect.center(), egui::vec2(24.0, 24.0)), + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + } else { + let default = match pack.base_mode.as_str() { + "raw" => IconName::Mic, + "light" => IconName::Feather, + "structured" => IconName::Layout, + _ => IconName::Doc, + }; + icons::draw_icon(ui, icon_rect.center(), default, theme::INK_2); + } + // `.ol-style-icon-edit`: a 16×16 pencil badge hanging off the bottom-right + // corner (right/bottom: -3px). + let pencil_rect = egui::Rect::from_center_size( + icon_rect.right_bottom() - egui::vec2(5.0, 5.0), + egui::vec2(16.0, 16.0), + ); + painter.rect_filled(pencil_rect, egui::CornerRadius::same(5), theme::SURFACE); + painter.rect_stroke( + pencil_rect, + egui::CornerRadius::same(5), + egui::Stroke::new(1.0, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + icons::draw_icon(ui, pencil_rect.center(), IconName::Pencil, theme::INK_2); + #[cfg(test)] + ui.ctx().data_mut(|data| { + let key = egui::Id::new("openless-style-card-icon-rects"); + let mut published: Vec<(usize, egui::Rect, egui::Rect, String)> = + data.get_temp(key).unwrap_or_default(); + published.retain(|(slot, ..)| *slot != index); + published.push((index, icon_rect, inner, lang_name.clone())); + data.insert_temp(key, published); + }); + if icon_response + .on_hover_text(fmt_l10n(lang, "style.pack.uploadIcon", &[&lang_name])) + .clicked() + { + actions.push(FrontendAction::StyleChooseIcon(index)); + } + if pack.icon_data_url.is_some() { + // `.ol-style-icon-reset`: right/top -5px. + let reset_rect = egui::Rect::from_min_size( + egui::pos2(inner.right() - 16.0 + 5.0, inner.top() - 5.0), + egui::vec2(16.0, 16.0), + ); + painter.rect_filled(reset_rect, egui::CornerRadius::same(5), theme::SURFACE); + painter.rect_stroke( + reset_rect, + egui::CornerRadius::same(5), + egui::Stroke::new(1.0, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + icons::draw_icon(ui, reset_rect.center(), IconName::Close, theme::INK_2); + if ui + .interact( + reset_rect, + ui.id().with(("style-icon-reset", index)), + egui::Sense::click(), + ) + .on_hover_text(tr_l10n(lang, "style.pack.resetIcon")) + .clicked() + { + actions.push(FrontendAction::StyleResetIcon(index)); + } + } + + // Name, then the builtin / current badges. Tauri's header is 15px/600. + let text_right = icon_rect.left() - 8.0; + let mut x = inner.left(); + painter.text( + egui::pos2(x, inner.top()), + egui::Align2::LEFT_TOP, + &lang_name, + theme::medium_font(15.0), + theme::INK, + ); + x += layout::text_width(ui, &lang_name, 15.0) + 8.0; + for (text, tone) in std::iter::once(( + if pack.is_builtin { + tr_l10n(lang, "style.pack.builtin") + } else { + tr_l10n(lang, "style.pack.imported") + }, + PillTone::Outline, + )) + .chain(active.then_some((tr_l10n(lang, "style.pack.current"), PillTone::Gray))) + { + let size = layout::pill_size(ui, text); + if x + size.x > text_right { + break; + } + layout::paint_pill( + &painter, + egui::Rect::from_min_size(egui::pos2(x, inner.top() + 2.0), size), + text, + tone, + ); + x += size.x + 6.0; + } + + // Description: 12.5px, three lines, in the text column beside the tile. + let description = pack_display_description(lang, pack); + let description_width = (text_right - inner.left()).max(80.0); + let galley = layout::text_galley(ui, &description, theme::INK_3, 12.5, description_width, 3); + let description_top = inner.top() + icon_size + 8.0; + let description_height = galley.size().y.max(60.0); + painter.galley( + egui::pos2(inner.left(), description_top), + galley.clone(), + theme::INK_3, + ); + + // Tags row: the workflow's mode pill, then the pack's first tag — both the + // plain `default` tone used by Tauri's `Pill`. + let mode_pill = if selection_workflow { + tr_l10n(lang, "style.pack.writtenPolish").to_string() + } else { + mode_of(pack) + .map(|mode| { + tr_l10n( + lang, + match mode { + openless_core::PolishMode::Raw => "overview.mode_raw", + openless_core::PolishMode::Light => "overview.mode_light", + openless_core::PolishMode::Structured => "overview.mode_structured", + openless_core::PolishMode::Formal => "overview.mode_formal", + }, + ) + .to_string() + }) + .unwrap_or_else(|| pack.name.clone()) + }; + let mut pill_x = inner.left(); + let pill_y = description_top + description_height + 12.0; + for tag in std::iter::once(mode_pill).chain(pack_display_tags(lang, pack).into_iter().take(1)) { + let size = layout::pill_size(ui, &tag); + if pill_x + size.x > inner.right() { + break; + } + layout::paint_pill( + &painter, + egui::Rect::from_min_size(egui::pos2(pill_x, pill_y), size), + &tag, + PillTone::Gray, + ); + pill_x += size.x + 8.0; + } + + // Actions row. + let button_height = 28.0; + let button_y = inner.bottom() - button_height; + let mut button_x = inner.left(); + let primary = if active { + tr_l10n(lang, "style.pack.current") + } else { + tr_l10n(lang, "style.pack.activate") + }; + let primary_width = layout::text_width(ui, primary, 11.5) + 24.0; + let primary_rect = egui::Rect::from_min_size( + egui::pos2(button_x, button_y), + egui::vec2(primary_width, button_height), + ); + button_x += primary_width + 6.0; + if layout::action_button( + ui, + primary_rect, + primary, + None, + if active { + ButtonKind::Ghost + } else { + ButtonKind::Blue + }, + ) + .clicked() + && !active + { + actions.push(FrontendAction::StyleActivate(index)); + } + + let export = tr_l10n(lang, "style.pack.export_short"); + let export_width = layout::text_width(ui, export, 11.5) + 24.0; + let export_rect = egui::Rect::from_min_size( + egui::pos2(button_x, button_y), + egui::vec2(export_width, button_height), + ); + button_x += export_width + 6.0; + if layout::action_button(ui, export_rect, export, None, ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::StyleExport(index)); + } + + let edit = tr_l10n(lang, "style.pack.edit"); + let edit_width = layout::text_width(ui, edit, 11.5) + 24.0; + let edit_rect = egui::Rect::from_min_size( + egui::pos2(button_x, button_y), + egui::vec2(edit_width, button_height), + ); + if layout::action_button(ui, edit_rect, edit, None, ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::StyleEdit(index)); + } +} + +/// The dashed "add pack" tile closing the grid. +fn new_pack_tile( + ui: &mut egui::Ui, + rect: egui::Rect, + lang: Lang, + actions: &mut Vec, +) { + let response = ui.interact( + rect, + ui.id().with("style-new-pack-tile"), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(rect); + painter.rect_filled( + rect, + egui::CornerRadius::same(14), + if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + painter.rect_stroke( + rect, + egui::CornerRadius::same(14), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + let center = rect.center(); + let stroke = egui::Stroke::new(1.4, theme::INK_4); + painter.line_segment( + [ + egui::pos2(center.x - 8.0, center.y - 12.0), + egui::pos2(center.x + 8.0, center.y - 12.0), + ], + stroke, + ); + painter.line_segment( + [ + egui::pos2(center.x, center.y - 20.0), + egui::pos2(center.x, center.y - 4.0), + ], + stroke, + ); + painter.text( + egui::pos2(center.x, center.y + 16.0), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "style.pack.add_pack_tile_title"), + egui::FontId::proportional(13.0), + theme::INK, + ); + painter.text( + egui::pos2(center.x, center.y + 36.0), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "style.pack.add_pack_tile_hint"), + egui::FontId::proportional(11.5), + theme::INK_4, + ); + if response.clicked() { + actions.push(FrontendAction::StyleNewPack); + } +} + +// ── Notice ────────────────────────────────────────────────────────────────── + +fn notice(ui: &mut egui::Ui, width: f32, vm: &mut FrontendViewModel) { + let Some(text) = vm.style_notice.clone() else { + return; + }; + ui.add_space(10.0); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 26.0), egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left(), rect.center().y), + egui::Align2::LEFT_CENTER, + "✓", + egui::FontId::proportional(13.0), + theme::OK, + ); + painter.text( + egui::pos2(rect.left() + 18.0, rect.center().y), + egui::Align2::LEFT_CENTER, + &text, + egui::FontId::proportional(11.5), + theme::INK_2, + ); + let dismiss = egui::Rect::from_min_size( + egui::pos2( + rect.left() + 18.0 + layout::text_width(ui, &text, 11.5) + 10.0, + rect.top() + 3.0, + ), + egui::vec2(20.0, 20.0), + ); + let response = ui.interact( + dismiss, + ui.id().with("style-notice-dismiss"), + egui::Sense::click(), + ); + if response.hovered() { + ui.painter() + .rect_filled(dismiss, egui::CornerRadius::same(6), theme::SURFACE_2); + } + painter.text( + dismiss.center(), + egui::Align2::CENTER_CENTER, + "×", + egui::FontId::proportional(12.0), + theme::INK_3, + ); + if response.clicked() { + vm.style_notice = None; + } +} + +// ── Editor ────────────────────────────────────────────────────────────────── + +/// Style-pack editor: the upstream Beta.2 drawer, not a centred modal. +/// +/// Tauri `Style.tsx` renders the editor as a right-hand panel +/// (`top/right/bottom: 16`, `width: min(760px, 100vw - 32px)`) with a fixed +/// header (title, workflow-specific description, close) and one scrolling body +/// holding the pills, the fields, the prompt editors and the footer actions. +const DRAWER_WIDTH: f32 = 760.0; +const DRAWER_INSET: f32 = 16.0; +const FIELD_GAP: f32 = 12.0; +// egui also inserts item spacing between stacked widgets. The vertical gap +// below yields the same form rhythm as Tauri's 16px section gap. +const SECTION_GAP: f32 = 20.0; + +fn editor_overlay( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + if !vm.style_editor_open { + return; + } + let body = layout::body_rect(ctx); + let body = egui::Rect::from_min_max( + egui::pos2(body.left() + layout::SIDEBAR_WIDTH, body.top()), + body.max, + ); + let width = DRAWER_WIDTH + .min(body.width() - DRAWER_INSET * 2.0) + .max(240.0); + let card_rect = egui::Rect::from_min_max( + egui::pos2( + body.right() - DRAWER_INSET - width, + body.top() + DRAWER_INSET, + ), + egui::pos2(body.right() - DRAWER_INSET, body.bottom() - DRAWER_INSET), + ); + // 卡片实际落点写进 memory,供测试查询(Area 覆盖整个 body,面积已不等于卡片). + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("openless-style-editor-card-rect"), card_rect) + }); + // 遮罩、点击拦截与卡片必须同属**一个** `Area`(同一个 LayerId):各自独立 Area 时, + // egui 会在按下后把被点到的 Area 抬到同层最上(`move_to_top`),遮罩被抬起就会盖住 + // 卡片;同一图层里先画遮罩、再画卡片在结构上就不可能出现。这里用 `Foreground` 而不是 + // `Tooltip`:弹窗若占 Tooltip,会盖住同层弹出的下拉/菜单(跨 Order 是 Tooltip > + // Foreground)。 + egui::Area::new(egui::Id::new("openless-style-editor-modal")) + .order(egui::Order::Foreground) + .fixed_pos(body.min) + .constrain(false) + .show(ctx, |ui| { + ui.set_min_size(body.size()); + // Like Settings, sample this frame's GPU-blurred page and apply the + // macOS overlay tint. Both layers obey the content area's corners. + // The drawer starts after the sidebar, so its lower-left edge is + // an internal seam and must stay square. + layout::paint_blurred_overlay( + ctx, + ui, + body, + layout::content_overlay_corner_radius(ctx), + ); + // 点击拦截:吃掉 body 上的点击,下方页面既看不到也点不到。 + let _ = ui.allocate_rect(body, egui::Sense::click()); + ui.scope_builder(egui::UiBuilder::new().max_rect(card_rect), |ui| { + ui.set_clip_rect(body.intersect(ui.clip_rect())); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .shadow(egui::Shadow { + offset: [0, 12], + blur: 28, + spread: 0, + color: egui::Color32::from_black_alpha(42), + }) + .show(ui, |ui| { + ui.set_min_size(card_rect.size()); + ui.set_max_size(card_rect.size()); + let inner = egui::Rect::from_min_size( + card_rect.min, + egui::vec2(card_rect.width(), card_rect.height()), + ); + let header_height = drawer_header(ui, inner, vm, actions); + let content = egui::Rect::from_min_max( + egui::pos2(inner.left(), inner.top() + header_height), + inner.max, + ); + // Header is painted at a fixed rectangle and does not advance + // egui's layout cursor. Anchor the scrollable body explicitly + // below it; otherwise the form starts under the header and + // ends one header-height before the drawer bottom. + ui.scope_builder( + egui::UiBuilder::new() + .max_rect(content) + .layout(egui::Layout::top_down(egui::Align::Min)), + |ui| { + ui.set_clip_rect(content.intersect(ui.clip_rect())); + drawer_body(ui, content, vm, actions); + }, + ); + }); + }); + }); +} + +/// Fixed drawer header. Returns its height so the body can start below it. +fn drawer_header( + ui: &mut egui::Ui, + inner: egui::Rect, + vm: &FrontendViewModel, + actions: &mut Vec, +) -> f32 { + let lang = vm.lang; + let height = 78.0; + let rect = egui::Rect::from_min_size(inner.min, egui::vec2(inner.width(), height)); + let painter = ui.painter().with_clip_rect(rect); + let text_left = rect.left() + 18.0; + let text_width = (rect.width() - 36.0 - 34.0).max(80.0); + painter.text( + egui::pos2(text_left, rect.top() + 18.0), + egui::Align2::LEFT_TOP, + tr_l10n(lang, "style.pack.editorTitle"), + egui::FontId::proportional(15.0), + theme::INK, + ); + // Two literal call sites on purpose: the i18n sync only registers catalog + // keys it can find as a string literal next to `tr_l10n(…,`. + let description = if vm.style_selection_workflow { + tr_l10n(lang, "style.pack.selectionPromptEditorDesc") + } else { + tr_l10n(lang, "style.pack.dictationPromptEditorDesc") + }; + let desc = layout::text_galley(ui, description, theme::INK_3, 12.0, text_width, 3); + painter.galley(egui::pos2(text_left, rect.top() + 40.0), desc, theme::INK_3); + // 28px circular close button, top-right. + let close = egui::Rect::from_min_size( + egui::pos2(rect.right() - 18.0 - 28.0, rect.top() + 14.0), + egui::vec2(28.0, 28.0), + ); + if ui + .interact( + close, + ui.id().with("style-editor-close"), + egui::Sense::click(), + ) + .on_hover_text(tr_l10n(lang, "style.pack.closeEditor")) + .clicked() + { + actions.push(FrontendAction::StyleCloseEditor); + } + icons::draw_icon(ui, close.center(), IconName::Close, theme::INK_3); + painter.line_segment( + [ + egui::pos2(rect.left(), rect.bottom()), + egui::pos2(rect.right(), rect.bottom()), + ], + egui::Stroke::new(0.5, theme::LINE), + ); + height +} + +fn drawer_body( + ui: &mut egui::Ui, + content: egui::Rect, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let field_width = (content.width() - 36.0 - FIELD_GAP) / 2.0; + let scroll = egui::ScrollArea::vertical() + .id_salt("style-editor-fields") + .max_width(content.width()) + .max_height(content.height().max(120.0)) + .auto_shrink([false, false]) + .show(ui, |ui| { + // Match the Tauri drawer's 18px scroll-body padding. A width + // constraint alone leaves every field hard against the card edge. + egui::Frame::new() + .inner_margin(egui::Margin::symmetric(18, 0)) + .show(ui, |ui| { + ui.set_width((content.width() - 36.0).max(120.0)); + ui.spacing_mut().item_spacing.x = FIELD_GAP; + ui.add_space(18.0); + pills_row(ui, content, vm, actions); + ui.add_space(SECTION_GAP); + editor_identity_grid(ui, vm); + ui.add_space(SECTION_GAP); + field_label(ui, tr_l10n(lang, "style.pack.fieldDescription")); + editor_textarea(ui, &mut vm.style_description, 86.0, "description"); + ui.add_space(SECTION_GAP); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(field_width); + field_label(ui, tr_l10n(lang, "style.pack.fieldModel")); + ui.add_sized( + [ui.available_width(), 38.0], + editor_input(&mut vm.style_model) + .hint_text(tr_l10n(lang, "style.pack.fieldModelPlaceholder")), + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "style.pack.fieldModelHint")) + .size(11.5) + .color(theme::INK_4), + ); + }); + ui.vertical(|ui| { + ui.set_width(field_width); + field_label(ui, tr_l10n(lang, "style.pack.fieldCompatibility")); + ui.add_sized( + [ui.available_width(), 38.0], + editor_input(&mut vm.style_compatible_version).hint_text(tr_l10n( + lang, + "style.pack.fieldCompatibilityPlaceholder", + )), + ); + }); + }); + ui.add_space(SECTION_GAP + 4.0); + + // The two workflows read different prompt slots from one pack, so the + // drawer only edits the one the page is currently showing. + if vm.style_selection_workflow { + prompt_field( + ui, + tr_l10n(lang, "style.pack.selectionPromptTitle"), + Some(tr_l10n(lang, "style.pack.selectionPromptHint")), + &mut vm.style_selection_prompt, + 150.0, + "selection-prompt", + ); + ui.add_space(SECTION_GAP); + prompt_field( + ui, + tr_l10n(lang, "style.pack.voiceEditPromptTitle"), + None, + &mut vm.style_voice_edit_prompt, + 150.0, + "voice-edit-prompt", + ); + } else { + prompt_field( + ui, + tr_l10n(lang, "style.pack.dictation_prompt_title"), + Some(tr_l10n(lang, "style.pack.dictation_prompt_hint")), + &mut vm.style_prompt, + 210.0, + "dictation-prompt", + ); + } + + // The runtime card belongs to the dictation workflow: it shows which + // directives Core actually assembles into the prompt right now. + if !vm.style_selection_workflow { + ui.add_space(SECTION_GAP + 4.0); + runtime_card(ui, vm); + } + + ui.add_space(18.0); + drawer_footer(ui, vm, actions); + ui.add_space(SECTION_GAP); + editor_metadata(ui, vm); + ui.add_space(SECTION_GAP); + editor_examples(ui, vm); + ui.add_space(18.0); + }); + }); + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp(egui::Id::new("style-editor-scroll-rect"), scroll.inner_rect); + data.insert_temp( + egui::Id::new("style-editor-scroll-content-height"), + scroll.content_size.y, + ); + data.insert_temp( + egui::Id::new("style-editor-scroll-offset"), + scroll.state.offset.y, + ); + }); + #[cfg(not(test))] + let _ = scroll; +} + +/// CSS repeat(auto-fit, minmax(180px, 1fr)): three identity fields on +/// the first row at the normal drawer width, and tags on the next row. +fn editor_identity_grid(ui: &mut egui::Ui, vm: &mut FrontendViewModel) { + let lang = vm.lang; + let width = ui.available_width(); + let columns = (((width + FIELD_GAP) / (180.0 + FIELD_GAP)).floor() as usize).clamp(1, 4); + let field_width = (width - FIELD_GAP * (columns - 1) as f32) / columns as f32; + for start in (0..4).step_by(columns) { + if start != 0 { + ui.add_space(12.0); + } + ui.horizontal(|ui| { + for index in start..(start + columns).min(4) { + ui.vertical(|ui| { + ui.set_width(field_width); + let (label, text, hint) = match index { + 0 => ("style.pack.fieldName", &mut vm.style_name, None), + 1 => ( + "style.pack.fieldAuthor", + &mut vm.style_author, + Some("style.pack.fieldAuthorPlaceholder"), + ), + 2 => ("style.pack.fieldVersion", &mut vm.style_version, None), + _ => ( + "style.pack.fieldTags", + &mut vm.style_tags, + Some("style.pack.fieldTagsPlaceholder"), + ), + }; + field_label(ui, tr_l10n(lang, label)); + let mut input = editor_input(text); + if let Some(hint) = hint { + input = input.hint_text(tr_l10n(lang, hint)); + } + ui.add_sized([field_width, 38.0], input); + }); + } + }); + } +} + +fn editor_metadata(ui: &mut egui::Ui, vm: &FrontendViewModel) { + let lang = vm.lang; + let width = ui.available_width(); + ui.spacing_mut().item_spacing.x = FIELD_GAP; + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(14)) + .show(ui, |ui| { + ui.set_width((width - 28.0).max(80.0)); + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "style.pack.metaTitle")) + .strong() + .size(13.0), + ); + ui.label( + egui::RichText::new(&vm.style_editor_id) + .size(10.0) + .color(theme::INK_3), + ); + }); + ui.add_space(10.0); + let values = [ + ( + "style.pack.metaSource", + tr_l10n( + lang, + if vm.style_editor_builtin { + "style.pack.builtin" + } else { + "style.pack.imported" + }, + ) + .to_string(), + ), + ("style.pack.metaBaseMode", vm.style_editor_mode.clone()), + ( + "style.pack.metaUpdatedAt", + vm.style_editor_saved + .as_ref() + .and_then(|pack| pack.updated_at.clone()) + .unwrap_or_else(|| "—".into()), + ), + ]; + let gap = FIELD_GAP; + let column_width = (ui.available_width() - gap * 2.0) / 3.0; + ui.horizontal(|ui| { + for (label, value) in values { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::symmetric(12, 10)) + .show(ui, |ui| { + ui.set_width((column_width - 24.0).max(30.0)); + ui.label( + egui::RichText::new(tr_l10n(lang, label)) + .size(11.0) + .color(theme::INK_4), + ); + ui.label(egui::RichText::new(value).size(12.5).color(theme::INK_2)); + }); + } + }); + }); +} + +fn editor_examples(ui: &mut egui::Ui, vm: &mut FrontendViewModel) { + let lang = vm.lang; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "style.pack.examplesTitle")) + .strong() + .size(13.0), + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "style.pack.examplesDesc")) + .size(11.5) + .color(theme::INK_4), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .button(format!("+ {}", tr_l10n(lang, "style.pack.addExample"))) + .clicked() + { + vm.style_examples.push(Default::default()); + } + }); + }); + ui.add_space(12.0); + if vm.style_examples.is_empty() { + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::same(18)) + .show(ui, |ui| { + ui.set_width((ui.available_width() - 36.0).max(80.0)); + ui.label( + egui::RichText::new(tr_l10n(lang, "style.pack.examplesEmpty")) + .size(12.5) + .color(theme::INK_3), + ); + }); + } + let mut remove = None; + let width = ui.available_width(); + for (index, example) in vm.style_examples.iter_mut().enumerate() { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(16)) + .show(ui, |ui| { + ui.set_width((width - 32.0).max(80.0)); + ui.horizontal(|ui| { + let title_width = (ui.available_width() - 8.0 - 32.0).max(120.0); + ui.add_sized( + [title_width, 38.0], + editor_input(example.title.get_or_insert_with(String::new)).hint_text( + fmt_l10n( + lang, + "style.pack.exampleTitlePlaceholder", + &[&(index + 1).to_string()], + ), + ), + ); + ui.add_space(8.0); + let (delete_rect, delete_response) = + ui.allocate_exact_size(egui::vec2(32.0, 32.0), egui::Sense::click()); + if delete_response.clicked() { + remove = Some(index); + } + ui.painter().rect_stroke( + delete_rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + icons::draw_icon(ui, delete_rect.center(), IconName::Trash, theme::INK_2); + }); + ui.add_space(12.0); + let field_width = (ui.available_width() - FIELD_GAP) / 2.0; + ui.spacing_mut().item_spacing.x = FIELD_GAP; + ui.horizontal(|ui| { + for (label, text, id) in [ + ("style.pack.exampleInput", &mut example.input, "input"), + ("style.pack.exampleOutput", &mut example.output, "output"), + ] { + ui.vertical(|ui| { + ui.set_width(field_width); + field_label(ui, tr_l10n(lang, label)); + ui.push_id((index, id), |ui| { + editor_textarea(ui, text, 120.0, "example") + }); + }); + } + }); + }); + ui.add_space(12.0); + } + if let Some(index) = remove { + vm.style_examples.remove(index); + } +} + +/// Kind / mode / active / unsaved pills plus export and activate. +fn pills_row( + ui: &mut egui::Ui, + content: egui::Rect, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let index = vm + .style_packs + .iter() + .position(|pack| pack.id == vm.style_editor_id); + let row = egui::Rect::from_min_size( + egui::pos2(content.left() + 18.0, ui.cursor().top()), + egui::vec2(content.width() - 36.0, 24.0), + ); + ui.allocate_rect(row, egui::Sense::hover()); + let painter = ui.painter().with_clip_rect(row); + let mut x = row.left(); + let pill = |text: &str, tone: PillTone, x: &mut f32| { + let size = layout::pill_size(ui, text); + if *x + size.x > row.right() - 300.0 { + return; + } + layout::paint_pill( + &painter, + egui::Rect::from_min_size(egui::pos2(*x, row.center().y - size.y / 2.0), size), + text, + tone, + ); + *x += size.x + 6.0; + }; + pill( + tr_l10n( + lang, + if vm.style_editor_builtin { + "style.pack.builtin" + } else { + "style.pack.imported" + }, + ), + if vm.style_editor_builtin { + PillTone::Outline + } else { + PillTone::Blue + }, + &mut x, + ); + if !vm.style_editor_mode.is_empty() { + let mode = vm.style_editor_mode.clone(); + pill(&mode, PillTone::Gray, &mut x); + } + if vm.style_editor_active { + pill(tr_l10n(lang, "style.pack.active"), PillTone::Blue, &mut x); + } + if vm.style_editor_dirty { + pill( + tr_l10n(lang, "style.pack.unsaved"), + PillTone::Outline, + &mut x, + ); + } + + // Right side: export ZIP + activate (both reuse the list-row actions). + let activate = if vm.style_editor_active { + tr_l10n(lang, "style.pack.active") + } else { + tr_l10n(lang, "style.pack.activate") + }; + let activate_width = layout::text_width(ui, activate, 11.5) + 30.0; + let activate_rect = egui::Rect::from_min_size( + egui::pos2(row.right() - activate_width, row.center().y - 14.0), + egui::vec2(activate_width, 28.0), + ); + let publish = tr_l10n(lang, "style.pack.publishMarketplace"); + let publish_width = layout::text_width(ui, publish, 11.5) + 42.0; + let publish_rect = egui::Rect::from_min_size( + egui::pos2( + activate_rect.left() - 8.0 - publish_width, + row.center().y - 14.0, + ), + egui::vec2(publish_width, 28.0), + ); + let export = tr_l10n(lang, "style.pack.exportZip"); + let export_width = layout::text_width(ui, export, 11.5) + 42.0; + let export_rect = egui::Rect::from_min_size( + egui::pos2( + publish_rect.left() - 8.0 - export_width, + row.center().y - 14.0, + ), + egui::vec2(export_width, 28.0), + ); + if layout::action_button( + ui, + export_rect, + export, + Some(IconName::Download), + ButtonKind::Ghost, + ) + .clicked() + { + if let Some(index) = index { + actions.push(FrontendAction::StyleExport(index)); + } + } + let publish_kind = + if vm.style_editor_builtin || !vm.marketplace_signed_in || vm.style_editor_publishing { + ButtonKind::Disabled + } else { + ButtonKind::Ghost + }; + if layout::action_button( + ui, + publish_rect, + publish, + Some(IconName::Cloud), + publish_kind, + ) + .clicked() + && !vm.style_editor_builtin + && vm.marketplace_signed_in + && !vm.style_editor_publishing + { + actions.push(FrontendAction::StylePublishMarketplace); + } + if layout::action_button( + ui, + activate_rect, + activate, + None, + if vm.style_editor_active { + ButtonKind::Ghost + } else { + ButtonKind::Blue + }, + ) + .clicked() + && !vm.style_editor_active + { + if let Some(index) = index { + actions.push(FrontendAction::StyleActivate(index)); + } + } + ui.add_space(6.0); +} + +fn editor_input(text: &mut String) -> egui::TextEdit<'_> { + egui::TextEdit::singleline(text) + .font(egui::FontId::proportional(12.5)) + .vertical_align(egui::Align::Center) + .margin(egui::Margin::symmetric(11, 9)) + .frame( + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(10)), + ) +} + +/// Multiline TextEdit grows with its content even when passed to `add_sized`: +/// that size is a *minimum*, not a height cap. Keep the border fixed and let +/// long text scroll inside it, independently of the drawer's own scroll area. +fn editor_textarea(ui: &mut egui::Ui, text: &mut String, height: f32, id: &'static str) { + let width = ui.available_width(); + let inner_height = height - 22.0; // 11px top and bottom, matching textareaStyle + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::symmetric(12, 11)) + .show(ui, |ui| { + ui.set_width((width - 24.0).max(1.0)); + let scroll = egui::ScrollArea::vertical() + .id_salt(("style-editor-textarea", id)) + .max_height(inner_height) + .min_scrolled_height(inner_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(text) + .font(egui::FontId::proportional(12.5)) + .frame(egui::Frame::NONE) + .margin(egui::Margin::ZERO) + .desired_width(ui.available_width()), + ); + }); + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new(("style-editor-textarea-measure", id)), + ( + scroll.content_size.y, + scroll.inner_rect.height(), + scroll.state.offset.y, + ), + ); + data.insert_temp( + egui::Id::new(("style-editor-textarea-rect", id)), + scroll.inner_rect, + ) + }); + #[cfg(not(test))] + let _ = scroll; + }); +} + +fn field_label(ui: &mut egui::Ui, text: &str) { + ui.label( + egui::RichText::new(text) + .size(12.0) + .strong() + .color(theme::INK), + ); + ui.add_space(8.0); +} + +fn prompt_field( + ui: &mut egui::Ui, + title: &str, + hint: Option<&str>, + value: &mut String, + height: f32, + id: &'static str, +) { + field_label(ui, title); + if let Some(hint) = hint { + ui.label(egui::RichText::new(hint).size(11.0).color(theme::INK_4)); + ui.add_space(4.0); + } + editor_textarea(ui, value, height, id); +} + +/// Dictation directives preview. Rendering only reads the DTO Core built, so the +/// UI can never disagree with the prompt the pipeline actually sends. +fn runtime_card(ui: &mut egui::Ui, vm: &FrontendViewModel) { + let lang = vm.lang; + let Some(runtime) = vm.style_runtime.as_ref() else { + return; + }; + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(14)) + .show(ui, |ui| { + ui.set_width((ui.available_width() - 2.0).max(80.0)); + ui.label( + egui::RichText::new(tr_l10n(lang, "style.pack.runtimeTitle")) + .size(13.0) + .strong() + .color(theme::INK), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "style.pack.runtimeDesc")) + .size(11.5) + .color(theme::INK_4), + ); + ui.add_space(12.0); + runtime_row( + ui, + tr_l10n(lang, "style.pack.runtimeContextTitle"), + tr_l10n(lang, "style.pack.runtimeContextDesc"), + runtime.context_active, + tr_l10n(lang, "style.pack.runtimeContextEmpty"), + lang, + ); + ui.add_space(8.0); + runtime_row( + ui, + tr_l10n(lang, "style.pack.runtimeHotwordTitle"), + tr_l10n(lang, "style.pack.runtimeHotwordDesc"), + runtime.hotword_active, + tr_l10n(lang, "style.pack.runtimeHotwordEmpty"), + lang, + ); + ui.add_space(8.0); + runtime_row( + ui, + tr_l10n(lang, "style.pack.runtimeHistoryTitle"), + tr_l10n(lang, "style.pack.runtimeHistoryDesc"), + runtime.history_active, + tr_l10n(lang, "style.pack.runtimeHistoryEmpty"), + lang, + ); + if runtime.omits_front_app { + ui.add_space(10.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "style.pack.runtimePreviewOmittedFrontApp")) + .size(11.5) + .color(theme::INK_4), + ); + } + }); +} + +/// One directive row: title + description on the left, active/inactive pill right. +fn runtime_row( + ui: &mut egui::Ui, + title: &str, + detail: &str, + active: bool, + inactive_hint: &str, + lang: Lang, +) { + let width = ui.available_width(); + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + let (row, _) = ui.allocate_exact_size( + egui::vec2((width - 24.0).max(80.0), 34.0), + egui::Sense::hover(), + ); + let painter = ui.painter().with_clip_rect(row); + let pill_text = if active { + tr_l10n(lang, "style.pack.runtimeActive") + } else { + tr_l10n(lang, "style.pack.runtimeInactive") + }; + let pill_size = layout::pill_size(ui, pill_text); + let text_width = (row.width() - pill_size.x - 12.0).max(60.0); + let title_galley = layout::text_galley(ui, title, theme::INK, 12.0, text_width, 1); + painter.galley( + egui::pos2(row.left(), row.top() + 2.0), + title_galley, + theme::INK, + ); + let hint = if active { detail } else { inactive_hint }; + let hint_galley = layout::text_galley(ui, hint, theme::INK_4, 11.0, text_width, 1); + painter.galley( + egui::pos2(row.left(), row.top() + 18.0), + hint_galley, + theme::INK_4, + ); + layout::paint_pill( + &painter, + egui::Rect::from_min_size( + egui::pos2( + row.right() - pill_size.x, + row.center().y - pill_size.y / 2.0, + ), + pill_size, + ), + pill_text, + if active { + PillTone::Blue + } else { + PillTone::Outline + }, + ); + }); +} + +/// Save / revert on the left, reset-or-delete on the right. +fn drawer_footer(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + // Keep the destructive action on the same baseline as Save/Revert, as in + // Tauri. Imported packs show Delete; built-ins intentionally show Reset. + ui.spacing_mut().item_spacing.x = 8.0; + let row_width = ui.available_width(); + let (row, _) = ui.allocate_exact_size(egui::vec2(row_width, 32.0), egui::Sense::hover()); + let save = tr_l10n(lang, "style.pack.save"); + let save_width = layout::text_width(ui, save, 11.5) + 40.0; + let save_rect = egui::Rect::from_min_size(row.min, egui::vec2(save_width, 30.0)); + // Upstream only enables Save while the draft differs from the stored pack. + if vm.style_editor_dirty + && layout::action_button(ui, save_rect, save, Some(IconName::Check), ButtonKind::Blue) + .clicked() + { + if !vm.style_name.trim().is_empty() { + actions.push(FrontendAction::StyleSaveEditor { + name: vm.style_name.clone(), + description: vm.style_description.clone(), + prompt: vm.style_prompt.clone(), + selection_prompt: vm.style_selection_prompt.clone(), + voice_edit_prompt: vm.style_voice_edit_prompt.clone(), + tags: vm.style_tags.clone(), + author: vm.style_author.clone(), + version: vm.style_version.clone(), + model: vm.style_model.clone(), + compatible_version: vm.style_compatible_version.clone(), + examples: vm.style_examples.clone(), + }); + } + } else if !vm.style_editor_dirty { + // Still paint the disabled button so the layout does not jump. + layout::action_button( + ui, + save_rect, + save, + Some(IconName::Check), + ButtonKind::Disabled, + ); + } + + let revert = tr_l10n(lang, "style.pack.revert"); + let revert_width = layout::text_width(ui, revert, 11.5) + 40.0; + let revert_rect = egui::Rect::from_min_size( + egui::pos2(save_rect.right() + 8.0, row.top()), + egui::vec2(revert_width, 30.0), + ); + let kind = if vm.style_editor_dirty { + ButtonKind::Ghost + } else { + ButtonKind::Disabled + }; + if layout::action_button(ui, revert_rect, revert, Some(IconName::Refresh), kind).clicked() + && vm.style_editor_dirty + { + actions.push(FrontendAction::StyleRevertDraft); + } + + // Built-in packs reset to Core's shipped prompt; imported ones are deleted. + let secondary = if vm.style_editor_builtin { + tr_l10n(lang, "style.pack.resetBuiltin") + } else { + tr_l10n(lang, "style.pack.deleteImported") + }; + let secondary_width = layout::text_width(ui, secondary, 11.5) + 42.0; + let secondary_rect = egui::Rect::from_min_size( + egui::pos2(row.right() - secondary_width, row.top()), + egui::vec2(secondary_width, 30.0), + ); + if layout::action_button( + ui, + secondary_rect, + secondary, + Some(if vm.style_editor_builtin { + IconName::Refresh + } else { + IconName::Trash + }), + ButtonKind::Ghost, + ) + .clicked() + { + actions.push(if vm.style_editor_builtin { + FrontendAction::StyleResetBuiltin + } else { + FrontendAction::StyleDeleteImported + }); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/translation.rs b/openless-all/app/linux-egui/src/ui/frontend/translation.rs new file mode 100644 index 000000000..c0b7acdb9 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/translation.rs @@ -0,0 +1,662 @@ +//! Translation page — port of the Tauri `pages/Translation.tsx`. +//! +//! Language search, a two-column working-language grid with checkboxes, the +//! target language and the inherited style on the right, then the usage guide. + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; + +use super::icons::{self, IconName}; +use super::layout; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel}; + +const GAP: f32 = 12.0; +const CARD_PADDING: f32 = 18.0; + +/// `(native name, language code)`. Core stores the native name, so selection +/// matching must keep using it; the code is the secondary label shown under the +/// name (the Tauri app resolves a localized name through `Intl.DisplayNames`, +/// which egui cannot use). +const SUPPORTED_LANGUAGES: [(&str, &str); 15] = [ + ("简体中文", "zh-Hans"), + ("繁体中文", "zh-Hant"), + ("English", "en"), + ("日本語", "ja"), + ("한국어", "ko"), + ("Français", "fr"), + ("Deutsch", "de"), + ("Español", "es"), + ("Italiano", "it"), + ("Português", "pt"), + ("Русский", "ru"), + ("العربية", "ar"), + ("Tiếng Việt", "vi"), + ("ไทย", "th"), + ("हिन्दी", "hi"), +]; + +pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + // The shared page scroll area is height-bounded by the shell, so this page + // only has to pin its own width. Pinning *both* min and max keeps the two + // columns (and the four-column usage guide) from feeding a wider content + // size back into the next frame's layout while a window is being resized. + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + if vm.translation_unsupported { + layout::unsupported_page(ui, lang, tr_l10n(lang, "translation.title")); + return; + } + + layout::page_header( + ui, + width, + tr_l10n(lang, "translation.kicker"), + tr_l10n(lang, "translation.title"), + Some(tr_l10n(lang, "translation.desc")), + ); + ui.add_space(GAP); + + // Toolbar: language search on the left, selected count on the right. + toolbar(ui, width, vm); + ui.add_space(GAP); + + // 主窗口最低宽度依然保留左右两栏,不按内容区宽度误判为窄屏。 + { + let column_width = (width - GAP) / 2.0; + ui.columns(2, |columns| { + // The left card drives the shared height so both columns end level. + let left_height = working_languages(&mut columns[0], column_width, vm, actions); + target_language( + &mut columns[1], + column_width, + vm, + actions, + Some(left_height), + ); + }); + } + ui.add_space(GAP); + usage(ui, width, vm); +} + +fn toolbar(ui: &mut egui::Ui, width: f32, vm: &mut FrontendViewModel) { + let lang = vm.lang; + let count = vm.translation_working_languages.len(); + let count_text = fmt_l10n(lang, "translation.selected_languages", &[&count]); + let count_width = layout::text_width(ui, &count_text, 11.5) + 4.0; + let (row, _) = ui.allocate_exact_size(egui::vec2(width, 34.0), egui::Sense::hover()); + let search = egui::Rect::from_min_max( + row.min, + egui::pos2(row.right() - count_width - 8.0, row.bottom()), + ); + ui.painter() + .rect_filled(search, egui::CornerRadius::same(17), theme::SURFACE); + ui.painter().rect_stroke( + search, + egui::CornerRadius::same(17), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + icons::draw_icon( + ui, + egui::pos2(search.left() + 17.0, search.center().y), + IconName::Search, + theme::INK_4, + ); + let input = egui::Rect::from_min_max( + egui::pos2(search.left() + 33.0, search.top() + 5.0), + egui::pos2(search.right() - 9.0, search.bottom() - 5.0), + ); + ui.put( + input, + egui::TextEdit::singleline(&mut vm.translation_query) + .id(egui::Id::new("openless-translation-search")) + .hint_text(tr_l10n(lang, "translation.search_languages")) + .text_color(theme::INK) + .frame(egui::Frame::NONE) + .vertical_align(egui::Align::Center), + ); + ui.painter().text( + egui::pos2(row.right(), row.center().y), + egui::Align2::RIGHT_CENTER, + count_text, + egui::FontId::proportional(11.5), + theme::INK_4, + ); +} + +fn working_languages( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) -> f32 { + let lang = vm.lang; + let query = vm.translation_query.trim().to_lowercase(); + let visible: Vec<(&str, &str)> = SUPPORTED_LANGUAGES + .iter() + .copied() + .filter(|(native, code)| { + query.is_empty() + || native.to_lowercase().contains(&query) + || code.to_lowercase().contains(&query) + }) + .collect(); + + card(ui, width, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.working_title")) + .size(13.5) + .strong(), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.working_desc")) + .size(11.5) + .color(theme::INK_4), + ); + ui.add_space(10.0); + + if visible.is_empty() { + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.no_matching_languages")) + .size(11.5) + .color(theme::INK_4), + ); + } else { + const ROW_HEIGHT: f32 = 46.0; + const ROW_GAP: f32 = 8.0; + let available = ui.available_width(); + let columns: usize = if available >= 560.0 { 3 } else { 2 }; + + let cell_width = (available - ROW_GAP * (columns as f32 - 1.0)) / columns as f32; + let mut index = 0; + while index < visible.len() { + let count = columns.min(visible.len() - index); + let (row, _) = + ui.allocate_exact_size(egui::vec2(available, ROW_HEIGHT), egui::Sense::hover()); + for slot in 0..count { + let (native, code) = visible[index + slot]; + let rect = egui::Rect::from_min_size( + egui::pos2(row.left() + slot as f32 * (cell_width + ROW_GAP), row.top()), + egui::vec2(cell_width, ROW_HEIGHT), + ); + let selected = vm + .translation_working_languages + .iter() + .any(|value| value == native); + if language_row(ui, rect, native, code, selected) { + actions.push(FrontendAction::TranslationToggleLanguage( + native.to_string(), + )); + } + } + index += count; + if index < visible.len() { + ui.add_space(ROW_GAP); + } + } + } + ui.add_space(10.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.language_support_hint")) + .size(11.0) + .color(theme::INK_4), + ); + }) +} + +/// One selectable language row: name (bold) over its code, checkbox on the right. +fn language_row( + ui: &mut egui::Ui, + rect: egui::Rect, + native: &str, + code: &str, + selected: bool, +) -> bool { + let response = ui.interact( + rect, + ui.id().with(("openless-language-row", native)), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(rect); + let fill = if selected || response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }; + painter.rect_filled(rect, egui::CornerRadius::same(10), fill); + painter.rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + let checkbox = egui::Rect::from_center_size( + egui::pos2(rect.right() - 18.0, rect.center().y), + egui::vec2(16.0, 16.0), + ); + draw_check(ui, checkbox, selected); + painter.text( + egui::pos2(rect.left() + 12.0, rect.center().y - 8.0), + egui::Align2::LEFT_CENTER, + native, + egui::FontId::proportional(12.5), + theme::INK, + ); + // Only show the code when it adds information next to the native name. + painter.text( + egui::pos2(rect.left() + 12.0, rect.center().y + 9.0), + egui::Align2::LEFT_CENTER, + code, + egui::FontId::proportional(10.5), + theme::INK_4, + ); + response.clicked() +} + +fn draw_check(ui: &egui::Ui, rect: egui::Rect, checked: bool) { + let painter = ui.painter(); + painter.rect_filled( + rect, + egui::CornerRadius::same(4), + if checked { theme::INK } else { theme::SURFACE }, + ); + painter.rect_stroke( + rect, + egui::CornerRadius::same(4), + egui::Stroke::new(1.0, if checked { theme::INK } else { theme::LINE }), + egui::StrokeKind::Inside, + ); + if checked { + let stroke = egui::Stroke::new(1.5, theme::SURFACE); + painter.line_segment( + [ + rect.left_center() + egui::vec2(3.0, 0.5), + rect.center_bottom() + egui::vec2(-1.0, -3.5), + ], + stroke, + ); + painter.line_segment( + [ + rect.center_bottom() + egui::vec2(-1.0, -3.5), + rect.right_center() + egui::vec2(-2.5, -5.0), + ], + stroke, + ); + } +} + +fn target_language( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, + fill_height: Option, +) { + let lang = vm.lang; + let target = vm.translation_target_language.clone(); + let redundant = !target.is_empty() + && vm.translation_working_languages.len() == 1 + && vm.translation_working_languages[0] == target; + let enabled = !target.is_empty() && !redundant; + let disabled_label = tr_l10n(lang, "translation.target_disabled").to_string(); + + card(ui, width, |ui| { + if let Some(height) = fill_height { + // Match the left column so the two cards end on the same line. + ui.set_min_height((height - CARD_PADDING * 2.0).max(0.0)); + } + // The status pill is `flex: 0 0 auto` upstream, so the text block has to + // wrap instead of pushing the pill past the card edge on a narrow column. + let pill_width = + layout::text_width(ui, tr_l10n(lang, "translation.status_disabled"), 10.5) + 24.0; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_max_width((ui.available_width() - pill_width - 8.0).max(80.0)); + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.target_title")) + .size(13.5) + .strong(), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.target_desc")) + .size(11.5) + .color(theme::INK_4), + ); + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + let label = if enabled { + tr_l10n(lang, "translation.status_enabled") + } else { + tr_l10n(lang, "translation.status_disabled") + }; + egui::Frame::new() + .fill(egui::Color32::TRANSPARENT) + .stroke(egui::Stroke::new(0.7, theme::LINE)) + .corner_radius(egui::CornerRadius::same(9)) + .inner_margin(egui::Margin::symmetric(8, 3)) + .show(ui, |ui| { + ui.label(egui::RichText::new(label).size(10.5).color(if enabled { + theme::BLUE + } else { + theme::INK_4 + })); + }); + }); + }); + ui.add_space(10.0); + + let mut selected_target = target.clone(); + // `ComboBox::width` is the *minimum* width of the selected text, and the + // final button is that text plus the drop-down icon and the button + // padding. Asking for `available - 4` therefore overflowed the card by + // that chrome, which made egui widen the whole page (and with it the two + // columns) again on the next frame. + let combo_width = (ui.available_width() - 28.0).clamp(60.0, 360.0); + egui::ComboBox::from_id_salt("translation-target-language") + .width(combo_width) + .height(32.0) + .truncate() + .selected_text(if target.is_empty() { + egui::RichText::new(&disabled_label).color(theme::INK_4) + } else { + egui::RichText::new(target.as_str()).color(theme::INK) + }) + .show_ui(ui, |ui| { + if ui + .selectable_label(selected_target.is_empty(), &disabled_label) + .clicked() + { + selected_target = String::new(); + ui.close(); + } + for (native, _) in SUPPORTED_LANGUAGES { + if ui + .selectable_label(selected_target == native, native) + .clicked() + { + selected_target = native.to_string(); + ui.close(); + } + } + }); + if selected_target != target { + actions.push(FrontendAction::TranslationSetTarget(selected_target)); + } + + ui.add_space(12.0); + ui.separator(); + ui.add_space(12.0); + // The host marks the persisted dictation pack with `is_active`. The + // page-local `style_selected` index is only a transient style-page + // cursor (and is not hydrated by the host), so using it here could + // leave Translation stuck on the first pack after another pack was + // activated in 润色模式. + let style_name = translation_style_name(vm, lang); + let row_width = ui.available_width(); + // CSS uses flex: 0 0 auto, max-width: 180px and a single ellipsized + // line. An egui Frame inside right_to_left instead inherited all the + // remaining width and wrapped the style name into a tall blue block. + let badge_width = (layout::text_width(ui, &style_name, 11.5) + 18.0) + .min(180.0) + .min((row_width * 0.48).max(18.0)); + let label_width = (row_width - badge_width - ui.spacing().item_spacing.x).max(1.0); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.set_width(label_width); + ui.set_max_width(label_width); + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.style_title")) + .size(12.0) + .strong(), + ); + ui.add_space(2.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.style_desc")) + .size(11.5) + .color(theme::INK_4), + ); + }); + let (badge, response) = + ui.allocate_exact_size(egui::vec2(badge_width, 24.0), egui::Sense::hover()); + paint_style_badge(ui, badge, &style_name); + response.on_hover_text(style_name); + }); + + if redundant { + ui.add_space(10.0); + egui::Frame::new() + .fill(theme::WARN_SOFT) + .stroke(egui::Stroke::new(0.5, theme::WARN)) + .corner_radius(egui::CornerRadius::same(10)) + .inner_margin(egui::Margin::symmetric(12, 8)) + .show(ui, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.target_same_as_working")) + .size(11.5) + .color(theme::WARN), + ); + }); + } + }); +} + +fn translation_style_name(vm: &FrontendViewModel, lang: Lang) -> String { + vm.style_packs + .iter() + .find(|pack| pack.is_active && pack.enabled) + .or_else(|| vm.style_packs.iter().find(|pack| pack.is_active)) + .map(|pack| pack.name.clone()) + .unwrap_or_else(|| { + if vm.style_selected == usize::MAX { + tr_l10n(lang, "overview.mode_raw").to_string() + } else { + tr_l10n(lang, "overview.mode_light").to_string() + } + }) +} + +fn paint_style_badge(ui: &egui::Ui, rect: egui::Rect, name: &str) { + let painter = ui.painter().with_clip_rect(rect); + painter.rect_filled(rect, egui::CornerRadius::same(12), theme::BLUE_SOFT); + let galley = layout::text_galley(ui, name, theme::BLUE, 11.5, rect.width() - 18.0, 1); + painter.galley( + egui::pos2(rect.left() + 9.0, rect.center().y - galley.size().y / 2.0), + galley, + theme::BLUE, + ); + #[cfg(test)] + ui.ctx() + .data_mut(|data| data.insert_temp(egui::Id::new("translation-style-badge"), rect)); +} + +fn usage_columns(width: f32) -> usize { + ((width + 14.0) / (150.0 + 14.0)).floor().clamp(1.0, 5.0) as usize +} + +fn usage(ui: &mut egui::Ui, width: f32, vm: &FrontendViewModel) { + let lang = vm.lang; + card(ui, width, |ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "translation.howto_title")) + .size(13.0) + .strong(), + ); + ui.add_space(10.0); + + let steps = [ + tr_l10n(lang, "translation.howto_step1").to_string(), + fmt_l10n(lang, "translation.howto_step2", &[&vm.dictation_hotkey]), + fmt_l10n(lang, "translation.howto_step3", &[&vm.translation_hotkey]), + fmt_l10n(lang, "translation.howto_step4", &[&vm.dictation_hotkey]), + tr_l10n(lang, "translation.howto_step5").to_string(), + ]; + // Match CSS grid repeat(auto-fit, minmax(150px, 1fr)) with a 14px + // column gap: five steps fit in one row only when the viewport allows. + let columns = usage_columns(ui.available_width()); + let gap = 14.0; + let cell_width = (ui.available_width() - gap * (columns as f32 - 1.0)) / columns as f32; + let mut index = 0; + while index < steps.len() { + let count = columns.min(steps.len() - index); + let mut row_height: f32 = 20.0; + let mut galleys = Vec::new(); + for slot in 0..count { + let galley = layout::text_galley( + ui, + &steps[index + slot], + theme::INK_2, + 12.0, + (cell_width - 26.0).max(1.0), + usize::MAX, + ); + row_height = row_height.max(galley.size().y + 2.0); + galleys.push(galley); + } + let (row, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), row_height), + egui::Sense::hover(), + ); + for (slot, galley) in galleys.into_iter().enumerate() { + let left = row.left() + slot as f32 * (cell_width + gap); + let painter = ui.painter().with_clip_rect(egui::Rect::from_min_size( + egui::pos2(left, row.top()), + egui::vec2(cell_width, row_height), + )); + let number = egui::pos2(left + 9.0, row.top() + 10.0); + painter.circle_filled(number, 9.0, theme::BLUE_SOFT); + painter.text( + number, + egui::Align2::CENTER_CENTER, + (index + slot + 1).to_string(), + egui::FontId::proportional(11.0), + theme::BLUE, + ); + painter.galley(egui::pos2(left + 26.0, row.top()), galley, theme::INK_2); + } + index += count; + if index < steps.len() { + ui.add_space(10.0); + } + } + }); +} + +/// Full-width card that sizes itself to its contents. Returns its height. +fn card(ui: &mut egui::Ui, width: f32, contents: impl FnOnce(&mut egui::Ui)) -> f32 { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(CARD_PADDING as i8)) + .show(ui, |ui| { + ui.set_width((width - CARD_PADDING * 2.0).max(1.0)); + contents(ui); + }) + .response + .rect + .height() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::frontend::view_model::StylePack; + + #[test] + fn translation_style_name_follows_the_host_active_pack() { + let pack = |id: &str, name: &str, is_active: bool| StylePack { + id: id.to_string(), + icon_path: None, + icon_data_url: None, + base_mode: "light".to_string(), + name: name.to_string(), + description: String::new(), + tags: Vec::new(), + is_builtin: true, + enabled: true, + is_active, + selection_active: false, + }; + let vm = FrontendViewModel { + style_selected: 0, + style_packs: vec![ + pack("builtin.light", "轻度润色", false), + pack("custom", "My translation style", true), + ], + ..Default::default() + }; + + assert_eq!( + translation_style_name(&vm, Lang::ZhCn), + "My translation style", + "translation must follow Core's active pack, not the stale page index" + ); + } + + #[test] + fn usage_steps_auto_fit_into_one_through_five_columns() { + for (width, expected) in [ + (140.0, 1), + (314.0, 2), + (478.0, 3), + (642.0, 4), + (806.0, 5), + (1600.0, 5), + ] { + assert_eq!(usage_columns(width), expected, "width={width}"); + } + } + + #[test] + fn long_style_name_is_ellipsized_inside_a_one_line_badge() { + let ctx = egui::Context::default(); + let _ = super::super::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(500.0, 300.0), + )), + ..Default::default() + }, + |ui| { + let name = "An exceptionally long translation style name that must not wrap"; + let badge = + egui::Rect::from_min_size(egui::pos2(50.0, 50.0), egui::vec2(180.0, 24.0)); + paint_style_badge(ui, badge, name); + let galley = + layout::text_galley(ui, name, theme::BLUE, 11.5, badge.width() - 18.0, 1); + assert_eq!(galley.rows.len(), 1); + assert!(galley.elided); + }, + ); + } + + #[test] + fn narrow_step_text_is_wrapped_without_three_line_truncation() { + let ctx = egui::Context::default(); + let _ = super::super::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(480.0, 300.0), + )), + ..Default::default() + }, + |ui| { + let text = "A long instruction must be fully readable even when the window is narrowed and the grid reflows"; + let galley = layout::text_galley(ui, text, theme::INK_2, 12.0, 90.0, usize::MAX); + assert!(galley.rows.len() > 3); + assert!(!galley.elided); + }, + ); + } +} 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 index c63982625..4db74df6d 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/view_model.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/view_model.rs @@ -1,10 +1,15 @@ +use openless_linux_egui::Lang; + // ── Page / Tab ────────────────────────────────────────────────────────────── -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive( + Clone, serde::Serialize, serde::Deserialize, Copy, Debug, Default, PartialEq, Eq, Hash, +)] pub enum Page { #[default] Overview, History, + QuickNote, Vocab, Style, Marketplace, @@ -19,10 +24,8 @@ pub enum Page { /// 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)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub enum FrontendAction { - AcceptCorrection(String), - RejectCorrection(String), /// Navigate to a different page. Navigate(Page), /// Open / close the in-window settings overlay. @@ -37,6 +40,22 @@ pub enum FrontendAction { MarketplaceRefresh, /// Marketplace "my packs" requested. MarketplaceMyPacks, + /// Open the local style-pack picker for a new publication or update. + MarketplaceUploadOpen { + origin_pack_id: Option, + target_name: Option, + }, + MarketplaceUploadSelect(usize), + MarketplaceUploadConfirm, + MarketplaceUploadCancel, + MarketplaceWithdrawRequest(usize), + MarketplaceWithdrawConfirm, + MarketplaceWithdrawCancel, + /// Start the GitHub device authorization flow from the identity chip. + MarketplaceAuthStart, + MarketplaceAuthOpenBrowser, + MarketplaceAuthCopyCode, + MarketplaceAuthCancel, /// Open marketplace pack detail. MarketplaceDetail(usize), /// Close marketplace detail modal. @@ -47,32 +66,46 @@ pub enum FrontendAction { MarketplaceInstall(usize), /// Toggle marketplace pack like. MarketplaceToggleLike(usize), - /// History search query changed. - HistorySearch(String), - /// History filter changed. - HistoryFilter(usize), - /// Select a history entry. + MarketplaceCloseMine, + /// Select a history entry (index into `history_entries`). HistorySelect(usize), - /// Clear all history. - HistoryClear, - /// Refresh history list. + /// Re-read the history list from Core. HistoryRefresh, - /// Play/pause history audio. - HistoryTogglePlay, - /// Repolish a history entry. - HistoryRepolish, - HistoryRetranscribe, - HistoryCancel, - /// Delete a history entry. - HistoryDelete(usize), - /// Export history recording. + /// Start or finish a permanent quick-note recording through Core. + QuickNoteToggle, + /// Ask for confirmation before clearing all history. + HistoryRequestClear, + /// Ask for confirmation before deleting one entry. + HistoryRequestDelete(String), + /// Confirm the pending destructive history action. + HistoryConfirmAction, + /// Dismiss the pending confirmation dialog. + HistoryCancelConfirm, + /// Export a history entry's recording to a file. HistoryExport(usize), + /// Re-run ASR on a history entry's recording. + HistoryRetranscribe(usize), + HistoryRepolishOpen(usize), + HistoryRepolishClose, + HistoryRepolish(usize, Option), + /// Open a history entry's recording in the system player. + HistoryPlay(usize), + /// Pause or resume the current in-app recording without dropping its cursor. + HistoryPauseToggle, + /// Seek the current recording to a millisecond timestamp. + HistorySeek(u64), /// Vocab entry added. VocabAddPhrase(String), + /// Vocab list filter changed (0 = all, 1 = auto-collected, 2 = manual). + VocabFilter(usize), + /// Vocab search query changed. + VocabSearch(String), /// Vocab entry removed. VocabRemovePhrase(usize), /// Vocab entry toggled enabled/disabled. VocabTogglePhrase(usize), + /// Reload dictionary and correction rules from Core. + VocabRefresh, /// Correction rule added. VocabAddRule { pattern: String, @@ -86,23 +119,41 @@ pub enum FrontendAction { VocabApplyPreset(usize), /// Vocab preset created. VocabCreatePreset { - id: Option, name: String, phrases: String, }, - VocabDeletePreset(usize), - VocabRefresh, /// Style pack activated. StyleActivate(usize), - StyleRefresh, - StyleReset, - StyleDelete, + /// Dismiss / restore the shortcut card on the Quick Note page. + QuickNoteShortcutHidden(bool), + StyleChooseIcon(usize), + StyleResetIcon(usize), /// Style pack exported. StyleExport(usize), + /// Publish the open style pack to the Marketplace. + StylePublishMarketplace, /// Style pack editor opened. StyleEdit(usize), + /// Reset a built-in pack to Core's shipped prompt (`reset_builtin_style_pack`). + StyleResetBuiltin, + /// Delete the imported pack the editor is showing (`remove_style_pack`). + StyleDeleteImported, + /// Throw away the editor's local draft and re-read the stored pack. + StyleRevertDraft, /// Style editor prompt saved. - StyleSaveEditor(String), + StyleSaveEditor { + name: String, + description: String, + prompt: String, + selection_prompt: String, + voice_edit_prompt: String, + tags: String, + author: String, + version: String, + model: String, + compatible_version: String, + examples: Vec, + }, /// Style editor closed. StyleCloseEditor, /// New style pack creation requested. @@ -125,8 +176,73 @@ pub enum FrontendAction { SettingsAction(SettingsActionField), /// Settings section changed. SettingsSection(SettingsSection), - /// Settings notice message. - SettingsNotice(String), + /// AI-services sub-tab (0 = LLM, 1 = ASR, 2 = local models, 3 = connections). + SettingsServicesView(usize), + /// Enable/disable a channel (index into `settings.channels`). + SettingsChannelToggle(usize), + /// Validate a channel (index into `settings.channels`). + SettingsChannelValidate(usize), + /// Delete a channel (index into `settings.channels`). + SettingsChannelDelete(usize), + /// Open/close the "add channel" form. + SettingsChannelFormOpen(bool), + /// Provider picked in the add-channel form. + SettingsChannelProvider(usize), + /// Channel name typed in the add-channel form. + SettingsChannelName(String), + /// Create the channel described by the form. + SettingsChannelCreate, + /// Select a channel and open its provider editor (index into `channels`). + SettingsChannelSelect(usize), + /// Move a channel up/down; Core's `reorder_channels` owns the order. + SettingsChannelMove { + index: usize, + delta: isize, + }, + /// Switch a channel to another provider type (Core's `set_channel_provider_type`). + SettingsChannelProviderType { + index: usize, + provider_type: String, + }, + /// Make a channel the active provider (Core's `set_active_provider`). + SettingsChannelActivate(usize), + /// Provider editor field edited. + SettingsProviderField(SettingsProviderField, String), + /// Save the editor: rename + endpoint/model + credentials through Core. + SettingsProviderSave, + /// Drop the channel's stored secrets (Core's `remove_credential`). + SettingsProviderClearSecrets, + /// Ask Core for the provider's model list (Core's `provider.list_models`). + SettingsProviderModels, + /// 从「可用模型」里选了一个:写进模型字段**并立刻持久化**(Tauri + /// `applyModel`:`setCredential(modelAccount, model)` +「已保存模型 X」)。 + SettingsProviderModelSelected(String), + /// 模型字段在「预设下拉」与「自定义输入」之间切换(Tauri `customModelMode`)。 + SettingsProviderModelCustom(bool), + /// 打开该服务商的模型文档页(Tauri `viewModels`,来自 `endpointPresets[].modelsUrl`)。 + SettingsProviderModelsUrl, + /// Close the provider editor. + SettingsProviderClose, + /// 快捷键行的交互:展开/收起编辑菜单(`None` 收起全部)。 + ShortcutMenu(Option), + /// 进入录制态(`None` = 取消录制)。 + ShortcutRecording(Option), + /// 录入完成:主键 + 修饰键写回该绑定。 + ShortcutCaptured(ShortcutField, String, Vec), + /// 停用该绑定(核心录音快捷键不可停用,见 `ShortcutField::Dictation`)。 + ShortcutDisable(ShortcutField), + /// 风格直达:开关草稿行 / 选择风格包 / 移除整行。 + StyleHotkeyDraft(bool), + StyleHotkeyDraftPack(usize), + StyleHotkeyRemove(usize), + StyleHotkeyRepack(usize, usize), + /// Re-read channels for the current AI-services view. + /// Overview: re-read credentials / history / activity from Core. + OverviewRefresh, + /// Overview: period toggle (0 = last 7 days, 1 = last 30 days). + OverviewPeriod(usize), + /// Overview: metric toggle (0 = count, 1 = chars, 2 = duration). + OverviewMetric(usize), /// Window close requested. WindowClose, /// Window maximize/minimize toggle. @@ -140,7 +256,7 @@ pub enum FrontendAction { // ── Marketplace types ─────────────────────────────────────────────────────── -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, Default, PartialEq, Eq)] pub enum MarketplaceSort { #[default] Popular, @@ -148,85 +264,257 @@ pub enum MarketplaceSort { Liked, } -#[derive(Clone, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub struct MarketplacePack { + /// Marketplace id; the install action is index-based, but the UI needs the + /// id to show which pack is currently installing. + pub id: String, pub name: String, pub version: String, pub description: String, pub mode: String, pub author: String, + pub origin_author_login: Option, pub tags: Vec, pub likes: u32, pub downloads: u32, - pub is_new: bool, + /// Whether the signed-in user has liked this pack (`me/likes`). + pub liked: bool, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct MarketplaceMinePack { + pub pack: MarketplacePack, + /// Server lifecycle state: pending / approved / rejected / withdrawn / … + pub state: String, + pub updated_at: String, } // ── Settings types ────────────────────────────────────────────────────────── -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +/// Host permission state, mirroring the Tauri permission rows. Linux has no +/// OS permission prompts, so most of these stay `Unsupported` — but the value +/// now comes from the host snapshot instead of a hardcoded label. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, Default, PartialEq, Eq)] +pub enum PermissionState { + #[default] + Unknown, + Granted, + Unsupported, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct SettingsPermissions { + pub microphone: PermissionState, + pub accessibility: PermissionState, + pub hotkey: PermissionState, + pub network: PermissionState, +} + +/// What Core's prompt composer actually puts into the dictation prompt right +/// now (`preview_style_pack_runtime`). The editor shows one row per directive +/// instead of letting the UI guess the rules. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct StyleRuntimePreview { + pub context_active: bool, + pub hotword_active: bool, + pub history_active: bool, + pub omits_front_app: bool, +} + +/// One 风格包直选 row (style pack name + its hotkey chip). +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct StylePackHotkeyRow { + pub pack_id: String, + /// 风格包显示名;风格包列表里找不到该 id 时用作下拉的回退文案。 + pub name: String, + pub hotkey: String, +} + +/// One editable shortcut row in 快捷键与选区. `StylePack(index)` addresses an +/// existing entry of [`SettingsFields::style_pack_hotkeys`]; `StyleDraft` is the +/// 「+ 添加风格快捷键」row before it is committed. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ShortcutField { + Dictation, + Translation, + Qa, + QuickNote, + SwitchStyle, + OpenApp, + CodingAgentVoice, + SelectionPolish, + StylePack(usize), + StyleDraft, +} + +/// One credential channel shown in the AI-services settings tab. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct SettingsChannel { + pub name: String, + /// Model / endpoint summary shown under the channel name. + pub model: String, + /// Provider descriptor label (already localized by the host). + pub provider: String, + /// Provider type id, used to offer the provider switch without a round trip. + pub provider_type: String, + /// True for the channel currently serving requests. + pub is_active: bool, + pub enabled: bool, + /// Human-readable result of the last validation, if any. + pub last_check: Option, +} + +/// A field of the provider editor. Secret fields are write-only: opening an +/// editor never reads an existing key back into egui state. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, PartialEq, Eq)] +pub enum SettingsProviderField { + Name, + Endpoint, + Model, + ResourceId, + AuthMode, + PrimarySecret, + SecondarySecret, +} + +/// Which inputs the editor renders. Core's `AuthRequirement` decides this; +/// the UI never judges whether the credentials are sufficient — ProviderService +/// re-checks the descriptor before any protocol request. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, PartialEq, Eq)] +pub enum SettingsProviderAuth { + /// No credentials (local models). + None, + /// OAuth is driven by Core; the editor only explains it. + OAuth, + /// Volcengine: APP ID + Access Token, or API Key + Resource ID. + Volcengine, + /// Xfyun: AppID + API Key. + Xfyun, + /// Another Core-defined shape (Tencent Cloud and friends). + Other, + /// Endpoint + Model + API Key. + ApiKey, +} + +/// The open channel editor. Hydrated once per load from Core, then driven by +/// the host-side draft so typing is never clobbered by a re-read. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct SettingsProviderEditor { + pub channel_id: String, + /// Localized provider label (read-only). + pub provider: String, + pub provider_type: String, + pub name: String, + pub endpoint: String, + pub model: String, + pub resource_id: String, + pub auth_mode: String, + pub auth: SettingsProviderAuth, + // Write-only secret drafts: they are empty on load and cleared once Core has + // them, so a stored key never reaches egui state. + pub primary_secret: String, + pub secondary_secret: String, + /// Result of `provider.list_models`. + pub models: Vec, + pub models_loading: bool, + /// Core descriptor 的固定模型清单(Tauri `descriptor.staticModels`):非空时模型 + /// 字段是一个预设下拉,而不是空白输入框。 + pub static_models: Vec, + /// Core descriptor 的默认模型(Tauri `descriptor.defaultModel`):模型为空时 + /// 提供「填入默认」。 + pub default_model: String, + /// 当前 endpoint 对应的服务商文档页(Tauri `modelsUrl`):有值时「可用模型」不是 + /// 拉取,而是打开文档。 + pub has_models_url: bool, + /// 模型字段是否处于「自定义模型…」手输模式(Tauri `customModelMode`)。 + pub custom_model: bool, + pub busy: bool, +} + +/// Provider kinds available when creating a channel. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct SettingsChannelProvider { + /// Provider type id sent back to Core. + pub provider_type: String, + /// Localized label shown in the picker. + pub label: String, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, PartialEq, Eq)] pub enum SettingsSection { General, + Shortcuts, + Appearance, Services, Privacy, Advanced, About, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug)] pub enum SettingsField { - RecordingEnabled, - RealtimeMode, StreamingInsert, + /// 稳定模式(先录音后识别,上游 #1082)。 + StableTranscription, + StreamingSaveClipboard, RestoreClipboard, StartMinimized, + LaunchAtLogin, AutoUpdate, RemoteInput, - SelectionAssistant, - SelectionVoice, - StackedLayout, - ConservativeLayout, + SilenceAutoStop, + AudioCue, + /// 录音胶囊是否显示(Tauri `showCapsule`;Linux 上同样生效)。 + ShowCapsule, + MuteWhileRecording, + RecordAudioForDebug, ActivityHeatmap, SystemProxy, - LocalModel, - MarketplaceEnabled, - RememberHistory, - RecordAudio, LessComputer, Multimodal, - BetaChannel, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug)] pub enum SettingsComboField { - Provider, Language, Theme, - Retention, Microphone, + /// 胶囊样式:0 = siri(流光),1 = classic(经典药丸),2 = typeless(深色胶囊)。 + CapsuleStyle, RecordingMode, + SilenceSeconds, + RemoteDefaultMode, + /// 选区润色交付方式:0 = 直接替换,1 = 预览确认。 + SelectionPolishDelivery, + /// Less Computer 的 Agent 后端(0 = Claude Code, 1 = OpenCode, 2 = Codex, 3 = dsh)。 + CodingAgentProvider, + /// Less Computer 权限模式(0 = 放行, 1 = 只读/计划, 2 = 默认, 3 = 完全放行)。 + CodingAgentPermission, } -#[derive(Clone, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub enum SettingsTextField { - ApiKey, - Endpoint, - Model, RemotePort, - ClaudePrompt, + HistoryMaxEntries, + /// 历史保留天数(0 = 永久)。 + RetentionDays, + /// 润色上下文窗口(分钟,0 = 关闭)。 + PolishContextWindow, + /// 调试录音最多保留条数。 + AudioRecordingMaxEntries, + CodingAgentModel, + CodingAgentWorkdir, + CodingAgentExe, } -#[derive(Clone, Copy, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug)] pub enum SettingsActionField { - ConnectionTest, - ModelManagement, - ExtensionManagement, - Permissions, - ClearHistory, - ClaudeDetect, - ClaudeConsole, - ClaudeRunTest, + RetrySave, + /// 试听录音提示音(Tauri `audioCuePreview` 按钮)。 + PreviewAudioCue, ExportDiagnostics, - CheckUpdate, + CopyCertFingerprint, OpenGitHub, OpenHelp, OpenReleaseNotes, @@ -236,7 +524,7 @@ pub enum SettingsActionField { // ── Vocab types ───────────────────────────────────────────────────────────── -#[derive(Clone, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub struct VocabEntry { pub phrase: String, pub hits: usize, @@ -244,7 +532,7 @@ pub struct VocabEntry { pub learned: bool, } -#[derive(Clone, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub struct CorrectionRule { pub pattern: String, pub replacement: String, @@ -252,47 +540,104 @@ pub struct CorrectionRule { pub learned: bool, } -#[derive(Clone, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub struct SavedVocabPreset { - pub id: String, pub name: String, pub phrases: String, } // ── History types ─────────────────────────────────────────────────────────── -#[derive(Clone, Debug)] +/// In-app playback state for the entry currently being played. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] +pub struct HistoryPlayback { + pub id: String, + pub position_ms: u64, + pub total_ms: u64, + pub paused: bool, +} + +/// A pending destructive action that needs an in-window confirmation. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)] +pub enum HistoryConfirm { + Clear, + Delete(String), +} + +/// One history row plus everything the detail panel shows. +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] pub struct HistoryEntry { pub id: String, - pub raw: String, - pub mode: String, + pub quick_note: bool, + pub error_code: Option, + pub created_at: String, + /// Base polish mode; drives the list pill tone (raw renders as outline). + pub mode: OverviewMode, + /// Pill text: the style-pack name, or the mode name for records without one. + pub style_label: String, + pub raw_transcript: String, + pub final_text: String, + pub duration_ms: Option, pub has_audio: bool, - pub asr: String, - pub llm: String, + pub asr_provider: Option, + pub asr_model: Option, pub asr_ms: Option, - pub polish_ms: Option, - pub time: String, - pub text: String, - pub duration: String, - pub tag: String, + pub llm_provider: Option, + pub app_name: Option, + pub dictionary_count: Option, } // ── Style types ───────────────────────────────────────────────────────────── -#[derive(Clone, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub struct StylePack { pub id: String, + pub icon_path: Option, + pub icon_data_url: Option, + /// `raw` / `light` / `structured` / `formal`; picks the default icon. + pub base_mode: String, pub name: String, pub description: String, pub tags: Vec, - pub accent: egui::Color32, pub is_builtin: bool, + pub enabled: bool, + /// Active pack for the dictation / ASR workflow. pub is_active: bool, + /// Active pack for the selection-polish workflow (`prefs.selection_polish_style_pack_id`). + pub selection_active: bool, } // ── Overview types ────────────────────────────────────────────────────────── -#[derive(Clone, Debug, Default)] +/// Polish mode shown as the mode pill on a "recent" row. +#[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, Default, PartialEq, Eq)] +pub enum OverviewMode { + #[default] + Raw, + Light, + Structured, + Formal, +} + +/// One calendar day of activity (chronological inside +/// [`OverviewSummary::activity_daily`]). +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct OverviewActivityDay { + /// `YYYY-MM-DD` in the host's local timezone. + pub date: String, + pub count: u32, + pub chars: u64, + pub duration_ms: u64, +} + +/// One day of the annual activity heatmap (`YYYY-MM-DD` + dictation count). +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] +pub struct OverviewHeatmapDay { + pub date: String, + pub count: u32, +} + +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] pub struct OverviewSummary { pub asr_provider: String, pub llm_provider: String, @@ -304,17 +649,22 @@ pub struct OverviewSummary { 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, + /// Last 30 days ending today, chronological (oldest first). The period + /// chart slices the tail for the 7-day view. + pub activity_daily: Vec, + /// Calendar year rendered by the annual heatmap card. + pub heatmap_year: i32, + /// Every day of `heatmap_year`, chronological. Days without activity are + /// present with `count == 0` so the page can lay out the grid. + pub heatmap: Vec, } -#[derive(Clone, Debug, Default)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] pub struct OverviewRecentEntry { pub created_at: String, pub final_text: String, + pub raw_transcript: String, + pub mode: OverviewMode, pub duration_ms: Option, } @@ -323,35 +673,54 @@ pub struct OverviewRecentEntry { /// 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)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub struct FrontendViewModel { pub active_page: Page, pub style_open: bool, pub tools_open: bool, pub settings_open: bool, + /// Resolved UI language, injected by the host each frame so the pure + /// renderer can look up localized strings without touching global state. + #[serde(with = "lang_tag")] + pub lang: Lang, + + /// 明暗主题(Core 偏好)。宿主注入,UI 进程按它决定配色。 + pub theme_mode: openless_core::shared_types::ThemeMode, + // Overview pub overview_loading: bool, pub overview_error: Option, pub overview: Option, + pub overview_period: usize, + pub overview_metric: usize, // History pub history_query: String, - pub history_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, + pub quick_note_recording: bool, + /// Whether the dismissible shortcut card on the Quick Note page is hidden. + pub quick_note_shortcut_hidden: bool, + pub history_loading: bool, + pub history_error: Option, + /// Set while a destructive action awaits confirmation (clear-all / delete). + pub history_confirm: Option, + /// In-app playback progress for one history entry. + pub history_playback: Option, + pub history_repolish_open: bool, + pub history_repolish_running: bool, + pub history_repolish_result: Option<(String, String)>, + pub history_repolish_error: Option, // Vocab pub vocab_entries: Vec, pub vocab_rules: Vec, - pub pending_corrections: Vec, + /// 纠正规则页的「只看自动收集」筛选(Tauri 的 `onlyLearnedRules`)。 + pub vocab_rules_only_learned: bool, + /// 0 = all, 1 = auto-collected, 2 = manual. + pub vocab_filter: usize, + pub vocab_query: String, pub vocab_input: String, pub vocab_pattern: String, pub vocab_replacement: String, @@ -360,9 +729,6 @@ pub struct FrontendViewModel { 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, @@ -375,8 +741,27 @@ pub struct FrontendViewModel { pub style_name: String, pub style_description: String, pub style_selection_prompt: String, - pub style_builtin: bool, - pub style_saving: bool, + pub style_voice_edit_prompt: String, + pub style_tags: String, + /// Editor-only fields (`author` / `version` / model hints) from the pack. + pub style_author: String, + pub style_version: String, + pub style_model: String, + pub style_compatible_version: String, + pub style_examples: Vec, + /// Editor chrome: which pack is open, whether it is pristine, and the pills. + pub style_editor_id: String, + pub style_editor_builtin: bool, + pub style_editor_active: bool, + pub style_editor_mode: String, + pub style_editor_dirty: bool, + pub style_editor_publishing: bool, + pub marketplace_signed_in: bool, + pub marketplace_login: String, + /// The stored pack the draft is compared against (dirty check + Revert). + pub style_editor_saved: Option, + /// Live runtime directives of the draft (dictation workflow only). + pub style_runtime: Option, pub style_notice: Option, pub style_unsupported: bool, @@ -385,15 +770,88 @@ pub struct FrontendViewModel { pub marketplace_sort: MarketplaceSort, pub marketplace_packs: Vec, pub marketplace_selected: Option, - pub marketplace_liked: Vec, + pub marketplace_detail_prompt: Option, + pub marketplace_mine_open: bool, + pub marketplace_mine_query: String, + pub marketplace_mine_packs: Vec, + pub marketplace_mine_loading: bool, pub marketplace_notice: Option, - pub marketplace_prompt: Option, + pub marketplace_upload_open: bool, + pub marketplace_upload_origin_pack_id: Option, + pub marketplace_upload_target_name: Option, + pub marketplace_upload_packs: Vec, + pub marketplace_upload_selected: Option, + pub marketplace_upload_submitting: bool, + pub marketplace_confirm_withdraw: Option, + pub marketplace_oauth_open: bool, + pub marketplace_oauth_loading: bool, + pub marketplace_oauth_flow_id: Option, + pub marketplace_oauth_user_code: String, + pub marketplace_oauth_uri: String, + pub marketplace_oauth_error: Option, pub marketplace_loading: bool, + /// Pack id currently being installed (the detail button shows 安装中…). + pub marketplace_installing: Option, + pub marketplace_unsupported: bool, // Settings pub settings_section: SettingsSection, + /// AI-services sub-tab (0 = LLM, 1 = ASR, 2 = local models, 3 = connections, + /// 4 = 多模态). `ServiceView` decides which of these are shown. + pub services_view: usize, + /// Whether the host has an enabled channel for LLM / ASR (drives the + /// required-service status dots on the AI-services tabs). + pub service_configured: [bool; 2], + /// The host has no local inference engine (Linux) → hide the local-model + /// tab, exactly like the Tauri app gates on `supports_local_asr`. + pub supports_local_asr: bool, + /// Multimodal pipeline is enabled → the 多模态 view joins the tab strip. + pub multimodal_view: bool, + /// The host can self-update (AppImage) → beta-channel / check-update rows. + /// The platform has a working desktop hotkey backend (fcitx5 listener up). + /// Tauri's `visibleSettingsSections(supportsDesktopHotkey)` hides the + /// 「快捷键」 section when there is none; the rail follows the same rule. + /// Defaults to true so pure-rendering callers (and the common case where the + /// host did start the listener) keep the section visible. + pub hotkeys_supported: bool, + /// 展开编辑菜单的快捷键行(`None` = 都收起)。 + pub shortcut_menu: Option, + /// 正在录入按键的快捷键行(`None` = 未在录入)。 + pub shortcut_recording: Option, + /// 「+ 添加风格快捷键」草稿行是否打开。 + pub style_hotkey_draft_open: bool, + /// 草稿行选中的风格包(`FrontendViewModel::style_packs` 索引)。 + pub style_hotkey_draft_pack: usize, + /// Real host permission snapshot for 隐私与数据(不再写死「已授权」)。 + pub permissions: SettingsPermissions, + /// 远程输入服务正在监听 → 显示配对码 / 访问网址 / 证书指纹。 + pub remote_running: bool, + /// 服务报出的地址已过期:继续展示旧网址会诱导用户在错误的地址上配对, + /// 因此与 Core 的 `RemoteInputStatus::urls_stale` 一起决定是否展示连接细节。 + pub remote_urls_stale: bool, + pub remote_pin: String, + pub remote_urls: Vec, + pub remote_cert_fingerprint: Option, + /// Channels of the active AI-services kind. + pub channels: Vec, + /// Provider kinds offered by the add-channel form. + pub channel_providers: Vec, + pub channels_loading: bool, + pub channel_form_open: bool, + pub channel_form_name: String, + pub channel_provider_index: usize, + /// Open provider editor, or `None` when the channel list is the whole view. + pub provider_editor: Option, + /// Rail search query in the settings modal. + pub settings_query: String, + /// 录制中的裸修饰键挂起状态(egui 没有修饰键 Key 事件,只能跨帧判断)。 + pub shortcut_pending_modifier: Option, + /// Expanded drill-in row in the 实验与扩展 section (`usize::MAX` = none). + pub advanced_open: usize, pub settings_notice: Option, + pub settings_saving: bool, + pub settings_save_error: Option, pub settings: SettingsFields, // Selection ask @@ -402,38 +860,65 @@ pub struct FrontendViewModel { // Translation pub translation_working_languages: Vec, + /// Language search query on the translation page. + pub translation_query: String, pub translation_target_language: String, pub translation_unsupported: bool, // Status bar pub version: String, pub status: String, + /// Display label for the dictation shortcut (e.g. `Ctrl+Shift+Space`). + pub dictation_hotkey: String, + /// Display label for the selection-ask popup shortcut. + pub qa_hotkey: String, + pub quick_note_hotkey: String, + /// Display label for the translation modifier shortcut. + pub translation_hotkey: String, + /// Display label for the switch-style shortcut. + pub switch_style_hotkey: String, + /// Display label for the open-app shortcut. + pub open_app_hotkey: String, + /// Display label for the Less Computer voice shortcut. + pub coding_agent_hotkey: String, + /// Display label for the selection-polish shortcut. + pub selection_polish_hotkey: String, + /// Pipeline mode is 多模态 → the task strip hides the legacy LLM/ASR views. + pub pipeline_multimodal: bool, } impl Default for FrontendViewModel { fn default() -> Self { Self { + lang: Lang::ZhCn, + theme_mode: openless_core::shared_types::ThemeMode::System, active_page: Page::Overview, style_open: true, - tools_open: false, + tools_open: true, settings_open: false, overview_loading: true, overview_error: None, overview: None, + overview_period: 0, + overview_metric: 0, history_query: String::new(), - history_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, + quick_note_recording: false, + quick_note_shortcut_hidden: false, + history_loading: true, + history_error: None, + history_confirm: None, + history_playback: None, + history_repolish_open: false, + history_repolish_running: false, + history_repolish_result: None, + history_repolish_error: None, vocab_entries: Vec::new(), vocab_rules: Vec::new(), - pending_corrections: Vec::new(), + vocab_rules_only_learned: false, + vocab_filter: 0, + vocab_query: String::new(), vocab_input: String::new(), vocab_pattern: String::new(), vocab_replacement: String::new(), @@ -442,9 +927,6 @@ impl Default for FrontendViewModel { 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(), @@ -455,102 +937,213 @@ impl Default for FrontendViewModel { style_name: String::new(), style_description: String::new(), style_selection_prompt: String::new(), - style_builtin: false, - style_saving: false, + style_voice_edit_prompt: String::new(), + style_tags: String::new(), + style_author: String::new(), + style_version: String::new(), + style_model: String::new(), + style_compatible_version: String::new(), + style_examples: Vec::new(), + style_editor_id: String::new(), + style_editor_builtin: false, + style_editor_active: false, + style_editor_mode: String::new(), + style_editor_dirty: false, + style_editor_publishing: false, + marketplace_signed_in: false, + marketplace_login: String::new(), + style_editor_saved: None, + style_runtime: None, 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_detail_prompt: None, + marketplace_mine_open: false, + marketplace_mine_query: String::new(), + marketplace_mine_packs: Vec::new(), + marketplace_mine_loading: false, marketplace_notice: None, - marketplace_prompt: None, + marketplace_upload_open: false, + marketplace_upload_origin_pack_id: None, + marketplace_upload_target_name: None, + marketplace_upload_packs: Vec::new(), + marketplace_upload_selected: None, + marketplace_upload_submitting: false, + marketplace_confirm_withdraw: None, + marketplace_oauth_open: false, + marketplace_oauth_loading: false, + marketplace_oauth_flow_id: None, + marketplace_oauth_user_code: String::new(), + marketplace_oauth_uri: String::new(), + marketplace_oauth_error: None, marketplace_loading: true, + marketplace_installing: None, marketplace_unsupported: true, settings_section: SettingsSection::General, + services_view: 0, + channels: Vec::new(), + channel_providers: Vec::new(), + channels_loading: false, + channel_form_open: false, + channel_form_name: String::new(), + channel_provider_index: 0, + provider_editor: None, + settings_query: String::new(), + shortcut_pending_modifier: None, + advanced_open: usize::MAX, settings_notice: None, + settings_saving: false, + settings_save_error: None, settings: SettingsFields::default(), qa_save_history: false, selection_unsupported: true, translation_working_languages: Vec::new(), + translation_query: String::new(), translation_target_language: String::new(), translation_unsupported: true, version: env!("CARGO_PKG_VERSION").to_string(), status: String::new(), + selection_polish_hotkey: String::new(), + pipeline_multimodal: false, + remote_running: false, + remote_urls_stale: false, + remote_pin: String::new(), + remote_urls: Vec::new(), + remote_cert_fingerprint: None, + service_configured: [false; 2], + supports_local_asr: false, + multimodal_view: false, + hotkeys_supported: true, + shortcut_menu: None, + shortcut_recording: None, + style_hotkey_draft_open: false, + style_hotkey_draft_pack: 0, + permissions: SettingsPermissions::default(), + dictation_hotkey: String::new(), + qa_hotkey: String::new(), + quick_note_hotkey: String::new(), + translation_hotkey: String::new(), + switch_style_hotkey: String::new(), + open_app_hotkey: String::new(), + coding_agent_hotkey: String::new(), } } } /// Mirror of the egui-frontend `SettingsState` fields, but with no default /// mock data. All values come from the host. -#[derive(Clone, Debug)] +#[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub struct SettingsFields { - pub recording_enabled: bool, - pub realtime_mode: bool, + /// 0 = toggle, 1 = hold, 2 = double click, 3 = auto. + pub recording_mode: usize, pub streaming_insert: bool, + /// 稳定模式(先录音后识别):上游 #1082 新增的偏好,录音期间不连接 ASR。 + pub stable_transcription: bool, + pub streaming_save_clipboard: bool, pub restore_clipboard: bool, pub start_minimized: bool, + pub launch_at_login: bool, pub auto_update: bool, pub remote_input: bool, - pub selection_assistant: bool, - pub selection_voice: bool, - pub stacked_layout: bool, - pub conservative_layout: bool, + pub remote_default_mode: usize, + pub silence_auto_stop: bool, + pub silence_seconds: usize, + pub microphone_name: String, + pub microphone_options: Vec, + /// 枚举麦克风失败时的原因(Tauri `microphoneLoadError` 行)。 + pub microphone_error: Option, + pub mute_while_recording: bool, + pub audio_cue: bool, + /// 录音胶囊开关与样式(Tauri `capsuleLabel` / `capsuleStyleLabel`)。 + pub show_capsule: bool, + pub capsule_style: usize, + pub record_audio_for_debug: bool, + pub history_max_entries: String, + /// 风格直达快捷键(可录制/停用/移除)。 + pub style_pack_hotkeys: Vec, + /// 润色上下文窗口分钟数(0 = 只用当前这条转写)。 + pub polish_context_window: String, + /// 调试录音最多保留条数。 + pub audio_recording_max_entries: String, + /// 选区润色交付方式:0 = 直接替换,1 = 预览确认。 + pub selection_polish_delivery: usize, 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, + /// Less Computer(Coding Agent)配置,全部直连 `coding_agent_*` 偏好。 + pub coding_agent_provider: usize, + pub coding_agent_permission: usize, + pub coding_agent_model: String, + pub coding_agent_workdir: String, + pub coding_agent_exe: String, pub multimodal: bool, - pub beta_channel: bool, - pub 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, + /// 历史保留天数(0 = 永久,输入框)。 + pub retention_days: String, pub remote_port: String, - pub claude_prompt: String, } impl Default for SettingsFields { fn default() -> Self { Self { - recording_enabled: false, - realtime_mode: false, + recording_mode: 0, streaming_insert: false, + stable_transcription: false, + streaming_save_clipboard: false, restore_clipboard: false, start_minimized: false, + launch_at_login: false, auto_update: false, remote_input: false, - selection_assistant: false, - selection_voice: false, - stacked_layout: false, - conservative_layout: false, - activity_heatmap: false, + remote_default_mode: 0, + silence_auto_stop: false, + silence_seconds: 2, + microphone_name: String::new(), + microphone_options: Vec::new(), + microphone_error: None, + mute_while_recording: false, + audio_cue: false, + show_capsule: true, + capsule_style: 0, + record_audio_for_debug: false, + history_max_entries: String::new(), + style_pack_hotkeys: Vec::new(), + polish_context_window: String::new(), + audio_recording_max_entries: String::new(), + selection_polish_delivery: 0, + activity_heatmap: true, system_proxy: false, - local_model: false, - marketplace_enabled: false, - remember_history: false, - record_audio: false, less_computer: false, + coding_agent_provider: 0, + coding_agent_permission: 0, + coding_agent_model: String::new(), + coding_agent_workdir: String::new(), + coding_agent_exe: String::new(), multimodal: false, - beta_channel: false, - claude_expanded: false, - provider: 0, language: 0, theme: 0, - retention: 0, - api_key: String::new(), - endpoint: String::new(), - model: String::new(), + retention_days: "0".to_string(), remote_port: String::new(), - claude_prompt: String::new(), } } } + +/// `Lang` 定义在 `i18n.rs`(由同步脚本生成,禁止手改),所以按语言标签序列化, +/// 而不是给它加 serde derive。跨进程传视图模型时用得上。 +mod lang_tag { + use openless_linux_egui::Lang; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(lang: &Lang, serializer: S) -> Result { + serializer.serialize_str(lang.tag()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let tag = String::deserialize(deserializer)?; + Lang::parse(&tag).ok_or_else(|| serde::de::Error::custom(format!("unknown lang tag {tag}"))) + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/vocab.rs b/openless-all/app/linux-egui/src/ui/frontend/vocab.rs new file mode 100644 index 000000000..40e91c43c --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/vocab.rs @@ -0,0 +1,1471 @@ +//! Dictionary (词典) page — port of the Tauri `pages/Vocab.tsx`. +//! +//! Layout: page header with a primary "new word" action, an icon tab row +//! (all / auto-collected / manual) with a select-all checkbox and a circular +//! search control, the word list, then the quick-add row, hint and the +//! scenario presets. Correction rules live on `corrections.rs`. + +use std::collections::BTreeSet; + +use eframe::egui; +use openless_linux_egui::{fmt_l10n, tr_l10n}; + +use super::icons::{self, IconName}; +use super::layout::{self, ButtonKind}; +use super::theme; +use super::view_model::{FrontendAction, FrontendViewModel, VocabEntry}; + +const GAP: f32 = 14.0; +const CARD_PADDING: f32 = 20.0; +const INPUT_ID: &str = "openless-vocab-input"; +const SEARCH_ID: &str = "openless-vocab-search"; +const SEARCH_INPUT_ID: &str = "openless-vocab-search-input"; +const NEW_WORD_OPEN: &str = "openless-vocab-new-word-open"; +const NEW_WORD_FOCUS: &str = "openless-vocab-new-word-focus"; + +/// Whether the new-word modal is currently mounted. The native host uses this +/// to prepare the same live GPU backdrop as the other modal surfaces. +pub fn new_word_overlay_open(ctx: &egui::Context) -> bool { + ctx.data(|data| { + data.get_temp::(egui::Id::new(NEW_WORD_OPEN)) + .unwrap_or(false) + }) +} +const PRESETS_OPEN: &str = "openless-vocab-presets-open"; +const SELECTION_ID: &str = "openless-vocab-selection"; +const TAB_HEIGHT: f32 = 32.0; +const SEARCH_WIDTH: f32 = 210.0; + +// ── Page-local UI state ───────────────────────────────────────────────────── +// +// Search expansion and the multi-select set are view concerns only: the host +// view model has no fields for them, so they live in egui memory. + +fn search_open(ctx: &egui::Context) -> bool { + ctx.data(|data| { + data.get_temp::(egui::Id::new(SEARCH_ID)) + .unwrap_or(false) + }) +} + +fn set_search_open(ctx: &egui::Context, open: bool) { + ctx.data_mut(|data| data.insert_temp(egui::Id::new(SEARCH_ID), open)); +} + +fn selection(ctx: &egui::Context) -> BTreeSet { + ctx.data(|data| { + data.get_temp::>(egui::Id::new(SELECTION_ID)) + .unwrap_or_default() + }) +} + +fn set_selection(ctx: &egui::Context, selection: BTreeSet) { + ctx.data_mut(|data| data.insert_temp(egui::Id::new(SELECTION_ID), selection)); +} + +// ── Entry point ───────────────────────────────────────────────────────────── + +pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let width = (ui.available_width() - 24.0).max(1.0); + ui.set_min_width(width); + ui.set_max_width(width); + let lang = vm.lang; + + if vm.vocab_unsupported { + layout::unsupported_page(ui, lang, tr_l10n(lang, "nav.vocab")); + return; + } + + let selected = selection(ui.ctx()); + let header = layout::page_header( + ui, + width, + tr_l10n(lang, "vocab.kicker"), + tr_l10n(lang, "vocab.title"), + Some(tr_l10n(lang, "vocab.desc")), + ); + let mut right = header.right(); + let mut opened_new_word_now = false; + // Primary "new word" action (dark solid, like the Tauri `variant=primary`). + let new_word = tr_l10n(lang, "vocab.new_word"); + let new_word_width = layout::text_width(ui, new_word, 12.5) + 42.0; + let new_word_rect = egui::Rect::from_min_size( + egui::pos2(right - new_word_width, header.top() + 22.0), + egui::vec2(new_word_width, 30.0), + ); + right = new_word_rect.left() - 8.0; + if primary_button(ui, new_word_rect, new_word, Some(IconName::Plus)).clicked() { + opened_new_word_now = true; + ui.ctx().data_mut(|data| { + data.insert_temp(egui::Id::new(NEW_WORD_OPEN), true); + data.insert_temp(egui::Id::new(NEW_WORD_FOCUS), true); + }); + vm.vocab_input.clear(); + vm.vocab_selected_presets.clear(); + } + // Batch delete appears only while something is selected. + if !selected.is_empty() { + let label = fmt_l10n(lang, "vocab.delete_selected", &[&selected.len()]); + let label_width = layout::text_width(ui, &label, 12.5) + 40.0; + let rect = egui::Rect::from_min_size( + egui::pos2(right - label_width, header.top() + 22.0), + egui::vec2(label_width, 30.0), + ); + if layout::action_button(ui, rect, &label, Some(IconName::Trash), ButtonKind::Ghost) + .clicked() + { + for index in selected.iter().rev() { + actions.push(FrontendAction::VocabRemovePhrase(*index)); + } + set_selection(ui.ctx(), BTreeSet::new()); + } + } + ui.add_space(GAP); + + // ── Tool row: tabs + select-all + expandable search ───────────────────── + let visible: Vec = visible_indices(vm); + tool_row(ui, width, vm, &visible, &selected, actions); + ui.add_space(12.0); + + if let Some(error) = vm.vocab_error.clone() { + error_banner(ui, width, &error); + ui.add_space(10.0); + } + + // Auto-collected group gets a stable "remove all" exit while filtering. + if vm.vocab_filter == 1 { + let learned: Vec = visible + .iter() + .copied() + .filter(|index| vm.vocab_entries[*index].learned) + .collect(); + if !learned.is_empty() { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new(fmt_l10n(lang, "vocab.learned_section", &[&learned.len()])) + .size(12.0) + .color(theme::INK_3), + ); + ui.add_space(6.0); + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "vocab.remove_all_learned")) + .size(11.5), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 28.0)), + ) + .clicked() + { + for index in learned.iter().rev() { + actions.push(FrontendAction::VocabRemovePhrase(*index)); + } + } + }); + ui.add_space(10.0); + } + } + + // 词条独立滚动。下方整块(阴影分隔线、输入、提示、场景预设) + // 贴住窗口底缘;面板展开时向上生长,不随词条内容高度漂浮。 + let presets_expanded = ui.ctx().data(|data| { + data.get_temp::(egui::Id::new(PRESETS_OPEN)) + .unwrap_or(false) + }); + let panel_id = egui::Id::new(("openless-vocab-bottom-height", presets_expanded)); + let measured = ui.ctx().data(|data| data.get_temp::(panel_id)); + let reserved = measured.unwrap_or(if presets_expanded { 290.0 } else { 190.0 }); + let viewport_bottom = ui.max_rect().bottom(); + // egui's ScrollArea adds its own spacing after the viewport. Leave room + // for it so the fixed panel is never pushed below the content bottom. + let list_height = (viewport_bottom - ui.cursor().min.y - reserved - 12.0).max(80.0); + egui::ScrollArea::vertical() + .id_salt("openless-vocab-list-scroll") + .max_height(list_height) + .auto_shrink([false, false]) + .show(ui, |ui| { + // ── Word list ────────────────────────────────────────────────────────── + if visible.is_empty() { + ui.add_space(6.0); + let message = if vm.vocab_query.trim().is_empty() { + tr_l10n(lang, "vocab.empty").to_string() + } else { + tr_l10n(lang, "vocab.search_empty").to_string() + }; + ui.label(egui::RichText::new(message).size(12.0).color(theme::INK_4)); + } else { + let mut next_selection = selected.clone(); + let mut remove = None; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); + for index in &visible { + let entry = &vm.vocab_entries[*index]; + let is_selected = selected.contains(index); + match word_chip(ui, entry, is_selected) { + ChipAction::None => {} + ChipAction::Toggle => { + actions.push(FrontendAction::VocabTogglePhrase(*index)); + } + ChipAction::Select => { + if is_selected { + next_selection.remove(index); + } else { + next_selection.insert(*index); + } + } + ChipAction::Remove => remove = Some(*index), + } + } + }); + if let Some(index) = remove { + actions.push(FrontendAction::VocabRemovePhrase(index)); + next_selection.remove(&index); + } + if next_selection != selected { + set_selection(ui.ctx(), next_selection); + } + } + }); + let panel_top = (viewport_bottom - reserved).max(ui.cursor().min.y + 6.0); + let panel_rect = egui::Rect::from_min_max( + egui::pos2(ui.cursor().min.x, panel_top), + egui::pos2(ui.cursor().min.x + width, viewport_bottom), + ); + let actual_height = layout::fixed_ui(ui, panel_rect, "openless-vocab-bottom", |ui| { + let top = panel_rect.top(); + let (divider, _) = ui.allocate_exact_size(egui::vec2(width, 3.0), egui::Sense::hover()); + ui.painter().line_segment( + [divider.left_top(), divider.right_top()], + egui::Stroke::new(1.0, theme::LINE), + ); + // Soft inset shadow separating the fixed entry toolbar from the scrolling list. + for (offset, alpha) in [(1.0, 20), (2.0, 10), (3.0, 5)] { + ui.painter().line_segment( + [ + divider.left_top() - egui::vec2(0.0, offset), + divider.right_top() - egui::vec2(0.0, offset), + ], + egui::Stroke::new(1.0, egui::Color32::from_black_alpha(alpha)), + ); + } + quick_add(ui, width, vm, actions); + ui.add_space(12.0); + presets(ui, width, vm, actions); + ui.cursor().min.y - top + }); + if measured.is_none_or(|previous| (previous - actual_height).abs() > 0.5) { + ui.ctx() + .data_mut(|data| data.insert_temp(panel_id, actual_height)); + ui.ctx().request_repaint(); + } + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("openless-vocab-bottom-test-rect"), + egui::Rect::from_min_size(panel_rect.min, egui::vec2(width, actual_height)), + ); + data.insert_temp( + egui::Id::new("openless-vocab-viewport-test-bottom"), + viewport_bottom, + ); + }); + if !opened_new_word_now + && ui.ctx().data(|data| { + data.get_temp::(egui::Id::new(NEW_WORD_OPEN)) + .unwrap_or(false) + }) + { + new_word_overlay(ui.ctx(), vm, actions); + } +} + +// ── Tool row ──────────────────────────────────────────────────────────────── + +fn tool_row( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + visible: &[usize], + selected: &BTreeSet, + _actions: &mut Vec, +) { + let lang = vm.lang; + let tabs = [ + (TabIcon::None, tr_l10n(lang, "vocab.filter_all")), + (TabIcon::Sparkle, tr_l10n(lang, "vocab.filter_auto")), + (TabIcon::Feather, tr_l10n(lang, "vocab.filter_manual")), + ]; + let (row, _) = ui.allocate_exact_size(egui::vec2(width, 40.0), egui::Sense::hover()); + + let tabs_width: f32 = tabs + .iter() + .map(|(icon, label)| { + layout::text_width(ui, label, 13.0) + if *icon == TabIcon::None { 28.0 } else { 47.0 } + }) + .sum(); + let tab_background = egui::Rect::from_min_size( + row.min + egui::vec2(0.0, 0.5), + egui::vec2(tabs_width + 10.0, TAB_HEIGHT + 7.0), + ); + ui.painter().rect_filled( + tab_background, + egui::CornerRadius::same(20), + theme::SURFACE_2, + ); + ui.painter().rect_stroke( + tab_background, + egui::CornerRadius::same(20), + egui::Stroke::new(0.5, theme::LINE), + egui::StrokeKind::Inside, + ); + // 先算出每个 tab 的矩形:活动胶囊的位置只用一个动画值插值。以前在循环里对 + // 同一个 id 反复写入「非活动 tab 就指向活动位置」的目标值,同一帧三次写不同 + // 目标,胶囊会抖动。 + let mut tab_rects = Vec::with_capacity(tabs.len()); + let mut x = row.left() + 3.0; + for (icon, label) in &tabs { + let text_width = layout::text_width(ui, label, 13.0); + let tab_width = text_width + if *icon == TabIcon::None { 28.0 } else { 47.0 }; + tab_rects.push(egui::Rect::from_min_size( + egui::pos2(x, row.top() + 4.0), + egui::vec2(tab_width, TAB_HEIGHT), + )); + x += tab_width + 2.0; + } + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("openless-vocab-tabs-test-rects"), + (tab_background, tab_rects.clone()), + ); + }); + let active = vm.vocab_filter.min(2); + let pill_x = ui.ctx().animate_value_with_time( + ui.id().with("vocab-active-pill-x"), + tab_rects[active].left(), + 0.18, + ); + ui.painter().rect_filled( + egui::Rect::from_min_size( + egui::pos2(pill_x, tab_rects[active].top()), + tab_rects[active].size(), + ), + egui::CornerRadius::same(16), + theme::SURFACE, + ); + for (index, (icon, label)) in tabs.iter().enumerate() { + let rect = tab_rects[index]; + let active = active == index; + let response = ui.interact( + rect, + ui.id().with(("openless-vocab-tab", index)), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(rect); + if !active && response.hovered() { + painter.rect_filled(rect, egui::CornerRadius::same(16), theme::SURFACE); + } + let ink = if active || response.hovered() { + theme::INK + } else { + theme::INK_3 + }; + let text_left = if *icon != TabIcon::None { + let center = egui::pos2(rect.left() + 21.0, rect.center().y); + match icon { + TabIcon::Feather => icons::draw_icon(ui, center, IconName::Feather, ink), + _ => icons::draw_icon(ui, center, IconName::Sparkle, ink), + } + rect.left() + 33.0 + } else { + rect.left() + 14.0 + }; + painter.text( + egui::pos2(text_left, rect.center().y), + egui::Align2::LEFT_CENTER, + *label, + egui::FontId::proportional(13.0), + ink, + ); + if response.clicked() { + _actions.push(FrontendAction::VocabFilter(index)); + } + } + let x = tab_rects.last().map(|rect| rect.right() + 3.0).unwrap_or(x); + + // Select-all checkbox: label shows the selected count while non-empty. + let all_selected = !visible.is_empty() && visible.iter().all(|index| selected.contains(index)); + let partial = !all_selected && visible.iter().any(|index| selected.contains(index)); + let checkbox_rect = egui::Rect::from_min_size( + egui::pos2(x + 10.0, row.center().y - 8.0), + egui::vec2(16.0, 16.0), + ); + let checkbox = ui.interact( + checkbox_rect, + ui.id().with("openless-vocab-select-all"), + egui::Sense::click(), + ); + draw_checkbox(ui, checkbox_rect, all_selected, partial); + let label = if selected.is_empty() { + tr_l10n(lang, "vocab.select_all_visible").to_string() + } else { + fmt_l10n(lang, "vocab.selected_count", &[&selected.len()]) + }; + ui.painter().text( + egui::pos2(checkbox_rect.right() + 7.0, row.center().y), + egui::Align2::LEFT_CENTER, + &label, + egui::FontId::proportional(12.0), + theme::INK_3, + ); + let label_rect = egui::Rect::from_min_max( + egui::pos2(checkbox_rect.right() + 3.0, row.top() + 4.0), + egui::pos2( + checkbox_rect.right() + 12.0 + layout::text_width(ui, &label, 12.0), + row.bottom() - 4.0, + ), + ); + let label_response = ui.interact( + label_rect, + ui.id().with("openless-vocab-select-all-label"), + egui::Sense::click(), + ); + if checkbox.clicked() || label_response.clicked() { + let mut next: BTreeSet = selected.clone(); + if all_selected { + for index in visible { + next.remove(index); + } + } else { + for index in visible { + next.insert(*index); + } + } + set_selection(ui.ctx(), next); + } + + // Search: a circular button at the right edge that expands into an input. + let open = search_open(ui.ctx()); + let circle = egui::Rect::from_center_size( + egui::pos2(row.right() - 15.0, row.center().y), + egui::vec2(30.0, 30.0), + ); + if open { + let rect = egui::Rect::from_min_size( + egui::pos2(circle.left() - 8.0 - SEARCH_WIDTH, row.center().y - 16.0), + egui::vec2(SEARCH_WIDTH, 32.0), + ); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(16), theme::SURFACE_2); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(16), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + let inner = rect.shrink2(egui::vec2(12.0, 6.0)); + let response = ui.put( + inner, + egui::TextEdit::singleline(&mut vm.vocab_query) + .id(egui::Id::new(SEARCH_INPUT_ID)) + .hint_text(tr_l10n(lang, "vocab.search_placeholder")) + .text_color(theme::INK) + .frame(egui::Frame::NONE), + ); + if response.changed() { + _actions.push(FrontendAction::VocabSearch(vm.vocab_query.clone())); + } + } + let search_response = ui.interact( + circle, + ui.id().with("openless-vocab-search-toggle"), + egui::Sense::click(), + ); + ui.painter().rect_filled( + circle, + egui::CornerRadius::same(15), + if search_response.hovered() || open { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + circle, + egui::CornerRadius::same(15), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + icons::draw_icon(ui, circle.center(), IconName::Search, theme::INK_3); + if search_response.clicked() { + if open && !vm.vocab_query.is_empty() { + vm.vocab_query.clear(); + _actions.push(FrontendAction::VocabSearch(String::new())); + } else { + set_search_open(ui.ctx(), !open); + if !open { + ui.memory_mut(|memory| memory.request_focus(egui::Id::new(SEARCH_ID))); + } + } + } +} + +fn visible_indices(vm: &FrontendViewModel) -> Vec { + let query = vm.vocab_query.trim().to_lowercase(); + vm.vocab_entries + .iter() + .enumerate() + .filter(|(_, entry)| match vm.vocab_filter { + 1 => entry.learned, + 2 => !entry.learned, + _ => true, + }) + .filter(|(_, entry)| query.is_empty() || entry.phrase.to_lowercase().contains(&query)) + .map(|(index, _)| index) + .collect() +} + +// ── Word chip ─────────────────────────────────────────────────────────────── + +enum ChipAction { + None, + Toggle, + Select, + Remove, +} + +/// Tab icons: the shared icon set has a sparkle but no pencil, so the manual +/// tab draws a small pencil locally. +#[derive(Clone, Copy, PartialEq, Eq)] +enum TabIcon { + None, + Sparkle, + Feather, +} + +fn word_chip(ui: &mut egui::Ui, entry: &VocabEntry, selected: bool) -> ChipAction { + let fill = if !entry.enabled { + theme::SURFACE_2 + } else if entry.hits > 0 { + theme::BLUE_SOFT + } else { + theme::SURFACE + }; + let text_color = if entry.enabled { + theme::INK + } else { + theme::INK_4 + }; + let phrase = ui.painter().layout_no_wrap( + entry.phrase.clone(), + egui::FontId::proportional(13.0), + text_color, + ); + let hits_text = entry.hits.to_string(); + let hits_color = if entry.enabled && entry.hits > 0 { + theme::SURFACE + } else { + theme::INK_4 + }; + let hits = ui + .painter() + .layout_no_wrap(hits_text, egui::FontId::proportional(11.0), hits_color); + let hits_size = egui::vec2((hits.size().x + 12.0).max(24.0), 22.0); + let checkbox_size = 16.0; + let close_size = 22.0; + let width = + 10.0 + checkbox_size + 8.0 + phrase.size().x + 8.0 + hits_size.x + 6.0 + close_size + 10.0; + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 32.0), egui::Sense::click()); + let painter = ui.painter(); + painter.rect_filled(rect, egui::CornerRadius::same(16), fill); + painter.rect_stroke( + rect, + egui::CornerRadius::same(16), + egui::Stroke::new(0.6, theme::LINE), + egui::StrokeKind::Inside, + ); + let checkbox_rect = egui::Rect::from_center_size( + egui::pos2(rect.left() + 10.0 + checkbox_size / 2.0, rect.center().y), + egui::vec2(checkbox_size, checkbox_size), + ); + draw_checkbox(ui, checkbox_rect, selected, false); + painter.galley( + egui::pos2( + checkbox_rect.right() + 8.0, + rect.center().y - phrase.size().y / 2.0, + ), + phrase, + text_color, + ); + let close_rect = egui::Rect::from_center_size( + egui::pos2(rect.right() - 10.0 - close_size / 2.0, rect.center().y), + egui::vec2(close_size, close_size), + ); + let hits_rect = egui::Rect::from_min_size( + egui::pos2( + close_rect.left() - 6.0 - hits_size.x, + rect.center().y - hits_size.y / 2.0, + ), + hits_size, + ); + painter.rect_filled( + hits_rect, + egui::CornerRadius::same(5), + if entry.enabled && entry.hits > 0 { + theme::BLUE + } else { + theme::TOGGLE_OFF + }, + ); + painter.galley( + egui::pos2( + hits_rect.center().x - hits.size().x / 2.0, + hits_rect.center().y - hits.size().y / 2.0, + ), + hits, + hits_color, + ); + painter.circle_filled(close_rect.center(), close_size / 2.0, theme::SURFACE_2); + painter.circle_stroke( + close_rect.center(), + close_size / 2.0, + egui::Stroke::new(0.5, theme::LINE), + ); + let center = close_rect.center(); + let x_stroke = egui::Stroke::new(1.1, theme::INK_4); + painter.line_segment( + [ + center + egui::vec2(-3.0, -3.0), + center + egui::vec2(3.0, 3.0), + ], + x_stroke, + ); + painter.line_segment( + [ + center + egui::vec2(3.0, -3.0), + center + egui::vec2(-3.0, 3.0), + ], + x_stroke, + ); + + if response.clicked() { + if let Some(pointer) = response.interact_pointer_pos() { + if close_rect.contains(pointer) { + return ChipAction::Remove; + } + if checkbox_rect.contains(pointer) { + return ChipAction::Select; + } + } + return ChipAction::Toggle; + } + ChipAction::None +} + +fn draw_checkbox(ui: &egui::Ui, rect: egui::Rect, checked: bool, partial: bool) { + let painter = ui.painter(); + let fill = if checked || partial { + theme::INK + } else { + theme::SURFACE + }; + painter.rect_filled(rect, egui::CornerRadius::same(4), fill); + painter.rect_stroke( + rect, + egui::CornerRadius::same(4), + egui::Stroke::new( + 0.8, + if checked || partial { + theme::INK + } else { + theme::LINE + }, + ), + egui::StrokeKind::Inside, + ); + if checked { + let stroke = egui::Stroke::new(1.5, theme::SURFACE); + painter.line_segment( + [ + rect.left_center() + egui::vec2(3.0, 0.5), + rect.center_bottom() + egui::vec2(-1.0, -3.5), + ], + stroke, + ); + painter.line_segment( + [ + rect.center_bottom() + egui::vec2(-1.0, -3.5), + rect.right_center() + egui::vec2(-2.5, -5.0), + ], + stroke, + ); + } else if partial { + painter.rect_filled( + egui::Rect::from_center_size(rect.center(), egui::vec2(8.0, 2.0)), + egui::CornerRadius::same(1), + theme::SURFACE, + ); + } +} + +// ── Bottom: quick add + presets ───────────────────────────────────────────── + +fn quick_add( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + ui.horizontal(|ui| { + let add_width = 88.0; + let input_width = (width - add_width - 8.0).max(80.0); + let content_width = (input_width - 24.0).max(1.0); + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(18)) + .inner_margin(egui::Margin::symmetric(12, 7)) + .show(ui, |ui| { + ui.set_width(content_width); + let response = ui.add_sized( + [content_width, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_input) + .id(egui::Id::new(INPUT_ID)) + .desired_width(content_width) + .hint_text(tr_l10n(lang, "vocab.placeholder")) + .frame(egui::Frame::NONE), + ); + if (response.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter))) + || (ui.input(|input| input.key_pressed(egui::Key::Enter)) + && response.has_focus()) + { + let phrase = vm.vocab_input.trim().to_string(); + if !phrase.is_empty() { + actions.push(FrontendAction::VocabAddPhrase(phrase)); + vm.vocab_input.clear(); + } + } + }); + let add = tr_l10n(lang, "btn.add"); + let rect = egui::Rect::from_min_size( + egui::pos2(ui.cursor().min.x, ui.cursor().min.y), + egui::vec2(add_width, 34.0), + ); + if primary_button(ui, rect, add, Some(IconName::Plus)).clicked() { + let phrase = vm.vocab_input.trim().to_string(); + if !phrase.is_empty() { + actions.push(FrontendAction::VocabAddPhrase(phrase)); + vm.vocab_input.clear(); + } + } + ui.allocate_space(egui::vec2(add_width, 34.0)); + }); + ui.add_space(8.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "vocab.tip")) + .size(11.5) + .color(theme::INK_4), + ); +} + +fn presets( + ui: &mut egui::Ui, + width: f32, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let open = ui.ctx().data(|data| { + data.get_temp::(egui::Id::new(PRESETS_OPEN)) + .unwrap_or(false) + }); + card(ui, width, |ui| { + let (heading, response) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 42.0), egui::Sense::click()); + let painter = ui.painter(); + painter.text( + egui::pos2(heading.left(), heading.top() + 11.0), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "vocab.presets_title"), + egui::FontId::proportional(13.0), + theme::INK, + ); + painter.text( + egui::pos2(heading.left(), heading.top() + 30.0), + egui::Align2::LEFT_CENTER, + tr_l10n(lang, "vocab.presets_tip"), + egui::FontId::proportional(11.5), + theme::INK_4, + ); + // Tauri uses a rotated ChevronRight. Draw its two strokes directly so + // expanded state never depends on an unavailable Unicode glyph. + let center = egui::pos2(heading.right() - 8.0, heading.center().y); + let points = if open { + [ + center + egui::vec2(-3.0, -1.5), + center + egui::vec2(0.0, 1.5), + center + egui::vec2(3.0, -1.5), + ] + } else { + [ + center + egui::vec2(-1.5, -3.0), + center + egui::vec2(1.5, 0.0), + center + egui::vec2(-1.5, 3.0), + ] + }; + for pair in points.windows(2) { + painter.line_segment([pair[0], pair[1]], egui::Stroke::new(1.5, theme::INK_3)); + } + if response.clicked() { + ui.ctx() + .data_mut(|data| data.insert_temp(egui::Id::new(PRESETS_OPEN), !open)); + } + if !open { + return; + } + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); + let names = [ + tr_l10n(lang, "vocab.presets_dev_tools"), + tr_l10n(lang, "vocab.presets_products"), + tr_l10n(lang, "vocab.presets_terms"), + tr_l10n(lang, "vocab.presets_english"), + ]; + for (index, name) in names.iter().enumerate() { + let selected = vm.vocab_selected_presets.contains(&index); + let response = ui.add( + egui::Button::new(egui::RichText::new(*name).size(12.5).color(if selected { + theme::BLUE + } else { + theme::INK_2 + })) + .fill(if selected { + theme::BLUE_SOFT + } else { + theme::SURFACE_2 + }) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .min_size(egui::vec2(88.0, 30.0)), + ); + if response.clicked() { + actions.push(FrontendAction::VocabApplyPreset(index)); + } + } + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "vocab.presets_create")).size(12.5), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(96.0, 32.0)), + ) + .clicked() + { + vm.vocab_editing_preset = Some(usize::MAX); + vm.vocab_preset_name = tr_l10n(lang, "vocab.presets_new_preset").into(); + vm.vocab_preset_phrases.clear(); + } + if !vm.vocab_selected_presets.is_empty() { + let apply = tr_l10n(lang, "vocab.presets_apply"); + let rect = egui::Rect::from_min_size( + egui::pos2(ui.cursor().min.x, ui.cursor().min.y), + egui::vec2(88.0, 32.0), + ); + if primary_button(ui, rect, apply, None).clicked() { + actions.push(FrontendAction::VocabApplyPreset(usize::MAX)); + } + ui.allocate_space(egui::vec2(88.0, 32.0)); + } + }); + + if vm.vocab_editing_preset.is_some() { + ui.add_space(12.0); + ui.horizontal(|ui| { + egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .inner_margin(egui::Margin::symmetric(10, 6)) + .show(ui, |ui| { + ui.add_sized( + [180.0, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_preset_name) + .hint_text(tr_l10n(lang, "vocab.presets_name_placeholder")) + .frame(egui::Frame::NONE), + ); + }); + let save = tr_l10n(lang, "vocab.presets_save"); + if primary_button( + ui, + egui::Rect::from_min_size(ui.cursor().min, egui::vec2(84.0, 32.0)), + save, + None, + ) + .clicked() + { + let name = vm.vocab_preset_name.trim().to_owned(); + if !name.is_empty() { + actions.push(FrontendAction::VocabCreatePreset { + name, + phrases: vm.vocab_preset_phrases.clone(), + }); + } + vm.vocab_editing_preset = None; + } + ui.allocate_space(egui::vec2(84.0, 32.0)); + if ui + .add( + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "common.cancel")).size(12.0), + ) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.8, theme::LINE)) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(72.0, 32.0)), + ) + .clicked() + { + vm.vocab_editing_preset = None; + } + }); + ui.add_space(8.0); + ui.add_sized( + [ui.available_width(), 64.0], + egui::TextEdit::multiline(&mut vm.vocab_preset_phrases) + .desired_rows(3) + .hint_text(tr_l10n(lang, "vocab.presets_words_placeholder")), + ); + } + + if vm.vocab_editing_preset.is_none() && !vm.vocab_saved_presets.is_empty() { + ui.add_space(10.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(6.0, 6.0); + let saved = vm.vocab_saved_presets.clone(); + for (index, preset) in saved.iter().enumerate() { + if ui + .add( + egui::Button::new( + egui::RichText::new(fmt_l10n( + lang, + "vocab.presets_edit", + &[&preset.name], + )) + .size(12.5), + ) + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.6, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .min_size(egui::vec2(0.0, 30.0)), + ) + .clicked() + { + vm.vocab_preset_name = preset.name.clone(); + vm.vocab_preset_phrases = preset.phrases.clone(); + vm.vocab_editing_preset = Some(index); + } + } + }); + } + }); +} + +// ── New-word modal (scoped to the right content pane) ─────────────────────── + +fn new_word_overlay( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let body = layout::body_rect(ctx); + let region = egui::Rect::from_min_max( + egui::pos2(body.left() + layout::SIDEBAR_WIDTH, body.top()), + body.max, + ); + // Tauri ModalShell is 440px wide and grows with the actual preset cards. + let presets = vm.vocab_saved_presets.clone(); + let modal_width = 440.0_f32.min(region.width() - 36.0); + let modal_height = (224.0 + presets.len() as f32 * 54.0) + .max(260.0) + .min(region.height() - 32.0); + let modal = + egui::Rect::from_center_size(region.center(), egui::vec2(modal_width, modal_height)); + let mut close = ctx.input(|input| input.key_pressed(egui::Key::Escape)); + if ctx.input(|input| { + input.pointer.any_click() + && input + .pointer + .interact_pos() + .is_some_and(|pos| region.contains(pos) && !modal.contains(pos)) + }) { + close = true; + } + egui::Area::new(egui::Id::new("openless-vocab-new-word-area")) + .order(egui::Order::Foreground) + .fixed_pos(region.min) + .constrain(false) + .show(ctx, |ui| { + ui.set_min_size(region.size()); + layout::paint_blurred_overlay( + ctx, + ui, + region, + layout::content_overlay_corner_radius(ctx), + ); + let _ = ui.allocate_rect(region, egui::Sense::click()); + let mut child = ui.new_child( + egui::UiBuilder::new() + .id_salt("new-word-dialog") + .max_rect(modal) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + child.set_clip_rect(region); + let card = egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .corner_radius(egui::CornerRadius::same(16)) + .shadow(egui::Shadow { + offset: [0, 24], + blur: 32, + spread: 0, + color: egui::Color32::from_black_alpha(72), + }) + .inner_margin(egui::Margin::same(20)) + .show(&mut child, |ui| { + ui.set_width((modal_width - 44.0).max(1.0)); + ui.set_min_height((modal_height - 44.0).max(1.0)); + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.label( + egui::RichText::new(tr_l10n(lang, "vocab.newWordTitle")) + .size(15.0) + .strong() + .color(theme::INK), + ); + ui.add_space(4.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "vocab.newWordDesc")) + .size(12.5) + .color(theme::INK_3), + ); + }); + let remaining = + (modal_width - 44.0 - ui.min_rect().width() - 34.0).max(0.0); + ui.add_space(remaining); + let (rect, response) = + ui.allocate_exact_size(egui::vec2(26.0, 26.0), egui::Sense::click()); + if response.hovered() { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + theme::SURFACE_2, + ); + } + icons::draw_icon(ui, rect.center(), IconName::Close, theme::INK_4); + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp(egui::Id::new("openless-new-word-close-rect"), rect) + }); + if response.clicked() { + close = true; + } + }); + ui.add_space(12.0); + ui.horizontal(|ui| { + let field_width = (modal_width - 44.0 - 92.0).max(90.0); + let edit = egui::Frame::new() + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(19)) + .inner_margin(egui::Margin::symmetric(12, 9)) + .show(ui, |ui| { + ui.add_sized( + [field_width - 24.0, 20.0], + egui::TextEdit::singleline(&mut vm.vocab_input) + .hint_text(tr_l10n(lang, "vocab.newWordInputPlaceholder")) + .frame(egui::Frame::NONE), + ) + }) + .inner; + if ui + .ctx() + .data_mut(|data| { + data.remove_temp::(egui::Id::new(NEW_WORD_FOCUS)) + }) + .unwrap_or(false) + { + edit.request_focus(); + } + let add = tr_l10n(lang, "btn.add"); + let (button, _) = + ui.allocate_exact_size(egui::vec2(84.0, 32.0), egui::Sense::hover()); + if primary_button(ui, button, add, Some(IconName::Plus)).clicked() + || (edit.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))) + { + let word = vm.vocab_input.trim().to_string(); + if !word.is_empty() { + actions.push(FrontendAction::VocabAddPhrase(word)); + vm.vocab_input.clear(); + } + } + }); + ui.add_space(16.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "vocab.newWordTemplates")) + .size(12.5) + .strong(), + ); + ui.add_space(8.0); + egui::ScrollArea::vertical() + .id_salt("openless-new-word-templates") + .max_height((modal_height - 222.0).max(52.0)) + .auto_shrink([false, true]) + .show(ui, |ui| { + for (index, preset) in presets.iter().enumerate() { + let selected = vm.vocab_selected_presets.contains(&index); + let (rect, response) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 46.0), + egui::Sense::click(), + ); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(10), + if selected { + theme::BLUE_SOFT + } else if response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new( + if selected { 1.0 } else { 0.5 }, + if selected { + theme::BLUE + } else { + theme::LINE_STRONG + }, + ), + egui::StrokeKind::Inside, + ); + let text_clip = egui::Rect::from_min_max( + rect.min, + egui::pos2(rect.right() - 65.0, rect.bottom()), + ); + let painter = ui.painter().with_clip_rect(text_clip); + painter.text( + rect.left_top() + egui::vec2(12.0, 14.0), + egui::Align2::LEFT_CENTER, + &preset.name, + egui::FontId::proportional(13.0), + theme::INK, + ); + painter.text( + rect.left_top() + egui::vec2(12.0, 32.0), + egui::Align2::LEFT_CENTER, + preset.phrases.replace('、', " · "), + egui::FontId::proportional(11.5), + theme::INK_4, + ); + let count = + preset.phrases.split('、').filter(|s| !s.is_empty()).count(); + ui.painter().text( + egui::pos2( + rect.right() - if selected { 30.0 } else { 12.0 }, + rect.center().y, + ), + egui::Align2::RIGHT_CENTER, + fmt_l10n(lang, "vocab.newWordTemplateCount", &[&count]), + egui::FontId::proportional(11.5), + theme::INK_4, + ); + if selected { + icons::draw_icon( + ui, + rect.right_center() - egui::vec2(12.0, 0.0), + IconName::Check, + theme::INK_2, + ); + } + if response.clicked() { + if selected { + vm.vocab_selected_presets.retain(|item| *item != index); + } else { + vm.vocab_selected_presets.push(index); + } + } + if index + 1 < presets.len() { + ui.add_space(8.0); + } + } + }); + ui.add_space(18.0); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .add_enabled( + !vm.vocab_selected_presets.is_empty(), + egui::Button::new( + egui::RichText::new(tr_l10n(lang, "vocab.newWordAddSelected")) + .size(13.0) + .color(theme::SURFACE), + ) + .fill(theme::INK) + .stroke(egui::Stroke::NONE) + .corner_radius(egui::CornerRadius::same(8)), + ) + .clicked() + { + for index in vm.vocab_selected_presets.drain(..) { + actions.push(FrontendAction::VocabApplyPreset(index)); + } + close = true; + } + if ui + .add( + egui::Button::new(tr_l10n(lang, "common.close")) + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(0.5, theme::LINE_STRONG)) + .corner_radius(egui::CornerRadius::same(8)), + ) + .clicked() + { + close = true; + } + }); + }); + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::new("openless-new-word-card-rect"), + card.response.rect, + ) + }); + #[cfg(not(test))] + let _ = card; + }); + if close { + ctx.data_mut(|data| data.insert_temp(egui::Id::new(NEW_WORD_OPEN), false)); + } +} + +// ── Small shared pieces ───────────────────────────────────────────────────── + +fn error_banner(ui: &mut egui::Ui, width: f32, message: &str) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 36.0), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(10), theme::DANGER_SOFT); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(10), + egui::Stroke::new(0.5, theme::ERR), + egui::StrokeKind::Inside, + ); + ui.painter().text( + egui::pos2(rect.left() + 12.0, rect.center().y), + egui::Align2::LEFT_CENTER, + message, + egui::FontId::proportional(12.0), + theme::ERR, + ); +} + +/// Dark solid button (the Tauri `variant=primary`). +fn primary_button( + ui: &mut egui::Ui, + rect: egui::Rect, + label: &str, + icon: Option, +) -> egui::Response { + let response = ui.interact( + rect, + ui.id().with(("openless-vocab-primary", label)), + egui::Sense::click(), + ); + let painter = ui.painter().with_clip_rect(rect); + let fill = if response.hovered() { + theme::INK_2 + } else { + theme::INK + }; + painter.rect_filled(rect, egui::CornerRadius::same(8), fill); + let label_width = layout::text_width(ui, label, 12.5); + let icon_space = if icon.is_some() { 18.0 } else { 0.0 }; + let mut x = rect.center().x - (label_width + icon_space) / 2.0; + if let Some(icon) = icon { + icons::draw_icon( + ui, + egui::pos2(x + 6.0, rect.center().y), + icon, + theme::SURFACE, + ); + x += icon_space; + } + painter.text( + egui::pos2(x, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.5), + theme::SURFACE, + ); + response +} + +/// Full-width card that sizes itself to its contents. +fn card(ui: &mut egui::Ui, width: f32, contents: impl FnOnce(&mut egui::Ui)) { + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(egui::CornerRadius::same(14)) + .inner_margin(egui::Margin::same(CARD_PADDING as i8)) + .show(ui, |ui| { + ui.set_width((width - CARD_PADDING * 2.0).max(1.0)); + contents(ui); + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bottom_panel_stays_at_viewport_bottom_and_grows_upward() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + vocab_unsupported: false, + ..Default::default() + }; + let mut actions = Vec::new(); + let mut collapsed_top = 0.0; + for expanded in [false, true] { + ctx.data_mut(|data| data.insert_temp(egui::Id::new(PRESETS_OPEN), expanded)); + // The first pass measures the panel; the next pass places it at the bottom. + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1240.0, 800.0), + )), + ..Default::default() + }); + layout::content_panel(&ctx, |ui| page(ui, &mut vm, &mut actions)); + let _ = crate::ui::frontend::end_pass(&ctx); + } + let rect: egui::Rect = ctx.data(|data| { + data.get_temp(egui::Id::new("openless-vocab-bottom-test-rect")) + .unwrap() + }); + let bottom: f32 = ctx.data(|data| { + data.get_temp(egui::Id::new("openless-vocab-viewport-test-bottom")) + .unwrap() + }); + assert!( + (rect.bottom() - bottom).abs() < 0.5, + "expanded={expanded}, rect={rect:?}, bottom={bottom}" + ); + if expanded { + assert!( + rect.top() < collapsed_top, + "expanded panel must grow upward" + ); + } else { + collapsed_top = rect.top(); + } + } + } + + #[test] + fn filters_match_tauri_segmented_control_spacing() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel::default(); + let _ = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1240.0, 800.0), + )), + ..Default::default() + }, + |ui| tool_row(ui, 900.0, &mut vm, &[], &BTreeSet::new(), &mut Vec::new()), + ); + let (outer, items): (egui::Rect, Vec) = ctx.data(|data| { + data.get_temp(egui::Id::new("openless-vocab-tabs-test-rects")) + .unwrap() + }); + assert_eq!(outer.height(), 39.0); + assert_eq!(items.len(), 3); + assert_eq!(items[0].height(), 32.0); + assert_eq!(items[0].left() - outer.left(), 3.0); + assert_eq!(items[1].left() - items[0].right(), 2.0); + assert_eq!(outer.right() - items[2].right(), 3.0); + } + + #[test] + fn new_word_dialog_uses_tauri_card_and_real_presets() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + ..Default::default() + }; + let presets: Vec<_> = [ + ("programmer", "PR、CI、Rust"), + ("chef", "heat、knife"), + ("clerk", "letter、report"), + ] + .into_iter() + .map( + |(name, phrases)| super::super::view_model::SavedVocabPreset { + name: name.into(), + phrases: phrases.into(), + }, + ) + .collect(); + vm.vocab_saved_presets = presets.clone(); + let mut heights = Vec::new(); + for count in [1, 3] { + vm.vocab_saved_presets.truncate(count); + let _ = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1240.0, 800.0), + )), + ..Default::default() + }, + |_ui| new_word_overlay(&ctx, &mut vm, &mut Vec::new()), + ); + let card: egui::Rect = ctx.data(|data| { + data.get_temp(egui::Id::new("openless-new-word-card-rect")) + .unwrap() + }); + let close: egui::Rect = ctx.data(|data| { + data.get_temp(egui::Id::new("openless-new-word-close-rect")) + .unwrap() + }); + assert!(card.contains_rect(close), "close={close:?}, card={card:?}"); + assert!((card.width() - 440.0).abs() < 10.0, "card={card:?}"); + assert!(card.height() < 440.0, "card={card:?}"); + heights.push(card.height()); + if count == 1 { + vm.vocab_saved_presets = presets.clone(); + } + } + assert!( + heights[1] > heights[0] + 90.0, + "modal must grow with real presets: {heights:?}" + ); + } +} diff --git a/openless-all/app/linux-egui/src/ui/local_hotkeys.rs b/openless-all/app/linux-egui/src/ui/local_hotkeys.rs new file mode 100644 index 000000000..aa51b8ad8 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/local_hotkeys.rs @@ -0,0 +1,156 @@ +//! 窗口进程里的本地热键匹配(读 egui 输入的那一半)。 +//! +//! 判定与类型在库侧 [`openless_linux_egui::local_hotkeys`];这里只做「读本帧输入 +//! → 捕获候选 → 命中判定 → 产出边沿」。之所以放在可执行文件里:`ui/` 属于 +//! `main.rs`,而协议类型必须在库侧(`ui::bridge` / `popup` 要用)。 +//! +//! 捕获复用设置页录制器的 `captured_binding`,命中判定复用注册给插件的 +//! `(keysym, states)` 换算,因此不会出现第二套键名表。 + +use openless_core::shared_types::ShortcutBinding; +use openless_core::HotkeyRuntimeTarget; +use openless_linux_egui::local_hotkeys::{ + match_hotkey, next_local_press_id, LocalHotkey, LocalHotkeyEdge, LocalHotkeyEdgeKind, +}; + +use crate::ui::frontend::settings::{bare_modifier_name, captured_binding}; + +/// 一次按键的本地匹配器(每个窗口进程一份)。 +#[derive(Default)] +pub struct LocalHotkeyMatcher { + /// 裸修饰键绑定的跨帧挂起态(由 `captured_binding` 维护,见它的文档)。 + pending_modifier: Option, + /// 已按下、还没松开的组合键。 + held: Option, +} + +struct HeldHotkey { + hotkey: LocalHotkey, + primary: String, + key: Option, + press_id: u64, +} + +impl LocalHotkeyMatcher { + /// 读本帧输入,返回至多一个热键边沿。 + /// + /// 每帧调用一次;没有按下/松开配置里的热键时返回 `None`,其余按键一律放行 + /// (本函数只读 `InputState`,不消费事件,文本框照常收到按键)。 + pub fn poll( + &mut self, + ctx: &egui::Context, + target: &HotkeyRuntimeTarget, + ) -> Option { + if let Some(held) = self.held.take() { + if held.key.is_none() && first_press_key(ctx).is_some() { + return Some(LocalHotkeyEdge { + hotkey: held.hotkey, + kind: LocalHotkeyEdgeKind::Cancelled, + press_id: held.press_id, + }); + } + if !released(ctx, held.key, &held.primary) { + self.held = Some(held); + return None; + } else { + return Some(LocalHotkeyEdge { + hotkey: held.hotkey, + kind: LocalHotkeyEdgeKind::Released, + press_id: held.press_id, + }); + } + } + + // A modifier-only binding needs a real press edge so Hold mode can start + // recording and stop on release. Core's modifier grace period handles + // ordinary typing; a following non-modifier key sends Cancelled above. + let bare_modifier = ctx + .input(|input| bare_modifier_name(input.modifiers)) + .map(str::to_string); + if let Some(primary) = bare_modifier { + let binding = ShortcutBinding { + primary: primary.clone(), + modifiers: Vec::new(), + }; + if let Some(hotkey) = match_hotkey(target, &binding) { + let press_id = next_local_press_id(); + self.held = Some(HeldHotkey { + hotkey: hotkey.clone(), + primary, + key: None, + press_id, + }); + return Some(LocalHotkeyEdge { + hotkey, + kind: LocalHotkeyEdgeKind::Pressed, + press_id, + }); + } + } + + let (primary, modifiers) = captured_binding(ctx, &mut self.pending_modifier)?; + if primary.is_empty() { + return None; + } + let pressed = ShortcutBinding { + primary: primary.clone(), + modifiers, + }; + let hotkey = match_hotkey(target, &pressed)?; + // 组合键在按下时就捕获到(`key` 有值)→ 发 Pressed,松开发 Released; + // 裸修饰键只能在松手时判定(egui 不给修饰键单独的 Key 事件)→ 发 Combined。 + let key = first_press_key(ctx); + let press_id = next_local_press_id(); + match key { + Some(key) => { + self.held = Some(HeldHotkey { + hotkey: hotkey.clone(), + primary, + key: Some(key), + press_id, + }); + Some(LocalHotkeyEdge { + hotkey, + kind: LocalHotkeyEdgeKind::Pressed, + press_id, + }) + } + None => Some(LocalHotkeyEdge { + hotkey, + kind: LocalHotkeyEdgeKind::Combined, + press_id, + }), + } + } +} + +/// 挂起的组合键是否已经松开。 +fn released(ctx: &egui::Context, key: Option, primary: &str) -> bool { + match key { + Some(key) => ctx.input(|input| input.key_released(key)), + // 裸修饰键:`bare_modifier_name` 只在「恰好按住一个修饰键类别」时给名字, + // 所以它不再是那个名字就说明松开了。 + None => bare_modifier_name(ctx.input(|input| input.modifiers)) != Some(primary), + } +} + +/// 本帧第一个「真键」按下的 `Key`(裸修饰键绑定返回 `None`)。 +fn first_press_key(ctx: &egui::Context) -> Option { + ctx.input(|input| { + input.events.iter().find_map(|event| match event { + egui::Event::Key { + key, + pressed: true, + repeat: false, + .. + } if !matches!( + key, + egui::Key::Escape | egui::Key::Copy | egui::Key::Cut | egui::Key::Paste + ) => + { + Some(*key) + } + _ => None, + }) + }) +} diff --git a/openless-all/app/linux-egui/src/ui/mod.rs b/openless-all/app/linux-egui/src/ui/mod.rs index c99acac83..d2c7d69c5 100644 --- a/openless-all/app/linux-egui/src/ui/mod.rs +++ b/openless-all/app/linux-egui/src/ui/mod.rs @@ -1,4 +1,6 @@ +pub mod backdrop; +pub mod bridge; pub mod frontend; -pub mod settings; +pub mod local_hotkeys; 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 deleted file mode 100644 index 17ad7f9ae..000000000 --- a/openless-all/app/linux-egui/src/ui/settings.rs +++ /dev/null @@ -1,184 +0,0 @@ -//! 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 index 06095b6b3..734a5d198 100644 --- a/openless-all/app/linux-egui/src/ui/shell.rs +++ b/openless-all/app/linux-egui/src/ui/shell.rs @@ -1,19 +1,15 @@ -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; +//! Shell page identity. +//! +//! The egui shell used to live here (titlebar / sidebar / content panel). All of +//! that was replaced by `ui::frontend`; only the page identity remains because +//! the host keeps its own navigation state and maps it onto the frontend pages. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum Page { #[default] Overview, History, + QuickNote, Vocabulary, Styles, Marketplace, @@ -22,439 +18,3 @@ pub enum Page { 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 index 1577b5852..f8813b059 100644 --- a/openless-all/app/linux-egui/src/ui/theme.rs +++ b/openless-all/app/linux-egui/src/ui/theme.rs @@ -1,101 +1,264 @@ 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; +pub const BLUE: egui::Color32 = egui::Color32::from_rgb(37, 99, 235); +pub const BLUE_SOFT: egui::Color32 = egui::Color32::from_rgb(239, 245, 255); +pub const CANVAS: egui::Color32 = egui::Color32::from_rgb(250, 250, 250); +pub const SURFACE: egui::Color32 = egui::Color32::WHITE; +pub const SURFACE_2: egui::Color32 = egui::Color32::from_rgb(244, 244, 245); +/// Tauri `--ol-sidebar-bg`:左侧导航栏底色。比内容区(纯白)更灰,两者不能搞反—— +/// 侧栏灰、内容白是 Tauri 的层次关系(`ol-sidebar-surface` / `ol-console-main`)。 +pub const SIDEBAR: egui::Color32 = egui::Color32::from_rgb(240, 240, 241); +/// 自绘标题栏底色。Tauri 的 Linux 标题栏用 `--ol-linux-titlebar-bg` +/// (`rgba(250,250,250,0.92)`,浮在不透明桌面之上才显灰);egui 的窗口是不透明的, +/// 所以直接取等价的浅灰,而不是纯白。 +pub const TITLEBAR: egui::Color32 = SURFACE_2; +/// Tauri `--ol-pill-blue-border`:蓝色徽章的描边(BETA 标签)。 +pub const BLUE_PILL_BORDER: egui::Color32 = egui::Color32::from_rgba_premultiplied(23, 40, 60, 61); +/// Tauri `--ol-segmented-bg`: the segmented-control track. +pub const SEGMENTED_TRACK: egui::Color32 = egui::Color32::from_rgba_premultiplied(10, 10, 10, 10); +/// Tauri `--ol-segmented-active-bg`: the selected chip is a plain white surface. +pub const SEGMENTED_ACTIVE_BG: egui::Color32 = SURFACE; +/// Tauri `--ol-segmented-active-shadow` 第二段 `0 0 0 0.5px rgba(0,0,0,0.06)`: +/// 选中片以细环代替描边(Tauri 的选中片 `border: 0`)。 +pub const SEGMENTED_ACTIVE_RING: egui::Color32 = egui::Color32::from_black_alpha(15); +/// Tauri `--ol-segmented-active-shadow` 第一段 `0 1px 2px rgba(0,0,0,0.06)`。 +/// egui 没有高斯模糊的矩形阴影,用向下偏移 1px 的淡色圆角矩形近似同一种"浮起"观感。 +pub const SEGMENTED_ACTIVE_SHADOW: egui::Color32 = egui::Color32::from_black_alpha(10); +pub const LINE: egui::Color32 = egui::Color32::from_rgb(228, 228, 231); +pub const INK: egui::Color32 = egui::Color32::from_rgb(9, 9, 11); +pub const INK_2: egui::Color32 = egui::Color32::from_rgb(63, 63, 70); +pub const INK_3: egui::Color32 = egui::Color32::from_rgb(113, 113, 122); +pub const INK_4: egui::Color32 = egui::Color32::from_rgb(161, 161, 170); +pub const OK: egui::Color32 = egui::Color32::from_rgb(22, 163, 74); +/// Tauri `--ol-line-soft`: 设置行之间的分隔线(比 `--ol-line` 更淡)。 +pub const LINE_SOFT: egui::Color32 = egui::Color32::from_rgb(244, 244, 245); +/// Tauri `--ol-line-strong`: 输入框 / 次级按钮的描边。 +pub const LINE_STRONG: egui::Color32 = egui::Color32::from_rgb(212, 212, 216); +/// Tauri `--ol-settings-rail-bg`: 设置弹窗左侧导航底色。 +pub const RAIL_BG: egui::Color32 = egui::Color32::from_rgb(240, 240, 241); +/// Tauri `--ol-settings-content-bg`: 设置弹窗内容区底色(卡片是白色的)。 +pub const CONTENT_BG: egui::Color32 = egui::Color32::from_rgb(247, 247, 248); +/// Tauri `--ol-style-card-icon-bg`: 风格包图标按钮的灰底。 +pub const STYLE_CARD_ICON_BG: egui::Color32 = + egui::Color32::from_rgba_premultiplied(141, 141, 143, 158); +/// Tauri `--ol-style-card-icon-bg-active`: 悬停时的灰底。 +pub const STYLE_CARD_ICON_BG_ACTIVE: egui::Color32 = egui::Color32::from_rgb(212, 212, 216); +/// Tauri `--ol-nav-hover-bg`: 侧栏/导航项悬停底色。 +pub const NAV_HOVER: egui::Color32 = egui::Color32::from_rgba_premultiplied(0, 0, 0, 10); +/// Tauri `--ol-toggle-off-bg`: 关闭态开关轨道。 +pub const TOGGLE_OFF: egui::Color32 = egui::Color32::from_rgba_premultiplied(0, 0, 0, 38); +/// Tauri `--ol-overlay-bg`: 设置/市场遮罩。 +pub const OVERLAY: egui::Color32 = egui::Color32::from_rgba_premultiplied(5, 5, 7, 82); +/// Tauri `--ol-err` 的淡底:红框提示卡。 +pub const DANGER_SOFT: egui::Color32 = egui::Color32::from_rgba_premultiplied(37, 11, 11, 18); +/// Tauri `--ol-warn-soft`: 警告卡底色。 +pub const WARN_SOFT: egui::Color32 = egui::Color32::from_rgb(255, 247, 237); +/// Tauri `--ol-warn`: 已配置但非必选的提示色。 +pub const WARN: egui::Color32 = egui::Color32::from_rgb(217, 119, 6); +/// Tauri `--ol-err`: used by the denied permission state. +/// Tauri `--ol-capsule-badge-bg` / `--ol-capsule-badge-border`(浅色)。 +pub const CAPSULE_BADGE_BG: egui::Color32 = egui::Color32::from_rgb(250, 250, 250); +pub const CAPSULE_BADGE_BORDER: egui::Color32 = + egui::Color32::from_rgba_premultiplied(9, 24, 58, 64); +pub const ERR: egui::Color32 = egui::Color32::from_rgb(220, 38, 38); -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) +/// Font key of the registered Medium face (see [`medium_font`]). +const MEDIUM_FACE: &str = "openless-medium"; +/// Named font family holding the Medium face plus the regular chain as fallback. +pub const MEDIUM_FAMILY: &str = "openless-medium-family"; + +static MEDIUM_AVAILABLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// Font for Tauri's `font-weight: 500` labels (setting rows). +/// +/// Uses the fontconfig-resolved Medium face when the desktop ships one; otherwise +/// falls back to the regular proportional face. The fallback is deliberate: egui +/// cannot synthesise a weight, and faking it would misreport the alignment. +pub fn medium_font(size: f32) -> egui::FontId { + if MEDIUM_AVAILABLE.load(std::sync::atomic::Ordering::Relaxed) { + egui::FontId::new(size, egui::FontFamily::Name(MEDIUM_FAMILY.into())) + } else { + egui::FontId::proportional(size) + } } -macro_rules! token { - ($($name:ident),* $(,)?) => { $(pub fn $name() -> egui::Color32 { TOKENS.with(|t| color(t.get().$name)) })* }; + +/// Resolve the file + face index fontconfig would pick for `query`. +/// +/// This is the same face the Tauri/WebKit app gets through `system-ui`. It +/// matters for `.ttc` collections: loading one without an index silently picks +/// face 0 (Noto Sans CJK **JP**), which is why Simplified Chinese used to render +/// with Japanese glyph variants. +fn fontconfig_match(query: &str) -> Option<(PathBuf, u32)> { + let output = std::process::Command::new("fc-match") + .args(["-f", "%{file}|%{index}", query]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let text = String::from_utf8_lossy(&output.stdout); + let (file, index) = text.split_once('|')?; + let file = file.trim(); + if file.is_empty() { + return None; + } + Some((PathBuf::from(file), index.trim().parse().unwrap_or(0))) } -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. + +/// Install the Linux font stack. +/// +/// Faces are resolved through fontconfig (so HarmonyOS Sans, Noto or whatever +/// the desktop prefers is used, exactly like the Tauri build) and CJK is loaded +/// with its proper face index. The egui defaults stay as the last fallback. pub fn install(ctx: &egui::Context) { - let mut candidates: Vec<(&str, PathBuf)> = Vec::new(); + enable_antialiasing(ctx); + // (font key, file, face index, proportional?, monospace?) + let mut candidates: Vec<(String, PathBuf, u32, bool, bool)> = Vec::new(); if let Some(path) = std::env::var_os("OPENLESS_IME_FONT").map(PathBuf::from) { - candidates.push(("openless-primary", path)); + candidates.push(("openless-primary".to_owned(), path, 0, true, true)); } - 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"), - ), + // 拉丁字体排在 CJK 前面:`fc-match sans-serif` 在中文桌面(例如 Noto Sans CJK SC) + // 返回的是 CJK 面,而 CJK 面的 `…`(U+2026)是**居中**的点(全角省略号), + // 于是「搜索转写内容…」的省略号看着像「···」;macOS 的 `system-ui` 是拉丁优先、 + // CJK 回退,所以我们要同样的顺序。缺字形时照样回退到下面的 CJK 面。 + if let Some((path, index)) = fontconfig_match("sans-serif:lang=en") { + candidates.push(("openless-ui-latin".to_owned(), path, index, true, false)); + } + if let Some((path, index)) = fontconfig_match("sans-serif") { + candidates.push(("openless-ui-sans".to_owned(), path, index, true, false)); + } + if let Some((path, index)) = fontconfig_match("monospace") { + candidates.push(("openless-ui-mono".to_owned(), path, index, false, true)); + } + for (name, query) in [ + ("openless-cjk", ":lang=zh-cn"), + // 本机 fc-match ':lang=ar/th/hi' 错误地总返回 Noto Sans CJK SC, + // 这些脚本的字形根本不存在;显式请求对应的 Noto 家族。 + ("openless-arabic", "Noto Sans Arabic"), + ("openless-thai", "Noto Looped Thai"), + ("openless-devanagari", "Noto Sans Devanagari UI"), + ] { + if let Some((path, index)) = fontconfig_match(query) { + candidates.push((name.to_owned(), path, index, true, true)); + } + } + // Last-resort fallbacks for minimal systems without fontconfig entries. + for (name, path) in [ ( - "openless-thai", - PathBuf::from("/usr/share/fonts/truetype/noto/NotoSansThai-Regular.ttf"), + "openless-legacy-cjk", + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", ), ( - "openless-devanagari", - PathBuf::from("/usr/share/fonts/truetype/noto/NotoSansDevanagari-Regular.ttf"), + "openless-legacy-latin", + "/usr/share/fonts/truetype/noto/NotoSans-Regular.ttf", ), - ]); + ] { + candidates.push((name.to_owned(), PathBuf::from(path), 0, true, true)); + } let mut fonts = egui::FontDefinitions::default(); - let mut loaded = Vec::new(); - for (name, path) in candidates { + let mut proportional: Vec = Vec::new(); + let mut monospace: Vec = Vec::new(); + let mut seen: Vec<(PathBuf, u32)> = Vec::new(); + for (name, path, index, is_proportional, is_monospace) in candidates { + if seen + .iter() + .any(|(seen_path, seen_index)| seen_path == &path && *seen_index == index) + { + continue; + } let Ok(bytes) = std::fs::read(&path) else { continue; }; + seen.push((path, index)); + fonts.font_data.insert( + name.clone(), + egui::FontData { + font: bytes.into(), + index, + tweak: Default::default(), + } + .into(), + ); + if is_proportional { + proportional.push(name.clone()); + } + if is_monospace { + monospace.push(name); + } + } + for name in proportional.iter().rev() { + fonts + .families + .entry(egui::FontFamily::Proportional) + .or_default() + .insert(0, name.clone()); + } + for name in monospace.iter().rev() { fonts - .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()); + .families + .entry(egui::FontFamily::Monospace) + .or_default() + .insert(0, name.clone()); + } + + // Tauri 的 `SettingRow` 标签是 `font-weight: 500`。egui 不能选可变字体的字重轴, + // 所以去 fontconfig 要一个真正的 Medium 面(例如 Noto Sans CJK Medium)单独注册 + // 成一个命名族;桌面没有 500 面时退回 Proportional(见 `medium_font`)。 + let regular_face = fontconfig_match("sans-serif"); + let medium_face = fontconfig_match("sans-serif:weight=medium"); + if let Some((path, index)) = medium_face { + if regular_face.as_ref() != Some(&(path.clone(), index)) { + if let Ok(bytes) = std::fs::read(&path) { + fonts.font_data.insert( + MEDIUM_FACE.to_owned(), + egui::FontData { + font: bytes.into(), + index, + tweak: Default::default(), + } + .into(), + ); + let mut chain = vec![MEDIUM_FACE.to_owned()]; + chain.extend( + fonts + .families + .get(&egui::FontFamily::Proportional) + .cloned() + .unwrap_or_default(), + ); + fonts + .families + .insert(egui::FontFamily::Name(MEDIUM_FAMILY.into()), chain); + MEDIUM_AVAILABLE.store(true, std::sync::atomic::Ordering::Relaxed); + } } } + ctx.set_fonts(fonts); apply_visuals(ctx, openless_core::shared_types::ThemeMode::System); - let mut style = (*ctx.style()).clone(); - style.spacing.item_spacing = egui::vec2(8.0, 8.0); - style.spacing.button_padding = egui::vec2(10.0, 6.0); - ctx.set_style(style); - 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); + for theme in [egui::Theme::Dark, egui::Theme::Light] { + ctx.style_mut_of(theme, |style| { + style.spacing.item_spacing = egui::vec2(8.0, 8.0); + style.spacing.button_padding = egui::vec2(10.0, 6.0); + }); + } } +/// Feather shape edges in physical pixels on both the WGPU windows (which also +/// use 4x MSAA) and the single-sample EGL layer surface. +pub fn enable_antialiasing(ctx: &egui::Context) { + ctx.tessellation_options_mut(|options| { + options.feathering = true; + options.feathering_size_in_pixels = 1.0; + }); +} + +/// Apply the light/dark visual theme. pub fn apply_visuals(ctx: &egui::Context, mode: openless_core::shared_types::ThemeMode) { let dark = match mode { openless_core::shared_types::ThemeMode::System => { @@ -104,20 +267,58 @@ pub fn apply_visuals(ctx: &egui::Context, mode: openless_core::shared_types::The 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); + if !dark { + visuals.panel_fill = CANVAS; + visuals.window_fill = SURFACE; + visuals.faint_bg_color = SURFACE_2; + visuals.selection.bg_fill = BLUE_SOFT; + } + visuals.selection.stroke = egui::Stroke::new(1.0, BLUE); + visuals.widgets.inactive.corner_radius = egui::CornerRadius::same(7); + visuals.widgets.hovered.corner_radius = egui::CornerRadius::same(7); + visuals.widgets.active.corner_radius = egui::CornerRadius::same(7); + // egui derives a widget's padding from + // `button_padding + visuals.expansion - visuals.bg_stroke.width`, and its + // stock styles have `inactive.bg_stroke.width = 0.0` against + // `hovered.bg_stroke.width = 1.0`. A plain `egui::Button` therefore shrinks + // by 2×2px the moment the pointer enters it, and every sibling in the row + // reflows with it — the "buttons wobble while I hover" report. Pin the + // stroke width and expansion across all interactive states so a control's + // *size* no longer depends on the pointer, while the fills still light the + // hovered/pressed state up. + let hairline = egui::Stroke::new(0.5, if dark { LINE } else { LINE_STRONG }); + for state in [ + &mut visuals.widgets.inactive, + &mut visuals.widgets.hovered, + &mut visuals.widgets.active, + &mut visuals.widgets.open, + ] { + state.bg_stroke = hairline; + state.expansion = 0.0; + } ctx.set_visuals(visuals); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn antialiasing_is_enabled_even_if_tessellation_was_disabled() { + let ctx = egui::Context::default(); + ctx.tessellation_options_mut(|options| { + options.feathering = false; + options.feathering_size_in_pixels = 0.0; + }); + enable_antialiasing(&ctx); + ctx.tessellation_options(|options| { + assert!(options.feathering); + assert_eq!(options.feathering_size_in_pixels, 1.0); + }); + } +} diff --git a/openless-all/app/linux-egui/src/ui_catalog.rs b/openless-all/app/linux-egui/src/ui_catalog.rs deleted file mode 100644 index 94e80486d..000000000 --- a/openless-all/app/linux-egui/src/ui_catalog.rs +++ /dev/null @@ -1,41 +0,0 @@ -//! 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 dbc610779..52cd0c41a 100644 --- a/openless-all/app/linux-egui/src/ui_state.rs +++ b/openless-all/app/linux-egui/src/ui_state.rs @@ -6,7 +6,7 @@ use std::path::PathBuf; use crate::desktop::atomic_save; -use crate::i18n::{LocalePref, FOLLOW_SYSTEM}; +use crate::i18n::LocalePref; const STATE_FILE: &str = "linux-ui-state.json"; @@ -77,6 +77,26 @@ pub fn load_locale_pref() -> LocalePref { /// 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> { + write_state_value("locale", serde_json::Value::String(pref.to_tag())) +} + +/// The state document as a JSON object. Unreadable or non-object content +/// degrades to an empty map so one bad field can never wedge the whole file. +fn read_state_object() -> serde_json::Map { + let Some(path) = ui_state_path() else { + return serde_json::Map::new(); + }; + let Ok(raw) = std::fs::read_to_string(&path) else { + return serde_json::Map::new(); + }; + match serde_json::from_str::(&raw) { + Ok(serde_json::Value::Object(map)) => map, + _ => serde_json::Map::new(), + } +} + +/// Merge one key into the state document, leaving every other key intact. +fn write_state_value(key: &str, value: serde_json::Value) -> Result<(), UiStateError> { let Some(dir) = ui_state_dir() else { return Err(io_error( "resolve ui-state directory", @@ -90,17 +110,9 @@ pub fn save_locale_pref(pref: LocalePref) -> Result<(), UiStateError> { )); }; 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) + let mut document = read_state_object(); + document.insert(key.to_string(), value); + let bytes = serde_json::to_vec_pretty(&serde_json::Value::Object(document)) .map_err(|error| UiStateError::Json(error.to_string()))?; atomic_save(&path, &bytes) .map(|_| ()) @@ -113,30 +125,20 @@ pub fn save_locale_pref(pref: LocalePref) -> Result<(), UiStateError> { }) } -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() +/// Whether the user dismissed the shortcut card on the Quick Note page. +/// A missing key keeps the card visible, matching the upstream default. +pub fn load_quick_note_shortcut_hidden() -> bool { + read_state_object() + .get("quick_note_shortcut_hidden") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false) } -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())) + +pub fn save_quick_note_shortcut_hidden(hidden: bool) -> Result<(), UiStateError> { + write_state_value( + "quick_note_shortcut_hidden", + serde_json::Value::Bool(hidden), + ) } #[cfg(test)] @@ -193,6 +195,22 @@ mod tests { }); } + #[test] + fn quick_note_shortcut_visibility_roundtrips_without_clobbering_locale() { + with_tmp_state(|_dir| { + // Absent key = the card is visible, matching the upstream default. + assert!(!load_quick_note_shortcut_hidden()); + save_locale_pref(LocalePref::Lang(Lang::Ja)).unwrap(); + save_quick_note_shortcut_hidden(true).unwrap(); + assert!(load_quick_note_shortcut_hidden()); + // Writing one key must not drop the other. + assert_eq!(load_locale_pref(), LocalePref::Lang(Lang::Ja)); + save_quick_note_shortcut_hidden(false).unwrap(); + assert!(!load_quick_note_shortcut_hidden()); + assert_eq!(load_locale_pref(), LocalePref::Lang(Lang::Ja)); + }); + } + #[test] fn corrupt_state_file_degrades_to_follow_system() { with_tmp_state(|_dir| { diff --git a/openless-all/app/linux-egui/src/updater.rs b/openless-all/app/linux-egui/src/updater.rs deleted file mode 100644 index 15c4ed162..000000000 --- a/openless-all/app/linux-egui/src/updater.rs +++ /dev/null @@ -1,1252 +0,0 @@ -//! 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 deleted file mode 100644 index fe7a4ff4b..000000000 --- a/openless-all/app/linux-egui/src/x11_desktop.rs +++ /dev/null @@ -1,392 +0,0 @@ -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/fcitx5_config_contract.rs b/openless-all/app/linux-egui/tests/fcitx5_config_contract.rs new file mode 100644 index 000000000..8cc2f4882 --- /dev/null +++ b/openless-all/app/linux-egui/tests/fcitx5_config_contract.rs @@ -0,0 +1,91 @@ +//! 输入法配置不变量契约(Tauri-free)。 +//! +//! 宿主对输入法只做一件事:把热键注册给 fcitx5 插件。它**从不改动输入法自己的 +//! 配置文件**。 +//! +//! 这条不变量是踩过坑后补的:曾有一版认为「拼音把分号注册成快速短语触发键,会在 +//! 插件之前吃掉 `Ctrl+Shift+;`」,于是启动时自动清空 `~/.config/fcitx5/conf/` +//! 里那一行。真机实测证伪了那个前提 —— fcitx 的 `Key::check` 要求修饰位精确相等 +//! (`semicolon` 是 `states=0`,而 `Ctrl+Shift+;` 到达时是 `states=Ctrl`),分号 +//! 占用与 QA 热键本来就能共存。那段逻辑留下的唯一效果是「每次启动偷偷改用户输入法 +//! 配置」,因此删除。 +//! +//! 需要在设置页**读取**(而不是写入)引擎配置时,请在这里显式放行并写明理由, +//! 不要悄悄绕过这条契约。 + +use std::path::{Path, PathBuf}; + +fn strip_comments(source: &str) -> String { + let bytes = source.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + let n = bytes.len(); + while i < n { + if bytes[i] == b'/' && i + 1 < n && bytes[i + 1] == b'*' { + i += 2; + while i + 1 < n && !(bytes[i] == b'*' && bytes[i + 1] == b'/') { + i += 1; + } + i = (i + 2).min(n); + continue; + } + if bytes[i] == b'/' && i + 1 < n && bytes[i + 1] == b'/' { + while i < n && bytes[i] != b'\n' { + i += 1; + } + continue; + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).unwrap() +} + +fn source_files_under(root: &Path) -> Vec { + let mut files = Vec::new(); + for entry in std::fs::read_dir(root).unwrap() { + let entry = entry.unwrap(); + let path = entry.path(); + if path.is_dir() { + files.extend(source_files_under(&path)); + } else if path.extension().is_some_and(|ext| ext == "rs") { + files.push(path); + } + } + files +} + +fn crate_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() +} + +/// 用户输入法配置目录:任何写入都必须落到这里,所以出现即违规。 +const INPUT_METHOD_CONFIG_DIR: &str = ".config/fcitx5"; +/// 引擎的按键保留设置:我们曾去清空它,绝不允许再次出现。 +const ENGINE_RESERVED_KEY_SETTING: &str = "QuickPhraseKey"; + +#[test] +fn the_host_never_rewrites_the_input_method_configuration() { + let root = crate_root(); + let mut violations = Vec::new(); + for file in source_files_under(&root.join("src")) { + let source = strip_comments(&std::fs::read_to_string(&file).unwrap()); + for (index, line) in source.lines().enumerate() { + if line.contains(INPUT_METHOD_CONFIG_DIR) || line.contains(ENGINE_RESERVED_KEY_SETTING) + { + violations.push(format!( + "{}:{}: {}", + file.strip_prefix(&root).unwrap().display(), + index + 1, + line.trim() + )); + } + } + } + assert!( + violations.is_empty(), + "The Linux egui host must never touch the input method's own configuration: it registers \ + hotkeys with the fcitx5 addon and leaves ~/.config/fcitx5 alone. Found:\n{}", + violations.join("\n") + ); +} diff --git a/openless-all/app/linux-egui/tests/fcitx5_contract.rs b/openless-all/app/linux-egui/tests/fcitx5_contract.rs index 926abd4ce..ca5a3fe56 100644 --- a/openless-all/app/linux-egui/tests/fcitx5_contract.rs +++ b/openless-all/app/linux-egui/tests/fcitx5_contract.rs @@ -12,8 +12,11 @@ use openless_linux_egui::{ #[ignore = "requires a running fcitx5 DBus service"] fn fcitx5_dbus_methods_and_listener_have_stable_platform_semantics() { assert!(fcitx5_available(), "fcitx5 service should answer DBus Ping"); - set_fcitx5_hotkeys(vec!["Shift_L".to_string()]).expect("set fcitx5 hotkey"); - set_fcitx5_less_computer_hotkey_raw(65, 0).expect("set Less Computer hotkey"); + // 这个契约测试只验证 DBus 方法可用:**必须清空**而不是注册真实按键。 + // 曾经这里注册 Shift_L + 'A',在开发机上跑 --ignored 会真的让插件吞掉 + // Shift/字母输入(用户踩过这个坑),所以现在只发清零请求。 + set_fcitx5_hotkeys(Vec::new()).expect("clear the legacy fcitx5 hotkey list"); + set_fcitx5_less_computer_hotkey_raw(0, 0).expect("clear the Less Computer hotkey"); let listener = Fcitx5HotkeyListener::start().expect("start fcitx5 hotkey listener"); assert!(listener.take_error().is_none()); @@ -70,7 +73,7 @@ fn fcitx5_dbus_methods_and_listener_have_stable_platform_semantics() { )); assert!(matches!( events[6], - openless_linux_egui::LinuxHotkeyEvent::TranslationPressed + openless_linux_egui::LinuxHotkeyEvent::TranslationPressed { .. } )); drop(listener); diff --git a/openless-all/app/linux-egui/tests/host_contract.rs b/openless-all/app/linux-egui/tests/host_contract.rs index 909337fdd..e05fe9d1e 100644 --- a/openless-all/app/linux-egui/tests/host_contract.rs +++ b/openless-all/app/linux-egui/tests/host_contract.rs @@ -17,7 +17,8 @@ use openless_linux_egui::{ fn history_session(id: &str) -> DictationSession { DictationSession { id: id.to_string(), - created_at: "2026-08-27T00:00:00Z".to_string(), + // Keep this inside the 30-day retention window regardless of when CI runs. + created_at: chrono::Utc::now().to_rfc3339(), source: HistorySource::Voice, raw_transcript: "raw".to_string(), asr_transcript: None, @@ -237,9 +238,12 @@ async fn forwarded_launch_intents_use_core_state_and_semantic_host_actions() { host.update_settings_strict(preferences, host.snapshot().preferences_revision) .unwrap(); assert_eq!( - host.dispatch_hotkey_event(LinuxHotkeyEvent::TranslationPressed) - .await - .unwrap(), + host.dispatch_hotkey_event(LinuxHotkeyEvent::TranslationPressed { + symbol: 0, + states: 0, + }) + .await + .unwrap(), None ); assert!(matches!( @@ -368,7 +372,10 @@ fn linux_public_settings_contract_is_validated_transactional_and_runtime_backed( conflicting.translation_hotkey = conflicting.dictation_hotkey.clone(); let error = host - .update_settings_strict(conflicting, revision) + .update_preference_fields(&std::collections::BTreeMap::from([( + "/translationHotkey".into(), + serde_json::to_value(conflicting.translation_hotkey).unwrap(), + )])) .expect_err("Linux host must receive the shared shortcut conflict"); assert_eq!(error.code, BackendErrorCode::InvalidArgument); @@ -389,7 +396,16 @@ fn linux_public_settings_contract_is_validated_transactional_and_runtime_backed( }; runtime_failure.launch_at_login = true; let error = host - .update_settings_strict(runtime_failure, revision) + .update_preference_fields(&std::collections::BTreeMap::from([ + ( + "/dictationHotkey".into(), + serde_json::to_value(runtime_failure.dictation_hotkey).unwrap(), + ), + ( + "/launchAtLogin".into(), + serde_json::Value::Bool(runtime_failure.launch_at_login), + ), + ])) .expect_err("Linux runtime failure must fail the settings transaction"); assert_eq!(error.code, BackendErrorCode::Platform); assert_eq!(backend.snapshot().preferences_revision, revision); diff --git a/openless-all/app/linux-egui/tests/provider_surface_contract.rs b/openless-all/app/linux-egui/tests/provider_surface_contract.rs new file mode 100644 index 000000000..891bfea66 --- /dev/null +++ b/openless-all/app/linux-egui/tests/provider_surface_contract.rs @@ -0,0 +1,68 @@ +//! 契约:Linux 界面必须是 Core provider / 凭据模块的**真实客户端**。 +//! +//! 这里的每一条对应 CI 门禁 `scripts/check-linux-public-surface.ps1` 的断言。 +//! 门禁只为「只有状态、或只有本地模型捷径」的界面留了口子,所以把同一组断言 +//! 复制到 Rust 测试里:某次合并或清理把实现覆盖掉时,本机 `cargo test` 就会 +//! 直接失败,而不是等到发布流水线第 8 步才发现(历史上有一次正是这样丢的)。 + +const MAIN: &str = concat!( + include_str!("../src/main.rs"), + include_str!("../src/linux_app/settings_save.rs"), + include_str!("../src/linux_app/window.rs"), + include_str!("../src/linux_app/history.rs"), +); + +/// 每个 token 都对应 Linux 设置页真实发出的 Core 调用。 +#[test] +fn linux_settings_page_drives_core_provider_and_credential_operations() { + for token in [ + // 渠道管理 + "provider_descriptors", + "list_channels", + "create_channel", + "set_channel_enabled", + "rename_channel", + "set_channel_provider_type", + "reorder_channels", + "set_active_provider", + // 凭据写入 / 删除 + "set_credential", + "remove_credential", + ] { + assert!( + MAIN.contains(token), + "Linux UI 缺少 Core provider 调用 `{token}`(设置页不再是 Core 的真实客户端)" + ); + } +} + +/// 校验与模型枚举走的是 `services().provider`(`ProviderApi`),而不是界面自己 +/// 拼 endpoint。这里检查调用形态,避免有人把 `.provider.validate(...)` 换成自造 +/// 的 HTTP 路径。 +#[test] +fn linux_ui_validates_and_lists_models_through_the_provider_api() { + assert!( + MAIN.contains(".provider"), + "Linux UI 必须通过 Core 的 ProviderApi 校验与列模型" + ); + assert!( + MAIN.contains(".validate("), + "Linux UI 缺少 Core 的 provider.validate 调用" + ); + assert!( + MAIN.contains(".list_models("), + "Linux UI 缺少 Core 的 provider.list_models 调用" + ); +} + +/// 界面不得自己拥有服务商默认值(端点 / 模型 / 预设),否则换 provider 只能靠 +/// 改界面代码。与门禁禁止 `ASR_PRESETS` / `LLM_PRESETS` / 写死的 `https://…/v1` 同级。 +#[test] +fn linux_ui_does_not_own_provider_defaults() { + for forbidden in ["ASR_PRESETS", "LLM_PRESETS", "OMNI_PRESETS"] { + assert!( + !MAIN.contains(forbidden), + "Linux UI 不得内置服务商预设 `{forbidden}`,默认值属于 Core" + ); + } +} diff --git a/openless-all/app/scripts/check-core-runtime-seam.ps1 b/openless-all/app/scripts/check-core-runtime-seam.ps1 index 6fc96e810..d07c6e403 100644 --- a/openless-all/app/scripts/check-core-runtime-seam.ps1 +++ b/openless-all/app/scripts/check-core-runtime-seam.ps1 @@ -5,10 +5,55 @@ $ErrorActionPreference = "Stop" $appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\")).Path $sourceRoot = Join-Path $appRoot "crates/openless-core/src" +$files = @(Get-ChildItem -LiteralPath $sourceRoot -Filter "*.rs" -Recurse -File) +$textByPath = @{} +foreach ($file in $files) { + $textByPath[$file.FullName] = Get-Content -Raw -LiteralPath $file.FullName +} + +function Get-CoreRelativePath([string] $fullName) { + return [System.IO.Path]::GetRelativePath($appRoot, $fullName).Replace("\", "/") +} + +# A module file is test-only when some module file declares it with `#[cfg(test)]`, +# or when another test-only module declares it (a `#[path]` include, for example). +# Test-only files are absent from a production build, so the TaskSpawner rule +# below does not apply to them. +$testOnly = [System.Collections.Generic.HashSet[string]]::new() +$changed = $true +while ($changed) { + $changed = $false + foreach ($file in $files) { + $relative = Get-CoreRelativePath $file.FullName + if ($testOnly.Contains($relative)) { continue } + $stem = [System.IO.Path]::GetFileNameWithoutExtension($file.FullName) + $declarations = @(Get-ChildItem -LiteralPath $file.DirectoryName -Filter "*.rs" -File) + $parentDir = Split-Path -Parent $file.DirectoryName + if ($parentDir) { + $declarations += @(Get-ChildItem -LiteralPath $parentDir -Filter "*.rs" -File -ErrorAction SilentlyContinue) + } + foreach ($declaration in $declarations) { + if ($declaration.FullName -eq $file.FullName) { continue } + $text = $textByPath[$declaration.FullName] + $module = "\bmod\s+$([regex]::Escape($stem))\s*;" + if ($text -match "(?s)#\[cfg\(test\)\][^;]{0,200}?$module") { + $testOnly.Add($relative) | Out-Null + $changed = $true + break + } + if ($text -match $module -and $testOnly.Contains((Get-CoreRelativePath $declaration.FullName))) { + $testOnly.Add($relative) | Out-Null + $changed = $true + break + } + } + } +} + $violations = [System.Collections.Generic.List[string]]::new() -foreach ($file in Get-ChildItem -LiteralPath $sourceRoot -Filter "*.rs" -Recurse -File) { - $text = Get-Content -Raw -LiteralPath $file.FullName - $relative = [System.IO.Path]::GetRelativePath($appRoot, $file.FullName).Replace("\", "/") +foreach ($file in $files) { + $text = $textByPath[$file.FullName] + $relative = Get-CoreRelativePath $file.FullName if ($text -match "tokio::runtime::Runtime::new\s*\(") { $violations.Add("${relative}: private Tokio Runtime::new is forbidden") @@ -21,6 +66,7 @@ foreach ($file in Get-ChildItem -LiteralPath $sourceRoot -Filter "*.rs" -Recurse # Production code must submit background work to the injected TaskSpawner. # The test modules may use #[tokio::test]/tokio::spawn to orchestrate tests. + if ($testOnly.Contains($relative)) { continue } $testMarker = $text.IndexOf("#[cfg(test)]", [System.StringComparison]::Ordinal) $production = if ($testMarker -ge 0) { $text.Substring(0, $testMarker) } else { $text } if ($production -match "tokio::spawn\s*\(") { @@ -29,7 +75,10 @@ foreach ($file in Get-ChildItem -LiteralPath $sourceRoot -Filter "*.rs" -Recurse } if ($violations.Count -gt 0) { - $violations | ForEach-Object { Write-Error $_ } + foreach ($violation in $violations) { + Write-Host "::error::$violation" + } + Write-Host "Core runtime seam gate failed: $($violations.Count) violation(s)." exit 1 } diff --git a/openless-all/app/scripts/check-linux-public-surface.ps1 b/openless-all/app/scripts/check-linux-public-surface.ps1 index 3aee13013..e1020b336 100644 --- a/openless-all/app/scripts/check-linux-public-surface.ps1 +++ b/openless-all/app/scripts/check-linux-public-surface.ps1 @@ -9,6 +9,7 @@ $linuxManifest = Get-Content -Raw -LiteralPath (Join-Path $appRoot "linux-egui/C $coreManifest = Get-Content -Raw -LiteralPath (Join-Path $appRoot "crates/openless-core/Cargo.toml") $coreApi = Get-Content -Raw -LiteralPath (Join-Path $appRoot "crates/openless-core/src/api.rs") $mainSource = Get-Content -Raw -LiteralPath (Join-Path $appRoot "linux-egui/src/main.rs") +$mainSource += (Get-ChildItem -LiteralPath (Join-Path $appRoot "linux-egui/src/linux_app") -Filter "*.rs" | Sort-Object Name | ForEach-Object { Get-Content -Raw -LiteralPath $_.FullName }) -join "`n" $backendSource = Get-Content -Raw -LiteralPath (Join-Path $appRoot "linux-egui/src/backend.rs") $qaSource = Get-Content -Raw -LiteralPath (Join-Path $appRoot "linux-egui/src/qa.rs") $selectionSource = Get-Content -Raw -LiteralPath (Join-Path $appRoot "linux-egui/src/selection.rs") @@ -58,7 +59,7 @@ if ($backendSource -match 'qa_runtime:\s*None' -or $installer = $mainSource.IndexOf('ensure_fcitx5_ready(&config)') $listener = $mainSource.IndexOf('Fcitx5HotkeyListener::start') if ($installer -lt 0 -or $listener -lt 0 -or $installer -gt $listener) { - Write-Error "Linux AppImage fcitx5 installation must run before the hotkey listener" + Write-Error "Linux fcitx5 readiness must run before the hotkey listener" exit 1 } diff --git a/openless-all/app/scripts/ci-cache-usage.sh b/openless-all/app/scripts/ci-cache-usage.sh new file mode 100755 index 000000000..602adf8fe --- /dev/null +++ b/openless-all/app/scripts/ci-cache-usage.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# +# Report GitHub Actions cache usage for this repository and, with --prune, +# delete the scopes that can never be restored again. +# +# scripts/ci-cache-usage.sh [--prune] [--limit-gb N] [--repo OWNER/NAME] +# +# Why this exists: GitHub keeps 10 GB of caches per repository and evicts the +# least recently used entries. The platform dependency trees are large (the +# Windows Tauri tree alone is ~1.6 GB), so a handful of stale scopes is enough +# to evict the caches the CI jobs depend on - which turns every run into a cold +# build. This tool keeps the budget visible and drops the dead weight. +# +# Env: GH_TOKEN or a logged-in `gh` is required. +# Exit: 1 when usage is above the budget (so a scheduled run is visibly red). +set -euo pipefail + +repo=${REPO:-Open-Less/openless} +limit_gb=${LIMIT_GB:-8} +prune=0 + +while [ $# -gt 0 ]; do + case "$1" in + --prune) prune=1 ;; + --limit-gb) + limit_gb=${2:?--limit-gb needs a number} + shift + ;; + --repo) + repo=${2:?--repo needs OWNER/NAME} + shift + ;; + *) + echo "usage: $0 [--prune] [--limit-gb N] [--repo OWNER/NAME]" >&2 + exit 2 + ;; + esac + shift +done + +command -v gh > /dev/null 2>&1 || { + echo "gh is required" >&2 + exit 1 +} + +caches=$(mktemp) +trap 'rm -f "$caches"' EXIT + +gh api --paginate "repos/$repo/actions/caches?per_page=100" \ + --jq '.actions_caches[] | [.ref, .key, .size_in_bytes] | @tsv' > "$caches" + +count=$(wc -l < "$caches" | tr -d ' ') +total_gb=$(awk -F'\t' '{ sum += $3 } END { printf "%.2f", sum / 1e9 }' "$caches") + +echo "$repo: $count cache(s), ${total_gb} GB used, budget ${limit_gb} GB" +echo "largest scopes:" +awk -F'\t' '{ size[$1] += $3 } END { for (ref in size) printf " %7.2f GB %s\n", size[ref] / 1e9, ref }' "$caches" \ + | sort -rn \ + | head -10 + +if [ "$prune" = 1 ]; then + pruned=0 + for ref in $(awk -F'\t' '$1 ~ /^refs\/pull\/[0-9]+\/(merge|head)$/ { print $1 }' "$caches" | sort -u); do + number=${ref#refs/pull/} + number=${number%%/*} + state=$(gh api "repos/$repo/pulls/$number" --jq .state 2> /dev/null || echo unknown) + if [ "$state" = "open" ]; then + echo " keep $ref (pull request is open)" + continue + fi + if [ "$state" = "unknown" ]; then + echo " keep $ref (pull request state is unreadable)" + continue + fi + # A closed or merged pull request can never restore this scope again. + echo " prune $ref (pull request $state)" + gh api -X DELETE "repos/$repo/actions/caches?ref=$ref" > /dev/null + pruned=$((pruned + 1)) + done + echo "pruned $pruned scope(s)" +fi + +if awk -v used="$total_gb" -v limit="$limit_gb" 'BEGIN { exit (used + 0 > limit + 0) ? 0 : 1 }'; then + echo "::error::cache usage ${total_gb} GB exceeds the ${limit_gb} GB budget" + exit 1 +fi + +echo "cache usage is inside the ${limit_gb} GB budget" diff --git a/openless-all/app/scripts/ci-cache-usage.test.mjs b/openless-all/app/scripts/ci-cache-usage.test.mjs new file mode 100644 index 000000000..9fd09330c --- /dev/null +++ b/openless-all/app/scripts/ci-cache-usage.test.mjs @@ -0,0 +1,177 @@ +// Contract test for scripts/ci-cache-usage.sh. +// +// The prune path deletes caches, so it is the one part of the CI tooling that +// must not be trusted on a hunch. This test runs the script against a stubbed +// `gh` and asserts that only the scopes of closed or merged pull requests are +// deleted, that open pull requests are kept, and that an unreadable pull +// request state is treated as "keep". +import { execFileSync } from 'node:child_process'; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = resolve(dirname(fileURLToPath(import.meta.url)), 'ci-cache-usage.sh'); +const scratch = mkdtempSync(join(tmpdir(), 'openless-cache-usage-')); +const binDir = join(scratch, 'bin'); +const deleteLog = join(scratch, 'deleted.tsv'); +mkdirSync(binDir, { recursive: true }); + +const fixtures = [ + { + ref: 'refs/heads/beta', + key: 'v0-rust-cross-platform-Windows_NT-x64-3619e479-a77d3f83', + size: 1_622_000_000, + }, + { + ref: 'refs/pull/101/merge', + key: 'v0-rust-cross-platform-Windows_NT-x64-3619e479-aaaaaaaa', + size: 1_600_000_000, + }, + { + ref: 'refs/pull/102/merge', + key: 'v0-rust-build-linux-egui-Linux-x64-32c982f2-6e438e89', + size: 1_338_000_000, + }, + { + ref: 'refs/pull/103/head', + key: 'v0-rust-macos-msrv-v1-macos-msrv-Darwin-arm64-832c8724-bbbb', + size: 715_000_000, + }, + { + ref: 'refs/tags/v2.0.0-Beta.3-tauri', + key: 'v0-rust-macos-release-v1-build-Darwin-x64-ab76de4a-c48940d0', + size: 759_000_000, + }, +]; +writeFileSync( + join(scratch, 'caches.json'), + JSON.stringify({ + total_count: fixtures.length, + actions_caches: fixtures.map(({ ref, key, size }) => ({ ref, key, size_in_bytes: size })), + }), +); + +const pullStates = { 101: 'open', 102: 'closed', 103: 'merged' }; + +// Stub `gh`: serves the cache listing, answers pull request lookups from the +// fixture above, and records every deletion instead of performing it. +writeFileSync( + join(binDir, 'gh'), + `#!/usr/bin/env bash +set -euo pipefail +args="$*" +case "$args" in + *-X\\ DELETE*) + printf '%s\\n' "$args" >>"$DELETE_LOG" + ;; + *actions/caches?per_page=100*) + python3 - "$CACHES_JSON" <<'PY' +import json, sys +data = json.load(open(sys.argv[1])) +for entry in data["actions_caches"]: + print(f"{entry['ref']}\\t{entry['key']}\\t{entry['size_in_bytes']}") +PY + ;; + *repos/*/pulls/*) + number=\${args##*/pulls/} + number=\${number%% *} + case "$number" in + 101) echo open ;; + 102) echo closed ;; + 103) echo merged ;; + *) exit 1 ;; + esac + ;; + *) + echo "unexpected gh invocation: $args" >&2 + exit 1 + ;; +esac +`, +); +chmodSync(join(binDir, 'gh'), 0o755); + +let failures = 0; +const check = (label, actual, expected) => { + const ok = actual === expected; + if (!ok) failures += 1; + console.log( + `${ok ? 'ok' : 'FAIL'} ${label} -> ${JSON.stringify(actual)} (expected ${JSON.stringify(expected)})`, + ); +}; + +const run = (args, env = {}) => { + try { + const stdout = execFileSync('bash', [scriptPath, ...args], { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${binDir}:${process.env.PATH}`, + CACHES_JSON: join(scratch, 'caches.json'), + DELETE_LOG: deleteLog, + ...env, + }, + }); + return { code: 0, stdout }; + } catch (error) { + return { code: error.status ?? 1, stdout: `${error.stdout ?? ''}${error.stderr ?? ''}` }; + } +}; + +try { + // Over budget without --prune: reported, nothing deleted, exit 1. + const report = run(['--repo', 'example/openless', '--limit-gb', '5']); + check('over budget exits 1', report.code, 1); + check('over budget reports total', /6\.03 GB used/.test(report.stdout), true); + check('over budget mentions the limit', /exceeds the 5 GB budget/.test(report.stdout), true); + + // Prune: closed and merged pull requests go, open ones stay. + const prune = run(['--prune', '--repo', 'example/openless', '--limit-gb', '5']); + check('prune exits 1 while still over budget', prune.code, 1); + const deleted = readFileSync(deleteLog, 'utf8').trim().split('\n').filter(Boolean); + check('prune deleted exactly two scopes', deleted.length, 2); + check( + 'prune deleted the closed pull request', + deleted.some((line) => line.includes('ref=refs/pull/102/merge')), + true, + ); + check( + 'prune deleted the merged pull request', + deleted.some((line) => line.includes('ref=refs/pull/103/head')), + true, + ); + check( + 'prune kept the open pull request', + deleted.some((line) => line.includes('ref=refs/pull/101/merge')), + false, + ); + check( + 'prune kept the branch scope', + deleted.some((line) => line.includes('refs/heads/beta')), + false, + ); + check( + 'prune kept the tag scope', + deleted.some((line) => line.includes('refs/tags/')), + false, + ); + check('prune announces the kept scope', /keep\s+refs\/pull\/101\/merge/.test(prune.stdout), true); + + // Inside the budget: exit 0. + const healthy = run(['--repo', 'example/openless', '--limit-gb', '10']); + check('inside budget exits 0', healthy.code, 0); + check('inside budget is reported', /inside the 10 GB budget/.test(healthy.stdout), true); + + // Usage errors keep the exit code distinct from the budget signal. + const badFlag = run(['--nonsense']); + check('unknown flag exits 2', badFlag.code, 2); +} finally { + rmSync(scratch, { recursive: true, force: true }); +} + +if (failures > 0) { + console.error(`ci-cache-usage contract test failed (${failures})`); + process.exit(1); +} +console.log('ci-cache-usage contract test passed'); diff --git a/openless-all/app/scripts/ci-changed-areas.sh b/openless-all/app/scripts/ci-changed-areas.sh new file mode 100755 index 000000000..b4deb928f --- /dev/null +++ b/openless-all/app/scripts/ci-changed-areas.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# +# Decide whether a heavy cross-platform check must run for this change set. +# +# ci-changed-areas.sh [base-sha] +# +# Areas: +# tauri macOS / Windows desktop Tauri builds and the Android build that +# shares the same dependency graph. +# msrv The Rust 1.88 minimum-supported-toolchain compile. +# linux The Linux host build, its tests and the deb/rpm package chain. +# +# Prints "true" when the check must run and "false" only when every changed file +# is provably out of scope for that area. Anything unknown - no base revision +# (push, tag, manual dispatch), a git failure, or an empty diff - prints "true". +# Skipping a platform check must always be the result of a change set that +# cannot reach it, never of a failure to read the change set. +set -uo pipefail + +area=${1:?usage: ci-changed-areas.sh [base-sha]} +base=${2:-} + +run() { + echo true + exit 0 +} + +case "$area" in + tauri | msrv | linux) ;; + *) + echo "unknown area: $area" >&2 + run + ;; +esac + +# Push, tag and manual runs verify every platform. +[ -n "$base" ] || run + +files=$(git diff --name-only "${base}...HEAD" 2> /dev/null) || run +[ -n "$files" ] || run + +while IFS= read -r file; do + [ -n "$file" ] || continue + case "$area" in + tauri) + # Only the Linux host, its fcitx5 plugin and prose are out of scope for + # the desktop Tauri build. The Linux host manifest stays in scope: it is + # part of the shared workspace and moves Cargo.lock for every platform. + case "$file" in + openless-all/app/linux-egui/Cargo.toml) run ;; + openless-all/app/linux-egui/*) ;; + openless-all/scripts/linux-fcitx5-plugin/*) ;; + docs/* | *.md | LICENSE* | NOTICE*) ;; + *) run ;; + esac + ;; + msrv) + # The 1.88 promise is about Rust sources and their dependency graph; + # only change sets that contain neither may skip that compile. + case "$file" in + *.rs | *Cargo.toml | *Cargo.lock | *rust-toolchain* | .github/*) run ;; + *) ;; + esac + ;; + linux) + # Prose cannot change what the host builds, tests or packages. + case "$file" in + docs/* | *.md | LICENSE* | NOTICE*) ;; + *) run ;; + esac + ;; + esac +done <<< "$files" + +echo false diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs new file mode 100644 index 000000000..0c35e26f8 --- /dev/null +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -0,0 +1,184 @@ +// Contract test for scripts/ci-changed-areas.sh. +// +// The CI jobs that verify the desktop Tauri builds and the Rust 1.88 MSRV are +// skipped for change sets that cannot reach them. That decision must never be +// made on a guess: this test drives the script against real git diffs and +// asserts both directions - out-of-scope change sets skip, everything else runs. +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const scriptPath = resolve(dirname(fileURLToPath(import.meta.url)), 'ci-changed-areas.sh'); +const git = (cwd, args) => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }); + +const scratch = mkdtempSync(join(tmpdir(), 'openless-changed-areas-')); +const repo = join(scratch, 'repo'); +mkdirSync(repo, { recursive: true }); + +git(repo, ['init', '--quiet', '--initial-branch=main']); +git(repo, ['config', 'user.email', 'ci@example.invalid']); +git(repo, ['config', 'user.name', 'CI Contract']); +git(repo, ['config', 'commit.gpgsign', 'false']); +writeFileSync(join(repo, 'README.md'), 'base\n'); +git(repo, ['add', '.']); +git(repo, ['commit', '--quiet', '-m', 'base']); +const base = git(repo, ['rev-parse', 'HEAD']).trim(); + +let failures = 0; +const check = (label, actual, expected) => { + const ok = actual === expected; + if (!ok) failures += 1; + console.log(`${ok ? 'ok' : 'FAIL'} ${label} -> ${actual} (expected ${expected})`); +}; + +const commit = (files, message) => { + for (const [path, body] of Object.entries(files)) { + mkdirSync(dirname(join(repo, path)), { recursive: true }); + writeFileSync(join(repo, path), body); + } + git(repo, ['add', '-A']); + git(repo, ['commit', '--quiet', '-m', message]); +}; + +const decide = (area, revision = base) => { + const args = [scriptPath, area]; + if (revision !== null) args.push(revision); + return execFileSync('bash', args, { cwd: repo, encoding: 'utf8' }).trim(); +}; + +const reset = () => { + git(repo, ['reset', '--hard', '--quiet', base]); + git(repo, ['clean', '-fdq']); +}; + +try { + // Documentation cannot reach either check. + commit({ 'docs/architecture.md': 'docs\n' }, 'docs only'); + check('docs only: tauri', decide('tauri'), 'false'); + check('docs only: msrv', decide('msrv'), 'false'); + check('docs only: linux', decide('linux'), 'false'); + reset(); + + // The Linux host is a separate crate: only its own Rust sources stay in. + commit({ 'openless-all/app/linux-egui/src/ui/theme.rs': 'fn x() {}\n' }, 'linux host source'); + check('linux host source: tauri', decide('tauri'), 'false'); + check('linux host source: msrv', decide('msrv'), 'true'); + check('linux host source: linux', decide('linux'), 'true'); + reset(); + + // ...but its manifest moves the shared workspace dependency graph. + commit( + { 'openless-all/app/linux-egui/Cargo.toml': '[package]\nname = "x"\n' }, + 'linux host manifest', + ); + check('linux host manifest: tauri', decide('tauri'), 'true'); + check('linux host manifest: msrv', decide('msrv'), 'true'); + reset(); + + // Frontend-only work still needs the Tauri job (it runs the frontend tests) + // but cannot change what the Rust compiler has to accept. + commit({ 'openless-all/app/src/pages/Style.tsx': 'export const x = 1;\n' }, 'frontend only'); + check('frontend only: tauri', decide('tauri'), 'true'); + check('frontend only: msrv', decide('msrv'), 'false'); + check('frontend only: linux', decide('linux'), 'true'); + reset(); + + // Native code keeps every check on. + commit({ 'openless-all/app/src-tauri/src/lib.rs': 'pub fn x() {}\n' }, 'native source'); + check('native source: tauri', decide('tauri'), 'true'); + check('native source: msrv', decide('msrv'), 'true'); + reset(); + + // A dependency bump moves both the Tauri build and the MSRV promise. + commit({ 'openless-all/app/Cargo.lock': '# lock\n' }, 'lockfile'); + check('lockfile: tauri', decide('tauri'), 'true'); + check('lockfile: msrv', decide('msrv'), 'true'); + reset(); + + // Workflow changes always verify everything. + commit({ '.github/workflows/ci.yml': 'name: CI\n' }, 'workflow'); + check('workflow change: tauri', decide('tauri'), 'true'); + check('workflow change: msrv', decide('msrv'), 'true'); + check('workflow change: linux', decide('linux'), 'true'); + reset(); + + // Unreadable or empty change sets never skip a platform check. + check('no base revision: tauri', decide('tauri', null), 'true'); + check('no base revision: msrv', decide('msrv', null), 'true'); + check('no base revision: linux', decide('linux', null), 'true'); + check('empty diff: tauri', decide('tauri'), 'true'); + check('empty diff: msrv', decide('msrv'), 'true'); + check('empty diff: linux', decide('linux'), 'true'); + check('unknown revision: tauri', decide('tauri', 'deadbeefdeadbeef'), 'true'); + check('unknown area', decide('nonsense'), 'true'); +} finally { + rmSync(scratch, { recursive: true, force: true }); +} + +// The wiring matters as much as the decision: a missing job output silently +// turns "this change set cannot reach the Linux build" into a skipped job. These +// checks read the workflow that consumes the script and fail when an area is not +// published, when a gate is not fail-open, or when prose-only runs could skip +// the Linux package chain without the reusable workflow's explicit opt-out. +const workflow = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../../..', '.github/workflows/ci.yml'), + 'utf8', +); +const reusable = readFileSync( + resolve( + dirname(fileURLToPath(import.meta.url)), + '../../..', + '.github/workflows/check-linux-egui.yml', + ), + 'utf8', +); +const areas = ['tauri', 'msrv', 'linux']; +check( + 'workflow publishes every area', + areas.every((area) => workflow.includes(area + ': ${{ steps.detect.outputs.' + area + ' }}')), + true, +); +check( + 'workflow computes every area in the detect step', + /for area in tauri msrv linux; do/.test(workflow), + true, +); +check( + 'tauri gate is fail-open', + workflow.includes("if: needs.changes.outputs.tauri != 'false'"), + true, +); +check( + 'msrv gate is fail-open', + workflow.includes("if: needs.changes.outputs.msrv != 'false'"), + true, +); +check( + 'android gate is fail-open', + /inputs\.platform != 'macos'\) && needs\.changes\.outputs\.tauri != 'false'/.test(workflow), + true, +); +check( + 'linux scope defaults to full', + workflow.includes("needs.changes.outputs.linux == 'false' && 'none' || 'full'"), + true, +); +check( + 'reusable workflow defaults to a full build', + /scope:[\s\S]{0,80}default: full/.test(reusable), + true, +); +check( + 'reusable workflow gates every build step', + (reusable.match(/if: inputs\.scope != 'none'/g) ?? []).length >= 10, + true, +); + +if (failures > 0) { + console.error(`ci-changed-areas contract test failed (${failures})`); + process.exit(1); +} +console.log('ci-changed-areas contract test passed'); diff --git a/openless-all/app/scripts/ci-disable-macos-qwen3.mjs b/openless-all/app/scripts/ci-disable-macos-qwen3.mjs index e0dda1e5b..6a202a592 100644 --- a/openless-all/app/scripts/ci-disable-macos-qwen3.mjs +++ b/openless-all/app/scripts/ci-disable-macos-qwen3.mjs @@ -19,7 +19,9 @@ if (!lock.includes('name = "qwen3-asr-rs"')) { throw new Error(`openless Cargo.lock package 未包含 qwen3-asr-rs:${lockPath}`); } -const cargoResult = spawnSync('cargo', ['generate-lockfile', '--manifest-path', cargoPath], { +// 仅调整移除 path 依赖后的工作区图,保留已验证的第三方依赖版本。 +// generate-lockfile 会重新解析整个依赖树,令非 macOS CI 偏离提交的锁文件。 +const cargoResult = spawnSync('cargo', ['update', '--workspace', '--manifest-path', cargoPath], { cwd: appRoot, stdio: 'inherit', }); @@ -27,11 +29,11 @@ if (cargoResult.error) { throw cargoResult.error; } if (cargoResult.status !== 0) { - throw new Error(`cargo generate-lockfile 失败,退出码:${cargoResult.status}`); + throw new Error(`cargo update --workspace 失败,退出码:${cargoResult.status}`); } const regeneratedLock = readFileSync(lockPath, 'utf8'); if (regeneratedLock.includes('name = "qwen3-asr-rs"')) { - throw new Error(`cargo generate-lockfile 后仍包含 qwen3-asr-rs:${lockPath}`); + throw new Error(`cargo update --workspace 后仍包含 qwen3-asr-rs:${lockPath}`); } console.log('[ci] disabled macOS-only qwen3-asr-rs dependency for this target'); diff --git a/openless-all/app/scripts/egui-icons-contract.test.mjs b/openless-all/app/scripts/egui-icons-contract.test.mjs new file mode 100644 index 000000000..b60690cc4 --- /dev/null +++ b/openless-all/app/scripts/egui-icons-contract.test.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { icons } from './sync-egui-lucide-icons.mjs'; + +const app = resolve(fileURLToPath(new URL('..', import.meta.url))); +const linux = readFileSync(resolve(app, 'linux-egui/src/ui/frontend/icons.rs'), 'utf8'); +const tauri = readFileSync(resolve(app, 'src/components/Icon.tsx'), 'utf8'); +const nav = readFileSync(resolve(app, 'linux-egui/src/ui/frontend/layout.rs'), 'utf8'); +const settings = readFileSync(resolve(app, 'linux-egui/src/ui/frontend/settings.rs'), 'utf8'); +const tauriSettings = readFileSync(resolve(app, 'src/pages/settings/navigation.ts'), 'utf8'); +const tauriNav = readFileSync(resolve(app, 'src/components/FloatingShell.tsx'), 'utf8'); +execFileSync(process.execPath, [resolve(app, 'scripts/sync-egui-lucide-icons.mjs'), '--check']); +for (const [name, glyph] of Object.entries(icons)) { + const start = linux.indexOf(`IconName::${name} =>`); + assert.ok(start >= 0, `${name} has no Rust match arm`); + const arm = linux.slice(start, linux.indexOf('IconName::', start + 1)); + assert.ok(arm.includes(`"${name}"`) && arm.includes(`../../../assets/lucide/${name}.svg`), `${name} is not wired to its SVG`); + const key = { + Overview: 'overview', History: 'history', Vocab: 'vocab', Style: 'style', + SelectionAsk: 'selectionAsk', Settings: 'settings', Mic: 'mic', + Sparkle: 'sparkle', Hash: 'hash', Clock: 'clock', Bolt: 'bolt', Copy: 'copy', + Search: 'search', Trash: 'trash', Refresh: 'refresh', Download: 'download', + Upload: 'upload', Plus: 'plus', Play: 'play', Close: 'close', Check: 'check', + More: 'more', ChevronRight: 'chevRight', Feather: 'feather', Layout: 'layout', + Doc: 'doc', Pencil: 'pencil', Cloud: 'cloud', Shield: 'shield', + Info: 'info', Help: 'help', External: 'external', Monitor: 'mac', + }[name]; + if (key) assert.match(tauri, new RegExp(`\\b${key}: ${glyph}\\b`), `${name} differs from Tauri's icon mapping`); +} +for (const [section, icon] of Object.entries({ + General: 'Mic', Shortcuts: 'Bolt', Services: 'Cloud', Appearance: 'Settings', + Privacy: 'Shield', Advanced: 'Sparkle', About: 'Info', +})) { + assert.ok(settings.includes(`Self::${section} => SettingsIcon::${icon}`), `${section} settings rail icon differs from Tauri`); + assert.ok(settings.includes(`SettingsIcon::${icon} => IconName::${icon}`), `${icon} must use the Lucide SVG`); + assert.match(tauriSettings, new RegExp(`id: '${section.toLowerCase()}', icon: '${icon.toLowerCase()}'`)); +} +const tools = nav.slice(nav.indexOf('"nav.group_tools"'), nav.indexOf('"nav.settings"')); +const expected = ['nav.translation', 'nav.selection_ask', 'nav.quickNote', 'nav.corrections']; +let previous = -1; +for (const item of expected) { + const at = tools.indexOf(`"${item}"`); + assert.ok(at > previous, `${item} must follow the previous tools entry`); + previous = at; +} +assert.equal(nav.slice(nav.indexOf('"nav.history"'), nav.indexOf('"nav.group_tools"')).includes('"nav.quickNote"'), false); +assert.match(tauriNav, /\{ id: 'selectionAsk' \},\s*\{ id: 'quickNote' \},\s*\{ id: 'corrections' \}/); +console.log('egui SVG outlines and tools navigation match Tauri'); diff --git a/openless-all/app/scripts/i18n-adopt-literals.mjs b/openless-all/app/scripts/i18n-adopt-literals.mjs new file mode 100644 index 000000000..aba38f1ef --- /dev/null +++ b/openless-all/app/scripts/i18n-adopt-literals.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// Adopt hardcoded Simplified-Chinese literals into the egui i18n catalog. +// +// The legacy egui pages hardcode zh-CN strings. The Tauri catalogs already hold +// translations for the same product surfaces, so a literal whose text matches a +// Tauri zh-CN value can be replaced by a `tr_l10n(lang, "key")` call without a +// translator touching it. +// +// Usage: node --experimental-strip-types --import ./scripts/register-ts-loader.mjs \ +// scripts/i18n-adopt-literals.mjs [...] +// +// The script only rewrites whole string literals that contain CJK and no `{}` +// placeholders (format templates need `fmt_l10n` and a hand-written call). +// Anything ambiguous (the same zh text under several keys) or unmatched is +// reported instead of guessed at. + +import { readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const APP_ROOT = resolve(new URL('..', import.meta.url).pathname); +const files = process.argv.slice(2); +if (files.length === 0) { + console.error('usage: i18n-adopt-literals.mjs [...]'); + process.exit(2); +} + +const zhCN = (await import(resolve(APP_ROOT, 'src/i18n/zh-CN.ts'))).zhCN; + +/** zh text -> [dotted keys] */ +const valueToKeys = new Map(); +function walk(value, prefix) { + for (const [key, entry] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key; + if (Array.isArray(entry)) continue; // arrays need the joining convention + if (entry && typeof entry === 'object') walk(entry, path); + else if (typeof entry === 'string') { + const list = valueToKeys.get(entry) ?? []; + list.push(path); + valueToKeys.set(entry, list); + } + } +} +walk(zhCN, ''); + +const CJK = /[\u4e00-\u9fff]/; + +/// These literals are used as `match` patterns for action dispatch, so they +/// cannot be replaced by a function call. They are listed in the localization +/// baseline and will be translated when the dispatch moves to typed ids. +const SKIP_LITERALS = new Set(['导出错误日志', '打开 GitHub']); +const cache = new Map(); +function toSnake(dotted) { + if (cache.has(dotted)) return cache.get(dotted); + const result = dotted + .split('.') + .map((part) => part.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()) + .join('.'); + cache.set(dotted, result); + return result; +} + +let totalReplaced = 0; +const unresolved = new Map(); // literal -> reason +for (const file of files) { + const path = resolve(process.cwd(), file); + const source = readFileSync(path, 'utf8'); + let out = ''; + let i = 0; + let replaced = 0; + while (i < source.length) { + const ch = source[i]; + if (ch !== '"') { + out += ch; + i += 1; + continue; + } + // Read the full literal, tracking escapes. + let j = i + 1; + let raw = ''; + while (j < source.length && source[j] !== '"') { + if (source[j] === '\\') { + raw += source[j] + (source[j + 1] ?? ''); + j += 2; + continue; + } + raw += source[j]; + j += 1; + } + const literal = raw; + const text = literal + .replace(/\\n/g, '\n') + .replace(/\\"/g, '"') + .replace(/\\\\/g, '\\'); + if (SKIP_LITERALS.has(text) || !CJK.test(text) || text.includes('{') || text.includes('}')) { + out += source.slice(i, j + 1); + i = j + 1; + continue; + } + const keys = valueToKeys.get(text); + if (!keys || keys.length === 0) { + if (!unresolved.has(text)) unresolved.set(text, 'no Tauri key with this zh text'); + out += source.slice(i, j + 1); + i = j + 1; + continue; + } + const unique = [...new Set(keys.map(toSnake))]; + if (unique.length > 1) { + if (!unresolved.has(text)) { + unresolved.set(text, `ambiguous: ${unique.join(', ')}`); + } + out += source.slice(i, j + 1); + i = j + 1; + continue; + } + out += `tr_l10n(lang, "${unique[0]}")`; + replaced += 1; + i = j + 1; + } + if (replaced > 0) writeFileSync(path, out); + console.log(`${file}: ${replaced} literal(s) adopted`); + totalReplaced += replaced; +} + +console.log(`total adopted: ${totalReplaced}`); +if (unresolved.size > 0) { + console.log(`\nneeds hand-written keys (${unresolved.size}):`); + for (const [text, reason] of unresolved) { + console.log(` ${JSON.stringify(text)} — ${reason}`); + } +} diff --git a/openless-all/app/scripts/linux-egui-manual-install.sh b/openless-all/app/scripts/linux-egui-manual-install.sh new file mode 100755 index 000000000..56b92ea19 --- /dev/null +++ b/openless-all/app/scripts/linux-egui-manual-install.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# OpenLess Linux egui —— 手动安装(给装不了 deb/rpm 的发行版用)。 +# +# 这个脚本随发布 zip 一起分发,和解压出来的散装文件同目录: +# install.sh 本脚本 +# usr/... 安装树(与 deb 内的文件逐项一致) +# SHA256SUMS 对上面散装文件重新计算的校验和 +# +# 安装 = 把 usr/ 按正确权限铺到 /,再让 fcitx5 重新加载插件。 +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +payload="$here/usr" + +if [ ! -d "$payload" ]; then + echo "找不到 $payload —— 请先解压整个 zip,再从解压目录里运行 ./install.sh" >&2 + exit 1 +fi + +if [ "$(id -u)" -ne 0 ]; then + echo "需要 root 权限:请运行 sudo ./install.sh" >&2 + exit 1 +fi + +# 散装文件比 deb 更容易在下载/解压中途损坏,所以自带校验和就先验一遍。 +if [ -s "$here/SHA256SUMS" ] && command -v sha256sum >/dev/null 2>&1; then + if ! ( cd "$here" && sha256sum -c SHA256SUMS >/dev/null ); then + echo "SHA256SUMS 校验失败:payload 已损坏,请重新下载 zip" >&2 + exit 1 + fi +fi + +# 权限按用途给:可执行文件 0755,配置/桌面项/图标 0644。 +while IFS= read -r -d '' file; do + rel="${file#"$here"/}" + case "$rel" in + usr/bin/* | usr/lib/*/fcitx5/*.so) mode=755 ;; + *) mode=644 ;; + esac + install -D -m "$mode" "$file" "/$rel" +done < <(find "$payload" -type f -print0) + +echo "已安装 openless 与 fcitx5 插件到 /usr。" + +# fcitx5 必须重载才能加载新插件,且要用用户会话里的 D-Bus controller Restart +# (立即返回)。**不要**用 `fcitx5 -r`:它会替换 daemon 并一直前台运行,脚本会卡死, +# 与 deb postinst 保持一致。 +for bus in /run/user/[0-9]*/bus; do + [ -S "$bus" ] || continue + runtime_dir="${bus%/bus}" + uid="${runtime_dir##*/}" + [ "$uid" != 0 ] || continue + user="$(getent passwd "$uid" | cut -d: -f1)" + [ -n "$user" ] && timeout 5s runuser -u "$user" -- env \ + XDG_RUNTIME_DIR="$runtime_dir" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=$bus" \ + dbus-send --session --dest=org.fcitx.Fcitx5 --type=method_call \ + /controller org.fcitx.Fcitx.Controller1.Restart >/dev/null 2>&1 || true +done + +cat <<'NOTE' +完成。请退出 OpenLess 后重新启动;全局快捷键会在 fcitx5 重载后生效。 +卸载:退出 OpenLess,删除下面这些文件,再重载 fcitx5: + /usr/bin/openless + /usr/lib/*/fcitx5/libopenless.so + /usr/share/fcitx5/addon/openless.conf + /usr/share/applications/openless.desktop + /usr/share/metainfo/top.openless.OpenLess.metainfo.xml + /usr/share/icons/hicolor/256x256/apps/openless.png +NOTE diff --git a/openless-all/app/scripts/linux-egui-release-contract.test.mjs b/openless-all/app/scripts/linux-egui-release-contract.test.mjs new file mode 100644 index 000000000..dfdccfccf --- /dev/null +++ b/openless-all/app/scripts/linux-egui-release-contract.test.mjs @@ -0,0 +1,117 @@ +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../../..', import.meta.url)); +const load = (path) => readFile(join(root, path), 'utf8'); +const [release, ci, check, pack, verify, gate, manifest, tauri, revisionFile, buildScript, host] = await Promise.all([ + load('.github/workflows/release-linux-egui.yml'), + load('.github/workflows/ci.yml'), + load('.github/workflows/check-linux-egui.yml'), + load('openless-all/app/scripts/package-linux-egui.sh'), + load('openless-all/app/scripts/verify-linux-egui-packages.sh'), + load('openless-all/app/scripts/check-core-deps.ps1'), + load('openless-all/app/linux-egui/Cargo.toml'), + load('.github/workflows/release-tauri.yml'), + load('openless-all/app/linux-egui/package-revision'), + load('openless-all/app/linux-egui/build.rs'), + load('openless-all/app/linux-egui/src/main.rs'), +]); + +// CI must execute the exact test/build/package/checksum path used for tags. +assert.match(ci, /linux-egui-package:[\s\S]*?uses: \.\/\.github\/workflows\/check-linux-egui\.yml/); +assert.match(ci, /linux-egui-package:[\s\S]*?permissions:\s*contents: read/); +assert.deepEqual( + [...ci.matchAll(/^ (linux-[\w-]+):/gm)].map((match) => match[1]), + ['linux-egui-package'], + 'PR CI must expose one Linux job, including Core and remote TLS checks', +); +assert.match(release, /uses: \.\/\.github\/workflows\/check-linux-egui\.yml/); +assert.match(check, /^on:\n workflow_call:/m, 'the reusable check must not run as a duplicate standalone workflow'); +assert.match(check, /contents: read/); +assert.match(check, /runs-on: ubuntu-24\.04/); +assert.match(check, /cargo test --locked -p openless-core/); +for (const crate of ['openless-core', 'openless-linux-egui']) { + assert.ok(check.includes(`cargo clippy --locked -p ${crate} --all-targets -- -D warnings`)); +} +assert.match(check, /cargo test --locked --manifest-path src-tauri\/backend-tests\/Cargo\.toml --test remote_tls/); +assert.match(check, /cargo test --locked -p openless-linux-egui --all-targets/); +assert.match(check, /cargo check --locked -p openless-linux-egui --all-targets/); +assert.match(check, /check-core-deps\.ps1 openless-linux-egui/); +assert.match(check, /ctest --test-dir build --output-on-failure/); +assert.match(check, /cargo build --locked --release -p openless-linux-egui/); +assert.match(check, /bash scripts\/package-linux-egui\.sh/); +assert.match(check, /sha256sum \.\/\*\.deb \.\/\*\.rpm > SHA256SUMS/); +assert.match(check, /bash scripts\/verify-linux-egui-packages\.sh/); +assert.match(gate, /cargo tree --locked/); +assert.match(gate, /tauri\|wry\|webkit2gtk/); +assert.doesNotMatch(ci + check, /libgtk-3|webkit2gtk|libwebkit/); + +// An admin's existing Tauri tag launches an independent Linux build. No PR or +// manual dispatch can attach assets; Beta remains a shared draft for review. +assert.match(release, /tags:\s*\n\s*- 'v\*-tauri'/); +assert.match(release, /if: github\.event_name == 'push' && startsWith\(github\.ref, 'refs\/tags\/v'\) && endsWith\(github\.ref, '-tauri'\)/); +assert.match(release, /needs: build-linux-egui/); +assert.match(release, /contents: write/); +assert.match(release, /softprops\/action-gh-release@v2/); +assert.match(release, /draft:.*Beta\./); +assert.match(release, /prerelease:.*Beta\./); +assert.match(release, /tag_name: \$\{\{ github\.ref_name \}\}/); +assert.match(check, /does not match package\.json version/); +const revision = revisionFile.trim(); +assert.match(revision, /^[1-9][0-9]*$/, 'Linux package revision must be a positive integer'); +assert.ok(Number(revision) > 79, 'new Linux packages must upgrade the last -79 candidate'); +assert.match(check, /REVISION=\$\(< linux-egui\/package-revision\)/); +assert.match(check, /version=\$\{VERSION%%\+\*\}-\$REVISION/); +assert.match(buildScript, /rerun-if-changed=package-revision/); +assert.match(buildScript, /rerun-if-env-changed=OPENLESS_LINUX_VERSION/); +assert.match(buildScript, /OPENLESS_APP_VERSION=\{expected\}/); +assert.match(host, /env!\("OPENLESS_APP_VERSION"\)/); +assert.match(verify, /DEB_VERSION=\$\(dpkg-deb --field/); +assert.match(verify, /OpenLess \$DEB_VERSION/); +assert.match(pack, /linux-egui\/package-revision/); +assert.match(pack, /if \[ "\$VERSION" != "\$EXPECTED_VERSION" \]/); +assert.ok(!tauri.includes('build-linux-egui:'), 'Linux does not build Tauri'); +assert.ok(!release.includes('release_tag:'), 'the manual build cannot target a Release'); + +// Keep deb/rpm only, including dynamically-loaded desktop libraries and the +// fcitx5 addon. No AppImage, minisign, embedded downloader or Qwen ASR runtime. +assert.match(pack, /dpkg-deb --build/); +assert.match(pack, /rpmbuild --define/); +assert.match(pack, /x86_64-linux-gnu\/fcitx5\/libopenless\.so/); +assert.match(pack, /\/usr\/lib64\/fcitx5\/libopenless\.so/); +assert.doesNotMatch(pack, /appimagetool|AppImage|qwen-asr|qwen_asr/i); +assert.doesNotMatch(release + check, /appimagetool|APPIMAGE_|MINISIGN|latest-linux-egui/i); +assert.match(verify, /test "\$\{#debs\[@\]\}" -eq 1/); +assert.match(verify, /test "\$\{#rpms\[@\]\}" -eq 1/); +assert.match(verify, /test "\$\{#appimages\[@\]\}" -eq 0/); +assert.match(verify, /sha256sum --check --strict SHA256SUMS/); +assert.match(verify, /desktop-file-validate/); +assert.match(verify, /check_elf "\$WORK\/deb\/usr\/bin\/openless"/); +assert.match(verify, /fcitx5\/libopenless\.so/); +assert.ok(!existsSync(join(root, 'openless-all/app/linux-egui/src/updater.rs'))); +assert.doesNotMatch(manifest, /minisign-verify/); +const debDeps = pack.match(/^Depends: ([^\n]+)/m)?.[1].split(/,\s*/) ?? []; +for (const dep of ['fcitx5', 'libpipewire-0.3-0', 'libegl1', 'liblzma5', 'libwayland-egl1']) { + assert.ok(debDeps.includes(dep), `deb must require ${dep}`); +} +const rpmDeps = pack.match(/^Requires: ([^\n]+)/m)?.[1].split(/,\s*/) ?? []; +for (const dep of ['fcitx5', 'pipewire-libs', 'libglvnd-egl', 'libwayland-egl.so.1()(64bit)']) { + assert.ok(rpmDeps.includes(dep), `rpm must require ${dep}`); +} +for (const [file, size] of [ + ['32x32.png', '32x32'], ['64x64.png', '64x64'], ['128x128.png', '128x128'], + ['128x128@2x.png', '256x256'], ['icon.png', '512x512'], +]) { + const [copy, shared] = await Promise.all([ + readFile(join(root, 'openless-all/app/linux-egui/packaging/icons', file)), + readFile(join(root, 'openless-all/app/src-tauri/icons', file)), + ]); + assert.deepEqual(copy, shared, `${file} must match the shared icon`); + assert.ok(pack.includes(`${size}:${file}`)); +} +assert.doesNotMatch(pack + release, /src-tauri/); +assert.doesNotMatch(check, /cargo (?:build|check|test) --locked --manifest-path src-tauri\/Cargo\.toml/); +console.log('linux-egui-release-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs b/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs new file mode 100644 index 000000000..6e79e20e8 --- /dev/null +++ b/openless-all/app/scripts/linux-egui-tauri-free-contract.test.mjs @@ -0,0 +1,45 @@ +import { readFile, readdir } from 'node:fs/promises'; +import { extname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const appRoot = new URL('..', import.meta.url); +const roots = [ + new URL('../linux-egui', import.meta.url), + new URL('../scripts/package-linux-egui.sh', import.meta.url), + new URL('../../../.github/workflows/release-linux-egui.yml', import.meta.url), +]; +const sourceExtensions = new Set(['.rs', '.toml', '.sh', '.yml', '.yaml']); + +async function collect(url) { + const path = fileURLToPath(url); + if (extname(path)) return [path]; + const entries = await readdir(path, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (entry.name === 'target') continue; + const child = join(path, entry.name); + if (entry.isDirectory()) files.push(...await collect(pathToFileURL(`${child}/`))); + else if (sourceExtensions.has(extname(entry.name))) files.push(child); + } + return files; +} + +const files = (await Promise.all(roots.map(collect))).flat(); +const violations = []; +for (const file of files) { + const source = await readFile(file, 'utf8'); + const normalizedFile = file.replaceAll('\\', '/'); + if (source.includes('src-tauri')) violations.push(`${file}: references src-tauri`); + if (normalizedFile.endsWith('Cargo.toml') && /^\s*(tauri|wry|webkit\w*)\s*=/mi.test(source)) { + violations.push(`${file}: declares a Tauri/Wry/WebKit dependency`); + } + if (/\b(Mock|Fake)(Backend|Provider|Repository)\b/.test(source)) { + violations.push(`${file}: production source names a mock backend/provider/repository`); + } +} + +if (violations.length) { + throw new Error(`Linux egui Tauri-free contract failed:\n${violations.join('\n')}`); +} + +console.log(`linux-egui-tauri-free-contract.test.mjs passed (${files.length} files)`); diff --git a/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs b/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs index 219e56abe..4d37a7f48 100644 --- a/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs +++ b/openless-all/app/scripts/macos-capsule-spaces-contract.test.mjs @@ -16,7 +16,7 @@ const coordinatorHostRs = ( await readFile(new URL('../src-tauri/src/tauri_coordinator_host.rs', import.meta.url), 'utf-8') ).replace(/\r\n/g, '\n'); const functionMatch = coordinatorHostRs.match( - /#\[cfg\(target_os = "macos"\)\]\s*(?:pub\((?:crate|super)\) )?fn show_capsule_window_no_activate[\s\S]*?\n}\n\n#\[cfg\(target_os = "linux"\)\]/, + /#\[cfg\(target_os = "macos"\)\]\s*(?:pub\((?:crate|super)\) )?fn show_capsule_window_no_activate[\s\S]*?\n}\n\n#\[cfg\(not\(any\(target_os = "macos", target_os = "windows"\)\)\)\]/, ); if (!functionMatch) { diff --git a/openless-all/app/scripts/package-linux-egui.sh b/openless-all/app/scripts/package-linux-egui.sh index 1cc519caf..78cb2842a 100644 --- a/openless-all/app/scripts/package-linux-egui.sh +++ b/openless-all/app/scripts/package-linux-egui.sh @@ -3,120 +3,192 @@ set -euo pipefail APP_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) VERSION=${OPENLESS_LINUX_VERSION:?OPENLESS_LINUX_VERSION is required} -PACKAGE_VERSION=${VERSION/-/\~} +# Never silently publish an unnumbered or stale package that apt treats as a +# downgrade. Each candidate explicitly advances linux-egui/package-revision. +REVISION=$(< "$APP_ROOT/linux-egui/package-revision") +[[ "$REVISION" =~ ^[1-9][0-9]*$ ]] || { echo 'invalid Linux package revision' >&2; exit 1; } +APP_VERSION=$(node -p "require('$APP_ROOT/package.json').version") +EXPECTED_VERSION="${APP_VERSION%%+*}-$REVISION" +if [ "$VERSION" != "$EXPECTED_VERSION" ]; then + echo "Linux package version must be $EXPECTED_VERSION (got $VERSION)" >&2 + exit 1 +fi ARCH=${OPENLESS_LINUX_ARCH:-x86_64} -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 +TARGET_DIR=${CARGO_TARGET_DIR:-"$APP_ROOT/target"} BINARY="$TARGET_DIR/release/openless-linux-egui" -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" +PLUGIN_ROOT="$APP_ROOT/../scripts/linux-fcitx5-plugin/build" PACKAGING="$APP_ROOT/linux-egui/packaging" OUTPUT="$TARGET_DIR/linux-egui-packages" -ICON="$APP_ROOT/src-tauri/icons/128x128@2x.png" -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" +# 图标:与 Tauri 侧共用同一套画(`icon.png` 与 `public/AppIcon.png` 的 md5 相同, +# 都是 512×512)。但本脚本必须保持 Tauri-free —— 两条契约都会检查脚本里不得出现 +# Tauri 源码树的路径名 —— 所以这里放字节相同的副本,并由 release 契约做逐字节 +# 一致性断言,保证两边共用一套素材且不会静默漂移。 +ICON_DIR="$PACKAGING/icons" +# 图标名 → hicolor 尺寸目录 → 源文件(与 Tauri 侧的图标集逐字节一致)。 +HICOLOR_ICONS=( + "32x32:32x32.png" + "64x64:64x64.png" + "128x128:128x128.png" + "256x256:128x128@2x.png" + "512x512:icon.png" +) + +test -x "$BINARY" +test -d "$ICON_DIR" +for spec in "${HICOLOR_ICONS[@]}"; do test -s "$ICON_DIR/${spec#*:}"; done +test -s "$PLUGIN_ROOT/libopenless.so" +test -s "$PLUGIN_ROOT/openless.conf" +test -s "$PACKAGING/openless.desktop" +test -s "$PACKAGING/top.openless.OpenLess.metainfo.xml" +command -v dpkg-deb >/dev/null +command -v rpmbuild >/dev/null + 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 -} +POST_INSTALL="$TARGET_DIR/openless-fcitx5-postinst" +cat > "$POST_INSTALL" <<'EOF' +#!/usr/bin/env bash +set +e +# Package installation runs as root, while fcitx5 belongs to the logged-in +# desktop user. Reconnect only to existing user DBus sessions; never start a +# daemon or fail the package transaction when no graphical session is active. +for bus in /run/user/[0-9]*/bus; do + [ -S "$bus" ] || continue + runtime_dir=${bus%/bus} + uid=${runtime_dir##*/} + [ "$uid" != "0" ] || continue + user=$(getent passwd "$uid" | cut -d: -f1) + [ -n "$user" ] || continue + runuser -u "$user" -- env \ + XDG_RUNTIME_DIR="$runtime_dir" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=$bus" \ + timeout 5s dbus-send --session --dest=org.fcitx.Fcitx5 --type=method_call /controller org.fcitx.Fcitx.Controller1.Restart >/dev/null 2>&1 || true +done +exit 0 +EOF +chmod 0755 "$POST_INSTALL" + +# 卸载同样要重启 fcitx5:文件被删掉后,运行中的输入法仍持有旧插件的映像。 +# 用 D-Bus 的 controller Restart(立即返回),**不要**用 `fcitx5 -r`:它会替换 daemon +# 并一直前台运行,导致 postinst/postrm 每次都要等满 timeout(安装卡顿),还会被 kill 掉新 daemon。 +POST_REMOVE="$TARGET_DIR/openless-fcitx5-postrm" +sed 's/Package installation runs as root/Package removal runs as root/' \ + "$POST_INSTALL" > "$POST_REMOVE" +chmod 0755 "$POST_REMOVE" + +# 插件指纹清单:装完包后一条命令就能核对「系统里的插件 == 包里的插件」。 +PLUGIN_SHA=$(sha256sum "$PLUGIN_ROOT/libopenless.so" | awk '{print $1}') +PLUGIN_MANIFEST="$TARGET_DIR/openless-fcitx5-manifest" +cat > "$PLUGIN_MANIFEST" < "$DEB_ROOT/DEBIAN/control" < "$RPM_TOP/SPECS/openless.spec" </dev/null 2>&1 || true +done +exit 0 +%post +set +e +for bus in /run/user/[0-9]*/bus; do + [ -S "\$bus" ] || continue + runtime_dir=\${bus%/bus}; uid=\${runtime_dir##*/} + [ "\$uid" != 0 ] || continue + user=\$(getent passwd "\$uid" | cut -d: -f1) + [ -n "\$user" ] && timeout 5s runuser -u "\$user" -- env XDG_RUNTIME_DIR="\$runtime_dir" DBUS_SESSION_BUS_ADDRESS="unix:path=\$bus" dbus-send --session --dest=org.fcitx.Fcitx5 --type=method_call /controller org.fcitx.Fcitx.Controller1.Restart >/dev/null 2>&1 || true done -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" +exit 0 +EOF +rpmbuild --define "_topdir $RPM_TOP" --define "_dbpath $RPM_TOP/rpmdb" \ + --define "_tmppath $RPM_TOP/tmp" -bb "$RPM_TOP/SPECS/openless.spec" +mv "$RPM_TOP/RPMS/x86_64/openless-$RPM_VERSION-1.x86_64.rpm" \ + "$OUTPUT/OpenLess-Linux-egui-${VERSION}-${ARCH}.rpm" -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/register-ts-loader.mjs b/openless-all/app/scripts/register-ts-loader.mjs new file mode 100644 index 000000000..b94fce2fa --- /dev/null +++ b/openless-all/app/scripts/register-ts-loader.mjs @@ -0,0 +1,4 @@ +// Registers the TypeScript resolve hook so scripts can import `src/i18n/*.ts`. +import { register } from 'node:module'; + +register('./ts-resolve-hook.mjs', import.meta.url); diff --git a/openless-all/app/scripts/selection-polish-panel-contract.test.mjs b/openless-all/app/scripts/selection-polish-panel-contract.test.mjs new file mode 100644 index 000000000..9252f39b7 --- /dev/null +++ b/openless-all/app/scripts/selection-polish-panel-contract.test.mjs @@ -0,0 +1,109 @@ +// 「划词润色结果」不再有独立窗口:它复用选区助手(qa)面板显示第二套 UI +// (只读结果 + 「确认并替换」)。本契约锁住这次合并,防止有人把独立窗口加回来 +// 或把 /qa 面板拆回去。 +// +// 为什么需要它: +// - 独立窗口 `selection-polish-preview` 曾是第 6 个桌面浮窗(多一个 WebKit 进程 +// + 一套只服务一个弹窗的前端页面);合并后入口只剩「预览确认」这一个输出模式。 +// - 合并依赖三处配合:后端把窗口指向 qa、前端 QaPanel 认事件、IPC 用 pending +// 标志区分「真的在润色模式」和「残留快照」。任一处回退都会静默失灵(窗口不再 +// 出现),所以逐条断言。 + +import { readFile, stat } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const read = (relativePath) => readFile(`${appRoot}/${relativePath}`, 'utf8'); + +const violations = []; + +const lib = await read('src-tauri/src/lib.rs'); +const capabilities = await read('src-tauri/capabilities/default.json'); +const commands = await read('src-tauri/src/commands/selection_polish_preview.rs'); +const qaPanel = await read('src/pages/QaPanel.tsx'); +const appTsx = await read('src/App.tsx'); +const mainTsx = await read('src/main.tsx'); + +// 1. 独立窗口彻底下线:能力表、前端路由、页面文件都不再有它。 +const windowCapabilities = JSON.parse(capabilities).windows; +if (windowCapabilities.includes('selection-polish-preview')) { + violations.push('capabilities/default.json still grants the selection-polish-preview window'); +} +if (!windowCapabilities.includes('qa')) { + violations.push('capabilities/default.json dropped the qa window (the merged panel needs it)'); +} +for (const [name, source] of [ + ['src/App.tsx', appTsx], + ['src/main.tsx', mainTsx], +]) { + if (source.includes('selection-polish-preview')) { + violations.push(`${name}: still routes the deleted selection-polish-preview window`); + } + if (source.includes('SelectionPolishPreview')) { + violations.push(`${name}: still imports the deleted SelectionPolishPreview page`); + } +} +const previewPageExists = await stat(`${appRoot}/src/pages/SelectionPolishPreview.tsx`).then( + () => true, + () => false, +); +if (previewPageExists) { + violations.push('src/pages/SelectionPolishPreview.tsx still exists'); +} + +// 2. 后端把「润色结果」交给 qa 面板,而不是新窗口。 +if (!/fn show_selection_polish_preview[\s\S]{0,400}?show_qa_window\(app, "polish-preview"\)/.test(lib)) { + violations.push('show_selection_polish_preview no longer opens the qa panel'); +} +if (!/emit_to\("qa", SELECTION_POLISH_PREVIEW_SHOWN/.test(lib)) { + violations.push('the polish-result "shown" event is not emitted to the qa window'); +} +if (!/emit_to\("qa", SELECTION_POLISH_PREVIEW_HIDE/.test(lib)) { + violations.push('the polish-result "hide" event is not emitted to the qa window'); +} +if (/WebviewWindowBuilder::new\(\s*app,\s*"selection-polish-preview"/.test(lib)) { + violations.push('lib.rs builds a selection-polish-preview window again'); +} + +// 3. 是否退出润色模式由前端决定(面板可能正在提问对话中)。 +if (!/fn hide_selection_polish_preview[\s\S]{0,300}?clear_selection_polish_preview_pending\(\)/.test(lib)) { + violations.push('hide_selection_polish_preview no longer clears the pending flag'); +} + +// 4. IPC:预览负载只在「真的处于润色模式」时给出。 +if (!/fn get_selection_polish_preview[\s\S]{0,400}?if !crate::selection_polish_preview_pending\(\)/.test(commands)) { + violations.push('get_selection_polish_preview lost its pending gate'); +} +if (!/fn confirm_selection_polish_preview[\s\S]{0,200}?clear_selection_polish_preview_pending\(\)/.test(commands)) { + violations.push('confirm_selection_polish_preview no longer clears the pending flag'); +} +if (!/fn cancel_selection_polish_preview[\s\S]{0,200}?clear_selection_polish_preview_pending\(\)/.test(commands)) { + violations.push('cancel_selection_polish_preview no longer clears the pending flag'); +} + +// 5. 面板侧:认事件、只读渲染、提供「确认并替换」。 +for (const eventName of ['selection-polish-preview:shown', 'selection-polish-preview:hide']) { + if (!qaPanel.includes(`'${eventName}'`)) { + violations.push(`QaPanel.tsx does not listen for ${eventName}`); + } +} +if (!qaPanel.includes('selectionPolishPreview.confirmReplace')) { + violations.push('QaPanel.tsx dropped the 「确认并替换」 button of the polish-result mode'); +} +if (!qaPanel.includes('aria-readonly="true"')) { + violations.push('QaPanel.tsx polish-result box is no longer read-only'); +} + +// 6. 五种语言都要有这套文案(合并后由 qa 面板复用同一批 key)。 +for (const locale of ['zh-CN', 'zh-TW', 'en', 'ja', 'ko']) { + const source = await read(`src/i18n/${locale}.ts`); + if (!source.includes('selectionPolishPreview:')) { + violations.push(`src/i18n/${locale}.ts lost the selectionPolishPreview strings`); + } +} + +if (violations.length) { + throw new Error(`Selection polish panel contract failed:\n${violations.join('\n')}`); +} + +console.log('selection-polish-panel-contract.test.mjs passed'); diff --git a/openless-all/app/scripts/sync-egui-i18n.mjs b/openless-all/app/scripts/sync-egui-i18n.mjs new file mode 100644 index 000000000..82330171a --- /dev/null +++ b/openless-all/app/scripts/sync-egui-i18n.mjs @@ -0,0 +1,340 @@ +#!/usr/bin/env node +// Sync the Linux egui localization catalog with the Tauri i18n catalogs. +// +// The two UIs ship as one product and support the same five locales, but they +// keep separate catalogs: the Tauri app uses `src/i18n/*.ts`, the egui host uses +// the Rust table in `linux-egui/src/i18n.rs`. This script removes the duplicated +// translation work: for every key the egui catalog shares with the Tauri one it +// copies the five translations over, so translators only maintain the Tauri +// files. +// +// Matching ignores case, `.` and `_`, so `overview.week_days` picks up the +// Tauri `overview.weekDays` value and `nav.polish_mode` picks up +// `nav.polishMode`. Tauri's `{{name}}` placeholders become the positional `{}` +// the Rust `fmt_catalog` expects, and array values (weekday/month label lists) +// are joined with `|`, which is the encoding the egui pages split on. +// +// Run with: node scripts/sync-egui-i18n.mjs [--check] +// --check report drift and exit non-zero instead of rewriting the catalog + +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const APP_ROOT = resolve(HERE, '..'); +const CATALOG_PATH = resolve(APP_ROOT, 'linux-egui/src/i18n.rs'); + +// Rust row order: [zh-CN, zh-TW, en, ja, ko]. +const LOCALES = [ + ['zh-CN', 'zhCN'], + ['zh-TW', 'zhTW'], + ['en', 'en'], + ['ja', 'ja'], + ['ko', 'ko'], +]; + +const RUST_LOCALE_ORDER = ['zh-CN', 'zh-TW', 'en', 'ja', 'ko']; + +/// Keys where the egui host deliberately keeps its own wording and must not be +/// overwritten by the Tauri value (different product surface / screenshot parity). +const KEEP_EGUI = new Set([ + // Screenshot labels the sidebar entry "纠错规则"; Tauri uses "纠正规则". + 'nav.corrections', + // Screenshot labels the detail action "重新转写"; Tauri uses "重新转录". + 'history.retranscribe', + // The egui status line prefixes the product name. + 'less_computer.done', +]); + +function placeholderCount(text) { + return (text.match(/\{\}/g) ?? []).length; +} + +function normalizeKey(key) { + return key.toLowerCase().replace(/[._]/g, ''); +} + +function flatten(value, prefix, out) { + for (const [key, entry] of Object.entries(value)) { + const path = prefix ? `${prefix}.${key}` : key; + if (Array.isArray(entry)) { + out.set(path, entry.join('|')); + } else if (entry && typeof entry === 'object') { + flatten(entry, path, out); + } else if (typeof entry === 'string') { + out.set(path, entry); + } + } + return out; +} + +async function loadTauriCatalog() { + const byLocale = new Map(); + for (const [tag, exportName] of LOCALES) { + const module = await import(resolve(APP_ROOT, `src/i18n/${tag}.ts`)); + byLocale.set(tag, flatten(module[exportName], '', new Map())); + } + // normalized key -> { tag -> text } + const shared = new Map(); + const primary = byLocale.get('zh-CN'); + for (const [key, text] of primary) { + const entry = { 'zh-CN': text }; + let complete = true; + for (const tag of RUST_LOCALE_ORDER) { + if (tag === 'zh-CN') continue; + const value = byLocale.get(tag).get(key); + if (value === undefined) { + complete = false; + break; + } + entry[tag] = value; + } + if (complete) shared.set(normalizeKey(key), entry); + } + return shared; +} + +/** Convert a Tauri string into the Rust catalog form. */ +function toRustText(text) { + return text + // React markup used only for emphasis in the Tauri UI has no egui meaning. + .replace(/<\/?[a-zA-Z][^>]*>/g, '') + .replace(/\{\{\s*[^}]+?\s*\}\}/g, '{}'); +} + +function rustEscape(text) { + return text.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r'); +} + +function quote(text) { + return `"${rustEscape(text)}"`; +} + +// ── Rust catalog scanner ──────────────────────────────────────────────────── + +/** Index of the matching close for the opening bracket at `open`. */ +function matchBracket(source, open, openChar, closeChar) { + let depth = 0; + let i = open; + let inString = false; + while (i < source.length) { + const ch = source[i]; + if (inString) { + if (ch === '\\') i += 1; + else if (ch === '"') inString = false; + } else if (ch === '"') { + inString = true; + } else if (ch === openChar) { + depth += 1; + } else if (ch === closeChar) { + depth -= 1; + if (depth === 0) return i; + } + i += 1; + } + throw new Error(`unbalanced ${openChar} at ${open}`); +} + +/** Read the Rust string literal starting at `start` (which must be `"`). */ +function readString(source, start) { + let out = ''; + let i = start + 1; + while (i < source.length) { + const ch = source[i]; + if (ch === '\\') { + const next = source[i + 1]; + if (next === 'n') out += '\n'; + else if (next === 'r') out += '\r'; + else if (next === 't') out += '\t'; + else out += next; + i += 2; + continue; + } + if (ch === '"') return { text: out, end: i }; + out += ch; + i += 1; + } + throw new Error('unterminated string literal'); +} + +/** All `Msg { key: "…", text: row(…) }` entries with their source ranges. */ +function scanMessages(source) { + const entries = []; + const keyPattern = /key:\s*"/g; + let match; + while ((match = keyPattern.exec(source)) !== null) { + // `match[0]` includes the opening quote; point `readString` at it. + const keyStart = match.index + match[0].length - 1; + const key = readString(source, keyStart); + const blockStart = source.lastIndexOf('Msg {', keyStart); + const braceStart = source.indexOf('{', blockStart); + const blockEnd = matchBracket(source, braceStart, '{', '}'); + const rowStart = source.indexOf('row(', keyStart); + if (rowStart === -1 || rowStart > blockEnd) continue; + const parenStart = rowStart + 'row'.length; + const parenEnd = matchBracket(source, parenStart, '(', ')'); + + const values = []; + let i = parenStart + 1; + while (i < parenEnd) { + const ch = source[i]; + if (ch === '"') { + const literal = readString(source, i); + values.push(literal.text); + i = literal.end + 1; + continue; + } + i += 1; + } + // Consume the block's trailing comma too; `renderBlock` re-emits it. + const end = source[blockEnd + 1] === ',' ? blockEnd + 2 : blockEnd + 1; + entries.push({ key: key.text, values, start: blockStart, end }); + } + return entries; +} + +function renderBlock(key, values) { + const oneLine = ` Msg {\n key: "${key}",\n text: row(${values.map(quote).join(', ')}),\n },`; + const anyLong = values.some((value) => value.length > 12); + if (!anyLong) return oneLine; + return [ + ' Msg {', + ` key: "${key}",`, + ' text: row(', + ...values.map((value) => ` ${quote(value)},`), + ' ),', + ' },', + ].join('\n'); +} + +/** `tr_l10n(…, "key")` / `fmt_l10n(…, "key", …)` call sites under `src/`. */ +function referencedKeys() { + const keys = new Set(); + const files = []; + const walk = (directory) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = resolve(directory, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.name.endsWith('.rs')) files.push(path); + } + }; + walk(resolve(APP_ROOT, 'linux-egui/src')); + const pattern = /\b(?:tr_l10n|fmt_l10n)\(\s*[^,()]+,\s*"([^"]+)"/g; + for (const file of files) { + const source = readFileSync(file, 'utf8'); + let match; + while ((match = pattern.exec(source)) !== null) keys.add(match[1]); + } + return keys; +} + +/** Insert index for new entries: just before the CATALOG slice terminator. */ +function catalogInsertIndex(source) { + const start = source.indexOf('pub const CATALOG'); + if (start === -1) throw new Error('CATALOG array not found in i18n.rs'); + const end = source.indexOf('\n];', start); + if (end === -1) throw new Error('CATALOG array terminator not found'); + return end + 1; +} + +async function main() { + const check = process.argv.includes('--check'); + const shared = await loadTauriCatalog(); + const source = readFileSync(CATALOG_PATH, 'utf8'); + const messages = scanMessages(source); + + const rewritten = []; + let updated = 0; + let unchanged = 0; + let skipped = 0; + let guarded = 0; + let cursor = 0; + for (const message of messages) { + rewritten.push(source.slice(cursor, message.start)); + const tauri = shared.get(normalizeKey(message.key)); + if (!tauri || KEEP_EGUI.has(message.key)) { + skipped += 1; + rewritten.push(source.slice(message.start, message.end)); + cursor = message.end; + continue; + } + const values = RUST_LOCALE_ORDER.map((tag) => toRustText(tauri[tag])); + // Never take a translation whose placeholders no longer line up with the + // arguments the egui pages pass; that would render a literal `{}`. + const expected = message.values.map(placeholderCount); + const mismatch = values.some( + (value, index) => placeholderCount(value) !== expected[index], + ); + if (mismatch) { + guarded += 1; + if (check) console.log(`skipped (placeholder mismatch): ${message.key}`); + rewritten.push(source.slice(message.start, message.end)); + cursor = message.end; + continue; + } + const same = + values.length === message.values.length && + values.every((value, index) => value === message.values[index]); + if (same) { + unchanged += 1; + rewritten.push(source.slice(message.start, message.end)); + } else { + updated += 1; + if (check) { + console.log(`drift: ${message.key}`); + console.log(` egui: ${JSON.stringify(message.values)}`); + console.log(` tauri: ${JSON.stringify(values)}`); + } + rewritten.push(renderBlock(message.key, values)); + } + cursor = message.end; + } + rewritten.push(source.slice(cursor)); + let output = rewritten.join(''); + + // Add catalog rows for keys the egui sources reference but the catalog lacks. + const known = new Set(messages.map((message) => message.key)); + const missing = []; + for (const key of referencedKeys()) { + if (known.has(key)) continue; + const tauri = shared.get(normalizeKey(key)); + if (!tauri) missing.push(key); + } + const additions = []; + for (const key of [...referencedKeys()].sort()) { + if (known.has(key)) continue; + const tauri = shared.get(normalizeKey(key)); + if (!tauri) continue; + additions.push(renderBlock(key, RUST_LOCALE_ORDER.map((tag) => toRustText(tauri[tag])))); + } + if (additions.length > 0) { + const at = catalogInsertIndex(output); + output = `${output.slice(0, at)}${additions.join('\n')}\n${output.slice(at)}`; + } + + console.log( + `egui i18n catalog: ${messages.length} keys · ${updated} updated · ${unchanged} in sync · ` + + `${skipped} egui-only/kept · ${guarded} skipped (placeholder mismatch) · ` + + `${additions.length} added`, + ); + if (missing.length > 0) { + console.error( + `referenced keys with no Tauri translation (add them by hand):\n ${missing.join('\n ')}`, + ); + } + + if (check) { + if (updated > 0 || additions.length > 0) { + console.error('Catalog is out of sync with the Tauri i18n files.'); + process.exit(1); + } + return; + } + if (updated > 0 || additions.length > 0) { + writeFileSync(CATALOG_PATH, output); + console.log(`wrote ${CATALOG_PATH}`); + } +} + +await main(); diff --git a/openless-all/app/scripts/sync-egui-lucide-icons.mjs b/openless-all/app/scripts/sync-egui-lucide-icons.mjs new file mode 100644 index 000000000..5991659ee --- /dev/null +++ b/openless-all/app/scripts/sync-egui-lucide-icons.mjs @@ -0,0 +1,41 @@ +// Reproduce the Linux interface icons from the exact lucide-react dependency +// used by the Tauri shell. Run with --check to verify checked-in SVG assets. +import { createElement } from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import * as lucide from 'lucide-react'; +import { readFileSync, mkdirSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { resolve } from 'node:path'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const dest = resolve(root, 'linux-egui/assets/lucide'); +// Names match src/components/Icon.tsx. Stop/Send/Pin/Chat are popup +// controls and use the corresponding Lucide shape from the same version. +export const icons = { + Overview: 'ChartNoAxesColumn', History: 'History', Vocab: 'BookOpenText', + Style: 'SlidersHorizontal', SelectionAsk: 'MessageSquareText', Settings: 'Settings', + Mic: 'Mic', Sparkle: 'Sparkles', Hash: 'Hash', Clock: 'Clock3', Bolt: 'Zap', + Copy: 'Copy', Search: 'Search', Trash: 'Trash2', Refresh: 'RefreshCw', + Download: 'Download', Upload: 'Upload', Plus: 'Plus', Play: 'Play', + Stop: 'Square', Close: 'X', Check: 'Check', Send: 'ArrowUp', Pin: 'Pin', + Chat: 'MessageSquare', More: 'Ellipsis', ChevronRight: 'ChevronRight', + Feather: 'Feather', Layout: 'PanelLeft', Doc: 'FileText', Pencil: 'Pencil', + Cloud: 'Cloud', Shield: 'ShieldCheck', Info: 'Info', Help: 'CircleHelp', + External: 'ExternalLink', Monitor: 'Monitor', +}; +const version = JSON.parse(readFileSync(resolve(root, 'node_modules/lucide-react/package.json'), 'utf8')).version; +const check = process.argv.includes('--check'); +if (!check) mkdirSync(dest, { recursive: true }); +for (const [name, component] of Object.entries(icons)) { + if (!lucide[component]) throw new Error(`Unknown Lucide icon ${component}`); + // White masks are tinted at draw time by egui. The shape, stroke width, + // caps, joins and viewBox are otherwise the same as Tauri's Icon component. + const svg = `${renderToStaticMarkup(createElement(lucide[component], { size: 24, color: '#ffffff', strokeWidth: 1.75 }))}\n`; + const path = resolve(dest, `${name}.svg`); + if (check) { + if (readFileSync(path, 'utf8') !== svg) throw new Error(`${name}.svg differs from lucide-react ${version}`); + } else { + writeFileSync(path, svg); + } +} +console.log(`${Object.keys(icons).length} Linux icons match lucide-react ${version}`); diff --git a/openless-all/app/scripts/tauri-linux-free-contract.test.mjs b/openless-all/app/scripts/tauri-linux-free-contract.test.mjs new file mode 100644 index 000000000..0bb0f9cd2 --- /dev/null +++ b/openless-all/app/scripts/tauri-linux-free-contract.test.mjs @@ -0,0 +1,95 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { access, constants } from 'node:fs/promises'; +import { basename, extname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +// The Tauri shell no longer ships a Linux desktop target: Linux runs the egui +// host in `openless-all/app/linux-egui`. This contract keeps that split from +// drifting back — a Linux-only cfg block, a Linux-only dependency, or a Linux +// branch in the React frontend would silently re-grow an unsupported target. +// +// Kept on purpose (so the checks below stay narrow): +// - `src-tauri/src/lib.rs` keeps a `#[cfg(target_os = "linux")] compile_error!` +// guard so an accidental Linux build fails with an actionable message. +// - i18n platform descriptions may legitimately say "Windows / Linux" +// because Linux users still run the product through the egui host. + +const appRoot = new URL('..', import.meta.url); +const tauriSrc = fileURLToPath(new URL('../src-tauri/src', import.meta.url)); +const reactSrc = fileURLToPath(new URL('../src', import.meta.url)); +const failures = []; + +async function collect(dir, extensions, { skip = () => false } = {}) { + const entries = await readdir(dir, { withFileTypes: true }); + const files = []; + for (const entry of entries) { + if (entry.name === 'target' || entry.name === 'node_modules') continue; + const child = join(dir, entry.name); + if (skip(child)) continue; + if (entry.isDirectory()) files.push(...await collect(child, extensions, { skip })); + else if (extensions.has(extname(entry.name))) files.push(child); + } + return files; +} + +// ── 1. Rust: no Linux cfg branches beyond the compile_error! guard ────────── +const rustFiles = await collect(tauriSrc, new Set(['.rs'])); +for (const file of rustFiles) { + const source = await readFile(file, 'utf8'); + const lines = source.split('\n'); + lines.forEach((line, index) => { + const normalized = line.trim(); + if (!normalized.includes('target_os = "linux"')) return; + const isGuard = basename(file) === 'lib.rs' + && /^#\[cfg\(target_os = "linux"\)\]$/.test(normalized) + && lines.slice(index, index + 3).some((entry) => entry.includes('compile_error!')); + if (!isGuard) failures.push(`${file}:${index + 1}: Linux cfg branch`); + }); + if (/#!?\[cfg_attr\(target_os = "linux"/.test(source)) { + failures.push(`${file}: still suppresses dead code with a Linux cfg_attr`); + } +} + +// ── 2. Cargo manifest: no Linux target deps, no Linux-only patch ───────────── +const manifest = await readFile(new URL('../src-tauri/Cargo.toml', import.meta.url), 'utf8'); +if (/\[target\..*cfg\(target_os = "linux"\)/.test(manifest)) { + failures.push('src-tauri/Cargo.toml: declares a Linux target dependency table'); +} +for (const forbidden of ['wayland-scanner', 'linux-native-sync-persistent', '[patch.crates-io]']) { + if (manifest.includes(forbidden)) { + failures.push(`src-tauri/Cargo.toml: still references ${forbidden}`); + } +} +try { + await access(new URL('../src-tauri/vendor/wayland-scanner', import.meta.url), constants.F_OK); + failures.push('src-tauri/vendor/wayland-scanner: Linux-only vendored patch is still present'); +} catch { + // absent, as expected +} + +// ── 3. React: no Linux branch (i18n copy is excluded, see the header) ──────── +const reactFiles = await collect(reactSrc, new Set(['.ts', '.tsx']), { + skip: (path) => /[/\\]i18n[/\\]/.test(path), +}); +for (const file of reactFiles) { + if (file.endsWith('.test.ts') || file.endsWith('.test.tsx')) continue; + const source = await readFile(file, 'utf8'); + for (const pattern of [/LINUX_TITLEBAR_HEIGHT/, /ol-linux-/, /['"]linux['"]/]) { + const match = source.match(pattern); + if (match) failures.push(`${file}: React still branches on Linux (${match[0]})`); + } +} + +// ── 4. The guard itself must stay meaningful ──────────────────────────────── +const libSource = await readFile(new URL('../src-tauri/src/lib.rs', import.meta.url), 'utf8'); +if (!/compile_error!\(\s*"Tauri 版已不再支持 Linux 桌面/.test(libSource)) { + failures.push('src-tauri/src/lib.rs: the Linux compile_error! guard is missing'); +} + +if (failures.length) { + throw new Error(`Tauri Linux-free contract failed:\n${failures.join('\n')}`); +} + +console.log( + `tauri-linux-free-contract.test.mjs passed (${rustFiles.length} rust files, ${reactFiles.length} react files)`, +); diff --git a/openless-all/app/scripts/ts-resolve-hook.mjs b/openless-all/app/scripts/ts-resolve-hook.mjs new file mode 100644 index 000000000..91398b784 --- /dev/null +++ b/openless-all/app/scripts/ts-resolve-hook.mjs @@ -0,0 +1,13 @@ +// ESM resolve hook: let Node import the Tauri i18n TypeScript modules, which +// use extension-less relative specifiers (`./en`) the way a bundler allows but +// Node's ESM resolver does not. +export async function resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.[cm]?[jt]sx?$/.test(specifier)) { + try { + return await nextResolve(`${specifier}.ts`, context); + } catch { + // fall through to the default resolution for a clearer error + } + } + return nextResolve(specifier, context); +} diff --git a/openless-all/app/scripts/verify-linux-egui-packages.sh b/openless-all/app/scripts/verify-linux-egui-packages.sh index b8bd7124a..f77fb5112 100644 --- a/openless-all/app/scripts/verify-linux-egui-packages.sh +++ b/openless-all/app/scripts/verify-linux-egui-packages.sh @@ -1,62 +1,81 @@ #!/usr/bin/env bash -# Package/backend verification only: never starts an egui or desktop UI. +# Validate exactly what the tag workflow uploads. No GUI session or AppImage required. 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 +TARGET_DIR=${CARGO_TARGET_DIR:-"$APP_ROOT/target"} OUTPUT="$TARGET_DIR/linux-egui-packages" shopt -s nullglob -debs=("$OUTPUT"/*.deb); rpms=("$OUTPUT"/*.rpm); appimages=("$OUTPUT"/*.AppImage); components=("$OUTPUT"/*.tar.gz) +debs=("$OUTPUT"/*.deb) +rpms=("$OUTPUT"/*.rpm) +appimages=("$OUTPUT"/*.AppImage) test "${#debs[@]}" -eq 1 test "${#rpms[@]}" -eq 1 -test "${#appimages[@]}" -eq 1 -test "${#components[@]}" -eq 1 -(cd "$OUTPUT"; sha256sum --check --strict SHA256SUMS) +test "${#appimages[@]}" -eq 0 +test -s "$OUTPUT/SHA256SUMS" +test "$(wc -l < "$OUTPUT/SHA256SUMS")" -eq 2 +test "$(find "$OUTPUT" -maxdepth 1 \( -type f -o -type l \) | wc -l)" -eq 3 +( + 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 + local binary=$1 deps + file -L "$binary" | grep -q 'ELF 64-bit.*x86-64' + deps=$(ldd "$binary") + if grep -q 'not found' <<< "$deps"; then printf '%s\n' "$deps"; return 1; fi + if grep -Eqi 'webkit|wry|tauri' <<< "$deps"; then printf '%s\n' "$deps"; 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 +check_elf "$TARGET_DIR/release/openless-linux-egui" + +WORK=$(mktemp -d) +trap 'rm -rf -- "$WORK"' EXIT dpkg-deb --extract "${debs[0]}" "$WORK/deb" +test -x "$WORK/deb/usr/bin/openless" +test -s "$WORK/deb/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so" +test -s "$WORK/deb/usr/share/fcitx5/addon/openless.conf" +test -s "$WORK/deb/usr/share/openless/fcitx5-addon.sha256" +DEB_PLUGIN_SHA=$(sha256sum "$WORK/deb/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so" | cut -d' ' -f1) +grep -Eq "^openless [^ ]+ fcitx5-addon-sha256 $DEB_PLUGIN_SHA$" \ + "$WORK/deb/usr/share/openless/fcitx5-addon.sha256" +for size in 32x32 64x64 128x128 256x256 512x512; do + test -s "$WORK/deb/usr/share/icons/hicolor/$size/apps/openless.png" +done 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' +# Catch stale build-script output: dpkg metadata may say -N while the UI +# still displays an older revision embedded in the binary. +DEB_VERSION=$(dpkg-deb --field "${debs[0]}" Version) +test "$("$WORK/deb/usr/bin/openless" --version)" = "OpenLess $DEB_VERSION" +DEB_FILES=$(dpkg-deb --contents "${debs[0]}") +grep -Fq 'usr/bin/openless' <<< "$DEB_FILES" +grep -Fq 'fcitx5/libopenless.so' <<< "$DEB_FILES" +DEB_DEPENDS=$(dpkg-deb --field "${debs[0]}" Depends) +grep -Fq 'fcitx5' <<< "$DEB_DEPENDS" +grep -Fq 'libpipewire-0.3-0' <<< "$DEB_DEPENDS" + +mkdir -p "$WORK/rpm" +( cd "$WORK/rpm" && rpm2cpio "${rpms[0]}" | cpio -idm --quiet ) +test -x "$WORK/rpm/usr/bin/openless" +test -s "$WORK/rpm/usr/lib64/fcitx5/libopenless.so" +test -s "$WORK/rpm/usr/share/fcitx5/addon/openless.conf" +test -s "$WORK/rpm/usr/share/openless/fcitx5-addon.sha256" +RPM_PLUGIN_SHA=$(sha256sum "$WORK/rpm/usr/lib64/fcitx5/libopenless.so" | cut -d' ' -f1) +grep -Eq "^openless [^ ]+ fcitx5-addon-sha256 $RPM_PLUGIN_SHA$" \ + "$WORK/rpm/usr/share/openless/fcitx5-addon.sha256" +test "$DEB_PLUGIN_SHA" = "$RPM_PLUGIN_SHA" +desktop-file-validate "$WORK/rpm/usr/share/applications/openless.desktop" +appstreamcli validate --no-net "$WORK/rpm/usr/share/metainfo/top.openless.OpenLess.metainfo.xml" +check_elf "$WORK/rpm/usr/bin/openless" +check_elf "$WORK/rpm/usr/lib64/fcitx5/libopenless.so" +test "$("$WORK/rpm/usr/bin/openless" --version)" = "OpenLess $DEB_VERSION" +RPM_FILES=$(rpm -qlp "${rpms[0]}") +grep -Fxq '/usr/bin/openless' <<< "$RPM_FILES" +grep -Fxq '/usr/lib64/fcitx5/libopenless.so' <<< "$RPM_FILES" +grep -Fxq '/usr/share/openless/fcitx5-addon.sha256' <<< "$RPM_FILES" +RPM_DEPENDS=$(rpm -qp --requires "${rpms[0]}") +grep -Fq 'fcitx5' <<< "$RPM_DEPENDS" +grep -Fq 'pipewire-libs' <<< "$RPM_DEPENDS" +printf 'PASS: deb/rpm contents, ELF dependencies, desktop metadata and SHA-256\n' diff --git a/openless-all/app/scripts/windows-ui-config.test.mjs b/openless-all/app/scripts/windows-ui-config.test.mjs index c99585d57..d4ab3ecc2 100644 --- a/openless-all/app/scripts/windows-ui-config.test.mjs +++ b/openless-all/app/scripts/windows-ui-config.test.mjs @@ -133,21 +133,12 @@ if (/standardWindowButton|setFrameOrigin: origin|tune_macos_main_window_controls 'macOS traffic lights should not be manually repositioned; keep native AppKit button frames visible', ); } -if (!/className=\"ol-linux-close-btn\"/.test(windowChromeTsx)) { - throw new Error('linux titlebar should keep the close button treatment'); -} assertMatch( tokensCss, /--ol-motion-spring:[\s\S]*?--ol-motion-soft:[\s\S]*?--ol-motion-quick:/, 'shared motion tokens should drive shell animations and transitions', ); -assertMatch( - windowChromeTsx, - /function LinuxTitlebar\(\)/, - 'linux should keep the custom ol-linux-titlebar shell', -); - assertMatch( floatingShellTsx, /className="ol-console-main"[\s\S]*?borderRadius:\s*0,[\s\S]*?boxShadow:\s*'none'/, diff --git a/openless-all/app/shared/remote_tls/tls_identity.rs b/openless-all/app/shared/remote_tls/tls_identity.rs new file mode 100644 index 000000000..d8b9f8358 --- /dev/null +++ b/openless-all/app/shared/remote_tls/tls_identity.rs @@ -0,0 +1,580 @@ +//! A persistent, per-installation CA and a separate short-lived TLS leaf. +//! Phones trust the CA once; changes to LAN or virtual-adapter IPs only reissue +//! the leaf. Never silently replace an unreadable CA: that revokes phone trust. + +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::Arc; + +use rcgen::{ + BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa, + KeyPair, KeyUsagePurpose, +}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; +use serde::{Deserialize, Serialize}; +use time::{Duration, OffsetDateTime}; + +const IDENTITY_FILE: &str = "remote-tls-identity-v1.json"; +const MAX_IDENTITY_BYTES: u64 = 64 * 1024; + +#[derive(Serialize, Deserialize)] +struct StoredIdentity { + ca_cert: Vec, + ca_key: Vec, + leaf_cert: Vec, + leaf_key: Vec, +} + +pub(super) struct TlsIdentity { + /// Only this public CA certificate is offered for phone installation. + pub trust_cert: Vec, + pub ca_fingerprint_sha256: String, + pub server_config: Arc, +} + +fn key_der(bytes: Vec) -> PrivateKeyDer<'static> { + PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(bytes)) +} + +fn matching_key(cert: &[u8], key: &[u8]) -> Result<(), String> { + rustls::sign::CertifiedKey::from_der( + vec![CertificateDer::from(cert.to_vec())], + key_der(key.to_vec()), + &rustls::crypto::ring::default_provider(), + ) + .map(|_| ()) + .map_err(|error| format!("remote TLS certificate/key mismatch: {error}")) +} + +fn parse_cert(cert: &[u8]) -> Result { + CertificateParams::from_ca_cert_der(&CertificateDer::from(cert)) + .map_err(|error| format!("invalid remote TLS certificate: {error}")) +} + +fn read_identity(path: &Path) -> Result, String> { + let metadata = match std::fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(format!("cannot inspect remote TLS identity: {error}")), + }; + if !metadata.is_file() || metadata.len() > MAX_IDENTITY_BYTES { + return Err("remote TLS identity must be a regular file under 64 KiB".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|error| format!("cannot protect remote TLS identity: {error}"))?; + } + let mut bytes = Vec::new(); + std::fs::File::open(path) + .and_then(|file| file.take(MAX_IDENTITY_BYTES + 1).read_to_end(&mut bytes)) + .map_err(|error| format!("cannot read remote TLS identity: {error}"))?; + if bytes.len() as u64 > MAX_IDENTITY_BYTES { + return Err("remote TLS identity exceeds 64 KiB".into()); + } + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|_| "remote TLS identity is damaged; restore it from backup instead of replacing phone trust".into()) +} + +fn save_identity( + path: &Path, + identity: &StoredIdentity, + first_install: bool, +) -> Result<(), String> { + let directory = path.parent().ok_or("remote TLS directory is unavailable")?; + let bytes = serde_json::to_vec(identity).map_err(|error| error.to_string())?; + // tempfile creates private files (0600 on Unix). Windows inherits the user's + // application-data ACL. Keep the key and certificates in one atomic bundle. + let mut file = tempfile::NamedTempFile::new_in(directory) + .map_err(|error| format!("cannot create remote TLS identity: {error}"))?; + file.write_all(&bytes) + .and_then(|_| file.as_file().sync_all()) + .map_err(|error| format!("cannot write remote TLS identity: {error}"))?; + if first_install { + file.persist_noclobber(path) + } else { + file.persist(path) + } + .map_err(|error| format!("cannot persist remote TLS identity: {error}"))?; + Ok(()) +} + +fn create_ca(now: OffsetDateTime) -> Result { + let mut params = CertificateParams::default(); + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, "OpenLess Remote Input CA"); + name.push(DnType::OrganizationName, "OpenLess"); + params.distinguished_name = name; + params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); + params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + params.not_before = now - Duration::days(1); + params.not_after = now + Duration::days(3650); + let key = KeyPair::generate().map_err(|error| error.to_string())?; + let cert = params + .self_signed(&key) + .map_err(|error| error.to_string())?; + Ok(StoredIdentity { + ca_cert: cert.der().to_vec(), + ca_key: key.serialize_der(), + leaf_cert: Vec::new(), + leaf_key: Vec::new(), + }) +} + +fn valid_leaf(identity: &StoredIdentity, sans: &[String], now: OffsetDateTime) -> bool { + let Ok(params) = parse_cert(&identity.leaf_cert) else { + return false; + }; + let Ok(required) = CertificateParams::new(sans.to_vec()) else { + return false; + }; + if !matches!(params.is_ca, IsCa::NoCa | IsCa::ExplicitNoCa) + || params.not_before > now + || params.not_after < now + Duration::days(30) + || !required + .subject_alt_names + .iter() + .all(|san| params.subject_alt_names.contains(san)) + || matching_key(&identity.leaf_cert, &identity.leaf_key).is_err() + { + return false; + } + // Validate the cached leaf against the stored trust anchor, not a sidecar + // list of SANs. A stale or mismatched leaf must never reach the listener. + use rustls::client::danger::ServerCertVerifier; + let mut roots = rustls::RootCertStore::empty(); + if roots + .add(CertificateDer::from(identity.ca_cert.clone())) + .is_err() + { + return false; + } + let Ok(verifier) = rustls::client::WebPkiServerVerifier::builder_with_provider( + Arc::new(roots), + Arc::new(rustls::crypto::ring::default_provider()), + ) + .build() else { + return false; + }; + let Some(name) = sans + .first() + .and_then(|name| rustls::pki_types::ServerName::try_from(name.clone()).ok()) + else { + return false; + }; + let Ok(timestamp) = u64::try_from(now.unix_timestamp()) else { + return false; + }; + verifier + .verify_server_cert( + &CertificateDer::from(identity.leaf_cert.as_slice()), + &[], + &name, + &[], + rustls::pki_types::UnixTime::since_unix_epoch(std::time::Duration::from_secs( + timestamp, + )), + ) + .is_ok() +} + +pub(super) fn load_or_create(directory: &Path, sans: &[String]) -> Result { + load_at(directory, sans, OffsetDateTime::now_utc()) +} + +fn load_at(directory: &Path, sans: &[String], now: OffsetDateTime) -> Result { + if sans.is_empty() { + return Err("remote TLS requires at least one server name".into()); + } + std::fs::create_dir_all(directory) + .map_err(|error| format!("cannot create remote TLS directory: {error}"))?; + let path = directory.join(IDENTITY_FILE); + let saved = read_identity(&path)?; + let first_install = saved.is_none(); + let mut identity = match saved { + Some(identity) => identity, + None => create_ca(now)?, + }; + matching_key(&identity.ca_cert, &identity.ca_key)?; + let (remainder, ca_x509) = x509_parser::parse_x509_certificate(&identity.ca_cert) + .map_err(|_| "invalid remote TLS CA encoding")?; + if !remainder.is_empty() + || ca_x509.issuer() != ca_x509.subject() + || ca_x509.verify_signature(None).is_err() + { + return Err("remote TLS CA signature is invalid; restore the identity from backup".into()); + } + let ca_params = parse_cert(&identity.ca_cert)?; + if !matches!(ca_params.is_ca, IsCa::Ca(_)) + || !ca_params.key_usages.contains(&KeyUsagePurpose::KeyCertSign) + || ca_params.not_before > now + || ca_params.not_after <= now + Duration::days(30) + { + return Err("remote TLS CA is invalid or expires soon; restore a valid identity or explicitly reset it and trust the new certificate on each phone".into()); + } + if !valid_leaf(&identity, sans, now) { + let ca_key = KeyPair::try_from(&key_der(identity.ca_key.clone())) + .map_err(|error| error.to_string())?; + let ca_expiry = ca_params.not_after; + let ca = ca_params + .self_signed(&ca_key) + .map_err(|error| error.to_string())?; + let key = KeyPair::generate().map_err(|error| error.to_string())?; + let mut params = + CertificateParams::new(sans.to_vec()).map_err(|error| error.to_string())?; + let mut name = DistinguishedName::new(); + name.push(DnType::CommonName, "OpenLess Remote Input"); + name.push(DnType::OrganizationName, "OpenLess"); + params.distinguished_name = name; + params.key_usages = vec![KeyUsagePurpose::DigitalSignature]; + params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + params.not_before = now - Duration::days(1); + params.not_after = (now + Duration::days(365)).min(ca_expiry); + params.use_authority_key_identifier_extension = true; + let cert = params + .signed_by(&key, &ca, &ca_key) + .map_err(|error| error.to_string())?; + identity.leaf_cert = cert.der().to_vec(); + identity.leaf_key = key.serialize_der(); + if !valid_leaf(&identity, sans, now) { + return Err("generated remote TLS certificate failed validation".into()); + } + save_identity(&path, &identity, first_install)?; + } + let config = rustls::ServerConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .map_err(|error| error.to_string())? + .with_no_client_auth() + .with_single_cert( + vec![CertificateDer::from(identity.leaf_cert)], + key_der(identity.leaf_key), + ) + .map_err(|error| format!("remote TLS config: {error}"))?; + Ok(TlsIdentity { + ca_fingerprint_sha256: fingerprint_sha256(&identity.ca_cert), + trust_cert: identity.ca_cert, + server_config: Arc::new(config), + }) +} + +/// 对实际 DER 证书计算指纹,而非散列名称或描述文件元数据。 +/// 电脑通过本地 IPC 显示完整值,供用户从独立渠道核对。 +pub(super) fn fingerprint_sha256(cert: &[u8]) -> String { + use sha2::{Digest, Sha256}; + format!("{:x}", Sha256::digest(cert)) +} + +/// The profile contains a public CA only. IDs include its fingerprint so two +/// computers can be trusted without replacing each other's iOS profiles. +pub(super) fn mobileconfig(cert: &[u8]) -> String { + use base64::Engine; + let fingerprint = fingerprint_sha256(cert); + let b64 = base64::engine::general_purpose::STANDARD.encode(cert); + format!( + r#" + + +PayloadContent +PayloadCertificateFileNameopenless-ca.cer +PayloadContent{b64} +PayloadTypecom.apple.security.root +PayloadIdentifiercom.openless.remote-input.{fingerprint}.cert +PayloadUUID{cert_uuid} +PayloadVersion1 +PayloadDisplayNameOpenLess Remote Input CA ({short}) + +PayloadDisplayNameOpenLess Remote Input ({short}) +PayloadDescriptionBefore trusting, compare the certificate's full SHA-256 fingerprint in system certificate details with OpenLess settings on your computer. Names and profile identifiers are not proof of identity. Expect exactly one root certificate and no other settings. If the fingerprint differs or cannot be checked, do not install or enable full trust; remove the profile if already installed. This CA can issue certificates; remove it when no longer needed. +PayloadIdentifiercom.openless.remote-input.{fingerprint} +PayloadTypeConfiguration +PayloadUUID{profile_uuid} +PayloadVersion1 +"#, + short = &fingerprint[..8], + cert_uuid = uuid::Uuid::new_v4(), + profile_uuid = uuid::Uuid::new_v4(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names() -> Vec { + vec!["localhost".into(), "127.0.0.1".into(), "192.168.1.2".into()] + } + + fn stored(directory: &Path) -> StoredIdentity { + read_identity(&directory.join(IDENTITY_FILE)) + .unwrap() + .unwrap() + } + + #[test] + fn fingerprint_uses_full_sha256() { + assert_eq!( + fingerprint_sha256(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn copied_profile_labels_do_not_authenticate_a_replacement_certificate() { + use base64::Engine; + let original_dir = tempfile::tempdir().unwrap(); + let replacement_dir = tempfile::tempdir().unwrap(); + let original = load_or_create(original_dir.path(), &names()).unwrap(); + let replacement = load_or_create(replacement_dir.path(), &names()).unwrap(); + + // 两张证书的名称完全相同,描述文件的名称和标识也可以照抄。 + assert_eq!( + parse_cert(&original.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( + &encode(&original.trust_cert), + &encode(&replacement.trust_cert), + ); + assert!(substituted.contains(&original.ca_fingerprint_sha256)); + let certificate_data = substituted + .split_once("") + .unwrap() + .1 + .split_once("") + .unwrap() + .0; + let actual_certificate = base64::engine::general_purpose::STANDARD + .decode(certificate_data) + .unwrap(); + + // 独立查看实际证书会发现指纹不同,且原 CA 的 TLS 验证拒绝替换证书。 + assert_ne!( + fingerprint_sha256(&actual_certificate), + original.ca_fingerprint_sha256 + ); + assert_eq!( + fingerprint_sha256(&actual_certificate), + replacement.ca_fingerprint_sha256 + ); + assert!(!verify_server( + &replacement, + &original.trust_cert, + "localhost" + )); + assert_ne!( + original.ca_fingerprint_sha256, + fingerprint_sha256(&stored(original_dir.path()).leaf_cert) + ); + } + + fn verify_server(identity: &TlsIdentity, trusted_ca: &[u8], name: &str) -> bool { + let mut roots = rustls::RootCertStore::empty(); + roots + .add(CertificateDer::from(trusted_ca.to_vec())) + .unwrap(); + let client_config = rustls::ClientConfig::builder_with_provider(Arc::new( + rustls::crypto::ring::default_provider(), + )) + .with_safe_default_protocol_versions() + .unwrap() + .with_root_certificates(roots) + .with_no_client_auth(); + let mut client = rustls::ClientConnection::new( + Arc::new(client_config), + name.to_owned().try_into().unwrap(), + ) + .unwrap(); + let mut server = + rustls::ServerConnection::new(Arc::clone(&identity.server_config)).unwrap(); + for _ in 0..10 { + let mut bytes = Vec::new(); + client.write_tls(&mut bytes).unwrap(); + server.read_tls(&mut bytes.as_slice()).unwrap(); + if server.process_new_packets().is_err() { + return false; + } + bytes.clear(); + server.write_tls(&mut bytes).unwrap(); + client.read_tls(&mut bytes.as_slice()).unwrap(); + if client.process_new_packets().is_err() { + return false; + } + if !client.is_handshaking() && !server.is_handshaking() { + return true; + } + } + false + } + + #[test] + fn restart_reuses_identity_and_tls_works_with_only_the_downloaded_ca() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let before = std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap(); + let second = load_or_create(dir.path(), &names()).unwrap(); + assert_eq!( + before, + std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap() + ); + assert_eq!(first.trust_cert, second.trust_cert); + assert_eq!( + first.ca_fingerprint_sha256, + fingerprint_sha256(&first.trust_cert) + ); + assert_eq!(first.ca_fingerprint_sha256, second.ca_fingerprint_sha256); + for name in names() { + assert!(verify_server(&second, &first.trust_cert, &name)); + } + assert!(!verify_server(&second, &first.trust_cert, "192.168.1.99")); + let saved = stored(dir.path()); + let ca = parse_cert(&saved.ca_cert).unwrap(); + let leaf = parse_cert(&saved.leaf_cert).unwrap(); + assert!(matches!( + ca.is_ca, + IsCa::Ca(BasicConstraints::Constrained(0)) + )); + assert!(matches!(leaf.is_ca, IsCa::NoCa | IsCa::ExplicitNoCa)); + assert!(leaf + .extended_key_usages + .contains(&ExtendedKeyUsagePurpose::ServerAuth)); + assert!(leaf.not_after - leaf.not_before <= Duration::days(366)); + } + + #[test] + fn virtual_adapter_and_lan_changes_preserve_phone_trust() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let before = stored(dir.path()); + let mut changed = names(); + changed.push("172.26.112.1".into()); + changed.push("192.168.2.3".into()); + let second = load_or_create(dir.path(), &changed).unwrap(); + let after = stored(dir.path()); + assert_eq!(before.ca_cert, after.ca_cert); + assert_eq!(before.ca_key, after.ca_key); + assert_eq!(first.ca_fingerprint_sha256, second.ca_fingerprint_sha256); + assert_ne!(before.leaf_cert, after.leaf_cert); + assert!(verify_server(&second, &first.trust_cert, "192.168.2.3")); + // Removing an adapter does not require another leaf or CA. + load_or_create(dir.path(), &names()).unwrap(); + assert_eq!(after.leaf_cert, stored(dir.path()).leaf_cert); + } + + #[test] + fn renews_expiring_leaf_without_rotating_ca() { + let dir = tempfile::tempdir().unwrap(); + let now = OffsetDateTime::now_utc(); + load_at(dir.path(), &names(), now).unwrap(); + let before = stored(dir.path()); + load_at(dir.path(), &names(), now + Duration::days(340)).unwrap(); + let after = stored(dir.path()); + assert_eq!(before.ca_cert, after.ca_cert); + assert_ne!(before.leaf_cert, after.leaf_cert); + assert!(parse_cert(&after.leaf_cert).unwrap().not_after > now + Duration::days(700)); + } + + #[test] + fn repairs_bad_leaf_but_never_replaces_damaged_or_mismatched_ca() { + let dir = tempfile::tempdir().unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let path = dir.path().join(IDENTITY_FILE); + let mut saved = stored(dir.path()); + saved.leaf_cert = vec![0]; + save_identity(&path, &saved, false).unwrap(); + let repaired = load_or_create(dir.path(), &names()).unwrap(); + assert!(verify_server(&repaired, &first.trust_cert, "localhost")); + saved = stored(dir.path()); + let mut damaged = stored(dir.path()); + *damaged.ca_cert.last_mut().unwrap() ^= 1; + save_identity(&path, &damaged, false).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!(damaged.ca_cert, stored(dir.path()).ca_cert); + saved.ca_key = KeyPair::generate().unwrap().serialize_der(); + save_identity(&path, &saved, false).unwrap(); + let before = std::fs::read(&path).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!(before, std::fs::read(&path).unwrap()); + std::fs::write(&path, b"interrupted write").unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert_eq!( + b"interrupted write", + std::fs::read(&path).unwrap().as_slice() + ); + } + + #[test] + fn refuses_expired_ca_instead_of_silently_revoking_trust() { + let dir = tempfile::tempdir().unwrap(); + let now = OffsetDateTime::now_utc(); + load_at(dir.path(), &names(), now).unwrap(); + let before = std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap(); + assert!(load_at(dir.path(), &names(), now + Duration::days(3651)).is_err()); + assert_eq!( + before, + std::fs::read(dir.path().join(IDENTITY_FILE)).unwrap() + ); + } + + #[test] + fn migration_keeps_legacy_files_and_profiles_only_contain_public_ca() { + use base64::Engine; + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("remote-cert-v4.der"), b"legacy cert").unwrap(); + std::fs::write(dir.path().join("remote-key-v4.der"), b"legacy key").unwrap(); + let first = load_or_create(dir.path(), &names()).unwrap(); + let profile = mobileconfig(&first.trust_cert); + let saved = stored(dir.path()); + let encoded = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); + assert!(profile.contains(&format!("{}", encoded(&first.trust_cert)))); + assert!(!profile.contains(&encoded(&saved.ca_key))); + assert!(!profile.contains(&encoded(&saved.leaf_key))); + assert!(!profile.contains(&encoded(&saved.leaf_cert))); + assert_eq!( + std::fs::read(dir.path().join("remote-cert-v4.der")).unwrap(), + b"legacy cert" + ); + assert_eq!( + std::fs::read(dir.path().join("remote-key-v4.der")).unwrap(), + b"legacy key" + ); + let other = tempfile::tempdir().unwrap(); + let second = load_or_create(other.path(), &names()).unwrap(); + assert_ne!(first.trust_cert, second.trust_cert); + assert!(!verify_server(&second, &first.trust_cert, "localhost")); + assert_ne!(profile, mobileconfig(&second.trust_cert)); + } + + #[test] + fn persistence_errors_never_fall_back_to_an_ephemeral_identity() { + let dir = tempfile::tempdir().unwrap(); + let blocked = dir.path().join("blocked"); + std::fs::write(&blocked, b"not a directory").unwrap(); + assert!(load_or_create(&blocked, &names()).is_err()); + std::fs::create_dir(dir.path().join(IDENTITY_FILE)).unwrap(); + assert!(load_or_create(dir.path(), &names()).is_err()); + assert!(load_or_create(dir.path(), &[]).is_err()); + } + + #[cfg(unix)] + #[test] + fn stored_private_keys_are_owner_only() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + load_or_create(dir.path(), &names()).unwrap(); + assert_eq!( + std::fs::metadata(dir.path().join(IDENTITY_FILE)) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + } +} diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index cb5fc9762..b6d36e7e0 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -180,7 +180,6 @@ dependencies = [ "parking_lot", "percent-encoding", "windows-sys 0.60.2", - "wl-clipboard-rs", "x11rb", ] @@ -212,7 +211,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom 7.1.3", + "nom", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -607,15 +606,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - [[package]] name = "block2" version = "0.5.1" @@ -838,15 +828,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - [[package]] name = "cc" version = "1.2.63" @@ -871,7 +852,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" dependencies = [ - "nom 7.1.3", + "nom", ] [[package]] @@ -1473,24 +1454,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "dbus-secret-service" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" -dependencies = [ - "aes", - "block-padding", - "cbc", - "dbus", - "fastrand", - "hkdf", - "num", - "once_cell", - "sha2", - "zeroize", -] - [[package]] name = "deflate64" version = "0.1.12" @@ -1505,7 +1468,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs", "displaydoc", - "nom 7.1.3", + "nom", "num-bigint", "num-traits", "rusticata-macros", @@ -1697,12 +1660,6 @@ dependencies = [ "tendril", ] -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "dpi" version = "0.1.2" @@ -2009,12 +1966,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "flate2" version = "1.1.9" @@ -2623,15 +2574,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hkdf" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" -dependencies = [ - "hmac", -] - [[package]] name = "hmac" version = "0.12.1" @@ -2995,7 +2937,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ - "block-padding", "generic-array", ] @@ -3236,10 +3177,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" dependencies = [ "byteorder", - "dbus-secret-service", - "linux-keyutils", "log", - "secret-service", "security-framework 2.11.1", "security-framework 3.7.0", "windows-sys 0.60.2", @@ -3326,16 +3264,6 @@ dependencies = [ "libc", ] -[[package]] -name = "linux-keyutils" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" -dependencies = [ - "bitflags 2.13.0", - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3739,19 +3667,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags 2.13.0", - "cfg-if", - "cfg_aliases", - "libc", - "memoffset", -] - [[package]] name = "nom" version = "7.1.3" @@ -3762,15 +3677,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 = "nu-ansi-term" version = "0.50.3" @@ -3780,20 +3686,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" -dependencies = [ - "num-bigint", - "num-complex", - "num-integer", - "num-iter", - "num-rational", - "num-traits", -] - [[package]] name = "num-bigint" version = "0.4.6" @@ -3839,28 +3731,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "num-iter" -version = "0.1.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" -dependencies = [ - "autocfg", - "num-integer", - "num-traits", -] - -[[package]] -name = "num-rational" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" -dependencies = [ - "num-bigint", - "num-integer", - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -4342,14 +4212,12 @@ dependencies = [ "core-graphics 0.24.0", "coreaudio-sys", "cpal", - "dbus", "enigo", "env_logger", "foundry-local-sdk", "futures-util", "getrandom 0.3.4", "global-hotkey", - "gtk", "hmac", "hyper", "hyper-util", @@ -4638,17 +4506,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 2.14.0", -] - [[package]] name = "phf" version = "0.13.1" @@ -5501,7 +5358,7 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom 7.1.3", + "nom", ] [[package]] @@ -5689,25 +5546,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "secret-service" -version = "4.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" -dependencies = [ - "aes", - "cbc", - "futures-util", - "generic-array", - "hkdf", - "num", - "once_cell", - "rand 0.8.6", - "serde", - "sha2", - "zbus 4.4.0", -] - [[package]] name = "security-framework" version = "2.11.1" @@ -6183,7 +6021,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" dependencies = [ "base64 0.13.1", - "nom 7.1.3", + "nom", "serde", "unicode-segmentation", ] @@ -6799,7 +6637,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus 5.16.0", + "zbus", ] [[package]] @@ -7487,17 +7325,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[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" @@ -7972,74 +7799,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wayland-backend" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" -dependencies = [ - "cc", - "downcast-rs", - "rustix", - "smallvec", - "wayland-sys", -] - -[[package]] -name = "wayland-client" -version = "0.31.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" -dependencies = [ - "bitflags 2.13.0", - "rustix", - "wayland-backend", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols" -version = "0.32.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" -dependencies = [ - "bitflags 2.13.0", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-scanner" -version = "0.31.10" -dependencies = [ - "proc-macro2", - "quick-xml", - "quote", -] - -[[package]] -name = "wayland-sys" -version = "0.31.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" -dependencies = [ - "pkg-config", -] - [[package]] name = "web-sys" version = "0.3.100" @@ -8972,24 +8731,6 @@ dependencies = [ "wasmparser", ] -[[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", - "thiserror 2.0.18", - "tree_magic_mini", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-protocols-wlr", -] - [[package]] name = "writeable" version = "0.6.3" @@ -9088,7 +8829,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom 7.1.3", + "nom", "oid-registry", "ring", "rusticata-macros", @@ -9106,16 +8847,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xdg-home" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" -dependencies = [ - "libc", - "windows-sys 0.59.0", -] - [[package]] name = "xkbcommon" version = "0.8.0" @@ -9174,38 +8905,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zbus" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" -dependencies = [ - "async-broadcast", - "async-process", - "async-recursion", - "async-trait", - "enumflags2", - "event-listener", - "futures-core", - "futures-sink", - "futures-util", - "hex", - "nix", - "ordered-stream", - "rand 0.8.6", - "serde", - "serde_repr", - "sha1", - "static_assertions", - "tracing", - "uds_windows", - "windows-sys 0.52.0", - "xdg-home", - "zbus_macros 4.4.0", - "zbus_names 3.0.0", - "zvariant 4.2.0", -] - [[package]] name = "zbus" version = "5.16.0" @@ -9236,22 +8935,9 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "winnow 1.0.3", - "zbus_macros 5.16.0", - "zbus_names 4.3.2", - "zvariant 5.12.0", -] - -[[package]] -name = "zbus_macros" -version = "4.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils 2.1.0", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] @@ -9264,20 +8950,9 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zbus_names 4.3.2", - "zvariant 5.12.0", - "zvariant_utils 3.4.0", -] - -[[package]] -name = "zbus_names" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" -dependencies = [ - "serde", - "static_assertions", - "zvariant 4.2.0", + "zbus_names", + "zvariant", + "zvariant_utils", ] [[package]] @@ -9288,7 +8963,7 @@ checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", "winnow 1.0.3", - "zvariant 5.12.0", + "zvariant", ] [[package]] @@ -9488,19 +9163,6 @@ dependencies = [ "zune-core", ] -[[package]] -name = "zvariant" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" -dependencies = [ - "endi", - "enumflags2", - "serde", - "static_assertions", - "zvariant_derive 4.2.0", -] - [[package]] name = "zvariant" version = "5.12.0" @@ -9511,21 +9173,8 @@ dependencies = [ "enumflags2", "serde", "winnow 1.0.3", - "zvariant_derive 5.12.0", - "zvariant_utils 3.4.0", -] - -[[package]] -name = "zvariant_derive" -version = "4.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" -dependencies = [ - "proc-macro-crate 3.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", - "zvariant_utils 2.1.0", + "zvariant_derive", + "zvariant_utils", ] [[package]] @@ -9538,18 +9187,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils 3.4.0", -] - -[[package]] -name = "zvariant_utils" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "zvariant_utils", ] [[package]] diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 3f5610b32..59d80662e 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -83,7 +83,7 @@ tauri-plugin-single-instance = "2" tauri-plugin-autostart = "2" global-hotkey = "0.6" enigo = "0.3" -arboard = { version = "3", features = ["wayland-data-control"] } +arboard = "3" rcgen = { version = "0.13", features = ["x509-parser"] } x509-parser = { version = "0.16", features = ["verify"] } time = "0.3" @@ -111,14 +111,6 @@ features = ["windows-native"] # uses libc::free for strings allocated by the C runtime. libc = "0.2" -[target.'cfg(target_os = "linux")'.dependencies] -dbus = "0.9" -gtk = "0.18" -[target.'cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))'.dependencies.keyring] -version = "3.6.3" -default-features = false -features = ["linux-native-sync-persistent", "crypto-rust"] - [target.'cfg(target_os = "android")'.dependencies] jni = "0.21" ndk-context = "0.1" @@ -192,9 +184,3 @@ lto = "thin" codegen-units = 1 # Xcode 27 beta 的 strip 会损坏 proc-macro dylib;本地构建加 CARGO_PROFILE_RELEASE_STRIP=none。 strip = "symbols" - -# issue #801: wayland-scanner 0.31.10 still pins vulnerable quick-xml 0.39. -# This local copy backports upstream security commit d07c4f91f28b without -# pulling the breaking, unreleased wayland-rs master branch. -[patch.crates-io] -wayland-scanner = { path = "vendor/wayland-scanner" } diff --git a/openless-all/app/src-tauri/capabilities/default.json b/openless-all/app/src-tauri/capabilities/default.json index d0379b11f..759ae4f27 100644 --- a/openless-all/app/src-tauri/capabilities/default.json +++ b/openless-all/app/src-tauri/capabilities/default.json @@ -3,7 +3,7 @@ "identifier": "default", "description": "Default capabilities for OpenLess windows", "platforms": ["macOS", "windows", "linux"], - "windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow", "selection-polish-preview"], + "windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow"], "permissions": [ "core:default", "core:window:default", diff --git a/openless-all/app/src-tauri/src/asr/local/cache.rs b/openless-all/app/src-tauri/src/asr/local/cache.rs index 220e1cded..bc398eea5 100644 --- a/openless-all/app/src-tauri/src/asr/local/cache.rs +++ b/openless-all/app/src-tauri/src/asr/local/cache.rs @@ -1,7 +1,3 @@ -#![cfg_attr( - target_os = "linux", - allow(dead_code, unused_imports, unused_variables) -)] //! 本地 Qwen3-ASR 引擎缓存。 //! //! 用途:避免每次 dictation 都重加载 1.2GB+ 模型。引擎一次 load 后驻留在内存, @@ -12,7 +8,7 @@ //! `last_used`——如果中间又被使用过则不释放,否则 drop 引擎让 OS 回收 RAM。 use std::path::Path; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -20,19 +16,19 @@ use std::time::{Duration, Instant}; use anyhow::Result; use parking_lot::Mutex; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use super::{LocalQwenEngine, QwenBackend}; pub struct LocalAsrCache { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] inner: Mutex>, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] load_generation: AtomicU64, - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(target_os = "macos"))] _phantom: (), } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] struct CachedEngine { model_id: String, backend: QwenBackend, @@ -50,18 +46,18 @@ impl Default for LocalAsrCache { impl LocalAsrCache { pub fn new() -> Self { Self { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] inner: Mutex::new(None), - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] load_generation: AtomicU64::new(0), - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(target_os = "macos"))] _phantom: (), } } /// 取已缓存的同 id 引擎,没有就加载(**阻塞、可能数秒**——调用方应放 /// `spawn_blocking`)。模型 id 不同则把旧的 drop 再加载新的。 - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] pub fn get_or_load( &self, backend: QwenBackend, @@ -71,7 +67,7 @@ impl LocalAsrCache { self.get_or_load_for_lease(backend, model_id, model_dir, None) } - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] pub(crate) fn get_or_load_for_lease( &self, backend: QwenBackend, @@ -133,7 +129,7 @@ impl LocalAsrCache { } /// 在激活新模型前认领原缓存,也使尚未完成的旧 loader 失去写回 cache 的资格。 - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] pub(crate) fn claim_lease(&self, model_id: &str, generation: u64) { let mut slot = self.inner.lock(); self.load_generation.fetch_add(1, Ordering::AcqRel); @@ -143,7 +139,7 @@ impl LocalAsrCache { } /// Core 只释放自己激活的那一代;同 ID 的新实例或普通 preload 都不属于旧 lease。 - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] pub(crate) fn release_lease(&self, model_id: &str, generation: u64) { let taken = { let mut slot = self.inner.lock(); @@ -166,7 +162,7 @@ impl LocalAsrCache { /// 标记最近使用时间——end_session 在调过 transcribe 之后调一下, /// 让 release 计时器从这一刻重新算。 pub fn touch(&self) { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] { if let Some(cached) = self.inner.lock().as_mut() { cached.last_used = Instant::now(); @@ -176,7 +172,7 @@ impl LocalAsrCache { /// Session 只允许清理自己实际借出且未被新激活认领的引擎。新激活可能复用 /// 同一个 Arc,仍需保留它的 owner;下一次普通 get_or_load 才撤销该保护。 - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] pub fn finish_use(&self, engine: &Arc, discard: bool) { let mut slot = self.inner.lock(); if slot.as_ref().is_some_and(|cached| { @@ -191,7 +187,7 @@ impl LocalAsrCache { } /// Timer 只保留 Weak,用户“立即释放”后不会被旧定时器额外占用数分钟 RAM。 - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] pub fn release_current_if_idle( &self, engine: &std::sync::Weak, @@ -210,7 +206,7 @@ impl LocalAsrCache { /// 如果空闲时长 ≥ threshold,释放引擎。返回是否真释放了。 pub fn release_if_idle(&self, idle_threshold: Duration) -> bool { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] { let taken = { let mut slot = self.inner.lock(); @@ -251,7 +247,7 @@ impl LocalAsrCache { } fn release_now_inner(&self, abort_in_use: bool) { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] { let taken = { let mut slot = self.inner.lock(); @@ -268,12 +264,12 @@ impl LocalAsrCache { pressure_relief(); } } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(target_os = "macos"))] let _ = abort_in_use; } pub fn loaded_model_id(&self) -> Option { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] { return self .inner @@ -282,12 +278,12 @@ impl LocalAsrCache { .filter(|cached| cached.engine.is_healthy()) .map(|cached| cached.model_id.clone()); } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(target_os = "macos"))] None } } -#[cfg(target_os = "linux")] +#[cfg(not(target_os = "macos"))] fn pressure_relief() {} /// drop MLX Qwen 引擎后调一次:让 macOS libmalloc 把 freelist 上的物理页归还内核。 diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs index 8fabd4ee3..c66d03cdc 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_runtime.rs @@ -1,5 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] - use std::sync::Arc; #[derive(Debug, Clone)] diff --git a/openless-all/app/src-tauri/src/asr/local/local_provider.rs b/openless-all/app/src-tauri/src/asr/local/local_provider.rs index b76082925..8c8b1fa1b 100644 --- a/openless-all/app/src-tauri/src/asr/local/local_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/local_provider.rs @@ -7,23 +7,23 @@ //! engine 现在由 `LocalAsrCache` 提供——Coordinator 在 build_local_qwen3 里 //! 取已缓存的引擎再传进来,避免每次会话都重加载 1.2GB+ 模型。 -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use std::sync::atomic::{AtomicBool, Ordering}; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use std::sync::Arc; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use super::LocalQwenEngine; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use crate::asr::RawTranscript; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use anyhow::{Context, Result}; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use parking_lot::Mutex; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use tauri::{AppHandle, Emitter}; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] pub struct LocalQwenAsr { engine: Arc, operation_id: u64, @@ -32,7 +32,7 @@ pub struct LocalQwenAsr { app: AppHandle, } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] impl LocalQwenAsr { pub fn new(app: AppHandle, engine: Arc) -> Self { let operation_id = engine.next_operation_id(); @@ -98,14 +98,14 @@ impl LocalQwenAsr { } } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] impl crate::recorder::AudioConsumer for LocalQwenAsr { fn consume_pcm_chunk(&self, pcm: &[u8]) { self.buffer.lock().extend_from_slice(pcm); } } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn i16_le_bytes_to_f32(bytes: &[u8]) -> Vec { bytes .chunks_exact(2) @@ -116,17 +116,17 @@ fn i16_le_bytes_to_f32(bytes: &[u8]) -> Vec { .collect() } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn pcm_duration_ms(byte_len: usize) -> u64 { (byte_len as u64 / 2) * 1000 / 16_000 } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn token_emission_enabled(cancelled: &AtomicBool) -> bool { !cancelled.load(Ordering::Acquire) } -#[cfg(all(test, any(target_os = "macos", target_os = "linux")))] +#[cfg(all(test, target_os = "macos"))] mod tests { use super::*; diff --git a/openless-all/app/src-tauri/src/asr/local/mod.rs b/openless-all/app/src-tauri/src/asr/local/mod.rs index 95e47d4a6..413377212 100644 --- a/openless-all/app/src-tauri/src/asr/local/mod.rs +++ b/openless-all/app/src-tauri/src/asr/local/mod.rs @@ -2,7 +2,6 @@ //! //! 当前本地引擎: //! - **macOS**:Qwen3-ASR 可选 MLX/Metal 或 C/CPU; -//! - **Linux**:Qwen3-ASR C/CPU; //! - **Windows**:Foundry Local Whisper(`foundry_*`),以及 sherpa-onnx-local //! 实验 provider(`sherpa*`,offline batch + online streaming) @@ -13,7 +12,7 @@ pub mod foundry; pub mod foundry_native; pub mod foundry_provider; pub mod foundry_runtime; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] mod local_provider; pub mod models; pub mod sherpa; @@ -40,15 +39,15 @@ mod apple_speech_provider; mod mlx_qwen_engine; #[cfg(all(target_os = "macos", target_arch = "aarch64"))] mod mlx_worker; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] mod qwen_engine; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] mod qwen_ffi; #[cfg(target_os = "macos")] #[allow(unused_imports)] pub use apple_speech_provider::{native_name_to_apple_locale, AppleSpeechAsr}; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] pub use local_provider::LocalQwenAsr; #[cfg(all(target_os = "macos", target_arch = "aarch64"))] pub use mlx_qwen_engine::MlxQwenAsrEngine; @@ -69,7 +68,7 @@ pub use whisper_provider::{LocalWhisperAsr, LocalWhisperCache}; pub use models::ModelId; /// 本地 Qwen3-ASR 在 active_asr 字段里的标识;与 Core ProviderDescriptor 的 type 对齐。 -/// 旧版本的本地 Qwen3-ASR provider id。macOS 映射到 MLX,Linux 映射到 C, +/// 旧版本的本地 Qwen3-ASR provider id。macOS 映射到 MLX, /// 仅用于兼容已经保存的渠道配置;新渠道请使用下方两个明确后端 id。 pub const PROVIDER_ID: &str = "local-qwen3"; pub const LOCAL_QWEN3_MLX_PROVIDER_ID: &str = "local-qwen3-mlx"; @@ -109,8 +108,6 @@ pub fn qwen_backend_for_provider(id: &str) -> Option { match id { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] PROVIDER_ID | LOCAL_QWEN3_MLX_PROVIDER_ID => Some(QwenBackend::Mlx), - #[cfg(target_os = "linux")] - PROVIDER_ID | LOCAL_QWEN3_C_PROVIDER_ID => Some(QwenBackend::C), #[cfg(target_os = "macos")] LOCAL_QWEN3_C_PROVIDER_ID => Some(QwenBackend::C), #[cfg(all(target_os = "macos", not(target_arch = "aarch64")))] @@ -119,14 +116,14 @@ pub fn qwen_backend_for_provider(id: &str) -> Option { } } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] pub enum LocalQwenEngine { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] Mlx(MlxQwenAsrEngine), C(qwen_engine::QwenAsrEngine), } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] impl LocalQwenEngine { pub fn load(backend: QwenBackend, model_dir: &std::path::Path) -> anyhow::Result { match backend { @@ -204,15 +201,15 @@ impl LocalQwenEngine { } } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] const C_STREAM_TAIL_PADDING_SAMPLES: usize = 8_000; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn append_c_stream_tail_padding(samples: &mut Vec) { samples.resize(samples.len() + C_STREAM_TAIL_PADDING_SAMPLES, 0.0); } -#[cfg(all(test, any(target_os = "macos", target_os = "linux")))] +#[cfg(all(test, target_os = "macos"))] mod qwen_dictation_tests { use super::*; diff --git a/openless-all/app/src-tauri/src/asr/local/test_run.rs b/openless-all/app/src-tauri/src/asr/local/test_run.rs index 64551de77..066d7cf07 100644 --- a/openless-all/app/src-tauri/src/asr/local/test_run.rs +++ b/openless-all/app/src-tauri/src/asr/local/test_run.rs @@ -1,4 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! 本地 Qwen3-ASR 一键"加载 + 测试"实现。 //! //! 流程: @@ -8,11 +7,11 @@ //! 3. 加载模型,跑 batch transcribe,分别记录 load_ms / transcribe_ms //! 4. 给前端用:用户点击「加载并测试」按钮立即知道模型是否能跑、有多快、识别什么 -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use std::path::Path; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use std::sync::Arc; -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] use std::time::Instant; use anyhow::Result; @@ -22,7 +21,7 @@ use super::models::ModelId; /// 内嵌测试音频。原始文件 `vendor/qwen-asr/samples/test_speech.wav` /// 内容:"Hello. This is a test of the Voxtrail speech-to-text system." -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] const TEST_WAV: &[u8] = include_bytes!("../../../vendor/qwen-asr/samples/test_speech.wav"); /// 测试结果给前端展示。 @@ -38,7 +37,7 @@ pub struct TestResult { pub transcribe_ms: u64, } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] pub async fn run_test( model_id: ModelId, backend: Option, @@ -47,8 +46,6 @@ pub async fn run_test( if model_id.is_whisper() { #[cfg(target_os = "macos")] return run_whisper_test(model_id, model_dir).await; - #[cfg(target_os = "linux")] - anyhow::bail!("本地 Whisper 测试仅支持 macOS"); } let backend = backend.ok_or_else(|| anyhow::anyhow!("当前系统不支持所选的本地 Qwen3-ASR 后端"))?; @@ -181,16 +178,16 @@ async fn run_whisper_test(model_id: ModelId, model_dir: std::path::PathBuf) -> R }) } -#[cfg(not(any(target_os = "macos", target_os = "linux")))] +#[cfg(not(target_os = "macos"))] pub async fn run_test( _model_id: ModelId, _backend: Option, _model_dir: std::path::PathBuf, ) -> Result { - anyhow::bail!("本地 Qwen3-ASR C 后端目前仅支持 macOS/Linux;MLX 后端仅支持 macOS") + anyhow::bail!("Tauri 本地 Qwen3-ASR C/MLX 后端仅支持 macOS") } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn load_engine(backend: super::QwenBackend, dir: &Path) -> Result> { let engine = super::LocalQwenEngine::load(backend, dir)?; Ok(Arc::new(engine)) diff --git a/openless-all/app/src-tauri/src/audio_mute.rs b/openless-all/app/src-tauri/src/audio_mute.rs index c05087d12..c93863688 100644 --- a/openless-all/app/src-tauri/src/audio_mute.rs +++ b/openless-all/app/src-tauri/src/audio_mute.rs @@ -152,104 +152,7 @@ mod platform { } } -#[cfg(target_os = "linux")] -mod platform { - use std::process::Command; - - enum Backend { - Wpctl, - Pactl, - } - - pub struct PlatformMuteGuard { - backend: Backend, - was_muted: bool, - } - - pub fn activate() -> Result { - if let Ok(was_muted) = wpctl_output_muted() { - if !was_muted { - wpctl_set_output_muted(true)?; - } - return Ok(PlatformMuteGuard { - backend: Backend::Wpctl, - was_muted, - }); - } - - let was_muted = pactl_output_muted()?; - if !was_muted { - pactl_set_output_muted(true)?; - } - Ok(PlatformMuteGuard { - backend: Backend::Pactl, - was_muted, - }) - } - - impl PlatformMuteGuard { - pub fn restore(self) { - let result = match self.backend { - Backend::Wpctl => wpctl_set_output_muted(self.was_muted), - Backend::Pactl => pactl_set_output_muted(self.was_muted), - }; - if let Err(err) = result { - log::warn!("[audio-mute] restore output mute failed: {err}"); - } - } - } - - fn wpctl_output_muted() -> Result { - let output = Command::new("wpctl") - .args(["get-volume", "@DEFAULT_AUDIO_SINK@"]) - .output() - .map_err(|e| format!("wpctl get-volume failed: {e}"))?; - if !output.status.success() { - return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); - } - Ok(String::from_utf8_lossy(&output.stdout).contains("[MUTED]")) - } - - fn wpctl_set_output_muted(muted: bool) -> Result<(), String> { - let value = if muted { "1" } else { "0" }; - let output = Command::new("wpctl") - .args(["set-mute", "@DEFAULT_AUDIO_SINK@", value]) - .output() - .map_err(|e| format!("wpctl set-mute failed: {e}"))?; - if output.status.success() { - Ok(()) - } else { - Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) - } - } - - fn pactl_output_muted() -> Result { - let output = Command::new("pactl") - .args(["get-sink-mute", "@DEFAULT_SINK@"]) - .output() - .map_err(|e| format!("pactl get-sink-mute failed: {e}"))?; - if !output.status.success() { - return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); - } - let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase(); - Ok(stdout.contains("yes") || stdout.contains("是")) - } - - fn pactl_set_output_muted(muted: bool) -> Result<(), String> { - let value = if muted { "1" } else { "0" }; - let output = Command::new("pactl") - .args(["set-sink-mute", "@DEFAULT_SINK@", value]) - .output() - .map_err(|e| format!("pactl set-sink-mute failed: {e}"))?; - if output.status.success() { - Ok(()) - } else { - Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) - } - } -} - -#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +#[cfg(not(any(target_os = "macos", target_os = "windows")))] mod platform { pub struct PlatformMuteGuard; diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 31cf88702..35141e491 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -586,11 +586,11 @@ fn local_asr_configured( } } if crate::asr::local::is_local_qwen3(provider) { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] { return Some(crate::asr::local::qwen_backend_for_provider(provider).is_some()); } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(target_os = "macos"))] { return Some(false); } diff --git a/openless-all/app/src-tauri/src/commands/local_asr.rs b/openless-all/app/src-tauri/src/commands/local_asr.rs index 46067e66e..147026e2c 100644 --- a/openless-all/app/src-tauri/src/commands/local_asr.rs +++ b/openless-all/app/src-tauri/src/commands/local_asr.rs @@ -39,7 +39,7 @@ pub struct LocalAsrSettings { pub mirror: String, pub models_base_dir: Option, pub models_root_dir: String, - /// macOS/Linux 编入本地 Qwen3-ASR C 引擎;MLX 仅在 macOS 可用。 + /// 本地 Qwen3-ASR C 引擎仅在 macOS 编入;Apple Silicon 另可用 MLX。 pub engine_available: bool, } diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index c6ed9b431..992d23a6d 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -1,4 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! Tauri IPC 命令入口,按设置、凭据、历史等领域拆分子模块。 //! //! 子模块的命令及宏生成的伴生项通过 glob 重导出,供 `lib.rs` 的 @@ -320,7 +319,7 @@ mod tests { &whisper_keyless_ready )); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] { assert!(asr_configured_for_provider( crate::asr::local::PROVIDER_ID, @@ -331,7 +330,7 @@ mod tests { &snapshot() )); } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(target_os = "macos"))] assert!(!asr_configured_for_provider( crate::asr::local::PROVIDER_ID, &snapshot() diff --git a/openless-all/app/src-tauri/src/commands/selection_polish_preview.rs b/openless-all/app/src-tauri/src/commands/selection_polish_preview.rs index 9c67f78b4..e8717aba7 100644 --- a/openless-all/app/src-tauri/src/commands/selection_polish_preview.rs +++ b/openless-all/app/src-tauri/src/commands/selection_polish_preview.rs @@ -22,6 +22,11 @@ fn map_selection_preview( pub async fn get_selection_polish_preview( core: CoreState<'_>, ) -> Result, String> { + // 面板懒创建后会自己拉一次;只有后端确实处在「润色结果」模式时才给负载, + // 免把残留的选区快照当成新请求(面板会被意外切到润色模式)。 + if !crate::selection_polish_preview_pending() { + return Ok(None); + } let snapshot = core .services() .selection @@ -36,6 +41,7 @@ pub async fn confirm_selection_polish_preview( core: CoreState<'_>, text: String, ) -> Result<(), String> { + crate::clear_selection_polish_preview_pending(); let snapshot = core .services() .selection @@ -54,6 +60,7 @@ pub async fn confirm_selection_polish_preview( #[tauri::command] pub async fn cancel_selection_polish_preview(core: CoreState<'_>) -> Result<(), String> { + crate::clear_selection_polish_preview_pending(); core.services() .selection .cancel(None) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 1c2cbf1d3..e409fc49e 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1,7 +1,3 @@ -#![cfg_attr( - target_os = "linux", - allow(dead_code, unused_imports, unused_variables) -)] //! Dictation coordinator. //! //! Mirrors the Swift `DictationCoordinator` state machine. Single owner of @@ -1275,8 +1271,6 @@ impl Coordinator { .name("openless-combo-hotkey-bridge".into()) .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) .ok(); - #[cfg(target_os = "linux")] - sync_custom_dictation_to_plugin(&inner_clone); } Err(e) => { log::warn!("[coord] update combo hotkey binding 失败: {e}"); @@ -1526,24 +1520,12 @@ impl Coordinator { binding: crate::types::HotkeyBinding, ) -> Result<(), String> { if let Some(monitor) = self.inner.hotkey.lock().as_ref() { - #[cfg(target_os = "linux")] - let plugin_binding = binding.clone(); monitor.update_binding(binding); - #[cfg(target_os = "linux")] - if plugin_binding.trigger == crate::types::HotkeyTrigger::Custom { - sync_custom_dictation_to_plugin(&self.inner); - } else { - crate::linux_fcitx::sync_binding_to_plugin(&plugin_binding); - } return Ok(()); } let (tx, rx) = mpsc::channel::(); - #[cfg(target_os = "linux")] - let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); let cancel_tx = spawn_esc_cancel_bridge(&self.inner); let combo_tx = spawn_combo_abort_bridge(&self.inner, handle_trigger_combined); - #[cfg(target_os = "linux")] - let combo_tx_for_fcitx = combo_tx.clone(); match HotkeyMonitor::start(binding, tx, cancel_tx, combo_tx) { Ok(monitor) => { let adapter = monitor.kind(); @@ -1561,27 +1543,6 @@ impl Coordinator { message: Some(format!("{} 已安装", adapter.display_name())), last_error: None, }; - // Linux: 启动 fcitx5 插件信号监听作为热键源。 - #[cfg(target_os = "linux")] - { - let (qa_trigger, selection_polish_trigger, translation_trigger) = - modifier_shortcut_triggers(&self.inner); - let custom_key = custom_dictation_key_string(&self.inner); - crate::linux_fcitx::start_dictation_signal_listener( - fcitx_tx, - combo_tx_for_fcitx, - fcitx_binding.clone(), - qa_trigger, - selection_polish_trigger, - translation_trigger, - custom_key, - ); - if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { - sync_custom_dictation_to_plugin(&self.inner); - } else { - crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); - } - } } Err(e) => { *self.inner.hotkey_status.lock() = HotkeyStatus { diff --git a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs index 4ccdfc1df..cefd535c3 100644 --- a/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs +++ b/openless-all/app/src-tauri/src/coordinator/capsule_focus.rs @@ -295,99 +295,9 @@ fn emit_capsule_payload_locked(inner: &Arc, payload: CapsulePayload) -> u // 入场帧:胶囊从不可见第一次变可见。按平时的「同步 emit + 异步 show」,前端会在窗口 // 还隐藏时就起播 capsule-in,等窗口真 show 出来动画早已播完 → 用户看到胶囊「凭空出 // 现」而非「滑入」。修法:入场帧把发给 capsule 窗口的事件推迟到主线程闭包里、 - // window.show 之后再 emit,保证前端起播入场动画时窗口已可见、动画完整可见。Linux 不 - // 走胶囊窗口(文字经 fcitx5 直接 commit),保持原同步 emit 不变。 + // window.show 之后再 emit,保证前端起播入场动画时窗口已可见、动画完整可见。 let was_visible = matches!(prev_state, Some(s) if !matches!(s, CapsuleState::Idle)); - let defer_capsule_emit = visible && !was_visible && cfg!(not(target_os = "linux")); - - // Linux: 通过 fcitx5 插件在候选词列表下方显示听写状态,不干扰输入法预编辑。 - // 只在文本变化时调用 DBus,避免录音中 ~30Hz 的音频电平回调重复调用。 - #[cfg(target_os = "linux")] - { - use std::sync::Mutex; - static LAST_AUX: Mutex> = Mutex::new(None); - - let aux = match state { - CapsuleState::Idle => None, - CapsuleState::Recording => Some("🎤 收音中..."), - CapsuleState::Transcribing => Some("🔄 识别中..."), - CapsuleState::Polishing => Some("✨ 润色中..."), - CapsuleState::Done => Some("✅ 已插入"), - CapsuleState::Cancelled => Some("— 已取消"), - CapsuleState::Error => Some("❌ 出错"), - }; - - let mut last = LAST_AUX.lock().unwrap(); - if aux != last.as_deref() { - *last = aux.map(String::from); - // 代数计数器:每次状态变化 +1,retry 线程只在自己代数仍为最新时生效。 - // 避免 Recording→Idle→Recording 快速切换时多个 retry 重复触发。 - static RETRY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - // fetch_add 返回旧值,所以 latest_gen > gen+1 才表示"在我之后又发生了变更"。 - let gen = RETRY_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - match aux { - Some(t) => { - log::info!("[capsule] set_aux_down: {t} gen={gen}"); - let text = t.to_string(); - std::thread::spawn(move || { - let current = LAST_AUX.lock().unwrap().clone(); - if current.as_deref() != Some(&text) { - log::info!( - "[capsule] set_aux_down skipped: state changed to {current:?}" - ); - return; - } - if let Err(e) = crate::linux_fcitx::set_aux_down(&text) { - log::warn!("[capsule] set_aux_down failed: {e}"); - } - }); - // 终态(Done/Cancelled/Error)3 秒后自动清除,避免一直跟随焦点。 - if matches!( - state, - CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error - ) { - let text = t.to_string(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_secs(3)); - let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst); - if latest_gen > gen + 1 { - return; - } - let current = LAST_AUX.lock().unwrap().clone(); - if current.as_deref() != Some(&text) { - return; - } - log::info!("[capsule] auto-clear terminal state: {text}"); - let _ = crate::linux_fcitx::set_aux_down(""); - *LAST_AUX.lock().unwrap() = None; - }); - } - } - None => { - log::info!("[capsule] clear_aux_down gen={gen}"); - std::thread::spawn(move || { - let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst); - if latest_gen > gen + 1 { - log::info!( - "[capsule] clear_aux_down skipped: gen {gen}, latest {latest_gen}" - ); - return; - } - let current = LAST_AUX.lock().unwrap().clone(); - if current.is_some() { - log::info!( - "[capsule] clear_aux_down skipped: state changed to {current:?}" - ); - return; - } - if let Err(e) = crate::linux_fcitx::clear_aux_down() { - log::warn!("[capsule] clear_aux_down failed: {e}"); - } - }); - } - } - } - } + let defer_capsule_emit = visible && !was_visible; // emit_capsule 会被 cpal process_callback(音频回调线程)调用 ~30 Hz —— 在该 // 线程上调用 NSWindow / HWND API 会撞 macOS dispatch_assert_queue_fail SIGTRAP @@ -427,7 +337,7 @@ fn emit_capsule_payload_locked(inner: &Arc, payload: CapsulePayload) -> u } }); - // 非入场帧(含 Linux、录音中的 level 更新、离场/终态)保持即时同步 emit,最低延迟; + // 非入场帧(录音中的 level 更新、离场/终态)保持即时同步 emit,最低延迟; // 入场帧已在上面的主线程闭包里、window.show 之后 emit 过,这里跳过避免重复下发。 if !defer_capsule_emit { inner.host.emit_capsule_state_to_capsule(&payload); diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index b1e239f43..af9f3e13f 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -98,24 +98,6 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { if inner.hotkey.lock().is_some() { return; } - // Linux: 启动前检查 fcitx5 插件是否可用 - #[cfg(target_os = "linux")] - if !crate::linux_fcitx::available() { - *inner.hotkey_status.lock() = HotkeyStatus { - adapter: capability.adapter, - state: HotkeyStatusState::Failed, - message: Some("fcitx5 插件不可用 — 请确保 fcitx5 已安装且在运行".into()), - last_error: Some(crate::types::HotkeyInstallError { - code: "fcitx5_unavailable".into(), - message: "fcitx5 插件 DBus 接口无响应".into(), - }), - }; - log::warn!("[hotkey-supervisor] fcitx5 plugin unavailable, retrying..."); - attempts += 1; - drop(registration); - std::thread::sleep(std::time::Duration::from_secs(3)); - continue; - } *inner.hotkey_status.lock() = HotkeyStatus { adapter: capability.adapter, state: HotkeyStatusState::Starting, @@ -130,12 +112,8 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { keys: None, }; let (tx, rx) = mpsc::channel::(); - #[cfg(target_os = "linux")] - let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); let cancel_tx = spawn_esc_cancel_bridge(&inner); let combo_tx = spawn_combo_abort_bridge(&inner, handle_trigger_combined); - #[cfg(target_os = "linux")] - let combo_tx_for_fcitx = combo_tx.clone(); match HotkeyMonitor::start(binding, tx, cancel_tx, combo_tx) { Ok(monitor) => { let adapter = monitor.kind(); @@ -164,27 +142,6 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { .name("openless-hotkey-bridge".into()) .spawn(move || hotkey_bridge_loop(inner_clone, rx)) .ok(); - // Linux: 启动 fcitx5 插件信号监听作为热键源。 - #[cfg(target_os = "linux")] - { - let (qa_trigger, selection_polish_trigger, translation_trigger) = - modifier_shortcut_triggers(&inner); - let custom_key = custom_dictation_key_string(&inner); - crate::linux_fcitx::start_dictation_signal_listener( - fcitx_tx, - combo_tx_for_fcitx, - fcitx_binding.clone(), - qa_trigger, - selection_polish_trigger, - translation_trigger, - custom_key, - ); - if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { - sync_custom_dictation_to_plugin(&inner); - } else { - crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); - } - } return; } Err(e) => { @@ -1512,8 +1469,6 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { .name("openless-combo-hotkey-bridge".into()) .spawn(move || combo_hotkey_bridge_loop(inner_clone, rx)) .ok(); - #[cfg(target_os = "linux")] - sync_custom_dictation_to_plugin(&inner); return; } Err(e) => { @@ -2152,35 +2107,6 @@ pub(super) fn is_builtin_translation_shift(binding: &crate::types::ShortcutBindi binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") } -/// Linux: 从 runtime target 读取自定义组合键,同步到 fcitx5 插件。 -#[cfg(target_os = "linux")] -pub(super) fn custom_dictation_key_string(inner: &Arc) -> Option { - let target = hotkey_runtime_target(inner); - let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(&target.dictation); - if key_string.is_empty() { - None - } else { - Some(key_string) - } -} - -#[cfg(target_os = "linux")] -pub(super) fn sync_custom_dictation_to_plugin(inner: &Arc) { - let target = hotkey_runtime_target(inner); - let dictation = &target.dictation; - let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(dictation); - if key_string.is_empty() { - return; - } - match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) { - Ok(()) => log::info!( - "[fcitx] Synced custom dictation trigger '{}' to plugin", - key_string - ), - Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"), - } -} - pub(super) fn modifier_shortcut_triggers( inner: &Arc, ) -> ( diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index ecedfc216..046819840 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -55,7 +55,7 @@ pub(crate) struct TauriNativeAsrDependencies { foundry_generation: Arc, #[cfg(target_os = "windows")] sherpa_generation: Arc, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] qwen_cache: Arc, #[cfg(target_os = "macos")] whisper_cache: Arc, @@ -80,14 +80,14 @@ impl TauriNativeAsrDependencies { Self { foundry: Arc::new(crate::asr::local::FoundryLocalRuntime::new()), sherpa: Arc::new(crate::asr::local::SherpaOnnxRuntime::new()), - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] qwen_cache: Arc::new(crate::asr::local::LocalAsrCache::new()), #[cfg(target_os = "macos")] whisper_cache: Arc::new(crate::asr::local::LocalWhisperCache::new()), } } - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] pub(crate) fn qwen_cache(&self) -> Arc { Arc::clone(&self.qwen_cache) } @@ -134,13 +134,13 @@ pub(crate) fn backend_dependencies( .register(*provider_type, Arc::clone(&production_asr)) .expect("built-in ASR provider ids are non-empty"); } - #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "windows", target_os = "macos"))] let native_asr: Arc = Arc::new(TauriNativeTranscriptionEngine::new( native_asr_dependencies, model_store.clone(), Arc::clone(&backend), )); - #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] let _ = native_asr_dependencies; #[cfg(target_os = "windows")] for provider_type in [ @@ -154,7 +154,7 @@ pub(crate) fn backend_dependencies( .register(provider_type, Arc::clone(&native_asr)) .expect("native ASR provider ids are non-empty"); } - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] for provider_type in [ crate::asr::local::PROVIDER_ID, crate::asr::local::LOCAL_QWEN3_MLX_PROVIDER_ID, @@ -313,7 +313,7 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter { fn engine_available(&self, runtime: openless_core::LocalAsrRuntime) -> bool { match runtime { openless_core::LocalAsrRuntime::Generic => { - cfg!(any(target_os = "macos", target_os = "linux")) + cfg!(target_os = "macos") } openless_core::LocalAsrRuntime::Foundry | openless_core::LocalAsrRuntime::SherpaOnnx => cfg!(target_os = "windows"), @@ -327,12 +327,7 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter { { true } - #[cfg(target_os = "linux")] - { - openless_core::LocalAsrModelId::from_wire_id(target.model_id()) - .is_some_and(openless_core::LocalAsrModelId::is_qwen) - } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(target_os = "macos"))] { false } @@ -443,7 +438,7 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter { ) -> BoxFuture<'static, Result> { let foundry = Arc::clone(&self.native.foundry); let sherpa = Arc::clone(&self.native.sherpa); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] let qwen_cache = Arc::clone(&self.native.qwen_cache); #[cfg(target_os = "macos")] let whisper_cache = Arc::clone(&self.native.whisper_cache); @@ -459,9 +454,7 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter { } else { qwen_cache.loaded_model_id() }; - #[cfg(target_os = "linux")] - let loaded = qwen_cache.loaded_model_id(); - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(target_os = "macos"))] let loaded: Option = None; Ok(openless_core::LocalAsrRuntimeStatus { runtime: settings.runtime, @@ -641,7 +634,7 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter { // 此时才能拿到本次激活的 MLX/C provider,不能偷读尚未提交的旧偏好。 openless_core::LocalAsrRuntime::Generic => { let model = native_local_asr_model(&target)?; - if cfg!(target_os = "macos") || (cfg!(target_os = "linux") && model.is_qwen()) { + if cfg!(target_os = "macos") { Ok(target.model_id().to_string()) } else { Err(BackendError::new( @@ -685,14 +678,14 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter { self.invalidate_scheduled_release(runtime); let foundry = Arc::clone(&self.native.foundry); let sherpa = Arc::clone(&self.native.sherpa); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] let qwen_cache = Arc::clone(&self.native.qwen_cache); #[cfg(target_os = "macos")] let whisper_cache = Arc::clone(&self.native.whisper_cache); Box::pin(async move { match runtime { openless_core::LocalAsrRuntime::Generic => { - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] qwen_cache.release_now(); #[cfg(target_os = "macos")] whisper_cache.release_now(); @@ -716,7 +709,7 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter { if lease.target.runtime != openless_core::LocalAsrRuntime::Generic { return; } - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] if native_local_asr_model(&lease.target).is_ok_and(|model| model.is_qwen()) { self.native .qwen_cache @@ -739,7 +732,7 @@ impl openless_core::ModelRuntimeAdapter for TauriLocalAsrRuntimeAdapter { } // Generic 在 macOS 下有两个独立 cache。由 cache 同锁校验模型及激活代次, // 不能整体 release(Generic),也不能先查询 ID 再清空以免释放新模型。 - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] if native_local_asr_model(&lease.target).is_ok_and(|model| model.is_qwen()) { self.native .qwen_cache @@ -856,7 +849,7 @@ impl TauriLocalAsrRuntimeAdapter { let foundry = Arc::clone(&self.native.foundry); #[cfg(target_os = "windows")] let sherpa = Arc::clone(&self.native.sherpa); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] let qwen_cache = Arc::clone(&self.native.qwen_cache); #[cfg(target_os = "macos")] let whisper_cache = Arc::clone(&self.native.whisper_cache); @@ -893,7 +886,7 @@ impl TauriLocalAsrRuntimeAdapter { )); } } - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] { let provider = provider_type.as_str(); let model = native_local_asr_model(&target)?; @@ -945,7 +938,7 @@ impl TauriLocalAsrRuntimeAdapter { return Ok(()); } } - #[cfg(not(any(target_os = "macos", target_os = "linux")))] + #[cfg(not(target_os = "macos"))] let _ = model_dir; Err(BackendError::new( BackendErrorCode::Unsupported, @@ -1056,18 +1049,6 @@ impl SelectionPlatformBridge for NativeSelectionPlatformBridge { replacement_text: &str, reactivate: bool, ) -> Result { - #[cfg(target_os = "macos")] - if reactivate { - let app = self.app.lock().clone().ok_or_else(|| { - BackendError::new(BackendErrorCode::InvalidState, "Tauri AppHandle is not bound yet") - })?; - if !crate::resign_selection_polish_preview_key_for_apply(&app) { - return Err(BackendError::new( - BackendErrorCode::Platform, - "selectionPolishTargetUnavailable", - )); - } - } if reactivate && !crate::selection::reactivate_selection_insertion_target(target) { return Err(BackendError::new( BackendErrorCode::Platform, @@ -1555,7 +1536,7 @@ impl openless_core::PlatformApi for TauriPlatformApi { } } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "macos"))] struct TauriNativeTranscriptionEngine { dependencies: TauriNativeAsrDependencies, model_store: Option>, @@ -1563,7 +1544,7 @@ struct TauriNativeTranscriptionEngine { backend: BackendSlot, } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "macos"))] impl TauriNativeTranscriptionEngine { fn new( dependencies: TauriNativeAsrDependencies, @@ -1579,7 +1560,7 @@ impl TauriNativeTranscriptionEngine { } } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "macos"))] #[derive(Clone)] enum TauriNativeTranscriptionSessionKind { #[cfg(target_os = "windows")] @@ -1592,7 +1573,7 @@ enum TauriNativeTranscriptionSessionKind { provider: Arc, runtime: Arc, }, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] Qwen { engine: Arc, cache: Arc, @@ -1612,7 +1593,7 @@ enum TauriNativeTranscriptionSessionKind { AppleSpeech(Arc), } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "macos"))] #[derive(Clone)] struct TauriNativeTranscriptionSession { kind: TauriNativeTranscriptionSessionKind, @@ -1621,9 +1602,9 @@ struct TauriNativeTranscriptionSession { backend: BackendSlot, #[cfg(target_os = "windows")] session_id: SessionId, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] partials: Arc, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] next_offset: Arc, #[cfg(target_os = "windows")] generation: u64, @@ -1633,7 +1614,7 @@ struct TauriNativeTranscriptionSession { released: Arc, } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "macos"))] impl TranscriptionEngine for TauriNativeTranscriptionEngine { fn start( &self, @@ -1661,7 +1642,7 @@ impl TranscriptionEngine for TauriNativeTranscriptionEngine { let backend = Arc::clone(&self.backend); #[cfg(target_os = "windows")] let sherpa = Arc::clone(&self.dependencies.sherpa); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] let qwen_cache = Arc::clone(&self.dependencies.qwen_cache); #[cfg(target_os = "macos")] let whisper_cache = Arc::clone(&self.dependencies.whisper_cache); @@ -1775,7 +1756,7 @@ impl TranscriptionEngine for TauriNativeTranscriptionEngine { )); }; - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] let (kind, label_model) = if crate::asr::local::is_local_qwen3(provider_type) { let backend = crate::asr::local::qwen_backend_for_provider(provider_type) .ok_or_else(|| { @@ -1908,7 +1889,7 @@ impl TranscriptionEngine for TauriNativeTranscriptionEngine { )); } } - #[cfg(target_os = "linux")] + #[cfg(not(target_os = "macos"))] { return Err(BackendError::new( BackendErrorCode::Unsupported, @@ -1930,9 +1911,9 @@ impl TranscriptionEngine for TauriNativeTranscriptionEngine { backend, #[cfg(target_os = "windows")] session_id: _session_id, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] partials, - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] next_offset: Arc::new(AtomicU64::new(0)), #[cfg(target_os = "windows")] generation, @@ -1945,7 +1926,7 @@ impl TranscriptionEngine for TauriNativeTranscriptionEngine { } } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "macos"))] impl CoreAudioConsumer for TauriNativeTranscriptionSession { fn consume_pcm_chunk(&self, pcm: &[u8]) { match &self.kind { @@ -1957,7 +1938,7 @@ impl CoreAudioConsumer for TauriNativeTranscriptionSession { TauriNativeTranscriptionSessionKind::Sherpa { provider, .. } => { LegacyAudioConsumer::consume_pcm_chunk(provider.as_ref(), pcm); } - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] TauriNativeTranscriptionSessionKind::Qwen { pcm: buffer, .. } => { buffer.lock().extend_from_slice(pcm); } @@ -1973,7 +1954,7 @@ impl CoreAudioConsumer for TauriNativeTranscriptionSession { } } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "macos"))] impl TranscriptionSession for TauriNativeTranscriptionSession { fn asr_call_label(&self) -> Option { Some(self.asr_call_label.clone()) @@ -1985,9 +1966,9 @@ impl TranscriptionSession for TauriNativeTranscriptionSession { let session = self.clone(); let kind = self.kind.clone(); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] let partials = Arc::clone(&self.partials); - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] let next_offset = Arc::clone(&self.next_offset); Box::pin(async move { #[cfg(target_os = "windows")] @@ -2044,7 +2025,7 @@ impl TranscriptionSession for TauriNativeTranscriptionSession { .map_err(map_native_asr_error)?; output } - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] TauriNativeTranscriptionSessionKind::Qwen { engine, cache: _, @@ -2178,7 +2159,7 @@ fn foundry_transcription_notices( }) } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "macos"))] impl TauriNativeTranscriptionSession { fn cancel_native(&self) { match &self.kind { @@ -2186,7 +2167,7 @@ impl TauriNativeTranscriptionSession { TauriNativeTranscriptionSessionKind::Foundry { provider, .. } => provider.cancel(), #[cfg(target_os = "windows")] TauriNativeTranscriptionSessionKind::Sherpa { provider, .. } => provider.cancel(), - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] TauriNativeTranscriptionSessionKind::Qwen { engine, pcm, @@ -2238,7 +2219,7 @@ impl TauriNativeTranscriptionSession { Arc::clone(&self.current_generation), ); } - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] TauriNativeTranscriptionSessionKind::Qwen { engine, cache, .. } => { cache.finish_use(engine, discard); if !discard { @@ -2267,7 +2248,7 @@ impl TauriNativeTranscriptionSession { } } -#[cfg(any(target_os = "macos", target_os = "linux", test))] +#[cfg(any(target_os = "macos", test))] async fn await_native_transcription( timeout: std::time::Duration, operation: impl std::future::Future>, @@ -2285,7 +2266,7 @@ async fn await_native_transcription( })? } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn pcm_i16_to_f32(bytes: &[u8]) -> Vec { bytes .chunks_exact(2) @@ -2293,12 +2274,12 @@ fn pcm_i16_to_f32(bytes: &[u8]) -> Vec { .collect() } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn pcm_duration_ms(bytes: &[u8]) -> u64 { (bytes.len() as u64 / 2).saturating_mul(1_000) / 16_000 } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux", test))] +#[cfg(any(target_os = "windows", target_os = "macos", test))] fn local_asr_release_delay(keep_loaded_secs: u32) -> Option { (keep_loaded_secs != openless_core::LOCAL_ASR_KEEP_LOADED_FOREVER_SECS) .then(|| std::time::Duration::from_secs(keep_loaded_secs as u64)) @@ -2377,7 +2358,7 @@ fn schedule_sherpa_release( }); } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn schedule_qwen_release( cache: Arc, engine: std::sync::Weak, @@ -2411,12 +2392,12 @@ fn schedule_whisper_release( }); } -#[cfg(any(target_os = "macos", target_os = "linux"))] +#[cfg(target_os = "macos")] fn cancelled_native_asr_error() -> BackendError { BackendError::new(BackendErrorCode::Cancelled, "native ASR request cancelled") } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] +#[cfg(any(target_os = "windows", target_os = "macos"))] fn map_native_asr_error(error: impl std::fmt::Display) -> BackendError { BackendError::new( BackendErrorCode::Provider, @@ -3129,7 +3110,7 @@ impl TauriTextInsertionSession { } #[cfg(not(target_os = "macos"))] self.restore_insertion_target()?; - #[cfg(any(target_os = "windows", target_os = "linux"))] + #[cfg(target_os = "windows")] { let chunk = text.clone(); #[cfg(target_os = "windows")] @@ -3144,8 +3125,6 @@ impl TauriTextInsertionSession { &chunk, crate::unicode_keystroke::WindowsSendInputOptions { newline_mode }, ); - #[cfg(target_os = "linux")] - let result = crate::unicode_keystroke::type_unicode_chunk(&chunk); let written = match result { Ok(written) => written, Err(error) => error.typed_chars(), @@ -3163,7 +3142,7 @@ impl TauriTextInsertionSession { written_chars: written, }) } - #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { let _ = text; Err(BackendError::new( @@ -3351,7 +3330,7 @@ impl TextInsertionSession for TauriTextInsertionSession { } #[cfg(not(target_os = "macos"))] { - cfg!(any(target_os = "windows", target_os = "linux")) + cfg!(target_os = "windows") } } @@ -4032,7 +4011,7 @@ mod tests { ); } - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] #[tokio::test] async fn generic_activation_uses_the_requested_target_before_preferences_commit() { use openless_core::{LocalAsrRuntime, LocalAsrTarget, ModelRuntimeAdapter}; diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index b10661e90..22ec081ec 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -1,11 +1,9 @@ -#![cfg_attr(target_os = "linux", allow(dead_code))] //! 全局热键监听:发送按下 / 抬起 / 取消三类边沿事件。 //! //! - macOS:原生 CGEventTap(core-foundation + core-graphics FFI),与 Swift //! `OpenLessHotkey/HotkeyMonitor.swift` 同源。 //! - Windows:原生 `WH_KEYBOARD_LL` low-level keyboard hook,保留 modifier-only //! trigger(如右 Control / 右 Alt)的真实语义。 -//! - Linux:fcitx5 插件提供热键事件(DBus 信号 `DictationKeyEvent`)。 //! //! 仅产出带代次和单调时间戳的原始边沿,业务语义由 openless-core 解释。 //! @@ -424,6 +422,29 @@ fn reset_shared_held_state(shared: &Shared) { // ─────────────────────────── macOS implementation ─────────────────────────── +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +mod platform { + //! 没有原生全局热键后端的桌面构建:安装直接失败,调用方据此给用户明确提示, + //! 而不是默默不工作。真实实现见 macOS(CGEventTap)与 Windows(WH_KEYBOARD_LL)。 + + use super::{ + HotkeyAdapter, HotkeyBinding, HotkeyCombinedEdge, HotkeyEvent, HotkeyInstallError, + }; + use std::sync::mpsc::Sender; + + pub fn start_adapter( + _binding: HotkeyBinding, + _tx: Sender, + _cancel_tx: Sender<()>, + _combo_tx: Sender, + ) -> Result, HotkeyInstallError> { + Err(HotkeyInstallError { + code: "hotkey_unsupported".to_string(), + message: "当前平台不提供原生全局热键后端".to_string(), + }) + } +} + #[cfg(target_os = "macos")] mod platform { use std::ffi::c_void; @@ -2234,65 +2255,3 @@ mod platform { } } } - -// ─────────────────────────── Linux / other implementation ─────────────────────────── - -#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] -mod platform { - use std::sync::mpsc::Sender; - - use super::{HotkeyAdapter, HotkeyCombinedEdge, HotkeyEvent}; - use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; - - /// Linux 统一使用 fcitx5 插件作为热键源(Wayland / X11 均可)。 - /// - /// 实际的热键事件由 `linux_fcitx::start_dictation_signal_listener` 接收 - /// fcitx5 插件的 DBus 信号并转发到 `Sender`。 - pub fn start_adapter( - _binding: HotkeyBinding, - _tx: Sender, - _cancel_tx: Sender<()>, - _combo_tx: Sender, - ) -> Result, HotkeyInstallError> { - log::info!("[hotkey] Linux — fcitx5 plugin handles hotkeys"); - Ok(Box::new(PlaceholderAdapter { - _tx, - _cancel_tx, - _combo_tx, - })) - } - - /// Linux 占位 adapter:实现接口但不监听键盘。 - /// 热键事件由 fcitx5 插件的 `DictationKeyEvent` DBus 信号提供。 - /// 组合键撤销由 fcitx5 插件通过 `DictationKeyCombined` 信号上报。 - struct PlaceholderAdapter { - _tx: Sender, - _cancel_tx: Sender<()>, - _combo_tx: Sender, - } - - impl HotkeyAdapter for PlaceholderAdapter { - fn kind(&self) -> HotkeyAdapterKind { - HotkeyAdapterKind::Fcitx5 - } - - fn update_binding(&self, _binding: HotkeyBinding) { - // fcitx5 插件热键由 sync_binding_to_plugin 单独同步。 - } - - fn update_modifier_shortcuts( - &self, - qa_trigger: Option, - selection_polish_trigger: Option, - translation_trigger: Option, - ) { - crate::linux_fcitx::sync_qa_binding(qa_trigger); - // 选区润色触发键:fcitx5 插件通过 SelectionPolishEvent 信号回传 - //(插件端需 `scripts/inject-fcitx5-plugin.sh` 重装新版 .so)。 - crate::linux_fcitx::sync_selection_polish_binding(selection_polish_trigger); - crate::linux_fcitx::sync_translation_binding(translation_trigger); - } - - fn reset_held_state(&self) {} - } -} diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index ee1bbb293..18f3b029f 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -1,9 +1,8 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! 跨平台光标位置文本插入。 //! //! 通用步骤:先写剪贴板(模拟失败时用户能手动粘贴)→ 模拟粘贴快捷键。 //! - macOS:用 CoreGraphics CGEvent 直接 post Cmd+V。 -//! - Windows / Linux:用 enigo 按 `PasteShortcut` 模拟。 +//! - Windows:用 enigo 按 `PasteShortcut` 模拟。 #[cfg(not(any(target_os = "android", target_os = "ios")))] use std::sync::atomic::{AtomicU64, Ordering}; @@ -39,23 +38,6 @@ impl TextInserter { Self } - /// Linux 路径:优先走 fcitx5 CommitText;插件不可用或提交失败时回退剪贴板粘贴。 - #[cfg(target_os = "linux")] - pub fn insert( - &self, - text: &str, - restore_clipboard_after_paste: bool, - paste_shortcut: PasteShortcut, - ) -> InsertStatus { - insert_with_fcitx_or_clipboard_fallback( - text, - restore_clipboard_after_paste, - paste_shortcut, - crate::linux_fcitx::commit_text, - insert_with_clipboard_restore, - ) - } - /// Windows 路径:写剪贴板 + 模拟 `paste_shortcut`。 /// - `restore_clipboard_after_paste`:粘贴后是否恢复用户原剪贴板。 /// - `paste_shortcut`:模拟按下的粘贴快捷键(如终端可能要 Ctrl+Shift+V)。 @@ -147,6 +129,18 @@ impl TextInserter { self.copy_fallback(text) } + /// 无原生粘贴快捷键实现的桌面构建:退回剪贴板兜底,与 + /// [`Self::insert_via_clipboard_fallback`] 同语义(典型场景:终端/无合成键平台)。 + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "android")))] + pub fn insert( + &self, + text: &str, + restore_clipboard_after_paste: bool, + paste_shortcut: PasteShortcut, + ) -> InsertStatus { + self.insert_via_clipboard_fallback(text, restore_clipboard_after_paste, paste_shortcut) + } + /// 只写剪贴板、不模拟粘贴。用于目标控件活跃状态无法验证时的兜底路径。 pub fn copy_fallback(&self, text: &str) -> InsertStatus { if text.is_empty() { @@ -160,32 +154,6 @@ impl TextInserter { } } -#[cfg(target_os = "linux")] -fn insert_with_fcitx_or_clipboard_fallback( - text: &str, - restore_clipboard_after_paste: bool, - paste_shortcut: PasteShortcut, - commit_text: C, - clipboard_fallback: F, -) -> InsertStatus -where - C: FnOnce(&str) -> Result<(), String>, - F: FnOnce(&str, bool, PasteShortcut) -> InsertStatus, -{ - if text.is_empty() { - return InsertStatus::CopiedFallback; - } - match commit_text(text) { - Ok(()) => InsertStatus::Inserted, - Err(err) => { - log::warn!( - "[insertion] fcitx commit_text failed, falling back to clipboard paste: {err}" - ); - clipboard_fallback(text, restore_clipboard_after_paste, paste_shortcut) - } - } -} - impl Default for TextInserter { fn default() -> Self { Self::new() @@ -672,81 +640,6 @@ mod tests { assert_eq!(inserter.copy_fallback(""), InsertStatus::CopiedFallback); } - #[test] - #[cfg(target_os = "linux")] - fn linux_commit_text_success_skips_clipboard_fallback() { - let mut fallback_called = false; - - let status = insert_with_fcitx_or_clipboard_fallback( - "dictated text", - true, - PasteShortcut::CtrlV, - |text| { - assert_eq!(text, "dictated text"); - Ok(()) - }, - |_, _, _| { - fallback_called = true; - InsertStatus::CopiedFallback - }, - ); - - assert_eq!(status, InsertStatus::Inserted); - assert!(!fallback_called); - } - - #[test] - #[cfg(target_os = "linux")] - fn linux_commit_text_failure_uses_clipboard_fallback() { - let mut fallback_args = None; - - let status = insert_with_fcitx_or_clipboard_fallback( - "dictated text", - true, - PasteShortcut::CtrlShiftV, - |_| Err("plugin unavailable".to_string()), - |text, restore_clipboard_after_paste, paste_shortcut| { - fallback_args = Some(( - text.to_string(), - restore_clipboard_after_paste, - paste_shortcut, - )); - InsertStatus::CopiedFallback - }, - ); - - assert_eq!(status, InsertStatus::CopiedFallback); - assert_eq!( - fallback_args, - Some(("dictated text".to_string(), true, PasteShortcut::CtrlShiftV)) - ); - } - - #[test] - #[cfg(target_os = "linux")] - fn linux_empty_insert_skips_commit_text_and_clipboard_fallback() { - let mut commit_called = false; - let mut fallback_called = false; - - let status = insert_with_fcitx_or_clipboard_fallback( - "", - true, - PasteShortcut::CtrlV, - |_| { - commit_called = true; - Ok(()) - }, - |_, _, _| { - fallback_called = true; - InsertStatus::CopiedFallback - }, - ); - - assert_eq!(status, InsertStatus::CopiedFallback); - assert!(!commit_called); - assert!(!fallback_called); - } - #[test] #[cfg(not(any(target_os = "android", target_os = "ios")))] fn pending_clipboard_restore_keeps_first_original_until_latest_restore() { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 828315312..34f91ea5f 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -1,7 +1,3 @@ -#![cfg_attr( - target_os = "linux", - allow(dead_code, unused_imports, unused_variables) -)] //! OpenLess Tauri backend. //! //! Modules mirror the original Swift libraries (one purpose per file): @@ -14,6 +10,19 @@ //! - coordinator: dictation state machine glue //! - commands: Tauri IPC surface +// ── Linux 桌面不再由 Tauri 提供 ─────────────────────────────────────────────── +// 用户在 Linux 上已使用 egui 前端(`openless-all/app/linux-egui`),Tauri 版的 Linux +// 桌面实现(fcitx5 插件桥 `linux_fcitx.rs`、Linux 热键/插入/选区路径、平台覆盖配置 +// `tauri.linux.conf.json`)已随本次一并删除。这里显式拦住误在 Linux 上构建 Tauri 版 +// 的情况,给出可操作的提示,而不是让人面对一堆“找不到模块”的零散错误。 +// +// 不受影响的平台:macOS / Windows(桌面)与 Android(`target_os = "android"`)。 +#[cfg(target_os = "linux")] +compile_error!( + "Tauri 版已不再支持 Linux 桌面:请使用 egui 前端 openless-all/app/linux-egui,\ + 打包用 openless-all/app/scripts/package-linux-egui.sh(deb/rpm/manual zip)。" +); + mod android; mod asr; mod audio_mute; @@ -38,7 +47,7 @@ mod macos_streaming_input; mod qa_adapter; mod tauri_coordinator_host; // 托盘麦克风设备变更监听:macOS CoreAudio / Windows MMDevice 原生通知(空闲零唤醒), -// Linux 退化为纯轮询兜底。仅桌面端。详见 issue #470。 +// 仅桌面端。详见 issue #470。 #[cfg(not(mobile))] mod device_watch; mod endpoint_security; @@ -55,8 +64,6 @@ mod hotkey; #[path = "mobile_stubs/hotkey.rs"] mod hotkey; mod insertion; -#[cfg(target_os = "linux")] -mod linux_fcitx; mod llm_gemini; #[cfg(mobile)] mod mobile_runtime; @@ -113,9 +120,6 @@ use std::sync::mpsc; use std::sync::Arc; use std::time::Duration; -#[cfg(target_os = "linux")] -use gtk::prelude::WidgetExt; - const LOG_ROTATE_LIMIT_BYTES: u64 = 10 * 1024 * 1024; #[cfg(target_os = "macos")] const OPENLESS_BUNDLE_ID: &str = "com.openless.app"; @@ -123,8 +127,27 @@ const OPENLESS_BUNDLE_ID: &str = "com.openless.app"; /// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition, /// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。 static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); +/// 「润色结果」模式的事件名。该模式复用选区助手(qa)面板,不再有独立预览窗。 +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub(crate) const SELECTION_POLISH_PREVIEW_SHOWN: &str = "selection-polish-preview:shown"; +/// 让面板自行决定退出「润色结果」模式(它可能正处在提问对话中)。 +#[cfg(not(any(target_os = "android", target_os = "ios")))] +pub(crate) const SELECTION_POLISH_PREVIEW_HIDE: &str = "selection-polish-preview:hide"; +/// 是否处于「润色结果」模式。`get_selection_polish_preview` 只在它为 true 时 +/// 返回负载:面板懒创建后挂载时也会拉一次,不能把残留的快照当成新请求。 +static SELECTION_POLISH_PREVIEW_PENDING: AtomicBool = AtomicBool::new(false); + +pub(crate) fn selection_polish_preview_pending() -> bool { + SELECTION_POLISH_PREVIEW_PENDING.load(Ordering::SeqCst) +} + +pub(crate) fn clear_selection_polish_preview_pending() { + SELECTION_POLISH_PREVIEW_PENDING.store(false, Ordering::SeqCst); +} + #[cfg(target_os = "macos")] static LESS_COMPUTER_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); + /// 聊天面板退场动画的世代计数:hide 先发 `chat-panel:closing` 让前端播 220ms /// 退场动画、240ms 后才真正 hide;期间再次 show 会推进世代,作废挂起的 hide。 static QA_PANEL_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -618,9 +641,8 @@ fn run_desktop() { // 否则两份 OpenLess(如 /Applications/ + dev build)会各自抓全局热键, // 导致按一次键、两个进程同时跑流水线、文本被插入两遍。见 issue #50。 // - // 第二个进程的 argv 还有一个用处:作为 Linux 下的「触发器入口」。 - // 桌面环境快捷键执行 `openless --toggle-dictation` 时,第二个进程被本插件 - // 拦截 → argv 直接转给主实例 coordinator。详见 issue #420 / `cli.rs`。 + // 第二个进程的 argv 还会转发 CLI 意图给主实例 coordinator; + // Linux 桌面快捷键由 egui Host 处理。详见 issue #420 / `cli.rs`。 .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { if let Some(intent) = cli::parse_cli_intent(&argv) { log::info!( @@ -652,8 +674,8 @@ fn run_desktop() { .plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_dialog::init()) - // 跨平台开机自启:mac 写 LaunchAgent plist,linux 写 ~/.config/autostart/*.desktop, - // windows 写 HKCU\Software\Microsoft\Windows\CurrentVersion\Run。前端 toggle 直接 + // 桌面开机自启:mac 写 LaunchAgent plist,windows 写 + // HKCU\Software\Microsoft\Windows\CurrentVersion\Run。前端 toggle 直接 // 调插件 isEnabled / enable / disable,不维持本地 prefs,让 OS 当唯一真相。issue #194。 .plugin(tauri_plugin_autostart::init( tauri_plugin_autostart::MacosLauncher::LaunchAgent, @@ -697,21 +719,6 @@ fn run_desktop() { } // 纯光效舞台没有任何可点元素(✕/✓ 按钮已移除),而窗口放大到 460×180 // 盖住屏幕底部中央 —— 必须鼠标穿透,否则会挡住底下应用的点击。 - // Linux 下 tao 的鼠标穿透实现会直接解包底层 GDK 窗口;visible=false 时 - // 窗口尚未 realize。这里只创建窗口系统资源而不 map,避免 X11 崩溃, - // 也不会像 show() 那样在不支持窗口定位的 Wayland 上造成启动闪窗。 - #[cfg(target_os = "linux")] - let cursor_passthrough_ready = match capsule.gtk_window() { - Ok(gtk_window) => { - gtk_window.realize(); - true - } - Err(e) => { - log::warn!("[capsule] gtk_window failed; skipping cursor passthrough: {e}"); - false - } - }; - #[cfg(not(target_os = "linux"))] let cursor_passthrough_ready = true; if cursor_passthrough_ready { @@ -778,43 +785,6 @@ fn run_desktop() { if suppress_show { log::info!("[main] start_minimized=true → 跳过初始 show,等用户点托盘"); } else { - #[cfg(target_os = "linux")] - { - // Workaround for Linux Wayland WebKitGTK compositing: - // `visible:false` → `show()` can leave the webview surface - // without a valid input region. The ±1px nudge forces - // GTK size-allocate → input surface reattach. - // Ref: tauri#9394, cc-switch linux_fix.rs - let main_clone = main.clone(); - let _ = main_clone.set_focus(); - tauri::async_runtime::spawn(async move { - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - let _ = main_clone.set_focus(); - if let Ok(orig) = main_clone.inner_size() { - let bumped = tauri::PhysicalSize::new( - orig.width.saturating_add(1), - orig.height, - ); - let _ = main_clone.set_size(bumped); - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let _ = main_clone.set_size(orig); - log::info!("[main] Linux nudge: focus + surface reactivation done"); - // Reconcile: compositor may have coalesced the two - // set_size calls, leaving the window at width+1. - tokio::time::sleep(std::time::Duration::from_millis(500)).await; - if let Ok(after) = main_clone.inner_size() { - // Only correct the ±1px nudge artifact — if the - // compositor or user resized the window significantly - // during this window, don't clobber that change. - let dw = if after.width > orig.width { after.width - orig.width } else { orig.width - after.width }; - let dh = if after.height > orig.height { after.height - orig.height } else { orig.height - after.height }; - if dw <= 1 && dh <= 1 && (dw > 0 || dh > 0) { - let _ = main_clone.set_size(orig); - } - } - } - }); - } if let Err(e) = main.show() { log::warn!("[main] initial show failed: {e}"); } @@ -833,10 +803,6 @@ fn run_desktop() { // 符合 Apple HIG:"在解释用途之后再弹出权限请求"。 // 已授权用户不受影响(AXIsProcessTrusted 返回 true,引导页直接跳过)。 - // AppImage / 便携版:fcitx5 插件缺了就从 bundled resources 自动安装 - // 到 ~/.local/ 下面。不会覆盖系统已有的插件。 - #[cfg(target_os = "linux")] - crate::linux_fcitx::ensure_plugin_installed(app.handle()); // 菜单栏图标 — 与 Swift `MenuBarController` 同语义: // 左键点 → 显示/聚焦主窗口;菜单含「显示主窗口」「退出」。 @@ -1406,7 +1372,7 @@ fn start_tray_microphone_watcher(app: AppHandle) { // 1) OS 原生设备变更通知(issue #470 的最优方案):空闲零唤醒。 // macOS → CoreAudio AudioObjectAddPropertyListener;Windows → IMMNotificationClient。 - // Linux 无原生路径,返回 false,纯靠下面的慢速兜底。 + // 未提供原生路径的平台返回 false,纯靠下面的慢速兜底。 // 注册失败(OSStatus≠0 / RegisterEndpoint Err)只 warn,不 panic——兜底轮询保证 // 三平台都「永远能检测到设备」。 let native_registered = device_watch::spawn_native_watcher( @@ -2853,417 +2819,28 @@ pub(crate) fn hide_qa_window(app: &AppHandle) { hide_chat_window_animated(app, "qa", &QA_PANEL_EPOCH); } -/// 选区润色预览是独立、可编辑的小窗:模型结果不会直接覆盖,用户确认后才回到原选区粘贴。 -#[cfg(not(any(target_os = "android", target_os = "ios")))] -fn ensure_selection_polish_preview_window( - app: &tauri::AppHandle, -) -> Option> { - if let Some(window) = app.get_webview_window("selection-polish-preview") { - return Some(window); - } - let built = WebviewWindowBuilder::new( - app, - "selection-polish-preview", - WebviewUrl::App("index.html?window=selection-polish-preview".into()), - ) - .title("OpenLess 選區潤色預覽") - .inner_size(640.0, 440.0) - .min_inner_size(480.0, 320.0) - .resizable(true) - .always_on_top(true) - .skip_taskbar(true) - .focused(false) - .visible(false) - // Nonactivating NSPanel 的 WebKit 內容不可靠地接受 first mouse;native flag - // 仍保留作輔助,HTML 第一擊由下方 local NSEvent monitor 保證。 - .accept_first_mouse(true) - .build(); - match built { - Ok(window) => { - // macOS:转「非激活 NSPanel」(胶囊/QA 同手法)。预览窗展示期间 LLM 可能 - // 还在等待、用户也可能切回原 app 继续工作——普通窗口的 show + set_focus - // 会把 OpenLess 整个激活成 frontmost,原 app 失去前台后很多编辑器的选区 - // 直接消失,之后 confirm 的 reactivate/validate 就「有时」失败。 - // 转成 NonactivatingPanel 后窗口可见、可编辑,但 app 保持后台。 - // 必须在主线程执行(NSWindow class 切换是 AppKit 操作,worker 线程 - // 调用可能触发 NSException 直接 abort)。 - #[cfg(target_os = "macos")] - { - let window_clone = window.clone(); - let _ = app.run_on_main_thread(move || { - make_selection_polish_preview_panel_macos(&window_clone); - }); - } - Some(window) - } - Err(error) => { - log::warn!("[selection-polish] create preview window failed: {error}"); - None - } - } -} - -/// 选区润色预览窗转「非激活 NSPanel」(macOS,胶囊/QA 同手法)。 -/// -/// `set_style_mask` 是全量替换而非 OR——只设 NSPanel 位会丢掉 titled/resizable, -/// 所以先读当前 mask 再叠加 NonactivatingPanel 位(NSWindowStyleMaskNonactivatingPanel -/// = 1 << 7)。面板保留标题栏(用户仍可拖动定位、点 X 关闭),只是不再 -/// 激活整个 app。 -#[cfg(target_os = "macos")] -fn make_selection_polish_preview_panel_macos(window: &tauri::WebviewWindow) { - use tauri_nspanel::cocoa::appkit::NSWindowCollectionBehavior; - use tauri_nspanel::WebviewWindowExt; - match window.to_panel() { - Ok(panel) => { - // style mask 要先读再 OR(set_style_mask 是全量替换)。RawNSPanel 实现 - // 的是 objc (v0) 的 Message trait,与 objc2::msg_send 不兼容,所以按 - // QA 的手法对原生指针直接发消息(ZST 包装指针即 ObjC 对象指针)。 - use objc2::msg_send; - use objc2::runtime::AnyObject; - let raw = &*panel as *const _ as *mut AnyObject; - if !raw.is_null() { - unsafe { - let current: usize = msg_send![raw, styleMask]; - const NS_NONACTIVATING_PANEL_MASK: usize = 1 << 7; - let _: () = msg_send![raw, setStyleMask: current | NS_NONACTIVATING_PANEL_MASK]; - log::info!( - "[selection-polish] preview converted to nonactivating NSPanel (mask {current:#x} -> {:#x})", - current | NS_NONACTIVATING_PANEL_MASK - ); - } - } - // 浮层级别(NSFloatingWindowLevel):盖普通窗口,不盖菜单栏/胶囊(25)。 - // to_panel 类切换后显式重设一次,与 QA 同配置。 - panel.set_level(3); - // 划词常发生在全屏 app 里:CanJoinAllSpaces + FullScreenAuxiliary - // 让面板能叠到全屏空间上(QA 同配置)。 - panel.set_collection_behaviour( - NSWindowCollectionBehavior::NSWindowCollectionBehaviorFullScreenAuxiliary - | NSWindowCollectionBehavior::NSWindowCollectionBehaviorCanJoinAllSpaces, - ); - install_selection_preview_first_click_guard(raw); - } - Err(e) => log::warn!("[selection-polish] preview to_panel failed: {e:?}"), - } -} - -/// 圈選預覽窗第一擊護欄(macOS)。 -/// -/// local monitor 在 AppKit 派發前,若左鍵事件屬於目前的選區預覽窗且該窗不是 key, -/// 先 makeKeyWindow,再原樣放行事件。監聽器只永久保存 windowNumber,不保存 NSPanel -/// 裸指標;預覽窗重建時更新 windowNumber,避免 stale pointer。 -#[cfg(target_os = "macos")] -fn install_selection_preview_first_click_guard(panel: *mut objc2::runtime::AnyObject) { - use block2::RcBlock; - use objc2::msg_send; - use objc2::runtime::{AnyObject, Bool}; - use std::sync::atomic::{AtomicI64, AtomicPtr, Ordering}; - - static TARGET_WINDOW_NUMBER: AtomicI64 = AtomicI64::new(-1); - static MONITOR: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); - - if panel.is_null() { - return; - } - let window_number = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { - objc2::exception::catch(std::panic::AssertUnwindSafe(|| { - let number: isize = msg_send![panel, windowNumber]; - number as i64 - })) - })) { - Ok(Ok(number)) if number >= 0 => number, - Ok(Ok(_)) => { - log::warn!("[selection-polish] first-click guard: invalid windowNumber"); - return; - } - Ok(Err(error)) => { - log::warn!("[selection-polish] first-click guard: windowNumber raised: {error:?}"); - return; - } - Err(_) => { - log::error!("[selection-polish] first-click guard: Rust panic reading windowNumber"); - return; - } - }; - TARGET_WINDOW_NUMBER.store(window_number, Ordering::SeqCst); - - if !MONITOR.load(Ordering::SeqCst).is_null() { - log::info!( - "[selection-polish] first-click guard target updated window_number={window_number}" - ); - return; - } - - let block = RcBlock::new(move |event: *mut AnyObject| -> *mut AnyObject { - let guarded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { - objc2::exception::catch(std::panic::AssertUnwindSafe(|| { - if event.is_null() { - return false; - } - let win: *mut AnyObject = msg_send![event, window]; - if win.is_null() { - return false; - } - let event_window_number: isize = msg_send![win, windowNumber]; - if event_window_number as i64 != TARGET_WINDOW_NUMBER.load(Ordering::SeqCst) { - return false; - } - // windowNumber 可能在視窗銷毀後被 AppKit 重用;再用固定 title 驗證 - // 事件確實來自選區預覽窗,避免誤把其他 OpenLess 視窗扶成 key。 - let title: *mut AnyObject = msg_send![win, title]; - if title.is_null() { - return false; - } - let expected: *mut AnyObject = msg_send![ - objc2::runtime::AnyClass::get("NSString").expect("NSString class"), - stringWithUTF8String: c"OpenLess 選區潤色預覽".as_ptr() - ]; - if expected.is_null() { - return false; - } - let title_matches: Bool = msg_send![title, isEqualToString: expected]; - if !title_matches.as_bool() { - return false; - } - let is_key: Bool = msg_send![win, isKeyWindow]; - if !is_key.as_bool() { - let _: () = msg_send![win, makeKeyWindow]; - return true; - } - false - })) - })); - match guarded { - Ok(Ok(true)) => log::info!( - "[selection-polish] first-click guard: panel made key before first mouse-down" - ), - Ok(Ok(false)) => {} - Ok(Err(error)) => log::warn!( - "[selection-polish] first-click guard: ObjC exception caught; event passed through: {error:?}" - ), - Err(_) => log::error!( - "[selection-polish] first-click guard: Rust panic caught; event passed through" - ), - } - event - }); - - let registration = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { - objc2::exception::catch(std::panic::AssertUnwindSafe(|| { - let Some(cls) = objc2::runtime::AnyClass::get("NSEvent") else { - return std::ptr::null_mut(); - }; - const MASK_LEFT_MOUSE_DOWN: u64 = 1 << 1; - let monitor: *mut AnyObject = msg_send![ - cls, - addLocalMonitorForEventsMatchingMask: MASK_LEFT_MOUSE_DOWN, - handler: &*block - ]; - if !monitor.is_null() { - let _: *mut AnyObject = msg_send![monitor, retain]; - } - monitor - })) - })); - match registration { - Ok(Ok(monitor)) if !monitor.is_null() => { - MONITOR.store(monitor, Ordering::SeqCst); - log::info!( - "[selection-polish] first-click guard installed window_number={window_number}" - ); - } - Ok(Ok(_)) => log::warn!( - "[selection-polish] first-click guard: monitor registration unavailable; will retry" - ), - Ok(Err(error)) => log::warn!( - "[selection-polish] first-click guard: registration raised; will retry: {error:?}" - ), - Err(_) => log::error!( - "[selection-polish] first-click guard: Rust panic during registration; will retry" - ), - } -} - +/// 「润色结果」不再有独立窗口:复用选区助手(qa)面板切到润色模式。核心在 +/// 「预览确认」输出模式下照旧只发 `HostAction::ShowSelectionPreview`。 #[cfg(not(any(target_os = "android", target_os = "ios")))] pub(crate) fn show_selection_polish_preview(app: &AppHandle) { - let Some(window) = ensure_selection_polish_preview_window(app) else { - return; - }; - let _ = app.emit_to( - "selection-polish-preview", - "selection-polish-preview:shown", - (), - ); - #[cfg(target_os = "macos")] - { - // 不用 window.show()/set_focus():tao 的 show 走 makeKeyAndOrderFront + - // NSApp.activate(已核对 tao 源码)——都会把 OpenLess 推成 frontmost,原 - // app 丢前台后选区被清,之后的 confirm reactivate/validate 就「有时」失败。 - // 改走 QA 同手法:主线程 orderFrontRegardless(可见但不抢前台、不成为 key - // window);面板已是 NonactivatingPanel(ensure 阶段转换,同一主线程队列, - // 顺序有保证),textarea 的 autofocus 在点击/聚焦时自行 makeKey,而 - // nonactivating 面板的 makeKey 不会激活 app。 - let window_clone = window.clone(); - let _ = app.run_on_main_thread(move || { - use objc2::msg_send; - use objc2::runtime::AnyObject; - match window_clone.ns_window() { - Ok(handle) => { - let ns = handle as *mut AnyObject; - if ns.is_null() { - log::warn!("[selection-polish] ns_window null; falling back to show()"); - let _ = window_clone.show(); - } else { - // 每次 show 都刷新 target;若初次 monitor 註冊失敗,這裡也會重試。 - install_selection_preview_first_click_guard(ns); - unsafe { - let _: () = msg_send![ns, orderFrontRegardless]; - } - } - } - Err(e) => { - log::warn!( - "[selection-polish] ns_window unavailable: {e}; falling back to show()" - ); - let _ = window_clone.show(); - } - } - }); - } - #[cfg(not(target_os = "macos"))] - { - if let Err(error) = window.show() { - log::warn!("[selection-polish] show preview failed: {error}"); - return; - } - if let Err(error) = window.set_focus() { - log::warn!("[selection-polish] focus preview failed: {error}"); - } - } + SELECTION_POLISH_PREVIEW_PENDING.store(true, Ordering::SeqCst); + show_qa_window(app, "polish-preview"); + // 面板可能是本次才懒创建(刚挂载,订阅还没装上)——它在挂载时也会自己 + // 拉一次负载(见 `get_selection_polish_preview` 的 pending 门禁)。 + let _ = app.emit_to("qa", SELECTION_POLISH_PREVIEW_SHOWN, ()); } #[cfg(any(target_os = "android", target_os = "ios"))] pub(crate) fn show_selection_polish_preview(_app: &AppHandle) {} +/// 退出「润色结果」模式。是否顺带关掉面板交给前端:面板可能正处在提问对话中, +/// 不能被润色流程的收尾动作一起关掉(与 egui 侧 `HideSelectionPreview` 一致)。 pub(crate) fn hide_selection_polish_preview(app: &AppHandle) { - let Some(window) = app.get_webview_window("selection-polish-preview") else { - return; - }; - // macOS:转换后的 NSPanel 不能从 worker 线程操作(AppKit 硬约束,resize/hide - // 都可能让进程 abort),统一 dispatch 回主线程;其他平台 hide 走 Tauri 内部 - // 主线程调度即可。 - #[cfg(target_os = "macos")] - { - let window_clone = window.clone(); - let _ = app.run_on_main_thread(move || { - let _ = window_clone.hide(); - }); - } - #[cfg(not(target_os = "macos"))] - { - let _ = window.hide(); - } -} - -/// Confirm 前同步撤掉選區預覽 NSPanel 的 key-window 狀態。 -/// -/// NonactivatingPanel 可以在來源 app 已是 frontmost 時仍保有 key window;若不先 -/// resign,validate 的全域 Cmd+C 可能仍送進預覽 WebView,讀到空剪貼簿後誤判 -/// SelectionChanged。只 resign、不 hide:失敗時預覽仍可見並可重試;成功後沿用 -/// selection service 原本的 hide 流程。 -#[cfg(target_os = "macos")] -fn resign_selection_polish_preview_key_macos( - window: &tauri::WebviewWindow, -) -> Result { - use objc2::msg_send; - use objc2::runtime::{AnyObject, Bool}; - - let handle = window - .ns_window() - .map_err(|error| format!("ns_window unavailable: {error}"))?; - let ns = handle as *mut AnyObject; - if ns.is_null() { - return Err("ns_window returned null".to_string()); - } - let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { - objc2::exception::catch(std::panic::AssertUnwindSafe(|| { - let was_key: Bool = msg_send![ns, isKeyWindow]; - if was_key.as_bool() { - let _: () = msg_send![ns, resignKeyWindow]; - } - was_key.as_bool() - })) - })); - match caught { - Ok(Ok(was_key)) => Ok(was_key), - Ok(Err(error)) => Err(format!("resignKeyWindow raised: {error:?}")), - Err(_) => Err("Rust panic while resigning preview key window".to_string()), - } -} - -#[cfg(target_os = "macos")] -pub(crate) fn resign_selection_polish_preview_key_for_apply( - app: &AppHandle, -) -> bool { - use objc2::msg_send; - use objc2::runtime::{AnyClass, Bool}; - - let Some(window) = app.get_webview_window("selection-polish-preview") else { - log::warn!("[selection-polish] apply: preview window missing before focus handoff"); - return false; - }; - - let on_main_thread = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe { - objc2::exception::catch(std::panic::AssertUnwindSafe(|| { - AnyClass::get("NSThread").is_some_and(|class| { - let is_main: Bool = msg_send![class, isMainThread]; - is_main.as_bool() - }) - })) - })) { - Ok(Ok(value)) => value, - Ok(Err(error)) => { - log::warn!("[selection-polish] apply: NSThread lookup raised: {error:?}"); - return false; - } - Err(_) => { - log::error!("[selection-polish] apply: Rust panic checking main thread"); - return false; - } - }; - let result = if on_main_thread { - resign_selection_polish_preview_key_macos(&window) - } else { - let (tx, rx) = std::sync::mpsc::sync_channel(1); - let window_clone = window.clone(); - if let Err(error) = app.run_on_main_thread(move || { - let result = resign_selection_polish_preview_key_macos(&window_clone); - let _ = tx.send(result); - }) { - log::warn!( - "[selection-polish] apply: main-thread focus handoff dispatch failed: {error}" - ); - return false; - } - // 呼叫端是 blocking apply;事件已排入主執行緒後等待唯一結果。主執行緒路徑 - // 已在上方直接執行,不會 self-deadlock;不設 timeout,避免回報失敗後延遲 - // resign 又在別的互動中生效。 - rx.recv() - .unwrap_or_else(|error| Err(format!("preview resign channel closed: {error}"))) - }; - - match result { - Ok(was_key) => { - log::info!( - "[selection-polish] apply: preview resigned key before reactivate was_key={was_key}" - ); - true - } - Err(error) => { - log::warn!("[selection-polish] apply: preview resign failed: {error}"); - false - } - } + clear_selection_polish_preview_pending(); + #[cfg(any(target_os = "android", target_os = "ios"))] + let _ = app; + #[cfg(not(any(target_os = "android", target_os = "ios")))] + let _ = app.emit_to("qa", SELECTION_POLISH_PREVIEW_HIDE, ()); } /// 选区语音:说完后由用户选择提问或编辑。 diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs deleted file mode 100644 index b5a7e1764..000000000 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ /dev/null @@ -1,976 +0,0 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] -//! Linux fcitx5 插件 DBus 客户端。 -//! -//! 封装对 `org.fcitx.Fcitx.OpenLess1` 接口的调用, -//! 提供文字提交(替代 enigo XTest)和热键设置功能。 -//! -//! 插件不可用时,各调用方按自身能力记录 warning 并继续运行;Linux 选区读取 -//! 只通过插件的 `GetSelectionText()` DBus 方法,失败视为无选区,不回退外部命令。 - -use std::time::Duration; - -use dbus::blocking::BlockingSender; - -const DEST: &str = "org.fcitx.Fcitx5"; -const PATH: &str = "/openless"; -const IFACE: &str = "org.fcitx.Fcitx.OpenLess1"; -const TIMEOUT: Duration = Duration::from_secs(3); - -/// 通过 fcitx5 插件向当前焦点输入上下文提交文字。 -/// -/// 返回 `Ok(())` 表示文字已提交,`Err` 表示调用失败(插件未加载 / DBus 不通等)。 -pub fn commit_text(text: &str) -> Result<(), String> { - let conn = - dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; - let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "CommitText") - .map_err(|e| format!("build msg: {e}"))? - .append1(text); - let reply = conn - .send_with_reply_and_block(msg, TIMEOUT) - .map_err(|e| format!("CommitText: {e}"))?; - let committed = reply - .read1::() - .map_err(|e| format!("CommitText reply: {e}"))?; - committed - .then_some(()) - .ok_or_else(|| "CommitText: no focused input context".to_string()) -} - -/// 通过 fcitx5 插件设置听写触发快捷键。 -/// -/// `keys` 为 Key::parse 格式的字符串数组,例如 `["Control+space"]`。 -pub fn set_hotkey(keys: &[&str]) -> Result<(), String> { - let conn = - dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; - let list: Vec = keys.iter().map(|s| s.to_string()).collect(); - let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetHotkey") - .map_err(|e| format!("build msg: {e}"))? - .append1(list); - conn.send_with_reply_and_block(msg, TIMEOUT) - .map_err(|e| format!("SetHotkey: {e}"))?; - Ok(()) -} - -/// 通过 fcitx5 插件直接设置 sym + states 作为触发键。 -pub fn set_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { - let conn = - dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; - let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetHotkeyRaw") - .map_err(|e| format!("build msg: {e}"))? - .append2(sym, states); - conn.send_with_reply_and_block(msg, TIMEOUT) - .map_err(|e| format!("SetHotkeyRaw: {e}"))?; - Ok(()) -} - -/// 通过 fcitx5 插件设置 QA 面板快捷键 sym + states。 -pub fn set_qa_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { - let conn = - dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; - let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetQaHotkeyRaw") - .map_err(|e| format!("build msg: {e}"))? - .append2(sym, states); - conn.send_with_reply_and_block(msg, TIMEOUT) - .map_err(|e| format!("SetQaHotkeyRaw: {e}"))?; - Ok(()) -} - -/// 通过 fcitx5 插件设置选区润色触发键 sym + states。 -pub fn set_selection_polish_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { - let conn = - dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; - let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetSelectionPolishHotkeyRaw") - .map_err(|e| format!("build msg: {e}"))? - .append2(sym, states); - conn.send_with_reply_and_block(msg, TIMEOUT) - .map_err(|e| format!("SetSelectionPolishHotkeyRaw: {e}"))?; - Ok(()) -} - -/// 通过 fcitx5 插件设置翻译模式修饰键 sym + states。 -pub fn set_translation_hotkey_raw(sym: u32, states: u32) -> Result<(), String> { - let conn = - dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; - let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetTranslationHotkeyRaw") - .map_err(|e| format!("build msg: {e}"))? - .append2(sym, states); - conn.send_with_reply_and_block(msg, TIMEOUT) - .map_err(|e| format!("SetTranslationHotkeyRaw: {e}"))?; - Ok(()) -} - -/// X11 keysym 值(用于 SetHotkeyRaw / SetQaHotkeyRaw / SetTranslationHotkeyRaw, -/// 绕过 Key::parse 的修饰键限制)。 -const KEYSYM_CONTROL_R: u32 = 0xffe4; -const KEYSYM_CONTROL_L: u32 = 0xffe3; -const KEYSYM_ALT_R: u32 = 0xffea; -const KEYSYM_ALT_L: u32 = 0xffe9; -const KEYSYM_SUPER_R: u32 = 0xffec; -const KEYSYM_SUPER_L: u32 = 0xffeb; -const KEYSYM_SHIFT_R: u32 = 0xffe2; -const KEYSYM_SHIFT_L: u32 = 0xffe1; - -/// 将 HotkeyTrigger 转换为 X11 keysym。 -fn trigger_to_keysym(trigger: crate::types::HotkeyTrigger) -> u32 { - match trigger { - crate::types::HotkeyTrigger::RightControl => KEYSYM_CONTROL_R, - crate::types::HotkeyTrigger::LeftControl => KEYSYM_CONTROL_L, - crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => { - KEYSYM_ALT_R - } - crate::types::HotkeyTrigger::LeftOption => KEYSYM_ALT_L, - crate::types::HotkeyTrigger::RightCommand => KEYSYM_SUPER_R, - crate::types::HotkeyTrigger::LeftCommand => KEYSYM_SUPER_L, - crate::types::HotkeyTrigger::LeftShift => KEYSYM_SHIFT_L, - crate::types::HotkeyTrigger::RightShift => KEYSYM_SHIFT_R, - crate::types::HotkeyTrigger::Fn => KEYSYM_CONTROL_R, - crate::types::HotkeyTrigger::MediaPlayPause => unreachable!("Windows-only"), - crate::types::HotkeyTrigger::Custom => unreachable!(), - } -} - -fn trigger_name(trigger: crate::types::HotkeyTrigger) -> &'static str { - match trigger { - crate::types::HotkeyTrigger::RightControl => "Control_R", - crate::types::HotkeyTrigger::LeftControl => "Control_L", - crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => "Alt_R", - crate::types::HotkeyTrigger::LeftOption => "Alt_L", - crate::types::HotkeyTrigger::RightCommand => "Super_R", - crate::types::HotkeyTrigger::LeftCommand => "Super_L", - crate::types::HotkeyTrigger::LeftShift => "Shift_L", - crate::types::HotkeyTrigger::RightShift => "Shift_R", - crate::types::HotkeyTrigger::Fn => "Control_R", - crate::types::HotkeyTrigger::MediaPlayPause => unreachable!("Windows-only"), - crate::types::HotkeyTrigger::Custom => unreachable!(), - } -} - -/// 将 OpenLess 的主听写热键绑定同步到 fcitx5 插件。 -pub fn sync_binding_to_plugin(binding: &crate::types::HotkeyBinding) { - if binding.trigger == crate::types::HotkeyTrigger::Custom - || binding.trigger == crate::types::HotkeyTrigger::MediaPlayPause - { - return; - } - let sym = trigger_to_keysym(binding.trigger); - let name = trigger_name(binding.trigger); - match set_hotkey_raw(sym, 0) { - Ok(()) => log::info!("[fcitx] Synced hotkey {name} (sym={sym}) to plugin via SetHotkeyRaw"), - Err(e) => log::warn!("[fcitx] Failed to sync hotkey to plugin: {e}"), - } -} - -/// 将 ShortcutBinding 转换为 fcitx5 Key::parse 格式的字符串。 -/// -/// 例如 `modifiers: ["Ctrl", "Alt"], primary: "d"` → `"Control+Alt+d"`。 -pub fn binding_to_fcitx_key_string(binding: &crate::types::ShortcutBinding) -> String { - let mut parts: Vec = Vec::new(); - for m in &binding.modifiers { - let lower = m.to_lowercase(); - let normalized = match lower.as_str() { - "ctrl" | "control" => "Control", - "alt" | "option" | "opt" => "Alt", - "shift" => "Shift", - "super" | "meta" | "cmd" | "win" | "command" => "Super", - other => other, - }; - if !parts.contains(&normalized.to_string()) { - parts.push(normalized.to_string()); - } - } - // 主键:取小写,去掉 "Key" 前缀(如 "KeyD" → "d") - let primary = binding.primary.trim(); - let primary = if let Some(stripped) = primary.strip_prefix("Key") { - stripped.to_lowercase() - } else { - primary.to_lowercase() - }; - if primary.is_empty() { - return String::new(); - } - if parts.is_empty() { - primary - } else { - format!("{}+{}", parts.join("+"), primary) - } -} - -/// 通过 fcitx5 插件的 SetCustomDictationTrigger 方法设置自定义组合键。 -pub fn set_custom_dictation_trigger(key_string: &str) -> Result<(), String> { - let conn = - dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; - let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetCustomDictationTrigger") - .map_err(|e| format!("build msg: {e}"))? - .append1(key_string); - conn.send_with_reply_and_block(msg, TIMEOUT) - .map_err(|e| format!("SetCustomDictationTrigger: {e}"))?; - Ok(()) -} - -/// 将 QA 面板快捷键同步到 fcitx5 插件。 -pub fn sync_qa_binding(trigger: Option) { - let Some(trigger) = trigger else { - // 无 QA 快捷键时清空插件端配置 - let _ = set_qa_hotkey_raw(0, 0); - return; - }; - if trigger == crate::types::HotkeyTrigger::MediaPlayPause { - return; - } - let sym = trigger_to_keysym(trigger); - let name = trigger_name(trigger); - match set_qa_hotkey_raw(sym, 0) { - Ok(()) => { - log::info!("[fcitx] Synced QA hotkey {name} (sym={sym}) to plugin via SetQaHotkeyRaw") - } - Err(e) => log::warn!("[fcitx] Failed to sync QA hotkey to plugin: {e}"), - } -} - -/// 将翻译模式快捷键同步到 fcitx5 插件。 -pub fn sync_translation_binding(trigger: Option) { - let Some(trigger) = trigger else { - let _ = set_translation_hotkey_raw(0, 0); - return; - }; - if trigger == crate::types::HotkeyTrigger::MediaPlayPause { - return; - } - let sym = trigger_to_keysym(trigger); - let name = trigger_name(trigger); - match set_translation_hotkey_raw(sym, 0) { - Ok(()) => log::info!("[fcitx] Synced translation hotkey {name} (sym={sym}) to plugin via SetTranslationHotkeyRaw"), - Err(e) => log::warn!("[fcitx] Failed to sync translation hotkey to plugin: {e}"), - } -} - -/// 将选区润色触发键同步到 fcitx5 插件。 -pub fn sync_selection_polish_binding(trigger: Option) { - let Some(trigger) = trigger else { - // 无选区润色快捷键时清空插件端配置 - let _ = set_selection_polish_hotkey_raw(0, 0); - return; - }; - if trigger == crate::types::HotkeyTrigger::MediaPlayPause { - return; - } - let sym = trigger_to_keysym(trigger); - let name = trigger_name(trigger); - match set_selection_polish_hotkey_raw(sym, 0) { - Ok(()) => { - log::info!("[fcitx] Synced selection polish hotkey {name} (sym={sym}) to plugin via SetSelectionPolishHotkeyRaw") - } - Err(e) => log::warn!("[fcitx] Failed to sync selection polish hotkey to plugin: {e}"), - } -} - -/// 通过 fcitx5 插件在候选词列表下方显示状态文本(不干扰输入法预编辑)。 -pub fn set_aux_down(text: &str) -> Result<(), String> { - let conn = - dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; - let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "SetAuxDown") - .map_err(|e| format!("build msg: {e}"))? - .append1(text); - conn.send_with_reply_and_block(msg, TIMEOUT) - .map_err(|e| format!("SetAuxDown: {e}"))?; - Ok(()) -} - -/// 清除 fcitx5 插件候选词列表下方状态文本。 -pub fn clear_aux_down() -> Result<(), String> { - let conn = - dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; - let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "ClearAuxDown") - .map_err(|e| format!("build msg: {e}"))?; - conn.send_with_reply_and_block(msg, TIMEOUT) - .map_err(|e| format!("ClearAuxDown: {e}"))?; - Ok(()) -} - -/// 快速检查 fcitx5 OpenLess 插件是否可用(DBus 对象存在)。 -pub fn available() -> bool { - let conn = match dbus::blocking::Connection::new_session() { - Ok(c) => c, - Err(_) => return false, - }; - let msg = match dbus::Message::new_method_call(DEST, PATH, "org.freedesktop.DBus.Peer", "Ping") - { - Ok(m) => m, - Err(_) => return false, - }; - conn.send_with_reply_and_block(msg, TIMEOUT).is_ok() -} - -/// 通过 fcitx5 插件获取当前 PRIMARY 选区文本。 -/// -/// 插件直接读取 fcitx5 clipboard addon 维护的 PRIMARY 缓存,不触碰用户剪贴板。 -/// 返回空字符串表示无选区;插件不可用或 DBus 调用失败则返回错误。 -pub fn get_selection_text() -> Result { - let conn = - dbus::blocking::Connection::new_session().map_err(|e| format!("dbus session: {e}"))?; - let msg = dbus::Message::new_method_call(DEST, PATH, IFACE, "GetSelectionText") - .map_err(|e| format!("build msg: {e}"))?; - let reply = conn - .send_with_reply_and_block(msg, TIMEOUT) - .map_err(|e| format!("GetSelectionText: {e}"))?; - reply - .read1::() - .map_err(|e| format!("GetSelectionText reply: {e}")) -} - -/// 启动 fcitx5 DictationKeyEvent 信号监听线程。 -/// -/// 当 fcitx5 OpenLess 插件检测到配置的听写热键被按下或松开时, -/// 发出 `DictationKeyEvent(uub)` DBus 信号(sym, states, isPress)。 -/// 本函数将此信号转发为 `HotkeyEvent::Pressed` / `Released` 到协调器事件通道。 -/// -/// 后台线程在 `tx` 全部 drop(协调器关闭)或 DBus 连接断开时自动退出。 -/// -/// 如果 fcitx5 尚未启动,线程会每 3 秒重试同步热键绑定,直到 fcitx5 可用。 -/// 同时监听 `NameOwnerChanged` 信号以在 fcitx5 重启后重新同步。 -#[cfg(target_os = "linux")] -pub fn start_dictation_signal_listener( - tx: std::sync::mpsc::Sender, - combo_tx: std::sync::mpsc::Sender, - binding: crate::types::HotkeyBinding, - qa_trigger: Option, - selection_polish_trigger: Option, - translation_trigger: Option, - custom_trigger_key: Option, -) { - use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Arc; - use std::time::Duration; - - std::thread::Builder::new() - .name("openless-fcitx-signal".into()) - .spawn(move || { - let conn = match dbus::blocking::SyncConnection::new_session() { - Ok(c) => c, - Err(e) => { - log::warn!("[fcitx-hotkey] DBus session failed: {e}"); - return; - } - }; - - // 同时监听所有三个 OpenLess 信号 - let rule = match dbus::message::MatchRule::parse( - "type='signal',\ - interface='org.fcitx.Fcitx.OpenLess1'", - ) { - Ok(r) => r, - Err(e) => { - log::warn!("[fcitx-hotkey] Invalid match rule: {e}"); - return; - } - }; - - let tx2 = tx.clone(); - let combo_tx = combo_tx.clone(); - let current_press_id = Arc::new(AtomicU64::new(0)); - let current_press_id_for_match = Arc::clone(¤t_press_id); - let _match = match conn.add_match(rule, move |args: (u32, u32, bool), _conn, msg| { - let (sym, states, is_press) = args; - let member = msg.member(); - let member_str: String = member.as_ref().map(|m| m.to_string()).unwrap_or_default(); - log::debug!( - "[fcitx-hotkey] Signal {}: sym={}, states={}, isPress={}", - member_str, sym, states, is_press, - ); - if let Some(member) = member { - if member == "DictationKeyEvent" { - let at = std::time::Instant::now(); - let event = if is_press { - // fcitx5 may repeat a down signal while the trigger is - // held. Reuse the physical generation so Core treats - // repeats as duplicates instead of Toggle stop presses. - let press_id = match current_press_id_for_match.load(Ordering::SeqCst) { - 0 => { - let press_id = crate::hotkey::next_press_id(); - current_press_id_for_match.store(press_id, Ordering::SeqCst); - press_id - } - press_id => press_id, - }; - crate::hotkey::HotkeyEvent::Pressed { at, press_id } - } else { - // swap pairs key-up with the same generation and makes - // a duplicated/late key-up carry zero (Core no-op). - let press_id = current_press_id_for_match.swap(0, Ordering::SeqCst); - crate::hotkey::HotkeyEvent::Released { at, press_id } - }; - let _ = tx.send(event); - } else if member == "DictationKeyCombined" && is_press { - let press_id = current_press_id_for_match.load(Ordering::SeqCst); - if press_id != 0 { - let _ = combo_tx.send(crate::hotkey::HotkeyCombinedEdge { - at: std::time::Instant::now(), - press_id, - }); - } - } else if member == "QaShortcutEvent" { - if is_press { - let _ = tx2.send(crate::hotkey::HotkeyEvent::QaShortcutPressed); - } - } else if member == "TranslationModifierEvent" { - if is_press { - let _ = tx2.send(crate::hotkey::HotkeyEvent::TranslationModifierPressed); - } - } else if member == "SelectionPolishEvent" { - if is_press { - let _ = tx2.send(crate::hotkey::HotkeyEvent::SelectionPolishShortcutPressed); - } - } - } - true - }) { - Ok(m) => m, - Err(e) => { - log::warn!("[fcitx-hotkey] Failed to add match: {e}"); - return; - } - }; - - // 监听 fcitx5 的 NameOwnerChanged 信号,用于在 fcitx5 重启后重新同步。 - // dbus crate 的 MatchRule::parse 不支持 arg0 过滤,在回调里做匹配。 - let fcitx_rule = match dbus::message::MatchRule::parse( - "type='signal',\ - sender='org.freedesktop.DBus',\ - interface='org.freedesktop.DBus',\ - member='NameOwnerChanged'", - ) { - Ok(r) => r, - Err(e) => { - log::warn!("[fcitx-hotkey] Invalid fcitx5 name watch rule: {e}"); - return; - } - }; - - // NOTE: NameOwnerChanged 捕获的是线程启动时的绑定快照。用户在 - // OpenLess 运行时改了快捷键且 fcitx5 恰好重启,重连会写入旧绑定。 - // 这是一个低概率场景(需要两个操作同时发生),暂时保留快照语义。 - // 要彻底解决需要把 Arc 传给监听线程做实时读取。 - let binding_for_name = binding.clone(); - let custom_for_name = custom_trigger_key.clone(); - let qa_for_name = qa_trigger; - let polish_for_name = selection_polish_trigger; - let trans_for_name = translation_trigger; - let _name_match = match conn.add_match(fcitx_rule, move |args: (String, String, String), _conn, _msg| { - let (name, _old_owner, new_owner) = args; - if name != "org.fcitx.Fcitx5" { return true; } - if !new_owner.is_empty() { - // fcitx5 已启动(或重启),重新同步所有快捷键绑定。 - // 把延迟+同步挪到独立线程:add_match 回调跑在 DBus 事件循环 - // 线程里,sleep 会阻塞所有信号处理。 - log::info!("[fcitx-hotkey] fcitx5 appeared on DBus, re-syncing bindings"); - let b = binding_for_name.clone(); - let c = custom_for_name.clone(); - let q = qa_for_name; - let s = polish_for_name; - let t = trans_for_name; - std::thread::spawn(move || { - std::thread::sleep(Duration::from_secs(1)); // 等插件完全加载 - resync_main_binding(&b, c.as_deref()); - sync_qa_binding(q); - sync_selection_polish_binding(s); - sync_translation_binding(t); - }); - } - true - }) { - Ok(m) => m, - Err(e) => { - log::warn!("[fcitx-hotkey] Failed to add fcitx5 name watch: {e}"); - return; - } - }; - - // 初始同步:等待 fcitx5 可用(最多重试 10 次,每次 3 秒)。 - for attempt in 0..10 { - if fcitx5_name_has_owner(&conn) { - log::info!("[fcitx-hotkey] fcitx5 available, syncing initial bindings (attempt {attempt})"); - resync_main_binding(&binding, custom_trigger_key.as_deref()); - sync_qa_binding(qa_trigger); - sync_selection_polish_binding(selection_polish_trigger); - sync_translation_binding(translation_trigger); - break; - } - if attempt == 0 { - log::info!("[fcitx-hotkey] fcitx5 not yet available, will retry..."); - } - std::thread::sleep(Duration::from_secs(3)); - } - - // ⚠️ `_match` / `_name_match` 是 dbus::MsgMatch guard — drop 即注销。 - // Rust 中 `let _name = ...` 绑定生命周期正常(仅有 `let _ = ...` 才立即 drop), - // 它们与 `loop {}` 在同一个闭包作用域内,事件循环期间不会提前析构。 - // 自动化审核对此的 HIGH 报告是误判。 - log::info!("[fcitx-hotkey] Listening for OpenLess1 signals"); - loop { - if let Err(e) = conn.process(Duration::from_millis(500)) { - log::warn!("[fcitx-hotkey] DBus process error: {e}"); - break; - } - } - }) - .ok(); -} - -/// 确保 Linux fcitx5 插件可用。 -/// -/// AppImage 从 bundled resources 同步到用户级 XDG 路径;deb/rpm 保持系统路径检查, -/// 不写入用户目录。所有同步失败只记录 warning,不阻塞应用启动。 -#[cfg(target_os = "linux")] -pub fn ensure_plugin_installed(app: &tauri::AppHandle) { - if is_appimage_runtime() { - ensure_appimage_plugin_installed(app); - return; - } - - // fcitx5 在不同发行版的 lib 路径不同,同时支持用户 XDG 安装 - let lib_dirs = [ - "/usr/lib/x86_64-linux-gnu/fcitx5", // Debian multiarch - "/usr/lib64/fcitx5", // RPM 64-bit - "/usr/lib/fcitx5", // 通用回退 - ]; - let system_conf = std::path::Path::new("/usr/share/fcitx5/addon/openless.conf"); - - // 用户 XDG 安装:~/.local/ 下自编译安装的版本 - let (user_so, user_conf) = if let Ok(home) = std::env::var("HOME") { - let home = std::path::PathBuf::from(home); - ( - home.join(".local/lib/fcitx5/libopenless.so"), - home.join(".local/share/fcitx5/addon/openless.conf"), - ) - } else { - (std::path::PathBuf::new(), std::path::PathBuf::new()) - }; - - let conf_ok = user_conf.exists() || system_conf.exists(); - let system_so_found = lib_dirs - .iter() - .find(|dir| std::path::Path::new(dir).join("libopenless.so").exists()); - let so_ok = user_so.exists() || system_so_found.is_some(); - - // 用户手动安装过 ~/.local/ 版本,同时系统路径也有(deb 注入的)→ - // fcitx5 优先加载用户路径的旧版,系统新版被忽略。 - // 提醒用户删除 ~/.local/ 的旧插件。 - if user_so.exists() && system_so_found.is_some() { - log::warn!( - "[fcitx] fcitx5 plugin found in both ~/.local/ and system paths. \ - fcitx5 will load the ~/.local/ version first, which may be outdated. \ - Remove it if you want to use the system-installed version: rm -f {}", - user_so.display() - ); - } - - if !conf_ok { - log::warn!( - "[fcitx] fcitx5 addon config not found. \ - The OpenLess package may be incomplete." - ); - return; - } - - if !so_ok { - log::warn!( - "[fcitx] fcitx5 plugin .so not found in {:?} or {:?}. \ - The OpenLess package may be incomplete.", - lib_dirs, - user_so - ); - } -} - -#[cfg(target_os = "linux")] -fn is_appimage_runtime() -> bool { - ["APPDIR", "APPIMAGE"].iter().any(|name| { - std::env::var_os(name) - .map(|value| !value.is_empty()) - .unwrap_or(false) - }) -} - -/// AppImage 里 bundled resources 下的插件子路径(相对 resource_dir)。 -/// -/// ⚠️ 与 `.github/workflows/release-tauri.yml` 的 `bundle.resources` 数组 -/// 和 AppImage 内容校验(`unsquashfs -l` 的 grep)**必须保持一致**—— -/// 改这里要同步改 CI,反之亦然。CI 校验能在打包时兜底抓失配,但那是 -/// 发布后才发现;此处常量让 Rust 侧所有引用(含测试)共享同一来源。 -#[cfg(target_os = "linux")] -const APPIMAGE_PLUGIN_SUBDIR: &str = "linux-fcitx5-plugin"; -#[cfg(target_os = "linux")] -fn appimage_resource_paths( - resource_dir: &std::path::Path, -) -> (std::path::PathBuf, std::path::PathBuf) { - ( - resource_dir - .join(APPIMAGE_PLUGIN_SUBDIR) - .join("libopenless.so"), - resource_dir - .join(APPIMAGE_PLUGIN_SUBDIR) - .join("openless.conf"), - ) -} - -#[cfg(target_os = "linux")] -fn ensure_appimage_plugin_installed(app: &tauri::AppHandle) { - use tauri::Manager; - - let resource_dir = match app.path().resource_dir() { - Ok(path) => path, - Err(error) => { - log::warn!("[fcitx] AppImage resource directory unavailable: {error}"); - return; - } - }; - let (source_so, source_conf) = appimage_resource_paths(&resource_dir); - let Some(home) = std::env::var_os("HOME").filter(|value| !value.is_empty()) else { - log::warn!("[fcitx] HOME is unavailable; keeping any existing user plugin"); - return; - }; - let home = std::path::PathBuf::from(home); - let target_so = home.join(".local/lib/fcitx5/libopenless.so"); - let target_conf = home.join(".local/share/fcitx5/addon/openless.conf"); - - match sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf) { - Ok(false) => return, - Ok(true) => log::info!("[fcitx] Updated AppImage fcitx5 plugin in ~/.local"), - Err(error) => { - log::warn!("[fcitx] AppImage fcitx5 plugin sync failed: {error}"); - return; - } - } - - reload_fcitx5_if_running(); -} - -#[cfg(target_os = "linux")] -fn sync_plugin_pair( - source_so: &std::path::Path, - source_conf: &std::path::Path, - target_so: &std::path::Path, - target_conf: &std::path::Path, -) -> Result { - let so = std::fs::read(source_so) - .map_err(|error| format!("read {}: {error}", source_so.display()))?; - let conf = std::fs::read(source_conf) - .map_err(|error| format!("read {}: {error}", source_conf.display()))?; - if so.is_empty() { - return Err(format!("bundled resource {} is empty", source_so.display())); - } - if conf.is_empty() { - return Err(format!( - "bundled resource {} is empty", - source_conf.display() - )); - } - - let so_changed = !target_matches(target_so, &so)?; - let conf_changed = !target_matches(target_conf, &conf)?; - if !so_changed && !conf_changed { - return Ok(false); - } - - for target in [target_so, target_conf] { - let parent = target - .parent() - .ok_or_else(|| format!("target has no parent: {}", target.display()))?; - std::fs::create_dir_all(parent) - .map_err(|error| format!("create {}: {error}", parent.display()))?; - } - - let so_temp = if so_changed { - Some(stage_atomic_write(&so, target_so)?) - } else { - None - }; - let conf_temp = if conf_changed { - match stage_atomic_write(&conf, target_conf) { - Ok(path) => Some(path), - Err(error) => { - if let Some(path) = so_temp.as_ref() { - let _ = std::fs::remove_file(path); - } - return Err(error); - } - } - } else { - None - }; - - let result = (|| { - if let Some(temp) = so_temp.as_ref() { - commit_atomic_write(temp, target_so)?; - } - if let Some(temp) = conf_temp.as_ref() { - commit_atomic_write(temp, target_conf)?; - } - Ok(true) - })(); - if result.is_err() { - for temp in [so_temp.as_ref(), conf_temp.as_ref()].into_iter().flatten() { - let _ = std::fs::remove_file(temp); - } - } - result -} - -#[cfg(target_os = "linux")] -fn target_matches(target: &std::path::Path, source: &[u8]) -> Result { - let target_bytes = match std::fs::read(target) { - Ok(bytes) => bytes, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), - Err(error) => return Err(format!("read {}: {error}", target.display())), - }; - Ok(sha256(&target_bytes) == sha256(source)) -} - -#[cfg(target_os = "linux")] -fn sha256(bytes: &[u8]) -> [u8; 32] { - use sha2::{Digest, Sha256}; - Sha256::digest(bytes).into() -} - -#[cfg(target_os = "linux")] -fn stage_atomic_write( - bytes: &[u8], - target: &std::path::Path, -) -> Result { - use std::io::Write; - - let parent = target - .parent() - .ok_or_else(|| format!("target has no parent: {}", target.display()))?; - let name = target - .file_name() - .ok_or_else(|| format!("target has no filename: {}", target.display()))? - .to_string_lossy(); - let temp = parent.join(format!(".{name}.tmp-{}", uuid::Uuid::new_v4())); - let result = (|| { - let mut file = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&temp) - .map_err(|error| format!("create {}: {error}", temp.display()))?; - file.write_all(bytes) - .map_err(|error| format!("write {}: {error}", temp.display()))?; - file.sync_all() - .map_err(|error| format!("sync {}: {error}", temp.display()))?; - Ok(temp.clone()) - })(); - if result.is_err() { - let _ = std::fs::remove_file(&temp); - } - result -} - -#[cfg(target_os = "linux")] -fn commit_atomic_write(temp: &std::path::Path, target: &std::path::Path) -> Result<(), String> { - std::fs::rename(temp, target) - .map_err(|error| format!("rename {} to {}: {error}", temp.display(), target.display())) -} - -#[cfg(target_os = "linux")] -fn reload_fcitx5_if_running() { - let conn = match dbus::blocking::SyncConnection::new_session() { - Ok(conn) => conn, - Err(error) => { - log::warn!("[fcitx] Cannot check fcitx5 before reload: {error}"); - return; - } - }; - if !fcitx5_name_has_owner(&conn) { - return; - } - match std::process::Command::new("fcitx5").arg("-r").status() { - Ok(status) if status.success() => log::info!("[fcitx] Reloaded fcitx5 after plugin update"), - Ok(status) => log::warn!("[fcitx] fcitx5 -r failed with status {status}"), - Err(error) => log::warn!("[fcitx] Could not run fcitx5 -r: {error}"), - } -} - -/// 同步主听写热键:自定义组合键走 SetCustomDictationTrigger,预设修饰键走 SetHotkeyRaw。 -fn resync_main_binding(binding: &crate::types::HotkeyBinding, custom_trigger_key: Option<&str>) { - if let Some(key_string) = custom_trigger_key { - if !key_string.is_empty() { - match set_custom_dictation_trigger(key_string) { - Ok(()) => log::info!("[fcitx] Resynced custom dictation trigger '{key_string}'"), - Err(e) => log::warn!("[fcitx] Failed to resync custom dictation trigger: {e}"), - } - return; - } - } - sync_binding_to_plugin(binding); -} - -/// 检查 fcitx5 是否在 DBus 上注册了名称(即 fcitx5 进程是否在运行且 DBus 模块已加载)。 -fn fcitx5_name_has_owner(conn: &dbus::blocking::SyncConnection) -> bool { - use dbus::blocking::BlockingSender; - let msg = match dbus::Message::new_method_call( - "org.freedesktop.DBus", - "/org/freedesktop/DBus", - "org.freedesktop.DBus", - "NameHasOwner", - ) { - Ok(m) => m, - Err(_) => return false, - }; - let msg = msg.append1("org.fcitx.Fcitx5"); - match conn.send_with_reply_and_block(msg, Duration::from_secs(1)) { - Ok(reply) => reply.read1::().unwrap_or(false), - Err(_) => false, - } -} - -#[cfg(all(test, target_os = "linux"))] -mod tests { - use super::*; - use std::fs; - use std::path::{Path, PathBuf}; - - struct TestDir(PathBuf); - - impl TestDir { - fn new() -> Self { - let path = - std::env::temp_dir().join(format!("openless-fcitx-test-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(&path).expect("create test directory"); - Self(path) - } - - fn path(&self) -> &Path { - &self.0 - } - } - - impl Drop for TestDir { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.0); - } - } - - #[test] - fn appimage_resources_use_the_expected_subdirectory() { - // 这些字面量是**契约的一部分**(与 CI 的 bundle.resources 数组一致), - // 故意不引用 APPIMAGE_PLUGIN_SUBDIR,避免测试与实现相互印证而漏掉契约漂移。 - let (so, conf) = appimage_resource_paths(Path::new("/opt/openless/resources")); - assert_eq!( - so, - Path::new("/opt/openless/resources/linux-fcitx5-plugin/libopenless.so") - ); - assert_eq!( - conf, - Path::new("/opt/openless/resources/linux-fcitx5-plugin/openless.conf") - ); - } - - #[test] - fn missing_targets_are_created_and_parent_directories_are_made() { - let dir = TestDir::new(); - let source_so = dir.path().join("source/libopenless.so"); - let source_conf = dir.path().join("source/openless.conf"); - let target_so = dir.path().join("home/.local/lib/fcitx5/libopenless.so"); - let target_conf = dir - .path() - .join("home/.local/share/fcitx5/addon/openless.conf"); - fs::create_dir_all(source_so.parent().unwrap()).unwrap(); - fs::write(&source_so, b"so-v1").unwrap(); - fs::write(&source_conf, b"conf-v1").unwrap(); - - assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap()); - assert_eq!(fs::read(&target_so).unwrap(), b"so-v1"); - assert_eq!(fs::read(&target_conf).unwrap(), b"conf-v1"); - } - - #[test] - fn identical_targets_are_not_rewritten() { - let dir = TestDir::new(); - let source_so = dir.path().join("source.so"); - let source_conf = dir.path().join("source.conf"); - let target_so = dir.path().join("target.so"); - let target_conf = dir.path().join("target.conf"); - fs::write(&source_so, b"same-so").unwrap(); - fs::write(&source_conf, b"same-conf").unwrap(); - fs::write(&target_so, b"same-so").unwrap(); - fs::write(&target_conf, b"same-conf").unwrap(); - - assert!(!sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap()); - assert_eq!(fs::read(&target_so).unwrap(), b"same-so"); - assert_eq!(fs::read(&target_conf).unwrap(), b"same-conf"); - } - - #[test] - fn changed_targets_are_updated_as_a_pair() { - let dir = TestDir::new(); - let source_so = dir.path().join("source.so"); - let source_conf = dir.path().join("source.conf"); - let target_so = dir.path().join("target.so"); - let target_conf = dir.path().join("target.conf"); - fs::write(&source_so, b"new-so").unwrap(); - fs::write(&source_conf, b"new-conf").unwrap(); - fs::write(&target_so, b"old-so").unwrap(); - fs::write(&target_conf, b"old-conf").unwrap(); - - assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap()); - assert_eq!(fs::read(&target_so).unwrap(), b"new-so"); - assert_eq!(fs::read(&target_conf).unwrap(), b"new-conf"); - } - - #[test] - fn missing_resource_keeps_existing_targets() { - let dir = TestDir::new(); - let source_so = dir.path().join("missing.so"); - let source_conf = dir.path().join("source.conf"); - let target_so = dir.path().join("target.so"); - let target_conf = dir.path().join("target.conf"); - fs::write(&source_conf, b"new-conf").unwrap(); - fs::write(&target_so, b"old-so").unwrap(); - fs::write(&target_conf, b"old-conf").unwrap(); - - assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).is_err()); - assert_eq!(fs::read(&target_so).unwrap(), b"old-so"); - assert_eq!(fs::read(&target_conf).unwrap(), b"old-conf"); - } - - #[test] - fn unusable_target_directory_keeps_existing_targets() { - let dir = TestDir::new(); - let source_so = dir.path().join("source.so"); - let source_conf = dir.path().join("source.conf"); - let target_so = dir.path().join("targets/libopenless.so"); - let target_conf = dir.path().join("blocked/openless.conf"); - fs::write(&source_so, b"new-so").unwrap(); - fs::write(&source_conf, b"new-conf").unwrap(); - fs::create_dir_all(target_so.parent().unwrap()).unwrap(); - fs::write(&target_so, b"old-so").unwrap(); - fs::write(dir.path().join("blocked"), b"not a directory").unwrap(); - - assert!(sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).is_err()); - assert_eq!(fs::read(&target_so).unwrap(), b"old-so"); - assert!(target_conf.is_dir() || !target_conf.exists()); - } - - #[test] - fn atomic_update_leaves_no_temporary_files() { - let dir = TestDir::new(); - let source_so = dir.path().join("source.so"); - let source_conf = dir.path().join("source.conf"); - let target_so = dir.path().join("target.so"); - let target_conf = dir.path().join("target.conf"); - fs::write(&source_so, b"so").unwrap(); - fs::write(&source_conf, b"conf").unwrap(); - - sync_plugin_pair(&source_so, &source_conf, &target_so, &target_conf).unwrap(); - let leftovers = fs::read_dir(dir.path()) - .unwrap() - .filter_map(Result::ok) - .filter_map(|entry| entry.file_name().into_string().ok()) - .filter(|name| name.contains(".tmp-")) - .collect::>(); - assert!( - leftovers.is_empty(), - "temporary files left behind: {leftovers:?}" - ); - } -} diff --git a/openless-all/app/src-tauri/src/main.rs b/openless-all/app/src-tauri/src/main.rs index dc101365e..cb1e6ae97 100644 --- a/openless-all/app/src-tauri/src/main.rs +++ b/openless-all/app/src-tauri/src/main.rs @@ -2,20 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - // Work around WebKitGTK compositing bugs on Linux Wayland: - // - WEBKIT_DISABLE_COMPOSITING_MODE=1 fixes "whole window unresponsive - // to clicks until maximize/restore" (tauri#9394) - // - WEBKIT_DISABLE_DMABUF_RENDERER=1 fixes white/black screen on some - // GPU/driver combos (e.g. Nvidia + Debian) - #[cfg(target_os = "linux")] - { - if std::env::var("WEBKIT_DISABLE_DMABUF_RENDERER").is_err() { - std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); - } - if std::env::var("WEBKIT_DISABLE_COMPOSITING_MODE").is_err() { - std::env::set_var("WEBKIT_DISABLE_COMPOSITING_MODE", "1"); - } - } - openless_lib::run(); } diff --git a/openless-all/app/src-tauri/src/permissions.rs b/openless-all/app/src-tauri/src/permissions.rs index a4be7dd54..a50471c8d 100644 --- a/openless-all/app/src-tauri/src/permissions.rs +++ b/openless-all/app/src-tauri/src/permissions.rs @@ -1,4 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! 系统权限请求 / 检查(macOS / Windows)。 //! //! 与 Swift `Sources/OpenLessHotkey/AccessibilityPermission.swift` + @@ -311,7 +310,7 @@ mod platform { } } -// ─────────────────────────── Windows / Linux / 其他 ─────────────────────────── +// ─────────────────────────────── Windows / 其他 ─────────────────────────────── #[cfg(all(not(target_os = "macos"), not(target_os = "android")))] mod platform { @@ -324,7 +323,7 @@ mod platform { #[cfg(target_os = "windows")] use winreg::RegKey; - /// Windows / Linux 不存在 macOS 那种 Accessibility 概念。 + /// Windows 不存在 macOS 那种 Accessibility 概念。 pub fn check_accessibility() -> PermissionStatus { PermissionStatus::NotApplicable } @@ -335,19 +334,7 @@ mod platform { /// Windows 的麦克风权限走系统设置 → 隐私 → 麦克风; /// 这里用 cpal 建立一次短生命周期输入流,避免只查设备格式时误报已授权。 - /// Linux 没有对应的应用级权限状态,只检查设备是否存在,避免 PipeWire - /// 因权限探测建立临时输入流而触发桌面音量 OSD(issue #968)。 pub fn check_microphone() -> PermissionStatus { - #[cfg(target_os = "linux")] - { - return if has_microphone_input_device() { - PermissionStatus::Granted - } else { - PermissionStatus::NoDevice - }; - } - - #[cfg(not(target_os = "linux"))] { if windows_microphone_registry_denied() { log::warn!("[mic] Windows microphone privacy registry is denied"); diff --git a/openless-all/app/src-tauri/src/persistence/correction.rs b/openless-all/app/src-tauri/src/persistence/correction.rs index cde656a84..53ebf3939 100644 --- a/openless-all/app/src-tauri/src/persistence/correction.rs +++ b/openless-all/app/src-tauri/src/persistence/correction.rs @@ -1,4 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! Tauri path adapter for the shared correction-rule repository. use std::path::PathBuf; diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index aedc6ce95..09733b304 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -1,4 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! Credentials vault. //! //! 正常读写走系统凭据库;旧 plaintext JSON 只作为迁移来源。为保持多 provider @@ -1099,7 +1098,7 @@ fn migrate_channels(root: &mut CredsRoot) -> bool { /// /// 只有 Windows 需要:那里的默认 ASR 是本地 Foundry,无需任何 key、装上就能用 /// (见 `creds_default_asr`)。渠道化后列表完全由用户添加,不预置的话 Windows 新用户 -/// 开箱会一个 ASR 都没有。mac / Linux 的默认是要填 key 的云端厂商,预置一张空卡片 +/// 开箱会一个 ASR 都没有。macOS 的默认是要填 key 的云端厂商,预置一张空卡片 /// 没有意义,交给新手引导。 /// /// 靠 `version < 2` 把"全新安装"和"用户把渠道全删了"区分开:后者 version 已经是 2, @@ -1323,7 +1322,7 @@ fn is_reserved_extra_header_name(name: &str) -> bool { } fn credentials_path() -> Result { - // macOS / Linux: ~/.openless/credentials.json (与 Swift 同源) + // macOS: ~/.openless/credentials.json (与 Swift 同源) // Windows: %APPDATA%\OpenLess\credentials.json (Windows 没有标准 HOME 环境变量) #[cfg(target_os = "windows")] { @@ -1852,7 +1851,7 @@ fn read_chunk_manifest(json: &str) -> Option { /// credential set spans, the more reads per load, the higher the odds at least /// one trips. Retry transient errors a few times with short backoff. /// -/// macOS / Linux keep the original single-shot behavior on purpose: their read +/// macOS keeps the original single-shot behavior on purpose: its read /// errors are ACL denials that won't heal on retry, and the un-cached error path /// already retries on the next call — adding sleeps there would only slow the /// macOS first-launch Keychain authorization flow. diff --git a/openless-all/app/src-tauri/src/persistence/dictionary.rs b/openless-all/app/src-tauri/src/persistence/dictionary.rs index 7d60c869e..c832b3959 100644 --- a/openless-all/app/src-tauri/src/persistence/dictionary.rs +++ b/openless-all/app/src-tauri/src/persistence/dictionary.rs @@ -1,4 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! Tauri path adapter for the shared vocabulary repository. use std::path::PathBuf; diff --git a/openless-all/app/src-tauri/src/persistence/history.rs b/openless-all/app/src-tauri/src/persistence/history.rs index 01f7d7621..1d6f74507 100644 --- a/openless-all/app/src-tauri/src/persistence/history.rs +++ b/openless-all/app/src-tauri/src/persistence/history.rs @@ -1,4 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! Tauri path adapter for the shared dictation history repository. use anyhow::Result; diff --git a/openless-all/app/src-tauri/src/persistence/mod.rs b/openless-all/app/src-tauri/src/persistence/mod.rs index 77d230aaa..c274ee018 100644 --- a/openless-all/app/src-tauri/src/persistence/mod.rs +++ b/openless-all/app/src-tauri/src/persistence/mod.rs @@ -1,15 +1,13 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! Local persistence: history JSON, user preferences JSON, vocab JSON, and //! platform-backed credentials vault. //! //! Storage roots: //! - macOS: `~/Library/Application Support/OpenLess` //! - Windows: `%APPDATA%\OpenLess` -//! - Linux: `$XDG_DATA_HOME/OpenLess` or `~/.local/share/OpenLess` //! - Android: `{Context.getFilesDir()}/OpenLess` (never `/data/local/tmp`) //! //! Credential storage policy: provider credentials are stored in the OS -//! credential vault (macOS Keychain, Windows Credential Manager, Linux keyring). +//! credential vault (macOS Keychain, Windows Credential Manager). //! A legacy plaintext JSON file is read once as a migration source and removed //! after a successful vault write; new writes never persist plaintext secrets. //! diff --git a/openless-all/app/src-tauri/src/persistence/paths.rs b/openless-all/app/src-tauri/src/persistence/paths.rs index 01a242075..47505a8f7 100644 --- a/openless-all/app/src-tauri/src/persistence/paths.rs +++ b/openless-all/app/src-tauri/src/persistence/paths.rs @@ -1,4 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! Storage path resolution: models root, recordings archive (with retention //! pruning), and the Windows //! Foundry Local cache roots. diff --git a/openless-all/app/src-tauri/src/persistence/preferences.rs b/openless-all/app/src-tauri/src/persistence/preferences.rs index 2df895bfa..61c4d2bab 100644 --- a/openless-all/app/src-tauri/src/persistence/preferences.rs +++ b/openless-all/app/src-tauri/src/persistence/preferences.rs @@ -1,4 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code))] //! Tauri path adapter for the framework-independent preferences store. use anyhow::{Context, Result}; diff --git a/openless-all/app/src-tauri/src/persistence/style_pack.rs b/openless-all/app/src-tauri/src/persistence/style_pack.rs index c927267fb..8edff5ef4 100644 --- a/openless-all/app/src-tauri/src/persistence/style_pack.rs +++ b/openless-all/app/src-tauri/src/persistence/style_pack.rs @@ -1,4 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! Tauri path adapter for the framework-independent style-pack store. use std::path::Path; diff --git a/openless-all/app/src-tauri/src/qa_hotkey.rs b/openless-all/app/src-tauri/src/qa_hotkey.rs index d5fd3f5e8..cadb8e5d7 100644 --- a/openless-all/app/src-tauri/src/qa_hotkey.rs +++ b/openless-all/app/src-tauri/src/qa_hotkey.rs @@ -2,7 +2,7 @@ //! //! 与 `hotkey.rs`(modifier-only 听写热键)平行——QA 用的是组合键 //! `Cmd+Shift+;` / `Ctrl+Shift+;`,所以走 `global-hotkey` crate(macOS 内部 -//! 用 Carbon `RegisterEventHotKey`,Windows 用 `RegisterHotKey`,Linux 用 X11)。 +//! 用 Carbon `RegisterEventHotKey`,Windows 用 `RegisterHotKey`)。 //! //! 仅产出 `QaHotkeyEvent::Pressed` 边沿事件;toggle / 录音生命周期由 //! coordinator 解释(第一次按 → 开始问答;第二次按 → 结束)。 diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index a41503e2a..01af1ab44 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -5,16 +5,15 @@ //! 走辅助功能 API 直读焦点元素的选区,**不**触碰剪贴板。 //! 2. **macOS / Windows** Cmd+C / Ctrl+C:snapshot 用户原剪贴板 → 模拟复制 → 80ms //! 后读出新内容 → 还原原剪贴板。 -//! 3. **Linux**:通过 fcitx5 插件的 `GetSelectionText()` DBus 方法读取 PRIMARY -//! 选区缓存,不触碰用户剪贴板;插件不可用、调用失败或返回空文本均视为无选区。 //! //! 截断策略:超过 4000 字符的选区只保留首 2000 + 尾 2000 + `[…truncated…]` 标记, //! 避免给 LLM 灌过长 context。 //! -//! 模块依赖:`arboard`(跨平台剪贴板)+ libc + 平台 native 框架,Linux 另依赖 -//! `linux_fcitx` 的 DBus 客户端。 +//! 模块依赖:`arboard`(跨平台剪贴板)+ libc + 平台 native 框架。 +//! Linux 桌面已改由 egui 前端(`openless-all/app/linux-egui`)承担,Tauri 版不再提供 +//! Linux 的选区读取路径。 -// 仅 macOS / Windows 的模拟复制路径用 sleep;Linux 走 fcitx5 DBus 直读,无 sleep。 +// 仅 macOS / Windows 的模拟复制路径用 sleep。 #[cfg(any(target_os = "macos", target_os = "windows"))] use std::time::Duration; @@ -349,8 +348,7 @@ pub(crate) fn capture_selection_insertion_target() -> SelectionInsertionTarget { /// snapshot is always available (there is always a frontmost app), so this /// passes once we have it. /// -/// 非 Windows/macOS(Linux / mobile)尚未实现等效的前台校验:Linux 依赖 -/// PRIMARY selection 重读做轻量校验,移动端不提供选区润色。 +/// 移动端不提供选区润色;只有 macOS / Windows 具有对应的前台校验。 pub(crate) fn selection_insertion_target_is_captured(target: &SelectionInsertionTarget) -> bool { #[cfg(target_os = "windows")] { @@ -364,9 +362,7 @@ pub(crate) fn selection_insertion_target_is_captured(target: &SelectionInsertion #[cfg(not(any(target_os = "windows", target_os = "macos")))] { - // Linux:无前台窗口校验,靠「选区文本一致性」兜底——capture 时能读到 - // PRIMARY selection(run_selection_polish 已挡掉无选区),validate 时 - // 重读 PRIMARY 比较,变了就拒绝粘贴。 + // 移动端不提供选区润色;此处保留非桌面平台的类型兜底。 let _ = target; true } @@ -431,25 +427,7 @@ pub(crate) fn validate_selection_insertion_target( return SelectionInsertionTargetValidation::Valid; } - #[cfg(target_os = "linux")] - { - // Linux:重读 PRIMARY selection 与捕获文本比较——用户改了选区 / 清空 - // PRIMARY 就拒绝粘贴(fcitx CommitText 直接写焦点输入上下文,无需 - // 恢复窗口焦点,所以这里不需要窗口级校验)。 - let current_selection = match linux_selection::read_selected_text() { - linux_selection::LinuxSelectionRead::Text(text) => { - let trimmed = text.trim(); - (!trimmed.is_empty()).then(|| truncate_selection(trimmed)) - } - _ => None, - }; - if !selection_text_matches(expected_selection, current_selection.as_deref()) { - return SelectionInsertionTargetValidation::SelectionChanged; - } - SelectionInsertionTargetValidation::Valid - } - - #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] { SelectionInsertionTargetValidation::TargetUnavailable } @@ -581,7 +559,7 @@ pub(crate) fn selection_target_still_front(target: &SelectionInsertionTarget) -> true } -/// 捕获选区。Linux 只通过 fcitx5 DBus 读取 PRIMARY 选区,失败统一视为无选区。 +/// 捕获当前前台应用的选区;失败统一视为无选区。 pub fn capture_selection_with_status() -> SelectionCaptureOutcome { capture_selection_with_status_diag().0 } @@ -672,38 +650,7 @@ fn capture_selection_with_status_diag() -> (SelectionCaptureOutcome, SelectionCa } } - // 3. Linux:通过 fcitx5 DBus 读取 PRIMARY selection。 - #[cfg(target_os = "linux")] - match linux_selection::read_selected_text() { - linux_selection::LinuxSelectionRead::Text(text) => { - let trimmed = text.trim(); - log::info!( - "[selection] linux primary selection OK ({} chars){}", - trimmed.chars().count(), - source_app - .as_deref() - .map(|a| format!(" front_app={a}")) - .unwrap_or_default() - ); - return ( - SelectionCaptureOutcome { - selection: Some(SelectionContext { - text: truncate_selection(trimmed), - source_app, - }), - }, - SelectionCaptureMissReason::Ok, - ); - } - linux_selection::LinuxSelectionRead::NoSelection => { - return ( - SelectionCaptureOutcome { selection: None }, - SelectionCaptureMissReason::EmptyTrimmed, - ); - } - } - - #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] + #[cfg(not(any(target_os = "macos", target_os = "windows")))] ( SelectionCaptureOutcome { selection: None }, SelectionCaptureMissReason::NoCapturePath, @@ -827,7 +774,7 @@ fn selected_text_for_validation() -> Option { (!trimmed.is_empty()).then(|| truncate_selection(trimmed)) } -#[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux", test))] +#[cfg(any(target_os = "windows", target_os = "macos", test))] fn selection_text_matches(expected: &str, actual: Option<&str>) -> bool { actual.is_some_and(|actual| actual == expected) } @@ -907,61 +854,6 @@ fn post_copy_shortcut() -> bool { windows_paste::send_ctrl_c().is_ok() } -#[cfg(target_os = "linux")] -mod linux_selection { - #[derive(Debug, PartialEq, Eq)] - pub enum LinuxSelectionRead { - Text(String), - NoSelection, - } - - pub fn read_selected_text() -> LinuxSelectionRead { - classify_selection_result(crate::linux_fcitx::get_selection_text()) - } - - fn classify_selection_result(result: Result) -> LinuxSelectionRead { - match result { - Ok(text) => { - let trimmed = text.trim(); - if trimmed.is_empty() { - LinuxSelectionRead::NoSelection - } else { - LinuxSelectionRead::Text(trimmed.to_string()) - } - } - Err(error) => { - log::debug!("[selection] fcitx5 GetSelectionText unavailable: {error}"); - LinuxSelectionRead::NoSelection - } - } - } - #[cfg(test)] - mod tests { - use super::*; - - #[test] - fn maps_dbus_text_to_selection() { - let result = classify_selection_result(Ok(" selected text ".to_string())); - assert_eq!( - result, - LinuxSelectionRead::Text("selected text".to_string()) - ); - } - - #[test] - fn maps_empty_dbus_text_to_no_selection() { - let result = classify_selection_result(Ok(" \n".to_string())); - assert_eq!(result, LinuxSelectionRead::NoSelection); - } - - #[test] - fn maps_dbus_error_to_no_selection() { - let result = classify_selection_result(Err("DBus unavailable".to_string())); - assert_eq!(result, LinuxSelectionRead::NoSelection); - } - } -} - // ─────────────────────────── macOS AX read ─────────────────────────── #[cfg(target_os = "macos")] diff --git a/openless-all/app/src-tauri/src/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs index ba6348d52..668e43eeb 100644 --- a/openless-all/app/src-tauri/src/side_aware_combo.rs +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -200,35 +200,23 @@ impl SideAwareComboMonitor { tx: Sender, combo_tx: Sender, ) -> Result { - // Linux has no side-aware platform dispatch (no CGEventTap / WH_KEYBOARD_LL - // equivalent wired here). Accepting the binding would leave the user with a - // "successfully configured" combo that silently never fires. Reject up front - // so the caller can surface an actionable error instead. - #[cfg(target_os = "linux")] - { - let _ = (&binding, &tx, &combo_tx); - return Err(crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier( - "侧向修饰键组合键在 Linux 暂不支持".into(), - )); - } + // Linux accepts the binding too: the egui host owns its own side-aware + // modifier layer, so the platform dispatch this file wraps is not the + // only source of those edges. Rejecting here would hide a working + // configuration behind a bogus error. + validate_side_binding(&binding)?; - #[cfg(not(target_os = "linux"))] - { - validate_side_binding(&binding)?; - - let slot = ACTIVE_MONITOR.get_or_init(|| RwLock::new(None)); - let mut guard = slot.write().expect("side combo monitor lock poisoned"); - *guard = Some(ActiveSideCombo { - tx, - combo_tx, - state: Mutex::new(SideAwareComboState::new(binding)), - }); - Ok(Self) - } + let slot = ACTIVE_MONITOR.get_or_init(|| RwLock::new(None)); + let mut guard = slot.write().expect("side combo monitor lock poisoned"); + *guard = Some(ActiveSideCombo { + tx, + combo_tx, + state: Mutex::new(SideAwareComboState::new(binding)), + }); + Ok(Self) } } -#[cfg(not(target_os = "linux"))] fn validate_side_binding( binding: &ShortcutBinding, ) -> Result<(), crate::combo_hotkey::ComboHotkeyError> { @@ -340,7 +328,8 @@ pub fn handle_primary_key(primary: &str, pressed: bool) { } pub fn handle_companion_key_down() { - if let Some(edge) = with_active(|active| active.state.lock().on_companion_key_down()).flatten() { + if let Some(edge) = with_active(|active| active.state.lock().on_companion_key_down()).flatten() + { with_active(|active| send_combo_abort(&active.combo_tx, edge)); } } @@ -652,14 +641,8 @@ mod tests { handle_primary_key(primary, true); handle_primary_key(primary, false); handle_side_modifier(SideModifier::CtrlRight, false); - assert!(matches!( - rx.try_recv(), - Ok(HotkeyEvent::Pressed { .. }) - )); - assert!(matches!( - rx.try_recv(), - Ok(HotkeyEvent::Released { .. }) - )); + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed { .. }))); + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Released { .. }))); assert!(rx.try_recv().is_err()); }; press_and_release("D"); diff --git a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs index 05995a70b..39d958398 100644 --- a/openless-all/app/src-tauri/src/tauri_coordinator_host.rs +++ b/openless-all/app/src-tauri/src/tauri_coordinator_host.rs @@ -166,16 +166,7 @@ fn show_capsule_window_no_activate( true } -#[cfg(target_os = "linux")] -fn show_capsule_window_no_activate( - _app: &AppHandle, - _window: &tauri::WebviewWindow, - _reassert_spaces: bool, -) -> bool { - true -} - -#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +#[cfg(not(any(target_os = "macos", target_os = "windows")))] fn show_capsule_window_no_activate( _app: &AppHandle, _window: &tauri::WebviewWindow, @@ -646,20 +637,6 @@ impl TauriCapsuleWindow { }; let fallback_card_active = self.state.defer_if_fallback_active(payload); - #[cfg(target_os = "linux")] - { - let _ = ( - window, - payload, - show_capsule, - style, - fallback_card_active, - reassert_spaces, - ); - return; - } - - #[cfg(not(target_os = "linux"))] { let action = capsule_window_action(fallback_card_active, show_capsule, payload.state); if action == CapsuleWindowAction::PreserveFallbackCard { @@ -806,7 +783,7 @@ impl TauriCoordinatorHost { tauri::async_runtime::block_on(future) } - #[cfg(any(target_os = "macos", target_os = "linux"))] + #[cfg(target_os = "macos")] pub(crate) fn local_qwen_asr( &self, engine: std::sync::Arc, diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 5001a5ffc..c825b0081 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -1,4 +1,3 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_imports))] //! Tauri compatibility re-exports for the framework-independent host contract. pub use openless_core::android_types; diff --git a/openless-all/app/src-tauri/src/unicode_keystroke.rs b/openless-all/app/src-tauri/src/unicode_keystroke.rs index 7638ea0b9..a49187262 100644 --- a/openless-all/app/src-tauri/src/unicode_keystroke.rs +++ b/openless-all/app/src-tauri/src/unicode_keystroke.rs @@ -1,11 +1,10 @@ -#![cfg_attr(target_os = "linux", allow(dead_code, unused_variables))] //! 跨平台 Unicode keystroke 合成(流式输入用)。 //! //! 公开 API 三件套: //! - `type_unicode_chunk(text)` —— 阻塞地把一段文字逐 codepoint 当作键盘事件发出去, //! 不动剪贴板。各平台用各自的原语;返回确认成功发送的字符数。 //! - `switch_to_ascii(app)` —— 仅 macOS 有效;切到 ABC 输入源以绕过 CJK / 日文 IME -//! 对 Unicode 字符串事件的拦截。Windows / Linux 上是 no-op。 +//! 对 Unicode 字符串事件的拦截。Windows 上是 no-op。 //! - `restore_input_source(app, prev)` —— 配对调用,恢复 macOS 上的原输入源。 //! //! ## 平台差异 @@ -15,7 +14,6 @@ //! 必须 `switch_to_ascii` 切到 ABC,session 结束再 `restore_input_source` 切回。 //! - **Windows**:`SendInput(KEYEVENTF_UNICODE)` 直接发 UTF-16 scancode。TSF 不拦 //! Unicode 事件(与 keyboard layout / IME 解耦),所以不需要切输入法。 -//! - **Linux**:走 fcitx5 插件 commitString 直写(DBus)或剪贴板回落。 //! //! ## 已知坑(macOS) //! @@ -58,12 +56,6 @@ pub enum TypeError { #[cfg(target_os = "windows")] #[error("Windows SendInput failed: {0}")] SendInputFailed(String), - #[cfg(target_os = "linux")] - #[error("enigo init failed: {0}")] - EnigoInit(String), - #[cfg(target_os = "linux")] - #[error("enigo text input failed: {0}")] - EnigoText(String), } impl TypeError { @@ -423,7 +415,7 @@ mod windows_impl { const SENDINPUT_CHUNK_CHARS: usize = 16; const SENDINPUT_CHUNK_DELAY: Duration = Duration::from_millis(12); - /// Windows / Linux 上没有 input source 概念,token 留空。Send/Sync 自动派生。 + /// Windows 上没有 input source 概念,token 留空。Send/Sync 自动派生。 pub struct PreviousInputSource; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -672,46 +664,6 @@ pub(crate) fn classify_sendinput_char(ch: char) -> SendInputCharKind { } } -// ═══════════════════════════════════════════════════════════════════════════ -// Linux 实现(实验性) -// ═══════════════════════════════════════════════════════════════════════════ -#[cfg(target_os = "linux")] -mod linux_impl { - use super::{TisError, TypeError}; - #[allow(unused_imports)] - use tauri::{AppHandle, Runtime}; - - pub struct PreviousInputSource; - - /// 通过 fcitx5 插件一次性提交整段文字(支持中文、Wayland/X11 均可)。 - /// 如果插件未加载返回 Err,调用方降级到剪贴板拷贝。 - pub fn type_unicode_chunk(text: &str) -> Result { - if text.is_empty() { - return Ok(0); - } - if crate::linux_fcitx::commit_text(text).is_ok() { - Ok(text.chars().count()) - } else { - Err(TypeError::EnigoText( - "fcitx5 plugin unavailable, try clipboard fallback".into(), - )) - } - } - - pub async fn switch_to_ascii( - _app: &AppHandle, - ) -> Result, TisError> { - Ok(None) - } - - pub async fn restore_input_source( - _app: &AppHandle, - _prev: Option, - ) -> Result<(), TisError> { - Ok(()) - } -} - #[cfg(test)] mod tests { use super::TypeError; @@ -829,11 +781,6 @@ mod tests { TypeError::SendInputFailed("fail".into()) } - #[cfg(target_os = "linux")] - fn platform_error() -> TypeError { - TypeError::EnigoText("fail".into()) - } - #[cfg(target_os = "windows")] #[test] fn expected_sendinput_typed_chars_includes_swallowed_carriage_return() { @@ -899,9 +846,3 @@ pub use windows_impl::{ restore_input_source, switch_to_ascii, type_unicode_chunk, type_unicode_chunk_with_options, PreviousInputSource, WindowsSendInputOptions, }; - -#[cfg(target_os = "linux")] -#[allow(unused_imports)] -pub use linux_impl::{ - restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource, -}; diff --git a/openless-all/app/src-tauri/tauri.linux.conf.json b/openless-all/app/src-tauri/tauri.linux.conf.json deleted file mode 100644 index abbd83b9a..000000000 --- a/openless-all/app/src-tauri/tauri.linux.conf.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "$schema": "https://schema.tauri.app/config/2", - "app": { - "windows": [ - { - "label": "main", - "title": "OpenLess", - "width": 1240, - "height": 800, - "minWidth": 980, - "minHeight": 640, - "resizable": true, - "decorations": false, - "transparent": false, - "shadow": true, - "visible": false, - "acceptFirstMouse": true - }, - { - "label": "capsule", - "url": "index.html?window=capsule", - "title": "OpenLess Capsule", - "width": 220, - "height": 110, - "decorations": false, - "transparent": false, - "shadow": false, - "alwaysOnTop": true, - "skipTaskbar": true, - "resizable": false, - "focus": false, - "visible": false, - "center": false, - "acceptFirstMouse": true - }, - { - "label": "qa", - "url": "index.html?window=qa", - "title": "OpenLess QA", - "width": 380, - "height": 440, - "decorations": false, - "transparent": false, - "shadow": true, - "alwaysOnTop": true, - "skipTaskbar": true, - "resizable": false, - "focus": false, - "visible": false, - "center": false, - "acceptFirstMouse": true - } - ] - }, - "bundle": { - "linux": { - "deb": { - "depends": [ - "libasound2", - "libdbus-1-3", - "libssl3", - "libjavascriptcoregtk-4.1-0", - "libsoup-3.0-0" - ], - "recommends": [ - "fcitx5" - ] - }, - "rpm": { - "depends": [ - "alsa-lib", - "dbus-libs", - "openssl-libs", - "webkit2gtk4.1", - "libsoup3" - ], - "recommends": [ - "fcitx5" - ] - } - } - } -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/CHANGELOG.md b/openless-all/app/src-tauri/vendor/wayland-scanner/CHANGELOG.md deleted file mode 100644 index 2712ef933..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/CHANGELOG.md +++ /dev/null @@ -1,67 +0,0 @@ -# CHANGELOG: wayland-scanner - -## Unreleased - -## 0.31.8 -- 2025-12-30 - -- Update `quick-xml` to 0.38 -- Generate bindings for `WlRegistry` - * Needed for client/server to use `wl_fixes` - -## 0.31.7 -- 2025-07-28 - -- Update `quick-xml` to 0.37 - -## 0.31.5 -- 2024-09-04 - -- Update `quick-xml` to 0.36 -- Allow `` in XML input - -## 0.31.3 -- 2024-07-03 - -- Update `quick-xml` to 0.34 - -## 0.31.2 -- 2024-05-30 - -- Use wrapper type implementing `Sync` instead of `static mut`s. -- Add headerless xml file parsing possibility for `parse` function. - -## 0.31.1 -- 2024-01-29 - -- Include an `std::convert::Infallible` in hidden `__phantom_lifetime` enum variants, - so they're explicitly unconstructable. - -## 0.31.0 -- 2023-09-02 - -#### Breaking changes - -- Bump bitflags to 2.0 -- Remove `io-lifetimes` from the generated code following wayland-backend 0.3 dropping it. -- Generate `BorrowedFd<'_>` arguments instead of `RawFd` - -# 0.30.1 -- 2023-06-17 - -- Generated protocol object types now implement `Borrow` and `Hash` - -## 0.30.0 -- 2022-12-27 - -## 0.30.0-beta.10 - -#### Additions - -- An `opcode()` method is added to message enums to retrive the opcode associated with a message variant. - -## 0.30.0-beta.9 - -- Migrate from xml-rs to quick-xml - -## 0.30.0-beta.6 - -- Generated enums now derive `Ord` and `Hash`. -- The scanner now generates constants for the opcode values of the protocol messages. - -## 0.30.0-alpha1 - -Full rework of the crate together of the reworks of `wayland-client` and `wayland-server`. - -The code generation is now achieved using procedural macros rather than build scripts. diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/Cargo.toml b/openless-all/app/src-tauri/vendor/wayland-scanner/Cargo.toml deleted file mode 100644 index 821a577dc..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -[package] -name = "wayland-scanner" -version = "0.31.10" -authors = ["Elinor Berger "] -repository = "https://github.com/smithay/wayland-rs" -documentation = "https://docs.rs/wayland-scanner/" -description = "Wayland Scanner for generating rust APIs from XML wayland protocol files." -license = "MIT" -categories = ["gui", "api-bindings"] -keywords = ["wayland", "codegen"] -edition = "2021" -rust-version = "1.71" -readme = "README.md" - -[lib] -proc-macro = true - -[dependencies] -proc-macro2 = "1.0.11" -quote = "1.0" -quick-xml = "0.41" - -[dev-dependencies] -similar = "2" - -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/LICENSE.txt b/openless-all/app/src-tauri/vendor/wayland-scanner/LICENSE.txt deleted file mode 100644 index 0ee195727..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/LICENSE.txt +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2015 Elinor Berger - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/OPENLESS_PATCH.md b/openless-all/app/src-tauri/vendor/wayland-scanner/OPENLESS_PATCH.md deleted file mode 100644 index 1c4241ad5..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/OPENLESS_PATCH.md +++ /dev/null @@ -1,12 +0,0 @@ -# OpenLess security backport - -This directory contains the sources from the published -`wayland-scanner 0.31.10` crate (upstream commit -`a3d7927d87799b2955bf491b51c7c2a3a82da661`). The source-package changes -backport upstream commits `ec2d93285559` and `d07c4f91f28b`: - -- bump `quick-xml` from `0.39` to `0.41`; -- use the equivalent `xml10_content()` API required by the newer release. - -The local patch can be removed after upstream publishes a compatible -`wayland-scanner` release containing that security fix. diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/README.md b/openless-all/app/src-tauri/vendor/wayland-scanner/README.md deleted file mode 100644 index 0c6662c47..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/README.md +++ /dev/null @@ -1,13 +0,0 @@ -[![crates.io](https://img.shields.io/crates/v/wayland-scanner.svg)](https://crates.io/crates/wayland-scanner) -[![docs.rs](https://docs.rs/wayland-scanner/badge.svg)](https://docs.rs/wayland-scanner) -[![Continuous Integration](https://github.com/Smithay/wayland-rs/workflows/Continuous%20Integration/badge.svg)](https://github.com/Smithay/wayland-rs/actions?query=workflow%3A%22Continuous+Integration%22) -[![codecov](https://codecov.io/gh/Smithay/wayland-rs/branch/master/graph/badge.svg)](https://codecov.io/gh/Smithay/wayland-rs) - -# wayland-scanner - -This crate provides procedural macros for generating the rust code associated with a -Wayland XML protocol specification, for use with the `wayland-client`, `wayland-server` -and `wayland-backend` crates. - -Before trying to use this crate, you may check if the protocol extension you want to use -is not already exposed in the `wayland-protocols` crate. diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/src/c_interfaces.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/src/c_interfaces.rs deleted file mode 100644 index 1a28bcddd..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/src/c_interfaces.rs +++ /dev/null @@ -1,172 +0,0 @@ -use std::cmp; - -use proc_macro2::{Literal, TokenStream}; -use quote::{format_ident, quote}; - -use crate::protocol::{Interface, Message, Protocol, Type}; - -pub(crate) fn generate_interfaces_prefix(protocol: &Protocol) -> TokenStream { - let longest_nulls = protocol.interfaces.iter().fold(0, |max, interface| { - let request_longest_null = interface.requests.iter().fold(0, |max, request| { - if request.all_null() { - cmp::max(request.args.len(), max) - } else { - max - } - }); - let events_longest_null = interface.events.iter().fold(0, |max, event| { - if event.all_null() { - cmp::max(event.args.len(), max) - } else { - max - } - }); - cmp::max(max, cmp::max(request_longest_null, events_longest_null)) - }); - - let types_null_len = Literal::usize_unsuffixed(longest_nulls); - - quote! { - use std::ptr::null; - struct SyncWrapper(T); - unsafe impl Sync for SyncWrapper {} - static types_null: SyncWrapper<[*const wayland_backend::protocol::wl_interface; #types_null_len]> = SyncWrapper([ - null::(); #types_null_len - ]); - } -} - -pub(crate) fn generate_interface(interface: &Interface) -> TokenStream { - let requests = gen_messages(interface, &interface.requests, "requests"); - let events = gen_messages(interface, &interface.events, "events"); - - let interface_ident = format_ident!("{}_interface", interface.name); - let name_value = null_terminated_byte_string_literal(&interface.name); - let version_value = Literal::i32_unsuffixed(interface.version as i32); - let request_count_value = Literal::i32_unsuffixed(interface.requests.len() as i32); - let requests_value = if interface.requests.is_empty() { - quote! { null::() } - } else { - let requests_ident = format_ident!("{}_requests", interface.name); - quote! { #requests_ident.0.as_ptr() } - }; - let event_count_value = Literal::i32_unsuffixed(interface.events.len() as i32); - let events_value = if interface.events.is_empty() { - quote! { null::() } - } else { - let events_ident = format_ident!("{}_events", interface.name); - quote! { #events_ident.0.as_ptr() } - }; - - quote! { - #requests - #events - - pub static #interface_ident: wayland_backend::protocol::wl_interface = wayland_backend::protocol::wl_interface { - name: #name_value as *const u8 as *const std::os::raw::c_char, - version: #version_value, - request_count: #request_count_value, - requests: #requests_value, - event_count: #event_count_value, - events: #events_value, - }; - } -} - -fn gen_messages(interface: &Interface, messages: &[Message], which: &str) -> TokenStream { - if messages.is_empty() { - return TokenStream::new(); - } - - let types_arrays = messages.iter().filter_map(|msg| { - if msg.all_null() { - None - } else { - let array_ident = format_ident!("{}_{}_{}_types", interface.name, which, msg.name); - let array_len = Literal::usize_unsuffixed(msg.args.len()); - let array_values = msg.args.iter().map(|arg| match (arg.typ, &arg.interface) { - (Type::Object, &Some(ref inter)) | (Type::NewId, &Some(ref inter)) => { - let interface_ident =format_ident!("{}_interface", inter); - quote! { &#interface_ident as *const wayland_backend::protocol::wl_interface } - } - _ => quote! { null::() }, - }); - - Some(quote! { - static #array_ident: SyncWrapper<[*const wayland_backend::protocol::wl_interface; #array_len]> = SyncWrapper([ - #(#array_values,)* - ]); - }) - } - }); - - let message_array_ident = format_ident!("{}_{}", interface.name, which); - let message_array_len = Literal::usize_unsuffixed(messages.len()); - let message_array_values = messages.iter().map(|msg| { - let name_value = null_terminated_byte_string_literal(&msg.name); - let signature_value = Literal::byte_string(&message_signature(msg)); - - let types_ident = if msg.all_null() { - format_ident!("types_null") - } else { - format_ident!("{}_{}_{}_types", interface.name, which, msg.name) - }; - - quote! { - wayland_backend::protocol::wl_message { - name: #name_value as *const u8 as *const std::os::raw::c_char, - signature: #signature_value as *const u8 as *const std::os::raw::c_char, - types: #types_ident.0.as_ptr(), - } - } - }); - - quote! { - #(#types_arrays)* - - static #message_array_ident: SyncWrapper<[wayland_backend::protocol::wl_message; #message_array_len]> = SyncWrapper([ - #(#message_array_values,)* - ]); - } -} - -fn message_signature(msg: &Message) -> Vec { - let mut res = Vec::new(); - - if msg.since > 1 { - res.extend_from_slice(msg.since.to_string().as_bytes()); - } - - for arg in &msg.args { - if arg.typ.nullable() && arg.allow_null { - res.push(b'?'); - } - match arg.typ { - Type::NewId => { - if arg.interface.is_none() { - res.extend_from_slice(b"su"); - } - res.push(b'n'); - } - Type::Uint => res.push(b'u'), - Type::Fixed => res.push(b'f'), - Type::String => res.push(b's'), - Type::Object => res.push(b'o'), - Type::Array => res.push(b'a'), - Type::Fd => res.push(b'h'), - Type::Int => res.push(b'i'), - _ => {} - } - } - - res.push(0); - res -} - -pub fn null_terminated_byte_string_literal(string: &str) -> Literal { - let mut val = Vec::with_capacity(string.len() + 1); - val.extend_from_slice(string.as_bytes()); - val.push(0); - - Literal::byte_string(&val) -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/src/client_gen.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/src/client_gen.rs deleted file mode 100644 index 2a32a11f9..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/src/client_gen.rs +++ /dev/null @@ -1,337 +0,0 @@ -use proc_macro2::{Ident, Span, TokenStream}; - -use quote::{format_ident, quote}; - -use crate::{ - protocol::{Interface, Protocol, Type}, - util::{description_to_doc_attr, dotted_to_relname, is_keyword, snake_to_camel, to_doc_attr}, - Side, -}; - -pub fn generate_client_objects(protocol: &Protocol) -> TokenStream { - protocol.interfaces.iter().map(generate_objects_for).collect() -} - -fn generate_objects_for(interface: &Interface) -> TokenStream { - let mod_name = Ident::new(&interface.name, Span::call_site()); - let mod_doc = interface.description.as_ref().map(description_to_doc_attr); - let iface_name = Ident::new(&snake_to_camel(&interface.name), Span::call_site()); - let iface_const_name = format_ident!("{}_INTERFACE", interface.name.to_ascii_uppercase()); - - let enums = crate::common::generate_enums_for(interface); - let sinces = crate::common::gen_msg_constants(&interface.requests, &interface.events); - - let requests = crate::common::gen_message_enum( - &format_ident!("Request"), - Side::Client, - false, - &interface.requests, - ); - let events = crate::common::gen_message_enum( - &format_ident!("Event"), - Side::Client, - true, - &interface.events, - ); - - let parse_body = crate::common::gen_parse_body(interface, Side::Client); - let write_body = crate::common::gen_write_body(interface, Side::Client); - let methods = gen_methods(interface); - - let event_ref = if interface.events.is_empty() { - "This interface has no events." - } else { - "See also the [Event] enum for this interface." - }; - let docs = match &interface.description { - Some((short, long)) => format!("{short}\n\n{long}\n\n{event_ref}"), - None => format!("{}\n\n{}", interface.name, event_ref), - }; - let doc_attr = to_doc_attr(&docs); - - quote! { - #mod_doc - pub mod #mod_name { - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - - use super::wayland_client::{ - backend::{ - Backend, WeakBackend, smallvec, ObjectData, ObjectId, InvalidId, - protocol::{WEnum, Argument, Message, Interface, same_interface} - }, - QueueProxyData, Proxy, Connection, Dispatch, QueueHandle, DispatchError, Weak, - }; - - #enums - #sinces - #requests - #events - - #doc_attr - #[derive(Debug, Clone)] - pub struct #iface_name { - id: ObjectId, - version: u32, - data: Option>, - backend: WeakBackend, - } - - impl std::cmp::PartialEq for #iface_name { - fn eq(&self, other: &#iface_name) -> bool { - self.id == other.id - } - } - - impl std::cmp::Eq for #iface_name {} - - impl PartialEq> for #iface_name { - fn eq(&self, other: &Weak<#iface_name>) -> bool { - self.id == other.id() - } - } - - impl std::borrow::Borrow for #iface_name { - fn borrow(&self) -> &ObjectId { - &self.id - } - } - - impl std::hash::Hash for #iface_name { - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - - impl super::wayland_client::Proxy for #iface_name { - type Request<'request> = Request<'request>; - type Event = Event; - - #[inline] - fn interface() -> &'static Interface{ - &super::#iface_const_name - } - - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - - #[inline] - fn version(&self) -> u32 { - self.version - } - - #[inline] - fn data(&self) -> Option<&U> { - self.data.as_ref().and_then(|arc| arc.data_as_any().downcast_ref::()) - } - - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - - fn backend(&self) -> &WeakBackend { - &self.backend - } - - fn send_request(&self, req: Self::Request<'_>) -> Result<(), InvalidId> { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, None)?; - debug_assert!(id.is_null()); - Ok(()) - } - - fn send_constructor(&self, req: Self::Request<'_>, data: Arc) -> Result { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, Some(data))?; - Proxy::from_id(&conn, id) - } - - #[inline] - fn from_id(conn: &Connection, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - let backend = conn.backend().downgrade(); - Ok(#iface_name { id, data, version, backend }) - } - - #[inline] - fn inert(backend: WeakBackend) -> Self { - #iface_name { id: ObjectId::null(), data: None, version: 0, backend } - } - - fn parse_event(conn: &Connection, msg: Message) -> Result<(Self, Self::Event), DispatchError> { - #parse_body - } - - fn write_request<'a>(&self, conn: &Connection, msg: Self::Request<'a>) -> Result<(Message>, Option<(&'static Interface, u32)>), InvalidId> { - #write_body - } - } - - impl #iface_name { - #methods - } - } - } -} - -fn gen_methods(interface: &Interface) -> TokenStream { - interface.requests.iter().map(|request| { - let created_interface = request.args.iter().find(|arg| arg.typ == Type::NewId).map(|arg| &arg.interface); - - let method_name = format_ident!("{}{}", if is_keyword(&request.name) { "_" } else { "" }, request.name); - let enum_variant = Ident::new(&snake_to_camel(&request.name), Span::call_site()); - - let fn_args = request.args.iter().flat_map(|arg| { - if arg.typ == Type::NewId { - if arg.interface.is_none() { - // the new_id argument of a bind-like method - // it shoudl expand as a (interface, type) tuple, but the type is already handled by - // the prototype type parameter, so just put a version here - return Some(quote! { version: u32 }); - } else { - // this is a regular new_id, skip it - return None; - } - } - - let arg_name = format_ident!("{}{}", if is_keyword(&arg.name) { "_" } else { "" }, arg.name); - - let arg_type = if let Some(ref enu) = arg.enum_ { - let enum_type = dotted_to_relname(enu); - quote! { #enum_type } - } else { - match arg.typ { - Type::Uint => quote! { u32 }, - Type::Int => quote! { i32 }, - Type::Fixed => quote! { f64 }, - Type::String => if arg.allow_null { quote!{ Option } } else { quote!{ String } }, - Type::Array => if arg.allow_null { quote!{ Option> } } else { quote!{ Vec } }, - Type::Fd => quote! { ::std::os::unix::io::BorrowedFd<'_> }, - Type::Object => { - let iface = arg.interface.as_ref().unwrap(); - let iface_mod = Ident::new(iface, Span::call_site()); - let iface_type = - Ident::new(&snake_to_camel(iface), Span::call_site()); - if arg.allow_null { quote! { Option<&super::#iface_mod::#iface_type> } } else { quote! { &super::#iface_mod::#iface_type } } - }, - Type::NewId => unreachable!(), - Type::Destructor => panic!("An argument cannot have type \"destructor\"."), - } - }; - - Some(quote! { - #arg_name: #arg_type - }) - }); - - let enum_args = request.args.iter().flat_map(|arg| { - let arg_name = format_ident!("{}{}", if is_keyword(&arg.name) { "_" } else { "" }, arg.name); - if arg.enum_.is_some() { - Some(quote! { #arg_name: WEnum::Value(#arg_name) }) - } else if arg.typ == Type::NewId { - if arg.interface.is_none() { - Some(quote! { #arg_name: (I::interface(), version) }) - } else { - None - } - } else if arg.typ == Type::Object { - if arg.allow_null { - Some(quote! { #arg_name: #arg_name.cloned() }) - } else { - Some(quote! { #arg_name: #arg_name.clone() }) - } - } else { - Some(quote! { #arg_name }) - } - }); - - let doc_attr = request - .description - .as_ref() - .map(description_to_doc_attr); - - match created_interface { - Some(Some(ref created_interface)) => { - // a regular creating request - let created_iface_mod = Ident::new(created_interface, Span::call_site()); - let created_iface_type = Ident::new(&snake_to_camel(created_interface), Span::call_site()); - quote! { - #doc_attr - #[allow(clippy::too_many_arguments)] - pub fn #method_name + 'static>(&self, #(#fn_args,)* qh: &QueueHandle, udata: U) -> super::#created_iface_mod::#created_iface_type { - self.send_constructor( - Request::#enum_variant { - #(#enum_args),* - }, - qh.make_data::(udata), - ).unwrap_or_else(|_| Proxy::inert(self.backend.clone())) - } - } - }, - Some(None) => { - // a bind-like request - quote! { - #doc_attr - #[allow(clippy::too_many_arguments)] - pub fn #method_name + 'static>(&self, #(#fn_args,)* qh: &QueueHandle, udata: U) -> I { - self.send_constructor( - Request::#enum_variant { - #(#enum_args),* - }, - qh.make_data::(udata), - ).unwrap_or_else(|_| Proxy::inert(self.backend.clone())) - } - } - }, - None => { - // a non-creating request - quote! { - #doc_attr - #[allow(clippy::too_many_arguments)] - pub fn #method_name(&self, #(#fn_args),*) { - let backend = match self.backend.upgrade() { - Some(b) => b, - None => return, - }; - let conn = Connection::from_backend(backend); - let _ = conn.send_request( - self, - Request::#enum_variant { - #(#enum_args),* - }, - None - ); - } - } - } - } - }).collect() -} - -#[cfg(test)] -mod tests { - #[test] - fn client_gen() { - let protocol_file = - std::fs::File::open("./tests/scanner_assets/test-protocol.xml").unwrap(); - let protocol_parsed = crate::parse::parse(protocol_file); - let generated: String = super::generate_client_objects(&protocol_parsed).to_string(); - let generated = crate::format_rust_code(&generated); - - let reference = - std::fs::read_to_string("./tests/scanner_assets/test-client-code.rs").unwrap(); - let reference = crate::format_rust_code(&reference); - - if reference != generated { - let diff = similar::TextDiff::from_lines(&reference, &generated); - print!("{}", diff.unified_diff().context_radius(10).header("reference", "generated")); - panic!("Generated does not match reference!") - } - } -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/src/common.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/src/common.rs deleted file mode 100644 index f2e07b3b5..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/src/common.rs +++ /dev/null @@ -1,631 +0,0 @@ -use std::fmt::Write; - -use proc_macro2::{Ident, Literal, Span, TokenStream}; - -use quote::{format_ident, quote, ToTokens}; - -use crate::{protocol::*, util::*, Side}; - -pub(crate) fn generate_enums_for(interface: &Interface) -> TokenStream { - interface.enums.iter().map(ToTokens::into_token_stream).collect() -} - -impl ToTokens for Enum { - fn to_tokens(&self, tokens: &mut TokenStream) { - let enum_decl; - let enum_impl; - - let doc_attr = self.description.as_ref().map(description_to_doc_attr); - let ident = Ident::new(&snake_to_camel(&self.name), Span::call_site()); - - if self.bitfield { - let entries = self.entries.iter().map(|entry| { - let doc_attr = entry - .description - .as_ref() - .map(description_to_doc_attr) - .or_else(|| entry.summary.as_ref().map(|s| to_doc_attr(s))); - - let prefix = if entry.name.chars().next().unwrap().is_numeric() { "_" } else { "" }; - let ident = format_ident!("{}{}", prefix, snake_to_camel(&entry.name)); - - let value = Literal::u32_unsuffixed(entry.value); - - quote! { - #doc_attr - const #ident = #value; - } - }); - - enum_decl = quote! { - bitflags::bitflags! { - #doc_attr - #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] - pub struct #ident: u32 { - #(#entries)* - } - } - }; - enum_impl = quote! { - impl std::convert::TryFrom for #ident { - type Error = (); - fn try_from(val: u32) -> Result<#ident, ()> { - #ident::from_bits(val).ok_or(()) - } - } - impl std::convert::From<#ident> for u32 { - fn from(val: #ident) -> u32 { - val.bits() - } - } - }; - } else { - let variants = self.entries.iter().map(|entry| { - let doc_attr = entry - .description - .as_ref() - .map(description_to_doc_attr) - .or_else(|| entry.summary.as_ref().map(|s| to_doc_attr(s))); - - let prefix = if entry.name.chars().next().unwrap().is_numeric() { "_" } else { "" }; - let variant = format_ident!("{}{}", prefix, snake_to_camel(&entry.name)); - - let value = Literal::u32_unsuffixed(entry.value); - - quote! { - #doc_attr - #variant = #value - } - }); - - enum_decl = quote! { - #doc_attr - #[repr(u32)] - #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] - #[non_exhaustive] - pub enum #ident { - #(#variants,)* - } - }; - - let match_arms = self.entries.iter().map(|entry| { - let value = Literal::u32_unsuffixed(entry.value); - - let prefix = if entry.name.chars().next().unwrap().is_numeric() { "_" } else { "" }; - let variant = format_ident!("{}{}", prefix, snake_to_camel(&entry.name)); - - quote! { - #value => Ok(#ident::#variant) - } - }); - - enum_impl = quote! { - impl std::convert::TryFrom for #ident { - type Error = (); - fn try_from(val: u32) -> Result<#ident, ()> { - match val { - #(#match_arms,)* - _ => Err(()) - } - } - } - impl std::convert::From<#ident> for u32 { - fn from(val: #ident) -> u32 { - val as u32 - } - } - }; - } - - enum_decl.to_tokens(tokens); - enum_impl.to_tokens(tokens); - } -} - -pub(crate) fn gen_msg_constants(requests: &[Message], events: &[Message]) -> TokenStream { - let req_constants = requests.iter().enumerate().map(|(opcode, msg)| { - let since_cstname = format_ident!("REQ_{}_SINCE", msg.name.to_ascii_uppercase()); - let opcode_cstname = format_ident!("REQ_{}_OPCODE", msg.name.to_ascii_uppercase()); - let since = msg.since; - let opcode = opcode as u16; - quote! { - /// The minimal object version supporting this request - pub const #since_cstname: u32 = #since; - /// The wire opcode for this request - pub const #opcode_cstname: u16 = #opcode; - } - }); - let evt_constants = events.iter().enumerate().map(|(opcode, msg)| { - let since_cstname = format_ident!("EVT_{}_SINCE", msg.name.to_ascii_uppercase()); - let opcode_cstname = format_ident!("EVT_{}_OPCODE", msg.name.to_ascii_uppercase()); - let since = msg.since; - let opcode = opcode as u16; - quote! { - /// The minimal object version supporting this event - pub const #since_cstname: u32 = #since; - /// The wire opcode for this event - pub const #opcode_cstname: u16 = #opcode; - } - }); - - quote! { - #(#req_constants)* - #(#evt_constants)* - } -} - -pub(crate) fn gen_message_enum( - name: &Ident, - side: Side, - receiver: bool, - messages: &[Message], -) -> TokenStream { - let variants = messages - .iter() - .map(|msg| { - let mut docs = String::new(); - if let Some((ref short, ref long)) = msg.description { - write!(docs, "{}\n\n{}\n", short, long.trim()).unwrap(); - } - if let Some(Type::Destructor) = msg.typ { - write!( - docs, - "\nThis is a destructor, once {} this object cannot be used any longer.", - if receiver { "received" } else { "sent" }, - ) - .unwrap() - } - if msg.since > 1 { - write!(docs, "\nOnly available since version {} of the interface", msg.since) - .unwrap(); - } - - let doc_attr = to_doc_attr(&docs); - let msg_name = Ident::new(&snake_to_camel(&msg.name), Span::call_site()); - let msg_variant_decl = - if msg.args.is_empty() { - msg_name.into_token_stream() - } else { - let fields = msg.args.iter().flat_map(|arg| { - let field_name = - format_ident!("{}{}", if is_keyword(&arg.name) { "_" } else { "" }, arg.name); - let field_type_inner = if let Some(ref enu) = arg.enum_ { - let enum_type = dotted_to_relname(enu); - quote! { WEnum<#enum_type> } - } else { - match arg.typ { - Type::Uint => quote! { u32 }, - Type::Int => quote! { i32 }, - Type::Fixed => quote! { f64 }, - Type::String => quote! { String }, - Type::Array => quote! { Vec }, - Type::Fd => { - if receiver { - quote! { OwnedFd } - } else { - quote! { std::os::unix::io::BorrowedFd<'a> } - } - } - Type::Object => { - if let Some(ref iface) = arg.interface { - let iface_mod = Ident::new(iface, Span::call_site()); - let iface_type = - Ident::new(&snake_to_camel(iface), Span::call_site()); - quote! { super::#iface_mod::#iface_type } - } else if side == Side::Client { - quote! { super::wayland_client::ObjectId } - } else { - quote! { super::wayland_server::ObjectId } - } - } - Type::NewId if !receiver && side == Side::Client => { - // Client-side sending does not have a pre-existing object - // so skip serializing it - if arg.interface.is_some() { - return None; - } else { - quote! { (&'static Interface, u32) } - } - } - Type::NewId => { - if let Some(ref iface) = arg.interface { - let iface_mod = Ident::new(iface, Span::call_site()); - let iface_type = - Ident::new(&snake_to_camel(iface), Span::call_site()); - if receiver && side == Side::Server { - quote! { New } - } else { - quote! { super::#iface_mod::#iface_type } - } - } else { - // bind-like function - if side == Side::Client { - quote! { (String, u32, super::wayland_client::ObjectId) } - } else { - quote! { (String, u32, super::wayland_server::ObjectId) } - } - } - } - Type::Destructor => panic!("An argument cannot have type \"destructor\"."), - } - }; - - let field_type = if arg.allow_null { - quote! { Option<#field_type_inner> } - } else { - field_type_inner.into_token_stream() - }; - - let doc_attr = arg - .description - .as_ref() - .map(description_to_doc_attr) - .or_else(|| arg.summary.as_ref().map(|s| to_doc_attr(s))); - - Some(quote! { - #doc_attr - #field_name: #field_type - }) - }); - - quote! { - #msg_name { - #(#fields,)* - } - } - }; - - quote! { - #doc_attr - #msg_variant_decl - } - }) - .collect::>(); - - let opcodes = messages.iter().enumerate().map(|(opcode, msg)| { - let msg_name = Ident::new(&snake_to_camel(&msg.name), Span::call_site()); - let opcode = opcode as u16; - if msg.args.is_empty() { - quote! { - #name::#msg_name => #opcode - } - } else { - quote! { - #name::#msg_name { .. } => #opcode - } - } - }); - - // Placeholder to allow generic argument to be added later, without ABI - // break. - // TODO Use never type. - let (generic, phantom_variant, phantom_case) = if !receiver { - ( - quote! { 'a }, - quote! { #[doc(hidden)] __phantom_lifetime { phantom: std::marker::PhantomData<&'a ()>, never: std::convert::Infallible } }, - quote! { #name::__phantom_lifetime { never, .. } => match never {} }, - ) - } else { - (quote! {}, quote! {}, quote! {}) - }; - - quote! { - #[derive(Debug)] - #[non_exhaustive] - pub enum #name<#generic> { - #(#variants,)* - #phantom_variant - } - - impl<#generic> #name<#generic> { - #[doc="Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - #(#opcodes,)* - #phantom_case - } - } - } - } -} - -pub(crate) fn gen_parse_body(interface: &Interface, side: Side) -> TokenStream { - let msgs = match side { - Side::Client => &interface.events, - Side::Server => &interface.requests, - }; - let object_type = Ident::new( - match side { - Side::Client => "Proxy", - Side::Server => "Resource", - }, - Span::call_site(), - ); - let msg_type = Ident::new( - match side { - Side::Client => "Event", - Side::Server => "Request", - }, - Span::call_site(), - ); - - let match_arms = msgs.iter().enumerate().map(|(opcode, msg)| { - let opcode = opcode as u16; - let msg_name = Ident::new(&snake_to_camel(&msg.name), Span::call_site()); - let args_pat = msg.args.iter().map(|arg| { - let arg_name = Ident::new( - &format!("{}{}", if is_keyword(&arg.name) { "_" } else { "" }, arg.name), - Span::call_site(), - ); - match arg.typ { - Type::Uint => quote!{ Some(Argument::Uint(#arg_name)) }, - Type::Int => quote!{ Some(Argument::Int(#arg_name)) }, - Type::String => quote!{ Some(Argument::Str(#arg_name)) }, - Type::Fixed => quote!{ Some(Argument::Fixed(#arg_name)) }, - Type::Array => quote!{ Some(Argument::Array(#arg_name)) }, - Type::Object => quote!{ Some(Argument::Object(#arg_name)) }, - Type::NewId => quote!{ Some(Argument::NewId(#arg_name)) }, - Type::Fd => quote!{ Some(Argument::Fd(#arg_name)) }, - Type::Destructor => panic!("Argument {}.{}.{} has type destructor ?!", interface.name, msg.name, arg.name), - } - }); - - let args_iter = msg.args.iter().map(|_| quote!{ arg_iter.next() }); - - let arg_names = msg.args.iter().map(|arg| { - let arg_name = format_ident!("{}{}", if is_keyword(&arg.name) { "_" } else { "" }, arg.name); - if arg.enum_.is_some() { - quote! { #arg_name: From::from(#arg_name as u32) } - } else { - match arg.typ { - Type::Uint | Type::Int | Type::Fd => quote!{ #arg_name }, - Type::Fixed => quote!{ #arg_name: (#arg_name as f64) / 256.}, - Type::String => { - if arg.allow_null { - quote! { - #arg_name: #arg_name.as_ref().map(|s| String::from_utf8_lossy(s.as_bytes()).into_owned()) - } - } else { - quote! { - #arg_name: String::from_utf8_lossy(#arg_name.as_ref().unwrap().as_bytes()).into_owned() - } - } - }, - Type::Object => { - let create_proxy = if let Some(ref created_interface) = arg.interface { - let created_iface_mod = Ident::new(created_interface, Span::call_site()); - let created_iface_type = Ident::new(&snake_to_camel(created_interface), Span::call_site()); - quote! { - match ::from_id(conn, #arg_name.clone()) { - Ok(p) => p, - Err(_) => return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode - }), - } - } - } else { - quote! { #arg_name.clone() } - }; - if arg.allow_null { - quote! { - #arg_name: if #arg_name.is_null() { None } else { Some(#create_proxy) } - } - } else { - quote! { - #arg_name: #create_proxy - } - } - }, - Type::NewId => { - let create_proxy = if let Some(ref created_interface) = arg.interface { - let created_iface_mod = Ident::new(created_interface, Span::call_site()); - let created_iface_type = Ident::new(&snake_to_camel(created_interface), Span::call_site()); - quote! { - match ::from_id(conn, #arg_name.clone()) { - Ok(p) => p, - Err(_) => return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - } else if side == Side::Server { - quote! { New::wrap(#arg_name.clone()) } - } else { - quote! { #arg_name.clone() } - }; - if arg.allow_null { - if side == Side::Server { - quote! { - #arg_name: if #arg_name.is_null() { None } else { Some(New::wrap(#create_proxy)) } - } - } else { - quote! { - #arg_name: if #arg_name.is_null() { None } else { Some(#create_proxy) } - } - } - } else if side == Side::Server { - quote! { - #arg_name: New::wrap(#create_proxy) - } - } else { - quote! { - #arg_name: #create_proxy - } - } - }, - Type::Array => { - if arg.allow_null { - quote! { if #arg_name.len() == 0 { None } else { Some(*#arg_name) } } - } else { - quote! { #arg_name: *#arg_name } - } - }, - Type::Destructor => unreachable!(), - } - } - }); - - quote! { - #opcode => { - if let (#(#args_pat),*) = (#(#args_iter),*) { - Ok((me, #msg_type::#msg_name { #(#arg_names),* })) - } else { - Err(DispatchError::BadMessage { sender_id: msg.sender_id, interface: Self::interface().name, opcode: msg.opcode }) - } - } - } - }); - - quote! { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - #(#match_arms),* - _ => Err(DispatchError::BadMessage { sender_id: msg.sender_id, interface: Self::interface().name, opcode: msg.opcode }), - } - } -} - -pub(crate) fn gen_write_body(interface: &Interface, side: Side) -> TokenStream { - let msgs = match side { - Side::Client => &interface.requests, - Side::Server => &interface.events, - }; - let msg_type = Ident::new( - match side { - Side::Client => "Request", - Side::Server => "Event", - }, - Span::call_site(), - ); - let arms = msgs.iter().enumerate().map(|(opcode, msg)| { - let msg_name = Ident::new(&snake_to_camel(&msg.name), Span::call_site()); - let opcode = opcode as u16; - let arg_names = msg.args.iter().flat_map(|arg| { - if arg.typ == Type::NewId && arg.interface.is_some() && side == Side::Client { - None - } else { - Some(format_ident!("{}{}", if is_keyword(&arg.name) { "_" } else { "" }, arg.name)) - } - }); - let mut child_spec = None; - let args = msg.args.iter().flat_map(|arg| { - let arg_name = format_ident!("{}{}", if is_keyword(&arg.name) { "_" } else { "" }, arg.name); - - match arg.typ { - Type::Int => vec![if arg.enum_.is_some() { quote!{ Argument::Int(Into::::into(#arg_name) as i32) } } else { quote!{ Argument::Int(#arg_name) } }], - Type::Uint => vec![if arg.enum_.is_some() { quote!{ Argument::Uint(#arg_name.into()) } } else { quote!{ Argument::Uint(#arg_name) } }], - Type::Fd => vec![quote!{ Argument::Fd(#arg_name) }], - Type::Fixed => vec![quote! { Argument::Fixed((#arg_name * 256.) as i32) }], - Type::Object => if arg.allow_null { - if side == Side::Server { - vec![quote! { if let Some(obj) = #arg_name { Argument::Object(Resource::id(&obj)) } else { Argument::Object(ObjectId::null()) } }] - } else { - vec![quote! { if let Some(obj) = #arg_name { Argument::Object(Proxy::id(&obj)) } else { Argument::Object(ObjectId::null()) } }] - } - } else if side == Side::Server { - vec![quote!{ Argument::Object(Resource::id(&#arg_name)) }] - } else { - vec![quote!{ Argument::Object(Proxy::id(&#arg_name)) }] - }, - Type::Array => if arg.allow_null { - vec![quote! { if let Some(array) = #arg_name { Argument::Array(Box::new(array)) } else { Argument::Array(Box::new(Vec::new()))}}] - } else { - vec![quote! { Argument::Array(Box::new(#arg_name)) }] - }, - Type::String => if arg.allow_null { - vec![quote! { Argument::Str(#arg_name.map(|s| Box::new(std::ffi::CString::new(s).unwrap()))) }] - } else { - vec![quote! { Argument::Str(Some(Box::new(std::ffi::CString::new(#arg_name).unwrap()))) }] - }, - Type::NewId => if side == Side::Client { - if let Some(ref created_interface) = arg.interface { - let created_iface_mod = Ident::new(created_interface, Span::call_site()); - let created_iface_type = Ident::new(&snake_to_camel(created_interface), Span::call_site()); - assert!(child_spec.is_none()); - child_spec = Some(quote! { { - let my_info = conn.object_info(self.id())?; - Some((super::#created_iface_mod::#created_iface_type::interface(), my_info.version)) - } }); - vec![quote! { Argument::NewId(ObjectId::null()) }] - } else { - assert!(child_spec.is_none()); - child_spec = Some(quote! { - Some((#arg_name.0, #arg_name.1)) - }); - vec![ - quote! { - Argument::Str(Some(Box::new(std::ffi::CString::new(#arg_name.0.name).unwrap()))) - }, - quote! { - Argument::Uint(#arg_name.1) - }, - quote! { - Argument::NewId(ObjectId::null()) - }, - ] - } - } else { - // server-side NewId is the same as Object - if arg.allow_null { - vec![quote! { if let Some(obj) = #arg_name { Argument::NewId(Resource::id(&obj)) } else { Argument::NewId(ObjectId::null()) } }] - } else { - vec![quote!{ Argument::NewId(Resource::id(&#arg_name)) }] - } - }, - Type::Destructor => panic!("Argument {}.{}.{} has type destructor ?!", interface.name, msg.name, arg.name), - } - }); - let args = if msg.args.is_empty() { - quote! { - smallvec::SmallVec::new() - } - } else if msg.args.len() <= 4 { - // Note: Keep in sync with `wayland_backend::protocol::INLINE_ARGS`. - // Fits in SmallVec inline capacity - quote! { { - let mut vec = smallvec::SmallVec::new(); - #( - vec.push(#args); - )* - vec - } } - } else { - quote! { - smallvec::SmallVec::from_vec(vec![#(#args),*]) - } - }; - if side == Side::Client { - let child_spec = child_spec.unwrap_or_else(|| quote! { None }); - quote! { - #msg_type::#msg_name { #(#arg_names),* } => { - let child_spec = #child_spec; - let args = #args; - Ok((Message { - sender_id: self.id.clone(), - opcode: #opcode, - args - }, child_spec)) - } - } - } else { - quote! { - #msg_type::#msg_name { #(#arg_names),* } => Ok(Message { - sender_id: self.id.clone(), - opcode: #opcode, - args: #args, - }) - } - } - }); - quote! { - match msg { - #(#arms,)* - #msg_type::__phantom_lifetime { never, .. } => match never {} - } - } -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/src/interfaces.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/src/interfaces.rs deleted file mode 100644 index 1545824b2..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/src/interfaces.rs +++ /dev/null @@ -1,142 +0,0 @@ -use proc_macro2::TokenStream; - -use crate::protocol::{Interface, Message, Protocol, Type}; - -use quote::{format_ident, quote}; - -pub fn generate(protocol: &Protocol, with_c_interfaces: bool) -> TokenStream { - let interfaces = - protocol.interfaces.iter().map(|iface| generate_interface(iface, with_c_interfaces)); - if with_c_interfaces { - let prefix = super::c_interfaces::generate_interfaces_prefix(protocol); - quote! { - #prefix - #(#interfaces)* - } - } else { - interfaces.collect() - } -} - -pub(crate) fn generate_interface(interface: &Interface, with_c: bool) -> TokenStream { - let const_name = format_ident!("{}_INTERFACE", interface.name.to_ascii_uppercase()); - let iface_name = &interface.name; - let iface_version = interface.version; - let requests = build_messagedesc_list(&interface.requests); - let events = build_messagedesc_list(&interface.events); - - let c_name = format_ident!("{}_interface", interface.name); - - if with_c { - let c_iface = super::c_interfaces::generate_interface(interface); - quote! { - pub static #const_name: wayland_backend::protocol::Interface = wayland_backend::protocol::Interface { - name: #iface_name, - version: #iface_version, - requests: #requests, - events: #events, - c_ptr: Some(unsafe { & #c_name }), - }; - - #c_iface - } - } else { - quote! { - pub static #const_name: wayland_backend::protocol::Interface = wayland_backend::protocol::Interface { - name: #iface_name, - version: #iface_version, - requests: #requests, - events: #events, - c_ptr: None, - }; - } - } -} - -fn build_messagedesc_list(list: &[Message]) -> TokenStream { - let desc_list = list.iter().map(|message| { - let name = &message.name; - let since = message.since; - let is_destructor = message.typ == Some(Type::Destructor); - let signature = message.args.iter().map(|arg| { - if arg.typ == Type::NewId && arg.interface.is_none() { - // this is a special generic message, it expands to multiple arguments - quote! { - wayland_backend::protocol::ArgumentType::Str(wayland_backend::protocol::AllowNull::No), - wayland_backend::protocol::ArgumentType::Uint, - wayland_backend::protocol::ArgumentType::NewId - } - } else { - let typ = arg.typ.common_type(); - if arg.typ.nullable() { - if arg.allow_null { - quote! { wayland_backend::protocol::ArgumentType::#typ(wayland_backend::protocol::AllowNull::Yes) } - } else { - quote! { wayland_backend::protocol::ArgumentType::#typ(wayland_backend::protocol::AllowNull::No) } - } - } else { - quote! { wayland_backend::protocol::ArgumentType::#typ } - } - } - }); - let child_interface = match message - .args - .iter() - .find(|arg| arg.typ == Type::NewId) - .and_then(|arg| arg.interface.as_ref()) - { - Some(name) => { - let target_iface = format_ident!("{}_INTERFACE", name.to_ascii_uppercase()); - quote! { Some(&#target_iface) } - } - None => quote! { None }, - }; - let arg_interfaces = message.args.iter().filter(|arg| arg.typ == Type::Object).map(|arg| { - match arg.interface { - Some(ref name) => { - let target_iface = format_ident!("{}_INTERFACE", name.to_ascii_uppercase()); - quote! { &#target_iface } - } - None => { - quote! { &wayland_backend::protocol::ANONYMOUS_INTERFACE } - } - } - }); - quote! { - wayland_backend::protocol::MessageDesc { - name: #name, - signature: &[ #(#signature),* ], - since: #since, - is_destructor: #is_destructor, - child_interface: #child_interface, - arg_interfaces: &[ #(#arg_interfaces),* ], - } - } - }); - - quote!( - &[ #(#desc_list),* ] - ) -} - -#[cfg(test)] -mod tests { - #[test] - fn interface_gen() { - let protocol_file = - std::fs::File::open("./tests/scanner_assets/test-protocol.xml").unwrap(); - let protocol_parsed = crate::parse::parse(protocol_file); - let generated: String = super::generate(&protocol_parsed, true).to_string(); - let generated = crate::format_rust_code(&generated); - - let reference = - std::fs::read_to_string("./tests/scanner_assets/test-interfaces.rs").unwrap(); - let reference = crate::format_rust_code(&reference); - - if reference != generated { - let diff = similar::TextDiff::from_lines(&reference, &generated); - print!("{}", diff.unified_diff().context_radius(10).header("reference", "generated")); - panic!("Generated does not match reference!") - } - } -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/src/lib.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/src/lib.rs deleted file mode 100644 index ed03efa01..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/src/lib.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! Wayland protocol code-generation machinnery -//! -//! This crate provides procedural macros for generating the rust code associated with a -//! Wayland XML protocol specification, for use with the `wayland-client`, `wayland-server` -//! and `wayland-backend` crates. -//! -//! Before trying to use this crate, you may check if the protocol extension you want to use -//! is not already exposed in the `wayland-protocols` crate. -//! -//! ## Example usage -//! -//! Below is a template for generating the code for a custom protocol client-side. Server-side -//! is identical, just replacing `client` by `server`. The path to the XML file is relative to the -//! crate root. -//! -//! ```rust,ignore -//! // Generate the bindings in their own module -//! pub mod my_protocol { -//! use wayland_client; -//! // import objects from the core protocol if needed -//! use wayland_client::protocol::*; -//! -//! // This module hosts a low-level representation of the protocol objects -//! // you will not need to interact with it yourself, but the code generated -//! // by the generate_client_code! macro will use it -//! pub mod __interfaces { -//! // import the interfaces from the core protocol if needed -//! use wayland_client::protocol::__interfaces::*; -//! wayland_scanner::generate_interfaces!("./path/to/the/protocol.xml"); -//! } -//! use self::__interfaces::*; -//! -//! // This macro generates the actual types that represent the wayland objects of -//! // your custom protocol -//! wayland_scanner::generate_client_code!("./path/to/the/protocol.xml"); -//! } -//! ``` - -use std::{ffi::OsString, path::PathBuf}; - -mod c_interfaces; -mod client_gen; -mod common; -mod interfaces; -mod parse; -mod protocol; -mod server_gen; -mod token; -mod util; - -/// Proc-macro for generating low-level interfaces associated with an XML specification -#[proc_macro] -pub fn generate_interfaces(stream: proc_macro::TokenStream) -> proc_macro::TokenStream { - let path: OsString = token::parse_lit_str_token(stream).into(); - let path = if let Some(manifest_dir) = std::env::var_os("CARGO_MANIFEST_DIR") { - let mut buf = PathBuf::from(manifest_dir); - buf.push(path); - buf - } else { - path.into() - }; - let file = match std::fs::File::open(&path) { - Ok(file) => file, - Err(e) => panic!("Failed to open protocol file {}: {}", path.display(), e), - }; - let protocol = parse::parse(file); - interfaces::generate(&protocol, true).into() -} - -/// Proc-macro for generating client-side API associated with an XML specification -#[proc_macro] -pub fn generate_client_code(stream: proc_macro::TokenStream) -> proc_macro::TokenStream { - let path: OsString = token::parse_lit_str_token(stream).into(); - let path = if let Some(manifest_dir) = std::env::var_os("CARGO_MANIFEST_DIR") { - let mut buf = PathBuf::from(manifest_dir); - buf.push(path); - buf - } else { - path.into() - }; - let file = match std::fs::File::open(&path) { - Ok(file) => file, - Err(e) => panic!("Failed to open protocol file {}: {}", path.display(), e), - }; - let protocol = parse::parse(file); - client_gen::generate_client_objects(&protocol).into() -} - -/// Proc-macro for generating server-side API associated with an XML specification -#[proc_macro] -pub fn generate_server_code(stream: proc_macro::TokenStream) -> proc_macro::TokenStream { - let path: OsString = token::parse_lit_str_token(stream).into(); - let path = if let Some(manifest_dir) = std::env::var_os("CARGO_MANIFEST_DIR") { - let mut buf = PathBuf::from(manifest_dir); - buf.push(path); - buf - } else { - path.into() - }; - let file = match std::fs::File::open(&path) { - Ok(file) => file, - Err(e) => panic!("Failed to open protocol file {}: {}", path.display(), e), - }; - let protocol = parse::parse(file); - server_gen::generate_server_objects(&protocol).into() -} - -#[cfg(test)] -fn format_rust_code(code: &str) -> String { - use std::{ - io::Write, - process::{Command, Stdio}, - }; - if let Ok(mut proc) = Command::new("rustfmt") - .arg("--emit=stdout") - .arg("--edition=2018") - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - //.stderr(Stdio::null()) - .spawn() - { - { - let stdin = proc.stdin.as_mut().unwrap(); - stdin.write_all(code.as_bytes()).unwrap(); - } - if let Ok(output) = proc.wait_with_output() { - if output.status.success() { - return std::str::from_utf8(&output.stdout).unwrap().to_owned(); - } - } - } - panic!("Rustfmt failed!"); -} - -#[derive(Copy, Clone, PartialEq, Eq, Debug)] -enum Side { - /// wayland client applications - Client, - /// wayland compositors - Server, -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/src/parse.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/src/parse.rs deleted file mode 100644 index 47bb89770..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/src/parse.rs +++ /dev/null @@ -1,411 +0,0 @@ -use super::protocol::*; -use std::{ - io::{BufRead, BufReader, Read}, - str::FromStr, -}; - -use quick_xml::{ - events::{attributes::Attributes, Event}, - Reader, -}; - -pub fn parse(stream: S) -> Protocol { - let mut reader = Reader::from_reader(BufReader::new(stream)); - let reader_config = reader.config_mut(); - reader_config.trim_text(true); - reader_config.expand_empty_elements = true; - parse_protocol(reader) -} - -fn decode_utf8_or_panic(txt: Vec) -> String { - match String::from_utf8(txt) { - Ok(txt) => txt, - Err(e) => panic!("Invalid UTF8: '{}'", String::from_utf8_lossy(&e.into_bytes())), - } -} - -fn parse_or_panic(txt: &[u8]) -> T { - match std::str::from_utf8(txt).ok().and_then(|val| val.parse().ok()) { - Some(version) => version, - None => panic!( - "Invalid value '{}' for parsing type '{}'", - String::from_utf8_lossy(txt), - std::any::type_name::() - ), - } -} - -fn init_protocol(reader: &mut Reader) -> Protocol { - // Check two firsts lines for protocol tag - for _ in 0..3 { - match reader.read_event_into(&mut Vec::new()) { - Ok(Event::Decl(_) | Event::DocType(_) | Event::Comment(_)) => { - continue; - } - Ok(Event::Start(bytes)) => { - assert!(bytes.name().into_inner() == b"protocol", "Missing protocol toplevel tag"); - if let Some(attr) = bytes - .attributes() - .filter_map(|res| res.ok()) - .find(|attr| attr.key.into_inner() == b"name") - { - return Protocol::new(decode_utf8_or_panic(attr.value.into_owned())); - } else { - panic!("Protocol must have a name"); - } - } - _ => panic!("Ill-formed protocol file"), - } - } - panic!("Ill-formed protocol file"); -} - -fn parse_protocol(mut reader: Reader) -> Protocol { - let mut protocol = init_protocol(&mut reader); - - loop { - match reader.read_event_into(&mut Vec::new()) { - Ok(Event::Start(bytes)) => { - match bytes.name().into_inner() { - b"copyright" => { - // parse the copyright - let mut copyright = String::new(); - loop { - match reader.read_event_into(&mut Vec::new()) { - Ok(Event::Text(text)) => { - if let Ok(text) = text.decode() { - copyright.push_str(&text); - } - } - Ok(Event::CData(cdata)) => { - if let Ok(cdata) = String::from_utf8(cdata.into_inner().into()) - { - copyright.push_str(&cdata); - } - } - Ok(Event::GeneralRef(byte_ref)) => { - if let Ok(Some(c)) = byte_ref.resolve_char_ref() { - copyright.push(c); - } else if let Ok(content) = byte_ref.xml10_content() { - if let Some(s) = - quick_xml::escape::resolve_xml_entity(&content) - { - copyright.push_str(s); - } - } - } - Ok(Event::End(bytes)) => { - assert!( - bytes.name().into_inner() == "copyright".as_bytes(), - "Ill-formed protocol file" - ); - break; - } - e => { - panic!("Ill-formed protocol file: {e:?}"); - } - } - } - - protocol.copyright = Some(copyright) - } - b"interface" => { - protocol.interfaces.push(parse_interface(&mut reader, bytes.attributes())); - } - b"description" => { - protocol.description = - Some(parse_description(&mut reader, bytes.attributes())); - } - name => panic!( - "Ill-formed protocol file: unexpected token `{}` in protocol {}", - String::from_utf8_lossy(name), - protocol.name - ), - } - } - Ok(Event::End(bytes)) => { - let name = bytes.name().into_inner(); - assert!( - name == b"protocol", - "Unexpected closing token `{}`", - String::from_utf8_lossy(name) - ); - break; - } - // ignore comments - Ok(Event::Comment(_)) => {} - e => panic!("Ill-formed protocol file: unexpected token {e:?}"), - } - } - - protocol -} - -fn parse_interface(reader: &mut Reader, attrs: Attributes) -> Interface { - let mut interface = Interface::new(); - for attr in attrs.filter_map(|res| res.ok()) { - match attr.key.into_inner() { - b"name" => interface.name = decode_utf8_or_panic(attr.value.into_owned()), - b"version" => interface.version = parse_or_panic(&attr.value), - _ => {} - } - } - - loop { - match reader.read_event_into(&mut Vec::new()) { - Ok(Event::Start(bytes)) => match bytes.name().into_inner() { - b"description" => { - interface.description = Some(parse_description(reader, bytes.attributes())) - } - b"request" => interface.requests.push(parse_request(reader, bytes.attributes())), - b"event" => interface.events.push(parse_event(reader, bytes.attributes())), - b"enum" => interface.enums.push(parse_enum(reader, bytes.attributes())), - name => panic!("Unexpected token: `{}`", String::from_utf8_lossy(name)), - }, - Ok(Event::End(bytes)) if bytes.name().into_inner() == b"interface" => break, - _ => {} - } - } - - interface -} - -fn parse_description(reader: &mut Reader, attrs: Attributes) -> (String, String) { - let mut summary = String::new(); - for attr in attrs.filter_map(|res| res.ok()) { - if attr.key.into_inner() == b"summary" { - summary = String::from_utf8_lossy(&attr.value) - .split_whitespace() - .collect::>() - .join(" "); - } - } - - let mut description = String::new(); - // Some protocols have comments inside their descriptions, so we need to parse them in a loop and - // concatenate the parts into a single block of text - loop { - match reader.read_event_into(&mut Vec::new()) { - Ok(Event::Text(bytes)) => { - if !description.is_empty() { - description.push_str("\n\n"); - } - description.push_str(&bytes.decode().unwrap_or_default()) - } - Ok(Event::End(bytes)) if bytes.name().into_inner() == b"description" => break, - Ok(Event::Comment(_)) => {} - e => panic!("Ill-formed protocol file: {e:?}"), - } - } - - (summary, description) -} - -fn parse_request(reader: &mut Reader, attrs: Attributes) -> Message { - let mut request = Message::new(); - for attr in attrs.filter_map(|res| res.ok()) { - match attr.key.into_inner() { - b"name" => request.name = decode_utf8_or_panic(attr.value.into_owned()), - b"type" => request.typ = Some(parse_type(&attr.value)), - b"since" => request.since = parse_or_panic(&attr.value), - _ => {} - } - } - - loop { - match reader.read_event_into(&mut Vec::new()) { - Ok(Event::Start(bytes)) => match bytes.name().into_inner() { - b"description" => { - request.description = Some(parse_description(reader, bytes.attributes())) - } - b"arg" => request.args.push(parse_arg(reader, bytes.attributes())), - name => panic!("Unexpected token: `{}`", String::from_utf8_lossy(name)), - }, - Ok(Event::End(bytes)) if bytes.name().into_inner() == b"request" => break, - _ => {} - } - } - - request -} - -fn parse_enum(reader: &mut Reader, attrs: Attributes) -> Enum { - let mut enu = Enum::new(); - for attr in attrs.filter_map(|res| res.ok()) { - match attr.key.into_inner() { - b"name" => enu.name = decode_utf8_or_panic(attr.value.into_owned()), - b"since" => enu.since = parse_or_panic(&attr.value), - b"bitfield" => { - if &attr.value[..] == b"true" { - enu.bitfield = true - } - } - _ => {} - } - } - - loop { - match reader.read_event_into(&mut Vec::new()) { - Ok(Event::Start(bytes)) => match bytes.name().into_inner() { - b"description" => { - enu.description = Some(parse_description(reader, bytes.attributes())) - } - b"entry" => enu.entries.push(parse_entry(reader, bytes.attributes())), - name => panic!("Unexpected token: `{}`", String::from_utf8_lossy(name)), - }, - Ok(Event::End(bytes)) if bytes.name().into_inner() == b"enum" => break, - _ => {} - } - } - - enu -} - -fn parse_event(reader: &mut Reader, attrs: Attributes) -> Message { - let mut event = Message::new(); - for attr in attrs.filter_map(|res| res.ok()) { - match attr.key.into_inner() { - b"name" => event.name = decode_utf8_or_panic(attr.value.into_owned()), - b"type" => event.typ = Some(parse_type(&attr.value)), - b"since" => event.since = parse_or_panic(&attr.value), - _ => {} - } - } - - loop { - match reader.read_event_into(&mut Vec::new()) { - Ok(Event::Start(bytes)) => match bytes.name().into_inner() { - b"description" => { - event.description = Some(parse_description(reader, bytes.attributes())) - } - b"arg" => event.args.push(parse_arg(reader, bytes.attributes())), - name => panic!("Unexpected token: `{}`", String::from_utf8_lossy(name)), - }, - Ok(Event::End(bytes)) if bytes.name().into_inner() == b"event" => break, - _ => {} - } - } - - event -} - -fn parse_arg(reader: &mut Reader, attrs: Attributes) -> Arg { - let mut arg = Arg::new(); - for attr in attrs.filter_map(|res| res.ok()) { - match attr.key.into_inner() { - b"name" => arg.name = decode_utf8_or_panic(attr.value.into_owned()), - b"type" => arg.typ = parse_type(&attr.value), - b"summary" => { - arg.summary = Some( - String::from_utf8_lossy(&attr.value) - .split_whitespace() - .collect::>() - .join(" "), - ) - } - b"interface" => arg.interface = Some(parse_or_panic(&attr.value)), - b"allow-null" => { - if &*attr.value == b"true" { - arg.allow_null = true - } - } - b"enum" => arg.enum_ = Some(decode_utf8_or_panic(attr.value.into_owned())), - _ => {} - } - } - - loop { - match reader.read_event_into(&mut Vec::new()) { - Ok(Event::Start(bytes)) => match bytes.name().into_inner() { - b"description" => { - arg.description = Some(parse_description(reader, bytes.attributes())) - } - name => panic!("Unexpected token: `{}`", String::from_utf8_lossy(name)), - }, - Ok(Event::End(bytes)) if bytes.name().into_inner() == b"arg" => break, - _ => {} - } - } - - arg -} - -fn parse_type(txt: &[u8]) -> Type { - match txt { - b"int" => Type::Int, - b"uint" => Type::Uint, - b"fixed" => Type::Fixed, - b"string" => Type::String, - b"object" => Type::Object, - b"new_id" => Type::NewId, - b"array" => Type::Array, - b"fd" => Type::Fd, - b"destructor" => Type::Destructor, - e => panic!("Unexpected type: {}", String::from_utf8_lossy(e)), - } -} - -fn parse_entry(reader: &mut Reader, attrs: Attributes) -> Entry { - let mut entry = Entry::new(); - for attr in attrs.filter_map(|res| res.ok()) { - match attr.key.into_inner() { - b"name" => entry.name = decode_utf8_or_panic(attr.value.into_owned()), - b"value" => { - entry.value = if attr.value.starts_with(b"0x") { - if let Some(val) = std::str::from_utf8(&attr.value[2..]) - .ok() - .and_then(|s| u32::from_str_radix(s, 16).ok()) - { - val - } else { - panic!("Invalid number: {}", String::from_utf8_lossy(&attr.value)) - } - } else { - parse_or_panic(&attr.value) - }; - } - b"since" => entry.since = parse_or_panic(&attr.value), - b"summary" => { - entry.summary = Some( - String::from_utf8_lossy(&attr.value) - .split_whitespace() - .collect::>() - .join(" "), - ) - } - _ => {} - } - } - - loop { - match reader.read_event_into(&mut Vec::new()) { - Ok(Event::Start(bytes)) => match bytes.name().into_inner() { - b"description" => { - entry.description = Some(parse_description(reader, bytes.attributes())) - } - name => panic!("Unexpected token: `{}`", String::from_utf8_lossy(name)), - }, - Ok(Event::End(bytes)) if bytes.name().into_inner() == b"entry" => break, - _ => {} - } - } - - entry -} - -#[cfg(test)] -mod tests { - #[test] - fn xml_parse() { - let protocol_file = - std::fs::File::open("./tests/scanner_assets/test-protocol.xml").unwrap(); - let _ = crate::parse::parse(protocol_file); - } - - #[test] - fn headerless_xml_parse() { - let protocol_file = - std::fs::File::open("./tests/scanner_assets/test-headerless-protocol.xml").unwrap(); - let _ = crate::parse::parse(protocol_file); - } -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/src/protocol.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/src/protocol.rs deleted file mode 100644 index 47fbac91e..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/src/protocol.rs +++ /dev/null @@ -1,154 +0,0 @@ -use proc_macro2::TokenStream; -use quote::quote; - -#[derive(Clone, Debug)] -pub struct Protocol { - pub name: String, - pub copyright: Option, - pub description: Option<(String, String)>, - pub interfaces: Vec, -} - -impl Protocol { - pub fn new(name: String) -> Protocol { - Protocol { name, copyright: None, description: None, interfaces: Vec::new() } - } -} - -#[derive(Clone, Debug)] -pub struct Interface { - pub name: String, - pub version: u32, - pub description: Option<(String, String)>, - pub requests: Vec, - pub events: Vec, - pub enums: Vec, -} - -impl Interface { - pub fn new() -> Interface { - Interface { - name: String::new(), - version: 1, - description: None, - requests: Vec::new(), - events: Vec::new(), - enums: Vec::new(), - } - } -} - -#[derive(Clone, Debug)] -pub struct Message { - pub name: String, - pub typ: Option, - pub since: u32, - pub description: Option<(String, String)>, - pub args: Vec, -} - -impl Message { - pub fn new() -> Message { - Message { name: String::new(), typ: None, since: 1, description: None, args: Vec::new() } - } - - pub fn all_null(&self) -> bool { - self.args - .iter() - .all(|a| !((a.typ == Type::Object || a.typ == Type::NewId) && a.interface.is_some())) - } -} - -#[derive(Clone, Debug)] -pub struct Arg { - pub name: String, - pub typ: Type, - pub interface: Option, - pub summary: Option, - pub description: Option<(String, String)>, - pub allow_null: bool, - pub enum_: Option, -} - -impl Arg { - pub fn new() -> Arg { - Arg { - name: String::new(), - typ: Type::Object, - interface: None, - summary: None, - description: None, - allow_null: false, - enum_: None, - } - } -} - -#[derive(Clone, Debug)] -pub struct Enum { - pub name: String, - pub since: u16, - pub description: Option<(String, String)>, - pub entries: Vec, - pub bitfield: bool, -} - -impl Enum { - pub fn new() -> Enum { - Enum { - name: String::new(), - since: 1, - description: None, - entries: Vec::new(), - bitfield: false, - } - } -} - -#[derive(Clone, Debug)] -pub struct Entry { - pub name: String, - pub value: u32, - pub since: u16, - pub description: Option<(String, String)>, - pub summary: Option, -} - -impl Entry { - pub fn new() -> Entry { - Entry { name: String::new(), value: 0, since: 1, description: None, summary: None } - } -} - -#[derive(Debug, PartialEq, Eq, Copy, Clone)] -pub enum Type { - Int, - Uint, - Fixed, - String, - Object, - NewId, - Array, - Fd, - Destructor, -} - -impl Type { - pub fn nullable(self) -> bool { - matches!(self, Type::String | Type::Object) - } - - pub fn common_type(self) -> TokenStream { - match self { - Type::Int => quote!(Int), - Type::Uint => quote!(Uint), - Type::Fixed => quote!(Fixed), - Type::Array => quote!(Array), - Type::Fd => quote!(Fd), - Type::String => quote!(Str), - Type::Object => quote!(Object), - Type::NewId => quote!(NewId), - Type::Destructor => panic!("Destructor is not a valid argument type."), - } - } -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/src/server_gen.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/src/server_gen.rs deleted file mode 100644 index 7124ce154..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/src/server_gen.rs +++ /dev/null @@ -1,298 +0,0 @@ -use proc_macro2::{Ident, Span, TokenStream}; - -use quote::{format_ident, quote}; - -use crate::{ - protocol::{Interface, Protocol, Type}, - util::{description_to_doc_attr, dotted_to_relname, is_keyword, snake_to_camel, to_doc_attr}, - Side, -}; - -pub fn generate_server_objects(protocol: &Protocol) -> TokenStream { - protocol - .interfaces - .iter() - .filter(|iface| iface.name != "wl_display") - .map(generate_objects_for) - .collect() -} - -fn generate_objects_for(interface: &Interface) -> TokenStream { - let mod_name = Ident::new(&interface.name, Span::call_site()); - let mod_doc = interface.description.as_ref().map(description_to_doc_attr); - let iface_name = Ident::new(&snake_to_camel(&interface.name), Span::call_site()); - let iface_const_name = format_ident!("{}_INTERFACE", interface.name.to_ascii_uppercase()); - - let enums = crate::common::generate_enums_for(interface); - let msg_constants = crate::common::gen_msg_constants(&interface.requests, &interface.events); - - let requests = crate::common::gen_message_enum( - &format_ident!("Request"), - Side::Server, - true, - &interface.requests, - ); - let events = crate::common::gen_message_enum( - &format_ident!("Event"), - Side::Server, - false, - &interface.events, - ); - - let parse_body = if interface.name == "wl_registry" { - quote! { unimplemented!("`wl_registry` is implemented internally in `wayland-server`") } - } else { - crate::common::gen_parse_body(interface, Side::Server) - }; - let write_body = crate::common::gen_write_body(interface, Side::Server); - let methods = gen_methods(interface); - - let event_ref = if interface.requests.is_empty() { - "This interface has no requests." - } else { - "See also the [Request] enum for this interface." - }; - let docs = match &interface.description { - Some((short, long)) => format!("{short}\n\n{long}\n\n{event_ref}"), - None => format!("{}\n\n{}", interface.name, event_ref), - }; - let doc_attr = to_doc_attr(&docs); - - quote! { - #mod_doc - pub mod #mod_name { - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - - use super::wayland_server::{ - backend::{ - smallvec, ObjectData, ObjectId, InvalidId, WeakHandle, - protocol::{WEnum, Argument, Message, Interface, same_interface} - }, - Resource, Dispatch, DisplayHandle, DispatchError, ResourceData, New, Weak, - }; - - #enums - #msg_constants - #requests - #events - - #doc_attr - #[derive(Debug, Clone)] - pub struct #iface_name { - id: ObjectId, - version: u32, - data: Option>, - handle: WeakHandle, - } - - impl std::cmp::PartialEq for #iface_name { - #[inline] - fn eq(&self, other: &#iface_name) -> bool { - self.id == other.id - } - } - - impl std::cmp::Eq for #iface_name {} - - impl PartialEq> for #iface_name { - #[inline] - fn eq(&self, other: &Weak<#iface_name>) -> bool { - self.id == other.id() - } - } - - impl std::borrow::Borrow for #iface_name { - #[inline] - fn borrow(&self) -> &ObjectId { - &self.id - } - } - - impl std::hash::Hash for #iface_name { - #[inline] - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - - impl super::wayland_server::Resource for #iface_name { - type Request = Request; - type Event<'event> = Event<'event>; - - #[inline] - fn interface() -> &'static Interface{ - &super::#iface_const_name - } - - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - - #[inline] - fn version(&self) -> u32 { - self.version - } - - #[inline] - fn data(&self) -> Option<&U> { - self.data.as_ref().and_then(|arc| (&**arc).downcast_ref::>()).map(|data| &data.udata) - } - - #[inline] - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - - fn handle(&self) -> &WeakHandle { - &self.handle - } - - #[inline] - fn from_id(conn: &DisplayHandle, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null(){ - return Err(InvalidId) - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - Ok(#iface_name { id, data, version, handle: conn.backend_handle().downgrade() }) - } - - fn send_event(&self, evt: Self::Event<'_>) -> Result<(), InvalidId> { - let handle = DisplayHandle::from(self.handle.upgrade().ok_or(InvalidId)?); - handle.send_event(self, evt) - } - - fn parse_request(conn: &DisplayHandle, msg: Message) -> Result<(Self, Self::Request), DispatchError> { - #parse_body - } - - fn write_event<'a>(&self, conn: &DisplayHandle, msg: Self::Event<'a>) -> Result>, InvalidId> { - #write_body - } - - fn __set_object_data(&mut self, odata: std::sync::Arc) { - self.data = Some(odata); - } - } - - impl #iface_name { - #methods - } - } - } -} - -fn gen_methods(interface: &Interface) -> TokenStream { - interface - .events - .iter() - .map(|request| { - let method_name = format_ident!( - "{}{}", - if is_keyword(&request.name) { "_" } else { "" }, - request.name - ); - let enum_variant = Ident::new(&snake_to_camel(&request.name), Span::call_site()); - - let fn_args = request.args.iter().flat_map(|arg| { - let arg_name = - format_ident!("{}{}", if is_keyword(&arg.name) { "_" } else { "" }, arg.name); - - let arg_type = if let Some(ref enu) = arg.enum_ { - let enum_type = dotted_to_relname(enu); - quote! { #enum_type } - } else { - match arg.typ { - Type::Uint => quote! { u32 }, - Type::Int => quote! { i32 }, - Type::Fixed => quote! { f64 }, - Type::String => { - if arg.allow_null { - quote! { Option } - } else { - quote! { String } - } - } - Type::Array => { - if arg.allow_null { - quote! { Option> } - } else { - quote! { Vec } - } - } - Type::Fd => quote! { ::std::os::unix::io::BorrowedFd<'_> }, - Type::Object | Type::NewId => { - let iface = arg.interface.as_ref().unwrap(); - let iface_mod = Ident::new(iface, Span::call_site()); - let iface_type = Ident::new(&snake_to_camel(iface), Span::call_site()); - if arg.allow_null { - quote! { Option<&super::#iface_mod::#iface_type> } - } else { - quote! { &super::#iface_mod::#iface_type } - } - } - Type::Destructor => panic!("An argument cannot have type \"destructor\"."), - } - }; - - Some(quote! { - #arg_name: #arg_type - }) - }); - - let enum_args = request.args.iter().flat_map(|arg| { - let arg_name = - format_ident!("{}{}", if is_keyword(&arg.name) { "_" } else { "" }, arg.name); - if arg.enum_.is_some() { - Some(quote! { #arg_name: WEnum::Value(#arg_name) }) - } else if arg.typ == Type::Object || arg.typ == Type::NewId { - if arg.allow_null { - Some(quote! { #arg_name: #arg_name.cloned() }) - } else { - Some(quote! { #arg_name: #arg_name.clone() }) - } - } else { - Some(quote! { #arg_name }) - } - }); - - let doc_attr = request.description.as_ref().map(description_to_doc_attr); - - quote! { - #doc_attr - #[allow(clippy::too_many_arguments)] - pub fn #method_name(&self, #(#fn_args),*) { - let _ = self.send_event( - Event::#enum_variant { - #(#enum_args),* - } - ); - } - } - }) - .collect() -} - -#[cfg(test)] -mod tests { - #[test] - fn server_gen() { - let protocol_file = - std::fs::File::open("./tests/scanner_assets/test-protocol.xml").unwrap(); - let protocol_parsed = crate::parse::parse(protocol_file); - let generated: String = super::generate_server_objects(&protocol_parsed).to_string(); - let generated = crate::format_rust_code(&generated); - - let reference = - std::fs::read_to_string("./tests/scanner_assets/test-server-code.rs").unwrap(); - let reference = crate::format_rust_code(&reference); - - if reference != generated { - let diff = similar::TextDiff::from_lines(&reference, &generated); - print!("{}", diff.unified_diff().context_radius(10).header("reference", "generated")); - panic!("Generated does not match reference!") - } - } -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/src/token.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/src/token.rs deleted file mode 100644 index 5d5f087bd..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/src/token.rs +++ /dev/null @@ -1,186 +0,0 @@ -// `bytes`, `next_chr`, `parse_lit_str`, `parse_lit_str_cooked` and `parse_lit_str_raw` are adapted -// from syn: -// https://github.com/dtolnay/syn/blob/362ee2d02df3f1b2e74c7b7a4cf2ed3c106404c9/src/lit.rs#L1062-L1167 -// and -// https://github.com/dtolnay/syn/blob/362ee2d02df3f1b2e74c7b7a4cf2ed3c106404c9/src/lit.rs#L1327-L1388 - -/// Get the byte at offset idx, or a default of `b'\0'` if we're looking -/// past the end of the input buffer. -fn byte(s: &str, idx: usize) -> u8 { - if idx < s.len() { - s.as_bytes()[idx] - } else { - 0 - } -} - -fn next_chr(s: &str) -> char { - s.chars().next().unwrap_or('\0') -} - -// Returns (content, suffix). -fn parse_lit_str(s: &str) -> String { - match byte(s, 0) { - b'"' => parse_lit_str_cooked(s), - b'r' => parse_lit_str_raw(s), - _ => unreachable!(), - } -} - -// Clippy false positive -// https://github.com/rust-lang-nursery/rust-clippy/issues/2329 -#[allow(clippy::needless_continue)] -fn parse_lit_str_cooked(mut s: &str) -> String { - assert_eq!(byte(s, 0), b'"'); - s = &s[1..]; - - let mut content = String::new(); - 'outer: loop { - let ch = match byte(s, 0) { - b'"' => break, - b'\\' => { - let b = byte(s, 1); - s = &s[2..]; - match b { - b'x' => { - let (byte, rest) = backslash_x(s); - s = rest; - assert!(byte <= 0x80, "Invalid \\x byte in string literal"); - char::from_u32(u32::from(byte)).unwrap() - } - b'u' => { - let (chr, rest) = backslash_u(s); - s = rest; - chr - } - b'n' => '\n', - b'r' => '\r', - b't' => '\t', - b'\\' => '\\', - b'0' => '\0', - b'\'' => '\'', - b'"' => '"', - b'\r' | b'\n' => loop { - let ch = next_chr(s); - if ch.is_whitespace() { - s = &s[ch.len_utf8()..]; - } else { - continue 'outer; - } - }, - b => panic!("unexpected byte {b:?} after \\ character in byte literal"), - } - } - b'\r' => { - assert_eq!(byte(s, 1), b'\n', "Bare CR not allowed in string"); - s = &s[2..]; - '\n' - } - _ => { - let ch = next_chr(s); - s = &s[ch.len_utf8()..]; - ch - } - }; - content.push(ch); - } - - assert!(s.starts_with('"')); - content -} - -fn parse_lit_str_raw(mut s: &str) -> String { - assert_eq!(byte(s, 0), b'r'); - s = &s[1..]; - - let mut pounds = 0; - while byte(s, pounds) == b'#' { - pounds += 1; - } - assert_eq!(byte(s, pounds), b'"'); - let close = s.rfind('"').unwrap(); - for end in s[close + 1..close + 1 + pounds].bytes() { - assert_eq!(end, b'#'); - } - - s[pounds + 1..close].to_owned() -} - -fn backslash_x(s: &str) -> (u8, &str) { - let mut ch = 0; - let b0 = byte(s, 0); - let b1 = byte(s, 1); - ch += 0x10 - * match b0 { - b'0'..=b'9' => b0 - b'0', - b'a'..=b'f' => 10 + (b0 - b'a'), - b'A'..=b'F' => 10 + (b0 - b'A'), - _ => panic!("unexpected non-hex character after \\x"), - }; - ch += match b1 { - b'0'..=b'9' => b1 - b'0', - b'a'..=b'f' => 10 + (b1 - b'a'), - b'A'..=b'F' => 10 + (b1 - b'A'), - _ => panic!("unexpected non-hex character after \\x"), - }; - (ch, &s[2..]) -} - -fn backslash_u(mut s: &str) -> (char, &str) { - if byte(s, 0) != b'{' { - panic!("{}", "expected { after \\u"); - } - s = &s[1..]; - - let mut ch = 0; - let mut digits = 0; - loop { - let b = byte(s, 0); - let digit = match b { - b'0'..=b'9' => b - b'0', - b'a'..=b'f' => 10 + b - b'a', - b'A'..=b'F' => 10 + b - b'A', - b'_' if digits > 0 => { - s = &s[1..]; - continue; - } - b'}' if digits == 0 => panic!("invalid empty unicode escape"), - b'}' => break, - _ => panic!("unexpected non-hex character after \\u"), - }; - if digits == 6 { - panic!("overlong unicode escape (must have at most 6 hex digits)"); - } - ch *= 0x10; - ch += u32::from(digit); - digits += 1; - s = &s[1..]; - } - assert!(byte(s, 0) == b'}'); - s = &s[1..]; - - if let Some(ch) = char::from_u32(ch) { - (ch, s) - } else { - panic!("character code {ch:x} is not a valid unicode character"); - } -} - -// End of code adapted from syn - -pub fn parse_lit_str_token(mut stream: proc_macro::TokenStream) -> String { - loop { - let mut iter = stream.into_iter(); - let token = iter.next().expect("expected string argument"); - assert!(iter.next().is_none(), "unexpected trailing token"); - let literal = match token { - proc_macro::TokenTree::Literal(literal) => literal, - proc_macro::TokenTree::Group(group) => { - stream = group.stream(); - continue; - } - _ => panic!("expected string argument found `{token:?}`"), - }; - return parse_lit_str(&literal.to_string()); - } -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/src/util.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/src/util.rs deleted file mode 100644 index b1f0ff4c9..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/src/util.rs +++ /dev/null @@ -1,115 +0,0 @@ -use proc_macro2::{Ident, Span, TokenStream}; -use quote::{quote, ToTokens}; - -pub(crate) fn to_doc_attr(text: &str) -> TokenStream { - let text = text.lines().map(str::trim).collect::>().join("\n"); - let text = text.trim(); - - quote!(#[doc = #text]) -} - -pub(crate) fn description_to_doc_attr((short, long): &(String, String)) -> TokenStream { - to_doc_attr(&format!("{short}\n\n{long}")) -} - -pub fn is_keyword(txt: &str) -> bool { - matches!( - txt, - "abstract" - | "alignof" - | "as" - | "become" - | "box" - | "break" - | "const" - | "continue" - | "crate" - | "do" - | "else" - | "enum" - | "extern" - | "false" - | "final" - | "fn" - | "for" - | "if" - | "impl" - | "in" - | "let" - | "loop" - | "macro" - | "match" - | "mod" - | "move" - | "mut" - | "offsetof" - | "override" - | "priv" - | "proc" - | "pub" - | "pure" - | "ref" - | "return" - | "Self" - | "self" - | "sizeof" - | "static" - | "struct" - | "super" - | "trait" - | "true" - | "type" - | "typeof" - | "unsafe" - | "unsized" - | "use" - | "virtual" - | "where" - | "while" - | "yield" - | "__handler" - | "__object" - ) -} - -pub fn is_camel_keyword(txt: &str) -> bool { - matches!(txt, "Self") -} - -pub fn snake_to_camel(input: &str) -> String { - let result = input - .split('_') - .flat_map(|s| { - let mut first = true; - s.chars().map(move |c| { - if first { - first = false; - c.to_ascii_uppercase() - } else { - c - } - }) - }) - .collect::(); - - if is_camel_keyword(&result) { - format!("_{}", &result) - } else { - result - } -} - -pub fn dotted_to_relname(input: &str) -> TokenStream { - let mut it = input.split('.'); - match (it.next(), it.next()) { - (Some(module), Some(name)) => { - let module = Ident::new(module, Span::call_site()); - let ident = Ident::new(&snake_to_camel(name), Span::call_site()); - quote::quote!(super::#module::#ident) - } - (Some(name), None) => { - Ident::new(&snake_to_camel(name), Span::call_site()).into_token_stream() - } - _ => unreachable!(), - } -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-client-code.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-client-code.rs deleted file mode 100644 index 6028b8baf..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-client-code.rs +++ /dev/null @@ -1,1833 +0,0 @@ -#[doc = "core global object\n\nThe core global object. This is a special singleton object. It\nis used for internal Wayland protocol features."] -pub mod wl_display { - use super::wayland_client::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, Backend, InvalidId, ObjectData, ObjectId, WeakBackend, - }, - Connection, Dispatch, DispatchError, Proxy, QueueHandle, QueueProxyData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = "global error values\n\nThese errors are global and can be emitted in response to any\nserver request."] - #[repr(u32)] - #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] - #[non_exhaustive] - pub enum Error { - #[doc = "server couldn't find object"] - InvalidObject = 0, - #[doc = "method doesn't exist on the specified interface or malformed request"] - InvalidMethod = 1, - #[doc = "server is out of memory"] - NoMemory = 2, - #[doc = "implementation error in compositor"] - Implementation = 3, - } - impl std::convert::TryFrom for Error { - type Error = (); - fn try_from(val: u32) -> Result { - match val { - 0 => Ok(Error::InvalidObject), - 1 => Ok(Error::InvalidMethod), - 2 => Ok(Error::NoMemory), - 3 => Ok(Error::Implementation), - _ => Err(()), - } - } - } - impl std::convert::From for u32 { - fn from(val: Error) -> u32 { - val as u32 - } - } - #[doc = r" The minimal object version supporting this request"] - pub const REQ_SYNC_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_SYNC_OPCODE: u16 = 0u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_GET_REGISTRY_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_GET_REGISTRY_OPCODE: u16 = 1u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_ERROR_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_ERROR_OPCODE: u16 = 0u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_DELETE_ID_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_DELETE_ID_OPCODE: u16 = 1u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request<'a> { - #[doc = "asynchronous roundtrip\n\nThe sync request asks the server to emit the 'done' event\non the returned wl_callback object. Since requests are\nhandled in-order and events are delivered in-order, this can\nbe used as a barrier to ensure all previous requests and the\nresulting events have been handled.\n\nThe object returned by this request will be destroyed by the\ncompositor after the callback is fired and as such the client must not\nattempt to use it after that point.\n\nThe callback_data passed in the callback is the event serial."] - Sync {}, - #[doc = "get global registry object\n\nThis request creates a registry object that allows the client\nto list and bind the global objects available from the\ncompositor.\n\nIt should be noted that the server side resources consumed in\nresponse to a get_registry request can only be released when the\nclient disconnects, not when the client side proxy is destroyed.\nTherefore, clients should invoke get_registry as infrequently as\npossible to avoid wasting memory."] - GetRegistry {}, - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Request<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::Sync { .. } => 0u16, - Request::GetRegistry { .. } => 1u16, - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event { - #[doc = "fatal error event\n\nThe error event is sent out when a fatal (non-recoverable)\nerror has occurred. The object_id argument is the object\nwhere the error occurred, most often in response to a request\nto that object. The code identifies the error and is defined\nby the object interface. As such, each interface defines its\nown set of error codes. The message is a brief description\nof the error, for (debugging) convenience."] - Error { - #[doc = "object where the error occurred"] - object_id: super::wayland_client::ObjectId, - #[doc = "error code"] - code: u32, - #[doc = "error description"] - message: String, - }, - #[doc = "acknowledge object ID deletion\n\nThis event is used internally by the object ID management\nlogic. When a client deletes an object that it had created,\nthe server will send this event to acknowledge that it has\nseen the delete request. When the client receives this event,\nit will know that it can safely reuse the object ID."] - DeleteId { - #[doc = "deleted object ID"] - id: u32, - }, - } - impl Event { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Event::Error { .. } => 0u16, - Event::DeleteId { .. } => 1u16, - } - } - } - #[doc = "core global object\n\nThe core global object. This is a special singleton object. It\nis used for internal Wayland protocol features.\n\nSee also the [Event] enum for this interface."] - #[derive(Debug, Clone)] - pub struct WlDisplay { - id: ObjectId, - version: u32, - data: Option>, - backend: WeakBackend, - } - impl std::cmp::PartialEq for WlDisplay { - fn eq(&self, other: &WlDisplay) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for WlDisplay {} - impl PartialEq> for WlDisplay { - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for WlDisplay { - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for WlDisplay { - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_client::Proxy for WlDisplay { - type Request<'request> = Request<'request>; - type Event = Event; - #[inline] - fn interface() -> &'static Interface { - &super::WL_DISPLAY_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data.as_ref().and_then(|arc| arc.data_as_any().downcast_ref::()) - } - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn backend(&self) -> &WeakBackend { - &self.backend - } - fn send_request(&self, req: Self::Request<'_>) -> Result<(), InvalidId> { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, None)?; - debug_assert!(id.is_null()); - Ok(()) - } - fn send_constructor( - &self, - req: Self::Request<'_>, - data: Arc, - ) -> Result { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, Some(data))?; - Proxy::from_id(&conn, id) - } - #[inline] - fn from_id(conn: &Connection, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - let backend = conn.backend().downgrade(); - Ok(WlDisplay { id, data, version, backend }) - } - #[inline] - fn inert(backend: WeakBackend) -> Self { - WlDisplay { id: ObjectId::null(), data: None, version: 0, backend } - } - fn parse_event( - conn: &Connection, - msg: Message, - ) -> Result<(Self, Self::Event), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - 0u16 => { - if let ( - Some(Argument::Object(object_id)), - Some(Argument::Uint(code)), - Some(Argument::Str(message)), - ) = (arg_iter.next(), arg_iter.next(), arg_iter.next()) - { - Ok(( - me, - Event::Error { - object_id: object_id.clone(), - code, - message: String::from_utf8_lossy( - message.as_ref().unwrap().as_bytes(), - ) - .into_owned(), - }, - )) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - 1u16 => { - if let (Some(Argument::Uint(id))) = (arg_iter.next()) { - Ok((me, Event::DeleteId { id })) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_request<'a>( - &self, - conn: &Connection, - msg: Self::Request<'a>, - ) -> Result< - (Message>, Option<(&'static Interface, u32)>), - InvalidId, - > { - match msg { - Request::Sync {} => { - let child_spec = { - let my_info = conn.object_info(self.id())?; - Some((super::wl_callback::WlCallback::interface(), my_info.version)) - }; - let args = { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::NewId(ObjectId::null())); - vec - }; - Ok((Message { sender_id: self.id.clone(), opcode: 0u16, args }, child_spec)) - } - Request::GetRegistry {} => { - let child_spec = { - let my_info = conn.object_info(self.id())?; - Some((super::wl_registry::WlRegistry::interface(), my_info.version)) - }; - let args = { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::NewId(ObjectId::null())); - vec - }; - Ok((Message { sender_id: self.id.clone(), opcode: 1u16, args }, child_spec)) - } - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - impl WlDisplay { - #[doc = "asynchronous roundtrip\n\nThe sync request asks the server to emit the 'done' event\non the returned wl_callback object. Since requests are\nhandled in-order and events are delivered in-order, this can\nbe used as a barrier to ensure all previous requests and the\nresulting events have been handled.\n\nThe object returned by this request will be destroyed by the\ncompositor after the callback is fired and as such the client must not\nattempt to use it after that point.\n\nThe callback_data passed in the callback is the event serial."] - #[allow(clippy::too_many_arguments)] - pub fn sync< - U: Send + Sync + 'static, - D: Dispatch + 'static, - >( - &self, - qh: &QueueHandle, - udata: U, - ) -> super::wl_callback::WlCallback { - self.send_constructor( - Request::Sync {}, - qh.make_data::(udata), - ) - .unwrap_or_else(|_| Proxy::inert(self.backend.clone())) - } - #[doc = "get global registry object\n\nThis request creates a registry object that allows the client\nto list and bind the global objects available from the\ncompositor.\n\nIt should be noted that the server side resources consumed in\nresponse to a get_registry request can only be released when the\nclient disconnects, not when the client side proxy is destroyed.\nTherefore, clients should invoke get_registry as infrequently as\npossible to avoid wasting memory."] - #[allow(clippy::too_many_arguments)] - pub fn get_registry< - U: Send + Sync + 'static, - D: Dispatch + 'static, - >( - &self, - qh: &QueueHandle, - udata: U, - ) -> super::wl_registry::WlRegistry { - self.send_constructor( - Request::GetRegistry {}, - qh.make_data::(udata), - ) - .unwrap_or_else(|_| Proxy::inert(self.backend.clone())) - } - } -} -#[doc = "global registry object\n\nThe singleton global registry object. The server has a number of\nglobal objects that are available to all clients. These objects\ntypically represent an actual object in the server (for example,\nan input device) or they are singleton objects that provide\nextension functionality.\n\nWhen a client creates a registry object, the registry object\nwill emit a global event for each global currently in the\nregistry. Globals come and go as a result of device or\nmonitor hotplugs, reconfiguration or other events, and the\nregistry will send out global and global_remove events to\nkeep the client up to date with the changes. To mark the end\nof the initial burst of events, the client can use the\nwl_display.sync request immediately after calling\nwl_display.get_registry.\n\nA client can bind to a global object by using the bind\nrequest. This creates a client-side handle that lets the object\nemit events to the client and lets the client invoke requests on\nthe object."] -pub mod wl_registry { - use super::wayland_client::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, Backend, InvalidId, ObjectData, ObjectId, WeakBackend, - }, - Connection, Dispatch, DispatchError, Proxy, QueueHandle, QueueProxyData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_BIND_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_BIND_OPCODE: u16 = 0u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_GLOBAL_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_GLOBAL_OPCODE: u16 = 0u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_GLOBAL_REMOVE_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_GLOBAL_REMOVE_OPCODE: u16 = 1u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request<'a> { - #[doc = "bind an object to the display\n\nBinds a new, client-created object to the server using the\nspecified name as the identifier."] - Bind { - #[doc = "unique numeric name of the object"] - name: u32, - #[doc = "bounded object"] - id: (&'static Interface, u32), - }, - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Request<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::Bind { .. } => 0u16, - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event { - #[doc = "announce global object\n\nNotify the client of global objects.\n\nThe event notifies the client that a global object with\nthe given name is now available, and it implements the\ngiven version of the given interface."] - Global { - #[doc = "numeric name of the global object"] - name: u32, - #[doc = "interface implemented by the object"] - interface: String, - #[doc = "interface version"] - version: u32, - }, - #[doc = "announce removal of global object\n\nNotify the client of removed global objects.\n\nThis event notifies the client that the global identified\nby name is no longer available. If the client bound to\nthe global using the bind request, the client should now\ndestroy that object.\n\nThe object remains valid and requests to the object will be\nignored until the client destroys it, to avoid races between\nthe global going away and a client sending a request to it."] - GlobalRemove { - #[doc = "numeric name of the global object"] - name: u32, - }, - } - impl Event { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Event::Global { .. } => 0u16, - Event::GlobalRemove { .. } => 1u16, - } - } - } - #[doc = "global registry object\n\nThe singleton global registry object. The server has a number of\nglobal objects that are available to all clients. These objects\ntypically represent an actual object in the server (for example,\nan input device) or they are singleton objects that provide\nextension functionality.\n\nWhen a client creates a registry object, the registry object\nwill emit a global event for each global currently in the\nregistry. Globals come and go as a result of device or\nmonitor hotplugs, reconfiguration or other events, and the\nregistry will send out global and global_remove events to\nkeep the client up to date with the changes. To mark the end\nof the initial burst of events, the client can use the\nwl_display.sync request immediately after calling\nwl_display.get_registry.\n\nA client can bind to a global object by using the bind\nrequest. This creates a client-side handle that lets the object\nemit events to the client and lets the client invoke requests on\nthe object.\n\nSee also the [Event] enum for this interface."] - #[derive(Debug, Clone)] - pub struct WlRegistry { - id: ObjectId, - version: u32, - data: Option>, - backend: WeakBackend, - } - impl std::cmp::PartialEq for WlRegistry { - fn eq(&self, other: &WlRegistry) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for WlRegistry {} - impl PartialEq> for WlRegistry { - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for WlRegistry { - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for WlRegistry { - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_client::Proxy for WlRegistry { - type Request<'request> = Request<'request>; - type Event = Event; - #[inline] - fn interface() -> &'static Interface { - &super::WL_REGISTRY_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data.as_ref().and_then(|arc| arc.data_as_any().downcast_ref::()) - } - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn backend(&self) -> &WeakBackend { - &self.backend - } - fn send_request(&self, req: Self::Request<'_>) -> Result<(), InvalidId> { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, None)?; - debug_assert!(id.is_null()); - Ok(()) - } - fn send_constructor( - &self, - req: Self::Request<'_>, - data: Arc, - ) -> Result { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, Some(data))?; - Proxy::from_id(&conn, id) - } - #[inline] - fn from_id(conn: &Connection, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - let backend = conn.backend().downgrade(); - Ok(WlRegistry { id, data, version, backend }) - } - #[inline] - fn inert(backend: WeakBackend) -> Self { - WlRegistry { id: ObjectId::null(), data: None, version: 0, backend } - } - fn parse_event( - conn: &Connection, - msg: Message, - ) -> Result<(Self, Self::Event), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - 0u16 => { - if let ( - Some(Argument::Uint(name)), - Some(Argument::Str(interface)), - Some(Argument::Uint(version)), - ) = (arg_iter.next(), arg_iter.next(), arg_iter.next()) - { - Ok(( - me, - Event::Global { - name, - interface: String::from_utf8_lossy( - interface.as_ref().unwrap().as_bytes(), - ) - .into_owned(), - version, - }, - )) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - 1u16 => { - if let (Some(Argument::Uint(name))) = (arg_iter.next()) { - Ok((me, Event::GlobalRemove { name })) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_request<'a>( - &self, - conn: &Connection, - msg: Self::Request<'a>, - ) -> Result< - (Message>, Option<(&'static Interface, u32)>), - InvalidId, - > { - match msg { - Request::Bind { name, id } => { - let child_spec = Some((id.0, id.1)); - let args = { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::Uint(name)); - vec.push(Argument::Str(Some(Box::new( - std::ffi::CString::new(id.0.name).unwrap(), - )))); - vec.push(Argument::Uint(id.1)); - vec.push(Argument::NewId(ObjectId::null())); - vec - }; - Ok((Message { sender_id: self.id.clone(), opcode: 0u16, args }, child_spec)) - } - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - impl WlRegistry { - #[doc = "bind an object to the display\n\nBinds a new, client-created object to the server using the\nspecified name as the identifier."] - #[allow(clippy::too_many_arguments)] - pub fn bind + 'static>( - &self, - name: u32, - version: u32, - qh: &QueueHandle, - udata: U, - ) -> I { - self.send_constructor( - Request::Bind { name, id: (I::interface(), version) }, - qh.make_data::(udata), - ) - .unwrap_or_else(|_| Proxy::inert(self.backend.clone())) - } - } -} -#[doc = "callback object\n\nClients can handle the 'done' event to get notified when\nthe related request is done."] -pub mod wl_callback { - use super::wayland_client::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, Backend, InvalidId, ObjectData, ObjectId, WeakBackend, - }, - Connection, Dispatch, DispatchError, Proxy, QueueHandle, QueueProxyData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_DONE_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_DONE_OPCODE: u16 = 0u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request<'a> { - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Request<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event { - #[doc = "done event\n\nNotify the client when the related request is done.\n\nThis is a destructor, once received this object cannot be used any longer."] - Done { - #[doc = "request-specific data for the callback"] - callback_data: u32, - }, - } - impl Event { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Event::Done { .. } => 0u16, - } - } - } - #[doc = "callback object\n\nClients can handle the 'done' event to get notified when\nthe related request is done.\n\nSee also the [Event] enum for this interface."] - #[derive(Debug, Clone)] - pub struct WlCallback { - id: ObjectId, - version: u32, - data: Option>, - backend: WeakBackend, - } - impl std::cmp::PartialEq for WlCallback { - fn eq(&self, other: &WlCallback) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for WlCallback {} - impl PartialEq> for WlCallback { - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for WlCallback { - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for WlCallback { - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_client::Proxy for WlCallback { - type Request<'request> = Request<'request>; - type Event = Event; - #[inline] - fn interface() -> &'static Interface { - &super::WL_CALLBACK_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data.as_ref().and_then(|arc| arc.data_as_any().downcast_ref::()) - } - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn backend(&self) -> &WeakBackend { - &self.backend - } - fn send_request(&self, req: Self::Request<'_>) -> Result<(), InvalidId> { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, None)?; - debug_assert!(id.is_null()); - Ok(()) - } - fn send_constructor( - &self, - req: Self::Request<'_>, - data: Arc, - ) -> Result { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, Some(data))?; - Proxy::from_id(&conn, id) - } - #[inline] - fn from_id(conn: &Connection, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - let backend = conn.backend().downgrade(); - Ok(WlCallback { id, data, version, backend }) - } - #[inline] - fn inert(backend: WeakBackend) -> Self { - WlCallback { id: ObjectId::null(), data: None, version: 0, backend } - } - fn parse_event( - conn: &Connection, - msg: Message, - ) -> Result<(Self, Self::Event), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - 0u16 => { - if let (Some(Argument::Uint(callback_data))) = (arg_iter.next()) { - Ok((me, Event::Done { callback_data })) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_request<'a>( - &self, - conn: &Connection, - msg: Self::Request<'a>, - ) -> Result< - (Message>, Option<(&'static Interface, u32)>), - InvalidId, - > { - match msg { - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - impl WlCallback {} -} -pub mod test_global { - use super::wayland_client::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, Backend, InvalidId, ObjectData, ObjectId, WeakBackend, - }, - Connection, Dispatch, DispatchError, Proxy, QueueHandle, QueueProxyData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_MANY_ARGS_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_MANY_ARGS_OPCODE: u16 = 0u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_GET_SECONDARY_SINCE: u32 = 2u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_GET_SECONDARY_OPCODE: u16 = 1u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_GET_TERTIARY_SINCE: u32 = 3u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_GET_TERTIARY_OPCODE: u16 = 2u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_LINK_SINCE: u32 = 3u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_LINK_OPCODE: u16 = 3u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_DESTROY_SINCE: u32 = 4u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_DESTROY_OPCODE: u16 = 4u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_REVERSE_LINK_SINCE: u32 = 5u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_REVERSE_LINK_OPCODE: u16 = 5u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_NEWID_AND_ALLOW_NULL_SINCE: u32 = 5u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_NEWID_AND_ALLOW_NULL_OPCODE: u16 = 6u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_MANY_ARGS_EVT_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_MANY_ARGS_EVT_OPCODE: u16 = 0u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_ACK_SECONDARY_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_ACK_SECONDARY_OPCODE: u16 = 1u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_CYCLE_QUAD_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_CYCLE_QUAD_OPCODE: u16 = 2u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request<'a> { - #[doc = "a request with every possible non-object arg"] - ManyArgs { - #[doc = "an unsigned int"] - unsigned_int: u32, - #[doc = "a singed int"] - signed_int: i32, - #[doc = "a fixed point number"] - fixed_point: f64, - #[doc = "an array"] - number_array: Vec, - #[doc = "some text"] - some_text: String, - #[doc = "a file descriptor"] - file_descriptor: std::os::unix::io::BorrowedFd<'a>, - }, - #[doc = "Only available since version 2 of the interface"] - GetSecondary {}, - #[doc = "Only available since version 3 of the interface"] - GetTertiary {}, - #[doc = "link a secondary and a tertiary\n\n\n\nOnly available since version 3 of the interface"] - Link { sec: super::secondary::Secondary, ter: Option, time: u32 }, - #[doc = "This is a destructor, once sent this object cannot be used any longer.\nOnly available since version 4 of the interface"] - Destroy, - #[doc = "reverse link a secondary and a tertiary\n\n\n\nOnly available since version 5 of the interface"] - ReverseLink { sec: Option, ter: super::tertiary::Tertiary }, - #[doc = "a newid request that also takes allow null arg\n\n\n\nOnly available since version 5 of the interface"] - NewidAndAllowNull { - sec: Option, - ter: super::tertiary::Tertiary, - }, - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Request<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::ManyArgs { .. } => 0u16, - Request::GetSecondary { .. } => 1u16, - Request::GetTertiary { .. } => 2u16, - Request::Link { .. } => 3u16, - Request::Destroy => 4u16, - Request::ReverseLink { .. } => 5u16, - Request::NewidAndAllowNull { .. } => 6u16, - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event { - #[doc = "an event with every possible non-object arg"] - ManyArgsEvt { - #[doc = "an unsigned int"] - unsigned_int: u32, - #[doc = "a singed int"] - signed_int: i32, - #[doc = "a fixed point number"] - fixed_point: f64, - #[doc = "an array"] - number_array: Vec, - #[doc = "some text"] - some_text: String, - #[doc = "a file descriptor"] - file_descriptor: OwnedFd, - }, - #[doc = "acking the creation of a secondary"] - AckSecondary { sec: super::secondary::Secondary }, - #[doc = "create a new quad optionally replacing a previous one"] - CycleQuad { new_quad: super::quad::Quad, old_quad: Option }, - } - impl Event { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Event::ManyArgsEvt { .. } => 0u16, - Event::AckSecondary { .. } => 1u16, - Event::CycleQuad { .. } => 2u16, - } - } - } - #[doc = "test_global\n\nSee also the [Event] enum for this interface."] - #[derive(Debug, Clone)] - pub struct TestGlobal { - id: ObjectId, - version: u32, - data: Option>, - backend: WeakBackend, - } - impl std::cmp::PartialEq for TestGlobal { - fn eq(&self, other: &TestGlobal) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for TestGlobal {} - impl PartialEq> for TestGlobal { - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for TestGlobal { - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for TestGlobal { - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_client::Proxy for TestGlobal { - type Request<'request> = Request<'request>; - type Event = Event; - #[inline] - fn interface() -> &'static Interface { - &super::TEST_GLOBAL_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data.as_ref().and_then(|arc| arc.data_as_any().downcast_ref::()) - } - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn backend(&self) -> &WeakBackend { - &self.backend - } - fn send_request(&self, req: Self::Request<'_>) -> Result<(), InvalidId> { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, None)?; - debug_assert!(id.is_null()); - Ok(()) - } - fn send_constructor( - &self, - req: Self::Request<'_>, - data: Arc, - ) -> Result { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, Some(data))?; - Proxy::from_id(&conn, id) - } - #[inline] - fn from_id(conn: &Connection, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - let backend = conn.backend().downgrade(); - Ok(TestGlobal { id, data, version, backend }) - } - #[inline] - fn inert(backend: WeakBackend) -> Self { - TestGlobal { id: ObjectId::null(), data: None, version: 0, backend } - } - fn parse_event( - conn: &Connection, - msg: Message, - ) -> Result<(Self, Self::Event), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - 0u16 => { - if let ( - Some(Argument::Uint(unsigned_int)), - Some(Argument::Int(signed_int)), - Some(Argument::Fixed(fixed_point)), - Some(Argument::Array(number_array)), - Some(Argument::Str(some_text)), - Some(Argument::Fd(file_descriptor)), - ) = ( - arg_iter.next(), - arg_iter.next(), - arg_iter.next(), - arg_iter.next(), - arg_iter.next(), - arg_iter.next(), - ) { - Ok(( - me, - Event::ManyArgsEvt { - unsigned_int, - signed_int, - fixed_point: (fixed_point as f64) / 256., - number_array: *number_array, - some_text: String::from_utf8_lossy( - some_text.as_ref().unwrap().as_bytes(), - ) - .into_owned(), - file_descriptor, - }, - )) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - 1u16 => { - if let (Some(Argument::Object(sec))) = (arg_iter.next()) { - Ok(( - me, - Event::AckSecondary { - sec: match ::from_id( - conn, - sec.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - }, - )) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - 2u16 => { - if let (Some(Argument::NewId(new_quad)), Some(Argument::Object(old_quad))) = - (arg_iter.next(), arg_iter.next()) - { - Ok(( - me, - Event::CycleQuad { - new_quad: match ::from_id( - conn, - new_quad.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - old_quad: if old_quad.is_null() { - None - } else { - Some( - match ::from_id( - conn, - old_quad.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - ) - }, - }, - )) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_request<'a>( - &self, - conn: &Connection, - msg: Self::Request<'a>, - ) -> Result< - (Message>, Option<(&'static Interface, u32)>), - InvalidId, - > { - match msg { - Request::ManyArgs { - unsigned_int, - signed_int, - fixed_point, - number_array, - some_text, - file_descriptor, - } => { - let child_spec = None; - let args = smallvec::SmallVec::from_vec(vec![ - Argument::Uint(unsigned_int), - Argument::Int(signed_int), - Argument::Fixed((fixed_point * 256.) as i32), - Argument::Array(Box::new(number_array)), - Argument::Str(Some(Box::new(std::ffi::CString::new(some_text).unwrap()))), - Argument::Fd(file_descriptor), - ]); - Ok((Message { sender_id: self.id.clone(), opcode: 0u16, args }, child_spec)) - } - Request::GetSecondary {} => { - let child_spec = { - let my_info = conn.object_info(self.id())?; - Some((super::secondary::Secondary::interface(), my_info.version)) - }; - let args = { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::NewId(ObjectId::null())); - vec - }; - Ok((Message { sender_id: self.id.clone(), opcode: 1u16, args }, child_spec)) - } - Request::GetTertiary {} => { - let child_spec = { - let my_info = conn.object_info(self.id())?; - Some((super::tertiary::Tertiary::interface(), my_info.version)) - }; - let args = { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::NewId(ObjectId::null())); - vec - }; - Ok((Message { sender_id: self.id.clone(), opcode: 2u16, args }, child_spec)) - } - Request::Link { sec, ter, time } => { - let child_spec = None; - let args = { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::Object(Proxy::id(&sec))); - vec.push(if let Some(obj) = ter { - Argument::Object(Proxy::id(&obj)) - } else { - Argument::Object(ObjectId::null()) - }); - vec.push(Argument::Uint(time)); - vec - }; - Ok((Message { sender_id: self.id.clone(), opcode: 3u16, args }, child_spec)) - } - Request::Destroy {} => { - let child_spec = None; - let args = smallvec::SmallVec::new(); - Ok((Message { sender_id: self.id.clone(), opcode: 4u16, args }, child_spec)) - } - Request::ReverseLink { sec, ter } => { - let child_spec = None; - let args = { - let mut vec = smallvec::SmallVec::new(); - vec.push(if let Some(obj) = sec { - Argument::Object(Proxy::id(&obj)) - } else { - Argument::Object(ObjectId::null()) - }); - vec.push(Argument::Object(Proxy::id(&ter))); - vec - }; - Ok((Message { sender_id: self.id.clone(), opcode: 5u16, args }, child_spec)) - } - Request::NewidAndAllowNull { sec, ter } => { - let child_spec = { - let my_info = conn.object_info(self.id())?; - Some((super::quad::Quad::interface(), my_info.version)) - }; - let args = { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::NewId(ObjectId::null())); - vec.push(if let Some(obj) = sec { - Argument::Object(Proxy::id(&obj)) - } else { - Argument::Object(ObjectId::null()) - }); - vec.push(Argument::Object(Proxy::id(&ter))); - vec - }; - Ok((Message { sender_id: self.id.clone(), opcode: 6u16, args }, child_spec)) - } - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - impl TestGlobal { - #[doc = "a request with every possible non-object arg"] - #[allow(clippy::too_many_arguments)] - pub fn many_args( - &self, - unsigned_int: u32, - signed_int: i32, - fixed_point: f64, - number_array: Vec, - some_text: String, - file_descriptor: ::std::os::unix::io::BorrowedFd<'_>, - ) { - let backend = match self.backend.upgrade() { - Some(b) => b, - None => return, - }; - let conn = Connection::from_backend(backend); - let _ = conn.send_request( - self, - Request::ManyArgs { - unsigned_int, - signed_int, - fixed_point, - number_array, - some_text, - file_descriptor, - }, - None, - ); - } - #[allow(clippy::too_many_arguments)] - pub fn get_secondary< - U: Send + Sync + 'static, - D: Dispatch + 'static, - >( - &self, - qh: &QueueHandle, - udata: U, - ) -> super::secondary::Secondary { - self.send_constructor( - Request::GetSecondary {}, - qh.make_data::(udata), - ) - .unwrap_or_else(|_| Proxy::inert(self.backend.clone())) - } - #[allow(clippy::too_many_arguments)] - pub fn get_tertiary< - U: Send + Sync + 'static, - D: Dispatch + 'static, - >( - &self, - qh: &QueueHandle, - udata: U, - ) -> super::tertiary::Tertiary { - self.send_constructor( - Request::GetTertiary {}, - qh.make_data::(udata), - ) - .unwrap_or_else(|_| Proxy::inert(self.backend.clone())) - } - #[doc = "link a secondary and a tertiary"] - #[allow(clippy::too_many_arguments)] - pub fn link( - &self, - sec: &super::secondary::Secondary, - ter: Option<&super::tertiary::Tertiary>, - time: u32, - ) { - let backend = match self.backend.upgrade() { - Some(b) => b, - None => return, - }; - let conn = Connection::from_backend(backend); - let _ = conn.send_request( - self, - Request::Link { sec: sec.clone(), ter: ter.cloned(), time }, - None, - ); - } - #[allow(clippy::too_many_arguments)] - pub fn destroy(&self) { - let backend = match self.backend.upgrade() { - Some(b) => b, - None => return, - }; - let conn = Connection::from_backend(backend); - let _ = conn.send_request(self, Request::Destroy {}, None); - } - #[doc = "reverse link a secondary and a tertiary"] - #[allow(clippy::too_many_arguments)] - pub fn reverse_link( - &self, - sec: Option<&super::secondary::Secondary>, - ter: &super::tertiary::Tertiary, - ) { - let backend = match self.backend.upgrade() { - Some(b) => b, - None => return, - }; - let conn = Connection::from_backend(backend); - let _ = conn.send_request( - self, - Request::ReverseLink { sec: sec.cloned(), ter: ter.clone() }, - None, - ); - } - #[doc = "a newid request that also takes allow null arg"] - #[allow(clippy::too_many_arguments)] - pub fn newid_and_allow_null< - U: Send + Sync + 'static, - D: Dispatch + 'static, - >( - &self, - sec: Option<&super::secondary::Secondary>, - ter: &super::tertiary::Tertiary, - qh: &QueueHandle, - udata: U, - ) -> super::quad::Quad { - self.send_constructor( - Request::NewidAndAllowNull { sec: sec.cloned(), ter: ter.clone() }, - qh.make_data::(udata), - ) - .unwrap_or_else(|_| Proxy::inert(self.backend.clone())) - } - } -} -pub mod secondary { - use super::wayland_client::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, Backend, InvalidId, ObjectData, ObjectId, WeakBackend, - }, - Connection, Dispatch, DispatchError, Proxy, QueueHandle, QueueProxyData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_DESTROY_SINCE: u32 = 2u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_DESTROY_OPCODE: u16 = 0u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request<'a> { - #[doc = "This is a destructor, once sent this object cannot be used any longer.\nOnly available since version 2 of the interface"] - Destroy, - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Request<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::Destroy => 0u16, - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event {} - impl Event { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self {} - } - } - #[doc = "secondary\n\nThis interface has no events."] - #[derive(Debug, Clone)] - pub struct Secondary { - id: ObjectId, - version: u32, - data: Option>, - backend: WeakBackend, - } - impl std::cmp::PartialEq for Secondary { - fn eq(&self, other: &Secondary) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for Secondary {} - impl PartialEq> for Secondary { - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for Secondary { - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for Secondary { - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_client::Proxy for Secondary { - type Request<'request> = Request<'request>; - type Event = Event; - #[inline] - fn interface() -> &'static Interface { - &super::SECONDARY_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data.as_ref().and_then(|arc| arc.data_as_any().downcast_ref::()) - } - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn backend(&self) -> &WeakBackend { - &self.backend - } - fn send_request(&self, req: Self::Request<'_>) -> Result<(), InvalidId> { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, None)?; - debug_assert!(id.is_null()); - Ok(()) - } - fn send_constructor( - &self, - req: Self::Request<'_>, - data: Arc, - ) -> Result { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, Some(data))?; - Proxy::from_id(&conn, id) - } - #[inline] - fn from_id(conn: &Connection, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - let backend = conn.backend().downgrade(); - Ok(Secondary { id, data, version, backend }) - } - #[inline] - fn inert(backend: WeakBackend) -> Self { - Secondary { id: ObjectId::null(), data: None, version: 0, backend } - } - fn parse_event( - conn: &Connection, - msg: Message, - ) -> Result<(Self, Self::Event), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_request<'a>( - &self, - conn: &Connection, - msg: Self::Request<'a>, - ) -> Result< - (Message>, Option<(&'static Interface, u32)>), - InvalidId, - > { - match msg { - Request::Destroy {} => { - let child_spec = None; - let args = smallvec::SmallVec::new(); - Ok((Message { sender_id: self.id.clone(), opcode: 0u16, args }, child_spec)) - } - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - impl Secondary { - #[allow(clippy::too_many_arguments)] - pub fn destroy(&self) { - let backend = match self.backend.upgrade() { - Some(b) => b, - None => return, - }; - let conn = Connection::from_backend(backend); - let _ = conn.send_request(self, Request::Destroy {}, None); - } - } -} -pub mod tertiary { - use super::wayland_client::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, Backend, InvalidId, ObjectData, ObjectId, WeakBackend, - }, - Connection, Dispatch, DispatchError, Proxy, QueueHandle, QueueProxyData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_DESTROY_SINCE: u32 = 3u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_DESTROY_OPCODE: u16 = 0u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request<'a> { - #[doc = "This is a destructor, once sent this object cannot be used any longer.\nOnly available since version 3 of the interface"] - Destroy, - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Request<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::Destroy => 0u16, - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event {} - impl Event { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self {} - } - } - #[doc = "tertiary\n\nThis interface has no events."] - #[derive(Debug, Clone)] - pub struct Tertiary { - id: ObjectId, - version: u32, - data: Option>, - backend: WeakBackend, - } - impl std::cmp::PartialEq for Tertiary { - fn eq(&self, other: &Tertiary) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for Tertiary {} - impl PartialEq> for Tertiary { - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for Tertiary { - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for Tertiary { - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_client::Proxy for Tertiary { - type Request<'request> = Request<'request>; - type Event = Event; - #[inline] - fn interface() -> &'static Interface { - &super::TERTIARY_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data.as_ref().and_then(|arc| arc.data_as_any().downcast_ref::()) - } - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn backend(&self) -> &WeakBackend { - &self.backend - } - fn send_request(&self, req: Self::Request<'_>) -> Result<(), InvalidId> { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, None)?; - debug_assert!(id.is_null()); - Ok(()) - } - fn send_constructor( - &self, - req: Self::Request<'_>, - data: Arc, - ) -> Result { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, Some(data))?; - Proxy::from_id(&conn, id) - } - #[inline] - fn from_id(conn: &Connection, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - let backend = conn.backend().downgrade(); - Ok(Tertiary { id, data, version, backend }) - } - #[inline] - fn inert(backend: WeakBackend) -> Self { - Tertiary { id: ObjectId::null(), data: None, version: 0, backend } - } - fn parse_event( - conn: &Connection, - msg: Message, - ) -> Result<(Self, Self::Event), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_request<'a>( - &self, - conn: &Connection, - msg: Self::Request<'a>, - ) -> Result< - (Message>, Option<(&'static Interface, u32)>), - InvalidId, - > { - match msg { - Request::Destroy {} => { - let child_spec = None; - let args = smallvec::SmallVec::new(); - Ok((Message { sender_id: self.id.clone(), opcode: 0u16, args }, child_spec)) - } - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - impl Tertiary { - #[allow(clippy::too_many_arguments)] - pub fn destroy(&self) { - let backend = match self.backend.upgrade() { - Some(b) => b, - None => return, - }; - let conn = Connection::from_backend(backend); - let _ = conn.send_request(self, Request::Destroy {}, None); - } - } -} -pub mod quad { - use super::wayland_client::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, Backend, InvalidId, ObjectData, ObjectId, WeakBackend, - }, - Connection, Dispatch, DispatchError, Proxy, QueueHandle, QueueProxyData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_DESTROY_SINCE: u32 = 3u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_DESTROY_OPCODE: u16 = 0u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request<'a> { - #[doc = "This is a destructor, once sent this object cannot be used any longer.\nOnly available since version 3 of the interface"] - Destroy, - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Request<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::Destroy => 0u16, - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event {} - impl Event { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self {} - } - } - #[doc = "quad\n\nThis interface has no events."] - #[derive(Debug, Clone)] - pub struct Quad { - id: ObjectId, - version: u32, - data: Option>, - backend: WeakBackend, - } - impl std::cmp::PartialEq for Quad { - fn eq(&self, other: &Quad) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for Quad {} - impl PartialEq> for Quad { - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for Quad { - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for Quad { - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_client::Proxy for Quad { - type Request<'request> = Request<'request>; - type Event = Event; - #[inline] - fn interface() -> &'static Interface { - &super::QUAD_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data.as_ref().and_then(|arc| arc.data_as_any().downcast_ref::()) - } - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn backend(&self) -> &WeakBackend { - &self.backend - } - fn send_request(&self, req: Self::Request<'_>) -> Result<(), InvalidId> { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, None)?; - debug_assert!(id.is_null()); - Ok(()) - } - fn send_constructor( - &self, - req: Self::Request<'_>, - data: Arc, - ) -> Result { - let conn = Connection::from_backend(self.backend.upgrade().ok_or(InvalidId)?); - let id = conn.send_request(self, req, Some(data))?; - Proxy::from_id(&conn, id) - } - #[inline] - fn from_id(conn: &Connection, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - let backend = conn.backend().downgrade(); - Ok(Quad { id, data, version, backend }) - } - #[inline] - fn inert(backend: WeakBackend) -> Self { - Quad { id: ObjectId::null(), data: None, version: 0, backend } - } - fn parse_event( - conn: &Connection, - msg: Message, - ) -> Result<(Self, Self::Event), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_request<'a>( - &self, - conn: &Connection, - msg: Self::Request<'a>, - ) -> Result< - (Message>, Option<(&'static Interface, u32)>), - InvalidId, - > { - match msg { - Request::Destroy {} => { - let child_spec = None; - let args = smallvec::SmallVec::new(); - Ok((Message { sender_id: self.id.clone(), opcode: 0u16, args }, child_spec)) - } - Request::__phantom_lifetime { never, .. } => match never {}, - } - } - } - impl Quad { - #[allow(clippy::too_many_arguments)] - pub fn destroy(&self) { - let backend = match self.backend.upgrade() { - Some(b) => b, - None => return, - }; - let conn = Connection::from_backend(backend); - let _ = conn.send_request(self, Request::Destroy {}, None); - } - } -} diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-headerless-protocol.xml b/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-headerless-protocol.xml deleted file mode 100644 index 3ba47b946..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-headerless-protocol.xml +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-interfaces.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-interfaces.rs deleted file mode 100644 index c67be05b6..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-interfaces.rs +++ /dev/null @@ -1,524 +0,0 @@ -use std::ptr::null; -struct SyncWrapper(T); -unsafe impl Sync for SyncWrapper {} -static types_null: SyncWrapper<[*const wayland_backend::protocol::wl_interface; 6]> = - SyncWrapper([null::(); 6]); -pub static WL_DISPLAY_INTERFACE: wayland_backend::protocol::Interface = - wayland_backend::protocol::Interface { - name: "wl_display", - version: 1u32, - requests: &[ - wayland_backend::protocol::MessageDesc { - name: "sync", - signature: &[wayland_backend::protocol::ArgumentType::NewId], - since: 1u32, - is_destructor: false, - child_interface: Some(&WL_CALLBACK_INTERFACE), - arg_interfaces: &[], - }, - wayland_backend::protocol::MessageDesc { - name: "get_registry", - signature: &[wayland_backend::protocol::ArgumentType::NewId], - since: 1u32, - is_destructor: false, - child_interface: Some(&WL_REGISTRY_INTERFACE), - arg_interfaces: &[], - }, - ], - events: &[ - wayland_backend::protocol::MessageDesc { - name: "error", - signature: &[ - wayland_backend::protocol::ArgumentType::Object( - wayland_backend::protocol::AllowNull::No, - ), - wayland_backend::protocol::ArgumentType::Uint, - wayland_backend::protocol::ArgumentType::Str( - wayland_backend::protocol::AllowNull::No, - ), - ], - since: 1u32, - is_destructor: false, - child_interface: None, - arg_interfaces: &[&wayland_backend::protocol::ANONYMOUS_INTERFACE], - }, - wayland_backend::protocol::MessageDesc { - name: "delete_id", - signature: &[wayland_backend::protocol::ArgumentType::Uint], - since: 1u32, - is_destructor: false, - child_interface: None, - arg_interfaces: &[], - }, - ], - c_ptr: Some(unsafe { &wl_display_interface }), - }; -static wl_display_requests_sync_types: SyncWrapper< - [*const wayland_backend::protocol::wl_interface; 1], -> = SyncWrapper([&wl_callback_interface as *const wayland_backend::protocol::wl_interface]); -static wl_display_requests_get_registry_types: SyncWrapper< - [*const wayland_backend::protocol::wl_interface; 1], -> = SyncWrapper([&wl_registry_interface as *const wayland_backend::protocol::wl_interface]); -static wl_display_requests: SyncWrapper<[wayland_backend::protocol::wl_message; 2]> = - SyncWrapper([ - wayland_backend::protocol::wl_message { - name: b"sync\0" as *const u8 as *const std::os::raw::c_char, - signature: b"n\0" as *const u8 as *const std::os::raw::c_char, - types: wl_display_requests_sync_types.0.as_ptr(), - }, - wayland_backend::protocol::wl_message { - name: b"get_registry\0" as *const u8 as *const std::os::raw::c_char, - signature: b"n\0" as *const u8 as *const std::os::raw::c_char, - types: wl_display_requests_get_registry_types.0.as_ptr(), - }, - ]); -static wl_display_events: SyncWrapper<[wayland_backend::protocol::wl_message; 2]> = SyncWrapper([ - wayland_backend::protocol::wl_message { - name: b"error\0" as *const u8 as *const std::os::raw::c_char, - signature: b"ous\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }, - wayland_backend::protocol::wl_message { - name: b"delete_id\0" as *const u8 as *const std::os::raw::c_char, - signature: b"u\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }, -]); -pub static wl_display_interface: wayland_backend::protocol::wl_interface = - wayland_backend::protocol::wl_interface { - name: b"wl_display\0" as *const u8 as *const std::os::raw::c_char, - version: 1, - request_count: 2, - requests: wl_display_requests.0.as_ptr(), - event_count: 2, - events: wl_display_events.0.as_ptr(), - }; -pub static WL_REGISTRY_INTERFACE: wayland_backend::protocol::Interface = - wayland_backend::protocol::Interface { - name: "wl_registry", - version: 1u32, - requests: &[wayland_backend::protocol::MessageDesc { - name: "bind", - signature: &[ - wayland_backend::protocol::ArgumentType::Uint, - wayland_backend::protocol::ArgumentType::Str( - wayland_backend::protocol::AllowNull::No, - ), - wayland_backend::protocol::ArgumentType::Uint, - wayland_backend::protocol::ArgumentType::NewId, - ], - since: 1u32, - is_destructor: false, - child_interface: None, - arg_interfaces: &[], - }], - events: &[ - wayland_backend::protocol::MessageDesc { - name: "global", - signature: &[ - wayland_backend::protocol::ArgumentType::Uint, - wayland_backend::protocol::ArgumentType::Str( - wayland_backend::protocol::AllowNull::No, - ), - wayland_backend::protocol::ArgumentType::Uint, - ], - since: 1u32, - is_destructor: false, - child_interface: None, - arg_interfaces: &[], - }, - wayland_backend::protocol::MessageDesc { - name: "global_remove", - signature: &[wayland_backend::protocol::ArgumentType::Uint], - since: 1u32, - is_destructor: false, - child_interface: None, - arg_interfaces: &[], - }, - ], - c_ptr: Some(unsafe { &wl_registry_interface }), - }; -static wl_registry_requests: SyncWrapper<[wayland_backend::protocol::wl_message; 1]> = - SyncWrapper([wayland_backend::protocol::wl_message { - name: b"bind\0" as *const u8 as *const std::os::raw::c_char, - signature: b"usun\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }]); -static wl_registry_events: SyncWrapper<[wayland_backend::protocol::wl_message; 2]> = SyncWrapper([ - wayland_backend::protocol::wl_message { - name: b"global\0" as *const u8 as *const std::os::raw::c_char, - signature: b"usu\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }, - wayland_backend::protocol::wl_message { - name: b"global_remove\0" as *const u8 as *const std::os::raw::c_char, - signature: b"u\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }, -]); -pub static wl_registry_interface: wayland_backend::protocol::wl_interface = - wayland_backend::protocol::wl_interface { - name: b"wl_registry\0" as *const u8 as *const std::os::raw::c_char, - version: 1, - request_count: 1, - requests: wl_registry_requests.0.as_ptr(), - event_count: 2, - events: wl_registry_events.0.as_ptr(), - }; -pub static WL_CALLBACK_INTERFACE: wayland_backend::protocol::Interface = - wayland_backend::protocol::Interface { - name: "wl_callback", - version: 1u32, - requests: &[], - events: &[wayland_backend::protocol::MessageDesc { - name: "done", - signature: &[wayland_backend::protocol::ArgumentType::Uint], - since: 1u32, - is_destructor: true, - child_interface: None, - arg_interfaces: &[], - }], - c_ptr: Some(unsafe { &wl_callback_interface }), - }; -static wl_callback_events: SyncWrapper<[wayland_backend::protocol::wl_message; 1]> = - SyncWrapper([wayland_backend::protocol::wl_message { - name: b"done\0" as *const u8 as *const std::os::raw::c_char, - signature: b"u\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }]); -pub static wl_callback_interface: wayland_backend::protocol::wl_interface = - wayland_backend::protocol::wl_interface { - name: b"wl_callback\0" as *const u8 as *const std::os::raw::c_char, - version: 1, - request_count: 0, - requests: null::(), - event_count: 1, - events: wl_callback_events.0.as_ptr(), - }; -pub static TEST_GLOBAL_INTERFACE: wayland_backend::protocol::Interface = - wayland_backend::protocol::Interface { - name: "test_global", - version: 5u32, - requests: &[ - wayland_backend::protocol::MessageDesc { - name: "many_args", - signature: &[ - wayland_backend::protocol::ArgumentType::Uint, - wayland_backend::protocol::ArgumentType::Int, - wayland_backend::protocol::ArgumentType::Fixed, - wayland_backend::protocol::ArgumentType::Array, - wayland_backend::protocol::ArgumentType::Str( - wayland_backend::protocol::AllowNull::No, - ), - wayland_backend::protocol::ArgumentType::Fd, - ], - since: 1u32, - is_destructor: false, - child_interface: None, - arg_interfaces: &[], - }, - wayland_backend::protocol::MessageDesc { - name: "get_secondary", - signature: &[wayland_backend::protocol::ArgumentType::NewId], - since: 2u32, - is_destructor: false, - child_interface: Some(&SECONDARY_INTERFACE), - arg_interfaces: &[], - }, - wayland_backend::protocol::MessageDesc { - name: "get_tertiary", - signature: &[wayland_backend::protocol::ArgumentType::NewId], - since: 3u32, - is_destructor: false, - child_interface: Some(&TERTIARY_INTERFACE), - arg_interfaces: &[], - }, - wayland_backend::protocol::MessageDesc { - name: "link", - signature: &[ - wayland_backend::protocol::ArgumentType::Object( - wayland_backend::protocol::AllowNull::No, - ), - wayland_backend::protocol::ArgumentType::Object( - wayland_backend::protocol::AllowNull::Yes, - ), - wayland_backend::protocol::ArgumentType::Uint, - ], - since: 3u32, - is_destructor: false, - child_interface: None, - arg_interfaces: &[&SECONDARY_INTERFACE, &TERTIARY_INTERFACE], - }, - wayland_backend::protocol::MessageDesc { - name: "destroy", - signature: &[], - since: 4u32, - is_destructor: true, - child_interface: None, - arg_interfaces: &[], - }, - wayland_backend::protocol::MessageDesc { - name: "reverse_link", - signature: &[ - wayland_backend::protocol::ArgumentType::Object( - wayland_backend::protocol::AllowNull::Yes, - ), - wayland_backend::protocol::ArgumentType::Object( - wayland_backend::protocol::AllowNull::No, - ), - ], - since: 5u32, - is_destructor: false, - child_interface: None, - arg_interfaces: &[&SECONDARY_INTERFACE, &TERTIARY_INTERFACE], - }, - wayland_backend::protocol::MessageDesc { - name: "newid_and_allow_null", - signature: &[ - wayland_backend::protocol::ArgumentType::NewId, - wayland_backend::protocol::ArgumentType::Object( - wayland_backend::protocol::AllowNull::Yes, - ), - wayland_backend::protocol::ArgumentType::Object( - wayland_backend::protocol::AllowNull::No, - ), - ], - since: 5u32, - is_destructor: false, - child_interface: Some(&QUAD_INTERFACE), - arg_interfaces: &[&SECONDARY_INTERFACE, &TERTIARY_INTERFACE], - }, - ], - events: &[ - wayland_backend::protocol::MessageDesc { - name: "many_args_evt", - signature: &[ - wayland_backend::protocol::ArgumentType::Uint, - wayland_backend::protocol::ArgumentType::Int, - wayland_backend::protocol::ArgumentType::Fixed, - wayland_backend::protocol::ArgumentType::Array, - wayland_backend::protocol::ArgumentType::Str( - wayland_backend::protocol::AllowNull::No, - ), - wayland_backend::protocol::ArgumentType::Fd, - ], - since: 1u32, - is_destructor: false, - child_interface: None, - arg_interfaces: &[], - }, - wayland_backend::protocol::MessageDesc { - name: "ack_secondary", - signature: &[wayland_backend::protocol::ArgumentType::Object( - wayland_backend::protocol::AllowNull::No, - )], - since: 1u32, - is_destructor: false, - child_interface: None, - arg_interfaces: &[&SECONDARY_INTERFACE], - }, - wayland_backend::protocol::MessageDesc { - name: "cycle_quad", - signature: &[ - wayland_backend::protocol::ArgumentType::NewId, - wayland_backend::protocol::ArgumentType::Object( - wayland_backend::protocol::AllowNull::Yes, - ), - ], - since: 1u32, - is_destructor: false, - child_interface: Some(&QUAD_INTERFACE), - arg_interfaces: &[&QUAD_INTERFACE], - }, - ], - c_ptr: Some(unsafe { &test_global_interface }), - }; -static test_global_requests_get_secondary_types: SyncWrapper< - [*const wayland_backend::protocol::wl_interface; 1], -> = SyncWrapper([&secondary_interface as *const wayland_backend::protocol::wl_interface]); -static test_global_requests_get_tertiary_types: SyncWrapper< - [*const wayland_backend::protocol::wl_interface; 1], -> = SyncWrapper([&tertiary_interface as *const wayland_backend::protocol::wl_interface]); -static test_global_requests_link_types: SyncWrapper< - [*const wayland_backend::protocol::wl_interface; 3], -> = SyncWrapper([ - &secondary_interface as *const wayland_backend::protocol::wl_interface, - &tertiary_interface as *const wayland_backend::protocol::wl_interface, - null::(), -]); -static test_global_requests_reverse_link_types: SyncWrapper< - [*const wayland_backend::protocol::wl_interface; 2], -> = SyncWrapper([ - &secondary_interface as *const wayland_backend::protocol::wl_interface, - &tertiary_interface as *const wayland_backend::protocol::wl_interface, -]); -static test_global_requests_newid_and_allow_null_types: SyncWrapper< - [*const wayland_backend::protocol::wl_interface; 3], -> = SyncWrapper([ - &quad_interface as *const wayland_backend::protocol::wl_interface, - &secondary_interface as *const wayland_backend::protocol::wl_interface, - &tertiary_interface as *const wayland_backend::protocol::wl_interface, -]); -static test_global_requests: SyncWrapper<[wayland_backend::protocol::wl_message; 7]> = - SyncWrapper([ - wayland_backend::protocol::wl_message { - name: b"many_args\0" as *const u8 as *const std::os::raw::c_char, - signature: b"uifash\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }, - wayland_backend::protocol::wl_message { - name: b"get_secondary\0" as *const u8 as *const std::os::raw::c_char, - signature: b"2n\0" as *const u8 as *const std::os::raw::c_char, - types: test_global_requests_get_secondary_types.0.as_ptr(), - }, - wayland_backend::protocol::wl_message { - name: b"get_tertiary\0" as *const u8 as *const std::os::raw::c_char, - signature: b"3n\0" as *const u8 as *const std::os::raw::c_char, - types: test_global_requests_get_tertiary_types.0.as_ptr(), - }, - wayland_backend::protocol::wl_message { - name: b"link\0" as *const u8 as *const std::os::raw::c_char, - signature: b"3o?ou\0" as *const u8 as *const std::os::raw::c_char, - types: test_global_requests_link_types.0.as_ptr(), - }, - wayland_backend::protocol::wl_message { - name: b"destroy\0" as *const u8 as *const std::os::raw::c_char, - signature: b"4\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }, - wayland_backend::protocol::wl_message { - name: b"reverse_link\0" as *const u8 as *const std::os::raw::c_char, - signature: b"5?oo\0" as *const u8 as *const std::os::raw::c_char, - types: test_global_requests_reverse_link_types.0.as_ptr(), - }, - wayland_backend::protocol::wl_message { - name: b"newid_and_allow_null\0" as *const u8 as *const std::os::raw::c_char, - signature: b"5n?oo\0" as *const u8 as *const std::os::raw::c_char, - types: test_global_requests_newid_and_allow_null_types.0.as_ptr(), - }, - ]); -static test_global_events_ack_secondary_types: SyncWrapper< - [*const wayland_backend::protocol::wl_interface; 1], -> = SyncWrapper([&secondary_interface as *const wayland_backend::protocol::wl_interface]); -static test_global_events_cycle_quad_types: SyncWrapper< - [*const wayland_backend::protocol::wl_interface; 2], -> = SyncWrapper([ - &quad_interface as *const wayland_backend::protocol::wl_interface, - &quad_interface as *const wayland_backend::protocol::wl_interface, -]); -static test_global_events: SyncWrapper<[wayland_backend::protocol::wl_message; 3]> = SyncWrapper([ - wayland_backend::protocol::wl_message { - name: b"many_args_evt\0" as *const u8 as *const std::os::raw::c_char, - signature: b"uifash\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }, - wayland_backend::protocol::wl_message { - name: b"ack_secondary\0" as *const u8 as *const std::os::raw::c_char, - signature: b"o\0" as *const u8 as *const std::os::raw::c_char, - types: test_global_events_ack_secondary_types.0.as_ptr(), - }, - wayland_backend::protocol::wl_message { - name: b"cycle_quad\0" as *const u8 as *const std::os::raw::c_char, - signature: b"n?o\0" as *const u8 as *const std::os::raw::c_char, - types: test_global_events_cycle_quad_types.0.as_ptr(), - }, -]); -pub static test_global_interface: wayland_backend::protocol::wl_interface = - wayland_backend::protocol::wl_interface { - name: b"test_global\0" as *const u8 as *const std::os::raw::c_char, - version: 5, - request_count: 7, - requests: test_global_requests.0.as_ptr(), - event_count: 3, - events: test_global_events.0.as_ptr(), - }; -pub static SECONDARY_INTERFACE: wayland_backend::protocol::Interface = - wayland_backend::protocol::Interface { - name: "secondary", - version: 5u32, - requests: &[wayland_backend::protocol::MessageDesc { - name: "destroy", - signature: &[], - since: 2u32, - is_destructor: true, - child_interface: None, - arg_interfaces: &[], - }], - events: &[], - c_ptr: Some(unsafe { &secondary_interface }), - }; -static secondary_requests: SyncWrapper<[wayland_backend::protocol::wl_message; 1]> = - SyncWrapper([wayland_backend::protocol::wl_message { - name: b"destroy\0" as *const u8 as *const std::os::raw::c_char, - signature: b"2\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }]); -pub static secondary_interface: wayland_backend::protocol::wl_interface = - wayland_backend::protocol::wl_interface { - name: b"secondary\0" as *const u8 as *const std::os::raw::c_char, - version: 5, - request_count: 1, - requests: secondary_requests.0.as_ptr(), - event_count: 0, - events: null::(), - }; -pub static TERTIARY_INTERFACE: wayland_backend::protocol::Interface = - wayland_backend::protocol::Interface { - name: "tertiary", - version: 5u32, - requests: &[wayland_backend::protocol::MessageDesc { - name: "destroy", - signature: &[], - since: 3u32, - is_destructor: true, - child_interface: None, - arg_interfaces: &[], - }], - events: &[], - c_ptr: Some(unsafe { &tertiary_interface }), - }; -static tertiary_requests: SyncWrapper<[wayland_backend::protocol::wl_message; 1]> = - SyncWrapper([wayland_backend::protocol::wl_message { - name: b"destroy\0" as *const u8 as *const std::os::raw::c_char, - signature: b"3\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }]); -pub static tertiary_interface: wayland_backend::protocol::wl_interface = - wayland_backend::protocol::wl_interface { - name: b"tertiary\0" as *const u8 as *const std::os::raw::c_char, - version: 5, - request_count: 1, - requests: tertiary_requests.0.as_ptr(), - event_count: 0, - events: null::(), - }; -pub static QUAD_INTERFACE: wayland_backend::protocol::Interface = - wayland_backend::protocol::Interface { - name: "quad", - version: 5u32, - requests: &[wayland_backend::protocol::MessageDesc { - name: "destroy", - signature: &[], - since: 3u32, - is_destructor: true, - child_interface: None, - arg_interfaces: &[], - }], - events: &[], - c_ptr: Some(unsafe { &quad_interface }), - }; -static quad_requests: SyncWrapper<[wayland_backend::protocol::wl_message; 1]> = - SyncWrapper([wayland_backend::protocol::wl_message { - name: b"destroy\0" as *const u8 as *const std::os::raw::c_char, - signature: b"3\0" as *const u8 as *const std::os::raw::c_char, - types: types_null.0.as_ptr(), - }]); -pub static quad_interface: wayland_backend::protocol::wl_interface = - wayland_backend::protocol::wl_interface { - name: b"quad\0" as *const u8 as *const std::os::raw::c_char, - version: 5, - request_count: 1, - requests: quad_requests.0.as_ptr(), - event_count: 0, - events: null::(), - }; diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-protocol.xml b/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-protocol.xml deleted file mode 100644 index 8e727011b..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-protocol.xml +++ /dev/null @@ -1,253 +0,0 @@ - - - - - A dummy copyright to make the parser work - - Including a few more lines. - Of text. - - - - - - - The core global object. This is a special singleton object. It - is used for internal Wayland protocol features. - - - - - The sync request asks the server to emit the 'done' event - on the returned wl_callback object. Since requests are - handled in-order and events are delivered in-order, this can - be used as a barrier to ensure all previous requests and the - resulting events have been handled. - - The object returned by this request will be destroyed by the - compositor after the callback is fired and as such the client must not - attempt to use it after that point. - - The callback_data passed in the callback is the event serial. - - - - - - - This request creates a registry object that allows the client - to list and bind the global objects available from the - compositor. - - It should be noted that the server side resources consumed in - response to a get_registry request can only be released when the - client disconnects, not when the client side proxy is destroyed. - Therefore, clients should invoke get_registry as infrequently as - possible to avoid wasting memory. - - - - - - - The error event is sent out when a fatal (non-recoverable) - error has occurred. The object_id argument is the object - where the error occurred, most often in response to a request - to that object. The code identifies the error and is defined - by the object interface. As such, each interface defines its - own set of error codes. The message is a brief description - of the error, for (debugging) convenience. - - - - - - - - - These errors are global and can be emitted in response to any - server request. - - - - - - - - - - This event is used internally by the object ID management - logic. When a client deletes an object that it had created, - the server will send this event to acknowledge that it has - seen the delete request. When the client receives this event, - it will know that it can safely reuse the object ID. - - - - - - - - The singleton global registry object. The server has a number of - global objects that are available to all clients. These objects - typically represent an actual object in the server (for example, - an input device) or they are singleton objects that provide - extension functionality. - - When a client creates a registry object, the registry object - will emit a global event for each global currently in the - registry. Globals come and go as a result of device or - monitor hotplugs, reconfiguration or other events, and the - registry will send out global and global_remove events to - keep the client up to date with the changes. To mark the end - of the initial burst of events, the client can use the - wl_display.sync request immediately after calling - wl_display.get_registry. - - A client can bind to a global object by using the bind - request. This creates a client-side handle that lets the object - emit events to the client and lets the client invoke requests on - the object. - - - - - Binds a new, client-created object to the server using the - specified name as the identifier. - - - - - - - - Notify the client of global objects. - - The event notifies the client that a global object with - the given name is now available, and it implements the - given version of the given interface. - - - - - - - - - Notify the client of removed global objects. - - This event notifies the client that the global identified - by name is no longer available. If the client bound to - the global using the bind request, the client should now - destroy that object. - - The object remains valid and requests to the object will be - ignored until the client destroys it, to avoid races between - the global going away and a client sending a request to it. - - - - - - - - Clients can handle the 'done' event to get notified when - the related request is done. - - - - - Notify the client when the related request is done. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-server-code.rs b/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-server-code.rs deleted file mode 100644 index b600075f9..000000000 --- a/openless-all/app/src-tauri/vendor/wayland-scanner/tests/scanner_assets/test-server-code.rs +++ /dev/null @@ -1,1474 +0,0 @@ -#[doc = "global registry object\n\nThe singleton global registry object. The server has a number of\nglobal objects that are available to all clients. These objects\ntypically represent an actual object in the server (for example,\nan input device) or they are singleton objects that provide\nextension functionality.\n\nWhen a client creates a registry object, the registry object\nwill emit a global event for each global currently in the\nregistry. Globals come and go as a result of device or\nmonitor hotplugs, reconfiguration or other events, and the\nregistry will send out global and global_remove events to\nkeep the client up to date with the changes. To mark the end\nof the initial burst of events, the client can use the\nwl_display.sync request immediately after calling\nwl_display.get_registry.\n\nA client can bind to a global object by using the bind\nrequest. This creates a client-side handle that lets the object\nemit events to the client and lets the client invoke requests on\nthe object."] -pub mod wl_registry { - use super::wayland_server::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, InvalidId, ObjectData, ObjectId, WeakHandle, - }, - Dispatch, DispatchError, DisplayHandle, New, Resource, ResourceData, Weak, - }; - use std::os::unix::io::OwnedFd; - use std::sync::Arc; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_BIND_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_BIND_OPCODE: u16 = 0u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_GLOBAL_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_GLOBAL_OPCODE: u16 = 0u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_GLOBAL_REMOVE_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_GLOBAL_REMOVE_OPCODE: u16 = 1u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request { - #[doc = "bind an object to the display\n\nBinds a new, client-created object to the server using the\nspecified name as the identifier."] - Bind { - #[doc = "unique numeric name of the object"] - name: u32, - #[doc = "bounded object"] - id: (String, u32, super::wayland_server::ObjectId), - }, - } - impl Request { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::Bind { .. } => 0u16, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event<'a> { - #[doc = "announce global object\n\nNotify the client of global objects.\n\nThe event notifies the client that a global object with\nthe given name is now available, and it implements the\ngiven version of the given interface."] - Global { - #[doc = "numeric name of the global object"] - name: u32, - #[doc = "interface implemented by the object"] - interface: String, - #[doc = "interface version"] - version: u32, - }, - #[doc = "announce removal of global object\n\nNotify the client of removed global objects.\n\nThis event notifies the client that the global identified\nby name is no longer available. If the client bound to\nthe global using the bind request, the client should now\ndestroy that object.\n\nThe object remains valid and requests to the object will be\nignored until the client destroys it, to avoid races between\nthe global going away and a client sending a request to it."] - GlobalRemove { - #[doc = "numeric name of the global object"] - name: u32, - }, - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Event<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Event::Global { .. } => 0u16, - Event::GlobalRemove { .. } => 1u16, - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[doc = "global registry object\n\nThe singleton global registry object. The server has a number of\nglobal objects that are available to all clients. These objects\ntypically represent an actual object in the server (for example,\nan input device) or they are singleton objects that provide\nextension functionality.\n\nWhen a client creates a registry object, the registry object\nwill emit a global event for each global currently in the\nregistry. Globals come and go as a result of device or\nmonitor hotplugs, reconfiguration or other events, and the\nregistry will send out global and global_remove events to\nkeep the client up to date with the changes. To mark the end\nof the initial burst of events, the client can use the\nwl_display.sync request immediately after calling\nwl_display.get_registry.\n\nA client can bind to a global object by using the bind\nrequest. This creates a client-side handle that lets the object\nemit events to the client and lets the client invoke requests on\nthe object.\n\nSee also the [Request] enum for this interface."] - #[derive(Debug, Clone)] - pub struct WlRegistry { - id: ObjectId, - version: u32, - data: Option>, - handle: WeakHandle, - } - impl std::cmp::PartialEq for WlRegistry { - #[inline] - fn eq(&self, other: &WlRegistry) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for WlRegistry {} - impl PartialEq> for WlRegistry { - #[inline] - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for WlRegistry { - #[inline] - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for WlRegistry { - #[inline] - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_server::Resource for WlRegistry { - type Request = Request; - type Event<'event> = Event<'event>; - #[inline] - fn interface() -> &'static Interface { - &super::WL_REGISTRY_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data - .as_ref() - .and_then(|arc| (&**arc).downcast_ref::>()) - .map(|data| &data.udata) - } - #[inline] - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn handle(&self) -> &WeakHandle { - &self.handle - } - #[inline] - fn from_id(conn: &DisplayHandle, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - Ok(WlRegistry { id, data, version, handle: conn.backend_handle().downgrade() }) - } - fn send_event(&self, evt: Self::Event<'_>) -> Result<(), InvalidId> { - let handle = DisplayHandle::from(self.handle.upgrade().ok_or(InvalidId)?); - handle.send_event(self, evt) - } - fn parse_request( - conn: &DisplayHandle, - msg: Message, - ) -> Result<(Self, Self::Request), DispatchError> { - unimplemented!("`wl_registry` is implemented internally in `wayland-server`") - } - fn write_event<'a>( - &self, - conn: &DisplayHandle, - msg: Self::Event<'a>, - ) -> Result>, InvalidId> { - match msg { - Event::Global { name, interface, version } => Ok(Message { - sender_id: self.id.clone(), - opcode: 0u16, - args: { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::Uint(name)); - vec.push(Argument::Str(Some(Box::new( - std::ffi::CString::new(interface).unwrap(), - )))); - vec.push(Argument::Uint(version)); - vec - }, - }), - Event::GlobalRemove { name } => Ok(Message { - sender_id: self.id.clone(), - opcode: 1u16, - args: { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::Uint(name)); - vec - }, - }), - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - fn __set_object_data( - &mut self, - odata: std::sync::Arc, - ) { - self.data = Some(odata); - } - } - impl WlRegistry { - #[doc = "announce global object\n\nNotify the client of global objects.\n\nThe event notifies the client that a global object with\nthe given name is now available, and it implements the\ngiven version of the given interface."] - #[allow(clippy::too_many_arguments)] - pub fn global(&self, name: u32, interface: String, version: u32) { - let _ = self.send_event(Event::Global { name, interface, version }); - } - #[doc = "announce removal of global object\n\nNotify the client of removed global objects.\n\nThis event notifies the client that the global identified\nby name is no longer available. If the client bound to\nthe global using the bind request, the client should now\ndestroy that object.\n\nThe object remains valid and requests to the object will be\nignored until the client destroys it, to avoid races between\nthe global going away and a client sending a request to it."] - #[allow(clippy::too_many_arguments)] - pub fn global_remove(&self, name: u32) { - let _ = self.send_event(Event::GlobalRemove { name }); - } - } -} -#[doc = "callback object\n\nClients can handle the 'done' event to get notified when\nthe related request is done."] -pub mod wl_callback { - use super::wayland_server::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, InvalidId, ObjectData, ObjectId, WeakHandle, - }, - Dispatch, DispatchError, DisplayHandle, New, Resource, ResourceData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_DONE_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_DONE_OPCODE: u16 = 0u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request {} - impl Request { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self {} - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event<'a> { - #[doc = "done event\n\nNotify the client when the related request is done.\n\nThis is a destructor, once sent this object cannot be used any longer."] - Done { - #[doc = "request-specific data for the callback"] - callback_data: u32, - }, - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Event<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Event::Done { .. } => 0u16, - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[doc = "callback object\n\nClients can handle the 'done' event to get notified when\nthe related request is done.\n\nThis interface has no requests."] - #[derive(Debug, Clone)] - pub struct WlCallback { - id: ObjectId, - version: u32, - data: Option>, - handle: WeakHandle, - } - impl std::cmp::PartialEq for WlCallback { - #[inline] - fn eq(&self, other: &WlCallback) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for WlCallback {} - impl PartialEq> for WlCallback { - #[inline] - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for WlCallback { - #[inline] - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for WlCallback { - #[inline] - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_server::Resource for WlCallback { - type Request = Request; - type Event<'event> = Event<'event>; - #[inline] - fn interface() -> &'static Interface { - &super::WL_CALLBACK_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data - .as_ref() - .and_then(|arc| (&**arc).downcast_ref::>()) - .map(|data| &data.udata) - } - #[inline] - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn handle(&self) -> &WeakHandle { - &self.handle - } - #[inline] - fn from_id(conn: &DisplayHandle, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - Ok(WlCallback { id, data, version, handle: conn.backend_handle().downgrade() }) - } - fn send_event(&self, evt: Self::Event<'_>) -> Result<(), InvalidId> { - let handle = DisplayHandle::from(self.handle.upgrade().ok_or(InvalidId)?); - handle.send_event(self, evt) - } - fn parse_request( - conn: &DisplayHandle, - msg: Message, - ) -> Result<(Self, Self::Request), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_event<'a>( - &self, - conn: &DisplayHandle, - msg: Self::Event<'a>, - ) -> Result>, InvalidId> { - match msg { - Event::Done { callback_data } => Ok(Message { - sender_id: self.id.clone(), - opcode: 0u16, - args: { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::Uint(callback_data)); - vec - }, - }), - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - fn __set_object_data( - &mut self, - odata: std::sync::Arc, - ) { - self.data = Some(odata); - } - } - impl WlCallback { - #[doc = "done event\n\nNotify the client when the related request is done."] - #[allow(clippy::too_many_arguments)] - pub fn done(&self, callback_data: u32) { - let _ = self.send_event(Event::Done { callback_data }); - } - } -} -pub mod test_global { - use super::wayland_server::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, InvalidId, ObjectData, ObjectId, WeakHandle, - }, - Dispatch, DispatchError, DisplayHandle, New, Resource, ResourceData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_MANY_ARGS_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_MANY_ARGS_OPCODE: u16 = 0u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_GET_SECONDARY_SINCE: u32 = 2u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_GET_SECONDARY_OPCODE: u16 = 1u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_GET_TERTIARY_SINCE: u32 = 3u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_GET_TERTIARY_OPCODE: u16 = 2u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_LINK_SINCE: u32 = 3u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_LINK_OPCODE: u16 = 3u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_DESTROY_SINCE: u32 = 4u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_DESTROY_OPCODE: u16 = 4u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_REVERSE_LINK_SINCE: u32 = 5u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_REVERSE_LINK_OPCODE: u16 = 5u16; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_NEWID_AND_ALLOW_NULL_SINCE: u32 = 5u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_NEWID_AND_ALLOW_NULL_OPCODE: u16 = 6u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_MANY_ARGS_EVT_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_MANY_ARGS_EVT_OPCODE: u16 = 0u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_ACK_SECONDARY_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_ACK_SECONDARY_OPCODE: u16 = 1u16; - #[doc = r" The minimal object version supporting this event"] - pub const EVT_CYCLE_QUAD_SINCE: u32 = 1u32; - #[doc = r" The wire opcode for this event"] - pub const EVT_CYCLE_QUAD_OPCODE: u16 = 2u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request { - #[doc = "a request with every possible non-object arg"] - ManyArgs { - #[doc = "an unsigned int"] - unsigned_int: u32, - #[doc = "a singed int"] - signed_int: i32, - #[doc = "a fixed point number"] - fixed_point: f64, - #[doc = "an array"] - number_array: Vec, - #[doc = "some text"] - some_text: String, - #[doc = "a file descriptor"] - file_descriptor: OwnedFd, - }, - #[doc = "Only available since version 2 of the interface"] - GetSecondary { - #[doc = "create a secondary"] - sec: New, - }, - #[doc = "Only available since version 3 of the interface"] - GetTertiary { - #[doc = "create a tertiary"] - ter: New, - }, - #[doc = "link a secondary and a tertiary\n\n\n\nOnly available since version 3 of the interface"] - Link { sec: super::secondary::Secondary, ter: Option, time: u32 }, - #[doc = "This is a destructor, once received this object cannot be used any longer.\nOnly available since version 4 of the interface"] - Destroy, - #[doc = "reverse link a secondary and a tertiary\n\n\n\nOnly available since version 5 of the interface"] - ReverseLink { sec: Option, ter: super::tertiary::Tertiary }, - #[doc = "a newid request that also takes allow null arg\n\n\n\nOnly available since version 5 of the interface"] - NewidAndAllowNull { - quad: New, - sec: Option, - ter: super::tertiary::Tertiary, - }, - } - impl Request { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::ManyArgs { .. } => 0u16, - Request::GetSecondary { .. } => 1u16, - Request::GetTertiary { .. } => 2u16, - Request::Link { .. } => 3u16, - Request::Destroy => 4u16, - Request::ReverseLink { .. } => 5u16, - Request::NewidAndAllowNull { .. } => 6u16, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event<'a> { - #[doc = "an event with every possible non-object arg"] - ManyArgsEvt { - #[doc = "an unsigned int"] - unsigned_int: u32, - #[doc = "a singed int"] - signed_int: i32, - #[doc = "a fixed point number"] - fixed_point: f64, - #[doc = "an array"] - number_array: Vec, - #[doc = "some text"] - some_text: String, - #[doc = "a file descriptor"] - file_descriptor: std::os::unix::io::BorrowedFd<'a>, - }, - #[doc = "acking the creation of a secondary"] - AckSecondary { sec: super::secondary::Secondary }, - #[doc = "create a new quad optionally replacing a previous one"] - CycleQuad { new_quad: super::quad::Quad, old_quad: Option }, - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Event<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Event::ManyArgsEvt { .. } => 0u16, - Event::AckSecondary { .. } => 1u16, - Event::CycleQuad { .. } => 2u16, - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[doc = "test_global\n\nSee also the [Request] enum for this interface."] - #[derive(Debug, Clone)] - pub struct TestGlobal { - id: ObjectId, - version: u32, - data: Option>, - handle: WeakHandle, - } - impl std::cmp::PartialEq for TestGlobal { - #[inline] - fn eq(&self, other: &TestGlobal) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for TestGlobal {} - impl PartialEq> for TestGlobal { - #[inline] - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for TestGlobal { - #[inline] - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for TestGlobal { - #[inline] - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_server::Resource for TestGlobal { - type Request = Request; - type Event<'event> = Event<'event>; - #[inline] - fn interface() -> &'static Interface { - &super::TEST_GLOBAL_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data - .as_ref() - .and_then(|arc| (&**arc).downcast_ref::>()) - .map(|data| &data.udata) - } - #[inline] - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn handle(&self) -> &WeakHandle { - &self.handle - } - #[inline] - fn from_id(conn: &DisplayHandle, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - Ok(TestGlobal { id, data, version, handle: conn.backend_handle().downgrade() }) - } - fn send_event(&self, evt: Self::Event<'_>) -> Result<(), InvalidId> { - let handle = DisplayHandle::from(self.handle.upgrade().ok_or(InvalidId)?); - handle.send_event(self, evt) - } - fn parse_request( - conn: &DisplayHandle, - msg: Message, - ) -> Result<(Self, Self::Request), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - 0u16 => { - if let ( - Some(Argument::Uint(unsigned_int)), - Some(Argument::Int(signed_int)), - Some(Argument::Fixed(fixed_point)), - Some(Argument::Array(number_array)), - Some(Argument::Str(some_text)), - Some(Argument::Fd(file_descriptor)), - ) = ( - arg_iter.next(), - arg_iter.next(), - arg_iter.next(), - arg_iter.next(), - arg_iter.next(), - arg_iter.next(), - ) { - Ok(( - me, - Request::ManyArgs { - unsigned_int, - signed_int, - fixed_point: (fixed_point as f64) / 256., - number_array: *number_array, - some_text: String::from_utf8_lossy( - some_text.as_ref().unwrap().as_bytes(), - ) - .into_owned(), - file_descriptor, - }, - )) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - 1u16 => { - if let (Some(Argument::NewId(sec))) = (arg_iter.next()) { - Ok(( - me, - Request::GetSecondary { - sec: New::wrap( - match ::from_id( - conn, - sec.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - ), - }, - )) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - 2u16 => { - if let (Some(Argument::NewId(ter))) = (arg_iter.next()) { - Ok(( - me, - Request::GetTertiary { - ter: New::wrap( - match ::from_id( - conn, - ter.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - ), - }, - )) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - 3u16 => { - if let ( - Some(Argument::Object(sec)), - Some(Argument::Object(ter)), - Some(Argument::Uint(time)), - ) = (arg_iter.next(), arg_iter.next(), arg_iter.next()) - { - Ok(( - me, - Request::Link { - sec: match ::from_id( - conn, - sec.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - ter: if ter.is_null() { - None - } else { - Some( - match ::from_id( - conn, - ter.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - ) - }, - time, - }, - )) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - 4u16 => { - if let () = () { - Ok((me, Request::Destroy {})) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - 5u16 => { - if let (Some(Argument::Object(sec)), Some(Argument::Object(ter))) = - (arg_iter.next(), arg_iter.next()) - { - Ok(( - me, - Request::ReverseLink { - sec: if sec.is_null() { - None - } else { - Some( - match ::from_id( - conn, - sec.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - ) - }, - ter: match ::from_id( - conn, - ter.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - }, - )) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - 6u16 => { - if let ( - Some(Argument::NewId(quad)), - Some(Argument::Object(sec)), - Some(Argument::Object(ter)), - ) = (arg_iter.next(), arg_iter.next(), arg_iter.next()) - { - Ok(( - me, - Request::NewidAndAllowNull { - quad: New::wrap( - match ::from_id( - conn, - quad.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - ), - sec: if sec.is_null() { - None - } else { - Some( - match ::from_id( - conn, - sec.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - ) - }, - ter: match ::from_id( - conn, - ter.clone(), - ) { - Ok(p) => p, - Err(_) => { - return Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - }, - }, - )) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_event<'a>( - &self, - conn: &DisplayHandle, - msg: Self::Event<'a>, - ) -> Result>, InvalidId> { - match msg { - Event::ManyArgsEvt { - unsigned_int, - signed_int, - fixed_point, - number_array, - some_text, - file_descriptor, - } => Ok(Message { - sender_id: self.id.clone(), - opcode: 0u16, - args: smallvec::SmallVec::from_vec(vec![ - Argument::Uint(unsigned_int), - Argument::Int(signed_int), - Argument::Fixed((fixed_point * 256.) as i32), - Argument::Array(Box::new(number_array)), - Argument::Str(Some(Box::new(std::ffi::CString::new(some_text).unwrap()))), - Argument::Fd(file_descriptor), - ]), - }), - Event::AckSecondary { sec } => Ok(Message { - sender_id: self.id.clone(), - opcode: 1u16, - args: { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::Object(Resource::id(&sec))); - vec - }, - }), - Event::CycleQuad { new_quad, old_quad } => Ok(Message { - sender_id: self.id.clone(), - opcode: 2u16, - args: { - let mut vec = smallvec::SmallVec::new(); - vec.push(Argument::NewId(Resource::id(&new_quad))); - vec.push(if let Some(obj) = old_quad { - Argument::Object(Resource::id(&obj)) - } else { - Argument::Object(ObjectId::null()) - }); - vec - }, - }), - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - fn __set_object_data( - &mut self, - odata: std::sync::Arc, - ) { - self.data = Some(odata); - } - } - impl TestGlobal { - #[doc = "an event with every possible non-object arg"] - #[allow(clippy::too_many_arguments)] - pub fn many_args_evt( - &self, - unsigned_int: u32, - signed_int: i32, - fixed_point: f64, - number_array: Vec, - some_text: String, - file_descriptor: ::std::os::unix::io::BorrowedFd<'_>, - ) { - let _ = self.send_event(Event::ManyArgsEvt { - unsigned_int, - signed_int, - fixed_point, - number_array, - some_text, - file_descriptor, - }); - } - #[doc = "acking the creation of a secondary"] - #[allow(clippy::too_many_arguments)] - pub fn ack_secondary(&self, sec: &super::secondary::Secondary) { - let _ = self.send_event(Event::AckSecondary { sec: sec.clone() }); - } - #[doc = "create a new quad optionally replacing a previous one"] - #[allow(clippy::too_many_arguments)] - pub fn cycle_quad( - &self, - new_quad: &super::quad::Quad, - old_quad: Option<&super::quad::Quad>, - ) { - let _ = self.send_event(Event::CycleQuad { - new_quad: new_quad.clone(), - old_quad: old_quad.cloned(), - }); - } - } -} -pub mod secondary { - use super::wayland_server::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, InvalidId, ObjectData, ObjectId, WeakHandle, - }, - Dispatch, DispatchError, DisplayHandle, New, Resource, ResourceData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_DESTROY_SINCE: u32 = 2u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_DESTROY_OPCODE: u16 = 0u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request { - #[doc = "This is a destructor, once received this object cannot be used any longer.\nOnly available since version 2 of the interface"] - Destroy, - } - impl Request { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::Destroy => 0u16, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event<'a> { - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Event<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[doc = "secondary\n\nSee also the [Request] enum for this interface."] - #[derive(Debug, Clone)] - pub struct Secondary { - id: ObjectId, - version: u32, - data: Option>, - handle: WeakHandle, - } - impl std::cmp::PartialEq for Secondary { - #[inline] - fn eq(&self, other: &Secondary) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for Secondary {} - impl PartialEq> for Secondary { - #[inline] - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for Secondary { - #[inline] - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for Secondary { - #[inline] - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_server::Resource for Secondary { - type Request = Request; - type Event<'event> = Event<'event>; - #[inline] - fn interface() -> &'static Interface { - &super::SECONDARY_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data - .as_ref() - .and_then(|arc| (&**arc).downcast_ref::>()) - .map(|data| &data.udata) - } - #[inline] - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn handle(&self) -> &WeakHandle { - &self.handle - } - #[inline] - fn from_id(conn: &DisplayHandle, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - Ok(Secondary { id, data, version, handle: conn.backend_handle().downgrade() }) - } - fn send_event(&self, evt: Self::Event<'_>) -> Result<(), InvalidId> { - let handle = DisplayHandle::from(self.handle.upgrade().ok_or(InvalidId)?); - handle.send_event(self, evt) - } - fn parse_request( - conn: &DisplayHandle, - msg: Message, - ) -> Result<(Self, Self::Request), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - 0u16 => { - if let () = () { - Ok((me, Request::Destroy {})) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_event<'a>( - &self, - conn: &DisplayHandle, - msg: Self::Event<'a>, - ) -> Result>, InvalidId> { - match msg { - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - fn __set_object_data( - &mut self, - odata: std::sync::Arc, - ) { - self.data = Some(odata); - } - } - impl Secondary {} -} -pub mod tertiary { - use super::wayland_server::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, InvalidId, ObjectData, ObjectId, WeakHandle, - }, - Dispatch, DispatchError, DisplayHandle, New, Resource, ResourceData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_DESTROY_SINCE: u32 = 3u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_DESTROY_OPCODE: u16 = 0u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request { - #[doc = "This is a destructor, once received this object cannot be used any longer.\nOnly available since version 3 of the interface"] - Destroy, - } - impl Request { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::Destroy => 0u16, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event<'a> { - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Event<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[doc = "tertiary\n\nSee also the [Request] enum for this interface."] - #[derive(Debug, Clone)] - pub struct Tertiary { - id: ObjectId, - version: u32, - data: Option>, - handle: WeakHandle, - } - impl std::cmp::PartialEq for Tertiary { - #[inline] - fn eq(&self, other: &Tertiary) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for Tertiary {} - impl PartialEq> for Tertiary { - #[inline] - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for Tertiary { - #[inline] - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for Tertiary { - #[inline] - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_server::Resource for Tertiary { - type Request = Request; - type Event<'event> = Event<'event>; - #[inline] - fn interface() -> &'static Interface { - &super::TERTIARY_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data - .as_ref() - .and_then(|arc| (&**arc).downcast_ref::>()) - .map(|data| &data.udata) - } - #[inline] - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn handle(&self) -> &WeakHandle { - &self.handle - } - #[inline] - fn from_id(conn: &DisplayHandle, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - Ok(Tertiary { id, data, version, handle: conn.backend_handle().downgrade() }) - } - fn send_event(&self, evt: Self::Event<'_>) -> Result<(), InvalidId> { - let handle = DisplayHandle::from(self.handle.upgrade().ok_or(InvalidId)?); - handle.send_event(self, evt) - } - fn parse_request( - conn: &DisplayHandle, - msg: Message, - ) -> Result<(Self, Self::Request), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - 0u16 => { - if let () = () { - Ok((me, Request::Destroy {})) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_event<'a>( - &self, - conn: &DisplayHandle, - msg: Self::Event<'a>, - ) -> Result>, InvalidId> { - match msg { - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - fn __set_object_data( - &mut self, - odata: std::sync::Arc, - ) { - self.data = Some(odata); - } - } - impl Tertiary {} -} -pub mod quad { - use super::wayland_server::{ - backend::{ - protocol::{same_interface, Argument, Interface, Message, WEnum}, - smallvec, InvalidId, ObjectData, ObjectId, WeakHandle, - }, - Dispatch, DispatchError, DisplayHandle, New, Resource, ResourceData, Weak, - }; - use std::sync::Arc; - use std::os::unix::io::OwnedFd; - #[doc = r" The minimal object version supporting this request"] - pub const REQ_DESTROY_SINCE: u32 = 3u32; - #[doc = r" The wire opcode for this request"] - pub const REQ_DESTROY_OPCODE: u16 = 0u16; - #[derive(Debug)] - #[non_exhaustive] - pub enum Request { - #[doc = "This is a destructor, once received this object cannot be used any longer.\nOnly available since version 3 of the interface"] - Destroy, - } - impl Request { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Request::Destroy => 0u16, - } - } - } - #[derive(Debug)] - #[non_exhaustive] - pub enum Event<'a> { - #[doc(hidden)] - __phantom_lifetime { - phantom: std::marker::PhantomData<&'a ()>, - never: std::convert::Infallible, - }, - } - impl<'a> Event<'a> { - #[doc = "Get the opcode number of this message"] - pub fn opcode(&self) -> u16 { - match *self { - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - } - #[doc = "quad\n\nSee also the [Request] enum for this interface."] - #[derive(Debug, Clone)] - pub struct Quad { - id: ObjectId, - version: u32, - data: Option>, - handle: WeakHandle, - } - impl std::cmp::PartialEq for Quad { - #[inline] - fn eq(&self, other: &Quad) -> bool { - self.id == other.id - } - } - impl std::cmp::Eq for Quad {} - impl PartialEq> for Quad { - #[inline] - fn eq(&self, other: &Weak) -> bool { - self.id == other.id() - } - } - impl std::borrow::Borrow for Quad { - #[inline] - fn borrow(&self) -> &ObjectId { - &self.id - } - } - impl std::hash::Hash for Quad { - #[inline] - fn hash(&self, state: &mut H) { - self.id.hash(state) - } - } - impl super::wayland_server::Resource for Quad { - type Request = Request; - type Event<'event> = Event<'event>; - #[inline] - fn interface() -> &'static Interface { - &super::QUAD_INTERFACE - } - #[inline] - fn id(&self) -> ObjectId { - self.id.clone() - } - #[inline] - fn version(&self) -> u32 { - self.version - } - #[inline] - fn data(&self) -> Option<&U> { - self.data - .as_ref() - .and_then(|arc| (&**arc).downcast_ref::>()) - .map(|data| &data.udata) - } - #[inline] - fn object_data(&self) -> Option<&Arc> { - self.data.as_ref() - } - fn handle(&self) -> &WeakHandle { - &self.handle - } - #[inline] - fn from_id(conn: &DisplayHandle, id: ObjectId) -> Result { - if !same_interface(id.interface(), Self::interface()) && !id.is_null() { - return Err(InvalidId); - } - let version = conn.object_info(id.clone()).map(|info| info.version).unwrap_or(0); - let data = conn.get_object_data(id.clone()).ok(); - Ok(Quad { id, data, version, handle: conn.backend_handle().downgrade() }) - } - fn send_event(&self, evt: Self::Event<'_>) -> Result<(), InvalidId> { - let handle = DisplayHandle::from(self.handle.upgrade().ok_or(InvalidId)?); - handle.send_event(self, evt) - } - fn parse_request( - conn: &DisplayHandle, - msg: Message, - ) -> Result<(Self, Self::Request), DispatchError> { - let me = Self::from_id(conn, msg.sender_id.clone()).unwrap(); - let mut arg_iter = msg.args.into_iter(); - match msg.opcode { - 0u16 => { - if let () = () { - Ok((me, Request::Destroy {})) - } else { - Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }) - } - } - _ => Err(DispatchError::BadMessage { - sender_id: msg.sender_id, - interface: Self::interface().name, - opcode: msg.opcode, - }), - } - } - fn write_event<'a>( - &self, - conn: &DisplayHandle, - msg: Self::Event<'a>, - ) -> Result>, InvalidId> { - match msg { - Event::__phantom_lifetime { never, .. } => match never {}, - } - } - fn __set_object_data( - &mut self, - odata: std::sync::Arc, - ) { - self.data = Some(odata); - } - } - impl Quad {} -} diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index ad111ce67..e61ba9465 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -30,9 +30,6 @@ const Onboarding = lazy(() => import('./components/Onboarding').then((m) => ({ default: m.Onboarding })), ); const QaPanel = lazy(() => import('./pages/QaPanel').then((m) => ({ default: m.QaPanel }))); -const SelectionPolishPreview = lazy(() => - import('./pages/SelectionPolishPreview').then((m) => ({ default: m.SelectionPolishPreview })), -); const SelectionVoiceIntentPicker = lazy(() => import('./pages/SelectionVoiceIntentPicker').then((m) => ({ default: m.SelectionVoiceIntentPicker, @@ -55,7 +52,6 @@ const LessComputerGlow = LESS_COMPUTER_BUNDLED interface AppProps { isCapsule: boolean; isQa: boolean; - isSelectionPolishPreview: boolean; isSelectionVoiceIntent: boolean; isLessComputer: boolean; isLessComputerGlow: boolean; @@ -99,7 +95,6 @@ export function App(props: AppProps) { function ReadyApp({ isCapsule, isQa, - isSelectionPolishPreview, isSelectionVoiceIntent, isLessComputer, isLessComputerGlow, @@ -115,13 +110,6 @@ function ReadyApp({ ); } - if (isSelectionPolishPreview) { - return ( - - - - ); - } if (isSelectionVoiceIntent) { return ( diff --git a/openless-all/app/src/components/AudioCue.tsx b/openless-all/app/src/components/AudioCue.tsx index 84e835dd7..624a0d1bd 100644 --- a/openless-all/app/src/components/AudioCue.tsx +++ b/openless-all/app/src/components/AudioCue.tsx @@ -1,10 +1,9 @@ // 录音提示音:监听 capsule:state 事件,在"开始录音"边沿播放合成提示音。 // 独立组件,不依赖胶囊窗口显示——胶囊隐藏时也能正常工作。 -// Linux 的 Web Audio 输出会被 PipeWire/KDE 当作 sink-input,启动/退出时触发音量 OSD; +// Web Audio 输出会被某些桌面音频栈当作 sink-input,启动/退出时触发音量 OSD; // Android 也可能因 Web Audio 输出切换录音输入路由,因此这两个平台禁用。 import { useEffect, useRef } from 'react'; -import { detectOS } from './WindowChrome'; import { isAndroid, isTauri } from '../lib/ipc'; import { playRecordStartCue, primeAudioCue, stopAudioCue } from '../lib/audioCue'; import type { CapsuleState, UserPreferences } from '../lib/types'; @@ -20,7 +19,7 @@ interface CapsulePayload { export function AudioCueListener() { const audioCueEnabledRef = useRef(true); const prevStateRef = useRef('idle' as CapsuleState); - const audioCueRuntimeEnabled = !isAndroid() && detectOS() !== 'linux'; + const audioCueRuntimeEnabled = !isAndroid(); // 读取设置(默认开启) useEffect(() => { diff --git a/openless-all/app/src/components/CapsuleStyles.css b/openless-all/app/src/components/CapsuleStyles.css index b4311504c..4a67c70e0 100644 --- a/openless-all/app/src/components/CapsuleStyles.css +++ b/openless-all/app/src/components/CapsuleStyles.css @@ -80,9 +80,7 @@ background: var(--ol-typeless-button-bg); color: var(--ol-typeless-ink); cursor: pointer; - transition: - background 120ms ease, - transform 120ms ease; + transition: background 120ms ease; } .ol-typeless-confirm { @@ -96,9 +94,6 @@ background: var(--ol-typeless-confirm-bg); opacity: 0.85; } -.ol-typeless-action:active { - transform: scale(0.94); -} .ol-typeless-action:focus-visible, .ol-typeless-stop:focus-visible { outline: 2px solid #79b0ff; diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index ac7ab4358..0a744359e 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -392,7 +392,6 @@ export function ShortcutRecorder({ {value && }
setMenuOpen((open) => !open)} aria-label={t('settings.recording.comboMenuToggle', 'More options')} aria-expanded={menuOpen} @@ -452,8 +451,6 @@ export function ShortcutRecorder({ initial={{ y: 4, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.16, ease: menuEase }} - whileHover={{ y: -1 }} - whileTap={{ scale: 0.96 }} onClick={startRecording} style={menuPrimaryStyle} > @@ -464,8 +461,6 @@ export function ShortcutRecorder({ initial={{ y: 4, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.16, ease: menuEase, delay: 0.03 }} - whileHover={{ y: -1 }} - whileTap={{ scale: 0.96 }} onClick={doReset} style={menuButtonStyle} > @@ -476,8 +471,6 @@ export function ShortcutRecorder({ initial={{ y: 4, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.16, ease: menuEase, delay: 0.06 }} - whileHover={canDisable ? { y: -1 } : undefined} - whileTap={canDisable ? { scale: 0.96 } : undefined} onClick={canDisable ? doDisable : undefined} title={canDisable ? undefined : disableHint} style={canDisable ? menuButtonStyle : disabledMenuButtonStyle} diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index 8b51653d8..7ac99812c 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -1,15 +1,9 @@ -import { Icon } from './Icon'; -import { useTranslation } from 'react-i18next'; import { type CSSProperties, type ReactNode, - useCallback, - useEffect, - useRef, - useState, } from 'react'; -export type OS = 'mac' | 'win' | 'linux' | 'android'; +export type OS = 'mac' | 'win' | 'android'; export function detectOS(): OS { if (typeof navigator === 'undefined') return 'mac'; @@ -20,13 +14,11 @@ export function detectOS(): OS { if (/Mac|iPhone|iPad|iPod/.test(hints)) return 'mac'; if (/Android/i.test(hints)) return 'android'; if (/Windows|Win32|Win64/.test(hints)) return 'win'; - if (/Linux|X11|Wayland/.test(hints)) return 'linux'; return 'mac'; } const MAC_TITLEBAR_HEIGHT = 44; const MAC_SYSTEM_CONTROLS_RESERVED_WIDTH = 88; -const LINUX_TITLEBAR_HEIGHT = 36; const WIN_CONSOLE_RADIUS = 10; interface WindowChromeProps { @@ -38,14 +30,11 @@ interface WindowChromeProps { export function WindowChrome({ os = 'mac', children, height = 800 }: WindowChromeProps) { // Windows: decorations:true 时外层不画圆角/边框/阴影/标题栏,避免与原生窗口重叠。 - // Linux: decorations:false 时外层画 14px 圆角 + 自定义标题栏。 const shellRadius = os === 'mac' ? 0 : os === 'win' || os === 'android' ? 0 : 14; - const consoleRadius = - os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : os === 'android' ? 0 : 14; - const titlebarHeight = - os === 'mac' ? MAC_TITLEBAR_HEIGHT : os === 'linux' ? LINUX_TITLEBAR_HEIGHT : 0; + const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 0; + const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : 0; - const useSolidSurface = os === 'linux' || os === 'android'; + const useSolidSurface = os === 'android'; return (
)} - {os === 'linux' && } - {os === 'linux' && ( - - )}
{children}
); } - -// ── Linux custom titlebar — mirrors cc-switch's approach ── - -type TauriWindow = import('@tauri-apps/api/window').Window; - -function LinuxTitlebar() { - const { t } = useTranslation(); - const [maximized, setMaximized] = useState(false); - const winRef = useRef(null); - - useEffect(() => { - let cancelled = false; - let unlisten: (() => void) | undefined; - import('@tauri-apps/api/window') - .then(({ getCurrentWindow }) => { - if (cancelled) return; - const w = getCurrentWindow(); - winRef.current = w; - w.isMaximized() - .then((m) => { - if (!cancelled) setMaximized(m); - }) - .catch(() => {}); - // Keep icon in sync when user maximizes via double-click / keyboard shortcut - w.listen('tauri://resize', () => { - if (cancelled) return; - w.isMaximized() - .then((m) => { - if (!cancelled) setMaximized(m); - }) - .catch(() => {}); - }) - .then((fn) => { - if (cancelled) fn(); - else unlisten = fn; - }) - .catch(() => {}); - }) - .catch(() => {}); - return () => { - cancelled = true; - unlisten?.(); - }; - }, []); - - const onMinimize = useCallback(() => { - winRef.current?.minimize().catch(() => {}); - }, []); - - const onToggleMaximize = useCallback(() => { - const w = winRef.current; - if (!w) return; - w.toggleMaximize().catch(() => {}); - // Re-query after window manager processes the toggle, in case WM rejects it - setTimeout(() => { - w.isMaximized() - .then(setMaximized) - .catch(() => {}); - }, 300); - }, []); - - const onClose = useCallback(() => { - winRef.current?.close().catch(() => {}); - }, []); - - return ( -
- OpenLess -
e.stopPropagation()} - > - - - -
-
- ); -} - -// ── inline SVG icons (no lucide-react dep) ── - -const svgWrap: CSSProperties = { width: 12, height: 12, display: 'block' }; -const ctrlBtn: CSSProperties = { - width: 30, - height: 24, - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 5, - border: 0, - padding: 0, - background: 'transparent', - color: 'var(--ol-ink-3)', - fontFamily: 'inherit', - cursor: 'default', - transition: 'background 0.12s, color 0.12s', -}; - -function MinimizeSvg() { - return ; -} - -function MaximizeSvg() { - return ; -} - -function RestoreSvg() { - return ; -} - -function CloseSvg() { - return ; -} diff --git a/openless-all/app/src/lib/capsuleLayout.test.ts b/openless-all/app/src/lib/capsuleLayout.test.ts index 47f578b21..f33307f10 100644 --- a/openless-all/app/src/lib/capsuleLayout.test.ts +++ b/openless-all/app/src/lib/capsuleLayout.test.ts @@ -82,7 +82,7 @@ const macErrorLayout = getCapsuleMessageLayout('mac', 'error'); assertEqual(macErrorLayout.lineClamp, 1, 'mac error message stays single-line'); assertEqual(macErrorLayout.allowWrap, false, 'mac error message stays nowrap'); -for (const os of ['mac', 'win', 'linux'] as const) { +for (const os of ['mac', 'win'] as const) { const classic = getCapsuleHostMetrics(os, false, 'classic'); const typeless = getCapsuleHostMetrics(os, true, 'typeless'); assertEqual(classic.height, 100, `${os}: classic uses the compact native window`); diff --git a/openless-all/app/src/lib/localAsr.test.ts b/openless-all/app/src/lib/localAsr.test.ts index 93a43194c..4890a0099 100644 --- a/openless-all/app/src/lib/localAsr.test.ts +++ b/openless-all/app/src/lib/localAsr.test.ts @@ -10,10 +10,10 @@ const qwen = { runtime: 'generic', family: 'qwen3' } as const; const whisper = { runtime: 'generic', family: 'whisper' } as const; const foundry = { runtime: 'foundry', family: 'whisper' } as const; const sherpa = { runtime: 'sherpa_onnx', family: 'qwen3_asr' } as const; -for (const os of ['mac', 'win', 'linux', 'android'] as const) { +for (const os of ['mac', 'win', 'android'] as const) { assertEqual( isLocalAsrModelSupportedOnOs(qwen, os), - os === 'mac' || os === 'linux', + os === 'mac', `generic Qwen on ${os}`, ); assertEqual(isLocalAsrModelSupportedOnOs(whisper, os), os === 'mac', `whisper.cpp on ${os}`); diff --git a/openless-all/app/src/lib/localAsr.ts b/openless-all/app/src/lib/localAsr.ts index 6482d860d..1b796b4fb 100644 --- a/openless-all/app/src/lib/localAsr.ts +++ b/openless-all/app/src/lib/localAsr.ts @@ -16,8 +16,7 @@ export function isLocalAsrModelSupportedOnOs( if (model.runtime === 'foundry' || model.runtime === 'sherpa_onnx') return os === 'win'; if (model.runtime !== 'generic') return false; if (model.family === 'whisper') return os === 'mac'; - if (model.family === 'qwen3' || model.family === 'qwen3_asr') - return os === 'mac' || os === 'linux'; + if (model.family === 'qwen3' || model.family === 'qwen3_asr') return os === 'mac'; return false; } @@ -29,7 +28,7 @@ export interface LocalAsrSettings { mirror: string; modelsBaseDir: string | null; modelsRootDir: string; - /** macOS/Linux 编入 C 引擎;MLX 仅在 macOS 可用。 */ + /** Tauri 仅 macOS 编入本地 C 引擎;MLX 仅在 macOS 可用。 */ engineAvailable: boolean; } diff --git a/openless-all/app/src/lib/platform.ts b/openless-all/app/src/lib/platform.ts index 42689521d..41d5845f9 100644 --- a/openless-all/app/src/lib/platform.ts +++ b/openless-all/app/src/lib/platform.ts @@ -82,7 +82,7 @@ export function inferPlatformCapabilities(): PlatformCapabilities { supportsTray: true, supportsOverlay: true, supportsImeInput: os === 'win', - supportsLocalAsr: os === 'mac' || os === 'linux' || os === 'win', + supportsLocalAsr: os === 'mac' || os === 'win', // Tauri returns the authoritative architecture-aware value. The browser fallback // keeps MLX visible on macOS until the native capability query is available. supportsLocalQwen3Mlx: os === 'mac', diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 057a5fe64..46d1c76d5 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -16,7 +16,6 @@ const params = new URLSearchParams(window.location.search); const windowKind = params.get('window'); const isCapsule = windowKind === 'capsule'; const isQa = windowKind === 'qa'; -const isSelectionPolishPreview = windowKind === 'selection-polish-preview'; const isSelectionVoiceIntent = windowKind === 'selection-voice-intent'; const isLessComputer = windowKind === 'less-computer'; const isLessComputerGlow = windowKind === 'less-computer-glow'; @@ -37,7 +36,6 @@ const renderApp = () => {
void onLike()} aria-label={marketplaceSignedIn ? undefined : t('marketplace.oauth.loginBtn')} style={{ diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index c4d49f164..d8cc352ac 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -25,8 +25,11 @@ import { VoiceWaveform } from '../components/chat/VoiceWaveform'; import { AssistantMarkdown } from '../components/chat/markdown'; import { useChatPanelLifecycle } from '../components/chat/lifecycle'; import { + cancelSelectionPolishPreview, chatPanelFocusKeyboard, + confirmSelectionPolishPreview, confirmSelectionVoicePreview, + getSelectionPolishPreview, getSelectionVoicePreview, isTauri, qaSetEditInstructionMode, @@ -64,6 +67,16 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) const [editRevertAvailable, setEditRevertAvailable] = useState(false); const [editApplyBusy, setEditApplyBusy] = useState(false); const [editInstructionMode, setEditInstructionMode] = useState(false); + /** + * 「润色结果」模式:核心在「预览确认」输出模式下请求预览,内容画在**本面板** + * (原独立的 `selection-polish-preview` 窗口已下线)。结果只读,「确认并替换」 + * 就是把它写回原选区的插入。 + */ + const [polishResult, setPolishResult] = useState<{ text: string; sourceText: string } | null>( + null, + ); + const [polishError, setPolishError] = useState(''); + const [polishBusy, setPolishBusy] = useState(false); const [level, setLevel] = useState(0); const [submitted, setSubmitted] = useState(false); const [submitBusy, setSubmitBusy] = useState(false); @@ -71,6 +84,26 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) const [modeBusy, setModeBusy] = useState(false); const [layoutError, setLayoutError] = useState(false); const activeSessionIdRef = useRef(null); + /** 供事件回调判断当前是否真的处于润色模式(长驻订阅闭包看不到最新 state)。 */ + const polishActiveRef = useRef(false); + polishActiveRef.current = polishResult !== null; + + /** + * 拉一次润色负载。面板懒创建时 `selection-polish-preview:shown` 可能早于订阅 + * 到达,所以挂载时也拉一次;后端用 pending 标志保证平时(无润色请求)返回 null。 + */ + const loadPolishResult = async () => { + try { + const payload = await getSelectionPolishPreview(); + if (!payload) return; + setPolishResult({ text: payload.text, sourceText: payload.sourceText }); + setPolishError(''); + } catch (error) { + console.error('[QaPanel] load polish preview failed', error); + } + }; + const loadPolishResultRef = useRef(loadPolishResult); + loadPolishResultRef.current = loadPolishResult; const statusRef = useRef('idle'); const panelEpoch = useRef(0); const nativeStateEpoch = useRef(0); @@ -243,6 +276,27 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) else void qaWindowDismiss().catch(() => undefined); }); handles.push(dismissHandle); + // 「润色结果」模式的进入/退出复用本面板,与 egui 侧 + // `HostAction::ShowSelectionPreview` 的落点一致。 + handles.push( + await listen('selection-polish-preview:shown', () => { + void loadPolishResultRef.current(); + }), + await listen('selection-polish-preview:hide', () => { + // 只有面板正处在润色模式时才收起它:提问对话中的面板不能被润色流程的 + // 收尾动作一起关掉。 + if (!polishActiveRef.current) return; + setPolishResult(null); + setPolishError(''); + if (embeddedRef.current) onRequestCloseRef.current?.(); + else void qaWindowDismiss(); + }), + ); + // 面板可能是本次才懒创建的(事件早于订阅):挂载时补拉一次负载。嵌入主窗口 + // 时不拉,主窗口的润色入口走页面内的选区助手。 + if (!embeddedRef.current) { + void loadPolishResultRef.current(); + } if (cancelled) { dismissHandle(); return; @@ -293,6 +347,8 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) setSelectionPreview(''); setComposerText(''); setEditInstructionMode(false); + setPolishResult(null); + setPolishError(''); setEditApplyAvailable(false); setEditRevertAvailable(false); setLevel(0); @@ -357,6 +413,19 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) const onKey = (event: KeyboardEvent) => { if (event.key !== 'Escape' || event.isComposing || event.keyCode === 229) return; event.preventDefault(); + // 润色结果模式下 Esc = 取消并关闭:只 hide 的话待处理标记会留在后端。 + if (polishActiveRef.current) { + void cancelSelectionPolishPreview() + .catch((error) => { + console.error('[QaPanel] cancel selection polish failed', error); + }) + .finally(() => { + setPolishResult(null); + void qaWindowDismiss(); + onRequestCloseRef.current?.(); + }); + return; + } void onClose(); }; window.addEventListener('keydown', onKey, true); @@ -528,6 +597,44 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) } }; + // ── 润色结果模式:取消 / 确认并替换 ────────────────────────────── + const closePolishResult = () => { + setPolishResult(null); + setPolishError(''); + if (embeddedRef.current) onRequestCloseRef.current?.(); + else void qaWindowDismiss(); + }; + + const onPolishCancel = () => { + if (polishBusy) return; + setPolishBusy(true); + void cancelSelectionPolishPreview() + .catch((error) => { + console.error('[QaPanel] cancel selection polish failed', error); + }) + .finally(() => { + setPolishBusy(false); + closePolishResult(); + }); + }; + + /** 「确认并替换」= 把结果写回原选区的插入(Core `selection.confirm`)。 */ + const onPolishConfirm = () => { + const text = polishResult?.text ?? ''; + if (polishBusy || !text.trim()) return; + setPolishBusy(true); + setPolishError(''); + void confirmSelectionPolishPreview(text) + .then(() => { + setPolishBusy(false); + closePolishResult(); + }) + .catch((error) => { + setPolishBusy(false); + setPolishError(error instanceof Error ? error.message : String(error)); + }); + }; + const lastRole = messages[messages.length - 1]?.role; const questionLanded = lastRole === 'user' || streamingAnswer.length > 0; const thinkingRow = status === 'thinking' && !streamingAnswer && lastRole === 'user'; @@ -543,6 +650,77 @@ export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) modeBusy || editApplyBusy; + // ── 「润色结果」模式:整块面板换成只读结果 + 确认并替换 ───────────── + if (polishResult) { + return ( +
+
+
+ +

+ {t('selectionPolishPreview.title')} +

+ {t('selectionPolishPreview.subtitle')} + +
+ {polishError && ( +

+ {t('selectionPolishPreview.applyError')} + {polishError} +

+ )} +
+
+ {/* 结果只读(不做就地编辑)——与 egui 侧润色结果模式一致。 */} +
+ {polishResult.text} +
+ {polishResult.sourceText && ( +

+ {t('selectionPolishPreview.sourcePrefix')} + {polishResult.sourceText} +

+ )} +
+
+
+ + +
+
+
+ ); + } + return (
(null); - const [busy, setBusy] = useState(false); - - useEffect(() => { - let unlisten: (() => void) | undefined; - let cancelled = false; - const load = async () => { - const preview = await getSelectionPolishPreview(); - if (!cancelled && preview) { - setText(preview.text); - setSourceText(preview.sourceText); - setError(null); - } - }; - void load(); - if (!isTauri) - return () => { - cancelled = true; - }; - void import('@tauri-apps/api/event').then(({ listen }) => - listen('selection-polish-preview:shown', () => { - // 预览窗是复用的:上一轮 confirm/cancel 成功后窗口 hide,但组件不卸载, - // busy 会停留在 true → 下一轮两个按钮全 disabled(表现为「点确认没反应」)。 - // 每次重新 show 必须复位交互状态。 - setBusy(false); - setError(null); - void load(); - }).then((handle) => { - if (cancelled) handle(); - else unlisten = handle; - }), - ); - return () => { - cancelled = true; - unlisten?.(); - }; - }, []); - - const cancel = async () => { - setBusy(true); - await cancelSelectionPolishPreview(); - }; - const confirm = async () => { - setBusy(true); - setError(null); - try { - await confirmSelectionPolishPreview(text); - } catch (reason) { - setError(String(reason)); - setBusy(false); - } - }; - - return ( -
- } - title={t('selectionPolishPreview.title')} - description={t('selectionPolishPreview.subtitle')} - onClose={() => void cancel()} - closeLabel={t('selectionPolishPreview.cancel')} - closeDisabled={busy} - /> -
-