diff --git a/.github/actions/cache-npm/action.yml b/.github/actions/cache-npm/action.yml new file mode 100644 index 000000000..9d545e0c0 --- /dev/null +++ b/.github/actions/cache-npm/action.yml @@ -0,0 +1,25 @@ +name: Cache npm from beta +description: Reuse the baseline npm download cache without creating per-PR copies. +runs: + using: composite + steps: + - name: Resolve npm cache identity + id: npm + shell: bash + run: | + echo "path=$(npm config get cache)" >> "$GITHUB_OUTPUT" + echo "arch=$(node -p process.arch)" >> "$GITHUB_OUTPUT" + # Keep setup-node's existing key and path so the current beta cache remains + # reusable. Cache lookup falls back to the PR base/default branch on a miss. + - name: Restore npm outside beta builds + if: github.ref != 'refs/heads/beta' || (github.event_name != 'push' && github.event_name != 'workflow_dispatch') + uses: actions/cache/restore@v4 + with: + path: ${{ steps.npm.outputs.path }} + key: node-cache-${{ runner.os }}-${{ steps.npm.outputs.arch }}-npm-${{ hashFiles('openless-all/app/package-lock.json') }} + - name: Maintain npm cache on beta builds + if: github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + uses: actions/cache@v4 + with: + path: ${{ steps.npm.outputs.path }} + key: node-cache-${{ runner.os }}-${{ steps.npm.outputs.arch }}-npm-${{ hashFiles('openless-all/app/package-lock.json') }} diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index bb0f54832..7fbe29d4f 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -16,10 +16,14 @@ name: Android APK (debug) on: push: - branches: - - '**' + # Same reasoning as release-tauri: an APK per branch push cost 13-18 minutes + # and duplicated the Gradle + Rust trees that `android-check` already keeps + # warm. Tags, a weekly smoke build and explicit dispatch cover it. tags: - 'v*-tauri' + schedule: + # Monday 05:23 UTC: weekly APK smoke build (debug-grade, not published). + - cron: '23 5 * * 1' workflow_dispatch: inputs: abis: diff --git a/.github/workflows/cache-maintenance.yml b/.github/workflows/cache-maintenance.yml index a1c6d0db0..c03ccbf73 100644 --- a/.github/workflows/cache-maintenance.yml +++ b/.github/workflows/cache-maintenance.yml @@ -38,4 +38,9 @@ jobs: 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" + # `--keep-family 2` also drops superseded generations inside the branch + # scope: only the two newest keys of one (branch, key family) pair stay, + # because `restore-keys` never needs more than the previous generation. + run: >- + bash scripts/ci-cache-usage.sh + --prune --keep-family 2 --repo "$REPO" --limit-gb "$LIMIT_GB" diff --git a/.github/workflows/check-linux-egui.yml b/.github/workflows/check-linux-egui.yml index 219ab51aa..99a4c56db 100644 --- a/.github/workflows/check-linux-egui.yml +++ b/.github/workflows/check-linux-egui.yml @@ -1,4 +1,4 @@ -name: Linux egui build and backend checks +name: Linux Core, egui and deb/rpm checks # CI and independent manual Linux builds share this deb/rpm verification path. # No standalone push trigger: a Linux PR must show one combined Linux check. @@ -12,9 +12,30 @@ on: required: false type: string default: full + # The tag whose commit is being packaged. Set by the release entry point so + # the version assertion and the GitHub Release linkage cannot be skipped by + # dispatching the workflow on a branch instead. + release_tag: + required: false + type: string + default: '' + # Attach the verified packages to that Release. Linux used to be + # administrator-attached only; the packages now ship in the same Release as + # the desktop and Android assets. + publish: + required: false + type: boolean + default: false + checkout_ref: + required: false + type: string + default: '' -permissions: - contents: read +# No `permissions:` here on purpose: a reusable workflow inherits its caller's +# grant, and the release caller needs `contents: write` for `gh release upload` +# while CI keeps `contents: read`. Asking for more than the caller grants is +# rejected before the run starts (`startup_failure`, zero jobs); asking for less +# silently breaks the upload. concurrency: group: linux-egui-build-${{ github.workflow }}-${{ github.ref }} @@ -27,16 +48,20 @@ jobs: # A cold dependency tree costs ~18 minutes; anything past this is a hang. timeout-minutes: 90 env: - RELEASE_TAG: ${{ github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri') && github.ref_name || '' }} + RELEASE_TAG: ${{ inputs.release_tag || (github.ref_type == 'tag' && 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 + # The standalone TLS contract otherwise recompiles common dependencies in + # src-tauri/backend-tests/target, outside the host's restored cache. + CARGO_TARGET_DIR: ${{ github.workspace }}/openless-all/app/target steps: - uses: actions/checkout@v6 with: submodules: false + ref: ${{ inputs.checkout_ref || github.ref }} - name: Install native build and packaging dependencies if: inputs.scope != 'none' @@ -85,14 +110,17 @@ jobs: - 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. + workspaces: | + openless-all/app -> target + openless-all/app/src-tauri/backend-tests -> ../../target + # Failed beta checks can still warm the shared tree. PRs never write + # their own copy, even when tests fail or dependencies change. cache-on-failure: true + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} - name: Build and test fcitx5 plugin if: inputs.scope != 'none' - working-directory: openless-all/scripts/linux-fcitx5-plugin + working-directory: openless-all/app/scripts/linux-fcitx5-plugin run: | cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=/usr cmake --build build --parallel @@ -109,10 +137,12 @@ jobs: 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/ci-cache-usage.test.mjs + node scripts/ci-cache-sharing.test.mjs + node scripts/harmony-release-contract.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 @@ -156,6 +186,9 @@ jobs: set -euo pipefail OUTPUT=target/linux-egui-packages ( cd "$OUTPUT" && sha256sum ./*.deb ./*.rpm > SHA256SUMS ) + # Release attachment name: the desktop and Android assets carry their own + # SHA256SUMS, so the Linux one is published under a distinct name. + cp "$OUTPUT/SHA256SUMS" "$OUTPUT/SHA256SUMS-linux" bash scripts/verify-linux-egui-packages.sh - name: Stage direct download aliases @@ -184,6 +217,39 @@ jobs: path: openless-all/app/target/linux-egui-packages/* if-no-files-found: error + - name: Attach Linux packages to the GitHub Release + # Runs only for the release entry point, which pins the tag; PR and push + # builds never publish. + if: inputs.scope != 'none' && inputs.publish && inputs.release_tag != '' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.release_tag }} + working-directory: openless-all/app + run: | + set -euo pipefail + # The desktop workflow creates the Release for this tag; wait for it + # instead of racing it, and fail loudly if it never appears. + for attempt in $(seq 1 40); do + if gh release view "$RELEASE_TAG" --json isDraft > /dev/null 2>&1; then + break + fi + if [ "$attempt" = 40 ]; then + echo "::error::release $RELEASE_TAG was not created by the desktop workflow" + exit 1 + fi + sleep 15 + done + output=target/linux-egui-packages + alias_dir="$RUNNER_TEMP/openless-linux-egui-direct-download" + gh release upload "$RELEASE_TAG" \ + "$output"/OpenLess-Linux-egui-*.deb \ + "$output"/OpenLess-Linux-egui-*.rpm \ + "$output"/SHA256SUMS-linux \ + "$alias_dir"/OpenLess_linux_x86_64.deb \ + "$alias_dir"/OpenLess_linux_x86_64.rpm \ + --clobber + echo "attached Linux assets to $RELEASE_TAG" + - name: Upload direct download aliases if: inputs.scope != 'none' && (github.event_name != 'push' || env.RELEASE_TAG != '') uses: actions/upload-artifact@v7 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f17d6cc4..700adcb31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,10 @@ jobs: tauri: ${{ steps.detect.outputs.tauri }} msrv: ${{ steps.detect.outputs.msrv }} linux: ${{ steps.detect.outputs.linux }} + harmony: ${{ steps.detect.outputs.harmony }} + # Only worth a macOS runner when the declared MSRV is actually older than + # the stable toolchain the other jobs already build with. + msrv_needed: ${{ steps.msrv.outputs.needed }} steps: - uses: actions/checkout@v4 with: @@ -50,11 +54,28 @@ jobs: BASE_SHA: ${{ github.event.pull_request.base.sha }} run: | set -euo pipefail - for area in tauri msrv linux; do + for area in tauri msrv linux harmony; do verdict=$(bash scripts/ci-changed-areas.sh "$area" "$BASE_SHA") # 失败即运行:脚本读不到差异时返回 true,这里也只接受明确的 false。 echo "$area=$( [ "$verdict" = 'false' ] && echo false || echo true )" >> "$GITHUB_OUTPUT" done + - name: Is the declared MSRV older than stable? + id: msrv + # `rust-version` is the promise; a dedicated compile only proves something + # while it is behind the toolchain the other jobs use. When they coincide + # (today: 1.99 vs stable 1.99.x) the macOS MSRV job is pure duplication. + run: | + set -euo pipefail + # The workspace manifest carries no `rust-version`; every member declares + # its own. `openless-core` is the shared one, so read it there. + declared=$(grep -m1 '^rust-version' openless-all/app/crates/openless-core/Cargo.toml | sed -E 's/.*"([0-9.]+)".*/\1/') + stable=$(rustc --version | sed -E 's/rustc ([0-9]+\.[0-9]+)\.[0-9]+.*/\1/') + echo "declared MSRV: ${declared:-none}, stable: $stable" + if [ -z "$declared" ] || [ "$declared" = "$stable" ]; then + echo "needed=false" >> "$GITHUB_OUTPUT" + else + echo "needed=true" >> "$GITHUB_OUTPUT" + fi ui-motion: name: UI motion (Chromium + WebKit) @@ -69,11 +90,33 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - cache: npm - cache-dependency-path: openless-all/app/package-lock.json + - name: Cache npm from beta + uses: ./.github/actions/cache-npm - run: npm ci - run: npm run build - - run: npx playwright install --with-deps chromium webkit + - name: Resolve Playwright browser version + id: playwright_version + run: | + version=$(node -p "require('@playwright/test/package.json').version") + echo "version=$version" >> "$GITHUB_OUTPUT" + - name: Restore Playwright browsers + id: playwright_cache + uses: actions/cache/restore@v4 + with: + path: ~/.cache/ms-playwright + # Browser revisions follow Playwright, not unrelated npm lock changes. + key: playwright-v1-${{ runner.os }}-${{ runner.arch }}-ubuntu24-${{ steps.playwright_version.outputs.version }} + - name: Install Playwright system dependencies + run: npx playwright install-deps chromium webkit + - name: Install Playwright browsers on cache miss + if: steps.playwright_cache.outputs.cache-hit != 'true' + run: npx playwright install chromium webkit + - name: Save Playwright browsers on beta builds + if: steps.playwright_cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') + uses: actions/cache/save@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ steps.playwright_cache.outputs.cache-primary-key }} - run: npm run test:ui-motion - uses: actions/upload-artifact@v4 if: failure() @@ -132,8 +175,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" - cache: npm - cache-dependency-path: openless-all/app/package-lock.json + - name: Cache npm from beta + uses: ./.github/actions/cache-npm - uses: actions/setup-java@v4 with: @@ -146,13 +189,14 @@ jobs: - uses: swatinem/rust-cache@v2 with: + shared-key: tauri-android workspaces: 'openless-all/app/src-tauri -> target' - # 仓库缓存上限 10 GB,超出后按 LRU 淘汰。过去每个 PR 都会各存一份 - # ~1.6 GB 的依赖树,把其他 job 的缓存挤掉,导致每次运行都从零编译。 - # 现在 PR 只从基线分支恢复,只有分支推送和 tag 才写入缓存。 - save-if: ${{ github.event_name != 'pull_request' }} + # Only beta maintains the shared dependency tree; PRs restore it. + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} - uses: gradle/actions/setup-gradle@v4 + with: + cache-read-only: ${{ github.ref != 'refs/heads/beta' || (github.event_name != 'push' && github.event_name != 'workflow_dispatch') }} - name: Install Linux check dependencies run: | @@ -246,6 +290,91 @@ jobs: -x :app:rustBuildX86_64Debug --no-daemon + # HarmonyOS covers three layers, in three places: + # * the Rust host is compiled and tested by the Linux job + # (`cargo test --locked -p openless-harmony-host`); + # * ArkTS cannot be compiled without the Huawei SDK, so this job checks what + # is reproducible on a hosted runner: the committed IPC audit still matches + # the code, the ArkTS contract tests pass, and the ArkWeb bundle builds; + # * when `HARMONY_SDK_URL` is configured, the last step additionally builds a + # real HAP, which is what actually type-checks the ArkTS sources. + harmony-check: + needs: [changes] + if: (github.event_name != 'workflow_dispatch' || inputs.platform != 'macos') && needs.changes.outputs.harmony != 'false' + name: Harmony (ArkTS contracts + ArkWeb bundle, HAP when the SDK is set) + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: openless-all/app + steps: + - uses: actions/checkout@v4 + with: + submodules: false + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Cache npm from beta + uses: ./.github/actions/cache-npm + - run: npm ci + - name: Shared IPC audit matches the code + # The report is committed so the ArkTS coverage is reviewable; a stale + # report hides commands that the Harmony host does not implement yet. + run: | + node scripts/audit-harmony-ipc.mjs + git diff --exit-code harmony/ipc-audit.json + - name: Harmony ArkTS contracts + run: | + node scripts/harmony-entry-startup.test.mjs + node scripts/harmony-app-dictation.test.mjs + node scripts/harmony-voice-configuration.test.mjs + node scripts/harmony-panel-layout.test.mjs + node scripts/harmony-editor-compat.test.mjs + node scripts/harmony-clipboard-history.test.mjs + node scripts/harmony-symbol-gesture.test.mjs + node scripts/harmony-rime-startup.test.mjs + - name: Build the ArkWeb bundle + env: + VITE_OPENLESS_PLATFORM: harmony + # The frontend half of the HAP: same sources, harmony platform flag, + # relative base for the rawfile origin. + run: npm run build -- --base=./ --outDir=dist-harmony + - uses: dtolnay/rust-toolchain@stable + with: + # Tier-2 OHOS target; `rustup target list` above lists it for stable. + targets: aarch64-unknown-linux-ohos + - uses: swatinem/rust-cache@v2 + with: + workspaces: 'openless-all/app -> target' + # The HAP embeds a release cdylib; keep its own key so the debug trees + # of the other jobs do not evict it. + key: harmony-host + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} + + - name: Cache the HarmonyOS toolchain + # The official command-line tools, downloaded from the public mirror that + # `prepare-harmony-sdk.sh` pins by checksum. No Huawei account is involved, + # so this runs on every pull request. + uses: actions/cache@v4 + with: + path: ~/.local/share/harmony/current + key: harmony-tools-6.0.1.251-${{ runner.os }} + - name: Build the HAP (compiles ArkTS and the Rust host) + env: + OPENLESS_HARMONY_CHANNEL: none + run: | + set -euo pipefail + bash scripts/prepare-harmony-sdk.sh + bash scripts/package-harmony-hap.sh + # AppGallery needs a numeric versionName, so the packaging script keeps + # only the numeric prefix ("2.0.2", not "2.0.2-Beta.1"). + version_name=$(node -p "require('./package.json').version" | sed 's/[-+].*//') + version_code=$(cat linux-egui/package-revision) + bash scripts/verify-harmony-hap.sh \ + target/harmony-packages/OpenLess_harmony_arm64.hap \ + "${OPENLESS_HARMONY_TOOLCHAIN:-$HOME/.local/share/harmony/current}" \ + "$version_name" "$version_code" --allow-unsigned + # 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. @@ -290,8 +419,8 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" - cache: npm - cache-dependency-path: openless-all/app/package-lock.json + - name: Cache npm from beta + uses: ./.github/actions/cache-npm - uses: actions/setup-python@v5 if: runner.os == 'Windows' @@ -299,7 +428,7 @@ jobs: python-version: "3.x" # macOS MSRV is checked in a separate parallel job; Windows keeps the original check order. - - uses: dtolnay/rust-toolchain@1.88.0 + - uses: dtolnay/rust-toolchain@1.99.0 if: runner.os == 'Windows' - uses: dtolnay/rust-toolchain@stable @@ -311,15 +440,21 @@ jobs: - uses: swatinem/rust-cache@v2 if: runner.os == 'Windows' with: + # `shared-key` replaces the job name in the cache prefix, so the release + # workflow restoring the same key can reuse this tree. Keep the two in + # sync (scripts/ci-cache-sharing.test.mjs enforces it). + shared-key: tauri-windows workspaces: 'openless-all/app/src-tauri -> target' - save-if: ${{ github.event_name != 'pull_request' }} + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} - 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' }} + # Same prefix as `release-tauri.yml`, which is what lets a tag build + # restore the baseline branch's dependency tree instead of a cold one. + shared-key: tauri-macos + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} workspaces: | openless-all/app -> target openless-all/app/src-tauri -> target @@ -403,13 +538,26 @@ jobs: if: runner.os == 'Windows' run: python scripts/test-recorder-module.py - - name: Check Tauri backend with Rust 1.88 MSRV + - name: Check Tauri backend with Rust 1.99 MSRV if: runner.os == 'Windows' - run: cargo +1.88.0 check --locked --manifest-path src-tauri/Cargo.toml + run: cargo +1.99.0 check --locked --manifest-path src-tauri/Cargo.toml - - name: Compile backend tests with Rust 1.88 MSRV + - name: Compile backend tests with Rust 1.99 MSRV if: runner.os == 'Windows' - run: cargo +1.88.0 test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --no-run + run: cargo +1.99.0 test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --no-run + + - name: Build the Windows TSF input method + # The TSF DLL has its own vcxproj and is not part of the Tauri build, so + # nothing else in CI ever compiles it. A compile error here would + # otherwise only surface on a Windows machine with Visual Studio. + if: runner.os == 'Windows' + uses: microsoft/setup-msbuild@v2 + with: + msbuild-version: '17.x' + - name: Compile the TSF IME (x64, Release) + if: runner.os == 'Windows' + working-directory: openless-all/app/windows-ime + run: msbuild OpenLessIme.sln /p:Configuration=Release /p:Platform=x64 /m /v:minimal - name: Verify version sync across all 5 files # Runs on both platforms: the Windows runner ships git-bash, giving consistent behavior @@ -443,9 +591,12 @@ jobs: macos-msrv: needs: [changes] - # 失败即运行:只有判定脚本明确说“够不到”才跳过。 - if: needs.changes.outputs.msrv != 'false' - name: macOS Rust 1.88 MSRV + # Two conditions, both fail-open in the direction that matters: the change set + # must be able to reach Rust sources, and the declared MSRV must be older than + # the stable toolchain (otherwise `cross-platform` already compiles that code + # with stable, which is a superset of the MSRV promise). + if: needs.changes.outputs.msrv != 'false' && needs.changes.outputs.msrv_needed == 'true' + name: macOS Rust 1.99 MSRV runs-on: macos-latest timeout-minutes: 60 env: @@ -463,19 +614,19 @@ jobs: - uses: actions/setup-node@v4 with: node-version: "22" - cache: npm - cache-dependency-path: openless-all/app/package-lock.json - - uses: dtolnay/rust-toolchain@1.88.0 + - name: Cache npm from beta + uses: ./.github/actions/cache-npm + - uses: dtolnay/rust-toolchain@1.99.0 - uses: swatinem/rust-cache@v2 with: key: macos-msrv-v1 - save-if: ${{ github.event_name != 'pull_request' }} + save-if: ${{ github.ref == 'refs/heads/beta' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} workspaces: | openless-all/app/src-tauri -> target openless-all/app/src-tauri/backend-tests -> target - run: npm ci - run: npm run build - - name: Check Tauri backend with Rust 1.88 MSRV - run: cargo +1.88.0 check --locked --manifest-path src-tauri/Cargo.toml --timings - - name: Compile backend tests with Rust 1.88 MSRV - run: cargo +1.88.0 test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --no-run --timings + - name: Check Tauri backend with Rust 1.99 MSRV + run: cargo +1.99.0 check --locked --manifest-path src-tauri/Cargo.toml --timings + - name: Compile backend tests with Rust 1.99 MSRV + run: cargo +1.99.0 test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --no-run --timings diff --git a/.github/workflows/release-harmony.yml b/.github/workflows/release-harmony.yml new file mode 100644 index 000000000..6d26a02b2 --- /dev/null +++ b/.github/workflows/release-harmony.yml @@ -0,0 +1,216 @@ +name: Release HarmonyOS + +# HarmonyOS ships its HAP in the same GitHub Release as the desktop, Android and +# Linux assets, and the same package is pushed to HUAWEI AppGallery Connect. +# +# The build uses the official HarmonyOS command-line tools. Huawei's own download +# page needs an account, but the same archive is mirrored publicly (the mirror +# sherpa-onnx's own CI uses); `scripts/prepare-harmony-sdk.sh` pins it by SHA-256, +# so the workflow needs no variables and no self-hosted runner. Only the AppGallery +# signing profile is secret - which it has to be. +# +# Override `HARMONY_SDK_URL` / `HARMONY_SDK_SHA256` when a newer tools release is +# needed, and `runner_labels` for a machine that already has the tools installed. +# +# Dispatch it after the tag exists; it rebuilds that tag, signs it with material +# from Secrets, attaches the HAP to the Release and uploads it to AppGallery. +on: + workflow_dispatch: + inputs: + tag: + description: Existing release tag to rebuild and attach (v-tauri) + required: true + type: string + channel: + description: 'release = AppGallery production signing, test = AppGallery test channel' + required: false + type: choice + default: release + options: + - release + - test + publish: + description: Attach the HAP to that GitHub Release + required: false + type: boolean + default: true + upload_to_appgallery: + description: Upload the HAP to HUAWEI AppGallery Connect + required: false + type: boolean + default: true + runner_labels: + description: JSON array of runner labels (a hosted runner is enough; use a self-hosted one only if it already has the tools) + required: false + type: string + default: '["ubuntu-latest"]' + +permissions: + contents: write + +concurrency: + group: release-harmony-${{ inputs.tag }} + cancel-in-progress: false + +jobs: + build-harmony-hap: + runs-on: ${{ fromJSON(inputs.runner_labels) }} + timeout-minutes: 120 + env: + # Device-independent: the SDK path can be overridden per runner with a + # repository variable instead of patching this file. + OPENLESS_HARMONY_TOOLCHAIN: ${{ vars.HARMONY_TOOLCHAIN }} + # Empty until the signing step resolves `$RUNNER_TEMP`; the `runner` + # context is not available in a job-level `env`, only inside a step. + OPENLESS_HARMONY_SIGNING_DIR: "" + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ inputs.tag }} + # Signing material: files are base64 secrets, passwords are plain secrets + # and never touch the workspace. + HARMONY_KEYSTORE_PASSWORD: ${{ secrets.HARMONY_KEYSTORE_PASSWORD }} + HARMONY_KEY_PASSWORD: ${{ secrets.HARMONY_KEY_PASSWORD }} + HARMONY_KEY_ALIAS: ${{ vars.HARMONY_KEY_ALIAS }} + APPGALLERY_CLIENT_ID: ${{ secrets.APPGALLERY_CLIENT_ID }} + APPGALLERY_CLIENT_SECRET: ${{ secrets.APPGALLERY_CLIENT_SECRET }} + APPGALLERY_APP_ID: ${{ secrets.APPGALLERY_APP_ID }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + submodules: false + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Cache the HarmonyOS toolchain + uses: actions/cache@v4 + with: + path: ~/.local/share/harmony/current + key: harmony-tools-6.0.1.251-${{ runner.os }} + + - name: Prepare the HarmonyOS toolchain + env: + HARMONY_SDK_URL: ${{ vars.HARMONY_SDK_URL }} + HARMONY_SDK_SHA256: ${{ vars.HARMONY_SDK_SHA256 }} + run: | + set -euo pipefail + bash openless-all/app/scripts/prepare-harmony-sdk.sh + toolchain="${OPENLESS_HARMONY_TOOLCHAIN:-$HOME/.local/share/harmony/current}" + echo "OPENLESS_HARMONY_TOOLCHAIN=$toolchain" >> "$GITHUB_ENV" + echo "HVIGOR_USER_HOME=$toolchain/hvigor-home" >> "$GITHUB_ENV" + + - name: Validate the tag against package.json + working-directory: openless-all/app + run: | + set -euo pipefail + VERSION=$(node -p "require('./package.json').version") + TAG_VERSION=${RELEASE_TAG#v} + TAG_VERSION=${TAG_VERSION%-tauri} + if [ "${VERSION%%+*}" != "$TAG_VERSION" ]; then + echo "::error::tag $RELEASE_TAG does not match package.json version $VERSION" + exit 1 + fi + + - name: Write the signing material for this channel + # One secret set per channel: a `test` dispatch installs the test profile + # under `test.*`, a `release` dispatch installs the production profile + # under `release.*`. The packaging script picks the prefix by channel. + run: | + set -euo pipefail + dir="$RUNNER_TEMP/harmony-signing" + prefix="${{ inputs.channel }}" + rm -rf "$dir" + mkdir -p "$dir" + umask 077 + echo "OPENLESS_HARMONY_SIGNING_DIR=$dir" >> "$GITHUB_ENV" + for pair in "HARMONY_CERT_BASE64:$prefix.cer" \ + "HARMONY_PROFILE_BASE64:$prefix.p7b" \ + "HARMONY_KEYSTORE_BASE64:$prefix.p12"; do + name="${pair%%:*}" + file="${pair##*:}" + value="${!name:-}" + if [ -z "$value" ]; then + echo "::error::missing secret $name (base64 of $file)" + exit 1 + fi + printf '%s' "$value" | base64 -d > "$dir/$file" + done + ls -l "$dir" + + - name: Build, sign and stage the HAP + working-directory: openless-all/app + env: + OPENLESS_HARMONY_CHANNEL: ${{ inputs.channel }} + run: bash scripts/package-harmony-hap.sh + + - name: Verify the packaged HAP + working-directory: openless-all/app + run: | + set -euo pipefail + output=target/harmony-packages + ( cd "$output" && sha256sum --check --strict SHA256SUMS ) + # AppGallery needs a numeric versionName, so the packaging script keeps + # only the numeric prefix ("2.0.2", not "2.0.2-Beta.1"). + version_name=$(node -p "require('./package.json').version" | sed 's/[-+].*//') + version_code=$(cat linux-egui/package-revision) + toolchain="${OPENLESS_HARMONY_TOOLCHAIN:-$HOME/.local/share/harmony/current}" + # The Release only carries the unsigned package; the store copy must + # additionally prove its signature. + bash scripts/verify-harmony-hap.sh "$output/OpenLess_harmony_arm64.hap" \ + "$toolchain" "$version_name" "$version_code" --allow-unsigned + bash scripts/verify-harmony-hap.sh \ + "$output/OpenLess_harmony_arm64-signed.hap" "$toolchain" + + - name: Upload the HAP as a workflow artifact + uses: actions/upload-artifact@v4 + with: + name: openless-harmony-hap + # `-signed` stays in the workflow artifacts for the store upload; the + # repository never publishes it. + path: | + openless-all/app/target/harmony-packages/* + if-no-files-found: error + + - name: Attach the HAP to the GitHub Release + if: inputs.publish + working-directory: openless-all/app + run: | + set -euo pipefail + # The desktop workflow creates the Release for this tag; wait for it + # rather than racing its creation. + for attempt in $(seq 1 40); do + if gh release view "$RELEASE_TAG" --json isDraft > /dev/null 2>&1; then + break + fi + if [ "$attempt" = 40 ]; then + echo "::error::release $RELEASE_TAG was not created by the desktop workflow" + exit 1 + fi + sleep 15 + done + output=target/harmony-packages + gh release upload "$RELEASE_TAG" \ + "$output"/OpenLess_harmony_arm64.hap \ + "$output"/OpenLess_harmony.hap \ + "$output"/SHA256SUMS \ + --clobber + echo "attached HarmonyOS assets to $RELEASE_TAG" + + - name: Upload to HUAWEI AppGallery Connect + if: inputs.upload_to_appgallery + working-directory: openless-all/app + # A fork without AGC credentials still gets the GitHub Release; the step + # reports that it skipped instead of failing the whole release. + run: >- + # Only the signed package is uploaded; the unsigned one is what the + # GitHub Release distributes. + node scripts/upload-appgallery.mjs \ + --channel "${{ inputs.channel }}" \ + --file target/harmony-packages/OpenLess_harmony_arm64-signed.hap \ + --skip-if-unconfigured diff --git a/.github/workflows/release-linux-egui.yml b/.github/workflows/release-linux-egui.yml index 091f50001..7c2787485 100644 --- a/.github/workflows/release-linux-egui.yml +++ b/.github/workflows/release-linux-egui.yml @@ -1,20 +1,40 @@ name: Release Linux egui -# Independent Linux builds retain the same tests and deb/rpm packaging as CI. -# After product acceptance, an administrator builds the accepted release tag and -# explicitly attaches the verified Actions artifacts to that existing Release. +# Linux ships in the same GitHub Release as the desktop and Android assets. +# +# The entry point stays manual: the tag is created by an administrator after the +# Linux host/UI acceptance in docs/linux-egui-handoff/07-acceptance.md, and this +# workflow only rebuilds that already-reviewed tag. It never reacts to Tauri +# tags, so a desktop release can never ship an unaccepted Linux package by +# accident; but once dispatched, the packages are attached to the Release +# automatically instead of being uploaded by hand. on: workflow_dispatch: + inputs: + tag: + description: Existing release tag to rebuild and attach (v-tauri) + required: true + type: string + publish: + description: Attach the verified packages to that Release + required: false + type: boolean + default: true permissions: - contents: read + contents: write concurrency: - group: release-linux-egui-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.ref }} + group: release-linux-egui-${{ inputs.tag }} cancel-in-progress: false jobs: build-linux-egui: permissions: - contents: read + contents: write uses: ./.github/workflows/check-linux-egui.yml + with: + scope: full + release_tag: ${{ inputs.tag }} + publish: ${{ inputs.publish }} + checkout_ref: ${{ inputs.tag }} diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 9ab9f61d9..d55d9d8fd 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -19,10 +19,15 @@ name: Release Tauri (cross-platform) on: push: - branches: - - '**' + # Release bundles are only needed for tags. Feature-branch pushes used to run + # this workflow purely to warm the release caches, which cost ~25 minutes per + # push and stored ~2.3 GB of release trees in the shared `beta` cache scope. + # The weekly schedule keeps that warm on the default branch instead. tags: - 'v*-tauri' + schedule: + # Monday 04:17 UTC: validate the release path and refresh the release caches. + - cron: '17 4 * * 1' workflow_dispatch: inputs: platform: @@ -139,6 +144,9 @@ jobs: if: matrix.platform == 'windows-latest' uses: swatinem/rust-cache@v2 with: + # Restores the tree the beta `cross-platform` job maintains, so a tag + # build starts warm instead of compiling every dependency from scratch. + shared-key: tauri-windows workspaces: 'openless-all/app/src-tauri -> target' cache-on-failure: true @@ -146,7 +154,9 @@ jobs: if: startsWith(matrix.platform, 'macos') uses: swatinem/rust-cache@v2 with: - key: macos-release-v1 + # Same prefix as the beta macOS check. The weekly schedule run fills it + # with release-profile artifacts, so tag builds are warm. + shared-key: tauri-macos workspaces: 'openless-all/app/src-tauri -> target' - name: Prepare Windows Sherpa static libraries diff --git a/.gitignore b/.gitignore index d859d8c6e..577a97949 100644 --- a/.gitignore +++ b/.gitignore @@ -135,4 +135,3 @@ CapsWriter # Local Linux toolchains, build logs and packaging workspaces. openless-all/app/.cache/ -openless-all/scripts/linux-desktop/kde/build/ diff --git a/README.md b/README.md index c41c3369f..5cbd52f3f 100644 --- a/README.md +++ b/README.md @@ -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 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. +Rust 1.99 is the minimum supported toolchain for every source tree (`openless-core`, `src-tauri`, `linux-egui`); the latest stable Rust is recommended. CI verifies both Rust 1.99 and stable on macOS and Windows, and stable for the Linux egui host. 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. @@ -364,7 +364,7 @@ What features does this app still need? …not a list of missing features. -Long-term reference rewrites are represented as `raw → polished → rule` triples in [Examples/polish-reference-examples.sample.jsonl](Examples/polish-reference-examples.sample.jsonl). The sample format describes reference data; current module responsibilities are documented in [Architecture](docs/architecture.md). +Long-term reference rewrites are represented as `raw → polished → rule` triples in [docs/references/polish-reference-examples.sample.jsonl](docs/references/polish-reference-examples.sample.jsonl). The sample format describes reference data; current module responsibilities are documented in [Architecture](docs/architecture.md). ## Dictionary diff --git a/README.zh.md b/README.zh.md index c8e171d93..2bc8e2245 100644 --- a/README.zh.md +++ b/README.zh.md @@ -274,7 +274,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 是桌面应用源码(`openless-core`、`src-tauri`)所支持的最低工具链版本;建议使用最新 stable Rust。CI 在 macOS 与 Windows 上同时验证 Rust 1.88 与 stable;Linux egui 宿主需要 Rust 1.95,按 stable 验证。 +Rust 1.99 是整个源码树(`openless-core`、`src-tauri`、`linux-egui`)所支持的最低工具链版本;建议使用最新 stable Rust。CI 在 macOS 与 Windows 上同时验证 Rust 1.99 与 stable,Linux egui 宿主按 stable 验证。 Apple Silicon 编译可选的 Qwen3-ASR MLX 后端时需要 Xcode 的 MetalToolchain 组件。执行 `xcodebuild -downloadComponent MetalToolchain` 安装,并用 `xcrun --find metal` 验证。它只属于源码构建依赖,已打包的 OpenLess 应用运行时不需要该组件。 @@ -371,7 +371,7 @@ OpenLess 的润色模型只重塑文本。它不回答问题、不执行任务 ……而不是一份缺失功能的清单。 -长期参考改写的 `raw → polished → rule` 三元组格式见 [Examples/polish-reference-examples.sample.jsonl](Examples/polish-reference-examples.sample.jsonl)。该文件说明参考数据格式;当前模块职责见[架构文档](docs/architecture.md)。 +长期参考改写的 `raw → polished → rule` 三元组格式见 [docs/references/polish-reference-examples.sample.jsonl](docs/references/polish-reference-examples.sample.jsonl)。该文件说明参考数据格式;当前模块职责见[架构文档](docs/architecture.md)。 ## 词典 diff --git a/RELEASING.md b/RELEASING.md index e3ce1d3ad..6e74345d7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -44,12 +44,51 @@ 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 build the macOS, Windows, and Android Tauri hosts in parallel. Linux is -independent: `.github/workflows/release-linux-egui.yml` is manual-only, runs the -same Core/host/deb/rpm verification as CI, and uploads Actions artifacts. It does -not react to Tauri tags or change a GitHub Release. After Linux product acceptance, -an administrator may run it against the accepted existing release tag, verify its -commit and package checksums, and explicitly attach the deb/rpm files to that -Release. Linux has no in-app updater manifest or AppImage. +triggered separately so an unaccepted package can never ride along with a desktop +release: `.github/workflows/release-linux-egui.yml` is manual-only, requires the +release tag as an input, checks it out, runs the same Core/host/deb/rpm +verification as CI, and then **attaches the verified packages to that same +Release automatically**. Nothing is uploaded by hand. Linux has no in-app updater +manifest or AppImage. + +HarmonyOS rides in the same Release through `.github/workflows/release-harmony.yml`. +It is manual-only as well and requires the same tag. It builds, signs and attaches +`OpenLess_harmony_arm64.hap` plus the `OpenLess_harmony.hap` alias, then uploads +the same HAP to HUAWEI AppGallery Connect. + +The build needs the official HarmonyOS command-line tools (SDK + hvigor + ohpm + +bundled node). Huawei's download page wants an account, but the same archive is +mirrored publicly - the mirror `k2-fsa/sherpa-onnx`'s own `harmony-os` workflow +uses - so `scripts/prepare-harmony-sdk.sh` downloads +`commandline-tools-linux-x64-6.0.1.251.zip` from it, verifies the pinned SHA-256 +and lays it out under `~/.local/share/harmony/current`. **No repository variable +and no self-hosted runner is required**; the toolchain is cached by +`actions/cache` under the tools version. + +Override `HARMONY_SDK_URL` / `HARMONY_SDK_SHA256` to move to a newer tools +release, or set `HARMONY_TOOLCHAIN` and dispatch with +`runner_labels: ["self-hosted","harmony"]` on a machine that already has the +tools installed. Do not commit the archive: the SDK licence does not permit +redistribution, which is also why it is fetched at job time rather than vendored. + +### Which channel gets which package + +| Channel | Artifact | Signing | Where it goes | +| --- | --- | --- | --- | +| Repository / GitHub Release | `OpenLess_harmony_arm64.hap` (+ `OpenLess_harmony.hap`) | **unsigned** | Release assets, `SHA256SUMS` | +| AppGallery 正式版 | `OpenLess_harmony_arm64-signed.hap` | `release.*` material | AppGallery only, never a Release asset | +| 测试渠道 | `OpenLess_harmony_arm64-signed.hap` | `test.*` material | AppGallery test channel | + +Dispatch the workflow with `channel: release` (default) or `channel: test`; the +input selects both the signing prefix (`release.cer/p7b/p12` vs +`test.cer/p7b/p12`) and the AppGallery upload label. The workflow artifact +bundle carries both packages, the Release step uploads only the unsigned pair, +and the AppGallery step uploads only the signed one. + +A local machine without store certificates can still produce a package: +`OPENLESS_HARMONY_CHANNEL=none` builds and ships the unsigned HAP only, and +`scripts/verify-harmony-hap.sh --allow-unsigned` +checks its contents without a device. 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 @@ -115,13 +154,49 @@ The script takes a plain `X.Y.Z`; for a prerelease version such as or manual cask edit is needed. Do not publish via an asset workflow's `GITHUB_TOKEN`, which does not emit the downstream release event. -Before independently attaching Linux assets, additionally require all of the following: +Before dispatching the Linux release build, 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 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 manual build targets the same existing tag whose commit, CI and Linux product acceptance were reviewed. `workflow_dispatch` only uploads Actions artifacts; an administrator separately uploads the verified packages to that Release. -5. Attach the Linux checksum file as `SHA256SUMS-linux` so it does not replace an existing desktop/Android checksum file. +4. The dispatch pins the tag as an input; the workflow checks that tag out and rejects a `package.json` version mismatch, so the packages provably come from the reviewed commit. +5. The checksum file is produced and attached as `SHA256SUMS-linux` so it does not replace an existing desktop/Android checksum file. + +## HarmonyOS signing material and AppGallery credentials + +The HAP build reads everything from the environment; no key material is ever +committed, cached or printed. + +| Kind | Name | Purpose | +|---|---|---| +| Secret | `HARMONY_KEYSTORE_BASE64` | base64 of the release `.p12` keystore | +| Secret | `HARMONY_CERT_BASE64` | base64 of the release `.cer` certificate | +| Secret | `HARMONY_PROFILE_BASE64` | base64 of the release `.p7b` provisioning profile | +| Secret | `HARMONY_KEYSTORE_PASSWORD` | keystore password | +| Secret | `HARMONY_KEY_PASSWORD` | key password (falls back to the keystore password) | +| Variable | `HARMONY_KEY_ALIAS` | key alias inside the keystore | +| Variable | `HARMONY_TOOLCHAIN` | absolute path of the DevEco / HarmonyOS SDK on a self-hosted runner | +| Variable (optional) | `HARMONY_SDK_URL` | override the tools archive URL | +| Variable (optional) | `HARMONY_SDK_SHA256` | SHA-256 of that archive | + +The `test` channel reads the same three Secrets (`HARMONY_KEYSTORE_BASE64`, +`HARMONY_CERT_BASE64`, `HARMONY_PROFILE_BASE64`). Store the test profile there +and switch channels by dispatching with `channel: test`, so one secret set at a +time matches the channel being built. +| Secret | `APPGALLERY_CLIENT_ID` | AppGallery Connect API client id | +| Secret | `APPGALLERY_CLIENT_SECRET` | AppGallery Connect API client secret | +| Secret | `APPGALLERY_APP_ID` | AppGallery Connect app id of the HarmonyOS app | + +The upload replaces the candidate package in AppGallery Connect; it never submits +the app for review, because AGC requires a human submission. Without the +`APPGALLERY_*` secrets the workflow still attaches the HAP to the GitHub Release +and reports that the store upload was skipped. + +`versionName`/`versionCode` in `openless-all/app/harmony/AppScope/app.json5` are a +development placeholder; the packaging script rewrites them for the build +(`versionName` from `package.json`, `versionCode` from +`linux-egui/package-revision`, which only increases) and restores the file +afterwards. ## Release notes and contributor attribution diff --git a/docs/architecture.md b/docs/architecture.md index 2569dab71..e98cbeb7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,10 +1,10 @@ # OpenLess 2.0 架构 -状态:canonical,当前实现说明;更新:2026-10-15。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 +状态:canonical,当前实现说明;更新:2026-10-19。平台范围见 [2.0 需求](2.0-requirements.md),文件定位见 [目录结构](structure.md)。 ## 1. 分层与工作区 -应用开发与构建源在 `openless-all/app/`。下文源码路径以该目录为基准。根 [Cargo workspace](../openless-all/app/Cargo.toml) 成员为 `crates/openless-core` + `linux-egui`;`src-tauri`(及其 `backend-tests` 测试 crate)被 exclude,独立构建。Core 是与 Host 同进程的业务库。 +应用开发与构建源在 `openless-all/app/`。下文源码路径以该目录为基准。根 [Cargo workspace](../openless-all/app/Cargo.toml) 成员为 `crates/openless-core` + `linux-egui` + `harmony/rust`;`src-tauri`(及其 `backend-tests` 测试 crate)被 exclude,独立构建。Core 是与 Host 同进程的业务库。 | 层 | 位置 | 职责 | | --- | --- | --- | @@ -12,8 +12,9 @@ | Host(Win/mac/Android) | `src-tauri/`(crate `openless`) | `src/lib.rs` 注册命令;适配窗口、热键、音频、凭据、插入、IME 和生命周期 | | 共享 Core | `crates/openless-core/` | 业务规则、会话、服务调用和数据仓储;通过 trait 接入 Host 能力 | | Linux Host + UI | `linux-egui/`(crate `openless-linux-egui`) | `backend.rs` 组装 `OpenLessBackend`,`main.rs` 实现 egui/eframe UI,不依赖 Tauri/WebKitGTK | +| HarmonyOS Host + UI | `harmony/`(ArkTS + ArkWeb)与 `harmony/rust`(crate `openless-harmony-host`) | ArkTS 提供输入法/窗口/剪贴板能力,Web 界面复用 `src/` 的 harmony 构建,Rust 侧经 napi 暴露同一套 IPC | -Android 侧:`src-tauri/src/android/`(JNI/桥接)+ `android/`(aidl、kotlin、manifests、frontend);`android/frontend` 经 Vite 别名 `@android` 被 `src/` 引用;manifest 由 `scripts/merge-android-*.mjs` 合成。Linux 已有可复用 Host/UI 起点,剩余能力与产品验收见 [交接目录](linux-egui-handoff/README.md)。 +Android 侧:`src-tauri/src/android/`(JNI/桥接)+ `android/`(aidl、kotlin、manifests、frontend);`android/frontend` 经 Vite 别名 `@android` 被 `src/` 引用;manifest 由 `scripts/merge-android-*.mjs` 合成。Android 与 HarmonyOS 共用的输入法 native 后端在 `mobile-ime/`;HarmonyOS 的构建与分发见 [发布规范](../RELEASING.md)。Linux 已有可复用 Host/UI 起点,剩余能力与产品验收见 [交接目录](linux-egui-handoff/README.md)。 ## 2. 数据流 diff --git a/docs/beta5-regression-repair.md b/docs/beta5-regression-repair.md deleted file mode 100644 index 250aacd27..000000000 --- a/docs/beta5-regression-repair.md +++ /dev/null @@ -1,93 +0,0 @@ -# beta.5 用户反馈修复与审查结果 - -范围:`0454dc56..0947c05a`(PR #1197),以及本次工作树修复。 - -**当前交接状态(2026-10-15):** 已构建并安装 `2.0.0-beta-a.beta.6+build.20261015-be` 到 `/Applications/OpenLess.app`,包含最后的实验下载状态 CSS 隔离修复。已安装主程序与最终构建产物 SHA-256 一致:`7f0918b3012d273fb1ec61b3a041089c85acf9fcb04f73e5ce933bbf6076bba4`。源码未提交、未推送。按用户最新要求,后续测试由用户负责;Agent 停止继续测试,不回滚、不撤回当前安装,不继续操作 App。其余审查 Agent 已关闭,本轮编排记录在备份后清空,自动任务列表为空。 - -## 结论 - -首录音停止后一直 Processing 的高可信根因不是转圈动画:**指定名称的麦克风 + CPAL 0.15.3 保留回调 sender + 音频处理线程只等队列 EOF**,使停止录音永久卡在 `join`,后续多模态请求尚未发出。该等待由 **`44972015` / PR #1194(麦克风降噪)** 引入,已经存在于此次整合基线之前。 - -已改为显式 FIFO `Shutdown`:停止接受原生采样、暂停/释放外层输入流,再排空已接收音频并结束处理线程,不再等待第三方回调析构。没有通过超时隐藏转圈、丢弃 join 或提前返回来掩盖阻塞。 - -本机日志 `~/Library/Logs/OpenLess/openless.log` 中,2026-10-15 20:26 与 20:38(+0800)都能看到输入流启动/释放,但没有后续 Omni POST;非秘密偏好为 DashScope 多模态、指定内建麦克风、降噪开启、低延迟关闭,与上述分支吻合。麦克风权限为 granted。旧 cb# 日志已随新 worker 停用,不能把缺少 cb# 当成没有采样的证据。 - -CPAL 自身的原生引用环没有被篡改或通过升级依赖绕过;本轮修复的是应用等待其析构造成的死锁。真实设备资源、拔插和完整输入仍需后述实机验收。 - -## ToDo 状态 - -- [x] P0:检查录音/停止/转写/多模态/输出调用链、本地日志与偏好,追溯引入提交;修复音频处理线程退出协议,并验证保留原生 sender 时已接收 PCM 不丢失、停止正常完成。 -- [x] 独立审查全部整合差异:语音/快捷键、核心、多模态、前端、egui 与构建边界;原始问题和本轮修复状态分开记录。 -- [x] 所有平台无显式偏好时默认界面大小 100%;首帧 bootstrap 同步应用。用户主动保存的 90%/100%/110% 不被覆盖。 -- [x] 100% 返回录音输入页:复用缓存平台能力、滚动在 layout effect 归零、修正条件 return 后调用 hooks、避免无变化的 ResizeObserver setState。双浏览器反复往返采样确认控件位置稳定。 -- [x] 共享主页面/设置内容保留短退出淡化,快速 A→B→A 取消旧任务;页面只淡化,不缩放/平移文字。设置对整个不透明 workspace 淡出,不再只淡出被实色外层遮挡的子节点。 -- [x] 桌面设置仅保留“返回应用”;顶部专用拖动条高于全窗设置,并按界面缩放换算原生尺寸。主设置原先就是原生红绿灯;真正的仿制灯位于 Less Computer,已移除并接入 NSWindow 装饰和原有 dismiss 清理流程。 -- [x] 实验模型采用 Settings/全局指示器共用的状态与取消路径;取消调用对应实验开关,而不是已退役的 Qwen 下载接口。首字节前为不定进度,少量字节不再伪装成固定 0%;错误可见,关闭设置不丢失任务状态。 -- [x] 实验模型三个条目预留位置,状态/进度/操作放右侧,开关位置稳定。浏览器回归确认开始下载不推动后续条目;错误使用平滑展开。 -- [x] 快捷键只有点击当前按键一个录制入口,删除重复展开菜单。录制提示使用右侧 Base UI Popover,不顶开下面内容;重置、停用和独立的 Mac 听写键动作保留。键盘捕获不依赖 Portal 焦点到达的时序。 -- [x] 普通/可搜索下拉统一 Base UI Select/Combobox;开关、展开、提示与录制弹层复用 Base UI Switch/Collapsible/Tooltip/Popover。长列表跨越 120ms 动态内容刷新后保持用户滚动;右优先并保留碰撞避让、键盘操作和 zoom 补偿。 -- [x] 云同步状态通过 Collapsible 平滑推动后续元素;实验完成提示、云同步设置/自动提示、市场登录使用共享退出保留。共享 Modal 处理顶层 Escape,保留已被子层消费的按键和 busy 保护。 -- [x] 110% 下多模态表单紧凑布局、禁用状态提示、快速模式往返后的高亮跟随及窄屏可访问性回归。 -- [ ] 本轮构建的原生 App:真实 Fn 首次/连续/取消录音,设置麦克风预览→听写交接,实际 DashScope/其他服务请求与目标应用上屏;没有用浏览器 mock 冒充通过。 -- [ ] 原生标题栏拖动/三色灯、真实下载零字节取消/断网/重启、Windows Foundry SDK 与设备拔插验收。 -- [ ] Linux 新视觉基线:本机仅能生成/检查 macOS 的八张图片;Linux 对应旧图尚未刷新,不能宣称 Linux UI CI 已绿。 - -## 根因与附带修复 - -| 问题 | 引入记录 | 修复与证据 | -| --- | --- | --- | -| 停止音频线程永久等待 EOF;还可堵塞麦克风预览操作锁,使下一段录音无电平 | `44972015` / #1194,基线前 | 显式 Shutdown;直接编译生产 recorder 的 22 项无硬件回归通过,含保留 sender、排空 8 帧并有界结束 | -| 普通页面删掉 exit;全窗设置不透明外层遮住淡出、覆盖拖动区;快捷键重复录制入口 | `8c32db9e` | 恢复保留/取消生命周期,整体淡化,专用拖动条及单一录制入口 | -| 长列表更新时重置高亮、抢回选中项;新实验轮询扩大重绘范围 | 原手写菜单为 `66915405`;放大因素 `16a73b9d` | Base UI 统一组件、稳定条目 identity、共享快照去重;双浏览器长列表动态更新验证 | -| Qwen/Stepfun 启动失败后持锁再 cancel,重入同一非重入锁 | `9ed34dab` / #775;`17fe67c5` / #835,均为历史缺陷 | 先释放状态锁再清理;两个错误分支回归通过。不是本机当前多模态故障的直接候选 | -| TextStream stdout EOF 后取走 Child 并 wait,取消/超时看不到仍运行的进程 | `16a73b9d` | 短锁 try_wait 保留取消句柄;生产 Process 模块的 EOF-before-exit 子进程回归通过 | -| 编辑非活动同 provider 渠道误释放活动 runtime;迟到状态刷新可能清理新实例 | `16a73b9d` | 按活动 owner 清理、只串行 owner 交接、清理完成后才做异步状态刷新;Core 合同 34 项通过 | -| Foundry 缺失/截断缓存无法冷删除,反而忘记 receipt | `16a73b9d` | 删除完整性与加载完整性分离,验证受管路径并保留失败凭据;6 项临时文件系统回归通过 | - -## 独立审查 - -Orca Run:`run_102ebb62f9c8`。审查与实现文件所有权分离:主 Agent 负责语音与前端;一个后续 Pi 任务仅修改 `api.rs`、`local_asr_contract.rs`、`native_model_receipt.rs`,其余审查只读。 - -| Agent / 结果 | 范围 | 固定报告 | -| --- | --- | --- | -| Codex,完成 | 首录音链路、日志、偏好白名单与提交追溯 | [语音审查](reviews/2026-10-15/voice.md) | -| Pi,完成 | Core、多模态、本地运行时、egui/构建差异 | [核心审查](reviews/2026-10-15/core.md) | -| Pi,完成后续独占修复 | 渠道 owner 与 Foundry 缓存删除 | [修复与回归](reviews/2026-10-15/core-fixes.md) | -| Pi,完成;替代失败的 Cursor/Codex UI 审查 | 固定 HEAD 的双浏览器动态对照与前端/测试审查 | [UI 审查](reviews/2026-10-15/ui.md) | - -Cursor 因额度耗尽退出;第一次替代 Codex 因 workspace routing discovery 连续失败未完成审查,没有把它们计为成功。最终三个审查范围均有独立报告。报告中的“当前 worktree”是各自交付时的快照;例如 UI 报告写作之后又将快捷键录制提示移入 Popover,并补了真实键盘输入回归,当前源码/本清单优先。 - -## 本轮验证 - -以下表格主要记录安装前代码修复阶段的检查,命令在 `openless-all/app/` 执行;没有清除用户配置或读出 Keychain 秘密。随后已完成 beta.6 的真实 Release 构建与安装;后续设备测试现交由用户负责。 - -| 检查 | 结果 | -| --- | --- | -| `npm run build` | TypeScript/Vite 通过;原有大 chunk 提示仍在,不把提高告警阈值当作优化 | -| `node scripts/frontend-test-runner.mjs` | 121 个前端/契约测试文件通过 | -| `CARGO_TARGET_DIR="$PWD/src-tauri/backend-tests/target" cargo test --offline --locked -p openless-core --lib` | 1204 通过,0 失败,1 个显式外部 Codex 用例忽略 | -| Core `local_asr_contract` | 34 通过,0 失败 | -| `CARGO_TARGET_DIR="$PWD/src-tauri/backend-tests/target" python3 scripts/test-recorder-module.py --offline` | 项目既有隔离脚本直接编译生产 recorder 与音频处理器:37 通过,0 失败,其中 recorder 22 项含本次退出回归 | -| `cargo test --offline --locked --manifest-path src-tauri/backend-tests/Cargo.toml` | 46 通过,0 失败;包括生产 Process、receipt 和已有 Host 合同 | -| Chromium/WebKit UI | 全量 96 项中首次 90 通过,6 项暴露真实布局/输入或旧断言问题;修复后 6 项及 4 个视觉用例无更新参数重跑全通过;另补共享 Modal/Guide Escape 4 项通过。共 100 个独立 UI 用例覆盖;没有把首轮失败记成一次全绿执行 | -| 八张 macOS 深/浅色截图 | 已更新并检查;不带 `--update-snapshots` 的独立像素比较通过,未提高容差;Linux 旧图未冒充已更新 | -| 原生 Rust 类型检查 | `DOCS_RS=1 CARGO_BUILD_JOBS=2 cargo check --offline --locked --manifest-path src-tauri/Cargo.toml` 通过;该模式跳过 Sherpa 本机库下载/链接,不是最终生产链接验证 | -| 格式/补丁 | 变更前端文件 Prettier、目标 Rust 文件 rustfmt、`git diff --check` 通过 | - -第一次完整 native `cargo test --lib` 曾受 Sherpa 1.13.2 预编译库下载的 TLS EOF 阻塞;直接 curl 同一公共下载地址当时也超时。后续已通过断点续传取得完整官方依赖,真实 Release `.app` / DMG 构建、签名与包校验通过,并安装到 `/Applications/OpenLess.app`;该阻塞现已解除。没有把构建成功当作完整 native 单元测试已执行。因此本轮复用项目既有 `scripts/test-recorder-module.py` 直接编译生产 recorder/音频处理器,并将生产 Process 模块挂入独立后端测试 crate,完成实际队列与子进程回归。隔离脚本仅替换 Core 的接口桥,不打开设备;没有用空壳实现或 docs.rs 模式冒充 native 测试成功。生产依赖版本不变,后端 lockfile 仅增加测试所需的既有 libc 直接依赖,没有顺带升级/降级其他包。 - -主要本轮日志:`/tmp/openless-regression-core.log`、`openless-regression-recorder-final.log`、`openless-regression-backend-final.log`、`openless-regression-frontend-final.log`、`openless-regression-full-ui.log`、`openless-regression-final-validation.log`、`openless-regression-presence-final.log`、`openless-regression-delivery-ui.log`、`openless-regression-downloads-final.log`、`openless-regression-native-check.log`。临时目录可能被系统清理;本文件和报告保留结论及复现入口。 - -## 后续发版接手(2026-10-16) - -用户随后确认本机修复版无明显问题,并授权全面 Review、PR/CI/合并和 beta6 → 2.0.0 正式发布;前述“不提交/停止测试”为上一阶段安排,已由本次授权更新。当前本机 App 不替换;同构建哈希、运行状态和最新真实录音完成日志已核验。发版前 Review、新发现的发布链路修复及本机门禁详见 [release-readiness.md](reviews/2026-10-16/release-readiness.md)。 - -## 实机验收入口 - -beta.6 已按项目构建流程生成并安装;[原 beta.5 安装包](desktop-beta5-integration.md) 不包含这些修复。按用户最新安排,以下剩余设备验收由用户接手,Agent 不再自动执行: - -1. 指定内建麦克风,保持用户的 DashScope 多模态与降噪设置,冷启动后 Fn 开始→结束;确认日志出现新增 `audio processor stopped (accepted frames drained)`,随后有 Omni 请求及最终上屏。 -2. 连续录音/取消,再在设置中打开、滚动、关闭麦克风列表后录音;不应残留预览锁、橙点或 Processing。 -3. 100%/110% 来回切页、拖动标题栏、使用原生窗口按钮;实验模型从零字节下载取消、关闭开关、离开设置和重启后复查。 - -这些步骤保留为用户接手清单,不是全部通过声明。此前已观察到真实录音完成处理线程退出、多模态请求和 `Inserted` 终态;其余界面与完整设备行为由用户继续确认。 diff --git a/docs/ci-trigger-and-cache-policy.md b/docs/ci-trigger-and-cache-policy.md index a17666c85..55e6dcf3a 100644 --- a/docs/ci-trigger-and-cache-policy.md +++ b/docs/ci-trigger-and-cache-policy.md @@ -19,8 +19,8 @@ | 区域 | 跳过条件(全部改动都命中才算跳过) | 覆盖的 job | |---|---|---| -| `tauri` | 仅 `linux-egui/**`(但**不含**它的 `Cargo.toml`,那会动全工作区 lock)、`scripts/linux-fcitx5-plugin/**`、`docs/**`、`*.md`、`LICENSE` | macOS / Windows 桌面检查、Android 检查、Chromium/WebKit UI 动效回归 | -| `msrv` | 全部改动里没有任何 `.rs`、`Cargo.toml`、`Cargo.lock`、`rust-toolchain*`、`.github/**` | macOS Rust 1.88 MSRV | +| `tauri` | 仅 `linux-egui/**`(但**不含**它的 `Cargo.toml`,那会动全工作区 lock)、`app/scripts/linux-fcitx5-plugin/**`、`docs/**`、`*.md`、`LICENSE` | macOS / Windows 桌面检查、Android 检查、Chromium/WebKit UI 动效回归 | +| `msrv` | 全部改动里没有任何 `.rs`、`Cargo.toml`、`Cargo.lock`、`rust-toolchain*`、`.github/**` | macOS Rust 1.99 MSRV | | `linux` | 全部改动都是散文(`docs/**`、`*.md`、`LICENSE`、`NOTICE`) | Linux Core/egui 测试与 deb/rpm 打包链 | 规则是**失败即运行**,而且这一原则贯穿三层: @@ -37,10 +37,11 @@ ## 2. MSRV 为什么是独立 job -- `openless-core`、`src-tauri`、`src-tauri/backend-tests` 都声明 `rust-version = "1.88"`,README 对外承诺该版本,因此必须有 CI 用真的 1.88 编译:MSRV 只会被“依赖升级抬高要求”或“自己用了更新的语言特性/API”打破,两者都只有在 1.88 下编译才会暴露。 +- `openless-core`、`src-tauri`、`src-tauri/backend-tests`、`linux-egui` 统一声明 `rust-version = "1.99"`,README 对外承诺该版本,因此必须有 CI 用真的 1.99 编译:MSRV 只会被“依赖升级抬高要求”或“自己用了更新的语言特性/API”打破,两者都只有在 1.99 下编译才会暴露。 + 该数字由依赖实际要求决定:`tauri 2.12` 要 1.90、`aes 0.9.3`/`uuid 1.28` 要 1.89,代码库选择跟随当前 stable(1.99)以保持单一数字,不再为旧工具链回退依赖。 - 工具链不同 ⇒ 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 已按此修正。 +- Linux 宿主 `linux-egui` 与桌面侧同用 `rust-version = "1.99"`(egui 0.37 本身只要求 1.95),Linux 侧按 stable 验证。 ## 3. 缓存配额分配(10 GB / 仓库) @@ -50,7 +51,7 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job | job | 缓存 | 命中收益 | 性价比 | |---|---|---|---| -| macOS Rust 1.88 MSRV | 0.72 GB | −13.1 min | 1102 s/GB | +| macOS Rust 1.99 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 | @@ -90,7 +91,7 @@ GitHub 每仓库保留 10 GB 缓存,超出按 LRU 静默淘汰;淘汰后 job | macOS checks | 20m01s | 8m36s | 6m16s | | Windows checks | 20m04s | 15m30s | 13m17s | | Android | 8m11s | 5m50s | 5m50s | -| macOS Rust 1.88 MSRV | 16m47s | 3m39s | 3m39s | +| macOS Rust 1.99 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。 diff --git a/docs/dependency-audit.md b/docs/dependency-audit.md new file mode 100644 index 000000000..d2dfc695b --- /dev/null +++ b/docs/dependency-audit.md @@ -0,0 +1,133 @@ +# 依赖审计(全部工程) + +审计时间:2026-10-19 · HEAD `feat/linux-egui-followups` + +工程里有**四个独立的依赖图**,必须分别看(Rust 侧其实是**三个独立锁文件**: +根 workspace、`src-tauri`、`src-tauri/backend-tests`——三者都要单独 `cargo update`, +CI 一律用 `--locked`): + +| 依赖图 | 位置 | 谁在用 | +|---|---|---| +| A. 根 workspace | `openless-all/app/Cargo.toml`(成员:`crates/openless-core`、`linux-egui`、`harmony/rust`) | Linux 宿主、Core、鸿蒙 | +| B. Tauri workspace | `openless-all/app/src-tauri/`(独立 `Cargo.lock`,根 workspace 的 `exclude` 里有它) | Windows / macOS / Android 客户端 | +| B2. backend-tests workspace | `openless-all/app/src-tauri/backend-tests/`(又一个独立 `Cargo.lock`) | 远程输入 TLS 身份的集成测试 | +| C. npm | `openless-all/app/package.json` | React 前端 + 打包脚本 | +| D. Gradle/Kotlin | **不在仓库内**:由 `tauri android init` 生成,`scripts/copy-android-scaffolding.mjs` 只拷 Kotlin 源码并打补丁 | Android | + +--- + +## A. 根 workspace —— 已在最新 + +- `cargo update` 把 42 个包推到最新兼容版本;直接依赖全部等于 crates.io 最新稳定版 + (校验方式:逐个 `cargo info ` 对比)。 +- egui/eframe 已升到 **0.37.0**(此前钉死 0.36.2),Core 的 `sha2` 升到 0.11.1。 + +仍落后但**本仓无法再升**: + +| 包 | 当前 | 最新 | 卡在哪 | +|---|---|---|---| +| `base64` | 0.22.1 | 0.23.1 | axum 0.8.9 与 harmony 宿主依赖 0.22(本仓直接依赖已是 0.23.1) | +| `generic-array` | 0.14.7 | 0.14.9 | 被 crypto 依赖链的多条 `^0.14` 约束共同钉住 | +| `matchit` | 0.8.4 | 0.8.6 | axum 0.8.9 写死 `matchit = "=0.8.4"` | + +未取的预发布版(不建议):`libc 1.0.0-alpha.5`、`rustls 0.24.0-dev.2`。 + +## B. Tauri workspace —— 补丁级已升,主版本被 tauri 2.11 挡着 + +本轮已升(同一 train 的最新补丁):`tauri 2.11.6`、`tauri-build 2.6.3`、 +`tauri-runtime 2.11.3`、`tauri-runtime-wry 2.11.4`、`tauri-macros 2.6.3`、 +`tauri-utils 2.9.3`;并 `cargo update` 刷新该独立锁文件。 + +**被 tauri 版本钉住、升不动**(尝试过,cargo 解析直接失败): + +| 目标 | 需要 | 冲突 | +|---|---|---| +| tauri 2.12.4 / tauri-build 2.7.3 / tauri-macros 2.7.3 / tauri-codegen 2.7.3 / tauri-utils 2.10.2 / tauri-runtime(-wry) 2.12.2 | `tauri-utils ^2.10` | `Cargo.toml` 里整条 train 用 `=` 钉死,注释记录了 2.12.0 时代的编译失败(Monitor / UnexpectedMenuKind) | +| tauri-plugin-fs 2.7.1 / dialog 2.8.3 / shell 2.5.0 / updater 2.14.0 / single-instance 2.5.4 / autostart 2.7.0 | `tauri-utils ^2.10` | 同上:`failed to select a version for tauri-utils ... required by tauri-plugin-fs v2.7.1` | + +要越过这道墙,必须整体迁到 tauri 2.12 训练线并重测 Windows/macOS/Android 三端编译 +(本地无法验证:`src-tauri` 已不支持 Linux 目标,`cargo check` 在 Linux 上因 +`keyring` 等 target 依赖直接失败)。 + +**与 tauri 无关、可独立升级的主版本**(需要改代码 + 三端 CI 验证,本轮未动): + +axum 0.7→0.8、base64 0.22→0.23、bzip2 0.4→0.6、cpal 0.15→0.18、enigo 0.3→0.6、 +getrandom 0.3→0.4、global-hotkey 0.6→0.9、hmac 0.12→0.13、jni 0.21→0.22、 +keyring 3.6.3→4.2.0、md-5 0.10→0.11、minisign-verify 0.2→0.3、objc2 0.5→0.6 +(连带 `objc2-foundation`/`objc2-app-kit` 0.2→0.3、`block2` 0.5→0.6、 +`core-graphics` 0.24→0.25)、rcgen 0.13→0.14、reqwest 0.12→0.13、sha1 0.10→0.11、 +sha2 0.10→0.11、thiserror 1→2、tokio-tungstenite 0.24→0.30、windows 0.58→0.62 +(连带 `windows-core` 0.58→0.100)、winreg 0.52→0.56、window-vibrancy 0.7→0.8、 +zip 2→9、x509-parser 0.16→0.18、toml 0.8.2→0.8.23(传递依赖,锁文件层面)。 + +**刻意钉死、不建议动**: + +- `sherpa-onnx = 1.13.2`:Android arm64 用官方预编译库,版本必须与 + `scripts/sherpa-onnx-android.mjs` 校验的产物一致。 +- `security-framework = 3.7.0`、`tao 0.35`(由 tauri-runtime-wry 约束)、 + `foundry-local-sdk`(winml 特性与 Windows 运行时绑定)。 +- `tauri-nspanel`:来自 git 分支 `v2`,没有版本化发布。 + +## B2. 用 `=` 钉住的 15 处(钉住 ≠ 落后) + +`Cargo.lock` 本来就把整个图锁定;这里的「钉」指 `Cargo.toml` 里的 `=x.y.z` 写法, +它会让 `cargo update` 连补丁版本都不再前进。分三类看: + +**① 钉住且正好是最新(钉与不钉今天没区别,钉着防未来破坏性小版本)** + +| 包 | 钉住 | 最新 | +|---|---|---| +| egui / eframe | 0.37.0 | 0.37.0 | +| resvg | 0.48.1 | 0.48.1 | +| tauri | 2.11.6 | 2.11.6(2.x 稳定线) | +| tauri-build / tauri-macros | 2.6.3 | 2.6.3 | +| tauri-runtime | 2.11.3 | 2.11.3 | +| tauri-runtime-wry | 2.11.4 | 2.11.4 | +| tauri-utils | 2.9.3 | 2.9.3 | +| security-framework | 3.7.0 | 3.7.0 | + +**② 钉住且确实落后(有升级空间,但都受外部条件约束)** + +| 包 | 钉住 | 最新 | 约束 | +|---|---|---|---| +| tauri-plugin-updater | 2.10.1 | 2.14.0 | 需要 `tauri-utils ^2.10`,即 tauri 2.12 训练线 | +| sherpa-onnx | 1.13.8 | 1.13.8 | 已升级;Android 归档的 size + SHA-256 在 `scripts/sherpa-onnx-android.mjs` 里同步(46_093_321 / `2ff63469…4f44`) | +| foundry-local-sdk | 1.2.1 | 2.1.0 | Windows winml 绑定,主版本跨越大 | + +**③ 被上游依赖钉死,本仓改不了** —— 见上面 A / B 两节的表 +(`base64 0.22.1`、`generic-array 0.14.7`、`matchit 0.8.4`、tauri 2.12 全线)。 + +## C. npm —— 次版本已升,剩 11 个主版本 + +`npm update` 升了 13 个(`@base-ui/react`、`dompurify`、`i18next`、`lucide-react`、 +`react-i18next`、`shadcn`、`tailwind-merge`、`tailwindcss`/`@tailwindcss/vite`、 +`@tauri-apps/api`、`@tauri-apps/cli`、`@tauri-apps/plugin-*` 等)。 + +另外 4 个原本写成精确版本的开发工具也一起升了: +`@playwright/test 1.63.0 → 1.64.1`、`prettier 3.9.6 → 3.9.10`、 +`prettier-plugin-sh 0.19.0 → 0.20.2`、`tsx 4.23.1 → 4.23.15`。 + +`npm run build`(含 `tsc`)与 `node scripts/frontend-test-runner.mjs` 均通过。 + +仍为主版本的: + +| 包 | 当前 | 最新 | +|---|---|---| +| react / react-dom | 18.3.1 | 19.3.1 | +| @types/react / @types/react-dom | 18.x | 19.3.0 | +| vite | 6.4.4 | 8.3.4 | +| @vitejs/plugin-react | 4.7.0 | 6.1.2 | +| typescript | 5.9.3 | 7.0.2 | +| framer-motion | 12.43.0 | 14.4.0 | +| marked | 11.2.0 | 18.1.1 | +| js-yaml | 4.3.0 | 5.5.0 | +| @shadcn/react | 0.2.1 | 0.3.1 | + +React 19 / Vite 8 / TS 7 是一次前端大迁移,和 Linux egui 宿主无关,建议单独一轮做。 + +## D. Gradle/Kotlin + +仓库里没有 `build.gradle.kts`。Android 工程由 `tauri android init` 生成, +`scripts/copy-android-scaffolding.mjs` 只负责拷 Kotlin 源码、改生成出来的 +`build.gradle.kts`。因此 Gradle 依赖版本跟着 Tauri 的 Android 模板走 —— +只有升级 tauri 2.12(见 B)才会变化,本仓不能单独升。 diff --git a/docs/desktop-beta5-integration.md b/docs/desktop-beta5-integration.md deleted file mode 100644 index 120a545bb..000000000 --- a/docs/desktop-beta5-integration.md +++ /dev/null @@ -1,121 +0,0 @@ -# 桌面 beta.5 整合与本地验收 - -> 本文记录最初整合与当时安装包。后续用户反馈发现的首录音阻塞、交互问题及本轮工作树修复见 [回归修复记录](beta5-regression-repair.md);下列旧安装包与哈希不包含这些后续修复。 - -- 版本:`2.0.0-beta-a.beta.5+build.20261015-bd`(按请求保留完整大小写与后缀)。 -- 合并分支:`integration/desktop-beta-a.beta.5-20261015-bd`;PR 目标:`beta`。 -- 基线:`origin/beta` 的 `0454dc56`。 -- 初次产品代码检查点:`5637abee8c92d1dbff6afecfdd0877771bcb8223`;后续 CI 修复限定于桌面模块编译边界、测试布局/时序及跨平台截图,没有修改三模型推理实现。 -- 最终产品代码与安装包检查点:`edff8913c48afa79609cbb29eacc47a00620e89d`;本节下方校验值来自该检查点重新构建的 Release 包。 -- 验收环境:2026-10-15,Apple Silicon / macOS 27.0.1。 - -## 合并来源与边界 - -| 来源 | 整合内容 | -| --- | --- | -| 当前三模型工作区 | macOS 旧引擎下架、定向清理、延迟加载、TextStream / SenseVoice / Embedding Gemma 独立启停与真实推理 | -| `appergb/合并两个分支` 的未提交桌面快照 | 首帧与启动视频移除、按需加载、扁平设置、字体与提示组件、快捷键命令列表、渠道弹簧重排、卡片删除与 WebGL 生命周期 | -| `origin/fix/macos-input-switch-crash`,PR #1142 | 输入源相关 NSEvent 转换转交主线程,避免切换输入法时在事件监听线程调用 AppKit | -| `origin/fix/linux-egui-ui`,PR #1136 | Linux 设置、QA 面板、窗口代次与迟到响应保护 | -| `origin/feature/testing-features-gate`,PR #1141 | Less Computer / Claude Console 的设备本地 Beta opt-in,撤回后的入口、热键与窗口可见性限制 | - -桌面 UI 未提交快照通过独立临时 index 生成提交,再三方合并,不直接覆盖已完成的三模型实现。旧的两模型实验实现没有被重新引入。原始 UI 工作区和原始实验模型工作区的 tracked diff SHA-256 在整合前后相同。 - -没有合入第三方尚未审核的 PR、未提交 iOS 目录或 Android 专属功能。相对当前 `origin/beta` 无 Android / iOS 源码改动;Android workflow 仅移除随旧 macOS 模型依赖退役而失效的处理步骤。根目录原有的未跟踪 `package.json`、`pnpm-lock.yaml` 未提交。 - -### 冲突处理保留的语义 - -- Mac 的旧模型入口仍禁用,提示为「该功能已下架,请去实验性与扩展里面」;新三模型入口仍在“实验与扩展”首项。 -- 三模型各自 opt-in;通用 Beta 开关只控制其原本负责的 Less Computer / Claude Console,不成为下载或加载模型的隐式授权。 -- 已访问的高级页面保留草稿;未访问页面不挂载;撤回 Beta 授权后相关测试页面卸载。 -- 新 UI 的紧凑模型行保留三模型、独立开关、内存/规格提示、实时 SSE 地址、进度、错误、重试、删除及渠道选择引导。说明通过支持鼠标、键盘、触屏的共享提示展示。 -- 退休入口与整行提示使用非按钮外层触发器,避免 Base UI 提示按钮包住已有按钮造成无效嵌套。 -- Windows Less Computer 的创建仍离开同步 IPC 线程;显示与 opt-in 复查在 UI 线程串行,结果回传调用方,不把“任务已排队”当作“窗口已成功打开”。 - -## 本地测试结果 - -| 检查 | 最终结果 | -| --- | --- | -| Core 完整测试 | 1,399 通过(1,202 单元 + 197 集成),0 失败,1 个需要固定 Codex CLI 的无关测试忽略 | -| Tauri 原生默认测试 | 533 通过,0 失败,4 个按需模型/安装用例默认忽略;真实模型另行验收 | -| 独立后端测试 | 40 通过 | -| `npm test` | 生产前端构建及 121 个前端/契约测试文件通过 | -| Chromium / WebKit 全量 UI | 84 通过,无重试;包含浅/深色截图、Beta opt-in 撤回、三模型提示、重排、取消、删除与撤销、窄屏和缩放 | -| Python worker 测试 | 7 通过 | -| 指定版本 debug `.app` 真实模型验收 | 通过,退出码 0,不带 `--download` | -| Release `.app` / DMG | 构建及签名、资源、版本、DMG 校验通过 | -| 变更组件 Prettier / `git diff --check` | 通过 | - -Ubuntu CI 执行完整交互、几何、生命周期和像素比较,不跳过视觉用例。针对系统字体栅格化差异,从首轮 CI 的实际产物逐张检查并增加了八张 Linux / 浏览器独立基线,不复用 macOS 像素,也不提高容差。macOS / Windows 模型入口测试显式声明产品 OS,不依赖 CI 物理平台。 - -合并后的初次 UI 回归暴露了旧版断言与新边界的冲突,已修复并重跑完整套件:退休模型入口不再作为可点击选项;导航按钮的统计包含合法外层提示;并行模型下载的测试等待三个完成项再确认;拖动用例先滚动到目标并验证真实位移再松手。仅更新了经截图检查确认的四张快捷键基线,变化为 Beta 未授权时隐藏 Less Computer 后的计数/行位移;没有提高截图容差,字体截图保持原样,最后一轮不带更新快照参数通过。 - -### 真实模型与生命周期 - -使用既有隔离模型缓存与 16 kHz 单声道固定英文 WAV,没有补下载、麦克风调用或当前安装应用凭据库访问。三模型均真实输出了包含 `hello`、`test`、`system` 的识别文本,并验证: - -1. 冷构造、设置/状态查询不初始化推理。 -2. 安装/启用后无已加载模型;已启用且已选中的重启恢复也不加载推理。 -3. 实际会话加载并识别;TextStream SSE 最终文本与原生返回一致,终态无未提交草稿。 -4. 公共渠道停用释放推理;取消会话不提交草稿。 -5. 关闭实验保留模型文件,停用后重启不能获得推理租约。 -6. 临时旧模型升级清理定向且幂等,实验包与无关文件保留。 -7. 结束后无残留 TextStream worker 或 TCP 7890 监听。 - -主要日志与机器可读结果:`/tmp/openless-desktop-beta5-20261015-bd/` 下的 `core.log`、`final-native-tests.log`、`final-frontend.log`、`backend.log`、`final-e2e.log`、`native-acceptance.log`、`final-release-package.log`、`final-artifacts.log`、`verified.json`。该目录可能被系统清理,本文保留主要结果。 - -## 首轮 CI 发现与修复 - -首轮 macOS checks 与 Rust 1.88 MSRV 已通过。Windows 的版本契约在 CRLF checkout 上未匹配 Cargo.lock,已统一文本换行;Android 发现 Foundry 桌面命令模块被错误编译进移动端,已像其余本地 ASR 模块一样以 `not(mobile)` 限定声明和导出,没有添加移动端空壳。Linux Core / egui 测试已通过,但运行时边界脚本未识别通过 `#[path]` 命名的独立测试模块;现改为标准 `experimental_asr/tests.rs` 测试布局,保留 TaskSpawner 门禁原有强度。 - -UI 的两个 Linux WebKit 时序问题改为测量确定的动画时间轴及浏览器真实滚动停稳,而非假设 CI 必须在固定毫秒内提供足够帧数。文档提交后的复核又发现旧探针可能漏掉短动画,且会暂停、回拨应用自己的动画;现于动画创建时捕获,先验证其自然结束和图层释放,再用独立副本测量实际 DOM 中间状态,不再干预业务动画的时钟/finished 生命周期。该探针在本机双浏览器连续 20 次通过,业务代码与安装包未改。八张 Linux 基线来自 [首轮 CI](https://github.com/Open-Less/openless/actions/runs/38675183147) 的实际渲染,已检查字体、表单、计数、布局和深浅主题。 - -最终产品代码 `edff8913` 的 [完整 CI](https://github.com/Open-Less/openless/actions/runs/38677199261) 已全部通过:Windows checks、macOS checks、macOS Rust 1.88 MSRV、Linux Core/egui 与 deb/rpm 检查、Android cargo check、Chromium/WebKit UI,以及改动范围检查。它们是 GitHub runner 的编译/自动测试证据,不等同于真实设备、安装升级或公开公证分发验收。 - -## 本地安装包 - -相对仓库根目录: - -- 应用:`openless-all/app/src-tauri/target/release/bundle/macos/OpenLess.app` -- 安装镜像:`openless-all/app/src-tauri/target/release/bundle/dmg/OpenLess_2.0.0-beta-a.beta.5+build.20261015-bd_aarch64.dmg` - -构建命令在 `openless-all/app` 执行,显式使用 `INSTALL=0`,未覆盖 `/Applications/OpenLess.app` 或重置现有权限: - -```sh -APPLE_SIGNING_IDENTITY=- INSTALL=0 CARGO_BUILD_JOBS=2 ./scripts/build-mac.sh -``` - -检查结果: - -- Release Info.plist 的 `CFBundleShortVersionString`、`CFBundleVersion` 均精确等于指定版本;npm、Tauri、Rust 与两个 lockfile 由版本契约共同核对。 -- Info.plist / Mach-O 最低系统版本均为 macOS 14.0;Gemma 自身仍要求 macOS 15+。 -- `codesign --verify --deep --strict` 通过,含麦克风 entitlement / 说明,不包含旧系统语音识别权限声明。 -- 主程序不链接旧 MLX、Whisper、Speech.framework;bundle 无旧 Metal 资源或模型 `.runtime`。 -- 旧 worker 参数仅返回下架信息。 -- DMG 的 CRC、Retina 背景、图标、窗口布局和 Applications 链接验证通过。 - -Release 主程序:74,479,344 字节,SHA-256: - -```text -d73474fbc663d072b9810d58a5e7a4f1d760a3465ce9d048f696ad28564f1a5b -``` - -DMG:28,887,027 字节,SHA-256: - -```text -765bf5bb13b3e5e2e408d4f564b660f14764474e8d73cbf7abcc13b70adbb762 -``` - -debug 验收主程序 SHA-256: - -```text -2014860f896169c6a402e52b924b41a3d9f49e9b2f571617ba6ebeb469674930 -``` - -## 未宣称通过的部分 - -- 本地包为 **ad-hoc 签名,未做 Apple 公证**,不等同于已获 Gatekeeper 认可的公开发行包。 -- 真实推理来自指定版本 debug `.app` 的隔离验收入口;Release 完成构建和产物验证,但没有用真实用户配置启动正式 UI 或进行真实麦克风/其他应用输入验收。 -- 本机没有 Windows 环境;Windows 编译、后端自动测试和 MSRV 已由 CI 通过,仍未进行真实 Windows 设备上的麦克风、SDK 推理或目标应用输入验收。 -- Linux host 依赖仅在 Linux target 中声明,直接在 macOS 上运行其测试会因 CPAL / Wayland 等缺失而失败;Docker daemon 未运行。Linux 原生构建、Core/egui 自动测试和 deb/rpm 检查已由 CI 通过,不记为本机或 Linux 桌面实机验收。 -- 已有 FFI 声明、弃用和 window-vibrancy 链接警告仍存在,不把消除这些无关警告当成本次发布的完成条件。 diff --git a/docs/experimental-local-asr.md b/docs/experimental-local-asr.md index 909155ca6..01c6d7506 100644 --- a/docs/experimental-local-asr.md +++ b/docs/experimental-local-asr.md @@ -77,4 +77,4 @@ src-tauri/target/debug/bundle/macos/OpenLess.app/Contents/MacOS/openless \ 浏览器回归使用 IPC mock,只证明界面行为,不能替代真实原生推理或签名发布验收。debug bundle 也不能替代 notarization 或真实 Windows 设备测试。 -本次已完成的真实模型、进程生命周期、Release 打包与验证边界见 [验收记录](local-asr-verification.md)。 +真实模型、进程生命周期与 Release 打包的验收记录只保留在 git 历史里;当前边界见 [本地 ASR 生命周期](local-asr-lifecycle.md)。 diff --git a/docs/index.md b/docs/index.md index f90aadef7..35dc3a68d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # OpenLess 文档入口 -状态:canonical;更新:2026-09-29。实现说明与当前源码保持一致;范围、接口合同和验收要求由各自文档维护。各专项文档的更新时间与状态单独标注。 +状态:canonical;更新:2026-10-19。实现说明与当前源码保持一致;范围、接口合同和验收要求由各自文档维护。各专项文档的更新时间与状态单独标注。 ## 范围与架构 @@ -15,6 +15,8 @@ - [交接总览](linux-egui-handoff/README.md):阅读顺序、责任与复用起点。 - [01 Core 合同](linux-egui-handoff/01-core-contract.md)、[02 缺口登记](linux-egui-handoff/02-gap-register.md)(L01–L12)、[03 热键与窗口](linux-egui-handoff/03-hotkeys-and-windows.md)、[04 页面与领域](linux-egui-handoff/04-ui-domains.md)、[05 原生宿主与数据](linux-egui-handoff/05-native-host-and-data.md)、[06 事件与会话](linux-egui-handoff/06-events-and-sessions.md)、[07 验收](linux-egui-handoff/07-acceptance.md)。 +- [Linux 已知缺口](linux-egui-known-gaps.md):当前仍未闭环的行为与复现路径(如 G01 纠错卡)。 + ## 接口契约 - [加密云同步客户端](encrypted-cloud-sync.md):Beta 3 新协议、受控数据范围、系统凭据库、恢复与验证边界。 @@ -33,11 +35,20 @@ - [讯飞(iflytek)ASR 配置](xfyun-asr.md) - [百炼(DashScope)ASR 模型](bailian-asr-models.md) - [Tauri CSP 边界](tauri-csp.md) +- [Gemini 专用转写](gemini-transcribe.md):模型、请求形态与失败边界。 +- [腾讯混元实时 ASR 长语音](tencent-long-audio.md):分段策略与结束判定。 +- [手机输入法后端整理与引擎选型](mobile-ime-engine.md):Android / 鸿蒙共用后端与引擎边界。 +- [远程输入:手机一次性证书配置](remote-input-certificates.md) +- [远程输入:息屏与录音恢复](remote-input-recording-recovery.md) +- [Homebrew 同步与本机提醒](homebrew-openless-updates.md) - [macOS 实验性本地语音模型](experimental-local-asr.md):三模型、旧版下架、升级清理和隔离验收。 - [本地 ASR 生命周期](local-asr-lifecycle.md):下载、选择和推理状态的边界。 -- [本地 ASR 验收记录](local-asr-verification.md):整合前的真实三模型与生命周期验证。 -- [桌面 beta.5 整合验收](desktop-beta5-integration.md):合并来源、指定版本、本地测试及最终安装包。 -- [旧 qwen-asr 子模块说明(已下架)](qwen-asr-submodule-upgrade-checklist.md) + +## 依赖与发布记录 + +- [依赖审计(全部工程)](dependency-audit.md):四个 workspace、npm、Gradle 的版本面与升级约束。 +- [2.0.2-Beta.1 发布说明](releases/2.0.2-Beta.1-release-notes.md) +- [2.0.1 发布说明](releases/2.0.1-release-notes.md) ## 验证入口(在 `openless-all/app` 执行) @@ -46,11 +57,3 @@ - `cargo fmt --all --check`:根 workspace 的 openless-core、linux-egui;Tauri 单独执行 `cargo fmt --manifest-path src-tauri/Cargo.toml --check`。 - `cargo test -p openless-core --locked`、`cargo test -p openless-linux-egui --locked`。 - `src-tauri` 及 `backend-tests` 被 workspace exclude,按平台独立构建。源码构建 Tauri 前初始化子模块:`git submodule update --init --recursive`;Core/Linux 独立检查不依赖 Tauri 子模块。 - -## 本地集成记录 - -- [2026-09-25 Beta 3 集成](local-builds/2026-09-25-beta3.md):最新 PR 整合、审查修复、CI 加速与平台范围。 - -- [2026-09-23 2.0 PR 审查与构建](local-builds/2026-09-23-2.0.md):PR 取舍、客户端修复及平台验证范围。 - -- [2026-09-23 模型与云同步界面检查](local-builds/2026-09-23-models-and-sync.md):运行时平台隔离、Qwen 中英文真实推理和新云同步文档边界。 diff --git a/docs/linux-egui-dependency-upgrade.md b/docs/linux-egui-dependency-upgrade.md deleted file mode 100644 index 1e55c3593..000000000 --- a/docs/linux-egui-dependency-upgrade.md +++ /dev/null @@ -1,32 +0,0 @@ -# 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 554cd9825..8c4e433a1 100644 --- a/docs/linux-egui-handoff/02-gap-register.md +++ b/docs/linux-egui-handoff/02-gap-register.md @@ -32,6 +32,7 @@ - `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 主机验收。 +- 词汇纠正卡片在 Linux 上**不产生数据源**(宿主未接 `EditObservationAdapter`),原因与补法见 [`linux-egui-known-gaps.md`](../linux-egui-known-gaps.md) 的 G01。 +- 依赖升级与 Vulkan 后端切换已落入源码;版本面与升级约束见 [`dependency-audit.md`](../dependency-audit.md)。设备图形路径仍需在真实 GPU/Wayland/X11 主机验收。 记录格式:`ID / commit / 实现效果 / 自动证据 / 设备证据 / 剩余限制`。 diff --git a/docs/linux-egui-handoff/07-acceptance.md b/docs/linux-egui-handoff/07-acceptance.md index 205bdc97c..e57363d75 100644 --- a/docs/linux-egui-handoff/07-acceptance.md +++ b/docs/linux-egui-handoff/07-acceptance.md @@ -1,6 +1,6 @@ # 07:验收与证据 -状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-08。 +状态:canonical(2026-09-07 以源码为准重写);更新:2026-09-27。 ## 1. 两个不同的完成门 @@ -28,3 +28,50 @@ ## 4. 记录格式 `ID / owner / commit / 已完成效果 / 自动证据 / 设备证据 / 剩余限制`。“待实测”不是“未实现”,也不是“已完成”;验收报告按证据分级表述(见[范围](../2.0-requirements.md)第 4 节)。 + +## 5. 本轮(Beta.3-103)设备验收清单 + +本轮改动集中在启动/运行时/保存三条链(`fcitx5.rs`、`ui/bridge.rs`、`linux_app/{window,settings_save,history}.rs`、`preference_patch.rs`),以及上一轮的 Tauri 视觉对齐项。逐项结果按第 4 节格式记录,未实测的不要写成已验证。 + +### 5.1 fcitx5 成为启动硬依赖(`fcitx5.rs::prepare_fcitx5`) + +就绪的判据是**插件接口能应答**,不是 fcitx5 进程被替换。桌面会话(KWin 拉起 `fcitx5-wayland-launcher --reopen`)会让 fcitx5 报告 `CanRestart=false`:此时 `Restart` 调用会“成功”但进程不替换,103 包因此每次启动都卡满 15 秒并报错。104 起该状态只记警告并继续使用正在运行的副本,安装的副本等 fcitx5 下次启动时生效。 + +| 场景 | 期望 | +| --- | --- | +| 插件已装、fcitx5 运行 | 启动不阻塞(15 秒就绪预算内),热键可用 | +| 插件缺失/未启用 | 弹出错误窗口(`startup.fcitx_help` 文案);**关闭后进程以非零状态退出并释放单实例锁**,可立即重新启动 | +| 启动后才 `fcitx5 -r` 重启 | 插件重载完成后热键仍可用(验证就绪等待是否覆盖慢重启) | +| 刚升级过插件、且 fcitx5 由会话/合成器托管(`CanRestart=false`) | 启动**不得**因重启失败而卡满 15 秒或弹错;日志写明保留正在运行的副本,热键仍可用(旧 103 包在此状态下每次启动都失败,见下) | +| 无会话 D-Bus(ssh/`systemd-run` 启动) | 明确失败并给出修复指引,不得静默降级为全局热键 | + +### 5.2 UI 子进程协议 v2(`ui/bridge.rs`) + +| 场景 | 期望 | +| --- | --- | +| 关闭 UI 主窗后从托盘/启动器重开 | 宿主与后台能力(热键、录音、托盘)持续存活;重开后重新握手并收到全量快照,历史/设置不丢 | +| UI 进程被 `kill -9` | 宿主不崩;下次开窗握手正常 | +| 状态高频变化(历史、录音电平)时 UI 较慢 | 显示的是最新状态,不出现回退到旧快照 | +| 误接协议不匹配的 UI 二进制 | 回 `Rejected` 并断开,而不是渲染半懂数据 | + +### 5.3 设置保存改为字段补丁(`linux_app/settings_save.rs`、`preference_patch.rs`) + +| 场景 | 期望 | +| --- | --- | +| 快速连改多个开关/滑条 | 不出现“改 A 丢 B”;最终值与最后一次操作一致 | +| 制造 revision 冲突(另一端同时改设置) | 冲突被重试(≤3 次)后正确落盘;仍失败时保留草稿并显式提供重试,不打开设置页 | + +### 5.4 历史缓存与删除身份(`linux_app/history.rs`) + +| 场景 | 期望 | +| --- | --- | +| 删除历史条目 | 删除的是点中的那条(不再因刷新错位);列表与概览计数同步 | +| 历史条目很多时打开概览/设置 | 首屏不卡顿(读取与 WAV 探测在后台任务,两条页面共享缓存) | + +### 5.5 回归:上一轮 Tauri 视觉对齐项 + +OAuth 弹窗(加宽、标题字号、居中状态行、整行验证码框、关闭按钮悬停不缩放)、风格页卡片(图标选择器贴右、铅笔/重置徽标、名称/描述/标签行)、市场“我的发布”上传/更新/下架流程。 + +### 5.6 安装与升级 + +`dpkg -i` 用 103 覆盖 102(apt 视为升级,不回退);rpm 同理;`apt remove` 后无残留(fcitx5 postinst/postrm 行为);核对 `openless --version` 自报 `OpenLess 2.0.0-Beta.3-103`。 diff --git a/docs/linux-egui-known-gaps.md b/docs/linux-egui-known-gaps.md new file mode 100644 index 000000000..2d5d3b8d2 --- /dev/null +++ b/docs/linux-egui-known-gaps.md @@ -0,0 +1,48 @@ +# Linux(egui 宿主)已知缺口登记 + +这份文件记录**已被确认、但当前不打算实现**的 Linux 侧缺口。每条都写明:现象、 +根因(带代码位置)、为什么现在不补、将来若要补需要什么。 + +补齐后把条目移到对应的验收文档(`docs/linux-egui-handoff/`)。 + +--- + +## G01 · 词汇纠正卡片在 Linux 上永不出现 + +**现象**:听写结束后手工改掉插入的文字,macOS/Windows 会弹出「要不要记住这个纠正」 +卡片,Linux 不出来。设置里的词汇表仍然可以手动加词。 + +**根因**:卡片的数据源是 Core 的 `pending_corrections()`,只有宿主把它接上 +「原生文档变更观察器」才会被填充: + +``` +手动改文本 → 宿主 EditObservationAdapter::arm() → Core 判定属于刚插入的文本 + → CoreEditObservationSink::publish() → queue_pending_correction() + → 宿主轮询 pending_corrections() → 胶囊窗画卡片 +``` + +- `linux-egui/src/backend.rs` 构造 `BackendDependencies` 时**没有设置 + `edit_observation`**,于是 Core 回退到 `NoopEditObservationAdapter` + (`crates/openless-core/src/ports.rs`,`arm()` 直接 `Ok(())`)。 +- 因此 `pending_corrections()` 恒为空,`main.rs::refresh_vocab_suggestions()` + 每次都走 `hide_vocab_card()`。 +- macOS 的实现在 `src-tauri/src/core_adapters.rs`(`TauriEditObservationAdapter`, + 基于 AX API),Windows 基于 UIA。Linux 没有等价物:X11 需要 AT-SPI 且要求目标 + 应用开启无障碍;Wayland 下没有跨应用读取焦点文本的通用接口。 + +**为什么现在不补**:卡片是「锦上添花」的学习入口,补它需要引入 AT-SPI 常驻监听、 +处理无障碍权限与隐私边界,收益不足以支撑当前范围。词汇学习仍可通过设置里的 +词汇表手动完成。 + +**将来若要补**,最小实现路径: + +1. 新增 `linux-egui/src/edit_observation.rs`,实现 `EditObservationAdapter`; +2. 订阅 AT-SPI `org.a11y.atspi.Event.Object::TextChanged`(需要 `atspi` crate, + 以及用户会话开启 `org.a11y.Status.IsEnabled`); +3. `publish()` 只上报「落在刚插入文本范围内的替换」,把接受/拒绝的决定权留给 + Core(`CoreEditObservationSink` 已经做了 generation 与范围校验); +4. 在 `docs/linux-egui-handoff/` 记录 AT-SPI 不可用时的降级行为(应静默不弹卡)。 + +**现有测试**:`main.rs` 里的卡片用例都是直接调用 `queue_pending_correction()` +构造数据(`vocab_card_message`、`capsule_rebuild_rotates_card_identity_*` 等), +验证的是「卡片出现后的行为」,不能证明 Linux 真的能产生卡片。 diff --git a/docs/linux-egui-tauri2-parity.md b/docs/linux-egui-tauri2-parity.md deleted file mode 100644 index 7ae8bd5eb..000000000 --- a/docs/linux-egui-tauri2-parity.md +++ /dev/null @@ -1,92 +0,0 @@ -# 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/docs/local-asr-verification.md b/docs/local-asr-verification.md deleted file mode 100644 index 5857e1bd7..000000000 --- a/docs/local-asr-verification.md +++ /dev/null @@ -1,102 +0,0 @@ -# macOS 本地 ASR 下架与实验模型验收 - -> 这是桌面分支整合前的历史验收记录,产物版本为 `2.0.0-Beta.4+build.20260930`。后续构建会覆盖相同的 `.app` 路径;当前 `2.0.0-beta-a.beta.5+build.20261015-bd` 的结果与校验值以 [桌面整合验收](desktop-beta5-integration.md) 为准。 - -验收时间:2026-10-15(Apple Silicon,macOS 27.0.1)。代码位于当前未提交工作区;不是已发布版本的验收声明。功能与复现入口见 [实验性本地语音模型](experimental-local-asr.md)。 - -## 结果 - -| 检查 | 结果 | -| --- | --- | -| `cargo test --locked -p openless-core` | 1,376 项通过(1,179 单元 + 197 集成),0 失败;另有 1 项依赖固定 Codex CLI 的无关测试默认忽略 | -| Tauri `cargo test --locked … --lib -- --test-threads=1` | 529 项通过,0 失败;4 个按需运行的模型/安装测试默认忽略,相关真实推理与安装另行验证 | -| 独立 `src-tauri/backend-tests` | 40 项通过,0 失败 | -| `npm test` | 生产前端构建及 117 个前端/契约测试文件通过 | -| Chromium / WebKit 设置页验收 | 共 6 项通过,覆盖旧入口禁用与指定悬浮提示、三模型入口、后台下载提示、Windows 原入口不变 | -| Python worker 协议/SSE 单元测试 | 7 项通过,无模型加载或麦克风调用 | -| SenseVoice 真实推理 | 英文、普通话固定 WAV 均通过 | -| Embedding Gemma 真实推理 | 英文固定 WAV 通过 | -| TextStream 真实推理 | 独立 Python/MLX worker、可修订预览、SSE 和最终文本验收通过 | -| 最新 debug `.app`,不补下载的隔离验收 | 通过,退出码 0 | -| Release `.app` / DMG | 构建、ad-hoc 签名验证、DMG 校验和及布局验证通过 | -| `git diff --check` / 新实验模块 Rust 格式检查 | 通过 | - -完整 Core、原生、前端测试中的初次失败已修复,以上统计来自最后一次成功结果,不把早期失败日志当作通过证据。旧引擎的空壳缓存、兼容 facade 和不可达调用路径也已删除,不再通过空实现维持旧运行接口。 - -## 真实模型、进程与文件验证 - -测试使用 `/tmp/openless-asr-acceptance` 隔离目录以及 6.925 秒、16 kHz 单声道 PCM WAV,由系统 TTS 合成后交给 OpenLess 的真实原生适配器。**没有调用麦克风、系统凭据库,或安装/清理当前用户的 OpenLess 应用数据。** - -- SenseVoice、TextStream 使用固定版本下载,并通过大小和 SHA-256 校验。下载中断后从断点恢复,没有重下已完成的模型。 -- Gemma 测试包从已有文件只读复制到隔离目录,全部 26 项文件通过固定清单校验;源应用数据未修改。 -- 实验开启、文件安装和渠道注册结束后,原生模型句柄仍为空,TextStream worker 未启动。 -- 已开启且已选中模型的重启恢复、反复状态查询不会加载模型。 -- 实际录音会话才加载模型;三模型识别结果均包含 `hello`、`test`、`system`。 -- TextStream 的 SSE 终态为 `type: stream`、`done: true`,`draft` 为空,`finalized` 与 OpenLess 返回的最终文本一致;录音中确实产生替换型预览,不靠最后一个草稿充当最终结果。 -- 通过真实 Core 渠道开关验证释放,不只调用测试用的释放函数;实验开关和模型文件不因停用渠道被删除。 -- 再次启动会话后取消,完成接口返回错误,未提交草稿;关闭实验开关后保留文件,重新启动仍为停用状态,无法获取推理租约。 -- 在真实 `.app` 内对临时旧模型/中断目录执行清理;重复执行仍安全,实验包和无关文件保留。符号链接、渠道退休与元数据边界另外由 Core 回归覆盖。 -- 验收结束后,没有残留 TextStream worker 或 TCP 7890 监听。 - -最新 debug bundle 验收记录:`/tmp/openless-asr-acceptance/deliverable-acceptance.log`、`deliverable-acceptance.exit`(0)。结果收据为同目录的 `passed.json`;汇总为 `verified-tests.json`。临时目录可能被系统清理,本文保留主要结果及产物校验值。 - -固定英文 WAV SHA-256: - -```text -d41257872b65e283ad4b54809e2cdda7160bd733b4cfc9a7dc13150669710843 -``` - -三模型输出(标点差异正常,不代表任意真实语音都能达到同样效果): - -```text -SenseVoice: Hello, this is a test of the speech recognition system; the local model should recognize this sentence. -Gemma: Hello, this is a test of the speech recognition system. The local model should recognize this sentence. -TextStream: Hello. This is a test of the speech recognition system. The local model should recognize this sentence. -``` - -最新 debug bundle 主程序 SHA-256: - -```text -4a6f0acf28e7e90cf1fe1037b050fe63daf252975e50d19b82c59e2ac8183855 -``` - -debug 模式的整包哈希校验开销显著,以上测试不作为发布版首字延迟/实时率基准。它证明真实模型与生命周期可用,不承诺所有语言、长录音、设备或口音的识别质量。 - -## 可交付产物 - -从仓库根目录查看: - -- Release 应用:`openless-all/app/src-tauri/target/release/bundle/macos/OpenLess.app` -- DMG:`openless-all/app/src-tauri/target/release/bundle/dmg/OpenLess_2.0.0-Beta.4+build.20260930_aarch64.dmg` -- debug 验收应用:`openless-all/app/src-tauri/target/debug/bundle/macos/OpenLess.app` - -Release 使用 `INSTALL=0` 的正式打包脚本生成,没有覆盖 `/Applications`,没有重置当前应用权限。Release `.app` 约 78 MiB,DMG 35,168,578 字节。 - -验证项目: - -- `codesign --verify --deep --strict` 通过,含麦克风 entitlement。 -- Info.plist 仍有麦克风说明,不再声明旧系统语音识别权限。 -- Info.plist 与 Mach-O `LC_BUILD_VERSION` 均要求 macOS 14.0;Gemma 自身另行限制 macOS 15+。 -- 不包含旧 `mlx.metallib`,主程序未链接旧 MLX/Whisper/Speech.framework。 -- 旧 `--openless-mlx-worker` 启动参数仅返回下架提示,不创建推理 worker。 -- DMG CRC 校验、Retina 背景、窗口/图标布局及 Applications 链接验证通过。 - -Release 主程序 SHA-256: - -```text -c4802ab6dd64842ecd1209751ab990df5a31650e6a8facf792ec16cccea412c0 -``` - -DMG SHA-256: - -```text -0a1de42b40f2cd6583321edee77821e1605ea38ea788e3d1760ab4ca2f43b43c -``` - -## 验证范围之外 - -- 本机没有 Developer ID / Apple 公证凭据。本次产物为 **ad-hoc 签名的本地测试包,不是已公证的公开分发包**;浏览器下载仍可能触发 Gatekeeper。 -- 三模型的进程级推理验收使用 debug `.app` 的隔离入口;Release 另行完成构建、签名、资源和安装镜像检查,未对已安装用户配置启动正式 UI。 -- Windows 入口由浏览器回归覆盖,相关共享业务逻辑由 Core 回归覆盖;本机没有 Windows 运行环境。尝试交叉编译时缺少 `x86_64-w64-mingw32-gcc`,因此没有把 Windows 原生构建、SDK 或真实设备行为标成通过。 -- 未进行真实麦克风、其他应用文本插入、旧版用户真实安装升级,或所有 macOS 版本/Intel 设备的验收。升级自动清理逻辑已实现并用隔离旧数据验证,但当前安装应用的数据未被清除。 -- 构建仍有现有的弃用/FFI 声明和 window-vibrancy 重复符号警告;未为这次 ASR 改动顺便重构无关窗口/热键代码。 diff --git a/docs/local-builds/2026-09-23-2.0.md b/docs/local-builds/2026-09-23-2.0.md deleted file mode 100644 index 62857e0a1..000000000 --- a/docs/local-builds/2026-09-23-2.0.md +++ /dev/null @@ -1,54 +0,0 @@ -# 2026-09-23 OpenLess 2.0 本地集成 - -基础:`origin/beta` 的 `a8ebcf6f`。交付平台为本机 macOS Apple Silicon;此记录不代表公开发布或其他平台的设备验收。 - -## PR 审查与取舍 - -| PR | 本地处理 | 重点 | -| --- | --- | --- | -| [#1096](https://github.com/Open-Less/openless/pull/1096) | 合入 | 渠道级百炼协议、冻结会话配置、宿主任务调度 | -| [#1094](https://github.com/Open-Less/openless/pull/1094) | 合入 | 全文流式快照、完成句段去重、胶囊动效、旧速记历史兼容 | -| [#1091](https://github.com/Open-Less/openless/pull/1091) | 合入 | 官方 ModelScope 映射、分块响应与 SHA-256 验证 | -| [#1087](https://github.com/Open-Less/openless/pull/1087) | 合入 | 速记、录音归档、历史重处理、取消与退出收尾 | -| [#1085](https://github.com/Open-Less/openless/pull/1085) | 合入后修正 | MiniMax 地址匹配过宽,且完整旧路径会被重复追加;已加回归并修复 | -| [#1075](https://github.com/Open-Less/openless/pull/1075) | 合入有效翻译并修正 | 原 PR 删除 235 个现用翻译 key;恢复原有翻译、去除旧 key,并修正重复实验标识 | -| [#1074](https://github.com/Open-Less/openless/pull/1074) | 合入 | 音效重建、输入完成反馈、选区动画;保留原工作区未提交内容 | -| [#1073](https://github.com/Open-Less/openless/pull/1073) | 合入后修正 | 预览尺寸与焦点交接;补接未使用的选区专用提示词,修正 AppKit 位掩码类型 | -| [#1067](https://github.com/Open-Less/openless/pull/1067) | 选择性合入 | 合入 Android 输入法、键盘及笔画输入;保留当前偏好写入机制,避免新实现把与历史快照相同的外部修改永久当成自身广播丢弃 | -| [#1065](https://github.com/Open-Less/openless/pull/1065) | 合入并适配 | 采用这一套 Linux Host/UI;保留新主线服务预设和稳定模式,修正平台条件编译与录音释放 | -| [#1055](https://github.com/Open-Less/openless/pull/1055) | 经 #1065 吸收 | 设计令牌文件逐字一致;主题和外壳按新 egui 版本接入 | -| [#1060](https://github.com/Open-Less/openless/pull/1060) | 部分经 #1065 吸收,其余暂缓 | 其余内容重写同一 Linux UI,并改动 Tauri/Linux 发布边界和子模块位置;该头提交 Windows 检查失败,不能与 #1065 直接叠加 | -| [#1064](https://github.com/Open-Less/openless/pull/1064) | 暂缓 | 内置 PI 的桌面检查仍失败;现实现为构建时强制打包,独立按需下载方案尚未完成 | -| [#1066](https://github.com/Open-Less/openless/pull/1066) | 暂缓 | 独立 Swift iOS 应用,原 PR 未进行编译与设备验证,超出本次桌面安装包范围 | - -最新 beta 已包含 #1082、#1079、#1077、#1072、#1070、#1069、#1061、#1059、#1058、#1056、#1052 和 #1009 等修复。本地整合保留其实现,不用旧 PR 内容覆盖。 - -## 本轮客户端修复 - -- [#1092](https://github.com/Open-Less/openless/issues/1092):离线 Sherpa 原生解码串行化。超时或丢弃调用者时,仍运行的工作线程继续持有模型与独占许可;等待中的调用不会启动第二个解码,取消后不再处理后续分块。 -- [#1088](https://github.com/Open-Less/openless/issues/1088):录音转追问保留已录问题和原选区,按会话 ID 完成转写,跳过听写润色与落字,再进入 QA;不产生速记草稿残留。 -- [#1078](https://github.com/Open-Less/openless/issues/1078):服务页不再轮询整个模型目录的远程信息;只查询下载详情选中的模型,按模型和镜像合并请求,隔离旧请求结果。 -- [#1095](https://github.com/Open-Less/openless/issues/1095)、[#1093](https://github.com/Open-Less/openless/issues/1093)、[#934](https://github.com/Open-Less/openless/issues/934):相应协议选择、原文快照/动效及音效恢复修复已合入。 -- 修复速记与 Android 输入法合并后的返回类型和平台隔离错误、重复 `QuickNote` 枚举、繁中缺失翻译,以及严格检查发现的测试循环问题。 -- Tauri/Linux 手机输入资源统一到 `assets/remote-input/`,现有测试和格式化入口随之更新;删除的旧资源与共享副本逐字一致。 -- [#1089](https://github.com/Open-Less/openless/issues/1089):README 的分发摘要补充必须先添加项目 tap,完整安装步骤原已包含该命令。 - -## 构建与证据 - -第一份整合版:提交 `3d8e530c`,版本 `2.0.0-Beta.1`。`.app`、DMG、ad-hoc 签名和包内 MLX metallib 一致性检查通过;整合阶段 Core 共 1033 项通过、1 项既有忽略。 - -修复版:`2.0.0-Beta.2.local.1`,构建源码提交 `6b45366b`。`.app` 与 DMG 构建成功,签名和 MLX 资源一致性检查通过;已安装到 `/Applications/OpenLess.app`,安装二进制与构建产物 SHA-256 一致。原应用、偏好及历史已备份。用户完成 macOS 钥匙串授权后,已验证新版正常显示设置与概览,版本标识为 `v2.0.0-Beta.2.local.1`,原有历史可正常读取。首次启动等待系统授权的进程采样保留在检查记录中。 - -已通过:Core 1038 项、1 项既有忽略;Core 严格 Clippy;前端生产构建及 90 个测试入口;独立后端回归 12 项;原生解码超时/调用者丢弃的线程所有权回归;百炼六种协议与保存边界浏览器测试;三种胶囊样式、长文本裁切及动画浏览器测试。ModelScope 官方 0.6B 文件清单已在线读取,未下载完整模型权重。 - -Linux 在 macOS 上的共享库及宿主合同共 111 项通过,1 项本地化约束失败:引入的原生界面仍有未纳入本地化目录的中文文案。未扩大允许列表或跳过测试;此项及 Linux 真机验收仍未完成。 - -产物、构建记录与 SHA-256 收据保存在工作区 `outputs/openless-2.0/2026-09-23/`:`merged/` 为第一次整合版,`fixed/` 为修复版,`previous/` 为升级前备份,`evidence/` 为检查记录。 - -## 仍需外部证据的事项 - -- [#1090](https://github.com/Open-Less/openless/issues/1090) 的账号 HTTP 409 涉及服务端旧身份映射。私有后端 `auth.rs` 明确拒绝仅凭同名 login 接管 `github_id IS NULL` 的旧记录;客户端包不能修复服务器数据。此次未修改生产账号映射。 -- [#1098](https://github.com/Open-Less/openless/issues/1098) 报告的是 stable 1.3.18 缺少 MLX 资源;本次两份包均须经过资源与签名检查,不代表已替换公开 stable 安装包。 -- 火山配置状态当前已按 `authMode` 和渠道的 `providerType` 判断,相关回归通过;未使用 #1071/#1016 报告者账号验证。 -- Windows IME/多屏、Linux X11/Wayland、Android 真机、Intel Mac、持续录音音效与各云服务真实密钥调用没有本次设备证据。旧 1.3.x 反馈和无法稳定复现的模型近似重复问题不据此标记关闭。 -- macOS 安装包采用本机 ad-hoc 签名,未进行 Apple 公证。 diff --git a/docs/local-builds/2026-09-23-models-and-sync.md b/docs/local-builds/2026-09-23-models-and-sync.md deleted file mode 100644 index 032a521e6..000000000 --- a/docs/local-builds/2026-09-23-models-and-sync.md +++ /dev/null @@ -1,44 +0,0 @@ -# 2026-09-23 本地模型与云同步界面检查 - -应用源码:`56a22be5`;构建版本:`2.0.0-Beta.2.local.2`;本机:macOS Apple Silicon。实时字幕、字幕组件和动效文件未修改。 - -## 模型目录与实际支持 - -本次在安装版实际查看到的 Mac 下载目录是 2 个 Qwen3-ASR 与 6 个 Whisper,未观察到 Foundry 或 sherpa-onnx 条目。Mac 的 Whisper 使用已编入应用的 whisper.cpp,Cargo 启用了 Metal 支持;不能仅因模型名称和 Windows 相同就判定为 Windows 专用,也不能把它标成仅 CPU。 - -原前端只按模型名开头判断平台,未知名称默认允许;通用模型 DTO 没有保留 Core 的 runtime。此次改为传递 runtime,结合 runtime/family 做平台过滤,对未知模型关闭默认放行;Windows 的两类独立目录也显式限制于 Windows。 - -| 模型运行时 | 当前应用支持的平台 | 页面标识 | -| --- | --- | --- | -| generic / Qwen3 | macOS、Linux | macOS 的 MLX/Metal 与 C/CPU 按实际能力显示;Linux 为 C/CPU | -| generic / Whisper | macOS | macOS · whisper.cpp | -| Foundry Local | Windows | Windows · Foundry Local | -| sherpa-onnx | Windows | Windows · sherpa-onnx | - -## 真实推理验证 - -通过应用下载了官方 Qwen3-ASR 0.6B,下载流程完成文件校验和原子安装。使用本次构建包自身的 MLX worker,读取该模型运行英语内置样本与本机合成的中文样本;没有切换用户当前 ASR 渠道,也没有把测试音频发给云端 ASR。 - -| 样本 | 音频时长 | 推理耗时 | 结果 | -| --- | --- | --- | --- | -| 英语 | 3.642 秒 | 3.313 秒 | 正常返回完整句子,hello/test/speech/system 关键内容通过 | -| 中文 | 4.225 秒 | 1.201 秒 | `这是本地语音识别测试。今天的天气很好。` | - -两次推理在同一 worker 中依次运行,中文为第二次调用;这些时间是本机样本观测,不是各模型/各硬件的延迟承诺。英语原始输出保存在结果文件中,未将关键内容检查当成逐字准确率测试。本次没有下载并实测全部 Whisper 型号或 Intel/Windows/Linux 设备。 - -`inference-result.json` 保存实际应用二进制 SHA-256;已确认它与 `/Applications/OpenLess.app` 正在使用的二进制一致。应用原生界面已读取到新的平台/运行时标签和已下载模型状态。 - -## 云同步 - -卡片改为 16px 段间距,标题和说明间距 8px,账号标签与用户名相邻,按钮保持 12px 间距。修改仅限云同步卡片,不改变全局字间距。 - -新加密云同步的需求、HTTP API、加密格式、OpenAPI 与客户端接口改动清单位于工作区 `5-cloud-sync/docs/`。它是独立的文档项目,尚无后端实现;现有手动 `/me/sync` 仍是旧协议,不能拿它明文上传 API 密钥。 - -## 检查与产物 - -- 90 个前端测试入口和生产构建通过,包含按运行时区分同名 Qwen/Whisper 的平台回归。 -- macOS `.app` 与 DMG 构建、签名、MLX metallib 包内容及一致性检查通过。 -- Qwen3-ASR 0.6B 的真实中英文 MLX 推理通过。 -- 运行中的应用版本和二进制与本次测试包一致。 - -安装包、构建记录、测试记录、中文 WAV 与推理结果保存在工作区 `outputs/openless-2.0/2026-09-23/models-sync-ui/`。 diff --git a/docs/local-builds/2026-09-25-beta3.md b/docs/local-builds/2026-09-25-beta3.md deleted file mode 100644 index 9c6608ffd..000000000 --- a/docs/local-builds/2026-09-25-beta3.md +++ /dev/null @@ -1,32 +0,0 @@ -# 2026-09-25 Beta 3 集成 - -基线为已发布且本机安装的 `2.0.0-Beta.2+build.20260924`,提交 `d9113e0b`。本次应用版本为 `2.0.0-Beta.3+build.20260925`;标签沿用更新器兼容的 `v2.0.0-Beta.3-tauri`。 - -## 新增整合 - -| PR | 整合内容 | 审查修正 | -| --- | --- | --- | -| #1107 | macOS Rust 编译、MLX 原生缓存、MSRV 并行和桌面打包 | 已完成四轮独立审查和 Apple Silicon / Intel 构建验证 | -| #1106 | Android 四 ABI 并行、缓存回写、手动单 ABI 与可选快速 profile | 排除 Linux 补丁;避免 Gradle 脚本缓存签名凭据;恢复严格 ZIP、CRC 与 ABI 校验 | -| #1102 | 纯修饰键组合听写快捷键 | 纳入 Core 仲裁窗口,防止普通组合键启动录音;录制时保留左右同类修饰键 | -| #1104 | 可选 Requesty 润色渠道 | 保留三种请求格式、八语种和描述符一致性;排除 Linux locale 文件 | -| #1110 | 远程输入停止/取消后隔离过期音频准备回调 | 保留麦克风复用,验证迟到回调和超时 | -| 本地 Omni 修复 | 百炼/DashScope 官方与专属端点的音频改用 WAV data URL | 标准 URL 解析仅匹配真实主机名;其他兼容端点保留裸 Base64,避免用户名、查询参数或片段误匹配 | - -所有新增 Linux egui 文件和 Linux 发布工作流均与 Beta 2 相同,本次不构建或发布 Linux 安装包。Linux 既有独立 CI 失败不通过修改或绕过测试处理。 - -## 已在 Beta 2 吸收的 PR - -#1096、#1094、#1091、#1087、#1085、#1074、#1073 的头提交已包含于本地 2.0 集成历史,并经 `16c3f841` 整体提交进入 Beta 2。保留 Beta 2 在其上增加的修正,不再次用旧 PR 内容覆盖。 - -#1067 只有早期 Android 输入法实现已吸收,后续输入法及生命周期大改尚未完成此次审查与 CI;#1064 的内置 PI 检查失败,#1066 的独立 iOS 也不在本次已验证交付中,继续保留。Linux 相关 #1065、#1060、#1055 不在本轮处理范围。 - -## 验证边界 - -整合树已通过前端生产构建及 93 个测试入口、Core 1041 项测试(1 项既有忽略),macOS Host 493 项及独立后端 12 项通过,并新增纯修饰键仲裁、左右键录制、APK 损坏/异常 ABI 和签名配置缓存回归。完整云端 CI、桌面/Android 构建与签名资产验收以发布记录为准。 - -本次继承 Beta 2 的平台设备验收范围;自动化测试和构建不代表新增 Windows、Intel Mac 或 Android 真机交互证据。性能比较同时记录基线、缓存状态、完整 job 与 Rust/Gradle 构建步骤耗时,见工作区 `outputs/beta3/2026-09-25/` 中的运行记录与发布验收结果。 - -### Omni 本地候选补充验证 - -2026-09-26 纳入额外的 Omni 音频格式修复,版本和构建号保持不变。补丁经过两轮独立审查;整合后的 Core 1045 项、macOS Host 493 项测试通过,Core 全目标严格 Clippy、单文件格式和差异检查通过。设置中的渠道验证仍只发送文本,实际音频链路需要用候选包完成一次真实录音验证,不能用文本验证成功代替。 diff --git a/docs/local-shortcuts-dropdown-20261016.md b/docs/local-shortcuts-dropdown-20261016.md deleted file mode 100644 index 8da022cde..000000000 --- a/docs/local-shortcuts-dropdown-20261016.md +++ /dev/null @@ -1,41 +0,0 @@ -# 本地修复:可清空快捷键与下拉位置 - -范围:修复当前桌面设置并构建 macOS 本地 App / DMG;不发布正式版、不改标签、不替换正在使用的 App,不修改用户配置或系统授权。 - -## 实现 - -- [x] 录音、翻译的 IPC 接受 `null`,清除后取消相应原生监听;其他可配置快捷键保留已有可停用能力,风格包绑定也提供共享清空入口。 -- [x] 清空在旧版必填结构中保存为显式空绑定,区别于“首次启动没有字段”;不会在重新加载配置或修改其他设置时恢复默认键。空绑定不参与冲突判断,重新录制的输入仍严格校验。 -- [x] 移除录音设置和快捷键页中的“Mac 听写键”选择入口;保留 Fn 录制与旧配置读取兼容性,不擅自改写已有用户绑定。 -- [x] 普通 Select、可搜索 Combobox 和快捷键录制弹层统一从控件下方展开;边缘空间不足时仅允许向上避让,不横向翻到另一侧。历史录音操作菜单原本就在按钮下方,保持不变。 -- [x] 百炼协议选择接入同一公共 Select,避免例外使用系统原生下拉。 - -## 下拉根因 - -两个公共下拉组件和快捷键录制弹层的 `side` 都固定为 `right`。另有可搜索长列表的首帧尺寸问题:定位器尚未提供 `--available-height` 时,整个限高表达式失效,长列表按未限高尺寸参与定位,即使下方有足够空间也可能先翻到上方。补充尺寸变量回退值,并用纵向 flex 和可收缩滚动列表限制包含搜索栏的整个菜单。 - -| 位置 | 修改前 | 修改后 | -| --- | --- | --- | -| `src/components/ui/Select.tsx` | 固定右侧 | 下方优先,禁止横向避让 | -| `src/components/ui/SelectLite.tsx` | 可搜索菜单固定右侧 | 与普通菜单一致 | -| `src/components/ShortcutRecorder.tsx` | 核心热键清空按钮禁用,额外 Mac 听写键入口,右侧录制面板 | 可清空,移除重复入口,下方录制面板 | -| `src/components/ui/Select.css` | 首帧缺少定位变量时列表不限高 | 首帧限高,搜索栏与滚动列表共同遵守可用高度 | - -## 验证 - -- [x] 完整前端测试:124 个测试文件通过。 -- [x] 正常原生依赖 `cargo test --lib --bins`:580 通过,4 项需外部模型文件的测试明确忽略,无失败。 -- [x] 新增 Core 回归:4/4,覆盖清空后序列化重载、修改其他设置不恢复、首次启动默认值、严格录制校验与空绑定不冲突。 -- [x] Chromium / WebKit:受影响的 24 个用例均已取得通过结果,覆盖所有可配置快捷键清空、离开设置再进入、重新绑定、录制弹层定位,以及 90% / 100% / 110% 下普通与搜索菜单的下方定位、长列表滚动稳定性。完整一轮为 23 通过 / 1 失败;失败发生在并行构建重写 `dist` 时页面变空,单独重跑 Chromium 该用例通过,未放宽断言。 -- [x] 正常 macOS Release App / DMG 构建完成;ARM64、最低 macOS 14.0、版本 `2.0.0+build.20261016`。App 的 ad-hoc 签名、Speech framework 链接、Speech / 麦克风描述均已验证;DMG 完整性和包内签名有效,包内可执行文件与构建 App 完全一致。临时只读挂载已卸载并清理。 -- [x] `/Applications/OpenLess.app` 可执行文件仍为原有 SHA-256 `7f0918b3012d273fb1ec61b3a041089c85acf9fcb04f73e5ce933bbf6076bba4`,未安装替换、未重置权限。 - -## 本地产物 - -- 便于安装的 DMG:`/Users/trip/Downloads/OpenLess-local-shortcuts-20261016-arm64.dmg`(28,977,094 字节)。 -- DMG SHA-256:`a116089c7e4634210cb538cb9b08aa43a4e364d2203d9290dc7dde5825cd54fe`。 -- 构建 App:`openless-all/app/src-tauri/target/release/bundle/macos/OpenLess.app`。 -- 新可执行文件 SHA-256:`e6e009cb557e36d691803d1915efad5e1a7995fd5aedd2ca41e53c0480f5fad7`。 -- 构建清单:`/Users/trip/Library/Caches/pi-openless-release-20261016/local-shortcuts-build.json`;明确记录这是未提交工作树的本地构建,不代表正式发布。 - -详细构建日志和验证结果保存在 `/Users/trip/Library/Caches/pi-openless-release-20261016/shortcut-*.log`。未执行新 App 的安装后设备验收;现有应用、设置和授权保持不变。原有根目录未跟踪的 `package.json`、`pnpm-lock.yaml` 未修改。 diff --git a/docs/macos-build-performance.md b/docs/macos-build-performance.md index e9578cbf9..da0fe10af 100644 --- a/docs/macos-build-performance.md +++ b/docs/macos-build-performance.md @@ -31,7 +31,7 @@ ARM/Intel 的整个打包步骤分别约 15 分 17 秒、15 分 08 秒。主要 macOS 检查统一关闭 dev/test debug info,分别缓存 Core、Host、backend-tests 的实际 target 目录。stable、MSRV、release 缓存分开,避免不同工具链和 profile 的产物互相挤占。 -MLX 的 CMake 输出另用 [cache-macos-mlx](../.github/actions/cache-macos-mlx/action.yml) 保存。实际使用的 `rust-cache` [源码](https://github.com/Swatinem/rust-cache/blob/6323deb102c322ba6fcbdcafc7e3dddab59af2b6/src/workspace.ts) 排除 workspace 目录内的 path 依赖,导致 `src-tauri/vendor/qwen3-asr-rs` 的原生输出在 post 阶段被清理;首轮验证中,命中 Cargo 缓存仍重建 MLX 约 7 分 17 秒。独立缓存步骤放在 `rust-cache` 后,利用 post 的逆序执行先保存原生输出。缓存按架构、profile、Rust/Clang/Metal/CMake/SDK、子模块提交、编译环境和 manifest/lock/config 隔离,无跨 key 的模糊回退。Metal 版本输出去掉每次启动可能变化的挂载目录,保留实际版本与目标信息。只保存 CMake `out`,不保存 Cargo freshness 指纹,下一轮仍执行 build script 与 CMake 输入校验。 + `scripts/macos-build-env.sh` 为 macOS 打包默认设置 `CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16`,保留 `opt-level=3`、thin LTO 和 unwind;环境变量可以显式覆盖为其他值。参数取舍依据 [Cargo profiles](https://doc.rust-lang.org/cargo/reference/profiles.html#codegen-units):更多 codegen units 允许更快的并行代码生成,可能影响最终体积或优化效果。共享 `Cargo.toml` 不修改。CI 在恢复缓存前加载同一环境,本地 `build-mac.sh` 也加载它。 diff --git a/docs/qwen-asr-submodule-upgrade-checklist.md b/docs/qwen-asr-submodule-upgrade-checklist.md deleted file mode 100644 index 727e1543d..000000000 --- a/docs/qwen-asr-submodule-upgrade-checklist.md +++ /dev/null @@ -1,7 +0,0 @@ -# 旧 Qwen-ASR 子模块清单(已下架) - -这份清单已被 [macOS 实验性本地语音模型](experimental-local-asr.md) 取代,不再是当前构建指南。 - -旧 `qwen-asr`、`qwen3-asr-rs` 子模块、原生 C/MLX 引擎、Metal 资源打包与对应 CI 启用/禁用脚本均已从应用移除。**不要为解决当前编译问题恢复旧子模块或旧 MLX 打包步骤。** - -TextStream 使用独立、按需安装的 Python/MLX worker。升级它时核对固定 revision、模型清单、wheel 版本/哈希及许可,并运行流式协议、真实推理、取消/退出和安装包验收;不再更新原生子模块指针。 diff --git a/Examples/polish-reference-examples.sample.jsonl b/docs/references/polish-reference-examples.sample.jsonl similarity index 100% rename from Examples/polish-reference-examples.sample.jsonl rename to docs/references/polish-reference-examples.sample.jsonl diff --git a/docs/references/tauri-webdriver-v2.workflow.yml b/docs/references/tauri-webdriver-v2.workflow.yml deleted file mode 100644 index ee1eeccd4..000000000 --- a/docs/references/tauri-webdriver-v2.workflow.yml +++ /dev/null @@ -1,80 +0,0 @@ -on: - workflow_dispatch: - pull_request: - paths: - - ".github/workflows/webdriver-v2.yml" - - "v2/**" - -name: WebDriver (v2) - -jobs: - test: - name: Test - runs-on: ${{ matrix.platform }} - strategy: - fail-fast: false - matrix: - platform: [ubuntu-latest, windows-latest] - webdriver-test: [selenium, webdriverio] - steps: - - uses: actions/checkout@v2 - - - name: install Tauri dependencies (Linux) - if: matrix.platform == 'ubuntu-latest' - run: > - sudo apt-get update && - sudo apt-get install -y - webkit2gtk-4.1 - libayatana-appindicator3-dev - webkit2gtk-driver - xvfb - - - name: install msdgedriver (Windows) - if: matrix.platform == 'windows-latest' - run: | - cargo install --git https://github.com/chippers/msedgedriver-tool - & "$HOME/.cargo/bin/msedgedriver-tool.exe" - $PWD.Path >> $env:GITHUB_PATH - - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - cache: false - rustflags: "" - - - uses: Swatinem/rust-cache@v2 - with: - workspaces: v2/src-tauri - - - uses: pnpm/action-setup@v4 - with: - version: 10 - - - name: Node v20 - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: "pnpm" - cache-dependency-path: | - v2/pnpm-lock.yaml - v2/webdriver/${{ matrix.webdriver-test }}/pnpm-lock.yaml - - - name: install dependencies - run: pnpm install - working-directory: v2 - - - name: Install tauri-driver - run: cargo install tauri-driver --locked - - - name: run tests using ${{ matrix.webdriver-test }} (Linux) - if: matrix.platform == 'ubuntu-latest' - run: | - pnpm install - xvfb-run pnpm test - working-directory: v2/webdriver/${{ matrix.webdriver-test }} - - - name: run tests using ${{ matrix.webdriver-test }} (Windows) - if: matrix.platform == 'windows-latest' - run: | - pnpm install - pnpm test - working-directory: v2/webdriver/${{ matrix.webdriver-test }} diff --git a/docs/releases/2.0.0-preparation-20261016.md b/docs/releases/2.0.0-preparation-20261016.md deleted file mode 100644 index ad3c10bdc..000000000 --- a/docs/releases/2.0.0-preparation-20261016.md +++ /dev/null @@ -1,21 +0,0 @@ -# 2.0.0 正式版准备(2026-10-16) - -用户授权顺序:全面 Review → PR/CI/合并 → 先公开 beta6 → 再公开日期构建号的 2.0.0;正式版发布前需确认当前电脑修复版正常。 - -## 已确认 - -- PR [#1197](https://github.com/Open-Less/openless/pull/1197) 已合并至 beta,保留已验证的精确提交 `b8f6a65ae96ce40e9f10c0b8f8f8c6a3245cdca4`。 -- 此提交完整 CI [38780356722](https://github.com/Open-Less/openless/actions/runs/38780356722) 七项全部成功:macOS、Windows、macOS Rust 1.88、Android(含 Keystore 模拟器)、Linux Core/egui/deb/rpm、Chromium/WebKit UI 和变更判定。 -- 合并采用管理员 fast-forward,原整合分支已有的 merge commit 保留;服务器记录了线性历史/末次推送者审批规则的管理员 bypass。未修改仓库保护配置,未跳过失败的 CI。 -- 本机已安装修复版经用户试用确认无明显问题;安装主程序 SHA-256 `7f0918b3012d273fb1ec61b3a041089c85acf9fcb04f73e5ce933bbf6076bba4`。真实录音退出、转写与 `Inserted` 日志、持续运行和签名已核对。App 未在本轮 Review 中替换。 -- 本机浏览器一次全量执行 100/100 通过,Linux 八张当前 UI 参考经实际 Ubuntu 产物审查并通过云端 CI。 -- `v2.0.0-Beta.6-tauri` 已指向上述绿色提交,两个资产流水线先构建共享 draft;尚不能将 tag 存在当作已公开 Release。 -- 官方 Homebrew tap 和本机 zsh 更新提示已经配置,`brew update` 成功;旧 1.3.18 cask 未覆盖当前修复版。 - -## 此准备 PR - -仅将五处 Tauri 版本源(含 package-lock 两处)和版本契约同步为 `2.0.0+build.20261016`,不修改已经验收的语音/UI 生产逻辑,不更换用户当前 App。独立的 Core/Linux workspace Cargo.lock 不属于版本同步范围。 - -此 PR 自身也以云端 CI 作为合并门禁。beta6 的两条资产流水线成功并核验/公开后,才把正式版候选经维护者的 `beta → main` PR 合并;确认最终提交 CI 后,管理员创建 `v2.0.0-tauri`,构建/核验正式版资产并公开为 latest。Stable 发布事件自动同步 Homebrew 版本、架构校验和和日期文件名,之后再执行本机 Homebrew 接管/升级。 - -Linux 安装包仍需独立产品验收,不随本次 Tauri Release 附加。已通过的构建/浏览器/本机验收不冒充未执行的 Windows 或其他设备完整实测。 diff --git a/docs/releases/2.0.0-windows-msi-repair-20261016.md b/docs/releases/2.0.0-windows-msi-repair-20261016.md deleted file mode 100644 index f5684d356..000000000 --- a/docs/releases/2.0.0-windows-msi-repair-20261016.md +++ /dev/null @@ -1,18 +0,0 @@ -# 2.0.0 Windows MSI 日期构建号修复 - -正式版 `v2.0.0-tauri` 固定指向已经通过最终七项 CI 的 `b5e60db6ac010dfc5fdd8aae4db77e1e3a72ac7f`;标签不移动、不重建已成功的 macOS/Android。 - -首次桌面发版 run `38799781882` 的 Windows job `116645498144` 已完成 NSIS 编译/签名,但 MSI 在生成 WiX 对象前失败: - -```text -optional build metadata in app version must be numeric-only and cannot be greater than 65535 for msi target -Required WiX object missing: .../release/wix/x64/main.wixobj -``` - -因此旧 ICE80 light.exe 修复路径不能处理本次失败,重复原 job 不会改变结果。应用版本 `2.0.0+build.20261016` 按用户要求保留;仅 MSI ProductVersion 通过官方 `bundle.windows.wix.version` 覆盖为 `2.0.0.0`。SDK schema 明确允许此字段,限制为 major/minor ≤255、patch/build ≤65535。配置 overlay 不包含顶层 version,不改变 App 显示版本、Rust crate 版本、更新清单或安装包日期文件名。 - -发布工作流新增 Windows-only 平台和显式 `release_tag` 草稿修复入口:只允许管理员、已存在未公开的 Stable 草稿、准确 tag checkout、相同提交七项 CI 全绿;上传前再次确认仍为 draft。工作流分支提供修复后的打包逻辑,应用源码始终来自原始不可变 tag。普通分支手动构建仍不发布,正常 tag 构建保持原有三平台行为。 - -对应契约执行实际 workflow 内嵌的 MSI 配置生成代码,覆盖日期/普通版本、数字边界、保持完整应用版本、updater 签名开关、草稿与管理员/CI 约束,以及所有 Bash 步骤语法。Windows 修复 run 还须实际生成、安装验证 NSIS/MSI 并签名/上传后,才允许公开正式版。 - -2026-10-16 系统重启清空了 `/tmp`;此前已完成的 beta6 发布、CI 和本机验收以 GitHub/PR/Release 记录恢复,未重新发布 beta6。后续临时证据存放在 `~/Library/Caches/pi-openless-release-20261016`。 diff --git a/docs/releases/2.0.1-windows-ci-isolation-20261016.md b/docs/releases/2.0.1-windows-ci-isolation-20261016.md deleted file mode 100644 index 17bf1da0d..000000000 --- a/docs/releases/2.0.1-windows-ci-isolation-20261016.md +++ /dev/null @@ -1,19 +0,0 @@ -# 2.0.1 Windows 安装器 CI 隔离 - -Stable 标签 `v2.0.1-tauri` 固定在 `33d17a4e`,精确源码 CI `38876378343` 及 main CI `38878764285` 均七项通过。macOS 两架构、Android 四 ABI 已构建、下载并核验;不重建这些资产。 - -桌面 run `38878773775` 两次 Windows job(`116900142784`、`116916344722`)都已生成 NSIS 和数字 MSI 版本的安装包;NSIS 安装、COM/TSF/后端注册检查及卸载通过。两次均在随后的 `msiexec /i ... /qn /norestart` 开始约 1 秒后收到 runner shutdown signal,MSI 检查被取消,未上传 Windows 资产。重复位置排除了继续盲目重跑的价值,符合 MSI Restart Manager 关闭持有 TSF 组件的 runner 进程的现象;日志不把它记录为已通过安装验收。 - -Microsoft 官方定义 `/norestart` 不等于禁止 Restart Manager 关闭应用。CI 在真实安装器 smoke 之前,临时将 `HKLM\Software\Policies\Microsoft\Windows\Installer` 的 DWORD `DisableAutomaticApplicationShutdown` 设为 1,禁止 Windows Installer 与 Restart Manager 交互,并在 finally 恢复原值/移除本次新增属性。该设置仅存在于可丢弃的 Windows CI 主机,不写入 MSI/NSIS,不改变用户电脑策略,不跳过安装、卸载、DLL 路径或任何 COM/TSF 注册检查。 - -来源:[DisableAutomaticApplicationShutdown](https://learn.microsoft.com/en-us/windows/win32/msi/disableautomaticapplicationshutdown)、[MSIRESTARTMANAGERCONTROL](https://learn.microsoft.com/en-us/windows/win32/msi/msirestartmanagercontrol)。官方 Markdown 已从 MicrosoftDocs/win32 对应 desktop-src/Msi 文件核对。 - -工作流与契约经 PR/CI 后,仅使用已存在的管理员/草稿/精确 tag 源码/七项 CI 守卫的 Windows-only repair 入口,对 `v2.0.1-tauri` 构建并验证。应用源码、版本与标签不变;最终仍须 Windows smoke 成功、全部资产与签名核验后才公开 Stable。 - -## 隔离后的安装器返回值 - -PR #1213 精确 CI `38888000367` 七项通过并合入 beta。Windows-only run `38889597557` 不再关闭 runner,MSI 安装返回 **3010**。Microsoft 官方 [Windows Installer error codes](https://learn.microsoft.com/en-us/windows/win32/msi/error-codes) 定义它为安装成功、需要重启,旧 smoke 却将所有非零返回视为失败,尚未执行后续注册验证。 - -修正通用进程 helper 的可接受返回值参数:默认仍仅 0,只有 MSI 安装/卸载显式允许 0 与 3010;NSIS 保持 0-only,1603 等错误及会实际重启 CI 的 1641 仍拒绝。接受 3010 后仍完整执行文件、DLL 路径和所有 COM/TSF/后端注册检查,不将成功返回码本身当成安装验收。新增 Windows 上执行实际 helper 的六个 mock 返回值回归,不启动安装器或修改注册表。 - -为保留不可变应用源码,工作流单独 sparse checkout **`github.workflow_sha`** 固定的 smoke 工具脚本;应用 checkout 仍指向原始 release tag。复制的只有测试脚本,编译/打包 payload 不使用工具分支;正常 tag 构建和定向草稿修复采用同一工具来源规则。Windows 编译缓存允许失败时保存,避免 smoke 问题每次重编所有依赖。后续实际 smoke 成功前仍不公开 Stable。 diff --git a/docs/releases/apple-omni-cpu-todo-20261016.md b/docs/releases/apple-omni-cpu-todo-20261016.md deleted file mode 100644 index 48daa22ec..000000000 --- a/docs/releases/apple-omni-cpu-todo-20261016.md +++ /dev/null @@ -1,43 +0,0 @@ -# Apple 识别、多模态与 CPU 预算修复 - -## 固定范围与待办 - -- [x] Apple Speech 恢复为 macOS 系统识别服务,独立于已下架和实验模型。 -- [x] 恢复原生注册、系统语音权限声明和取消处理;渠道不显示模型管理控件。 -- [x] Omni 的 `[DONE]` 结束流,不等待 HTTP EOF;静默流允许取消;连接握手单独限时 8 秒。 -- [x] 实验模型使用启动时空闲逻辑 CPU 容量的 80% 作为平均 CPU 预算;CPU 推理线程至多 2。 -- [x] TextStream 显式选择 Metal GPU;Gemma 保留 Core ML `.all`,CPU BLAS 单线程,原生推理使用 utility QoS。 -- [x] CPU 检查点可以取消;超时设置取消标志;等待期间不把 UI/录音 CPU 累加成永远还不完的欠账。 -- [x] TextStream 只升级已知、受管的 worker,不重装其 Python 环境、不重下模型。 -- [x] 修复渠道编辑页返回按钮与 macOS 标题栏拖动区重叠。 -- [x] 本地定向测试、前端完整测试与 diff 检查通过。 -- [x] 提交修复,创建 [PR #1207](https://github.com/Open-Less/openless/pull/1207)。 -- [ ] 云端 CI 全绿后合并;实时结果以 PR 检查与记录为准。 - -## 已验证证据 - -- Core 全量首次:1206 passed / 1 旧退役契约失败 / 1 外部 Codex 测试 ignored。旧契约移除 Apple 后,ProviderService 定向 30/30;退役元数据 7/7。 -- Omni 三项真实本机 HTTP 流回归通过:`[DONE]` 后连接保持打开、空结束、静默读取消。 -- 原生实验模型定向:16 passed / 4 需要模型文件的测试 ignored。含 CPU 实际计时、Darwin QoS、Swift/Rust 取消回调和受管 worker 升级。 -- Apple 识别原生生产模块最终 37/37,包括只读系统类链接检查;不触发系统授权提示。 -- 已有 SenseVoice 权重配合本地生成的固定英文合成语音,真实识别通过;音频 3.502 秒、推理约 604ms。没有读取或导出用户录音。 -- Swift 以 macOS 14 部署基线完成 typecheck。Python 单元 11/11,Ruff、Black 和新增预算代码的 mypy strict 通过。 -- 前端测试 runner 最终完整通过。浏览器原有 6 项通过;新增 Apple 渠道创建、分类、标题栏返回、停用/启用在 Chromium/WebKit 2/2 通过。 - -### CI 收尾 - -首轮 CI `38847638369`:Android、macOS Rust 1.88、UI 双浏览器、Windows、Linux 和 Changed areas 通过。macOS 完整原生单元测试为 579 passed / 1 failed / 4 ignored:剩余旧断言把 Apple 系统识别当作已退役模型。已把该断言改为「macOS 无需云凭据、其他平台不可用」,保留旧 Whisper 模型不可用的断言。本地完整原生测试还暴露 CPU 计时测试的并发测量边界问题:它把其他测试线程在推理让出期间的 CPU 也算进了推理预算。修正为在让出前冻结计量,不修改生产节流、不放宽预算断言。修正后使用正常原生依赖执行 `cargo test --lib --bins`:**580 passed / 0 failed / 4 explicit model-file-dependent ignored**。后续结果见 PR #1207,已有成功记录不删除、不伪装为新源码的检查结果。 - -## 明确边界 - -CPU 预算是分段平均限制,不是内核级瞬时硬上限。单次 ONNX/Core ML 调用可能短时突发;绝不暂停整个 OpenLess 进程来伪造限额。预算下限为 0.05 个逻辑核以保持进展。Apple 系统识别由 OS 管理,不声称能够限制其独立系统服务。 - -Gemma 已允许 GPU/ANE/CPU,但没有本轮完整模型实测,不能声称 ANE 已被实际使用。TextStream 的 Metal 可用性 API 已核对固定版本 MLX 0.32.3 的官方源码;没有下载完整模型或声称整条真实流已验收。 - -当前多模态域名与标准 DashScope 域名的匿名、无认证 HEAD 探测均未完成 TLS 握手(HTTP 000)。这证实当前网络路径有问题,不能据此推断 API Key、模型名、额度或业务接口正常。`audio=false` 日志属于文本探测,不能当作录音丢失证据。 - -## 发布状态 - -beta6 已公开,不移动该标签。Stable `v2.0.0-tauri` 在 2026-10-16 仍为草稿;本轮修复完成前暂停发布。不将这些源码改动冒充已安装应用中的功能;当前 `/Applications/OpenLess.app` 未被本轮修改。 - -持久验证日志:`~/Library/Caches/pi-openless-release-20261016/`。不提交日志中的用户内容、认证资料或本机模型文件。根目录既有 `package.json`、`pnpm-lock.yaml` 保持不动。 diff --git a/docs/releases/beta7-stable201-20261016.md b/docs/releases/beta7-stable201-20261016.md deleted file mode 100644 index 41c9ae3ad..000000000 --- a/docs/releases/beta7-stable201-20261016.md +++ /dev/null @@ -1,26 +0,0 @@ -# Beta7 / 2.0.1 发布记录 - -用户授权:将本地修复经 PR/review 合入 beta,构建并公开 Beta 与正式版,完成后结束。不替换本机 App、不迁移用户配置。 - -## 版本与来源 - -- 已公开 `v2.0.0-Beta.6-tauri` 和旧源码草稿 `v2.0.0-tauri` 保持原样,不移动标签、不混入新资产。 -- 本轮 Beta 使用 `2.0.0-Beta.7+build.20261016` / `v2.0.0-Beta.7-tauri`;正式版使用 `2.0.1+build.20261016` / `v2.0.1-tauri`。递增版本避免重复 Beta6/Stable2.0.0,日期采用当前构建日期,不使用此前误写的 20161016。 -- 功能来源:#1207 Apple Speech、Omni 结束与 CPU 预算修复;#1208 可清空快捷键、纵向下拉与高级自建同步入口迁移。 -- #1208 精确 head `24837abd` 的 CI `38869652397` 七项全部通过;独立复审无剩余代码问题。合并至 beta 为 `f7d1cfb0`。 -- 合并使用现有组织管理员允许的 bypass,因为 GitHub 仍要求非末次推送者审批;未更改保护配置,未跳过失败检查。 - -## 发布门禁 - -版本 PR 同步五处 Tauri 版本源(package-lock 两处)和版本契约,不改变依赖或生产逻辑。每个版本等待精确源码 CI,通过后由管理员创建新标签。桌面与 Android 两条资产流水线均通过,核对 SHA-256、更新签名、渠道清单、macOS 架构/最低系统/包签名后再公开。Beta 先公开,正式版之后标记 latest。Linux 不附加,仍需独立产品验收。 - -本机修复版已有用户确认和真实识别证据;#1208 本地新版 App/DMG 已构建并核验,路径见 `../reviews/2026-10-16/shortcuts-sync-review.md`。不把云端构建、安装器 smoke 或浏览器回归写成未执行的真机完整验收。当前分支不修改已安装应用或 TCC 权限。 - -## 最终交付 - -- 功能 PR #1208、Beta 版本 #1209、正式版本 #1210、MSI 日期版本修复 #1206 均已合入 beta。原直接晋级 #1211 因 squash 历史冲突,由保留相同生产 tree 的 #1212 替代;#1212 已合入 main,详见 `2.0.1-main-integration-20261016.md`。 -- [Beta7 已公开](https://github.com/Open-Less/openless/releases/tag/v2.0.0-Beta.7-tauri),2026-10-16 09:11:19 UTC。精确源码 `78e47239`,CI `38871951368` 七项通过;桌面 `38873999060`、Android `38873999030` 成功。30 项原始资产 SHA-256、7 项 minisign 签名、14 项 Beta 清单和两个 DMG 完整性/包签名/版本/架构/最低系统核验通过,附 SHA256SUMS,共 31 项资产。 -- [正式版 2.0.1 已公开并设为 latest](https://github.com/Open-Less/openless/releases/tag/v2.0.1-tauri),2026-10-16 12:40:48 UTC。标签固定 `33d17a4e`;晋级 CI `38876378343`、main CI `38878764285` 均七项通过。macOS 来自 `38878773775`,Android `38878773759` 成功;原 Windows 安装 smoke 被 Restart Manager 关闭 runner,CI 隔离 #1213 和 MSI 成功码 3010 修正 #1215 均经七项 CI 后合入 beta,再由 Windows-only `38896122121` 对同一 tag 构建、真实 NSIS/MSI 安装/卸载和 IME 注册验证,全部成功。工作流工具脚本与应用源码明确分离,未移动标签。 -- 正式版 31 项原始资产 SHA-256、7 项 minisign 签名、14 项 Stable 清单、两个 macOS DMG 完整性/包签名/版本/架构/最低系统核验通过,附 SHA256SUMS,共 32 项资产。Linux 未附加。 -- Homebrew 发布同步 `38902224240` 已核对两架构 DMG,但因缺少既有要求的 `HOMEBREW_CASK_ADMIN_TOKEN` 拒绝推送。使用已有授权的本机组织管理员路径补齐 cask,仅写入已公开 latest 的完整版本和已核验 SHA-256,不降低保护、不抽取或上传凭据;无需重跑应用跨平台 CI。 -- 未替换本机 App、未改用户配置或权限;旧 Beta6 和旧 2.0.0 tag/draft 均保留。所有公开状态已从 GitHub 回读验证。 diff --git a/docs/research/issue-1192.md b/docs/research/issue-1192.md deleted file mode 100644 index 0d8efe097..000000000 --- a/docs/research/issue-1192.md +++ /dev/null @@ -1,201 +0,0 @@ -# Issue #1192 调研:Android 整套配置本地导入 / 导出 - -调研日期:2026-10-14 -Issue: - -## 结论摘要 - -Issue #1192 对 **v1.3.18 稳定版用户**仍然是有效需求,但对当前 `beta` 代码树来说不是从零开始开发:Android IME 已经有一版“导出 / 导入配置”实现,能够通过系统文件选择器迁移一部分本地设置、键盘学习数据、当前 ASR/LLM 渠道和可选 API Key。 - -这版实现还不能称为“整套配置迁移”。主要缺口是: - -- 没有导出完整的 Android / App 偏好; -- 只处理当前激活的 ASR 与 LLM 渠道,不是全部渠道,也没有在不携带凭据时保留完整的非敏感端点 / 模型 / 高级参数; -- 只迁移笔画频率和简拼学习,未迁移 Core 词典、词库预设和完整词库状态; -- 只迁移风格包 ID,不迁移风格包本体、提示词、示例和图标; -- 导出文件是明文 JSON,没有“个人迁移 / 分享配置”两种安全模式、密码加密、导入前备份或可回滚事务; -- 未找到 `OpenLessSettingsExport` 的直接 Kotlin 单元测试或端到端往返测试。 - -因此建议 **保留 Issue 作为完整迁移的总需求,不直接关闭**,但将现有实现认定为部分交付,并拆出“完整本地 bundle / 安全分享 / 原子导入 / 测试验证”等子任务。若产品只要求“换机时减少重复填写”,当前 beta 版可以作为 MVP;若要求 Issue 正文中的验收标准,则仍需继续实现。 - -## 一手来源 - -- Issue 正文与评论: -- 稳定版 v1.3.18(2026-08-21): -- 当前 beta 的 Android APK 发布版 v2.0.0-Beta.4(2026-09-30): -- Android IME 大型 PR #1067(已关闭、未标记为 merged): -- Android 凭据安全边界 Issue #817: - -## Issue 要求拆解 - -Issue #1192 要求一个不依赖同一账号、云同步或链接服务的本地文件入口,覆盖: - -1. ASR / LLM 及其他服务的协议、渠道名称、Base URL / 完整地址、模型、启用状态、激活渠道和高级参数; -2. 通用词典、用户词条、预设词库、当前激活状态; -3. 风格包本体、提示词、示例及当前默认风格; -4. Android 应用偏好,例如语言、主题、输入与剪贴板策略、触发 / 录音设置; -5. API Key 的显式选择,普通分享默认脱敏; -6. 导入前显示来源、版本、类别和变更摘要,支持合并或替换,并自动备份原配置; -7. 权限不随配置文件迁移,导入后只提示接收者重新授权; -8. 版本化格式、兼容性校验、设备不适用配置的显式跳过或确认。 - -Issue 的唯一评论来自维护者,内容为“满足你。”;没有关联 PR、标签或进一步的验收讨论。该评论不能替代上述完整验收标准。 - -## 当前代码现状 - -### 稳定版与 beta 的边界 - -在 `v1.3.18-tauri` tag 中不存在 `OpenLessSettingsExport.kt`、Android IME 设置页和 `android_transfer.rs`;这与 Issue 正文以官方 APK 1.3.18 为主要场景的描述一致。当前 `beta` 已包含 Android IME 和导入 / 导出实现,并在 v2.0.0-Beta.4 发布了多架构 APK。 - -### 已有导出 / 导入入口 - -[OpenLessSettingsExport.kt](../../openless-all/app/android/kotlin/OpenLessSettingsExport.kt) 定义了版本化 JSON(当前版本为 2),并提供六个可勾选类别: - -- `cloudNotes`:云笔记 URL / Token; -- `haptic`:震动开关、强度、时长; -- `strokeFrequency`:笔画输入频率; -- `pinyinLearnedPhrases`:简拼组合学习; -- `providerSelection`:ASR / LLM 当前渠道与两个风格包 ID; -- `credentials`:ASR / LLM 渠道凭据。 - -入口在 [OpenLessKeyboardSettingsActivity.kt:538-734](../../openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt#L538-L734): - -- 导出使用 `ACTION_CREATE_DOCUMENT` 写 JSON; -- 导入使用 `ACTION_OPEN_DOCUMENT` 读取 JSON; -- 导入前按文件实际包含的类别弹出复选框; -- 文件读取上限为 4 MiB; -- 导入后显示成功类别与逐项错误。 - -这版实现已经具备 schema/version、按类别选择、基础输入校验和分项错误报告,不能算完全没有设计。 - -### Provider / credential 迁移 - -[android/native_bridge.rs:61-187](../../openless-all/app/src-tauri/src/android/native_bridge.rs#L61-L187) 通过运行中的 Core backend 和 `CredentialsVault` 读写,而不是由 Kotlin 手工修改 `preferences.json`。导入会: - -- 校验版本 1 / 2; -- 校验 provider/channel 身份一致性; -- 将渠道合并到目标设备而不是删除其他渠道; -- 刷新 backend 的凭据视图; -- 只在目标设备存在对应风格包时切换风格包,否则保持原值并报告缺失。 - -[credentials/android_transfer.rs:170-201](../../openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs#L170-L201) 的导出只取 `root.active.asr` 与 `root.active.llm` 对应的渠道,因此不是所有已配置渠道的完整备份。当前 native bridge 也只处理 ASR、LLM,未覆盖 Omni 渠道。 - -Android 凭据在设备本地由 Android Keystore + AES-GCM 保护,见 [OpenLessCredentialVault.kt:114-169](../../openless-all/app/android/kotlin/OpenLessCredentialVault.kt#L114-L169) 和 [android_credentials.rs:11-18](../../openless-all/app/src-tauri/src/persistence/android_credentials.rs#L11-L18)。这解决的是“设备存储时”的保护,不等于导出文件安全。 - -## 与 Issue 验收标准的差异 - -### 1. 应用偏好覆盖不足 - -现有 JSON 只读写 `openless_ime_ui` 中的云笔记与震动字段,以及 provider 的几个选择字段。完整的 `UserPreferences` 还包含主题、语言、录音 / 自动停止、剪贴板、流式输入、Android 插入策略、悬浮窗触发模式、手势、尺寸等大量字段,见 [shared_types.rs:385-849](../../openless-all/app/crates/openless-core/src/shared_types.rs#L385-L849)。 - -Android 自身还把 `theme_mode`、`input_mode`、`latin_input_mode`、英文提示等放在 `openless_ime_ui`,例如 [OpenLessImeService.kt:464-503](../../openless-all/app/android/kotlin/OpenLessImeService.kt#L464-L503);这些字段不在 `OpenLessSettingsExport.Category` 的导出逻辑中。 - -**影响:** 当前功能能迁移“键盘设置页里明确列出来的少量选项”,不能满足“安装 APK 后导入即可恢复应用偏好”的目标。 - -### 2. Provider 的“脱敏分享”不完整 - -`export_provider_settings_json(false)` 会复制渠道身份后移除 `configuration`,见 [native_bridge.rs:67-79](../../openless-all/app/src-tauri/src/android/native_bridge.rs#L67-L79)。因此不携带 credentials 时,Base URL、模型、协议高级参数等非敏感配置也不会保留;携带 credentials 时则使用完整 `configuration`,其中包含裸 API Key。 - -此外,导出只取当前激活的 ASR / LLM 渠道,见 [android_transfer.rs:174-188](../../openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs#L174-L188),不满足“已配置渠道、启用状态、其他服务均可迁移”的完整范围。 - -### 3. 词典 / 词库覆盖不足 - -当前导出只包含 Android 键盘自己的 `StrokeUserFrequency` 与 `LitePinyinLearnedPhrases`。Core 的通用词典和词库预设是独立持久化资源: - -- `DictionaryStore` 使用 `dictionary.json`; -- 词库预设使用 `vocab-presets.json`; -- Android mobile command handler 已经注册 `list_vocab`、`add_vocab`、`list_vocab_presets` 等命令,见 [lib.rs:577-588](../../openless-all/app/src-tauri/src/lib.rs#L577-L588)。 - -因此实现上有现成入口可复用,但当前配置文件没有这些内容,也没有恢复当前启用状态。 - -### 4. 风格包只迁移引用,不迁移实体 - -当前 provider selection 只写 `activeStylePackId` 和 `selectionPolishStylePackId`。导入时若目标设备没有对应 ID,则保留旧 ID并报告,见 [native_bridge.rs:146-173](../../openless-all/app/src-tauri/src/android/native_bridge.rs#L146-L173)。这意味着自定义风格的 prompt、examples、元数据和图标不会随配置一起迁移。 - -Core 已有完整的 ZIP 能力:[persistence/style_pack.rs:112-134](../../openless-all/app/src-tauri/src/persistence/style_pack.rs#L112-L134) 暴露 style pack 的 ZIP 导入 / 导出;Android mobile handler 也已注册对应命令,见 [lib.rs:599-610](../../openless-all/app/src-tauri/src/lib.rs#L599-L610)。后续应把这些资源纳入同一个 bundle,而不是只复制 ID。 - -实现时需注意 Android SAF 的 `content://` 目标:当前 [commands/style_packs.rs:215-228](../../openless-all/app/src-tauri/src/commands/style_packs.rs#L215-L228) 的 ZIP 导出路径直接按文件路径处理,而导入已经专门支持 `content://`。完整 bundle 需要补充 content URI 写入或在 Kotlin 层统一写出。 - -### 5. 安全流程与恢复流程不足 - -导出器明确采用明文 JSON,见 [OpenLessSettingsExport.kt:15-19](../../openless-all/app/android/kotlin/OpenLessSettingsExport.kt#L15-L19);UI 只展示“文件包含明文 API Key,请妥善保管”的警告,见 [OpenLessKeyboardSettingsActivity.kt:538-555](../../openless-all/app/android/kotlin/OpenLessKeyboardSettingsActivity.kt#L538-L555)。当前没有: - -- “个人换机”与“分享配置”两种明确模式; -- 密码加密 / 密钥派生; -- 默认脱敏后仍保留非敏感 provider 配置; -- 导入前快照、自动备份、回滚; -- 合并 / 替换策略选择; -- 写入前的实际变更摘要。 - -导入是按类别分开写入;非 provider 类别即使前一项成功、后一项失败,也不会进行全局回滚,见 [OpenLessSettingsExport.kt:91-140](../../openless-all/app/android/kotlin/OpenLessSettingsExport.kt#L91-L140)。provider 导入虽然使用 Core 的合并逻辑,但整体配置仍没有统一事务边界。 - -Issue #817 已明确项目对 Android 凭据的安全要求是 Keystore-backed protection、认证加密、迁移 / 篡改处理和测试;本地导出应遵循同一安全边界,不能因为文件通过系统分享就退回无保护的裸密钥。 - -### 6. 权限边界目前基本正确,但需要产品化提示 - -现有导出器没有导出麦克风、悬浮窗、无障碍、输入法启用等系统授权状态;这是正确的,权限不能被配置文件自动授予。完整导入流程仍应在成功后明确列出“尚需在本机授权”的权限,而不是只显示“Imported N categories”。 - -### 7. 测试覆盖 - -[android_transfer.rs:281-387](../../openless-all/app/src-tauri/src/persistence/credentials/android_transfer.rs#L281-L387) 已覆盖渠道身份绑定、合并时保留其他渠道、重复导入幂等性;style pack archive / store 也有较完整的 ZIP 安全和持久化测试。 - -但在当前代码树中没有找到 `OpenLessSettingsExport` 的直接 Kotlin test,也没有覆盖以下完整场景的往返测试: - -- 导出所有类别后在干净 Android 数据目录导入; -- 缺失 provider / style pack 的部分成功与回滚; -- 明文 / 脱敏 / 加密三种分享模式; -- SAF `content://` 输入和输出; -- 系统权限不随文件迁移; -- 旧 schema 与未来 schema 的兼容矩阵。 - -## 与现有 Cloud Sync 的关系 - -不能简单把用户导向 Cloud Sync:Issue 明确要求无需同一账号、无需云同步的本地文件。但 Cloud Sync 的 E2EE 文档层已经提供了值得复用的“可迁移数据分类”和验证模型: - -- [cloud_sync_e2ee_documents/export.rs:27-69](../../openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs#L27-L69) 将偏好分类为 portable / device profile / excluded; -- [export.rs:71-153](../../openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs#L71-L153) 已处理 provider、词典、词库预设、纠错规则和 style packs; -- [types.rs:136-189](../../openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs#L136-L189) 明确区分渠道身份、provider credentials 与完整 style pack; -- [registry.rs:34-83](../../openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs#L34-L83) 提供字段级 portable / device-bound / excluded 清单。 - -推荐抽取一个“本地 bundle source / sink”复用这些 canonical record、字段白名单、引用校验和安全错误模型,但不复用账号、服务器或云端同步状态。 - -## 建议的实现拆分 - -### P0:先定义稳定本地格式和安全模式 - -- 统一 ZIP bundle(manifest、schemaVersion、源版本、类别摘要、payload、style assets);JSON 只作为内部 payload,不作为默认分享格式。 -- 明确两种用途: - - **分享配置**:默认不含凭据,但保留 endpoint / model / protocol / advanced non-secret fields; - - **个人迁移**:凭据必须单独勾选,并使用用户输入的密码进行 AEAD 加密。 -- 统一限制:最大文件大小、最大条目数、压缩炸弹防护、路径穿越防护、未知字段策略、未来版本拒绝策略。 - -### P1:完整 portable snapshot - -- 用 Cloud Sync registry 的 portable / device-bound / excluded 分类为基础,显式定义 Android 可迁移白名单。 -- 导出 / 导入完整 `UserPreferences` 中适用于 Android 的字段,以及 `openless_ime_ui` 中的主题、语言、输入模式、键盘偏好和悬浮窗配置。 -- 导出所有 ASR / LLM / Omni 渠道的身份与非敏感配置;凭据按渠道和账号单独加密。 -- 复用 Core 的 DictionaryStore、vocab presets、correction rules 和 StylePackStore;自定义 style pack 必须携带实体而非只有 ID。 - -### P1:原子导入与用户可控恢复 - -- 导入前自动创建本地备份;显示来源、版本、类别、凭据状态和实际变更摘要。 -- 支持“合并”与“替换”;默认合并,禁止静默删除目标设备已有渠道 / 词条。 -- 用临时目录 / snapshot + 校验后一次性提交;任何类别失败时保留原配置并报告逐项原因。 -- 对缺失 provider、风格包、设备相关路径明确标记“跳过 / 需要选择”,不要把 ID 写成不可用状态。 -- 完成后提示重启 backend / App,并列出需要用户手动授予的系统权限。 - -### P2:测试与发布验证 - -- Kotlin:schema、类别筛选、脱敏、坏文件、版本兼容、大小限制。 -- Rust:完整 bundle 的 schema、引用完整性、凭据加密 / 解密、原子提交 / 回滚、style asset 安全。 -- Android instrumented:SAF `content://` 读写、Keystore 锁定 / 密钥失效、换机干净安装、旧版本升级。 -- 真机矩阵:arm64-v8a、armeabi-v7a、x86_64;覆盖无悬浮窗 / 无障碍 / 未启用 IME 的权限状态。 - -## 最终 triage 建议 - -- **不要把 Issue 直接标记为重复并关闭**:稳定版用户仍没有入口,且当前 beta 只完成了子集。 -- 可在 Issue 中补充说明:v2 beta 已提供“部分配置导出 / 导入”MVP,并列出上述缺口,避免重复实现已有 provider transfer。 -- 将 Issue 标题或描述收敛为“Android 本地完整迁移 bundle(含脱敏分享、可选加密凭据、词典与风格包)”,把当前 `OpenLessSettingsExport` 作为基础实现继续演进。 -- 第一阶段优先实现完整非敏感配置、词典 / 词库 / style pack 和备份回滚;凭据分享必须单独经过安全设计与 review。 - -本报告为代码与 GitHub 一手资料的静态调研,未运行 Android 真机验证或构建 APK。 diff --git a/docs/reviews/2026-10-15/core-fixes.md b/docs/reviews/2026-10-15/core-fixes.md deleted file mode 100644 index 34e326d70..000000000 --- a/docs/reviews/2026-10-15/core-fixes.md +++ /dev/null @@ -1,73 +0,0 @@ -# R1 / R3 修复交付 - -基线 `0947c05a`,工作目录 `/Users/trip/orca/workspaces/openless/修复-Matel存在的部分问题`。R1、R3 已完成修复和语义回归;只改授权的 3 个文件,没有提交,没有修改 R2 TextStream、前端、其他 Core 文件或 backend-tests manifest/lock。 - -## 修改内容 - -### R1:只清理退出的活动 owner,不让清理落后于异步状态刷新 - -文件:`openless-all/app/crates/openless-core/src/api.rs`。 - -- `:2332` 新增局部 `local_asr_channel_gate`,只协调 ASR 渠道的 owner 变更与 runtime 清理;LLM 渠道不使用该锁。 -- `:4860` 的渠道 mutation 捕获活动 owner,而不是按“正在编辑的任意渠道”的 provider 无条件释放。`set_channel_provider_type` (`:4636`) 与 reorder (`:4771`) 复用此路径,`set_active_provider` (`:4815`) 和显式本地 activation 也参与同一 owner 临界区。 -- `:4897` 解析 channel ID / provider type,并兼容 legacy provider-only selection;`active_provider` 不受支持的 persistence-only store 不会因为新检查而失去原有 mutation 能力。 -- `:4930` 比较 mutation 前后的活动 owner。非活动同 provider 渠道被禁用、删除、更换 provider 或清理空白草稿时,不会释放活动 runtime;活动本地 owner 离开、且没有同 runtime 新 owner 接管时,仍执行 release。 -- 如果规范化后的新活动渠道继续拥有同一个共享 runtime,则保留该实例,避免把“旧 owner 退出”变成“杀掉新 owner”;模型差异仍由下一次显式使用/既有 activation lease 处理。 -- **runtime release 在 credential status refresh 之前完成**,之后释放 owner gate。状态查询被阻塞时,新的选择、activation 和 preload 不被 gate 卡住;旧请求恢复时不会再执行任何 runtime 清理,因此不能卸载刚加载的新实例。 -- native release 返回错误时,仍发布已经提交的 credential metadata,再向调用方返回 release 错误,避免新增“设置已提交但状态不再更新”的回归。 - -该修改集中在现有渠道 facade,没有更改 runtime adapter / TextStream ABI,也没有重构全局 settings 或录音链路。 - -### R3:删除完整性与加载完整性分离 - -文件:`openless-all/app/src-tauri/src/asr/local/native_model_receipt.rs`。 - -- `:127` 冷删除直接解析 receipt 的 `model_dir`,不再依赖只接受完整文件的 `installed_dir()`;模型缺文件或文件截断,不会阻止清理受管目录。 -- `:93` 通过真实存在的祖先解析目录,即使模型目录及其部分父目录已经不存在,仍可验证归属并完成幂等 cleanup。 -- 保留 canonical root containment 检查,拒绝删除缓存根本身、根外现有/缺失路径及指向外部的 symlink。 -- 只有目录成功删除或确认不存在后才删除 receipt;JSON 损坏、越界或删除失败会保留 receipt 并报错,后续仍能追踪/重试。 -- `installed_dir()` 的加载完整性标准未放宽,状态页也不会为了清理而初始化 Foundry SDK。 - -## 新增语义回归 - -`openless-all/app/crates/openless-core/tests/local_asr_contract.rs` 新增 5 项: - -1. `non_active_same_provider_channel_edits_do_not_release_the_active_runtime` (`:1287`):分别禁用、删除、更换 provider、删除空白的非活动同 provider 渠道;断言活动渠道未变、release 不发生、已加载模型保留。 -2. `active_local_channel_edits_release_its_runtime_when_no_local_owner_remains` (`:1310`):对当前 owner 做上述四种操作,断言恰好一次 release 且模型清空。 -3. `committed_channel_changes_are_published_even_when_runtime_release_fails` (`:1330`):release 失败仍传播明确错误,但已提交的渠道变化发出 `CredentialsChanged`。 -4. `a_same_runtime_channel_takeover_preserves_the_shared_loaded_instance` (`:1355`):当前 owner 关闭后由同 runtime 渠道接管,断言不误释放新 owner 的共享实例。 -5. `late_credentials_refresh_never_releases_a_reselected_or_reloaded_local_owner` (`:1380`):分别阻塞 disable/delete/provider type/select/reorder 的 credential refresh,期间重新激活并加载本地模型,然后恢复旧请求;断言新实例保留、不会出现第二次 stale release,且 replacement 不会被 owner gate 锁在状态刷新之后。 - -`native_model_receipt.rs` 新增 5 项(原有 1 项保留): - -- `:202` 缺文件和截断两种情形都删除整个模型目录,而不只是删除 receipt。 -- `:227` 模型目录及其父目录已经缺失时,安全忘记 receipt。 -- `:235` 缓存根、根外现有目录、根外缺失目录均拒绝删除,并保留 receipt / 外部文件。 -- `:252` Unix symlink 指向根外目录时,即使 receipt 文件列表不完整也拒绝删除。 -- `:269` receipt JSON 损坏时报错,不偷偷丢弃追踪信息。 - -## 测试结果 - -从 `openless-all/app` 执行: - -```sh -CARGO_TARGET_DIR="$PWD/src-tauri/backend-tests/target" \ - cargo test --offline --locked -p openless-core --test local_asr_contract - -cargo test --offline --locked \ - --manifest-path src-tauri/backend-tests/Cargo.toml \ - --test native_model_receipt -``` - -最终结果: - -```text -local_asr_contract: 34 passed; 0 failed; 0 ignored -native_model_receipt: 6 passed; 0 failed; 0 ignored -``` - -`git diff --check` 对 3 个授权文件通过。只对新写的局部代码块使用 rustfmt,没有格式化整个工程;生成局部格式化替换的 `/tmp` 文件已经删除,测试使用的临时模型目录由测试回收。 - -## 验证边界与交接 - -这轮 Cargo 仅构建 Core 和独立 backend-tests 的指定目标,全程 `--offline --locked`,没有触发完整 native 构建或 sherpa 库下载,也没有模型下载、录音、调用收费 API、读取用户秘密或安装程序。Core 回归使用内存 credential store / fake runtime,receipt 回归直接编译生产文件、操作临时目录;Windows Foundry SDK 与真实 Windows 文件系统、真实 TextStream/MLX 推理和 GUI 未在本轮执行。R2 文件及主 Agent 并行修改的 manifest、lock、recorder、前端与其他 ASR 文件均未由本 worker 修改。 diff --git a/docs/reviews/2026-10-15/core.md b/docs/reviews/2026-10-15/core.md deleted file mode 100644 index 325933b29..000000000 --- a/docs/reviews/2026-10-15/core.md +++ /dev/null @@ -1,151 +0,0 @@ -# PR #1197:Core / native / egui 独立只读审查 - -审查基线:`0454dc56..0947c05a`,仓库 `/Users/trip/orca/workspaces/openless/修复-Matel存在的部分问题`。下文行号对应 `0947c05a`;本次没有修改仓库源码、配置或凭据,没有安装程序、下载模型、主动录音、调用收费 API、启动应用或创建子 Agent,也没有执行 Cargo / 完整构建。 - -## 结论与优先级 - -发现 3 个生命周期问题,均由 **`16a73b9d`** 引入。优先修复 R1 的渠道所有权判断,其次修复 R2 的子进程取消句柄和 R3 的损坏缓存清理;未发现能够在本次约束内确认的永久互锁,也没有把“多模态无输出”未经实测地归因于本分支。 - -| 编号 | 优先级 | 问题 | 核验强度 | -|---|---|---|---| -| R1 | P2 | 禁用/删除非活动同类型 ASR 渠道,会释放活动渠道共享的 runtime | 原函数提取后的 spy-port 探针已复现 release 决策;native 后果沿源码追踪 | -| R2 | P2 | TextStream 在 EOF 后等待进程退出时丢失取消句柄,可留下仍存活的 worker | 提取原 `Process` 实现、以自有 shell/sleep 子进程确定性复现;不是 MLX 真机复现 | -| R3 | P2 | Windows Foundry 的损坏/缺文件缓存无法冷删除,且删除成功后丢失 receipt | 静态路径确认;未运行 Windows SDK / 原生 UI | - -这里的 P2 表示应修复的条件性回归,不表示所有普通录音都会失败。R2 的成立条件特意保留为“stdout 已 EOF,但进程仍存活”,不能把该边界探针写成真实 MLX teardown 已发生挂起。 - -## R1 — 非活动渠道编辑也会释放活动渠道的共享 runtime - -**定位**:`openless-all/app/crates/openless-core/src/api.rs:4901-4915`,尤其 `4914-4915`。引入提交:`16a73b9d`。 - -`apply_channel_mutation()` 按被修改渠道的 `provider_type` 获取 `retiring_provider`,但不检查该渠道是否是活动渠道、是否持有该 runtime 的录音/加载 lease;持久化与状态刷新后,直接调用 `release_local_asr_provider()`。因此渠道 A 与 B 共用一个本地 provider,A 活动、B 非活动时,禁用或删除 B 仍会释放 A 的 runtime。重复 provider 的渠道是 Core 支持的状态,Windows 的 Foundry/Sherpa 添加渠道也允许重复;实验 provider 不在当前 UI 的新增列表中,但 Core 创建/导入契约仍可形成该状态。 - -**触发条件**:存在两个同 provider 的 ASR 渠道,其中只有 A 是活动渠道;编辑 B 的启用状态或删除 B。若活动 provider 是 TextStream,且已有运行中的 worker,该无关编辑会走 `api.rs:4921-4923` → `experimental_asr.rs:510-516` 的 `release_runtime()` → `textstream.rs:112-133` 的 `stop()`,取消活动 worker;Foundry/Sherpa 则发生不必要的共享实例释放,不能据此一概断言它们丢失转写,因为其部分 session 能保留 Arc 或重新加载。 - -**可复核证据**:探针 `/tmp/openless-review-core-channel-probe.py` 直接从当前 `api.rs` 提取 `apply_channel_mutation()` 函数体,使用只在内存工作的 credential ports 与 release spy,未执行 native 推理。运行: - -```sh -PYTHONDONTWRITEBYTECODE=1 python3 /tmp/openless-review-core-channel-probe.py -``` - -输出: - -```text -inactive_channel_action=disable; active_channel=active; runtime_releases=["experimental-textstream"] -inactive_channel_action=delete; active_channel=active; runtime_releases=["experimental-textstream"] -``` - -这证明“活动渠道未变化,却执行了同 provider release”,不是完整产品录音验证。 - -**修复建议**:区分渠道配置变更与 runtime 所有权退出。至少仅在被修改渠道持有活动 runtime 时执行释放;更稳妥的是捕获原 owner / generation,在异步刷新之后只释放该旧 lease,避免 A→B→A 快速切换时旧清理误杀新实例。不要仅在 await 前读取活动 provider,await 后无条件释放同类型的当前实例。 - -**现有测试缺口**:`crates/openless-core/tests/local_asr_contract.rs` 覆盖活动渠道的 disable/reorder、无隐式 preload、重启冷启动等;`experimental/acceptance.rs` 也只禁用当前正在测试的渠道。缺少“两个同 provider 渠道,编辑非活动的一个,活动 runtime / session 保持不变”的断言,也缺少延迟 credential status 期间切换回来后的 owner/generation 场景。 - -## R2 — EOF 后 `completed()` 取走 Child,之后 cancel/timeout 不再能杀它 - -**定位**:`openless-all/app/src-tauri/src/asr/local/experimental/textstream.rs:57-64`;关联 EOF 路径 `:366-368`,取消/超时清理 `:440-459`。引入提交:`16a73b9d`。 - -`Process::completed()` 在 `child.wait()` 前用 `.take()` 将唯一 `Child` 从共享 `Mutex>` 中拿走。若 stdout 已关闭但 worker 仍停留在退出/原生析构阶段,reader 线程会在局部 `Child::wait()` 中阻塞;此时 `stop()`、Session 超时清理、关闭实验开关和 `Drop` 看到的都是 `None`,不能再杀掉该仍存活的进程。`running()` 同时错误返回 false,下一轮可尝试启动另一个使用固定 7890 端口的 worker。 - -**触发条件**:worker stdout 到达 EOF,但 worker 进程尚未退出;随后调用 cancel / runtime release,或等到 finish 的 180 秒超时。正常 Python/MLX 退出若很快完成,不会暴露这个问题。本次不声称真实 MLX 已复现退出挂起。 - -**可复核证据**:`/tmp/openless-review-core-process-probe.py` 从 `textstream.rs` 提取原 `Process` struct/impl,只用 std 和 libc 声明编译一个小型 rustc 探针,不经过 Cargo。自有子进程关闭 stdout 后执行 `sleep`,模拟同一退出边界;探针调用原 `completed()` 和 `stop()`,最后明确 SIGKILL 并回收自己的进程组。 - -```sh -PYTHONDONTWRITEBYTECODE=1 python3 /tmp/openless-review-core-process-probe.py -``` - -输出: - -```text -EOF observed; completed owns Child; running()=false; child_alive_after_stop=true -waiter_after_explicit_cleanup=Err("TextStream worker exited: signal: 9 (SIGKILL)") -``` - -**修复建议**:等待退出与取消必须共享仍有效的进程所有权。可由一个专用进程监督任务独占 Child、通过消息接受 kill/wait;或使用短周期 `try_wait`,仅在确认已退出后从共享 slot 移除。不要把 mutex 持有到无界 `wait()` 结束,否则只是把“丢失 kill 句柄”换成 stop 无法获得锁。 - -**现有测试缺口**:`textstream.rs` 已测试 startup cancellation、空闲 writer 唤醒和 generation preflight;`protocol.rs` 与 `test_worker.py` 也覆盖 EOF 不提交 draft。但它们没有测试“EOF 不等于进程退出”,也没有断言 finish timeout / disable / cancel 后 owned PID 确实消失。应新增先发 ready/final、再关闭 stdout 而不退出的假 worker,验证取消与超时都能回收。 - -## R3 — 损坏的 Foundry 缓存删除只移除 receipt,留下大文件 - -**定位**:`openless-all/app/src-tauri/src/asr/local/native_model_receipt.rs:92-106`,完整性判定 `:70-80`;调用方 `foundry_runtime.rs:1131-1134`。引入提交:`16a73b9d`。 - -冷删除 `remove()` 通过 `installed_dir()` 获取删除路径,而 `installed_dir()` 只有 receipt 中每个文件都存在且大小完全一致时才返回目录。成功下载后,只要其中一个文件被截断/移走/清理,或者一次后续替换使 receipt 不再完整,`installed_dir()` 就返回 None;`remove()` 随后跳过目录删除,仍调用 `forget()` 并返回成功。其余数百 MB / GB 缓存因此残留,而且唯一指向缓存目录的 receipt 被删掉,之后无法再通过该冷删除路径找到它。 - -**触发条件**:Windows Foundry 已有 receipt,模型缓存后来不完整;重启后 SDK manager 尚未初始化,调用模型删除。相同问题不要求 SDK 推理发生,也不应靠先启动 SDK 才能清理。 - -**验证边界**:已逐行追踪 `installed_dir → remove → forget` 以及 manager 为 None 的实际调用分支;这是静态确定的文件状态分支。本次未执行 `native_model_receipt` Rust 测试、未运行 Windows SDK,也没有修改用户缓存来复现。 - -**修复建议**:把“可用于加载的完整模型”和“应用可安全删除的受管路径”分开。删除时先读取 receipt 的 model_dir,独立做 canonical / root containment 与 symlink 边界检查,不要求内容完整;只有删除完成或目录确实不存在后才忘记 receipt。receipt 损坏或目录无法安全归属时应返回明确错误,而不是成功且失去追踪。 - -**现有测试缺口**:`native_model_receipt.rs:113-128` 只覆盖完整权重的 record/lookup/remove 和非法 alias。应覆盖两个文件中缺一个、一个文件截断、receipt 存在而目录不存在、拒绝 root/outside 路径,并在成功删除后断言剩余大文件也消失。 - -## 本次执行的验证 - -以下检查均通过,没有使用 Cargo 锁、模型下载或录音: - -- `PYTHONDONTWRITEBYTECODE=1 python3 openless-all/app/src-tauri/src/asr/local/experimental/textstream/test_worker.py -v`:7/7,通过;native 模块全部 fake,SSE 仅临时 loopback server。 -- `node scripts/local-asr-lifecycle-contract.test.mjs`。 -- `node scripts/macos-local-asr-retirement-contract.test.mjs`。 -- `node scripts/macos-audio-usage-description-contract.test.mjs`:只对 `/tmp` plist fixtures 运行检查脚本。 -- `node scripts/macos-optional-runtime-bundle-contract.test.mjs`。 -- `node scripts/macos-runtime-minimum-version-contract.test.mjs`:静态契约确认 14.0。 -- `node scripts/less-computer-opencode-contract.test.mjs`。 -- 上述两个原函数提取探针均编译、执行成功,临时 rustc 源码/可执行文件自动清理,自有子进程也已回收。 - -这些 Node 检查主要是源码/结构契约,不能证明 worker 生命周期、Windows 原生推理、打包/签名或真实 GUI 成功。`git diff --exit-code HEAD -- openless-all/app/crates/openless-core openless-all/app/linux-egui openless-all/app/src-tauri .github` 在核验时为空;工作树里主 Agent 的前端修改未被本次更改或回滚。 - -## 审查覆盖清单 - -“完整”指读取新增文件或相关实现全部;“差异+追踪”指覆盖本范围所有变更块,并读取必要未修改调用方,不表示整个大型文件每一行都重新审计。 - -### Core(差异+追踪;实验 service/tests 完整) - -路径前缀 `openless-all/app/crates/openless-core/`: - -- `src/api.rs`:Beta admission / opt-out cleanup、本地渠道 mutation、ASR activation/release/settings 事务;追踪活动所有权与 credential await。 -- `src/experimental_asr.rs`、`src/experimental_asr/tests.rs`:下载、取消、独立 worker 锁、开关 generation、重启恢复、install、删除、channel 注册与 lease。 -- `src/experimental-gemma-manifest.json`、`src/experimental-textstream-manifest.json`:读取 manifest 入口与 shape/哈希校验路径;未联网重验全部上游对象内容。许可证文件为新增数据,不作法律审查。 -- `src/local_asr_service.rs`、`src/model_store.rs`、`src/local_asr_catalog.rs`:本地 activation、prepared/installed 边界、文件下载取消、迁移、旧模型清理、storage relocation、native adapter 分流。 -- `src/coding_agent.rs`、`src/less_computer.rs`:Beta gate、settings admission 锁序、run/capture/test ownership、取消与 cleanup;没有执行外部 Coding Agent CLI。 -- `src/credentials.rs`、`src/provider_rules.rs`、`src/provider_service.rs`:retired channel 投影、experimental channel 注册、本地验证 probe 不启动推理、provider defaults。 -- `src/settings.rs`、`src/shared_types.rs`、`src/domains.rs`、`src/dictation_context.rs`、`src/lib.rs`:新开关默认值与反序列化、Host 能力、接口及 TextStream batch preference 例外;未扩展审查首录音链路。 -- `src/cloud_sync_e2ee_documents/{registry.rs,tests.rs}`、`src/cloud_sync_e2ee_store/{native.rs,tests.rs}`:device-local opt-in 排除同步及 rollback 状态保留。 -- `examples/export_provider_descriptors.rs`。 -- `tests/{local_asr_contract.rs,macos_asr_retirement.rs,auxiliary_voice_lifecycle.rs}`:已有生命周期断言及上述缺口;Rust 测试没有运行。 - -### Tauri / native(差异+追踪;新增运行时/installer/protocol 完整) - -路径前缀 `openless-all/app/src-tauri/`: - -- `src/asr/local/experimental.rs`、`experimental/{gemma.rs,gemma.swift,textstream.rs,acceptance.rs}`。 -- `src/asr/local/experimental/textstream/{install.rs,install-guardian.sh,protocol.rs,worker.py,test_worker.py}`;`requirements.lock` 仅核对被 `--require-hashes` / `--only-binary` 使用的边界,未联网审计全部 wheels。 -- `src/asr/local/native_model_receipt.rs`、`src/asr/local/foundry_runtime.rs`、`src/core_adapters.rs`:Foundry download/restart、receipt inspection、冷删除、runtime release 与实验 service 绑定。 -- `src/asr/local/{mod.rs,models.rs,test_run.rs}`;旧 native 文件删除及其 router/build 引用清理:`apple_speech_provider.rs`、`cache.rs`、`local_provider.rs`、`mlx_qwen_engine.rs`、`mlx_set_metallib_path.cpp`、`mlx_worker.rs`、`qwen_engine.rs`、`qwen_ffi.rs`、`whisper_provider.rs`;未重新审计被删除代码内部全部算法。 -- `src/commands/{credentials.rs,experimental_asr.rs,foundry_asr.rs,mod.rs,qa.rs}`:host/窗口边界、provider retirement、IPC 注册与 file-only 状态。 -- `src/{lib.rs,tauri_coordinator_host.rs,tauri_events.rs,hotkey.rs}`、`src/coordinator/hotkey_loops.rs`、`src/persistence/credentials.rs`:window gate/main-thread 调度、隐藏、AppKit 系统事件转换、权限与设备级迁移;未触发系统权限弹窗。 -- `backend-tests/tests/{native_model_receipt.rs,textstream_protocol.rs}`:测试文件挂接检查。 -- `Cargo.toml`、`Cargo.lock`、`build.rs`、`Info.plist`、`tauri.conf.json`、`tauri.macos.conf.json`、删除的 `tauri.macos-mlx.conf.json` 与 vendor gitlinks。 - -### egui / Linux(全部变更块+必要调用方;不运行原生窗口) - -路径前缀 `openless-all/app/linux-egui/`: - -- `src/main.rs`:QA popup generation、queued event 隔离、状态 delta、失败投递、窗口重开/关闭、选区预览与 QA 共用窗口、tray 生命周期;逐项对照新增回归测试。 -- `src/{backend.rs,i18n.rs,lib.rs,popup.rs,settings.rs}`、`src/linux_app/settings_save.rs`。 -- `src/ui/frontend/{mod.rs,settings.rs,siri_gl.rs,view_model.rs}`:Beta 显隐/撤销、快捷键显示、CPU glow 非有限值修复及 tessellation 测试。 -- `examples/headless_host.rs`、`tests/host_contract.rs`。 - -本轮未找到可确认由这些变更造成的 QA/选区“无输出”回归;不能将这一结果解释成已完成真实选区捕获、fcitx5、X11/Wayland 或付费多模态服务验收。Less Computer popup 未在 supervisor polling 列表中是基线已存在现象,未将它作为本分支新问题报告。 - -### 构建 / 合同 / 脚本 - -- `.gitmodules`、`.github/actions/cache-macos-mlx/action.yml` 删除;`.github/workflows/{android-apk.yml,ci.yml,release-tauri.yml}` 差异。 -- `openless-all/app/contract/backend-2.0.json`。 -- `openless-all/app/scripts/build-mac.sh`、`check-macos-audio-usage-description.sh`、`macos-dmg-layout.py`、`sync-egui-i18n.mjs`、`measure-startup.mjs`;后者仅为 browser benchmark,未当作 native 启动性能证据。 -- 新增/变更的 `desktop-version-contract.test.mjs`、`local-asr-lifecycle-contract.test.mjs`、`macos-local-asr-retirement-contract.test.mjs`、`macos-audio-usage-description-contract.test.mjs`、`macos-optional-runtime-bundle-contract.test.mjs`、`macos-runtime-minimum-version-contract.test.mjs`、`macos-build-cache.test.mjs`、`shared-backend-wire-contract.test.mjs`、`less-computer-opencode-contract.test.mjs`、`encrypted-sync-ui-bridge.test.mjs`、`frontend-test-runner.test.mjs` 的本次变更;删除的 macOS MLX staging/toolchain/cache 脚本及其引用。 -- 对照 `docs/local-asr-lifecycle.md`、`docs/experimental-local-asr.md`、`docs/local-asr-verification.md` 的边界说明。Foundry 老安装无 receipt 必须一次显式 Download/verify + 重启是已有明确设计,未将该行为本身误报为 bug。 - -## 未验证与交接边界 - -前端视觉/交互和首录音链路由其他审查负责;本轮仅为论证上面的生命周期结论读取了少量相关前端/录音适配调用。未运行 Cargo 测试或构建、Windows/Linux 原生程序、真实实验模型、安装 Python/wheels、实际选区/麦克风/Accessibility 操作、DMG/签名/公证与任何收费服务;历史文档中“绿 CI/真实模型验收”没有被复述为本次独立执行结果。主 Agent 若修复,建议先补上述 owner / EOF-live-child / corrupt-receipt 三组小测试,再按对应平台做必要验证,不需要为本报告重新完整构建全部平台。 diff --git a/docs/reviews/2026-10-15/ui.md b/docs/reviews/2026-10-15/ui.md deleted file mode 100644 index 8fc2fb5e5..000000000 --- a/docs/reviews/2026-10-15/ui.md +++ /dev/null @@ -1,129 +0,0 @@ -# PR #1197 UI 只读审查 - -## 结论与审查边界 - -原始 HEAD `0947c05a5b91` 存在能解释用户反馈的具体实现问题:普通页面的 exit 被删掉,Settings 只淡出内层却保留不透明外层,新的全窗 Settings 覆盖原生拖动条,实验 ASR 的每秒轮询会触发旧 SelectLite 抢回选中项,ShortcutRecorder 直接替换内容造成高度跳变。主 Agent 当前 worktree 已处理这些主路径的大部分;本报告末尾另列追加检查发现的裸 Modal 与交付前复核状态,不能把原始分支问题当成尚未修复的当前状态。 - -审查基线为 `0454dc56..0947c05a`,覆盖 `openless-all/app/src` 与 `e2e` 的变更清单、交互实现和新增测试;着重检查 UI 提交 `8c32db9e`、实验 ASR 提交 `16a73b9d` 及后续测试修正。行号未注明“当前 worktree”的均指固定 HEAD,可用 `git show 0947c05a:` 核实。仓库一开始就有主 Agent 的未提交修复,因此动态验证使用 `git archive 0947c05a` 导出的临时副本,没有改仓库源码、设置、凭据或安装依赖。 - -验证使用已安装 Playwright 的 headless Chromium/WebKit、1300×835、zh-CN、macOS UA;部分场景显式将临时浏览器的界面大小设为 100%。Vite 仅按需转换源文件,没有完整构建或运行全套 e2e;浏览器请求限制在本机临时服务器,`isTauri=false`,没有实际录音、麦克风捕获、OAuth、云同步或收费 API。新增 PNG 是静态视觉参考,不能证明动态过渡和原生标题栏行为。 - -## 可核实问题与最小修复边界 - -### 1. P2:普通页退出没有淡出,直接拆掉旧节点 - -- **位置**:`openless-all/app/src/lib/motion.ts:246-253`;调用端 `src/components/FloatingShell.tsx:166,533-534`。 -- **触发**:鼠标从 History 切回 Overview,或切换其他主导航页。 -- **原因**:`usePageTransition` 直接 `return requested`,只调用 `useContentMotion`;外层又是 `key={displayTab}`,旧页在本次 React commit 立即卸载,不存在旧内容的 exit 阶段。 -- **引入提交**:`8c32db9e` 明确删去基线的 displayed 状态和 PAGE_EXIT_MS 流程。 -- **已验证**:两个浏览器从 History→Overview 均只创建新页的 `[opacity:0, translateY(6px)]→[opacity:1, none]`、240ms 动画,没有旧页 exit;没有 pageerror。 -- **最小建议**:在共享 hook 保留 outgoing displayed,先对旧页做短 opacity exit,再切最新请求页并 opacity enter;用代次/取消处理快速 A→B→A,保留 reduced-motion 分支,不需要引入路由库或重写全部页面。当前 worktree 已恢复这种 hook,但本审查未对新实现重新跑浏览器。 - -### 2. P2:Settings“关闭”看似没淡出,是外层不透明遮挡 - -- **位置**:`src/components/SettingsModal.tsx:94-95,332-344`。 -- **触发**:Settings 已打开,用返回或右上角关闭退出。 -- **原因**:`overlayRef` 的 `useOverlayMotion(..., enabled=false)` 被关闭,但 workspace 被改成全窗不透明 `background:var(--ol-surface)`;只有 inner surface 在淡出。底下主页面一直被外层实色挡住,180ms 到时整层卸载才突然露出。 -- **引入提交**:`8c32db9e`。 -- **已验证**:Chromium/WebKit 中 inner opacity 已降到约 0.001,outer 仍是 `opacity:1`、`rgb(28,28,31)`;随后 outer/inner 同时消失。这是合成结果问题,不是没有调用 exit,也不是需延长退出时间。 -- **最小建议**:只对整个不透明 workspace 做 opacity enter/exit,消除内外层重复动画;继续复用 `useExitMount` 维持生命周期。当前 worktree 已改为整体淡出。 - -### 3. P2:Settings 顶部覆盖拖动条;重复关闭入口确实存在,但自绘红绿灯归因不对 - -- **位置**:`src/components/SettingsModal.tsx:332-344,411-428,601-612`;`src/components/WindowChrome.tsx:67-77`。 -- **触发**:macOS 主窗口打开 Settings 后拖顶部空白;观察 sidebar“返回”和右上角“关闭”。 -- **原因**:全窗 Settings 的 z-index 50 覆盖同为 50、但先绘制的 WindowChrome drag region;sidebar 顶部 padding 仅留出视觉位置,并未赋予拖动能力。新增 sidebar 返回与原右上角关闭都调用 `closeSettings`,属于重复出口。 -- **引入提交**:覆盖与重复入口由 `8c32db9e` 的全窗设置/新增返回引入;WindowChrome 旧的 z-index 本身早已存在。 -- **已验证**:两个浏览器 `elementFromPoint(150,12)` 在打开前命中 drag region,打开后命中 Settings `ASIDE`,`closest('[data-tauri-drag-region]')` 从 true 变 false。 -- **最小建议**:保留 WindowChrome/原生标题栏,将专用拖动条置于全窗设置之上并按 zoom 换算固定原生高度,避开搜索/按钮;桌面保留一个 Settings 返回出口,子页返回不是关闭设置,应单独保留。当前 worktree 已提高拖动条并删掉右上角重复关闭。 -- **重要纠正**:HEAD 的 SettingsModal/WindowChrome **没有自绘三色灯**,其顶部也没有三色灯 DOM。自绘灯在 `src/pages/LessComputerPanel.tsx:598-626`、`src/pages/less-computer-panel.css:227-262`,源于基线前的 `51f6f4cf`,不应报成 `8c32db9e` 新增的 Settings 红绿灯。替换 LessComputer 时应仅收口该窗口的 native decorations/CloseRequested→既有 dismiss 路径,不能只删前端灯造成无关闭入口;这部分 native 实际行为未验证。 - -### 4. P2:长菜单被强行滚回顶部,真正问题在旧 SelectLite 的高亮初始化 - -- **位置**:`src/components/ui/SelectLite.tsx:184-202,209-217`;`src/pages/settings/MicrophoneSelect.tsx:65-79,100-119`;新放大因素为 `src/pages/settings/ExperimentalAsrSection.tsx:33-58` 和 `src/components/SettingsModal.tsx:76`。 -- **触发**:打开超过视口高度的列表,把鼠标移到较下方选项并继续浏览;父组件有 ASR 状态轮询或麦克风电平更新。 -- **原因**:`filteredOptions` 随 options identity 更新,effect 每次都把 highlight 改回 selectedIndex;另一个 effect 又把 highlight 对应项滚进视口。麦克风电平每 120ms 改 level 并重新生成 options。分支又把无条件每秒 `setModels(next)` 的实验 ASR hook 放在整个 SettingsModal 中,即使实验未打开也会触发子树 render,扩大旧故障范围。 -- **引入提交**:高亮重置的原始代码为基线前 `66915405`;电平刷新也是旧实现,**不是新 Base UI Select 的 bug**。全 Settings 的每秒刷新由 `16a73b9d` 新增。 -- **已验证**:临时 fixture 使用原始真实组件、50 个选项、真实 `useExperimentalAsr(true)` mock 轮询。两个浏览器 Legacy Select 均从 scrollTop 700 在约一秒内回到 4;相同 owner/相同重建 options 下的新 Base UI Select 稳定保持 696。WebKit 的 mock 麦克风列表从 700 下一次 120ms 采样变 4;Chromium 在首采样前就已经回到 4。三种场景都没有 pageerror。 -- **最小建议**:普通 selects 复用已有 `@base-ui/react/select` 包装;需要搜索的复用同依赖的 Combobox,补齐现有 disabled/trailing/alwaysVisible/onOpenChange 契约即可。不要再造 wheel 拦截或二次惯性滚动;至少必须把“打开/筛选/值改变初始化”与“选项内容刷新”分离,不要在 hover 或电平更新时 scrollIntoView。当前 worktree 已用 Select/Combobox facade 统一,方向与上述对照验证一致;新 facade 的全部调用契约未逐一动态验证。 - -### 5. P2:100% 返回录音页的“抽搐”有两个机制,不应笼统称为 zoom 错误 - -- **位置**:`src/components/SettingsModal.tsx:121,206-208,731-743`;`src/lib/motion.ts:165-192`;`src/pages/settings/RecordingInputSection.tsx:57-64,150-163`;`src/pages/settings/tabs.tsx:54-74`。 -- **触发**:100% 界面大小,Settings 从其他分类返回“录音与输入”,尤其返回时原滚动位置较深或 native capabilities 返回较慢。 -- **机制**:分类 `key` 使表单重新挂载;caps 每次从 null 起步,桌面热键/启动/远程输入部分要等 effect 补挂;scrollTop 在 passive effect 才归零;整个新内容又先归零透明度再沿 6px 平移,产生分数字素重采样。100% 时 zoom=1,仍会经历上述过程。 -- **归因边界**:caps 从 null、passive scroll reset 和 settings 的 useContentMotion 在基线已有(例如 `1ea467a0`、`30e580fc`、`6f9af895`);本分支加入新的内容/新快捷键结构,但没有证据能把用户机器的全部重复闪烁单独归因给本次 fontScale 改动。 -- **已验证**:两浏览器 100% 返回页会重建一个 240ms opacity/translateY 动画,采样到 `translateY(4.29px)` 等分数位移;采样开始后页高分别稳定在 1426/1418,没有复现持续多次抖动或异常。因此这里只确认跳入机制,**未宣称已复现真机 WKWebView 的全部闪烁**。 -- **最小建议**:初始化复用 `getCachedPlatformCapabilities`,scroll reset 放 layout effect,页面内容只做 opacity 过渡;不要用调整默认缩放来掩盖。当前 worktree 已做这些主路径修复。native 首帧、macOS 系统字体栅格化及真机100%往返仍需主 Agent 的运行验收。 - -### 6. P2:快捷键出现重复录制入口,提示/错误内容直接顶开布局 - -- **位置**:`src/components/ShortcutRecorder.tsx:343-365,386-410,429-433`;`src/styles/refinements.css:261-272`。 -- **触发**:点击 Fn 等按键显示进入录制,或展开箭头菜单;录制失败出现错误文案。 -- **原因**:按键按钮和展开后的“录制快捷键”都绑定 `startRecording`;录制提示使用 `recording ? ... : ...` 直接替换整个 idle 区域,并没有用新增 Collapse 包住。error 也是裸条件内容。 -- **引入提交**:`8c32db9e` 把只显示的按键改成可点击,同时保留菜单同一动作,并移除了旧 AnimatePresence 过渡。 -- **已验证**:原始 HEAD 两浏览器的单个 recorder 高度均从 38px 直接变为 59px;内容是“请按下快捷键组合… / Esc · 取消”。这是快捷键捕获的 mock,不是音频录制。 -- **最小建议**:保留按键本体为单一录制入口,reset/disable/native dictation key 是独立动作;仅为提示/错误加稳定容器和高度插值/复用 Base Collapsible,保留现有 Fn 原生事件、左右修饰键、校验与保存流程,不重写热键协议。`Fn` 和 `MacDictationKey` 是不同 binding,不能把原生听写键选项当作同义动作删除。 -- **当前 worktree 余项**:展开箭头/重复录制已删;但 `src/components/ShortcutRecorder.tsx:302-316` 仍直接条件替换录制区,错误也仍直接条件挂载。未发现其外层已有高度插值,建议作为剩余“提示平滑撑开”复核点;当前新布局未重新测高度。 - -### 7. P2:实验下载生命周期与窗口生命周期混在一起,0% 取消要区分前后端 - -- **位置**:`src/components/SettingsModal.tsx:76,764-770`;`src/pages/settings/ExperimentalAsrSection.tsx:20-59,60-76,155-174,208-222`;`src/components/GlobalDownloadProgress.tsx:33,51-153`。 -- **触发**:实验模型下载尚未有首字节、关闭 Settings、再打开或想取消。 -- **原始实现**:controller 只由 SettingsModal 持有,关闭后轮询和可见控制一同停止;GlobalDownloadProgress 只支持 qwen3/sherpa/foundry,不支持三个 experimental provider。因此下载继续时主窗口没有统一进度和取消入口,恢复后只能等下一次 status 读。下载进度/错误段落裸条件插入,状态和 Toggle 放进 `SettingRow` 默认 `justifyContent:flex-start` 的同一行,状态不是稳定靠右。 -- **引入提交**:`16a73b9d` 新增实验控制器/页面,却没有接入已有 global download surface。 -- **0% 的证据边界**:`downloadPercent` 会 floor,收到少量字节也可能显示 0%。前端 `toggle(false)` 并不检查 downloadedBytes/percent,也没有把 downloading 状态永久禁用;只有 pending/removing 等状态禁用。不能仅凭此 UI 代码断言 native 0% 请求“不允许取消”,或保证禁用已终止下载/释放进程;这些属于主 Agent 的后端审核。 -- **最小建议**:一个共享 controller/store 同时供 Settings 与全局下载区域读取;取消复用 `setExperimentalAsrEnabled(model,false)`,区分等待首字节与已开始的确定进度,显示 pending/error,不把“关闭设置”隐式等同于取消。复用现有全局区域即可,不新增独立下载框架。状态组靠右、Toggle 最右,进度保留固定槽,错误复用 Collapse。 -- **当前 worktree**:已看到 `src/lib/useExperimentalAsr.ts` 的共享 useSyncExternalStore、去重更新与 generation 清理;GlobalDownloadProgress 与 Settings 已共同使用,状态右对齐、首字节 indeterminate、错误 Collapse 亦已落地。只做静态复核,未验证真实模型下载/取消、应用重启、native worker 释放或取消残留。 - -### 8. 要求差距而非新回归:默认 110% 与可用弹层右优先 - -- **位置**:`src/lib/fontScale.ts:15-28`;`src/components/Tooltip.tsx:120`;`src/components/ui/Select.tsx:30-35`。 -- **原始行为**:没保存设置的 Windows 用 large/110%,macOS 用 medium/100%;`Hint` 显式强制 `placement="top"`,覆盖 Tooltip 本身默认 right;新 Base Select 未设 side,因此也不是右优先。 -- **归因**:字体默认早于范围(Windows 分支 `9e62dfe2`、medium fallback `48f21341`);Hint 的 top 和新 Select 为 `8c32db9e`,且原 e2e 故意验证 top。这两项应按用户新要求修改,不应包装成比例计算回归。 -- **最小建议**:仅无效/不存在存储值时默认 large,尊重显式90/100%;Hint/Select/Combobox 用 Base UI Positioner `side="right"`,继续依赖 collision handling 在无空间时翻转,保留 portal 的 zoom compensation。当前 worktree 已做。 - -## 补充:所有主要退出生命周期调用点(协调者追加要求) - -共享 `Modal` 的 closing 默认 false,只画 exit 并不会替调用者延迟卸载。以下缺口已通过协调消息发送,主 Agent在本报告写作期间继续修复;表中同时记录原始证据和交付前的最新静态状态。 - -| 原始调用点(HEAD) | 触发/实际卸载原因 | 引入归因 | 交付前 worktree 复核 | -|---|---|---|---| -| `src/pages/settings/ExperimentalAsrSection.tsx:242,257` | acknowledge 后 ready=[],直接 return null | 新增 `16a73b9d` | 已改 `:170-171` 为无条件 PresenceModal + open=ready.length>0,保留最后 render closure;未动态复测。 | -| `src/components/CloudSyncSetupPrompt.tsx:114-116` | 用户点稍后/背景/打开设置后 open=false,直接 return null | 基线前 `9731a7f0` | 已改 `:116` 为 PresenceModal。原注释的 Core eligibility revoke 即时撤销语义应保留(动画中不可交互),未验证 native revoke。 | -| `src/pages/settings/CloudSyncSection.tsx:873-879,1435` | `dialog && `,closeDialog 置空;换 kind 也重建 | 基线前 `9731a7f0`/`62e09f1f` | 已在当前 `:878` 用 ExitPresence 保留 SyncDialog,`:1442` 透传 closing;`:865` 的 Github 登录亦有 ExitPresence。业务 busy/恢复流程没有动态验证。 | -| `src/pages/Marketplace.tsx:1600-1601` | showLogin=false 立即卸载 GithubLoginModal,未传 closing | 基线前 `52c49fcf`/`4519f19c` | 已在相同调用点改 ExitPresence 并传 closing;不重挂 OAuth flow 的运行行为仍需验证。 | -| `src/pages/settings/MarketplaceSection.tsx:126-128` | 同上 | 基线前 `52c49fcf` | 当前 `:127` 已 ExitPresence + closing。 | -| `src/pages/LessComputerPanel.tsx:235,796-799` | login.mounted 保留,传 login.closing | 已有实现 | **原本已覆盖**;不要把 Github 所有调用点一概列成缺陷。 | -| `src/components/CloudSyncSetupPrompt.tsx:198` 的 CloudSyncWelcome、`src/components/AutoUpdate.tsx:340`、`src/pages/Marketplace.tsx:923,1125,1277` | PresenceModal 缓存退出内容 | 已有实现 | **原本已覆盖**。 | -| Settings、MobileMoreSheet/MobileStyleSheet、Vocab ModalShell | useExitMount + closing | 已有实现/本轮修复 | 有生命周期,应检查真正合成/内容保留,不是再包一层动画组件。 | -| `src/pages/settings/ChannelList.tsx:1191` 的无 host fallback Modal | ChannelDialog 原主路径在 Settings 使用 right-pane host;fallback 为裸 Modal | 基线前 | Settings 当前主路径非此 fallback,不作为本轮主修边界;若独立复用 fallback,需要与 caller 一起保留 closing。 | - -另一个已反馈遗漏:原始 `ExperimentalAsrGuidance` 没有 Escape handler,共享 Modal 原来只处理 Tab,Settings 又对内层 dialog 提前返回,因此 Guide 原先不支持 Escape 关闭。**交付前复核已看到** `src/components/ui/Modal.tsx:66-73` 新增 top-most、defaultPrevented、isComposing、inert 判断并通过 closeRef 调 onClose,此静态缺口已补;子 Select/Combobox 消费 Escape 后不能同时关闭 Guide/Settings 的行为未动态复测。当前 ExitPresence/PresenceModal 只做保留最后 render closure 和透传 closing,改动边界较小。 - -## e2e 能证明什么、缺什么 - -- `e2e/motion.spec.ts:140-181` 验证的是 inner Settings surface 存在 exit、起始 opacity 连续,未检查 opaque workspace 是否跟着淡出,所以能漏掉问题2。`:126-140` 的快速返回只检查最终可见/inert,而且按键 Enter 使原始 interaction mode 跳过动画,不能证明鼠标导航有完整退出。 -- `e2e/settings-feedback.spec.ts:99-135` 只打开 7 个字体选项,验证键盘 End/Enter 与锚点,没有长列表 wheel + 高频 options refresh。新增回归应使用50项,先 hover下部并滚动,跨越120ms电平或1000ms轮询,确认 scrollTop 不被 selectedItem 抢回。 -- `e2e/macos-experimental-asr.spec.ts:94-111` 的关闭再打开测试验证 mock 状态/指导恢复,但 mock 仅在 `getExperimentalAsrStatus` 中按 Date.now 推导进度;它不能证明 native 下载在关闭期间继续、0字节可取消或 worker 退出。 -- `e2e/refinements.spec.ts:266-285` 只验证 ExpandableText 最终高度,没有检查 recorder 条件切换或提示关闭时每帧高度,不能证明“平滑撑开”。 -- 原测试中有按旧行为刻意写死的断言:`motion.spec.ts:118-119` 要求 Settings y>16/scale<.97;`settings-feedback.spec.ts:53-55,132-134` 要求提示在上方;多文件桌面设置按名称“关闭”退出。用户已要求纯淡出/右优先/单一返回入口,修复后应同步这些 oracle,不应为了绿测恢复旧交互。 -- `0947c05a` 自然观察入口完成、`edff8913` 跨平台 UI oracle 修正是测试可靠性改进;它们没有解决以上生命周期/父层遮挡问题。 - -## 动态证据摘要与未验证项 - -独立固定 HEAD 临时浏览器结果: - -| 探针 | Chromium | WebKit | -|---|---|---| -| Settings 顶部命中 | drag=true → false/ASIDE | 相同 | -| Settings 关闭 | inner≈0.001,outer=1且不透明,然后一起卸载 | 相同 | -| 100% 分类返回 | 240ms 透明+6px平移;观察后页高1426稳定 | 同类动画;页高1418稳定 | -| Shortcut idle→recording | 38px→59px,无高度过渡 | 相同 | -| 主导航 History→Overview | 只有 enter,无 outgoing exit | 相同 | -| 50项旧 SelectLite + 实验轮询 | 700→4 | 700→4 | -| 50项 Base UI Select + 同样轮询 | 696保持不变 | 696保持不变 | -| 50项 mock 麦克风 | 首采样已回4 | 700→4,下个120ms采样发生 | -| pageerror | 无 | 无 | - -当前 worktree 的修复只做静态比对;没有把旧 HEAD 的探针结果冒充成修复后验证结果。未验证:Tauri 真机拖动/三色灯位置和 CloseRequested 行为、100%/110%系统字体反复栅格化、真实实验模型首字节等待/取消与子进程回收、OAuth 和云同步完整生命周期、所有搜索 Select 契约以及新 PNG 基线。临时服务器、快照和探针文件已清理;可复现方法是将 HEAD 导出到独立临时目录,用既有 Vite/Playwright 启动 mock 浏览器,记录 Element.animate 的目标/关键帧、elementFromPoint 和 listbox.scrollTop;复核不依赖任何真实服务。 diff --git a/docs/reviews/2026-10-15/voice.md b/docs/reviews/2026-10-15/voice.md deleted file mode 100644 index 8542e5740..000000000 --- a/docs/reviews/2026-10-15/voice.md +++ /dev/null @@ -1,92 +0,0 @@ -# macOS 语音链路只读审查 - -审查仓库:`/Users/trip/orca/workspaces/openless/修复-Matel存在的部分问题`。原始审查 HEAD:`0947c05a5b911aebce7a750c3045160859428814`;PR 范围 `0454dc56..0947c05a`。以下原始问题行号以该 HEAD 为准;末尾另评主 Agent 尚未提交的修复。时间均明确标注 UTC 或 +0800。 - -## 结论 - -**P1:已定位与本机配置及最新日志吻合的首录音永久 Processing 根因。** 显式指定麦克风名称时,CPAL 0.15.3 的 macOS disconnect listener 形成强引用环,音频回调继续持有队列 sender;OpenLess 新增音频处理 worker 却依赖所有 sender 析构才退出,停止录音会永久卡在 join。此缺陷由 `44972015`(#1194)引入,已经存在于审查基线,不能归入本次 UI/实验 ASR 的新增回归。 - -另确认两个独立缺陷:Qwen/Stepfun Realtime 启动错误清理自锁(历史问题),以及 TextStream 等待进程退出时丢失可取消句柄(`16a73b9d` 新增)。当前本机偏好使用 DashScope 多模态,实验模型全部关闭,后两者不解释最新故障窗口。 - -## 1. P1:指定麦克风的回调 sender 被 CPAL 引用环保留,停止时永远等待 EOF - -**主定位**:`openless-all/app/src-tauri/src/recorder.rs:1515`、`:1871`、`:1906`。 - -**触发条件**:macOS,选择一个非空名称的麦克风(即使名称恰好是当前系统默认内建麦克风),开始并停止普通听写、设置页电平预览或其他共用 Recorder 的会话。无需 ASR 服务报错,分步与多模态均受影响。 - -可复核因果链: - -1. `recorder.rs:1676-1682` 按名称从 `host.input_devices()` 返回设备。锁定依赖为 `openless-all/app/src-tauri/Cargo.lock:1065-1066` 的 CPAL 0.15.3。 -2. 本机 CPAL 源码 `~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/cpal-0.15.3/src/host/coreaudio/macos/enumerate.rs:84-86` 将枚举设备标记为 `is_default=false`;只有 `default_input_device` 创建的 handle 标记为 true(同文件 `:114-116`)。 -3. 该依赖 `src/host/coreaudio/macos/mod.rs:616-617` 为非 default handle 安装 disconnect listener;`:456-468` 的 listener closure 捕获 `stream.clone()`,而 StreamInner 又拥有 listener,形成 `StreamInner -> listener -> Stream(Arc)` 强引用环。 -4. OpenLess 回调在 `recorder.rs:1780-1789` 捕获 `frame_tx`。`drop(stream)` 不能解开依赖的环,因此回调及该 sender 并未析构。`:1512` 的 `cpal Stream dropped (mic released)` 只说明外部 stream handle 被 drop,不能证明所有回调资源或处理线程已结束。 -5. `:1513-1514` 只释放 OpenLess 自己的 sender;worker 在 `:1871` 等待 `receiver.recv()` 返回 disconnected,`:1904-1906` 无退出指令地 join,永远无法返回。 -6. `core_adapters.rs:2502-2514` 的停止任务因此无法返回;`crates/openless-core/src/cloud_providers.rs:2191-2198` 的 Omni finish 要先等待录音 stop,后面才调用多模态请求。用户能看到 Processing,但请求尚未发出。 - -**无电平的相关扩散路径**:`src-tauri/src/commands/misc.rs:199-205` 停止预览时持有 `MicrophoneMonitorState.operation` 并同步执行 `recorder.stop()`;卡住后再次预览和普通听写都会等这把锁。`src-tauri/src/core_adapters.rs:2648-2653` 在异步任务中同步取得该锁,或直接 `preview.stop()`,此时新 Recorder 尚未启动,因而可能表现为无电平并占住 Tokio worker。该路径是源码确认的后续影响;未通过真人操作确认用户具体经历过哪次预览交接。 - -**引入记录**:`git blame` 将 `processor.finish()`、worker 的 `recv`/`join` 指向 `44972015`,提交时间 `2026-10-14 17:51:39 +0800`,`feat(audio): add lightweight microphone noise suppression (#1194)`。`git merge-base --is-ancestor 44972015 0454dc56` 成功;基线提交时间是 17:51:53,故它在基线前。按名称选设备代码更早来自 `ec773f08`(#306);引用环本身位于锁定第三方依赖。 - -**修复建议**:worker 应拥有显式、FIFO 的 Shutdown 结束协议,在停止接收原生帧后排空已入队帧并退出,不能把 native callback 析构当作 worker 结束条件。覆盖“仍保留一份 native sender、队列已有 PCM 时,stop 有界完成且已入队帧不丢”的纯内存回归;同时覆盖设置预览停止再开始听写。不要靠丢弃 join 或提前释放 voice gate 绕过清理。 - -**验证**:已读锁定 CPAL 实际源码,并用标准库 Rust 2021 探针模拟相同的 Arc/closure/sender 所有权结构。drop 外部 Stream 和 sender 后 worker 100ms 内仍等待;断开模型中的引用环后立即结束。另探针确认保留 native sender 时 FIFO Shutdown 仍能排空帧并退出。未实际打开麦克风或调用 CoreAudio;因此是源码、隔离复现和本机条件吻合的高可信根因,非真实设备端到端实测结论。 - -## 2. P1(历史):Qwen/Stepfun Realtime 启动失败时重入同一非重入锁 - -**定位**:`crates/openless-core/src/asr/qwen_realtime.rs:307-308`,其 `cancel()` 再锁于 `:375`;同类代码 `asr/stepfun_realtime.rs:375-376`,再锁于 `:530`。这些路径均位于 `openless-all/app/` 下。 - -**触发条件**:对应 provider 在 `session.updated` 前收到服务端 error/连接关闭等启动失败。`if let Some(error) = self.state.lock().session_start_error.clone()` 的临时 guard 在成功匹配分支仍存活;分支中 `self.cancel()` 再次锁定 state,自锁。网络超时不能解除一个同步 Mutex 阻塞。稳定转写在停止后启动 ASR,因此可表现为永久 Processing;非稳定模式卡 Starting,stop 等待启动完成。 - -**引入记录**:Qwen 首次为 `9ed34dab`(2026-07-12,#775);Stepfun 为 `17fe67c5`(2026-07-18,#835)。二者均早于本审查基线,当前本机多模态路径不经过它们。 - -**修复建议**:先在独立语句/作用域复制 `session_start_error`,释放 guard 后再判断并 cancel;用假 WebSocket/无网络状态机回归覆盖启动前错误和关闭,确认返回错误、释放会话而非挂住。 - -**验证**:无依赖 Rust 2021 探针在 if-let 分支中 `try_lock` 得到 WouldBlock,独立 snapshot 后能够取得锁;与仓库 `edition=2021` 一致。未运行真实 provider 请求。最新日志无该 provider 的直接命中证据,不能当作本次用户故障原因。 - -## 3. P2(本分支新增):TextStream 完成等待取走 Child,超时/取消无法终止仍存活进程 - -**定位**:`openless-all/app/src-tauri/src/asr/local/experimental/textstream.rs:57-64`,调用于 `:367-368`,取消路径 `:45-55`、`:443-455`。 - -**触发条件**:worker stdout EOF,但 worker 进程尚未退出(例如原生库或解释器退出阶段仍运行)。`completed()` 先从 `Mutex>` take 句柄,再在局部变量中 `wait()`;并发 `stop()` 见 None 即返回。180 秒 finalization timeout、Esc、切换/关闭渠道均可能报告清理结束,而本地进程仍存活;`running()` 也会误认无进程。不是当前全部关闭的实验模型所致。 - -**引入记录**:`16a73b9d`(2026-10-15,实验 ASR 提交)首次新增,属于 `0454dc56..HEAD`。 - -**修复建议**:在完成等待期间保留统一可终止的进程所有权,例如短锁 `try_wait` 轮询,由 stop 独占取走并 kill/wait;不要在持全局 mutex 的阻塞 wait 中等待退出,也不要先取走 Child 使取消失去目标。测试 EOF-before-exit 与取消/超时并发,断言子进程已真正被回收。 - -**验证**:从仓库提取原始 `Process` 实现放入临时独立 Rust 探针,以仅关闭 stdout 后等待的 shell 子进程模拟 worker。已验证 completed 取走句柄后 stop 返回但 child 仍存活;探针自行 kill 并 reap 其唯一测试进程。未运行 Python 模型、SSE 服务、下载或真实推理。 - -## 本机证据与排除项 - -协调 Agent 追加授权后,只对白名单非秘密偏好字段做只读检查,未读 Keychain。`preferences.json` mtime 为 `2026-10-15 20:25:53 +0800`:`pipelineMode=multimodal`、`multimodalPipelineEnabled=true`、`activeOmniProvider=dashscope-omni`、`stableTranscriptionEnabled=false`、`microphoneDeviceName=MacBook Air麦克风`、Fn toggle、`lowLatencyDictationEnabled=false`、`audioEnhancementEnabled=true`、静音自动停止和录音静音均关闭。`experimental-asr.json` 的三个 enabled 均 false。偏好快照不是每一历史会话的完整运行态证明,但其修改时间先于以下最新问题窗口。 - -只读检查 `/Users/trip/Library/Logs/OpenLess/openless.log`(当时末行 13439、mtime 20:38:43 +0800)与滚动日志;未保存正文、转写、请求内容或任何秘密。 - -| 原日志位置 | UTC 时间 | 可用事实 | -|---|---|---| -| `openless.log:13264-13273` | 12:25–12:26 | 麦克风权限探针均 granted | -| `openless.log:13274,13277,13278` | 12:26:44 → 12:26:49 | 首次 recording 胶囊、输入设备启动、Stream dropped;之后未见 Omni POST | -| `openless.log:13396,13399` | 12:37:00 | recording 胶囊与输入设备启动;退出前未见对应 stream drop | -| `openless.log:13434,13435` | 12:38:36 → 12:38:38 | 再次输入设备启动与 Stream dropped;仍无后续 Omni POST | -| `openless.log:12328,12329` | 00:07:47 | 一次明确 watchdog 回调停止 4 秒,随后释放原生流;此记录更早,不能证明最新会话也是硬件回调停顿 | - -`recorder.rs:1655-1660` 在 `stream.play()` 成功后才打印 inputDevice,因此有此日志可证明 play 返回成功。`omni.rs:320-326` 在发送多模态请求前固定打印 `[omni] POST`,最新故障窗口中没有该标签,与 stop 卡在 processor join 相容。 - -旧 `cb#` 日志来自 `recorder.rs:1987` 一带的旧 `process_callback`;`44972015` 新 worker 在 `:1870-1884` 不打印它。**缺少 cb# 不是无电平或未采集的证据。** 同样,native Stream dropped 不等于 audio processor 已退出。 - -最新两份 `DiagnosticReports/OpenLess-2026-10-14-193439.ips`、`...193133.ips` 为旧 `0.1.0 (1)` 的 AppleSpeechService.permissions / Swift executor SIGTRAP,均在本次 10 月 15 日窗口之前;不应拿来解释当前多模态 Processing。没有导出诊断报告全文。 - -## 已检查但未发现本分支直接新增的阻塞 - -- 普通 hotkey bridge、PipelineDictationEngine、SharedOmniDictationEngine、Recorder、cloud ASR/Omni 主体在 `0454dc56..HEAD` 未改动;hotkey_loops 的差异仅测试开关。8c32db9e 对 Capsule 的相关改动为字体样式,没有改听写状态流转。 -- macOS Fn system-defined 事件新增主线程同步分类,但 listener shutdown 不 join 主线程;本次未发现足以解释上述 stop 日志的新增循环等待。 -- Core stop 在读取 Starting 前登记 phase_changed waiter,并绑定具体 session ID;现有实现能防止 stop-before-ready 的普通丢唤醒/停错会话。启动未完成时 stop 等待是明确行为;provider 或原生资源清理挂住时该等待不会自行解决。 -- TauriTaskSpawner 明确使用 Tauri 共享 runtime;常规录音 start/stop 的原生重操作位于 spawn_blocking,未发现本分支引入新的私有 runtime 生命周期问题。预览交接中仍有同步 stop/锁进入异步任务的边界,已作为问题 1 的扩散路径说明。 -- 凭据退役投影、实验注册和通道释放的变更已读;当前实验全部关闭且多模态不经过本地 ASR 启动。本次未发现这些新增操作能解释 recorder 已 stop 却无 Omni 请求的确定锁链。 - -## 主 Agent 修复 diff 的独立复核 - -已只读复核主 Agent 在本次审查期间对 `recorder.rs` 的未提交修改:增加 `AudioProcessingMessage::Shutdown`、worker 自持 shutdown sender、finish 发 FIFO Shutdown 后 join,以及 `audio_processing_shutdown_drains_frames_with_a_retained_native_sender`(8 帧、2 秒、保留 native sender)回归。该退出协议与问题 1 的确定阻塞条件吻合,未发现此 diff 新增问题;可以使 preview->dictation 的资源交接返回。依赖自身的 AudioUnit/closure 引用环仍是另一个原生资源释放边界,显式 Shutdown 修复的是等待这个环析构导致的应用死锁。 - -我未运行主 Agent 的仓库回归,避免重复构建;其编译/测试结果由主 Agent 汇总。本审查运行的三个隔离探针全部通过,临时源码、可执行文件与探针子进程已清理。没有改仓库源码、配置、凭据,未安装应用、创建子 Agent、主动录音、调用收费 API 或完整构建;仅新增本报告。 - -仍未验证:真实设备开始/停止及设置预览交接的端到端表现、固定后的请求和最终输入、其他麦克风设备/拔插、CPAL 原生资源环是否另需依赖修复、实验模型真实退出阶段。报告中所有根因与修复结论均限定于上述证据。 diff --git a/docs/reviews/2026-10-16/local-asr-capsule-performance.md b/docs/reviews/2026-10-16/local-asr-capsule-performance.md deleted file mode 100644 index cc00f4d64..000000000 --- a/docs/reviews/2026-10-16/local-asr-capsule-performance.md +++ /dev/null @@ -1,63 +0,0 @@ -# 本地识别资源占用与多屏胶囊 - -## 范围 - -基线 beta `96278f40`。处理更新后多屏同时显示胶囊、macOS 本地识别启动/停止等待和资源突增;不更改用户模型开关、凭据、录音、权限或已安装 App,不改已公开版本及标签。当前机器已有 SenseVoice 与 TextStream 权重,Gemma 不具备本次真实模型测试条件。 - -## 原因与修正 - -### 停止响应 - -普通听写在麦克风启动后仍 await `buffered.attach()`,本地模型加载/校验未完成时,Core 公开状态仍为 Starting。`stop_dictation_session_with_options` 会等待该状态结束,导致用户已点停止但麦克风不能及时释放、思考状态也迟迟不出现。SenseVoice 合成音频测试中冷加载约 8–10 秒,能解释这类等待;它不是关闭按钮没有绑定。 - -只对实验本地 ASR 使用宿主 TaskSpawner 后台 attach。麦克风已就绪即可进入 Recording;停止先释放 capture 并进入正常识别收尾,结果仍等待真实模型输出,不伪造完成。模型启动失败通过 RecordingFault 反馈,finalizing 后由 finish 返回原始错误;取消前尚未调度的加载不执行,加载中取消后的迟到 session 不回放音频并清理。云端管线和先录音后识别的稳定模式不改变。 - -### CPU、内存与加载 - -- TextStream 每次启动都会重新读取并 SHA-256 校验约 1.9 GB 固定权重。新增仅存在当前进程的校验缓存,文件目录、设备、inode、大小及 mtime/ctime(纳秒)未变才复用;首次、替换、修改或新进程仍全量校验,校验前后发生变化不缓存。没有写入可跨进程复用的信任标记。 -- MLX 默认内存指导上限为推荐工作集的 1.5 倍,空闲缓存默认也可增长到该上限。现在显式设置工作集指导值为物理内存的三分之一、推荐工作集与 4 GiB 的最小值;空闲缓存最多 128 MiB,并在加载/识别片段/最终化后释放不再使用的缓存。它不是 OS 级硬 RSS 限额,活动模型权重和 KV 状态不会被强删。 -- 原 CPU 总预算可能达到 5–7 个逻辑核,线程参数最多 2 不等于全部 native 辅助线程总用量最多 2。现在协作式平均预算另加 2 个逻辑核上限,保留 utility QoS 和可取消让步;单次不可分割 native 调用仍可能短时突发。 -- SenseVoice 先前按采样到的空闲 CPU 调整线程,忙闲变化可能重建整个 ONNX 模型;创建新模型时旧模型还在 slot 内,造成双份驻留峰值。SenseVoice 固定单 CPU 推理线程,已加载实例不因预算变化重建;必须替换的实例在构造新模型前释放。单线程优先降低争用,不承诺更快的批量推理。 -- 补充无文本的模型加载、包校验缓存命中、ready 和解码阶段计时。 -- 受管 TextStream worker 自动升级同时识别已知旧脚本/环境指纹,保留 Python 环境和权重;未知或修改过的脚本不覆盖。lease/status 能识别可升级版本,防止旧 worker 在进入 refresh 前被拦截。 - -### 多屏 - -后续用户明确要求两种模式自行选择:本轮 Beta 在设置增加 `capsuleDisplayMode`,默认 `cursorScreen`,显式选择 `allScreens` 时才镜像。macOS/Windows 共享策略,macOS 镜像转换为非激活面板并使用目标屏幕逻辑坐标;Windows 保留独立原文 rail。设置同步、恢复和主线程缓存均接线,模式切换使镜像缓存失效。默认值、序列化重载、无效值拒绝、原生模式缓存及两个 OS 的双浏览器控件测试通过。 - -复查发现频繁销毁/重建 WKWebView 会累积原生资源,因此改为有限槽位复用:不用的窗口隐藏并投递 Idle 停止动画,实时 capsule 状态只发往可见镜像;不为每块屏幕创建额外识别会话。以下是最初单屏修复的历史说明,新 Beta 已不再强制用户只能单屏。 - -Windows #1183 曾主动增加“主屏固定胶囊 + 其他屏幕镜像”,因此两个屏幕同时显示是该版本的明确行为,不是识别引擎创建两份 UI。现在只显示鼠标所在屏幕上的一个 capsule / transcript rail,兼容路径隐藏已有镜像;鼠标不可读时回退主屏。混合 DPI、负坐标屏幕和卡片模式使用同一定位依据。 - -macOS 镜像创建原本就被 cfg 排除,继续保留现有鼠标屏幕定位;不把 Windows 镜像根因冒称为 macOS 双胶囊已复现。本机没有完成 Windows 真双屏 UI 操作,定位与重复窗口策略由纯几何回归验证,Windows 编译需云端门禁。 - -## 验证 - -- Core 初轮全库:1210 passed / 1 外部依赖测试 ignored;后增“取消已排队的本地加载”后,最终受影响 dictation_engine 回归 34/34。覆盖三类实验模型启动尚未完成时的停止、取消、音频完整性、启动失败、云端与稳定模式原有语义。 -- 正常原生 macOS 全库:582 passed / 5 明确的模型/安装依赖 ignored。SenseVoice 与固定权重 TextStream 校验两项 ignored 测试另以已有模型、合成语音实际执行通过,不下载模型、不使用用户录音。 -- Core Clippy `--lib --tests -- -D warnings` 通过。改动后的 Python worker 13 项测试通过,Black/Ruff 通过,资源策略 helper 的 mypy strict 通过;既有 lifecycle/retirement/capsule replay 契约通过。 -- 原生包缓存回归覆盖同大小文件替换、删除、校验中修改和未知 worker 拒绝;旧 worker receipt 升级保留解释器。 -- 胶囊多屏纯逻辑 9/9,覆盖多屏不创建镜像、负坐标鼠标选择、DPI、边界与拓扑变化。 - -## 合成音频对比(本机开发验证,不是所有设备性能保证) - -TextStream 使用同一 30.46 秒英语合成音频、相同已有权重和平均 2 核预算,直接启动生产 worker;100ms 采样 worker RSS/CPU,输入由独立发送线程供给,不阻断采样。未读取用户音频。 - -| 指标 | 修正前 | 修正后 | -| --- | --- | --- | -| worker 峰值 RSS | 1674.14 MiB | 1340.75 MiB | -| 首个草稿(含启动) | 2.18s | 1.65s | -| 最终结果(含启动) | 9.21s | 8.76s | -| 合成音频转写 | 完整 | 与修正前相同 | - -RSS 降约 20%,采样 CPU 峰值约 88.5% / 90.3%,这次样本不支持“CPU 峰值已下降”的结论;平均预算与线程约束由计时回归验证。 - -SenseVoice 单线程实测:6.092 秒合成音频识别正确,冷加载 9.72s,随后 warm prepare 112ms,解码约 1.03s。此前两线程一次约 544ms;单线程降低争用会增加这段解码时间,不能包装成普遍提速。 - -TextStream 包校验在 debug 未优化 SHA 实测 cold 53.07s、warm 约 50µs,仅说明避免重复全文件读校验有效;不将 debug 耗时当作用户 Release 包的真实耗时。 - -## 本机与磁盘边界 - -磁盘此前开发构建缓存已清理,当前可用空间约 79 GiB;本轮只用一个共享 validation target、2 个构建任务、关闭 incremental/debug info,没有重建多个工作区的 target。未删除未经确认的系统快照。本轮验证结束后清理不再需要的临时构建产物,保留小型日志与对比结果用于复核。 - -当前只是源码修复与验证,不宣称已安装、已重新发布或所有设备问题已消失。 diff --git a/docs/reviews/2026-10-16/release-readiness.md b/docs/reviews/2026-10-16/release-readiness.md deleted file mode 100644 index 86dd71cfa..000000000 --- a/docs/reviews/2026-10-16/release-readiness.md +++ /dev/null @@ -1,37 +0,0 @@ -# beta6 / 2.0.0 发版前 Review - -## 范围与结论 - -复核 PR #1197 的 `0454dc56..0947c05a` 整合范围、此前语音/Core/UI 独立报告,以及当前工作树全部生产代码、回归用例、版本和发布链路改动。主 Agent 完成最终复核,没有重新创建 Agent 或定时提醒。 - -语音 worker 显式 FIFO Shutdown、Qwen/Stepfun 启动错误解锁、TextStream 可取消进程等待、ASR 活动 owner 退役与冷删除边界均已复核。设置页/主页面退出保留、Base UI Select/Combobox/Switch/Collapsible/Popover、快捷键保存/取消、共享实验下载快照与取消、云同步/登录弹窗的惰性退出,以及 macOS 原生窗口按钮/关闭事件均在本轮范围内。没有发现尚未修复的合并阻塞项;仍须以当前 PR HEAD 的云端 CI 作为合并门禁。 - -## 本轮发现并修复的发布问题 - -1. **日期构建号会使 Homebrew 下载 404。** 原脚本从 `v2.0.0-tauri` 推导 `2.0.0`,但实际文件名含 `2.0.0+build.YYYYMMDD`。新增 `resolve-release-cask-version.mjs`,从 ARM/Intel 实际文件名读取完整且一致的应用版本,并核对 metadata-free tag;cask URL 的 tag 部分去除 build metadata,文件名保留完整版本。拒绝缺失、多余、错版本、跨架构不一致的 DMG。 -2. **cask 最低系统要求与 2.0 Host 不符。** 2.0 的 Tauri 配置要求 macOS 14.0;cask 原来仍标记 Monterey。现保留 1.x 的 Monterey,2.x 使用 Sonoma,并增加与 Host minimumSystemVersion 的契约检查。 -3. **Stable 资产可能未齐就对外可见。** Tauri 与 Android 原来只让 Beta 保持 draft,Stable 第一个平台完成就发布。现两个渠道都上传到共享 draft,管理员在两条资产工作流全部通过并核验后显式发布。Homebrew 对 draft 不更新;Stable 由管理员公开后自动触发独立 `sync-homebrew-cask.yml`,不依赖人工记得更新 cask,也不重跑资产流水线。仅 published latest Stable 可修改 cask,旧 tag 的手动重放不会降级。流程同步到 `RELEASING.md`。 -4. **快速 Escape 可能跳过退出保留。** 全量本地浏览器回归发现 `useExitMount` 的 passive effect 尚未记住打开状态时即可关闭。改在 layout effect 中提交保留状态;Chromium/WebKit 各重复 5 次,10/10 通过。截图基线按已验收的 100% 默认值更新,不增加容差。 -5. **本机 Homebrew 未管理 OpenLess。** 已接入 README 指定的官方 tap,当前公开 cask 仍是 1.3.18,不用它覆盖已安装修复版。新增并安装 zsh 事件提醒,成功执行 brew update/upgrade 后只读比较实际 App 与 cask,终端提示及同版本去重通知;无 cron/LaunchAgent,安装器幂等且备份已有 `.zshrc`。正式版公开后再由 Homebrew 接管更新。 - -## 本机发布前门禁 - -用户确认当前修复版无明显问题。只读核验 `/Applications/OpenLess.app`: - -- 版本 `2.0.0-beta-a.beta.6+build.20261015-be`,签名校验通过,主程序 SHA-256 为 `7f0918b3012d273fb1ec61b3a041089c85acf9fcb04f73e5ce933bbf6076bba4`,与最终本机构建一致。 -- 本机核验时生产源文件没有晚于该构建的功能改动。后续 Review 额外补充共享退出保留的 layout-effect 竞态修复及发布/提醒脚本;该 UI 修复经浏览器重复验证,没有替换正在使用的 App,不能冒称它已经进入本机旧二进制。 -- 进程持续运行。日志中 2026-10-15 23:46 +0800 的两次录音均依次出现音频处理线程退出、Omni stream 完成、`phase=completed outcome=Inserted`,分别 227 与 24 字符;此前也有连续开始/停止和完整转写成功记录,没有再出现已停止录音却卡住 worker 的原故障。 -- 日志曾出现启动凭据迁移重试及更新检查网络失败,不把它们解释为已消失的全局错误;后续真实录音/请求/插入已经成功。未导出转写正文或 Keychain 秘密,未重置用户配置。 - -结合用户验收,本机修复版运行门禁已满足。此结论不冒充 Windows 真机、Linux 产品验收或云端安装包验收;不额外挂载/发布未经独立验收的 Linux 安装包。 - -## 验证及发布次序 - -- `npm test`(含 build 与前端/契约)通过;新增日期 cask 与双渠道 draft 契约分别通过,Ruby cask 语法检查通过。 -- Core lib:1204 passed、1 条显式 external Codex ignored;`local_asr_contract`:34 passed;独立 backend-tests:46 passed。 -- 本机当前完整 Chromium/WebKit 用例已一次执行 100/100 通过(不带更新截图参数)。首轮 PR CI 中 macOS、MSRV、Android、Linux Core/egui/deb/rpm 已通过;UI 仅四个 Linux 快捷键旧截图失配,功能用例通过。随后排版页的四张旧 Linux 参考也被完整收集;已逐张审查 Ubuntu 实际图并更新快捷键、排版共八张对应参考;两张独立页面使用 soft 截图断言收集完整失败证据,任一失配仍让 CI 失败。后续以最终 HEAD 全量 CI 为合并门禁,不提高容差。 -- 本机 `brew update` 已成功;官方 tap 已接入,提醒脚本已安装。当前 Homebrew 公开版仍为 1.3.18,未用旧版覆盖已验收修复版。 -- Beta 应用版本规范为 `2.0.0-Beta.6+build.20261015`,tag 为 `v2.0.0-Beta.6-tauri`。日期为该候选准备日期,不改已安装 App。 -- PR 合并、被标记提交的 CI 通过后,先构建/验证/公开 beta6;之后通过 `beta → main` PR 发布 `2.0.0+build.<正式版准备当天 YYYYMMDD>`,tag 保持 `v2.0.0-tauri`,Stable 标记 latest。 - -本报告记录 Review 与本机门禁结论;PR、CI、tag、资产及公开状态以实际后续交付记录为准,不将尚未执行事项写成通过。 diff --git a/docs/reviews/2026-10-16/shortcuts-sync-review.md b/docs/reviews/2026-10-16/shortcuts-sync-review.md deleted file mode 100644 index 51bbd6d8f..000000000 --- a/docs/reviews/2026-10-16/shortcuts-sync-review.md +++ /dev/null @@ -1,40 +0,0 @@ -# 快捷键、下拉位置与自建同步入口审查 - -范围:将本轮本地改动提交到 `beta`;不包含性能调优、模型修改、正式发版、标签移动或用户配置迁移。 - -基线:`f4ffcd42862bf6394fd6dd4a3443aadd576a9fb3`。此前本地 `7a7b872d` 与该 squash merge 的树完全一致;已将新分支基线对齐,不重复提交 #1207。 - -## 独立 review 及处理 - -初审为只读审查,无测试执行。发现并处理: - -- **P1 Linux 空绑定契约不完整**:原先空翻译键无法转为 keysym,空听写键走无效 custom-key 路径,旧绑定可能保留。现将显式空绑定映射到原生 `(0, 0)` 注销;听写使用已有 `SetHotkeyRaw`,该路径清除 custom/raw/keylist 并保存插件状态。录音页与快捷键页都允许清空,保存层不再拒绝;增加转换及保存重载回归。 -- **P1 视觉基线包含已删除的 Mac 听写键按钮**:更新并审查 macOS Chromium/WebKit、深浅主题共四张快捷键截图,不提高像素容差。Ubuntu 四张对应参考已从 CI `38868358498` 的实际 Chromium/WebKit 截图取得并逐张审查、更新;没有复制 macOS 字体渲染结果。 -- **P2 自建同步配置在同步结束后仍禁用**:只在 `syncing` 期间做 1 秒状态轮询,防止请求重叠、退出清理轮询、忽略退出后返回值;增加确定性的 `syncing → ready` 与轮询停止回归。正常同步页不加载自建 Token。 -- 原字体菜单位置测试仍断言左右展开,已改为下方对齐断言;Tooltip 的右侧解释提示保持不变。 - -## 检查与边界 - -- 原生 macOS 580 通过、4 项需模型文件测试忽略的已有证据仍适用于未更改的原生路径。 -- 新增 Core 清空/保存/重载/冲突回归 4/4 已通过。 -- review 修正后前端契约测试 124 个文件通过;TypeScript/Vite production build 通过;完整 macOS Chromium/WebKit 回归 **112/112**。 -- 独立 follow-up 已确认所有代码问题修复,无新增代码 finding。首轮 CI `38868358498` 的 Ubuntu UI 为 108 通过 / 4 个旧截图失败;实际四张截图均符合删除重复入口后的预期,已更新,待最终 head CI 验证。 -- 首轮 Linux Core 回归均通过,随后 Clippy 拒绝新测试的 `field_reassign_with_default`:测试改用 struct initializer,不禁用 lint;本地同一测试 `cargo clippy -- -D warnings` 通过。 -- macOS 无法执行 Linux 主机 crate:其 egui/cpal/Wayland 依赖仅在 Linux target 声明;本地运行因此报目标依赖缺失。未把它记为测试通过,也未为此改变依赖契约,需以 Linux CI 结果为准。 -- 未修改已安装 App、用户快捷键偏好、录音、历史、凭据或 TCC 授权。用户已自行采用初始本地构建:版本 `2.0.0+build.20261016`、可执行文件 SHA-256 `e6e009cb557e36d691803d1915efad5e1a7995fd5aedd2ca41e53c0480f5fad7`。 -- 不提交根目录原有未跟踪 `package.json` 与 `pnpm-lock.yaml`。 - -## 新本地构建 - -源码提交 `e0a85f30a45eb9d01151f8fd6bd1e7df685c19bd` 已正常构建 macOS ARM64 App / DMG,包含同步入口迁移与 review 的生产修复。内部版本暂保持 `2.0.0+build.20261016`(发版版本另待确认)。 - -- 安装包:`/Users/trip/Downloads/OpenLess-local-settings-20261016-arm64.dmg`,28,977,430 字节。 -- DMG SHA-256:`20515b4151a44a98243d95fabf4769ca78867bf59ac3d0dff6681ed88e853813`。 -- 可执行文件 SHA-256:`9596c71b01f7ab5d93ac28d8b1e19a38daf6e033f804d4e4017ac1a9f189f8d7`。 -- ad-hoc 深层严格签名、只读挂载后包内文件一致性、Speech / 麦克风描述、磁盘映像完整性及布局检查通过;临时挂载已清理。未替换用户正在运行的初始构建。 - -## 发版请求待确认 - -用户提出再次构建 Beta6 及 2.0 正式版,但指定 Beta 版本为 `2.0.0-beta-a.beta.5+build.20261016`,正式版为 `2.0.0+build.20161016`;已询问 Beta5/Beta6 与正式版日期是否为笔误。未擅自变更版本。 - -已公开 `v2.0.0-Beta.6-tauri`(31 项资产)与旧源 `v2.0.0-tauri` 草稿(release ID 414239763,26 项资产)均保持不变。新源码不可通过移动已有标签伪装成同一构建。版本及新标签确认前,继续 PR/CI/合并和不发布的本地构建。 diff --git a/docs/structure.md b/docs/structure.md index 746edf583..653f820fd 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -1,29 +1,31 @@ # 应用目录与工程结构 -状态:canonical;更新:2026-09-23。分层与调用链见 [架构](architecture.md)。 +状态:canonical;更新:2026-10-19。分层与调用链见 [架构](architecture.md)。 ## 仓库与应用工作目录 ```text -1-app/ Git 仓库、分支与发布边界 -├── AGENTS.md / docs/ 规则、架构、合同说明和平台交接 +openless-src/ Git 仓库、分支与发布边界 +├── docs/ 架构、合同说明、平台交接、已知缺口与参考样例 ├── README.md / README.zh.md 面向使用者和贡献者的双语介绍 ├── RELEASING.md / USAGE.md 发布规则与使用说明 -├── .github/workflows/ CI、Tauri、Android、Linux 发布 +├── .github/workflows/ CI、Tauri / Android / Linux / HarmonyOS 发布 ├── Casks/ Homebrew 分发定义 -├── Examples/ 示例数据 -├── assets/ / video-materials/ 产品展示材料 -├── scripts/ 仓库级辅助脚本 +├── assets/ 产品展示材料 +├── scripts/ 仓库级辅助脚本(版本号同步等) └── openless-all/ - ├── design_handoff_openless/ 设计交接材料 - └── app/ npm 与 Core/Linux Cargo 工作目录 + ├── README.md 全平台总览 + └── app/ npm 与 Core/Linux Cargo 工作目录 ├── src/ React / TypeScript 界面 ├── crates/openless-core/ 共享业务 Rust crate - ├── src-tauri/ Tauri Host,独立 Cargo manifest - ├── linux-egui/ Linux Host 和 egui UI + ├── src-tauri/ Tauri Host(macOS / Windows / Android),独立 Cargo manifest + ├── linux-egui/ Linux Host 和 egui UI(不链接 Tauri / WebKitGTK) + ├── harmony/ HarmonyOS 宿主(ArkTS + ArkWeb + Rust napi) + ├── mobile-ime/ Android / 鸿蒙共用的输入法 native 后端 ├── android/ Kotlin / AIDL / manifest / 前端片段 ├── windows-ime/ 原生 TSF/IME 工程 ├── contract/ 机器可读 backend-2.0 合同 + ├── .cargo/config.toml Windows GNU 目标的 linker flag(cdylib 符号导出) ├── scripts/ 构建、平台检查与合同测试 └── public/ Vite 静态资源 ``` diff --git a/docs/ui-ux-refinement.md b/docs/ui-ux-refinement.md deleted file mode 100644 index dd1d459f2..000000000 --- a/docs/ui-ux-refinement.md +++ /dev/null @@ -1,105 +0,0 @@ -# 桌面 UI 整合来源与历史校验 - -> 本文记录此前 UI 工作区的实现与验证。文中的两模型、测试数量及未打包说明属于该阶段;三模型、Beta 功能开关、最终版本和本地包验收以 [桌面整合验收](desktop-beta5-integration.md) 为准。 - -## 来源与边界 - -共同基线:`e4727697`。两边均为未提交改动,不能只合并分支指针。 - -- `/Users/trip/orca/openless`:本地实验语音识别,45 个变更文件。 -- `/Users/trip/orca/workspaces/openless/UI-UX-细节增强`:54 个变更文件。 -- 将这两份快照做三方整合到 `appergb/合并两个分支`,没有修改原工作区,没有提交或推送。完成时再次比对,原工作区内容与快照一致。 -- 原始补丁、文件与清单保存在 `/var/folders/lf/zll5whg118q4yd9jnq61mxgm0000gn/T/openless-merge-5irykbgr`。临时目录不是长期备份。 -- 根目录原有的未跟踪 `package.json`、`pnpm-lock.yaml` 保持不变;真正应用位于 `openless-all/app/`。 - -## 设计与实现 - -遵循工具型软件的即时响应,不把展示性动画塞到高频键盘操作中。 - -| Before | After | Why | -| --- | --- | --- | -| 6.1 MB 启动视频,25 秒 watchdog,另加 1.2 秒退出 | 删除视频与播放器;代码生成的三条线标记,装饰运动 2.5 秒内结束,就绪即进入 | 动画不再延迟进入;真正的后端契约失败仍阻止业务操作 | -| 首帧等待 UI 同步/Keychain | 本地主题与字体预先应用、HTML 首帧、React 就绪后渐进加载;同步后台初始化 | 避免空白等待;提前操作的偏好仍进入原有 CAS 队列,不被旧镜像覆盖 | -| 所有主页面和设置随主壳加载 | 非首页页面与设置按需加载,设置高级页面首次访问才挂载 | 降低初始解析和未访问功能的常驻开销;已打开的实验页仍保留草稿 | -| 设置浮窗、双层边框 | 设置占用窗口本身,统一左栏与右侧内容,返回应用入口 | 减少嵌套;保留焦点恢复、弹出子层与待保存表单保护 | -| 快捷键长卡片和层层展开的录制控件 | 右侧内嵌命令搜索、分类、分配状态、独立 keycaps,直接点击录制 | 高频动作即时生效;冲突校验、取消、禁用、重置继续复用真实 IPC | -| 渠道拖动时直接交换整行 | 临时脱离文档流的列表、逐帧计算连续插槽、每行独立临界阻尼弹簧 | 半程拖动也有部分让位;拖动帧不写 React state;取消不保存 | -| 无键盘重排 | 拖动手柄可聚焦,方向键移动,结果播报 | 不依赖鼠标/触控;持久化失败恢复后端状态 | -| 开关用 `left` 移动 | `transform` 驱动滑块;组内邻近开关有最多 1.5px 的视觉反馈 | 不改变其他开关状态,键盘与减少动态效果时不传播运动 | -| 描述文本截断 | 有明确入口的高度展开、180° 箭头 | 仅内容变化和尺寸改变时测量,200ms 局部高度过渡 | -| 删除卡片只有淡出 | 向右下删除方向的短弧线、缩小/旋转/透明度变化;支持水平划走 | 240ms 的定向反馈,保留 6 秒撤销及删除失败恢复 | -| 标签仅变色 | 选中项轻微放大,实际增加布局占位,邻项弹簧让位 | 不覆盖相邻选项;用于风格切换 | -| 滚动页面整面 mask/纹理合成 | 移除整页滚动遮罩与装饰噪点;稳定滚动条槽位 | 降低合成开销,避免内容顶部淡隐和滚动条出现时宽度抖动 | -| WebGL 预览离开可视区域仍绘制 | 离屏和隐藏时停钟;尺寸只在 resize 时测量;减少动态效果使用静态帧 | 自动测试确认离屏绘制计数不增长,重新可见后恢复 | -| 字体与排版占满一屏 | 仅保留字体、界面大小、阅读宽度三行,使用 Base UI Select | 删除文字用途、平铺字体、预设、最近使用、预览及导入导出面板;只用本机字体 | -| 问号按钮和内嵌长说明 | 共用 `Hint`,文字虚线下划线,Base UI Tooltip 优先向上弹出 | Portal 不撑开设置容器;支持悬浮、键盘、触屏、Esc 与碰撞避让 | -| 实验模型默认显示多段说明 | 默认两个模型名称和开关,规格及启停影响放入名称提示 | 下载进度、错误、重试和完成后的渠道引导仍然可见 | - -### 滚动策略 - -不接管 `wheel`,不对滚动事件做防抖,不安装第二套模拟惯性。macOS 触控板惯性由系统/浏览器处理,用户反向输入、触碰或停止时不会再被 JS 速度累加器拖走。保留原有历史/市场搜索的 300ms 防抖,以及流式内容的 rAF 合并。动画主要使用 transform/opacity;高度动画仅用于小型展开容器。真实触控板的加速度与制动感不能用合成 wheel 事件完全验证。 - -### 字体与数据范围 - -字体配置保存在设备本地 `ol.typography.v1`;不包含账号、密钥或任意 CSS,旧数据仍可读取。新的单一字体选择同时应用于界面、正文、标题和数字,代码保持原来的等宽字体设置。界面整体缩放继续使用原有 `ol-font-scale` 偏好和同步机制;Base UI Portal 抵消根节点缩放,再在弹层恢复字号,避免 90%/110% 下重复缩放坐标。新增排版资源提供中英文,其余语言的新增文案暂用英文。 - -风格卡片通过 `elementRef` 传递页面测量引用,内部与 `forwardRef` 合并,避免 Framer Motion 的 `PopChild` 在 React 18 中访问特殊属性 `props.ref`。保留 `popLayout`、滑动删除、退出动画和撤销,不修改依赖包、不屏蔽控制台警告。 - -## 可复现验证 - -设计反馈的新增回归在 `e2e/settings-feedback.spec.ts`:开发模式控制台、文字提示、触屏持久显示、键盘/Esc、窄屏、三档缩放下的定位;排版持久化用例位于 `e2e/refinements.spec.ts`。实测 1429×930 视口下,字体区域由约 739px 收至 251px,实验模型区域由约 649px 收至 127px。本轮生产构建、前端测试集及 Chromium/WebKit 全部 80 项 UI 回归通过;4 张排版视觉基线已更新,并在不更新快照的全量测试中复核通过。 - -在 `openless-all/app/` 执行: - -```sh -npm ci -npm run build -node scripts/frontend-test-runner.mjs -npm run test:ui-motion -cargo test -p openless-core --lib -node scripts/measure-startup.mjs 5 -``` - -以下数量为最初两分支整合的验证记录(不包含后续设计反馈用例): - -- TypeScript + Vite 生产构建通过。 -- 118 个前端测试文件通过,包括新的排版输入校验、连续插槽计算、启动阶段偏好写入、删除生命周期。 -- Chromium / WebKit:64 项 UI 测试通过,包含主页面和设置入口、7 类辅助窗口、移动窄屏、查询、导入导出、取消重排、视觉反馈不改状态、离屏 WebGL 停止、减少动态效果。 -- 8 张深浅主题视觉基线位于 `openless-all/app/e2e/visual-refinements.spec.ts-snapshots/`,按浏览器和 OS 区分;比较阈值 100 像素。更新后已独立比较通过,而非只生成图片。 -- Core:1,163 通过、1 跳过。其中本地实验 ASR 的 7 项生命周期/渠道隔离测试全部通过。 -- 修复了 Less Computer 的跨 chunk `getSettings` 重导出警告;主入口约 616KB 的大 chunk 提示仍存在,不将调高告警阈值当作优化。 - -### 启动测量的含义 - -`startupMetrics.ts` 记录导航相对时间:HTML 引导、前端代码、React 帧估计、后端就绪、壳与偏好就绪、同步桥。首屏与可交互不是同一个指标,不用假进度或动画时长抵扣真正工作。设置 → 关于与更新 → 启动诊断可查看当次数据。 - -一次本机 Chromium headless、生产资源、5 个新浏览器 context、模拟后端的结果: - -| 指标 | 结果 | -| --- | --- | -| FCP | 32–92ms,中位数 32ms | -| 壳与偏好就绪的双 rAF 估计 | 254–350ms,中位数 256ms | -| 首屏资源请求数 | 每次 9 个 | -| 主壳 chunk(未压缩) | 整合基线 369.92KB → 约 50.46KB;设置与其余页面改为按需加载 | - -这些是浏览器样本,不是原生进程冷启动,也不是优化前后速度对照;系统文件缓存、CPU/GPU 负载会影响结果。未声称 CPU 或 GPU 百分比下降。 - -## 预览与人工验收 - -Orca 当前工作区的内置浏览器:`http://127.0.0.1:5337/`。对应托管终端为 `OpenLess UI Preview`,支持源码修改后热更新。浏览器用模拟后端,不能据此验证真实麦克风、模型推理、系统级快捷键或系统权限。 - -建议实机检查: - -1. 快捷键搜索、录制、Esc 取消、冲突提示、分类切换;检查浅色/深色、90%/110% 缩放。 -2. AI 服务中的渠道上拖、半程停留、反向拖、边缘自动滚动、Esc 取消和方向键重排。 -3. 设置中的开关反馈,确认只有当前开关改变;开启系统“减少动态效果”再试。 -4. 风格卡片展开、水平划走、撤销、快速连续删除和切换页面。 -5. 在真实 macOS 打包 App 中用触控板检验原生惯性与快速制动;测原生启动及真实 ASR。此次没有构建或验证新的原生 App 安装包。 - -参考: - -- https://developer.apple.com/design/human-interface-guidelines/motion -- https://developer.apple.com/design/human-interface-guidelines/launching -- https://developer.apple.com/design/human-interface-guidelines/loading -- https://developer.apple.com/design/human-interface-guidelines/typography -- https://developer.apple.com/videos/play/wwdc2018/803/ diff --git a/openless-all/README.md b/openless-all/README.md index 337abfed2..01ac24df2 100644 --- a/openless-all/README.md +++ b/openless-all/README.md @@ -1,6 +1,6 @@ # OpenLess All-Platform -This directory contains the cross-platform OpenLess application and design handoff material. Start with the repository [documentation index](../docs/index.md), [architecture](../docs/architecture.md), and [source structure](../docs/structure.md). +This directory contains the cross-platform OpenLess application. Start with the repository [documentation index](../docs/index.md), [architecture](../docs/architecture.md), and [source structure](../docs/structure.md). ## App Directory @@ -9,7 +9,10 @@ The runnable sources live in `app/`: - `app/crates/openless-core`: framework-independent shared backend Interface and business rules; - `app/src`: React frontend for the Tauri hosts; - `app/src-tauri`: macOS, Windows, and Android Tauri Host and native adapters; -- `app/linux-egui`: Linux Host and egui/eframe UI; it does not depend on Tauri or WebKitGTK. +- `app/linux-egui`: Linux Host and egui/eframe UI; it does not depend on Tauri or WebKitGTK; +- `app/harmony`: HarmonyOS host (ArkTS + ArkWeb) with the shared Rust backend; +- `app/mobile-ime`: native input-method backend shared by Android and HarmonyOS; +- `app/scripts/linux-fcitx5-plugin`: the fcitx5 addon Linux dictation goes through. The Tauri manifest includes local ASR path dependencies under `app/src-tauri/vendor/`; initialize submodules before resolving it, including for non-macOS source builds. The root Core/Linux workspace excludes `src-tauri`, so its independent checks do not require those submodules. @@ -34,7 +37,7 @@ pwsh ./scripts/check-core-deps.ps1 pwsh ./scripts/check-core-deps.ps1 openless-linux-egui ``` -The independent [Linux package workflow](../.github/workflows/release-linux-egui.yml) builds deb/rpm/AppImage and the fcitx5 plugin without WebKitGTK. It supports manual and reusable-workflow invocation; automatic tag triggering remains gated on real Ubuntu runtime, input, installation, upgrade, and rollback evidence. +The independent [Linux package workflow](../.github/workflows/release-linux-egui.yml) builds deb/rpm and the fcitx5 plugin without WebKitGTK (AppImage is retired). It supports manual and reusable-workflow invocation; automatic tag triggering remains gated on real Ubuntu runtime, input, installation, upgrade, and rollback evidence. ## macOS Build diff --git a/.cargo/config.toml b/openless-all/app/.cargo/config.toml similarity index 100% rename from .cargo/config.toml rename to openless-all/app/.cargo/config.toml diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index 804ab803b..b91375eff 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -4,76 +4,70 @@ version = 4 [[package]] name = "accesskit" -version = "0.24.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +checksum = "ad442f58ee04714aaa0ba0a2768c1ea1935b29507bb22ddece8cbbc76db02932" dependencies = [ "uuid", ] [[package]] name = "accesskit_atspi_common" -version = "0.18.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +checksum = "52c182f9c282ac9c5638d876d551d15e5f7d397ec263349a0c6a2b61595dd5e4" dependencies = [ "accesskit", - "accesskit_consumer 0.36.0", + "accesskit_consumer", "atspi-common", - "phf", + "phf 0.14.0", "serde", - "zvariant 5.15.0", -] - -[[package]] -name = "accesskit_consumer" -version = "0.35.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" -dependencies = [ - "accesskit", - "hashbrown 0.16.1", + "zvariant", ] [[package]] name = "accesskit_consumer" -version = "0.36.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +checksum = "8a45e2bb300bea6d5f33a82c3e051c3ddc6f41c209750f68a0acbe991a284344" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] -name = "accesskit_consumer" -version = "0.38.0" +name = "accesskit_ios" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +checksum = "28e539d64b27fd4c14de8f78ada8f7c944c092f52f7e49fb63316507a57f6937" dependencies = [ "accesskit", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", ] [[package]] name = "accesskit_macos" -version = "0.26.3" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +checksum = "5701624bf6a11efb6d4c8e922bf44126ed96250e5faa9a7a0468c979635839a4" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", - "hashbrown 0.16.1", - "objc2 0.5.2", + "accesskit_consumer", + "hashbrown 0.17.1", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] [[package]] name = "accesskit_unix" -version = "0.21.1" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +checksum = "202f24df034a7476d07b7f74284de84f6d62aabd858dbe7ee9cad3b7ad6f8f9d" dependencies = [ "accesskit", "accesskit_atspi_common", @@ -84,18 +78,18 @@ dependencies = [ "futures-lite", "futures-util", "serde", - "zbus 5.19.0", + "zbus", ] [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.35.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "ce63f35d6bdcf59f26b76b3379063f738e6412cef46999cc772d46aa3de35adb" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", - "hashbrown 0.16.1", + "accesskit_consumer", + "hashbrown 0.17.1", "static_assertions", "windows", "windows-core", @@ -103,11 +97,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.34.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "ee96dd150375c05912d8a473f98c2fb155fb3234fac88292d396548a2460fa77" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_unix", "accesskit_windows", @@ -123,23 +118,23 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.2.2", + "inout", ] [[package]] name = "aes" -version = "0.8.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cfg-if", "cipher", - "cpufeatures 0.2.17", + "cpubits", + "cpufeatures 0.3.1", ] [[package]] @@ -208,7 +203,7 @@ dependencies = [ "ndk-context", "ndk-sys", "num_enum", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -249,43 +244,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] -name = "arbitrary" -version = "1.4.2" +name = "apple-native-keyring-store" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" dependencies = [ - "derive_arbitrary", + "keyring-core", + "log", + "security-framework", ] [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", - "x11rb", + "windows-sys 0.61.2", + "wl-clipboard-rs", + "x11rb 0.13.2", ] [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.1", "password-hash", "zeroize", ] @@ -317,29 +315,11 @@ dependencies = [ "libloading", ] -[[package]] -name = "ashpd" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39" -dependencies = [ - "enumflags2", - "futures-channel", - "futures-util", - "rand 0.9.5", - "raw-window-handle", - "serde", - "serde_repr", - "tokio", - "url", - "zbus 5.19.0", -] - [[package]] name = "asn1-rs" -version = "0.6.2" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" dependencies = [ "asn1-rs-derive", "asn1-rs-impl", @@ -347,20 +327,20 @@ dependencies = [ "nom 7.1.3", "num-traits", "rusticata-macros", - "thiserror 1.0.69", + "thiserror 2.0.21", "time", ] [[package]] name = "asn1-rs-derive" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -425,7 +405,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.4", + "rustix 1.1.5", "slab", "windows-sys 0.61.2", ] @@ -456,18 +436,18 @@ dependencies = [ "cfg-if", "event-listener", "futures-lite", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -482,7 +462,7 @@ dependencies = [ "cfg-if", "futures-core", "futures-io", - "rustix 1.1.4", + "rustix 1.1.5", "signal-hook-registry", "slab", "windows-sys 0.61.2", @@ -502,7 +482,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -530,11 +510,11 @@ dependencies = [ "enumflags2", "serde", "static_assertions", - "zbus 5.19.0", + "zbus", "zbus-lockstep", "zbus-lockstep-macros", - "zbus_names 4.3.4", - "zvariant 5.15.0", + "zbus_names", + "zvariant", ] [[package]] @@ -545,7 +525,7 @@ checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" dependencies = [ "atspi-common", "serde", - "zbus 5.19.0", + "zbus", ] [[package]] @@ -554,15 +534,37 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" -version = "0.7.9" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ - "async-trait", "axum-core", - "base64", + "base64 0.22.1", "bytes", "futures-util", "http", @@ -576,12 +578,11 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", - "sha1", + "serde_core", + "sha1 0.10.7", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.29.0", "tower", "tower-layer", "tower-service", @@ -589,19 +590,17 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.4.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ - "async-trait", "bytes", - "futures-util", + "futures-core", "http", "http-body", "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -613,6 +612,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -652,6 +657,9 @@ name = "bit-vec" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] [[package]] name = "bitflags" @@ -667,11 +675,11 @@ checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -683,13 +691,23 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + [[package]] name = "block-padding" -version = "0.3.3" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -698,7 +716,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -707,7 +725,7 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -749,7 +767,7 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -763,13 +781,13 @@ dependencies = [ [[package]] name = "bytemuck_derive" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -792,31 +810,11 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" dependencies = [ - "cc", - "pkg-config", + "libbz2-rs-sys", ] [[package]] @@ -833,57 +831,32 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "calloop" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" -dependencies = [ - "bitflags 2.13.2", - "polling", - "rustix 1.1.4", - "slab", - "tracing", -] - [[package]] name = "calloop-wayland-source" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "calloop 0.13.0", + "calloop", "rustix 0.38.44", "wayland-backend", "wayland-client", ] -[[package]] -name = "calloop-wayland-source" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" -dependencies = [ - "calloop 0.14.4", - "rustix 1.1.4", - "wayland-backend", - "wayland-client", -] - [[package]] name = "cbc" -version = "0.1.2" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" dependencies = [ "cipher", ] [[package]] name = "cc" -version = "1.4.5" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -902,9 +875,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.9" +version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4ece8474b5f766c63426647e7b4b316b67431ade1036a8313cee24a03ae917" +checksum = "9ba9e9ec16c447027685b1f897b720e18e9a8afd00bd7332c483537e38086c9f" dependencies = [ "smallvec", "target-lexicon", @@ -912,9 +885,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -931,17 +904,6 @@ dependencies = [ "libc", ] -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - [[package]] name = "chacha20" version = "0.10.2" @@ -949,21 +911,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", + "cipher", "cpufeatures 0.3.1", "rand_core 0.10.1", ] [[package]] name = "chacha20poly1305" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ "aead", - "chacha20 0.9.1", + "chacha20", "cipher", "poly1305", - "zeroize", ] [[package]] @@ -979,13 +941,13 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common", + "block-buffer 0.12.1", + "crypto-common 0.2.2", "inout", - "zeroize", ] [[package]] @@ -1008,6 +970,21 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1028,9 +1005,9 @@ dependencies = [ [[package]] name = "combine" -version = "4.6.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -1045,11 +1022,20 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] [[package]] name = "cookie-factory" @@ -1067,6 +1053,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -1080,7 +1076,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -1093,19 +1089,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "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" @@ -1139,7 +1126,7 @@ dependencies = [ "ndk-context", "num-derive", "num-traits", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-audio-toolbox", "objc2-avf-audio", "objc2-core-audio", @@ -1154,6 +1141,12 @@ dependencies = [ "windows-core", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1172,26 +1165,11 @@ dependencies = [ "libc", ] -[[package]] -name = "crc" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" -dependencies = [ - "crc-catalog", -] - -[[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -1215,10 +1193,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ctutils" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] + [[package]] name = "cursor-icon" version = "1.2.0" @@ -1289,24 +1286,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" @@ -1315,9 +1294,9 @@ checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" [[package]] name = "der-parser" -version = "9.0.0" +version = "10.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" dependencies = [ "asn1-rs", "displaydoc", @@ -1329,20 +1308,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "derive_builder" @@ -1381,9 +1349,21 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", - "subtle", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", + "zeroize", ] [[package]] @@ -1401,7 +1381,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -1412,7 +1392,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1445,11 +1425,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "ecolor" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +checksum = "31bcbef0d4263a63eb32c4834e5d711613035ce1333468e107d928923e21e6e2" dependencies = [ "bytemuck", "emath", @@ -1457,9 +1443,9 @@ dependencies = [ [[package]] name = "eframe" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +checksum = "9c5642501a65ca657d05f00c643a5c14ff2346cb3818032bfe23d58e1f8e5f84" dependencies = [ "ahash", "bytemuck", @@ -1473,7 +1459,7 @@ dependencies = [ "image", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "parking_lot", @@ -1481,6 +1467,7 @@ dependencies = [ "pollster 1.0.1", "profiling", "raw-window-handle", + "send_wrapper", "static_assertions", "wasm-bindgen", "web-sys", @@ -1492,9 +1479,9 @@ dependencies = [ [[package]] name = "egui" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +checksum = "bec9144f49b7a28b5f395d3147e325b4fe3e77ba686eac11d1bdf0a7da3f375f" dependencies = [ "accesskit", "ahash", @@ -1512,9 +1499,9 @@ dependencies = [ [[package]] name = "egui-wgpu" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +checksum = "0309cbe1febed48af5b974e4bcc424b35e9664863a4d402697147b57a488c864" dependencies = [ "ahash", "bytemuck", @@ -1523,7 +1510,7 @@ dependencies = [ "epaint", "log", "profiling", - "thiserror 2.0.20", + "thiserror 2.0.21", "type-map", "web-time", "wgpu", @@ -1532,30 +1519,32 @@ dependencies = [ [[package]] name = "egui-winit" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +checksum = "e4e449f4c4a2a60c7ccb4ba7fa4078417bfe1f9ddc03b5e05ce75748e33a70de" dependencies = [ "accesskit_winit", "arboard", + "block2 0.6.2", "bytemuck", "egui", "log", - "objc2 0.6.4", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit 0.3.2", "profiling", "raw-window-handle", - "smithay-clipboard", "web-time", + "windows-sys 0.61.2", "winit", ] [[package]] name = "egui_glow" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" +checksum = "9275ca5cc35a6b7c94e0e8cedf22542f66cb0cf45ed8ab2657c78ff49fec7268" dependencies = [ "bytemuck", "egui", @@ -1567,15 +1556,15 @@ dependencies = [ [[package]] name = "either" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "emath" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" +checksum = "e73ce90f54750be8672c02be0743fe47530e6f251e066f0ee2869b59eb297af5" dependencies = [ "bytemuck", ] @@ -1620,9 +1609,9 @@ dependencies = [ [[package]] name = "epaint" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +checksum = "97bac86ae3dde93fc3951e790951caea739ff39a6c89d909cdaf1d9278dbb1bd" dependencies = [ "ahash", "bytemuck", @@ -1639,21 +1628,22 @@ dependencies = [ "skrifa", "smallvec", "unicode-general-category", + "unicode-properties", "unicode-segmentation", "vello_cpu", ] [[package]] name = "epaint_default_fonts" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" +checksum = "cfc1cc505827f4c23eedc297975a0fd038268d41293f389dd8ff3b574aefab19" [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1736,13 +1726,13 @@ dependencies = [ "anyhow", "ferrous-opencc-compiler", "fst", - "phf", + "phf 0.13.1", "phf_codegen", "rkyv", "serde", "serde_json", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -1768,9 +1758,15 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" @@ -1795,6 +1791,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -1803,9 +1805,9 @@ checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "font-types" -version = "0.12.5" +version = "0.12.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8eb065f3251655b3c90e22e5e363f310fc5332fb3402e37bbc94752283248f6" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" dependencies = [ "bytemuck", ] @@ -1816,20 +1818,19 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] name = "fontdb" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f" dependencies = [ "fontconfig-parser", "log", "slotmap", "tinyvec", - "ttf-parser", ] [[package]] @@ -1850,7 +1851,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1878,6 +1879,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fst" version = "0.4.7" @@ -1953,7 +1960,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -2001,7 +2008,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "windows-link", ] @@ -2025,11 +2032,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -2070,12 +2075,12 @@ dependencies = [ [[package]] name = "glifo" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +checksum = "282a26c1e23de04bdab3e34a21b6f877a479b96737792516b9b8b8f69b6661be" dependencies = [ "bytemuck", - "foldhash", + "foldhash 0.2.0", "hashbrown 0.17.1", "log", "peniko", @@ -2116,7 +2121,7 @@ dependencies = [ "glutin_glx_sys", "glutin_wgl_sys", "libloading", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -2178,7 +2183,7 @@ dependencies = [ "hashbrown 0.16.1", "log", "presser", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -2192,9 +2197,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.19" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -2233,6 +2238,15 @@ dependencies = [ "smallvec", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -2241,7 +2255,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2250,7 +2264,7 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -2273,20 +2287,20 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hkdf" -version = "0.12.4" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ "hmac", ] [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -2334,11 +2348,20 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -2358,9 +2381,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -2369,21 +2392,21 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", ] [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -2541,15 +2564,15 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", - "png 0.18.1", + "png", "tiff", ] [[package]] name = "imagesize" -version = "0.13.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" +checksum = "65b27460c2c92b037f3f94c538ed9a3342f3fdf923606781629ccb35f82d042a" [[package]] name = "indexmap" @@ -2563,12 +2586,12 @@ dependencies = [ [[package]] name = "inout" -version = "0.1.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ "block-padding", - "generic-array", + "hybrid-array", ] [[package]] @@ -2613,7 +2636,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", "windows-link", ] @@ -2671,9 +2694,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -2682,15 +2705,23 @@ dependencies = [ [[package]] name = "keyring" -version = "3.6.3" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29fbf4b551298c67925f38cf341a1ada797a75f1837bdf8ae1fc8e2ebc6efb59" dependencies = [ - "dbus-secret-service", - "linux-keyutils", "log", - "secret-service", - "zeroize", ] [[package]] @@ -2699,17 +2730,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" -[[package]] -name = "kurbo" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" -dependencies = [ - "arrayvec", - "euclid", - "smallvec", -] - [[package]] name = "kurbo" version = "0.13.1" @@ -2724,15 +2744,21 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libdbus-sys" @@ -2761,14 +2787,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.23" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "bitflags 2.13.2", "libc", "plain", - "redox_syscall 0.9.4", + "redox_syscall 0.9.5", ] [[package]] @@ -2783,7 +2809,7 @@ dependencies = [ "libc", "libspa-sys", "nom 8.0.0", - "rustix 1.1.4", + "rustix 1.1.5", "system-deps", ] @@ -2804,16 +2830,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" -[[package]] -name = "linux-keyutils" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" -dependencies = [ - "bitflags 2.13.2", - "libc", -] - [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2834,9 +2850,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "local-ip-address" @@ -2866,29 +2882,17 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lzma-rs" -version = "0.3.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] -name = "lzma-sys" -version = "0.1.20" +name = "lzma-rust2" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb" dependencies = [ - "cc", - "libc", - "pkg-config", + "sha2", ] [[package]] @@ -2899,18 +2903,18 @@ checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "matchit" -version = "0.7.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -2981,9 +2985,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "log", @@ -3043,7 +3047,7 @@ dependencies = [ "once_cell", "rustc-hash 1.1.0", "spirv", - "thiserror 2.0.20", + "thiserror 2.0.21", "unicode-ident", ] @@ -3056,7 +3060,7 @@ dependencies = [ "hashbrown 0.17.1", "indexmap", "rustc-hash 1.1.0", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -3120,15 +3124,14 @@ dependencies = [ [[package]] name = "nix" -version = "0.29.0" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.13.2", "cfg-if", "cfg_aliases", "libc", - "memoffset", ] [[package]] @@ -3270,9 +3273,9 @@ dependencies = [ [[package]] name = "num_threads" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" dependencies = [ "libc", ] @@ -3285,9 +3288,9 @@ checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" [[package]] name = "objc2" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" dependencies = [ "objc-sys", "objc2-encode", @@ -3295,9 +3298,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", ] @@ -3311,7 +3314,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "libc", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-data", "objc2-core-image", "objc2-foundation 0.2.2", @@ -3326,7 +3329,7 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -3340,7 +3343,7 @@ checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ "bitflags 2.13.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio", "objc2-core-audio-types", "objc2-core-foundation", @@ -3354,7 +3357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3366,7 +3369,7 @@ checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", ] @@ -3378,7 +3381,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3389,7 +3392,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" dependencies = [ "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-audio-types", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3402,7 +3405,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3413,7 +3416,7 @@ checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3427,7 +3430,7 @@ dependencies = [ "block2 0.6.2", "dispatch2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3438,7 +3441,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3450,7 +3453,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", ] @@ -3462,7 +3465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-contacts", "objc2-foundation 0.2.2", ] @@ -3483,7 +3486,7 @@ dependencies = [ "block2 0.5.1", "dispatch", "libc", - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -3495,7 +3498,7 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3506,7 +3509,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3517,7 +3520,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", ] @@ -3530,7 +3533,7 @@ checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3541,7 +3544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3553,7 +3556,7 @@ checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal 0.2.2", ] @@ -3565,7 +3568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", @@ -3577,7 +3580,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3589,7 +3592,7 @@ checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-cloud-kit", "objc2-core-data", "objc2-core-image", @@ -3609,7 +3612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ "bitflags 2.13.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", ] @@ -3621,7 +3624,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3633,16 +3636,16 @@ checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-location", "objc2-foundation 0.2.2", ] [[package]] name = "oid-registry" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" dependencies = [ "asn1-rs", ] @@ -3653,25 +3656,20 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openless-core" version = "0.1.0" dependencies = [ "anyhow", "argon2", - "base64", - "bzip2 0.4.4", + "base64 0.23.1", + "bzip2", "chacha20poly1305", "chrono", "ferrous-opencc", "futures-util", - "getrandom 0.3.4", + "getrandom 0.4.3", + "hex", "hmac", "log", "md-5", @@ -3681,12 +3679,12 @@ dependencies = [ "reqwest", "serde", "serde_json", - "sha1", + "sha1 0.11.0", "sha2", "tar", - "thiserror 1.0.69", + "thiserror 2.0.21", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.30.0", "unicode-normalization", "url", "uuid", @@ -3698,7 +3696,7 @@ dependencies = [ name = "openless-harmony-host" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "futures-util", "openless-core", "serde_json", @@ -3710,17 +3708,14 @@ name = "openless-linux-egui" version = "0.1.0" dependencies = [ "axum", - "base64", + "base64 0.23.1", "chrono", "cpal", "dbus", "eframe", "egui", - "egui_glow", "fs2", "futures-util", - "glow", - "glutin", "hyper-util", "image", "keyring", @@ -3733,7 +3728,7 @@ dependencies = [ "reqwest", "resvg", "rfd", - "roxmltree", + "roxmltree 0.21.1", "rustls", "semver", "serde", @@ -3744,14 +3739,21 @@ dependencies = [ "time", "tokio", "tokio-rustls", + "unicode-segmentation", "uuid", "wayland-client", "wayland-protocols-wlr", "wgpu", - "x11rb", + "x11rb 0.14.0", "x509-parser", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.55" @@ -3781,6 +3783,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "parking" version = "2.2.1" @@ -3812,32 +3824,31 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom 0.4.3", + "phc", ] [[package]] name = "pbkdf2" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest", + "digest 0.11.3", "hmac", ] [[package]] name = "pem" -version = "3.0.6" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120" dependencies = [ - "base64", + "base64 0.23.1", "serde_core", ] @@ -3849,7 +3860,7 @@ checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" dependencies = [ "bytemuck", "color", - "kurbo 0.13.1", + "kurbo", "linebender_resource_handle", "smallvec", ] @@ -3860,14 +3871,47 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", - "phf_shared", + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", "serde", ] @@ -3877,8 +3921,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", ] [[package]] @@ -3888,7 +3932,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", ] [[package]] @@ -3897,8 +3951,21 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -3913,6 +3980,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pico-args" version = "0.5.0" @@ -3967,7 +4043,7 @@ dependencies = [ "libspa", "libspa-sys", "pipewire-sys", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] @@ -3993,19 +4069,6 @@ 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" @@ -4029,7 +4092,7 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -4047,12 +4110,11 @@ checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" [[package]] name = "poly1305" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", + "cpufeatures 0.3.1", "universal-hash", ] @@ -4091,9 +4153,15 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppmd-rust" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "494b4379484fc92217cc7d9e3a532375b1d01a61889ec8333025dc3b0377af56" [[package]] name = "ppv-lite86" @@ -4151,14 +4219,14 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "pulseaudio" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d70623bd7967a9ca4c2ae0e807fc380b291f98480fc037042305ec643a4d3373" +checksum = "9ba79a4c6904c987cc31fa50e9b750539404511c2dbd9ce95e5e58444177a21e" dependencies = [ "bitflags 2.13.2", "byteorder", @@ -4193,9 +4261,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -4205,7 +4273,7 @@ dependencies = [ "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -4213,21 +4281,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.17" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -4235,9 +4304,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -4277,48 +4346,27 @@ dependencies = [ "ptr_meta", ] -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ - "rand_chacha 0.9.0", + "rand_chacha", "rand_core 0.9.5", ] [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20 0.10.2", + "chacha20", "getrandom 0.4.3", "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - [[package]] name = "rand_chacha" version = "0.9.0" @@ -4329,15 +4377,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - [[package]] name = "rand_core" version = "0.9.5" @@ -4374,7 +4413,7 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", @@ -4382,9 +4421,9 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.13.2" +version = "0.14.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8" dependencies = [ "pem", "ring", @@ -4425,9 +4464,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd" dependencies = [ "bitflags 2.13.2", ] @@ -4478,11 +4517,11 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-core", "futures-util", @@ -4500,6 +4539,7 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", @@ -4515,15 +4555,15 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.9", ] [[package]] name = "resvg" -version = "0.45.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +checksum = "67e3803f97b999e80cbf7c6ecdd07a8102204d92e1633cf48783720c521196bd" dependencies = [ + "bytemuck", "log", "pico-args", "rgb", @@ -4534,26 +4574,26 @@ dependencies = [ [[package]] name = "rfd" -version = "0.16.0" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ - "ashpd", "block2 0.6.2", "dispatch2", "js-sys", + "libc", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", + "percent-encoding", "pollster 0.4.0", "raw-window-handle", - "urlencoding", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4606,7 +4646,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -4615,6 +4655,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -4660,9 +4709,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags 2.13.2", "errno", @@ -4673,10 +4722,11 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.44" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -4686,6 +4736,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -4696,12 +4758,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4713,24 +4803,6 @@ 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" @@ -4746,6 +4818,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4760,21 +4841,44 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "secret-service" -version = "4.0.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" dependencies = [ "aes", "cbc", "futures-util", - "generic-array", + "getrandom 0.4.3", "hkdf", + "hybrid-array", "num", "once_cell", - "rand 0.8.8", "serde", "sha2", - "zbus 4.4.0", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -4789,6 +4893,12 @@ version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.229" @@ -4816,14 +4926,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -4840,14 +4950,14 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -4872,18 +4982,29 @@ checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] name = "sha2" -version = "0.10.9" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -4952,9 +5073,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "skrifa" @@ -4983,9 +5104,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "smithay-client-toolkit" @@ -4994,8 +5115,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ "bitflags 2.13.2", - "calloop 0.13.0", - "calloop-wayland-source 0.3.0", + "calloop", + "calloop-wayland-source", "cursor-icon", "libc", "log", @@ -5012,44 +5133,6 @@ dependencies = [ "xkeysym", ] -[[package]] -name = "smithay-client-toolkit" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" -dependencies = [ - "bitflags 2.13.2", - "calloop 0.14.4", - "calloop-wayland-source 0.4.1", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 1.1.4", - "thiserror 2.0.20", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols", - "wayland-protocols-experimental", - "wayland-protocols-misc", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smithay-clipboard" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" -dependencies = [ - "libc", - "smithay-client-toolkit 0.20.0", - "wayland-backend", -] - [[package]] name = "smol_str" version = "0.2.2" @@ -5113,11 +5196,11 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "svgtypes" -version = "0.15.3" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +checksum = "695b5790b3131dafa99b3bbfd25a216edb3d216dad9ca208d4657bfb8f2abc3d" dependencies = [ - "kurbo 0.11.3", + "kurbo", "siphasher", ] @@ -5134,9 +5217,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -5163,6 +5246,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -5170,7 +5264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5223,7 +5317,7 @@ dependencies = [ "fastrand", "getrandom 0.4.3", "once_cell", - "rustix 1.1.4", + "rustix 1.1.5", "windows-sys 0.61.2", ] @@ -5247,11 +5341,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -5267,13 +5361,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -5297,6 +5391,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "js-sys", "libc", "num-conv", "num_threads", @@ -5324,24 +5419,24 @@ dependencies = [ [[package]] name = "tiny-skia" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" dependencies = [ "arrayref", "arrayvec", "bytemuck", "cfg-if", "log", - "png 0.17.16", + "png", "tiny-skia-path", ] [[package]] name = "tiny-skia-path" -version = "0.11.4" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" dependencies = [ "arrayref", "bytemuck", @@ -5360,24 +5455,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -5386,7 +5472,6 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "tracing", "windows-sys 0.61.2", ] @@ -5398,14 +5483,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -5413,9 +5498,21 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", @@ -5423,15 +5520,15 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.30.0", "webpki-roots 0.26.11", ] [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -5443,9 +5540,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.6+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -5458,18 +5555,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.15+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", "toml_datetime", @@ -5479,18 +5576,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -5543,7 +5640,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -5569,6 +5665,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom 8.0.0", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5576,32 +5683,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] -name = "ttf-parser" -version = "0.25.1" +name = "tungstenite" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" dependencies = [ - "core_maths", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1 0.10.7", + "thiserror 2.0.21", ] [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ - "byteorder", "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.8.8", + "rand 0.10.3", "rustls", "rustls-pki-types", - "sha1", - "thiserror 1.0.69", - "utf-8", + "sha1 0.11.0", + "thiserror 2.0.21", ] [[package]] @@ -5613,6 +5725,12 @@ dependencies = [ "rustc-hash 2.1.3", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -5632,9 +5750,9 @@ dependencies = [ [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-bidi" @@ -5642,18 +5760,6 @@ 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" @@ -5662,15 +5768,15 @@ checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] @@ -5707,12 +5813,12 @@ checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "universal-hash" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -5734,30 +5840,24 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "urlencoding" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" - [[package]] name = "usvg" -version = "0.45.1" +version = "0.48.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +checksum = "977d0a4abdef933f424a99fe09f95576e089b90aebc6f016a3bc813762493e91" dependencies = [ - "base64", + "base64 0.23.1", "data-url", - "flate2", "fontdb", + "harfrust", "imagesize", - "kurbo 0.11.3", + "kurbo", "log", "pico-args", - "roxmltree", - "rustybuzz", + "roxmltree 0.21.1", "simplecss", "siphasher", + "skrifa", "strict-num", "svgtypes", "tiny-skia-path", @@ -5767,12 +5867,6 @@ dependencies = [ "xmlwriter", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -5781,9 +5875,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.1" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5793,9 +5887,9 @@ dependencies = [ [[package]] name = "vello_common" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +checksum = "bbb2141a2bca6e6d598e471fd4d1d7eed8e020aad6a28187edda07f091a325dd" dependencies = [ "bytemuck", "fearless_simd", @@ -5803,14 +5897,14 @@ dependencies = [ "log", "peniko", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "vello_cpu" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +checksum = "ace506ca414548966fcf3c283be94eb1cad8254de488791ee698bf438b0890a9" dependencies = [ "bytemuck", "glifo", @@ -5842,9 +5936,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -5866,9 +5960,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -5879,19 +5973,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5899,31 +5994,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -5940,7 +6035,7 @@ checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ "cc", "downcast-rs", - "rustix 1.1.4", + "rustix 1.1.5", "scoped-tls", "smallvec", "wayland-sys", @@ -5953,7 +6048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ "bitflags 2.13.2", - "rustix 1.1.4", + "rustix 1.1.5", "wayland-backend", "wayland-scanner", ] @@ -5975,7 +6070,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" dependencies = [ - "rustix 1.1.4", + "rustix 1.1.5", "wayland-client", "xcursor", ] @@ -5992,32 +6087,6 @@ dependencies = [ "wayland-scanner", ] -[[package]] -name = "wayland-protocols-experimental" -version = "20250721.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-misc" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" -dependencies = [ - "bitflags 2.13.2", - "wayland-backend", - "wayland-client", - "wayland-protocols", - "wayland-scanner", -] - [[package]] name = "wayland-protocols-plasma" version = "0.3.12" @@ -6069,9 +6138,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -6087,6 +6156,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -6164,7 +6242,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", @@ -6210,7 +6288,7 @@ dependencies = [ "renderdoc-sys", "smallvec", "static_assertions", - "thiserror 2.0.20", + "thiserror 2.0.21", "wgpu-naga-bridge", "wgpu-types", "windows", @@ -6346,6 +6424,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.3.1" @@ -6391,7 +6482,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -6400,16 +6491,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -6427,31 +6509,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -6469,96 +6534,48 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winit" version = "0.30.13" @@ -6571,10 +6588,10 @@ dependencies = [ "bitflags 2.13.2", "block2 0.5.1", "bytemuck", - "calloop 0.13.0", + "calloop", "cfg_aliases", "concurrent-queue", - "core-foundation", + "core-foundation 0.9.4", "core-graphics", "cursor-icon", "dpi", @@ -6582,7 +6599,7 @@ dependencies = [ "libc", "memmap2", "ndk", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "objc2-ui-kit 0.2.2", @@ -6592,7 +6609,7 @@ dependencies = [ "raw-window-handle", "redox_syscall 0.4.1", "rustix 0.38.44", - "smithay-client-toolkit 0.19.2", + "smithay-client-toolkit", "smol_str", "tracing", "unicode-segmentation", @@ -6606,7 +6623,7 @@ dependencies = [ "web-time", "windows-sys 0.52.0", "x11-dl", - "x11rb", + "x11rb 0.13.2", "xkbcommon-dl", ] @@ -6625,6 +6642,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d7888ccd4896447b2d14d3a9350a85df2aeb6f181e2e7a31349d104ac46cac1" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.5", + "thiserror 2.0.21", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" @@ -6653,8 +6688,19 @@ dependencies = [ "libc", "libloading", "once_cell", - "rustix 1.1.4", - "x11rb-protocol", + "rustix 1.1.5", + "x11rb-protocol 0.13.2", +] + +[[package]] +name = "x11rb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8885a854a8bfdf87a301e53e41b17c5f8f33639903131338b997b1eb614f44" +dependencies = [ + "gethostname", + "rustix 1.1.5", + "x11rb-protocol 0.14.0", ] [[package]] @@ -6663,11 +6709,17 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x11rb-protocol" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf4d1bc32aa46eec18caa634ec3cf4c05bfa151f12b93b510b15190f69a1ca8" + [[package]] name = "x509-parser" -version = "0.16.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ "asn1-rs", "data-encoding", @@ -6677,7 +6729,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 1.0.69", + "thiserror 2.0.21", "time", ] @@ -6688,7 +6740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.4", + "rustix 1.1.5", ] [[package]] @@ -6697,16 +6749,6 @@ version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" -[[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-dl" version = "0.4.2" @@ -6738,21 +6780,13 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - [[package]] name = "yasna" -version = "0.5.2" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" dependencies = [ + "bit-vec", "time", ] @@ -6769,46 +6803,14 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "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.8", - "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", + "syn 3.0.9", + "synstructure 0.14.0", ] [[package]] @@ -6833,18 +6835,17 @@ dependencies = [ "hex", "libc", "ordered-stream", - "rustix 1.1.4", + "rustix 1.1.5", "serde", "serde_repr", - "tokio", "tracing", "uds_windows", "uuid", "windows-sys 0.61.2", "winnow", - "zbus_macros 5.19.0", - "zbus_names 4.3.4", - "zvariant 5.15.0", + "zbus_macros", + "zbus_names", + "zvariant", ] [[package]] @@ -6854,7 +6855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" dependencies = [ "zbus_xml", - "zvariant 5.15.0", + "zvariant", ] [[package]] @@ -6868,20 +6869,18 @@ dependencies = [ "syn 2.0.119", "zbus-lockstep", "zbus_xml", - "zvariant 5.15.0", + "zvariant", ] [[package]] -name = "zbus_macros" -version = "4.4.0" +name = "zbus-secret-service-keyring-store" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" dependencies = [ - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 2.0.119", - "zvariant_utils 2.1.0", + "keyring-core", + "secret-service", + "zbus", ] [[package]] @@ -6893,32 +6892,21 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.5", - "zbus_names 4.3.4", - "zvariant 5.15.0", - "zvariant_utils 4.2.0", -] - -[[package]] -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", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", ] [[package]] name = "zbus_names" -version = "4.3.4" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", "winnow", - "zvariant 5.15.0", + "zvariant", ] [[package]] @@ -6929,8 +6917,8 @@ checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" dependencies = [ "serde", "winnow", - "zbus_names 4.3.4", - "zvariant 5.15.0", + "zbus_names", + "zvariant", ] [[package]] @@ -6944,18 +6932,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.57" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.57" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -6973,21 +6961,21 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.9", + "synstructure 0.14.0", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -7033,34 +7021,32 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "zip" -version = "2.4.2" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "aes", - "arbitrary", - "bzip2 0.5.2", + "bzip2", "constant_time_eq", "crc32fast", - "crossbeam-utils", "deflate64", - "displaydoc", "flate2", - "getrandom 0.3.4", + "getrandom 0.4.3", "hmac", "indexmap", - "lzma-rs", + "lzma-rust2", "memchr", + "nix", "pbkdf2", - "sha1", - "thiserror 2.0.20", + "ppmd-rust", + "sha1 0.11.0", "time", - "xz2", + "typed-path", "zeroize", "zopfli", "zstd", @@ -7068,9 +7054,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" @@ -7080,9 +7066,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -7092,27 +7078,27 @@ dependencies = [ [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.3.0" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.1.0+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", @@ -7135,68 +7121,30 @@ dependencies = [ [[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.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", "serde", - "url", "winnow", "zcheapstr", - "zvariant_derive 5.15.0", - "zvariant_utils 4.2.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", - "proc-macro2", - "quote", - "syn 2.0.119", - "zvariant_utils 2.1.0", + "zvariant_derive", + "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.15.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.5", - "zvariant_utils 4.2.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.119", + "syn 3.0.9", + "zvariant_utils", ] [[package]] @@ -7208,6 +7156,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.5", + "syn 3.0.9", "winnow", ] diff --git a/openless-all/app/crates/openless-core/Cargo.toml b/openless-all/app/crates/openless-core/Cargo.toml index e58f79a95..1c55fc063 100644 --- a/openless-all/app/crates/openless-core/Cargo.toml +++ b/openless-all/app/crates/openless-core/Cargo.toml @@ -4,40 +4,41 @@ version = "0.1.0" license = "AGPL-3.0-only" description = "Framework-independent OpenLess application core" edition = "2021" -rust-version = "1.88" +rust-version = "1.99" publish = false [dependencies] anyhow = "1" -argon2 = { version = "0.5", features = ["zeroize"] } -base64 = "0.22" -bzip2 = "0.4" -chacha20poly1305 = "0.10" +argon2 = { version = "0.6.0", features = ["zeroize"] } +base64 = "0.23.1" +bzip2 = "0.6.1" +chacha20poly1305 = "0.11.0" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } futures-util = "0.3" -getrandom = "0.3" +getrandom = "0.4.3" ferrous-opencc = "0.4" -hmac = "0.12" +hmac = "0.13.0" +hex = "0.4.3" log = "0.4" -md-5 = "0.10" +md-5 = "0.11.0" once_cell = "1" parking_lot = "0.12" regex = "1" -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream", "system-proxy"] } +reqwest = { version = "0.13.5", default-features = false, features = ["json", "multipart", "rustls", "stream", "system-proxy", "query", "form"] } serde = { version = "1", features = ["derive"] } serde_json = "1" -sha1 = "0.10" -sha2 = "0.10" +sha1 = "0.11.0" +sha2 = "0.11.1" tar = "0.4" -thiserror = "1" +thiserror = "2.0.21" # `rt`/`macros` are needed by the injected Tokio spawner and the core's # asynchronous contract tests. The core still does not own a runtime: hosts # may provide another `TaskSpawner` implementation when they need a different # executor. tokio = { version = "1", features = ["sync", "time", "rt", "macros", "rt-multi-thread", "net", "io-util"] } -tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } +tokio-tungstenite = { version = "0.30.0", features = ["rustls-tls-webpki-roots"] } url = { version = "2", features = ["serde"] } uuid = { version = "1", features = ["v4", "serde"] } unicode-normalization = "0.1" zeroize = { version = "1", features = ["derive"] } -zip = "2" +zip = "9.0.0" diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 9bc537fb8..8b9604294 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -2851,6 +2851,10 @@ impl OpenLessBackend { )); } let events = Arc::new(EventBus::new(256)); + // 保留裁剪与「最近 N 分钟」必须跟着注入的时钟走:同一个后端里 + // `created_at` 已经用 `clock` 生成,裁剪却偷读系统时钟的话,测试/ + // 回放的固定时钟下刚写入的条目会在写入瞬间被判超期。 + repositories.history.attach_clock(Arc::clone(&clock)); let preferences_revision = Arc::new(AtomicU64::new(0)); let style_pack_revision = Arc::new(AtomicU64::new(0)); let history_revision = Arc::new(AtomicU64::new(0)); diff --git a/openless-all/app/crates/openless-core/src/asr/bailian.rs b/openless-all/app/crates/openless-core/src/asr/bailian.rs index 8f78f4d9d..ec8b36294 100644 --- a/openless-all/app/crates/openless-core/src/asr/bailian.rs +++ b/openless-all/app/crates/openless-core/src/asr/bailian.rs @@ -788,7 +788,7 @@ async fn send_text(writer: &SharedWriter, text: String) -> Result<(), BailianASR "websocket writer not available".to_string(), )); }; - ws.send(Message::Text(text)) + ws.send(Message::Text(text.into())) .await .map_err(|e| BailianASRError::SendFailed(e.to_string())) } @@ -800,7 +800,7 @@ async fn send_binary(writer: &SharedWriter, data: Vec) -> Result<(), Bailian "websocket writer not available".to_string(), )); }; - ws.send(Message::Binary(data)) + ws.send(Message::Binary(data.into())) .await .map_err(|e| BailianASRError::SendFailed(e.to_string())) } diff --git a/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs b/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs index d0c66816d..48faff141 100644 --- a/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs +++ b/openless-all/app/crates/openless-core/src/asr/qwen_realtime.rs @@ -719,7 +719,7 @@ async fn send_text(writer: &SharedWriter, text: String) -> Result<(), Qwen3ASREr "websocket writer not available".to_string(), )); }; - ws.send(Message::Text(text)) + ws.send(Message::Text(text.into())) .await .map_err(|e| Qwen3ASRError::SendFailed(e.to_string())) }) diff --git a/openless-all/app/crates/openless-core/src/asr/stepfun_realtime.rs b/openless-all/app/crates/openless-core/src/asr/stepfun_realtime.rs index 1d7b71ecc..392ff58d6 100644 --- a/openless-all/app/crates/openless-core/src/asr/stepfun_realtime.rs +++ b/openless-all/app/crates/openless-core/src/asr/stepfun_realtime.rs @@ -935,7 +935,7 @@ async fn send_text(writer: &SharedWriter, text: String) -> Result<(), StepfunASR "websocket writer not available".to_string(), )); }; - ws.send(Message::Text(text)) + ws.send(Message::Text(text.into())) .await .map_err(|e| StepfunASRError::SendFailed(e.to_string())) }) @@ -1322,17 +1322,17 @@ mod tests { ws.next().await.unwrap().unwrap(), Message::Text(_) )); - ws.send(Message::Text( + ws.send(Message::text( json!({ "type": "session.updated" }).to_string(), )) .await .unwrap(); release_events_rx.await.unwrap(); - ws.send(Message::Text(speech_started_event())) + ws.send(Message::text(speech_started_event())) .await .unwrap(); - ws.send(Message::Text(completed_event("delayed speech"))) + ws.send(Message::text(completed_event("delayed speech"))) .await .unwrap(); // Keep the connection open until the client processes the completed and @@ -1396,7 +1396,7 @@ mod tests { ws.next().await.unwrap().unwrap(), Message::Text(_) )); - ws.send(Message::Text( + ws.send(Message::text( json!({ "type": "session.updated" }).to_string(), )) .await @@ -1407,7 +1407,7 @@ mod tests { Message::Text(_) if !fired => { fired = true; for event in &events_on_first_audio { - ws.send(Message::Text(event.clone())).await.unwrap(); + ws.send(Message::text(event.clone())).await.unwrap(); } } Message::Close(_) => break, diff --git a/openless-all/app/crates/openless-core/src/asr/tencent_cloud.rs b/openless-all/app/crates/openless-core/src/asr/tencent_cloud.rs index 518b060b0..a32cb6033 100644 --- a/openless-all/app/crates/openless-core/src/asr/tencent_cloud.rs +++ b/openless-all/app/crates/openless-core/src/asr/tencent_cloud.rs @@ -20,7 +20,7 @@ use std::time::Duration; use base64::Engine; use futures_util::{SinkExt, StreamExt}; -use hmac::{Hmac, Mac}; +use hmac::{Hmac, KeyInit, Mac}; use parking_lot::Mutex as ParkingMutex; use serde_json::Value; use sha1::Sha1; @@ -553,7 +553,7 @@ async fn send_binary(writer: &SharedWriter, data: Vec) -> Result<(), Tencent let Some(ws) = guard.as_mut() else { return Err(TencentCloudASRError::ConnectionFailed); }; - tokio::time::timeout(FRAME_SEND_TIMEOUT, ws.send(Message::Binary(data))) + tokio::time::timeout(FRAME_SEND_TIMEOUT, ws.send(Message::Binary(data.into()))) .await .map_err(|_| TencentCloudASRError::ConnectionFailed)? .map_err(|_| TencentCloudASRError::ConnectionFailed) @@ -564,10 +564,13 @@ async fn send_text(writer: &SharedWriter, data: &str) -> Result<(), TencentCloud let Some(ws) = guard.as_mut() else { return Err(TencentCloudASRError::ConnectionFailed); }; - tokio::time::timeout(FRAME_SEND_TIMEOUT, ws.send(Message::Text(data.to_string()))) - .await - .map_err(|_| TencentCloudASRError::ConnectionFailed)? - .map_err(|_| TencentCloudASRError::ConnectionFailed) + tokio::time::timeout( + FRAME_SEND_TIMEOUT, + ws.send(Message::Text(data.to_string().into())), + ) + .await + .map_err(|_| TencentCloudASRError::ConnectionFailed)? + .map_err(|_| TencentCloudASRError::ConnectionFailed) } fn connect_url_at( @@ -794,7 +797,7 @@ mod tests { let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - ws.send(Message::Text( + ws.send(Message::text( serde_json::json!({ "code": 0, "message": "success" }).to_string(), )) .await @@ -805,9 +808,9 @@ mod tests { let end = ws.next().await.unwrap().unwrap(); assert!(matches!(first, Message::Binary(bytes) if bytes.len() == 6_400)); assert!(matches!(second, Message::Binary(bytes) if bytes.len() == 100)); - assert_eq!(end, Message::Text(r#"{"type":"end"}"#.to_string())); + assert_eq!(end, Message::text(r#"{"type":"end"}"#.to_string())); - ws.send(Message::Text( + ws.send(Message::text( serde_json::json!({ "code": 0, "message": "success", @@ -817,7 +820,7 @@ mod tests { )) .await .unwrap(); - ws.send(Message::Text( + ws.send(Message::text( serde_json::json!({ "code": 0, "message": "success", "final": 1 }).to_string(), )) .await @@ -847,7 +850,7 @@ mod tests { let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - ws.send(Message::Text( + ws.send(Message::text( serde_json::json!({ "code": 0, "message": "success" }).to_string(), )) .await @@ -859,9 +862,9 @@ mod tests { )); assert_eq!( ws.next().await.unwrap().unwrap(), - Message::Text(r#"{"type":"end"}"#.to_string()) + Message::text(r#"{"type":"end"}"#.to_string()) ); - ws.send(Message::Text( + ws.send(Message::text( serde_json::json!({ "code": 0, "message": "success", "final": 1 }).to_string(), )) .await @@ -910,7 +913,7 @@ mod tests { let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - ws.send(Message::Text( + ws.send(Message::text( serde_json::json!({ "code": 0, "message": "success" }).to_string(), )) .await @@ -925,12 +928,12 @@ mod tests { } assert_eq!( ws.next().await.unwrap().unwrap(), - Message::Text(r#"{"type":"end"}"#.to_string()) + Message::text(r#"{"type":"end"}"#.to_string()) ); elapsed_tx .send(received_at[2].duration_since(received_at[0])) .unwrap(); - ws.send(Message::Text( + ws.send(Message::text( serde_json::json!({ "code": 0, "message": "success", "final": 1 }).to_string(), )) .await @@ -961,7 +964,7 @@ mod tests { let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - ws.send(Message::Text( + ws.send(Message::text( serde_json::json!({ "code": 4002, "message": "SENSITIVE_SERVER_RESPONSE" @@ -992,7 +995,7 @@ mod tests { let server = tokio::spawn(async move { let (stream, _) = listener.accept().await.unwrap(); let mut ws = tokio_tungstenite::accept_async(stream).await.unwrap(); - ws.send(Message::Text( + ws.send(Message::text( serde_json::json!({ "code": 0, "message": "success" }).to_string(), )) .await diff --git a/openless-all/app/crates/openless-core/src/asr/tencent_cloud/segmented.rs b/openless-all/app/crates/openless-core/src/asr/tencent_cloud/segmented.rs index 8b6493da3..f211fe6f7 100644 --- a/openless-all/app/crates/openless-core/src/asr/tencent_cloud/segmented.rs +++ b/openless-all/app/crates/openless-core/src/asr/tencent_cloud/segmented.rs @@ -475,7 +475,7 @@ mod tests { "session exceeded its audio budget" ); // Index restarts at zero for every connection. - ws.send(Message::Text(serde_json::json!({"code":0,"result":{"index":0,"slice_type":1,"voice_text_str":text}}).to_string())).await.unwrap(); + ws.send(Message::text(serde_json::json!({"code":0,"result":{"index":0,"slice_type":1,"voice_text_str":text}}).to_string())).await.unwrap(); } Message::Text(end) => { assert_eq!(end, r#"{"type":"end"}"#); @@ -488,7 +488,7 @@ mod tests { received, audio, "no skipped or duplicated PCM at a boundary" ); - ws.send(Message::Text(serde_json::json!({"code":0,"result":{"index":0,"slice_type":2,"voice_text_str":text},"final":1}).to_string())).await.unwrap(); + ws.send(Message::text(serde_json::json!({"code":0,"result":{"index":0,"slice_type":2,"voice_text_str":text},"final":1}).to_string())).await.unwrap(); } assert!( tokio::time::timeout(Duration::from_millis(100), listener.accept()) diff --git a/openless-all/app/crates/openless-core/src/asr/volcengine.rs b/openless-all/app/crates/openless-core/src/asr/volcengine.rs index 6d43255ee..7b073747e 100644 --- a/openless-all/app/crates/openless-core/src/asr/volcengine.rs +++ b/openless-all/app/crates/openless-core/src/asr/volcengine.rs @@ -917,7 +917,7 @@ async fn send_binary(writer: &SharedWriter, data: Vec) -> Result<(), Volceng "websocket not open".into(), )); }; - sink.send(Message::Binary(data)) + sink.send(Message::Binary(data.into())) .await .map_err(|e| VolcengineASRError::ConnectionFailed(e.to_string())) } @@ -1250,7 +1250,7 @@ mod tests { .status(status) .body(None) .expect("build test http response"); - tokio_tungstenite::tungstenite::Error::Http(resp) + tokio_tungstenite::tungstenite::Error::Http(Box::new(resp)) } #[test] diff --git a/openless-all/app/crates/openless-core/src/asr/xfyun.rs b/openless-all/app/crates/openless-core/src/asr/xfyun.rs index 40bf5dfb1..0b7c5335b 100644 --- a/openless-all/app/crates/openless-core/src/asr/xfyun.rs +++ b/openless-all/app/crates/openless-core/src/asr/xfyun.rs @@ -24,7 +24,7 @@ use std::time::{Duration, Instant}; use base64::Engine; use futures_util::{SinkExt, StreamExt}; -use hmac::{Hmac, Mac}; +use hmac::{Hmac, KeyInit, Mac}; use md5::{Digest as Md5Digest, Md5}; use parking_lot::Mutex as ParkingMutex; use serde_json::Value; @@ -629,7 +629,7 @@ async fn send_binary(writer: &SharedWriter, data: Vec) -> Result<(), XfyunAS "websocket not open".to_string(), )); }; - ws.send(Message::Binary(data)) + ws.send(Message::Binary(data.into())) .await .map_err(|e| XfyunASRError::ConnectionFailed(e.to_string())) } diff --git a/openless-all/app/crates/openless-core/src/cloud_providers.rs b/openless-all/app/crates/openless-core/src/cloud_providers.rs index a542bb897..26b40c4a8 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -1539,7 +1539,7 @@ async fn build_cloud_polisher_provider( )) } -fn parse_temperature(value: &str) -> Result, BackendError> { +pub fn parse_temperature(value: &str) -> Result, BackendError> { let value = value.trim(); if value.is_empty() { return Ok(None); 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 23d2acb27..f5ce14cf0 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 @@ -329,7 +329,7 @@ fn scope_bucket(scope: &SyncScope) -> DocumentResult { &scope.device_id, ]) .map_err(|_| DocumentError::InvalidDocument)?; - Ok(format!("scope:{:x}", Sha256::digest(bytes))) + Ok(format!("scope:{}", hex::encode(Sha256::digest(bytes)))) } impl SyncServiceData for CoreSyncStore { diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs index 923a97f47..bc7449622 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs @@ -96,7 +96,7 @@ impl LocalStorage { pub(crate) fn account_namespace(origin: &str, owner: &str) -> SyncResult { let binding = serde_json::to_vec(&[origin, owner]).map_err(|_| error("local_storage_unavailable"))?; - Ok(format!("remote-v2:{:x}", Sha256::digest(binding))) + Ok(format!("remote-v2:{}", hex::encode(Sha256::digest(binding)))) } pub(crate) fn initialized(&self) -> SyncResult { @@ -130,8 +130,11 @@ impl LocalStorage { ) -> SyncResult { let binding = serde_json::to_vec(&[&self.origin, owner, vault, key, &self.device_id]) .map_err(|_| error("local_storage_unavailable"))?; - SyncSecretAccount::new(format!("cloud-sync.e2ee.key.{:x}", Sha256::digest(binding))) - .map_err(|_| error("local_storage_unavailable")) + SyncSecretAccount::new(format!( + "cloud-sync.e2ee.key.{}", + hex::encode(Sha256::digest(binding)) + )) + .map_err(|_| error("local_storage_unavailable")) } pub(crate) async fn key(&self) -> SyncResult> { @@ -139,8 +142,8 @@ impl LocalStorage { .get_or_try_init(|| async { let aad = self.aad("device", "storage-key")?; let account = SyncSecretAccount::new(format!( - "cloud-sync.e2ee.local.{:x}", - Sha256::digest(aad) + "cloud-sync.e2ee.local.{}", + hex::encode(Sha256::digest(aad)) )) .map_err(|_| error("local_storage_unavailable"))?; let value = self @@ -223,8 +226,10 @@ impl LocalStorage { fn path(&self, owner: &str, name: &str) -> PathBuf { let id = format!("{owner}\0{name}"); - self.root - .join(format!("{:x}.enc", Sha256::digest(id.as_bytes()))) + self.root.join(format!( + "{}.enc", + hex::encode(Sha256::digest(id.as_bytes())) + )) } pub(crate) async fn read( diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs index 7d863dd1a..72d09f77f 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs @@ -567,7 +567,7 @@ fn has_live(unit: Option<&Unit>) -> bool { fn conflict_id(key: &DocumentKey) -> String { let identity = serde_json::to_vec(key).expect("fixed conflict identity serializes"); - format!("{:x}", Sha256::digest(identity)) + hex::encode(Sha256::digest(identity)) } /// Materialize explicit local deletes relative to the last accepted baseline; keep all old marks. diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto.rs index 2eac760a5..4bd6db11d 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto.rs @@ -5,7 +5,7 @@ use super::{types::*, Error, Result}; use argon2::{Algorithm, Argon2, Block, Params, Version}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use chacha20poly1305::{ - aead::{AeadInPlace, KeyInit}, + aead::{AeadInOut, KeyInit}, XChaCha20Poly1305, XNonce, }; use std::fmt; @@ -260,11 +260,7 @@ fn seal_frame( let cipher = XChaCha20Poly1305::new(key.expose_bytes().into()); let associated = aad(&snapshot)?; cipher - .encrypt_in_place( - XNonce::from_slice(snapshot.nonce.bytes()), - &associated, - &mut *frame, - ) + .encrypt_in_place(snapshot.nonce.bytes().into(), &associated, &mut *frame) .map_err(|_| Error::InvalidPasswordOrCiphertext)?; snapshot.ciphertext_sha256 = Sha256Digest::of(&frame); snapshot.ciphertext = URL_SAFE_NO_PAD.encode(&frame); @@ -284,11 +280,7 @@ pub(crate) fn decrypt_snapshot( let mut frame = Zeroizing::new(snapshot.decoded_ciphertext()?); let cipher = XChaCha20Poly1305::new(key.expose_bytes().into()); cipher - .decrypt_in_place( - XNonce::from_slice(snapshot.nonce.bytes()), - &aad(snapshot)?, - &mut *frame, - ) + .decrypt_in_place(snapshot.nonce.bytes().into(), &aad(snapshot)?, &mut *frame) .map_err(|_| Error::InvalidPasswordOrCiphertext)?; decode_frame(&frame) } @@ -359,7 +351,7 @@ pub(crate) fn seal_local(key: &DerivedKey, scope_aad: &[u8], plaintext: &[u8]) - let mut buffer = Zeroizing::new(Vec::with_capacity(plaintext.len() + 16)); buffer.extend_from_slice(plaintext); XChaCha20Poly1305::new(key.expose_bytes().into()) - .encrypt_in_place(XNonce::from_slice(nonce.bytes()), &associated, &mut *buffer) + .encrypt_in_place(nonce.bytes().into(), &associated, &mut *buffer) .map_err(|_| Error::InvalidPasswordOrCiphertext)?; let mut envelope = Vec::with_capacity(LOCAL_ENVELOPE_MAGIC.len() + 24 + buffer.len()); envelope.extend_from_slice(LOCAL_ENVELOPE_MAGIC); @@ -381,10 +373,12 @@ pub(crate) fn open_local( return Err(Error::InvalidPasswordOrCiphertext); } let associated = local_aad(scope_aad)?; - let nonce = &envelope[LOCAL_ENVELOPE_MAGIC.len()..prefix]; + let nonce: &XNonce = envelope[LOCAL_ENVELOPE_MAGIC.len()..prefix] + .try_into() + .map_err(|_| Error::InvalidPasswordOrCiphertext)?; let mut buffer = Zeroizing::new(envelope[prefix..].to_vec()); XChaCha20Poly1305::new(key.expose_bytes().into()) - .decrypt_in_place(XNonce::from_slice(nonce), &associated, &mut *buffer) + .decrypt_in_place(nonce, &associated, &mut *buffer) .map_err(|_| Error::InvalidPasswordOrCiphertext)?; Ok(buffer) } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto/tests.rs index 1cb43fc50..f6f5d10d5 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto/tests.rs @@ -311,18 +311,14 @@ fn wrong_nonce_length_and_permuted_aad_never_authenticate() { let nonce = snapshot.nonce.clone(); let mut frame = Zeroizing::new(snapshot.decoded_ciphertext().unwrap()); cipher - .decrypt_in_place( - XNonce::from_slice(nonce.bytes()), - &aad(&snapshot).unwrap(), - &mut *frame, - ) + .decrypt_in_place(nonce.bytes().into(), &aad(&snapshot).unwrap(), &mut *frame) .unwrap(); let mut reordered: Value = serde_json::from_slice(&aad(&snapshot).unwrap()).unwrap(); reordered.as_array_mut().unwrap().swap(3, 4); // Public deterministic fixture only; production never accepts injected nonces. cipher .encrypt_in_place( - XNonce::from_slice(nonce.bytes()), + nonce.bytes().into(), &serde_json::to_vec(&reordered).unwrap(), &mut *frame, ) diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs index 92066f34b..a146d991d 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs @@ -1269,7 +1269,7 @@ mod tests { "iterations": 3, "parallelism": 4, "salt": URL_SAFE_NO_PAD.encode([0_u8; 16]) }, "aead": "xchacha20poly1305-ietf", "codec": "json-pad64k-v1", "nonce": URL_SAFE_NO_PAD.encode([0_u8; 24]), - "ciphertextSha256": format!("{:x}", Sha256::digest(&ciphertext)), + "ciphertextSha256": hex::encode(Sha256::digest(&ciphertext)), "ciphertext": URL_SAFE_NO_PAD.encode(ciphertext) }) } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/state.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/state.rs index 3b743ef01..a4783c4a2 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/state.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/state.rs @@ -54,5 +54,5 @@ pub fn scope_id(scope: &SyncScope) -> DocumentResult { &scope.device_id, )) .map_err(|_| DocumentError::InvalidDocument)?; - Ok(format!("{:x}", Sha256::digest(bytes))) + Ok(hex::encode(Sha256::digest(bytes))) } diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests/local_migration.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests/local_migration.rs index 353fb4de1..c31f228b0 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests/local_migration.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests/local_migration.rs @@ -396,7 +396,7 @@ fn legacy_usage_bundle(bytes: &[u8]) -> Vec { }); let documents = serde_json::to_vec(&set).unwrap(); manifest["documents"]["bytes"] = json!(documents.len()); - manifest["documents"]["sha256"] = json!(format!("{:x}", Sha256::digest(&documents))); + manifest["documents"]["sha256"] = json!(hex::encode(Sha256::digest(&documents))); let mut output = zip::ZipWriter::new(Cursor::new(Vec::new())); for (name, bytes) in [ ("manifest.json", serde_json::to_vec(&manifest).unwrap()), diff --git a/openless-all/app/crates/openless-core/src/experimental_asr.rs b/openless-all/app/crates/openless-core/src/experimental_asr.rs index 5190ae0b7..0ab787bca 100644 --- a/openless-all/app/crates/openless-core/src/experimental_asr.rs +++ b/openless-all/app/crates/openless-core/src/experimental_asr.rs @@ -613,7 +613,7 @@ pub fn verify_package(model: ExperimentalAsrModel, directory: &Path) -> Result<( } hash.update(&buffer[..n]); } - if Some(format!("{:x}", hash.finalize())) != file.sha256 { + if Some(hex::encode(hash.finalize())) != file.sha256 { return Err(error(format!("Invalid checksum: {}", file.path))); } } diff --git a/openless-all/app/crates/openless-core/src/experimental_asr/tests.rs b/openless-all/app/crates/openless-core/src/experimental_asr/tests.rs index c294f1f38..7d8fef64d 100644 --- a/openless-all/app/crates/openless-core/src/experimental_asr/tests.rs +++ b/openless-all/app/crates/openless-core/src/experimental_asr/tests.rs @@ -132,7 +132,7 @@ impl Fixture { path: "model.bin".into(), url: "https://example.com/pinned/model.bin".into(), size_bytes: 4, - sha256: Some(format!("{:x}", Sha256::digest(b"test"))), + sha256: Some(hex::encode(Sha256::digest(b"test"))), }]); } let store = Arc::new( diff --git a/openless-all/app/crates/openless-core/src/history.rs b/openless-all/app/crates/openless-core/src/history.rs index 31f8efa76..92a20f826 100644 --- a/openless-all/app/crates/openless-core/src/history.rs +++ b/openless-all/app/crates/openless-core/src/history.rs @@ -1,8 +1,9 @@ //! Newest-first dictation history with retention and count caps. use std::path::{Path, PathBuf}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; +use crate::config::{Clock, SystemClock}; use crate::errors::{BackendError, BackendErrorCode}; use crate::persistence::{atomic_write, persistence_error}; use crate::types::{DictationSession, HistorySource}; @@ -12,6 +13,12 @@ pub const HISTORY_CAP: usize = 200; pub struct HistoryStore { path: PathBuf, lock: Mutex<()>, + /// 保留裁剪和「最近 N 分钟」都只认这一个时钟,而不是各自去读 + /// `chrono::Utc::now()`:后者会让注入固定时钟的后端(测试、回放)里 + /// `created_at` 与裁剪依据分成两套时间,条目可能在写入的瞬间就超期。 + /// `BackendRepositories::open` 时还不知道时钟来源,所以由后端随后用 + /// `attach_clock` 补上;没补就是系统时钟。 + clock: Mutex>, } impl HistoryStore { @@ -23,9 +30,19 @@ impl HistoryStore { Self { path, lock: Mutex::new(()), + clock: Mutex::new(Arc::new(SystemClock)), } } + /// 把后端注入的时钟交给历史仓。同一个仓只会被后端组装一次。 + pub(crate) fn attach_clock(&self, clock: Arc) { + *self.clock.lock().expect("history clock lock") = clock; + } + + fn now_utc(&self) -> chrono::DateTime { + self.clock.lock().expect("history clock lock").now_utc() + } + pub fn list(&self) -> Result, BackendError> { let _guard = self.lock_store()?; self.read_locked() @@ -66,7 +83,7 @@ impl HistoryStore { let _guard = self.lock_store()?; let mut sessions = self.read_locked()?; sessions.insert(0, session); - retain_with_policy(&mut sessions, retention_days, max_entries); + retain_with_policy(&mut sessions, retention_days, max_entries, self.now_utc()); self.write_locked(&sessions) }, ) @@ -95,7 +112,7 @@ impl HistoryStore { } else { sessions.insert(0, session); } - retain_with_policy(&mut sessions, retention_days, max_entries); + retain_with_policy(&mut sessions, retention_days, max_entries, self.now_utc()); self.write_locked(&sessions) }, ) @@ -123,7 +140,7 @@ impl HistoryStore { } let _guard = self.lock_store()?; let sessions = self.read_locked()?; - let cutoff = chrono::Utc::now() - chrono::Duration::minutes(i64::from(minutes)); + let cutoff = self.now_utc() - chrono::Duration::minutes(i64::from(minutes)); Ok(sessions .into_iter() .take_while(|session| { @@ -206,13 +223,14 @@ fn retain_with_policy( sessions: &mut Vec, retention_days: u32, max_entries: Option, + now: chrono::DateTime, ) { // Quick notes are intentionally outside the ordinary history retention // policy. A recording-start draft is also protected until it reaches a // terminal state, because its audio may be the only recoverable artifact // after a process crash. if retention_days > 0 { - let cutoff = chrono::Utc::now() - chrono::Duration::days(i64::from(retention_days)); + let cutoff = now - chrono::Duration::days(i64::from(retention_days)); sessions.retain(|session| { session.source == HistorySource::QuickNote || session.error_code.as_deref() == Some("recording") @@ -329,6 +347,53 @@ mod tests { let _ = std::fs::remove_file(path); } + #[test] + fn retention_follows_the_injected_clock_instead_of_the_wall_clock() { + // CI 2026-09-27 之后这个场景一直是红的:裁剪偷读 `chrono::Utc::now()`, + // 于是一个 injected 固定时钟(2026-08-28)+ retention 30 天的后端,会把 + // 刚写入的条目当成过期档删掉。 + let path = std::env::temp_dir().join(format!( + "openless-history-clock-{}.json", + uuid::Uuid::new_v4().simple() + )); + let store = HistoryStore::at_path(path.clone()); + let fixed = chrono::DateTime::parse_from_rfc3339("2026-08-28T12:34:56+00:00") + .unwrap() + .with_timezone(&chrono::Utc); + store.attach_clock(Arc::new(crate::testing::FixedClock::new( + fixed, + fixed.date_naive(), + ))); + + store + .append_with_retention(session("kept", fixed.to_rfc3339()), 30, Some(20)) + .unwrap(); + assert_eq!( + store.list().unwrap().len(), + 1, + "a fresh entry is never stale" + ); + assert_eq!( + store.recent_within_minutes(5).unwrap().len(), + 1, + "the polish context window reads the same clock" + ); + + // 功能本身不能被放宽:同一个固定时钟下超期的条目照旧被裁掉。 + let stale = (fixed - chrono::Duration::days(31)).to_rfc3339(); + store + .append_with_retention(session("stale", stale), 30, None) + .unwrap(); + let ids: Vec = store + .list() + .unwrap() + .into_iter() + .map(|entry| entry.id) + .collect(); + assert_eq!(ids, vec!["kept".to_string()]); + let _ = std::fs::remove_file(path); + } + #[test] fn update_delete_clear_and_recent_queries_are_stable() { let path = std::env::temp_dir().join(format!( diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index a5f35420b..bdf7f9ed1 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod audio; pub mod auxiliary; pub mod cli; mod cloud_providers; +pub use cloud_providers::parse_temperature; pub mod cloud_sync; pub mod cloud_sync_e2ee; pub mod cloud_sync_e2ee_documents; diff --git a/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs b/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs index f4fb2b7bf..e36050918 100644 --- a/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs +++ b/openless-all/app/crates/openless-core/src/local_migration_bundle/container.rs @@ -546,7 +546,11 @@ fn read_entries(bytes: &[u8]) -> BundleResult>> { let file = archive .by_index(index) .map_err(|_| BundleError::MalformedContainer)?; - let name = file.name().to_owned(); + // `zip` 9(本仓)返回 `Result, ZipError>`,2.x 直接返回 `&str`。 + let name = file + .name() + .map_err(|_| BundleError::MalformedContainer)? + .into_owned(); if file.is_dir() || file.encrypted() || !safe_entry_name(&name) diff --git a/openless-all/app/crates/openless-core/src/local_migration_bundle/crypto.rs b/openless-all/app/crates/openless-core/src/local_migration_bundle/crypto.rs index 3b2caf162..68b76b8fa 100644 --- a/openless-all/app/crates/openless-core/src/local_migration_bundle/crypto.rs +++ b/openless-all/app/crates/openless-core/src/local_migration_bundle/crypto.rs @@ -54,9 +54,11 @@ pub(crate) fn encrypt( fill(&mut nonce).map_err(|_| BundleError::InvalidPassword)?; let key = derive_key(password, &salt)?; let cipher = XChaCha20Poly1305::new((&*key.0).into()); + // `cipher` 0.5 把 `from_slice` 标成 deprecated:`&[u8; N]` 直接转 `&Array`。 + let nonce_ref: &XNonce = (&nonce).into(); let ciphertext = cipher .encrypt( - XNonce::from_slice(&nonce), + nonce_ref, Payload { msg: plaintext, aad, @@ -92,9 +94,10 @@ pub(crate) fn decrypt( .map_err(|_| BundleError::InvalidPassword)?; let key = derive_key(password, salt)?; let cipher = XChaCha20Poly1305::new((&*key.0).into()); + let nonce_ref: &XNonce = nonce.into(); let plaintext = cipher .decrypt( - XNonce::from_slice(nonce), + nonce_ref, Payload { msg: &envelope[HEADER_BYTES..], aad, diff --git a/openless-all/app/crates/openless-core/src/model_store.rs b/openless-all/app/crates/openless-core/src/model_store.rs index 744f3f9aa..65da9771a 100644 --- a/openless-all/app/crates/openless-core/src/model_store.rs +++ b/openless-all/app/crates/openless-core/src/model_store.rs @@ -2565,7 +2565,7 @@ fn sha256_file(path: &Path) -> Result { } hasher.update(&buffer[..read]); } - Ok(format!("{:x}", hasher.finalize())) + Ok(hex::encode(hasher.finalize())) } pub fn validate_model_id(value: &str) -> Result<(), BackendError> { @@ -3058,9 +3058,13 @@ pub fn extract_archive_safely( let mut entry = archive .by_index(index) .map_err(|error| invalid(error.to_string()))?; - validate_model_path(entry.name())?; - if matches!(entry.name(), MODEL_READY_SENTINEL | MODEL_PARTIAL_INDEX) - || !seen.insert(entry.name().to_string()) + let name = entry + .name() + .map_err(|error| invalid(error.to_string()))? + .into_owned(); + validate_model_path(&name)?; + if matches!(name.as_str(), MODEL_READY_SENTINEL | MODEL_PARTIAL_INDEX) + || !seen.insert(name.clone()) { return Err(invalid("archive contains a reserved or duplicate path")); } @@ -3076,7 +3080,7 @@ pub fn extract_archive_safely( if total > DEFAULT_MODEL_MAX_TOTAL_BYTES { return Err(invalid("archive exceeds the configured total size limit")); } - let output = staging.join(entry.name()); + let output = staging.join(name); if let Some(parent) = output.parent() { std::fs::create_dir_all(parent).map_err(platform_error)?; } @@ -3463,7 +3467,7 @@ mod tests { path: "weights.bin".into(), url: "https://example.test/weights.bin".into(), size_bytes: body.len() as u64, - sha256: Some(format!("{:x}", Sha256::digest(&body))), + sha256: Some(hex::encode(Sha256::digest(&body))), }], ) .unwrap(); @@ -3507,7 +3511,7 @@ mod tests { path: "weights.bin".into(), url: "https://example.test/weights.bin".into(), size_bytes: body.len() as u64, - sha256: Some(format!("{:x}", Sha256::digest(&body))), + sha256: Some(hex::encode(Sha256::digest(&body))), }], ) .unwrap(); @@ -3631,7 +3635,7 @@ mod tests { path: "weights.bin".into(), url: "https://example.test/weights.bin".into(), size_bytes: body.len() as u64, - sha256: Some(format!("{:x}", Sha256::digest(&body))), + sha256: Some(hex::encode(Sha256::digest(&body))), }], ) .unwrap(); diff --git a/openless-all/app/crates/openless-core/src/net.rs b/openless-all/app/crates/openless-core/src/net.rs index 084826627..26f321981 100644 --- a/openless-all/app/crates/openless-core/src/net.rs +++ b/openless-all/app/crates/openless-core/src/net.rs @@ -27,6 +27,9 @@ use std::time::Duration; use once_cell::sync::Lazy; use parking_lot::Mutex; +/// Client type returned by the shared factories across adapter crate boundaries. +pub use reqwest::Client as HttpClient; + /// Whether the user allows the app to use the system proxy (issue #869). Default /// true = follow the system proxy, matching historical behavior; when off, all /// reqwest clients are built with `.no_proxy()` for direct connections. diff --git a/openless-all/app/crates/openless-core/src/style_pack_archive.rs b/openless-all/app/crates/openless-core/src/style_pack_archive.rs index f7aada5c0..35950f9d5 100644 --- a/openless-all/app/crates/openless-core/src/style_pack_archive.rs +++ b/openless-all/app/crates/openless-core/src/style_pack_archive.rs @@ -436,7 +436,10 @@ fn preflight_archive_metadata( let file = archive .by_index(index) .with_context(|| format!("read style pack zip entry metadata at index {index}"))?; - let name = file.name().to_string(); + let name = file + .name() + .context("decode style pack zip entry name")? + .into_owned(); validate_entry_name(&name, file.is_dir())?; if file.enclosed_name().is_none() { bail!("unsafe style pack zip entry name: {name}"); diff --git a/openless-all/app/harmony/ipc-audit.json b/openless-all/app/harmony/ipc-audit.json index 2d3391d8e..12e59360f 100644 --- a/openless-all/app/harmony/ipc-audit.json +++ b/openless-all/app/harmony/ipc-audit.json @@ -1,8 +1,8 @@ { "scope": "Static shared IPC command coverage; excludes event subscriptions, dynamic commands and desktop-only calls outside src/lib/ipc.", - "total": 190, + "total": 191, "wired": 100, - "missing": 90, + "missing": 91, "commands": [ { "name": "accept_pending_correction", @@ -58,7 +58,7 @@ "calledFrom": [ "src/lib/ipc/dictation.ts" ], - "implementation": "rust" + "implementation": "arkts" }, { "name": "cancel_selection_polish_preview", @@ -823,6 +823,13 @@ ], "implementation": "rust" }, + { + "name": "open_android_local_migration", + "calledFrom": [ + "src/lib/ipc/local-migration.ts" + ], + "implementation": "unimplemented" + }, { "name": "open_external_url", "calledFrom": [ @@ -1227,7 +1234,7 @@ "calledFrom": [ "src/lib/ipc/dictation.ts" ], - "implementation": "rust" + "implementation": "arkts" }, { "name": "start_microphone_level_monitor", @@ -1255,7 +1262,7 @@ "calledFrom": [ "src/lib/ipc/dictation.ts" ], - "implementation": "rust" + "implementation": "arkts" }, { "name": "stop_microphone_level_monitor", diff --git a/openless-all/app/linux-egui/Cargo.toml b/openless-all/app/linux-egui/Cargo.toml index 6880ad512..f7b0e7dd0 100644 --- a/openless-all/app/linux-egui/Cargo.toml +++ b/openless-all/app/linux-egui/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" license = "AGPL-3.0-only" description = "Linux host seam for the OpenLess egui frontend" edition = "2021" -rust-version = "1.95" +rust-version = "1.99" publish = false [dependencies] @@ -14,13 +14,14 @@ fs2 = "0.4" futures-util = "0.3" log = "0.4" simplelog = "0.12" -base64 = "0.22" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +base64 = "0.23.1" +reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream", "query", "form"] } semver = "1" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } serde = { version = "1", features = ["derive"] } serde_json = "1" uuid = { version = "1", features = ["v4"] } +unicode-segmentation = "1.13" [features] default = ["x11-overlay"] @@ -30,33 +31,30 @@ x11-overlay = ["dep:x11rb"] [target.'cfg(target_os = "linux")'.dependencies] dbus = "0.9" -keyring = { version = "3.6.3", default-features = false, features = ["linux-native-sync-persistent", "crypto-rust"] } +keyring = "4.2.0" cpal = { version = "0.18.2", default-features = false, features = ["pipewire", "pulseaudio"] } -rfd = { version = "0.16", default-features = false, features = ["xdg-portal", "tokio"] } -egui = "=0.36.2" +rfd = { version = "0.17.2", default-features = false, features = ["xdg-portal"] } +egui = "=0.37.0" # 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"] } +eframe = { version = "=0.37.0", 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" +resvg = { version = "=0.48.1", default-features = false, features = ["text", "system-fonts"] } +roxmltree = "0.21.1" libc = "0.2" -x11rb = { version = "0.13.2", optional = true } -axum = { version = "0.7", default-features = false, features = ["ws", "http1", "tokio"] } +x11rb = { version = "0.14.0", optional = true, features = ["randr"] } +axum = { version = "0.8.9", default-features = false, features = ["ws", "http1", "tokio"] } hyper-util = { version = "0.1", features = ["tokio", "server-auto", "server", "http1"] } local-ip-address = "0.6" -base64 = "0.22" -sha2 = "0.10" -rcgen = { version = "0.13", features = ["x509-parser"] } -x509-parser = { version = "0.16", features = ["verify"] } +base64 = "0.23.1" +sha2 = "0.11.0" +rcgen = { version = "0.14.10", features = ["x509-parser"] } +x509-parser = { version = "0.18.1", features = ["verify"] } time = "0.3" tempfile = "3" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12", "logging"] } diff --git a/openless-all/app/linux-egui/assets/lucide/Bolt.svg b/openless-all/app/linux-egui/assets/lucide/Bolt.svg index 78748b21b..0d632f645 100644 --- a/openless-all/app/linux-egui/assets/lucide/Bolt.svg +++ b/openless-all/app/linux-egui/assets/lucide/Bolt.svg @@ -1 +1 @@ - + diff --git a/openless-all/app/linux-egui/assets/lucide/ChevronLeft.svg b/openless-all/app/linux-egui/assets/lucide/ChevronLeft.svg new file mode 100644 index 000000000..fbb681f0e --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/ChevronLeft.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 index 915477f9c..868bca94f 100644 --- a/openless-all/app/linux-egui/assets/lucide/Clock.svg +++ b/openless-all/app/linux-egui/assets/lucide/Clock.svg @@ -1 +1 @@ - + diff --git a/openless-all/app/linux-egui/assets/lucide/Feather.svg b/openless-all/app/linux-egui/assets/lucide/Feather.svg index 38ea56aa3..cf2f21a20 100644 --- a/openless-all/app/linux-egui/assets/lucide/Feather.svg +++ b/openless-all/app/linux-egui/assets/lucide/Feather.svg @@ -1 +1 @@ - + diff --git a/openless-all/app/linux-egui/assets/lucide/Grip.svg b/openless-all/app/linux-egui/assets/lucide/Grip.svg new file mode 100644 index 000000000..0c78def14 --- /dev/null +++ b/openless-all/app/linux-egui/assets/lucide/Grip.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 index 64f18ff63..77acec13d 100644 --- a/openless-all/app/linux-egui/assets/lucide/Help.svg +++ b/openless-all/app/linux-egui/assets/lucide/Help.svg @@ -1 +1 @@ - + diff --git a/openless-all/app/linux-egui/assets/lucide/History.svg b/openless-all/app/linux-egui/assets/lucide/History.svg index 04bb758be..21e050d60 100644 --- a/openless-all/app/linux-egui/assets/lucide/History.svg +++ b/openless-all/app/linux-egui/assets/lucide/History.svg @@ -1 +1 @@ - + diff --git a/openless-all/app/linux-egui/assets/lucide/Layout.svg b/openless-all/app/linux-egui/assets/lucide/Layout.svg index 8a3e2b32e..4a93cf99f 100644 --- a/openless-all/app/linux-egui/assets/lucide/Layout.svg +++ b/openless-all/app/linux-egui/assets/lucide/Layout.svg @@ -1 +1 @@ - + diff --git a/openless-all/app/linux-egui/assets/lucide/More.svg b/openless-all/app/linux-egui/assets/lucide/More.svg index 6a9e28d3c..8e8520da3 100644 --- a/openless-all/app/linux-egui/assets/lucide/More.svg +++ b/openless-all/app/linux-egui/assets/lucide/More.svg @@ -1 +1 @@ - + diff --git a/openless-all/app/linux-egui/assets/lucide/Overview.svg b/openless-all/app/linux-egui/assets/lucide/Overview.svg index c8860e9a5..46473a5dd 100644 --- a/openless-all/app/linux-egui/assets/lucide/Overview.svg +++ b/openless-all/app/linux-egui/assets/lucide/Overview.svg @@ -1 +1 @@ - + diff --git a/openless-all/app/linux-egui/assets/lucide/Sparkle.svg b/openless-all/app/linux-egui/assets/lucide/Sparkle.svg index 0606c1cae..20439b582 100644 --- a/openless-all/app/linux-egui/assets/lucide/Sparkle.svg +++ b/openless-all/app/linux-egui/assets/lucide/Sparkle.svg @@ -1 +1 @@ - + diff --git a/openless-all/app/linux-egui/assets/lucide/Trash.svg b/openless-all/app/linux-egui/assets/lucide/Trash.svg index 11f17232e..ffccdb179 100644 --- a/openless-all/app/linux-egui/assets/lucide/Trash.svg +++ b/openless-all/app/linux-egui/assets/lucide/Trash.svg @@ -1 +1 @@ - + diff --git a/openless-all/app/linux-egui/assets/lucide/Vocab.svg b/openless-all/app/linux-egui/assets/lucide/Vocab.svg index a816d47f7..bf00b2358 100644 --- a/openless-all/app/linux-egui/assets/lucide/Vocab.svg +++ b/openless-all/app/linux-egui/assets/lucide/Vocab.svg @@ -1 +1 @@ - + diff --git a/openless-all/app/linux-egui/package-revision b/openless-all/app/linux-egui/package-revision index 257e56326..dee261df4 100644 --- a/openless-all/app/linux-egui/package-revision +++ b/openless-all/app/linux-egui/package-revision @@ -1 +1 @@ -102 +140 diff --git a/openless-all/app/linux-egui/src/audio.rs b/openless-all/app/linux-egui/src/audio.rs index 64a39b8c8..3c51ae00d 100644 --- a/openless-all/app/linux-egui/src/audio.rs +++ b/openless-all/app/linux-egui/src/audio.rs @@ -454,6 +454,10 @@ fn run_audio_thread(args: LinuxAudioThreadArgs) { result = Ok(stream); break; } + Err(error) if error.code == BackendErrorCode::PermissionDenied => { + let _ = startup.send(Err(error)); + return; + } Err(error) => { log::warn!("{backend} audio backend failed; trying next backend: {error}"); result = Err(error); @@ -503,27 +507,92 @@ fn try_start_audio_stream( let device = select_input_device(host, preferred_device_name).map_err(|error| { BackendError::new(error.code, format!("{backend} backend: {}", error.message)) })?; - let supported = device - .default_input_config() - .map_err(|error| classify_audio_error("default input config", error.to_string()))?; - let sample_format = supported.sample_format(); - let input_sample_rate = supported.sample_rate(); - let channels = usize::from(supported.channels()); - let config: cpal::StreamConfig = supported.into(); - let stream = build_input_stream( - &device, - &config, - sample_format, - input_sample_rate, - channels, - frame_tx.clone(), - Arc::clone(stop), - Arc::clone(runtime_error), - )?; - stream - .play() - .map_err(|error| classify_audio_error("start input stream", error.to_string()))?; - Ok(stream) + let mut native = None; + let mut start = |supported: cpal::SupportedStreamConfig| -> Result { + let accepting = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let config = supported.config(); + let stream = build_input_stream( + &device, + &config, + supported.sample_format(), + supported.sample_rate(), + usize::from(supported.channels()), + frame_tx.clone(), + Arc::clone(stop), + Arc::clone(runtime_error), + Arc::clone(&accepting), + )?; + stream + .play() + .map_err(|error| classify_audio_error("start input stream", error.to_string()))?; + accepting.store(true, std::sync::atomic::Ordering::Release); + Ok(stream) + }; + let initial_error = match device.default_input_config() { + Ok(config) => { + native = Some(config); + match start(config) { + Ok(stream) => return Ok(stream), + Err(error) if error.code == BackendErrorCode::PermissionDenied => { + return Err(error) + } + Err(error) => error, + } + } + Err(error) => classify_audio_error("default input config", error.to_string()), + }; + if initial_error.code == BackendErrorCode::PermissionDenied { + return Err(initial_error); + } + let ranges = match device.supported_input_configs() { + Ok(ranges) => ranges, + Err(_) => return Err(initial_error), + }; + try_input_candidates( + native_input_alternatives(native.as_ref(), ranges), + initial_error, + &mut start, + ) +} + +#[cfg(target_os = "linux")] +fn try_input_candidates( + candidates: impl IntoIterator, + mut last_error: BackendError, + mut start: impl FnMut(T) -> Result, +) -> Result { + for candidate in candidates { + match start(candidate) { + Ok(stream) => return Ok(stream), + Err(error) if error.code == BackendErrorCode::PermissionDenied => return Err(error), + Err(error) => last_error = error, + } + } + Err(last_error) +} + +#[cfg(target_os = "linux")] +fn native_input_alternatives( + default: Option<&cpal::SupportedStreamConfig>, + ranges: impl IntoIterator, +) -> Vec { + let native_rate = default.map(|config| config.sample_rate()); + let mut candidates = Vec::new(); + for range in ranges { + let min = range.min_sample_rate(); + let max = range.max_sample_rate(); + for rate in [native_rate.unwrap_or(max).clamp(min, max), min, max] { + let candidate = range.with_sample_rate(rate); + let same = |other: &cpal::SupportedStreamConfig| { + other.config() == candidate.config() + && other.sample_format() == candidate.sample_format() + }; + if !default.is_some_and(same) && !candidates.iter().any(same) { + candidates.push(candidate); + } + } + } + candidates } #[cfg(target_os = "linux")] @@ -565,6 +634,7 @@ fn build_input_stream( frame_tx: SyncSender, stop: Arc, runtime_error: Arc>>, + accepting: Arc, ) -> Result { use cpal::traits::DeviceTrait; @@ -575,11 +645,15 @@ fn build_input_stream( let stop_for_callback = Arc::clone(&stop); let runtime_error_for_callback = Arc::clone(&runtime_error); let runtime_error_for_error = Arc::clone(&runtime_error); + let accepting_audio = Arc::clone(&accepting); + let accepting_error = Arc::clone(&accepting); device .build_input_stream::<$sample, _, _>( *config, move |data: &[$sample], _| { - if stop_for_callback.load(std::sync::atomic::Ordering::Acquire) { + if !accepting_audio.load(std::sync::atomic::Ordering::Acquire) + || stop_for_callback.load(std::sync::atomic::Ordering::Acquire) + { return; } let samples = data.iter().copied().map($to_f32).collect::>(); @@ -607,6 +681,9 @@ fn build_input_stream( } }, move |error| { + if !accepting_error.load(std::sync::atomic::Ordering::Acquire) { + return; + } let error = classify_audio_error("input stream", error.to_string()); let mut slot = runtime_error_for_error .lock() @@ -624,6 +701,18 @@ fn build_input_stream( match sample_format { cpal::SampleFormat::F32 => make_stream!(f32, |sample: f32| sample), + cpal::SampleFormat::F64 => make_stream!(f64, |sample: f64| sample as f32), + cpal::SampleFormat::I64 => { + make_stream!(i64, |sample: i64| (sample as f64 / i64::MAX as f64) as f32) + } + cpal::SampleFormat::U64 => make_stream!( + u64, + |sample: u64| (sample as f64 / u64::MAX as f64 * 2.0 - 1.0) as f32 + ), + cpal::SampleFormat::U32 => make_stream!( + u32, + |sample: u32| (sample as f64 / u32::MAX as f64 * 2.0 - 1.0) as f32 + ), cpal::SampleFormat::I16 => { make_stream!(i16, |sample: i16| sample as f32 / i16::MAX as f32) } @@ -771,6 +860,55 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn native_alternatives_preserve_channels_format_and_advertised_rates() { + let range = cpal::SupportedStreamConfigRange::new( + 2, + 44_100, + 48_000, + cpal::SupportedBufferSize::Unknown, + cpal::SampleFormat::F32, + ); + let native = range.with_sample_rate(48_000); + let alternatives = native_input_alternatives(Some(&native), [range, range]); + assert_eq!(alternatives.len(), 1); + assert_eq!(alternatives[0].channels(), 2); + assert_eq!(alternatives[0].sample_format(), cpal::SampleFormat::F32); + assert_eq!(alternatives[0].sample_rate(), 44_100); + } + + #[cfg(target_os = "linux")] + #[test] + fn candidates_stop_at_success_or_permission_failure() { + let mut attempts = Vec::new(); + let result = try_input_candidates( + [1, 2, 3], + classify_audio_error("start", "failed".into()), + |candidate| { + attempts.push(candidate); + if candidate == 2 { + Ok(candidate) + } else { + Err(classify_audio_error("start", "failed".into())) + } + }, + ); + assert_eq!(result.unwrap(), 2); + assert_eq!(attempts, [1, 2]); + let mut attempts = 0; + let result: Result<(), _> = try_input_candidates( + [1, 2], + classify_audio_error("start", "failed".into()), + |_| { + attempts += 1; + Err(classify_audio_error("start", "permission denied".into())) + }, + ); + assert_eq!(result.unwrap_err().code, BackendErrorCode::PermissionDenied); + assert_eq!(attempts, 1); + } + #[test] fn wav_archive_header_and_pcm_round_trip() { let pcm = [1u8, 0, 2, 0]; diff --git a/openless-all/app/linux-egui/src/backend.rs b/openless-all/app/linux-egui/src/backend.rs index c41399ff4..346dcb1f4 100644 --- a/openless-all/app/linux-egui/src/backend.rs +++ b/openless-all/app/linux-egui/src/backend.rs @@ -136,7 +136,19 @@ impl LinuxBackendBuilder { .with_auxiliary_polisher(auxiliary_polisher) .with_credential_store(credential_store) .with_services(services) - .with_marketplace_config(MarketplaceConfig::production()) + // 加密云同步是 Marketplace 的可选扩展:不装这个配置,Core 里 + // `encrypted_sync` 永远是 None,界面上任何同步动作都会得到 + // `service_unavailable`(用户看到「同步暂不可用」)。 + // Tauri 在 coordinator.rs 里也是这么接的,两边保持一致。 + .with_marketplace_config(MarketplaceConfig::production().with_encrypted_sync( + openless_core::cloud_sync_e2ee::EncryptedSyncConfig { + // 自建源是运行时偏好;这里固定本地加密存储的身份, + // 换服务器不会改变本地保险库标识。 + service_origin: + openless_core::cloud_sync_e2ee::DEFAULT_SYNC_SERVICE_ORIGIN.into(), + app_version: env!("CARGO_PKG_VERSION").into(), + }, + )) .with_settings_runtime(Arc::new(LinuxSettingsRuntime::new(store)))) } @@ -432,6 +444,36 @@ mod tests { let _ = std::fs::remove_dir_all(data_dir); } + /// 加密云同步必须由宿主显式装配。漏掉 `with_encrypted_sync` 时 Core 的 + /// `encrypted_sync` 是 `None`,界面每个同步动作都返回 `service_unavailable`, + /// 用户只能看到「暂不可用」——这正是之前那块卡片「用不了」的原因。 + #[tokio::test] + async fn shared_provider_builder_wires_encrypted_cloud_sync() { + let data_dir = std::env::temp_dir().join(format!( + "openless-linux-encrypted-sync-{}-{}", + std::process::id(), + uuid::Uuid::new_v4().simple() + )); + let runtime = LinuxBackendBuilder::from_shared_providers(BackendConfig { + data_dir: data_dir.clone(), + ..BackendConfig::default() + }) + .unwrap() + .with_recorder(Arc::new(FixtureAudioRecorder::new(Vec::new(), Vec::new()))) + .with_text_inserter(Arc::new(FixtureTextInserter::with_outcome( + InsertOutcome::Inserted, + ))) + .build() + .unwrap(); + + let status = runtime + .backend + .cloud_sync_e2ee_status() + .expect("encrypted sync must be constructed by the production builder"); + assert!(!status.enabled, "a fresh data dir starts disabled"); + let _ = std::fs::remove_dir_all(data_dir); + } + #[tokio::test] async fn shared_provider_factory_does_not_fall_back_to_unsupported() -> Result<(), BackendError> { diff --git a/openless-all/app/linux-egui/src/capabilities.rs b/openless-all/app/linux-egui/src/capabilities.rs index e58ba6f30..a9d7454d0 100644 --- a/openless-all/app/linux-egui/src/capabilities.rs +++ b/openless-all/app/linux-egui/src/capabilities.rs @@ -203,6 +203,7 @@ fn enumerate_microphones() -> Result, BackendError> { let host = cpal::default_host(); let default_name = host.default_input_device().map(|device| device.to_string()); + let pulse = pulse_sources(); let devices = host.input_devices().map_err(|error| { BackendError::new( BackendErrorCode::Platform, @@ -212,11 +213,16 @@ fn enumerate_microphones() -> Result, BackendError> { let mut out: Vec = Vec::new(); for (index, device) in devices.enumerate() { let name = device.to_string(); + let device_id = device + .id() + .ok() + .filter(|id| matches!(id.host(), cpal::HostId::PipeWire | cpal::HostId::PulseAudio)); + let source_id = device_id.as_ref().map(|id| id.id()); let channels = device .default_input_config() .map(|config| config.channels()) .unwrap_or(0); - if !selectable_microphone(&name, channels) { + if !keep_microphone(&name, channels, source_id, &pulse) { continue; } // 同一个设备会以多条节点出现(例如「内置音频 Pro」出现两次)。 @@ -225,13 +231,134 @@ fn enumerate_microphones() -> Result, BackendError> { } out.push(MicrophoneDevice { id: format!("cpal:{index}:{name}"), - is_default: default_name.as_deref() == Some(name.as_str()), + // cpal 的 `default_input_device` 拿到的是 PipeWire 的 `default_input` + // 合成节点,永远不等于真实设备名;真正要标「默认」的是 pulse 的默认源。 + is_default: pulse.is_default(source_id) + || default_name.as_deref() == Some(name.as_str()), name, }); } Ok(out) } +/// 仅剔除能按稳定身份唯一关联的输出监听;显示名相同不足以证明设备是监听。 +#[cfg(target_os = "linux")] +fn keep_microphone( + name: &str, + channels: u16, + device_id: Option<&str>, + pulse: &PulseSources, +) -> bool { + selectable_microphone(name, channels) + && !pulse + .source(device_id) + .is_some_and(|source| source.monitor_of_sink.is_some()) +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct PulseSource { + name: String, + node_name: Option, + monitor_of_sink: Option, +} + +#[derive(Debug, Default, PartialEq, Eq)] +struct PulseSources { + sources: Vec, + /// 默认采集源的稳定名称,不使用可能重名的 Description。 + default: Option, +} + +#[cfg(target_os = "linux")] +impl PulseSources { + fn source(&self, device_id: Option<&str>) -> Option<&PulseSource> { + let device_id = device_id.filter(|id| !id.is_empty())?; + let mut matches = self.sources.iter().filter(|source| { + source.name == device_id + || source.node_name.as_deref() == Some(device_id) + || source.monitor_of_sink.as_deref() == Some(device_id) + }); + let source = matches.next()?; + matches.next().is_none().then_some(source) + } + + fn is_default(&self, device_id: Option<&str>) -> bool { + self.source(device_id).is_some_and(|source| { + source.monitor_of_sink.is_none() + && self.default.as_deref() == Some(source.name.as_str()) + }) + } +} + +/// 读 `pactl`。工具缺失或失败时返回空值:宁可多列(退回旧行为),也不误删用户的麦克风。 +#[cfg(target_os = "linux")] +fn pulse_sources() -> PulseSources { + let list = std::process::Command::new("pactl") + .args(["list", "sources"]) + .output(); + let Ok(list) = list else { + return PulseSources::default(); + }; + if !list.status.success() { + return PulseSources::default(); + } + let default = std::process::Command::new("pactl") + .arg("get-default-source") + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|name| !name.is_empty()); + parse_pulse_sources(&String::from_utf8_lossy(&list.stdout), default.as_deref()) +} + +/// 解析 `pactl list sources`。 +/// +/// 每个 source 形如(顶层字段缩进一个制表符,`Properties:` 里两个;下面用空格示意): +/// +/// ```text +/// Source #57 +/// Name: alsa_output.pci-0000_00_1f.3.pro-output-0.monitor +/// Description: Monitor of Built-in Audio Pro +/// Monitor of Sink: alsa_output.pci-0000_00_1f.3.pro-output-0 +/// Properties: +/// node.name = "alsa_output.pci-0000_00_1f.3.pro-output-0.monitor" +/// ``` +#[cfg(target_os = "linux")] +fn parse_pulse_sources(text: &str, default_name: Option<&str>) -> PulseSources { + let mut out = PulseSources { + default: default_name.map(str::to_string), + ..Default::default() + }; + let mut source = PulseSource::default(); + for line in text.lines() { + let Some(field) = line.strip_prefix('\t') else { + continue; + }; + // PipeWire 的 CPAL ID 是 node.name;PulseAudio 的 ID 是顶层 Name。 + if let Some(value) = field.strip_prefix("\tnode.name = ") { + source.node_name = Some(value.trim().trim_matches('"').to_string()); + } + if field.starts_with('\t') { + continue; + } + if let Some(value) = field.strip_prefix("Name: ") { + if !source.name.is_empty() { + out.sources.push(std::mem::take(&mut source)); + } + source.name = value.trim().to_string(); + } else if let Some(value) = field.strip_prefix("Monitor of Sink:") { + let value = value.trim(); + source.monitor_of_sink = + (!value.is_empty() && value != "n/a").then(|| value.to_string()); + } + } + if !source.name.is_empty() { + out.sources.push(source); + } + out +} + /// cpal 的 PipeWire 后端把**所有节点**都当成输入设备:输出监听(`sink-*`、 /// `*.monitor`)、合成默认节点、以及 HDMI/环绕监听都会出现在 `input_devices()` 里, /// 同名设备还会重复出现。原样透传会让设置页的「首选麦克风」变成一个装满噪声的 @@ -260,7 +387,155 @@ fn selectable_microphone(name: &str, channels: u16) -> bool { #[cfg(all(test, target_os = "linux"))] mod microphone_filter_tests { - use super::selectable_microphone; + use super::{keep_microphone, parse_pulse_sources, selectable_microphone}; + + /// 本机 `pactl list sources` 的真实形态(两个 sink 监听 + 一个真采集源), + /// 设备名沿用现场的转义写法。 + const PACTL_LIST_SOURCES: &str = "Source #57\n\ +\tName: alsa_output.pci-0000_01_00.1.pro-output-3.monitor\n\ +\tDescription: Monitor of TU116 High Definition Audio Controller Pro\n\ +\tMonitor of Sink: alsa_output.pci-0000_01_00.1.pro-output-3\n\ +\tProperties:\n\ +\t\tnode.name = \"alsa_output.pci-0000_01_00.1.pro-output-3.monitor\"\n\ +\t\tdevice.description = \"TU116 High Definition Audio Controller\"\n\ +Source #5207\n\ +\tName: alsa_output.pci-0000_00_1f.3.pro-output-0.monitor\n\ +\tDescription: Monitor of \u{5185}\u{7f6e}\u{97f3}\u{9891} Pro\n\ +\tMonitor of Sink: alsa_output.pci-0000_00_1f.3.pro-output-0\n\ +Source #5208\n\ +\tName: alsa_input.pci-0000_00_1f.3.pro-input-2\n\ +\tDescription: \u{5185}\u{7f6e}\u{97f3}\u{9891} Pro 2\n\ +\tMonitor of Sink: n/a\n\ +\tProperties:\n\ +\t\tdevice.description = \"\u{5185}\u{7f6e}\u{97f3}\u{9891} Pro 2\"\n"; + + #[test] + fn monitor_sources_are_not_offered_as_microphones() { + let pulse = parse_pulse_sources( + PACTL_LIST_SOURCES, + Some("alsa_input.pci-0000_00_1f.3.pro-input-2"), + ); + assert_eq!(pulse.sources.len(), 3); + assert_eq!( + pulse.sources[0].node_name.as_deref(), + Some("alsa_output.pci-0000_01_00.1.pro-output-3.monitor") + ); + assert!(pulse.sources[0].monitor_of_sink.is_some()); + assert!(pulse.sources[1].monitor_of_sink.is_some()); + assert!(pulse.sources[2].monitor_of_sink.is_none()); + // 默认采集源是那台真麦克风,不是任何监听。 + assert_eq!( + pulse.default.as_deref(), + Some("alsa_input.pci-0000_00_1f.3.pro-input-2") + ); + } + + #[test] + fn real_capture_sources_survive_the_monitor_filter() { + let pulse = parse_pulse_sources( + PACTL_LIST_SOURCES, + Some("alsa_input.pci-0000_00_1f.3.pro-input-2"), + ); + let keep = + |name: &str, channels: u16, id: &str| keep_microphone(name, channels, Some(id), &pulse); + // 现场 cpal 给出的 8 个节点,逐个核对(raw 顺序即此)。 + assert!(!keep("default_sink", 2, "sink_default")); + assert!(!keep("default_input", 2, "input_default")); + assert!(!keep( + "TU116 High Definition Audio Controller Pro", + 2, + "alsa_output.pci-0000_01_00.1.pro-output-3", + )); + assert!(!keep( + "TU116 High Definition Audio Controller Pro 7", + 8, + "hdmi7" + )); + assert!(!keep( + "TU116 High Definition Audio Controller Pro 9", + 8, + "hdmi9" + )); + assert!(!keep( + "\u{5185}\u{7f6e}\u{97f3}\u{9891} Pro", + 2, + "alsa_output.pci-0000_00_1f.3.pro-output-0.monitor", + )); + // 只有真麦克风留下来。 + assert!(keep( + "\u{5185}\u{7f6e}\u{97f3}\u{9891} Pro 2", + 2, + "alsa_input.pci-0000_00_1f.3.pro-input-2", + )); + } + + const SAME_DESCRIPTION_SOURCES: &str = "Source #1\n\ +\tName: output.monitor\n\ +\tDescription: Monitor of USB Headset\n\ +\tMonitor of Sink: output\n\ +\tProperties:\n\ +\t\tnode.name = \"output\"\n\ +Source #2\n\ +\tName: input\n\ +\tDescription: USB Headset\n\ +\tMonitor of Sink: n/a\n\ +\tProperties:\n\ +\t\tnode.name = \"capture-node\"\n"; + + #[test] + fn same_description_does_not_hide_the_real_default_source() { + let pulse = parse_pulse_sources(SAME_DESCRIPTION_SOURCES, Some("input")); + for id in ["input", "capture-node"] { + assert!(keep_microphone("USB Headset", 2, Some(id), &pulse)); + assert!(pulse.is_default(Some(id))); + } + for id in ["output.monitor", "output"] { + assert!(!keep_microphone("USB Headset", 2, Some(id), &pulse)); + assert!(!pulse.is_default(Some(id))); + } + } + + #[test] + fn unavailable_or_unknown_identity_does_not_filter_by_description() { + let pulse = parse_pulse_sources(SAME_DESCRIPTION_SOURCES, Some("input")); + for id in [None, Some(""), Some("unknown-id")] { + assert!(keep_microphone("USB Headset", 2, id, &pulse)); + assert!(!pulse.is_default(id)); + } + } + + #[test] + fn ambiguous_source_identity_is_preserved() { + let text = SAME_DESCRIPTION_SOURCES.replace("capture-node", "output"); + let pulse = parse_pulse_sources(&text, Some("input")); + assert!(keep_microphone("USB Headset", 2, Some("output"), &pulse)); + assert!(!pulse.is_default(Some("output"))); + assert!(keep_microphone("USB Headset", 2, Some("input"), &pulse)); + } + + #[test] + fn monitor_role_does_not_depend_on_english_description_prefix() { + let text = SAME_DESCRIPTION_SOURCES.replace("Monitor of USB Headset", "USB Headset"); + let pulse = parse_pulse_sources(&text, Some("output.monitor")); + assert!(!keep_microphone( + "USB Headset", + 2, + Some("output.monitor"), + &pulse + )); + assert!(!pulse.is_default(Some("output.monitor"))); + assert!(keep_microphone("USB Headset", 2, Some("input"), &pulse)); + } + + #[test] + fn without_pactl_no_device_is_dropped() { + // 拿不到源列表时退回旧行为:宁可多列,也不能误删用户的麦克风。 + let pulse = parse_pulse_sources("", None); + assert_eq!(pulse, super::PulseSources::default()); + assert!(keep_microphone("Some USB microphone", 1, None, &pulse)); + assert!(keep_microphone("USB Headset", 2, Some("input"), &pulse)); + assert!(!pulse.is_default(Some("input"))); + } #[test] fn non_capture_nodes_are_not_offered_as_microphones() { diff --git a/openless-all/app/linux-egui/src/cloud_sync_ui.rs b/openless-all/app/linux-egui/src/cloud_sync_ui.rs new file mode 100644 index 000000000..378901f0c --- /dev/null +++ b/openless-all/app/linux-egui/src/cloud_sync_ui.rs @@ -0,0 +1,1018 @@ +//! Settings-page state for the encrypted cloud sync card. +//! +//! Business truth stays in Core: this module only mirrors +//! `openless_core::cloud_sync_e2ee::EncryptedSyncStatus` into the shape the egui +//! settings page renders, plus the pure view decisions (which step to focus, +//! whether "sync now" is allowed, which stable error code to show). Nothing here +//! reads, stores or logs a secret beyond the password box the user is typing +//! into: the drafts are cleared as soon as an action is dispatched. + +use openless_core::cloud_sync_e2ee::{ + AuthState, ConflictSide, EncryptedSyncStatus, KeyState, RestoreMode, RestorePreview, SyncState, +}; +use serde::{Deserialize, Serialize}; + +/// Consent version the host asks Core to record. Must match Core's constant so a +/// version bump forces every client back through the consent form. +pub const CONSENT_VERSION: &str = openless_core::cloud_sync_e2ee::CONSENT_VERSION; + +/// What the user asked for before the consent/protocol screens ran. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CloudSyncIntent { + Enable, + Unlock, + Restore, +} + +impl CloudSyncIntent { + pub fn ready_action(self) -> Option { + match self { + Self::Enable => Some(CloudSyncAction::SetEnabled(true)), + Self::Restore => Some(CloudSyncAction::PreviewRestore), + Self::Unlock => None, + } + } +} + +/// The modal the card is currently showing. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum CloudSyncDialog { + None, + Consent { + intent: CloudSyncIntent, + }, + Protocol { + intent: CloudSyncIntent, + }, + Create { + observed_revision: String, + }, + Unlock { + intent: CloudSyncIntent, + }, + ChangePassword, + Restore { + preview: Box, + enable_after: bool, + }, + Disable, + Delete { + vault_id: String, + revision: String, + }, +} + +impl CloudSyncDialog { + pub fn is_open(&self) -> bool { + !matches!(self, Self::None) + } +} + +/// One user gesture on the cloud sync card. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum CloudSyncAction { + /// Re-read the status from Core. + Reload, + /// Flip the master toggle: enabling opens the consent flow, disabling asks + /// for confirmation. + Toggle(bool), + /// Run Core's enable preparation after the consent + protocol screens. + Prepare(CloudSyncIntent), + /// Submit the password form for whatever dialog is open. + SubmitPassword(CloudSyncIntent), + /// Turn sync on or off once the user confirmed. + SetEnabled(bool), + Lock, + SyncNow, + Cancel(String), + PreviewRestore, + ApplyRestore(RestoreMode), + DeleteRemote, + SignOut, + ConfigureServer, + OpenDialog(CloudSyncDialog), + CloseDialog, + /// Restore-review conflict choice: `None` keeps the item unresolved. + ResolveConflict(String, Option), + Password(String), + Confirmation(String), + CurrentPassword(String), + RememberKey(bool), + ConsentChecked(bool), + CustomOrigin(String), + CustomToken(String), +} + +/// One Core call the host should perform. The host turns this into a single +/// async task so every arm shares one `await` point. +#[derive(Clone, Debug)] +pub enum CloudSyncOp { + Prepare { + consent_version: String, + }, + Create { + password: String, + confirmation: String, + remember_key: bool, + consent_version: String, + observed_revision: String, + }, + Unlock { + password: String, + remember_key: bool, + }, + ChangePassword { + current_password: String, + password: String, + confirmation: String, + remember_key: bool, + }, + SetEnabled(bool), + Lock, + SyncNow, + Cancel(String), + PreviewRestore { + observed: String, + }, + ApplyRestore { + preview_id: String, + mode: RestoreMode, + conflicts: Vec, + }, + DeleteRemote { + vault_id: String, + revision: String, + }, + SignOut, + ConfigureServer { + origin: Option, + token: String, + }, +} + +impl CloudSyncOp { + /// Run the operation against Core. Passwords are moved in and dropped with + /// the task; nothing is retained on the UI side. + pub async fn run( + backend: std::sync::Arc, + operation: CloudSyncOp, + ) -> Result { + use openless_core::cloud_sync_e2ee::AuthState; + let status = |status: EncryptedSyncStatus| SyncOutcome::Status(Box::new(status)); + match operation { + Self::Prepare { consent_version } => backend + .cloud_sync_e2ee_prepare_enable(consent_version) + .await + .map(|prepared| SyncOutcome::Prepared(Box::new(prepared))) + .map_err(|error| error.to_string()), + Self::Create { + password, + confirmation, + remember_key, + consent_version, + observed_revision, + } => backend + .cloud_sync_e2ee_create( + password, + confirmation, + remember_key, + consent_version, + observed_revision, + ) + .await + .map(status) + .map_err(|error| error.to_string()), + Self::Unlock { + password, + remember_key, + } => backend + .cloud_sync_e2ee_unlock(password, remember_key) + .await + .map(status) + .map_err(|error| error.to_string()), + Self::ChangePassword { + current_password, + password, + confirmation, + remember_key, + } => backend + .cloud_sync_e2ee_change_password( + current_password, + password, + confirmation, + remember_key, + ) + .await + .map(status) + .map_err(|error| error.to_string()), + Self::SetEnabled(enabled) => backend + .cloud_sync_e2ee_set_enabled(enabled) + .await + .map(status) + .map_err(|error| error.to_string()), + Self::Lock => backend + .cloud_sync_e2ee_lock() + .await + .map(status) + .map_err(|error| error.to_string()), + Self::SyncNow => backend + .cloud_sync_e2ee_sync_now() + .await + .map(status) + .map_err(|error| error.to_string()), + // `cancel` is synchronous in Core: it only marks the task. + Self::Cancel(task) => backend + .cloud_sync_e2ee_cancel(task) + .map(status) + .map_err(|error| error.to_string()), + Self::PreviewRestore { observed } => backend + .cloud_sync_e2ee_preview_restore(observed) + .await + .map(|preview| SyncOutcome::Preview(Box::new(preview))) + .map_err(|error| error.to_string()), + Self::ApplyRestore { + preview_id, + mode, + conflicts, + } => backend + .cloud_sync_e2ee_apply_restore(preview_id, mode, conflicts) + .await + .map(status) + .map_err(|error| error.to_string()), + Self::DeleteRemote { vault_id, revision } => backend + .cloud_sync_e2ee_delete_remote(vault_id, revision, true) + .await + .map(status) + .map_err(|error| error.to_string()), + Self::SignOut => backend + .cloud_sync_e2ee_sign_out() + .await + .map(status) + .map_err(|error| error.to_string()), + Self::ConfigureServer { origin, token } => { + let mut status = backend + .cloud_sync_e2ee_configure_server(origin, token) + .await + .map_err(|error| error.to_string())?; + // 自定义服务器自带会话:配完立刻用刚存的 token 登录。 + if status.auth_state == AuthState::SignedOut { + if let Ok(signed_in) = backend.cloud_sync_e2ee_sign_in_with_token().await { + status = signed_in; + } + } + Ok(SyncOutcome::Status(Box::new(status))) + } + } + } +} + +/// What a finished Core call hands back to the host. +#[derive(Clone, Debug)] +pub enum SyncOutcome { + Status(Box), + Prepared(Box), + Preview(Box), + /// `sign_out_account` returns nothing; the host reloads the status itself. + Done, +} + +/// Which control the card highlights when nothing is running. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CloudSyncFocus { + Enable, + Unlock, + Restore, + State, +} + +/// Stable, allow-listed failure codes the card may render. Everything else +/// collapses to `unknown` so a Core message (which may quote a path) never +/// reaches the screen. +pub const KNOWN_ERROR_CODES: &[&str] = &[ + "busy", + "conflict", + "denied", + "expired", + "invalid_response", + "invalidPassword", + "locked", + "network", + "not_signed_in", + "outcomeUnknown", + "rate_limited", + "recovery_required", + "secureStorage", + "server", + "service_unavailable", + "stale_preview", + "unlock", + "unknown", + "unsupported", +]; + +/// Map a Core/local failure string onto the allow-listed code set. +pub fn error_code(raw: &str) -> &'static str { + let trimmed = raw.trim(); + for candidate in KNOWN_ERROR_CODES { + if candidate.eq_ignore_ascii_case(trimmed) { + return candidate; + } + } + // Core 用 snake_case,catalog 里有几个用 camelCase。 + match trimmed { + "invalid_password" | "wrong_password" => "invalidPassword", + "password_mismatch" => "passwordMismatch", + "weak_password" => "weakPassword", + "too_large" => "tooLarge", + "invalid_data" => "invalidData", + "review_required" => "reviewRequired", + "choices_required" => "choicesRequired", + "not_signed_in" => "not_signed_in", + "recovery_required" => "recovery_required", + "outcome_unknown" => "outcomeUnknown", + "rate_limited" => "rate_limited", + "service_unavailable" => "service_unavailable", + "invalid_response" => "invalid_response", + "stale_preview" => "stale_preview", + "secure_storage" => "secureStorage", + _ => "unknown", + } +} + +/// 错误代码 → catalog 键。 +/// +/// 之前界面用 `format!("cloudSyncE2ee.errors.{code}")` 拼键,而 catalog 里根本 +/// 没有这批条目,用户看到的是裸键(`cloudSyncE2ee.errors.service_unavailable`)。 +/// 这里把**每个代码的完整键**写成字面量:既能被 i18n 校对脚本看到,也不会再拼 +/// 出一个不存在的键。Core 侧特有的代码映射到语义最接近的文案。 +pub fn error_key(raw: &str) -> &'static str { + // 直接按原始代码匹配:`error_code()` 会把「不在 allow-list 里」的 Core 代码 + // 折叠成 `unknown`,而这些代码恰恰最需要自己的文案。 + match raw.trim() { + // ── Core(snake_case)───────────────────────────────────────────── + "account_changed" => "cloudSyncE2ee.errors.accountChanged", + "busy" => "cloudSyncE2ee.errors.busy", + "cancelled" => "cloudSyncE2ee.errors.cancelled", + "choices_required" => "cloudSyncE2ee.errors.choicesRequired", + "cloud_deleted" => "cloudSyncE2ee.errors.cloudDeleted", + "conflict" | "revision_conflict" => "cloudSyncE2ee.errors.conflict", + "consent_required" => "cloudSyncE2ee.errors.consentRequired", + "crypto_worker_failed" => "cloudSyncE2ee.errors.cryptoWorkerFailed", + "delete_confirmation_required" => "cloudSyncE2ee.errors.deleteConfirmationRequired", + "denied" => "cloudSyncE2ee.errors.denied", + "expired" => "cloudSyncE2ee.errors.expired", + "invalid_data" => "cloudSyncE2ee.errors.invalidData", + "invalid_password" | "wrong_password" => "cloudSyncE2ee.errors.invalidPassword", + "invalid_response" => "cloudSyncE2ee.errors.invalidResponse", + "local_storage_unavailable" => "cloudSyncE2ee.errors.localData", + "locked" => "cloudSyncE2ee.errors.locked", + "metadata_required" => "cloudSyncE2ee.errors.metadataRequired", + "network" => "cloudSyncE2ee.errors.network", + "not_signed_in" | "sign_in_required" => "cloudSyncE2ee.errors.signIn", + "outcome_unknown" => "cloudSyncE2ee.errors.outcomeUnknown", + "password_mismatch" => "cloudSyncE2ee.errors.passwordMismatch", + "rate_limited" => "cloudSyncE2ee.errors.rateLimited", + "recovery_required" => "cloudSyncE2ee.errors.recovery", + "restore_review_required" | "review_required" => "cloudSyncE2ee.errors.reviewRequired", + "revision_rollback" => "cloudSyncE2ee.errors.rolledBack", + "secure_random_unavailable" => "cloudSyncE2ee.errors.secureRandomUnavailable", + "secure_storage" | "secure_storage_denied" => "cloudSyncE2ee.errors.secureStorage", + "server" => "cloudSyncE2ee.errors.server", + "service_unavailable" => "cloudSyncE2ee.errors.serviceUnavailable", + "stale_preview" => "cloudSyncE2ee.errors.changed", + "stale_task" => "cloudSyncE2ee.errors.staleTask", + "too_large" => "cloudSyncE2ee.errors.tooLarge", + "unlock_required" => "cloudSyncE2ee.errors.unlock", + "unsupported" | "unsupported_protocol" => "cloudSyncE2ee.errors.unsupported", + "weak_password" => "cloudSyncE2ee.errors.weakPassword", + // ── Tauri 的 camelCase 键名 ────────────────────────────────────── + "invalidPassword" => "cloudSyncE2ee.errors.invalidPassword", + "outcomeUnknown" => "cloudSyncE2ee.errors.outcomeUnknown", + "rateLimited" => "cloudSyncE2ee.errors.rateLimited", + "secureStorage" => "cloudSyncE2ee.errors.secureStorage", + "stalePreview" => "cloudSyncE2ee.errors.changed", + "unavailable" => "cloudSyncE2ee.errors.serviceUnavailable", + "unlock" => "cloudSyncE2ee.errors.unlock", + _ => "cloudSyncE2ee.errors.unknown", + } +} + +/// Status mirror plus the card's transient UI state. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CloudSyncUi { + pub status: Option, + pub loading: bool, + pub busy: bool, + pub signed_in: bool, + pub dialog: CloudSyncDialog, + /// What the running enable/unlock/restore flow was started for. + pub intent: Option, + /// 完整 catalog 键(例如 `cloudSyncE2ee.noticeDone`),渲染时直接查表。 + pub notice: Option, + /// Stable error code while the last gesture failed. + pub error: Option, + pub password: String, + pub confirmation: String, + pub current_password: String, + pub remember_key: bool, + pub consent_checked: bool, + pub conflicts: Vec<(String, Option)>, + pub custom_origin: String, + pub custom_token: String, + /// Origin Core currently syncs against (empty when unset). + pub saved_origin: String, +} + +impl Default for CloudSyncUi { + fn default() -> Self { + Self { + status: None, + loading: true, + busy: false, + signed_in: false, + dialog: CloudSyncDialog::None, + intent: None, + notice: None, + error: None, + password: String::new(), + confirmation: String::new(), + current_password: String::new(), + remember_key: true, + consent_checked: false, + conflicts: Vec::new(), + custom_origin: String::new(), + custom_token: String::new(), + saved_origin: String::new(), + } + } +} + +impl CloudSyncUi { + /// Accept a status snapshot only when it is at least as new as the current + /// one. Core's `sequence` is a decimal counter, so a lexicographic compare is + /// wrong the moment it crosses a digit boundary. + pub fn accept_status(&mut self, next: EncryptedSyncStatus) -> bool { + if let Some(current) = &self.status { + match ( + sequence_value(¤t.sequence), + sequence_value(&next.sequence), + ) { + (Some(previous), Some(incoming)) if incoming < previous => return false, + _ => {} + } + } + let scope_changed = self + .status + .as_ref() + .is_some_and(|current| scope_key(current) != scope_key(&next)); + if scope_changed { + // A different account/vault invalidates whatever dialog was open. + self.dialog = CloudSyncDialog::None; + self.conflicts.clear(); + clear_secrets(self); + } + // `saved_origin` mirrors the *user-configured* custom server, not the + // effective origin Core reports, so it is only set by the configure + // action. Overwriting it here would make every vault look custom. + self.status = Some(next); + true + } + + pub fn apply_error(&mut self, raw: &str) { + self.error = Some(error_code(raw).to_string()); + self.busy = false; + } + + /// Drop typed secrets. Called after every dispatched action and whenever the + /// dialog closes so a password never outlives the form that asked for it. + pub fn forget_inputs(&mut self) { + clear_secrets(self); + } + + /// Clear the password boxes after dispatching, but keep what the user typed + /// into the custom-server row (it is not a secret and re-typing it is rude). + pub fn forget_secrets_keep_origin(&mut self) { + self.password.clear(); + self.confirmation.clear(); + self.current_password.clear(); + self.custom_token.clear(); + self.remember_key = true; + } + + pub fn open(&mut self, dialog: CloudSyncDialog) { + clear_secrets(self); + self.notice = None; + self.error = None; + if let CloudSyncDialog::Restore { preview, .. } = &dialog { + self.conflicts = preview + .conflicts + .iter() + .map(|item| (item.id.clone(), None)) + .collect(); + } + self.dialog = dialog; + } + + pub fn close(&mut self) { + if self.busy { + return; + } + self.dialog = CloudSyncDialog::None; + self.notice = None; + self.error = None; + self.conflicts.clear(); + clear_secrets(self); + } + + pub fn unlocked(&self) -> bool { + self.status + .as_ref() + .is_some_and(|status| status.key_state == KeyState::Unlocked) + } + + pub fn signed_in_to_sync(&self) -> bool { + self.status.as_ref().is_some_and(|status| { + status.auth_state == AuthState::SignedIn + && status.sync_state != SyncState::SignInRequired + }) + } + + /// Identities that can start the enable flow: Core's own sync session or the + /// marketplace login when the user has not pointed sync at a custom server. + pub fn identity_available(&self) -> bool { + let custom = !self.saved_origin.trim().is_empty(); + self.status.as_ref().is_some_and(|status| { + status.auth_state != AuthState::Expired + && status.sync_state != SyncState::SignInRequired + && (self.signed_in_to_sync() || (!custom && self.signed_in)) + }) + } + + pub fn working(&self) -> bool { + self.busy + || self + .status + .as_ref() + .is_some_and(|status| status.sync_state == SyncState::Syncing) + } + + /// "Sync now" needs an unlocked key and no pending recovery/conflict. + pub fn can_sync(&self) -> bool { + let Some(status) = &self.status else { + return false; + }; + !self.working() + && !status.recovery_required + && (status.pending_operation_id.is_some() + || (status.enabled + && status.key_state == KeyState::Unlocked + && status.sync_state != SyncState::Conflict)) + } + + pub fn focus(&self) -> CloudSyncFocus { + let Some(status) = &self.status else { + return CloudSyncFocus::Enable; + }; + if status.recovery_required + || matches!( + status.sync_state, + SyncState::Conflict + | SyncState::Failed + | SyncState::OutcomeUnknown + | SyncState::RecoveryRequired + | SyncState::Pending + | SyncState::Syncing + ) + { + return CloudSyncFocus::State; + } + if !self.identity_available() { + return CloudSyncFocus::Enable; + } + if !status.enabled { + return CloudSyncFocus::Enable; + } + if status.key_state == KeyState::Locked { + return CloudSyncFocus::Unlock; + } + if status.has_cloud_snapshot == Some(false) { + return CloudSyncFocus::Restore; + } + CloudSyncFocus::State + } + + /// Whether the enable toggle may be flipped right now. + pub fn toggle_enabled(&self) -> bool { + !self.loading && !self.working() && self.identity_available() + } + + /// Title/detail keys for the focus block, resolved against the catalog. + pub fn focus_keys(&self) -> (&'static str, &'static str) { + const STEP_ENABLE: (&str, &str) = ( + "cloudSyncE2ee.stepEnableTitle", + "cloudSyncE2ee.stepEnableDetail", + ); + const STEP_UNLOCK: (&str, &str) = ( + "cloudSyncE2ee.stepUnlockTitle", + "cloudSyncE2ee.stepUnlockDetail", + ); + const STEP_RESTORE: (&str, &str) = ( + "cloudSyncE2ee.stepRestoreTitle", + "cloudSyncE2ee.stepRestoreDetail", + ); + match self.focus() { + CloudSyncFocus::Enable => STEP_ENABLE, + CloudSyncFocus::Unlock => STEP_UNLOCK, + CloudSyncFocus::Restore => STEP_RESTORE, + CloudSyncFocus::State => ("cloudSyncE2ee.stepFollowDetail", ""), + } + } + + /// `cloudSyncE2ee.states.*` key for the current sync state (or recovery). + pub fn state_key(&self) -> &'static str { + let Some(status) = &self.status else { + return "cloudSyncE2ee.states.disabled"; + }; + if status.recovery_required { + return "cloudSyncE2ee.states.recovery_required"; + } + match status.sync_state { + SyncState::Disabled => "cloudSyncE2ee.states.disabled", + SyncState::SignInRequired => "cloudSyncE2ee.states.sign_in_required", + SyncState::UnlockRequired => "cloudSyncE2ee.states.unlock_required", + SyncState::Ready => "cloudSyncE2ee.states.ready", + SyncState::Pending => "cloudSyncE2ee.states.pending", + SyncState::Syncing => "cloudSyncE2ee.states.syncing", + SyncState::Conflict => "cloudSyncE2ee.states.conflict", + SyncState::Failed => "cloudSyncE2ee.states.failed", + SyncState::OutcomeUnknown => "cloudSyncE2ee.states.outcome_unknown", + SyncState::RecoveryRequired => "cloudSyncE2ee.states.recovery_required", + } + } + + /// Choices handed to Core, in the order the review listed the conflicts. + pub fn conflict_choices(&self) -> Vec { + self.conflicts + .iter() + .filter_map(|(id, side)| { + side.map(|side| openless_core::cloud_sync_e2ee::SyncConflictChoice { + id: id.clone(), + side, + }) + }) + .collect() + } + + pub fn resolve_conflict(&mut self, id: &str, side: Option) { + if let Some(entry) = self.conflicts.iter_mut().find(|(key, _)| key == id) { + entry.1 = side; + } + } + + /// Every conflict needs a side before Core will apply the restore. + pub fn conflicts_resolved(&self) -> bool { + self.conflicts.iter().all(|(_, side)| side.is_some()) + } + + /// Custom server origin must be a bare `https://host[:port]` (or plain http + /// for a local dev server) with no path: Core refuses anything else. + pub fn normalized_origin(&self) -> Result, &'static str> { + normalize_origin(&self.custom_origin) + } +} + +fn clear_secrets(state: &mut CloudSyncUi) { + state.password.clear(); + state.confirmation.clear(); + state.current_password.clear(); + state.custom_token.clear(); + state.remember_key = true; + state.consent_checked = false; +} + +/// Parse a Core sequence counter. Values Core wrote are plain decimal strings; +/// anything else is treated as absent so the caller does not drop a snapshot on a +/// parse failure. +fn sequence_value(raw: &str) -> Option { + raw.trim().parse::().ok() +} + +fn scope_key(status: &EncryptedSyncStatus) -> (Option, Option, String) { + ( + status + .account + .as_ref() + .map(|account| account.github_id.clone()), + status.vault_id.clone(), + status.service_origin.clone(), + ) +} + +/// `https://host[:port]` with no path, query or credentials. Returns `Ok(None)` +/// for an empty input (meaning "use the built-in server"). +pub fn normalize_origin(value: &str) -> Result, &'static str> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let (scheme, rest) = match trimmed.split_once("://") { + Some(parts) => parts, + None => return Err("invalid_response"), + }; + let scheme = scheme.to_ascii_lowercase(); + if scheme != "https" && scheme != "http" { + return Err("invalid_response"); + } + let rest = rest.trim_end_matches('/'); + if rest.is_empty() || rest.contains('/') || rest.contains('?') || rest.contains('#') { + return Err("invalid_response"); + } + if rest.contains('@') { + return Err("invalid_response"); + } + if scheme == "http" && !is_loopback_host(rest) { + // Cleartext to a remote host would leak the vault key material. + return Err("invalid_response"); + } + Ok(Some(format!("{scheme}://{rest}"))) +} + +fn is_loopback_host(rest: &str) -> bool { + let host = rest.split(':').next().unwrap_or_default(); + matches!(host, "localhost" | "127.0.0.1" | "[::1]" | "::1") +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Build a status through serde so the fixture cannot drift from Core's DTO. + fn status(sequence: &str) -> EncryptedSyncStatus { + serde_json::from_value(serde_json::json!({ + "sequence": sequence, + "enabled": true, + "authState": "signed_in", + "keyState": "unlocked", + "syncState": "ready", + "account": { "githubId": "1", "login": "octocat" }, + "vaultId": "vault-1", + "keyId": "key-1", + "localGeneration": "3", + "lastSyncedLocalGeneration": "3", + "remoteRevision": "3", + "lastSuccessfulSyncAt": null, + "pendingOperationId": null, + "lastError": null, + "recoveryRequired": false, + "hasCloudSnapshot": true, + "taskId": null, + "serviceOrigin": "https://sync.example.com", + "consentVersion": CONSENT_VERSION, + "backupRetentionDays": null + })) + .expect("fixture is a valid EncryptedSyncStatus") + } + + #[test] + fn a_ready_device_keeps_the_requested_enable_restore_or_unlock_intent() { + assert_eq!( + CloudSyncIntent::Enable.ready_action(), + Some(CloudSyncAction::SetEnabled(true)) + ); + assert_eq!( + CloudSyncIntent::Restore.ready_action(), + Some(CloudSyncAction::PreviewRestore) + ); + assert_eq!(CloudSyncIntent::Unlock.ready_action(), None); + } + + #[test] + fn sequences_compare_numerically_not_lexicographically() { + let mut ui = CloudSyncUi::default(); + assert!(ui.accept_status(status("9"))); + // "10" sorts before "9" as text: a lexicographic compare would drop it. + assert!(ui.accept_status(status("10"))); + assert!( + !ui.accept_status(status("9")), + "an older sequence is dropped" + ); + assert_eq!(ui.status.as_ref().unwrap().sequence, "10"); + } + + #[test] + fn a_different_vault_closes_the_open_dialog_and_drops_secrets() { + let mut ui = CloudSyncUi::default(); + ui.accept_status(status("1")); + ui.open(CloudSyncDialog::Unlock { + intent: CloudSyncIntent::Unlock, + }); + ui.password = "hunter2".into(); + let mut other = status("2"); + other.vault_id = Some("vault-2".into()); + assert!(ui.accept_status(other)); + assert_eq!(ui.dialog, CloudSyncDialog::None); + assert!(ui.password.is_empty(), "a stale password must not survive"); + } + + #[test] + fn retry_only_needs_an_unlocked_enabled_vault() { + let mut ui = CloudSyncUi::default(); + assert!(!ui.can_sync(), "no status yet"); + ui.accept_status(status("1")); + assert!(ui.can_sync()); + let mut locked = status("2"); + locked.key_state = KeyState::Locked; + ui.accept_status(locked); + assert!(!ui.can_sync()); + let mut pending = status("3"); + pending.key_state = KeyState::Locked; + pending.pending_operation_id = Some("op-1".into()); + ui.accept_status(pending); + assert!(ui.can_sync(), "a pending operation can always be checked"); + } + + #[test] + fn recovery_and_conflict_focus_the_state_block() { + let mut ui = CloudSyncUi::default(); + let mut conflict = status("1"); + conflict.sync_state = SyncState::Conflict; + ui.accept_status(conflict); + assert_eq!(ui.focus(), CloudSyncFocus::State); + assert_eq!(ui.state_key(), "cloudSyncE2ee.states.conflict"); + + let mut recovery = status("2"); + recovery.recovery_required = true; + ui.accept_status(recovery); + assert_eq!(ui.state_key(), "cloudSyncE2ee.states.recovery_required"); + assert!(!ui.can_sync(), "recovery blocks every sync gesture"); + } + + #[test] + fn an_unsigned_in_vault_focuses_the_enable_step() { + let mut ui = CloudSyncUi::default(); + let mut signed_out = status("1"); + signed_out.auth_state = AuthState::SignedOut; + signed_out.sync_state = SyncState::SignInRequired; + ui.accept_status(signed_out); + assert_eq!(ui.focus(), CloudSyncFocus::Enable); + assert!(!ui.identity_available()); + } + + #[test] + fn a_custom_server_removes_the_marketplace_identity_fallback() { + let mut ui = CloudSyncUi::default(); + let mut signed_out = status("1"); + signed_out.auth_state = AuthState::SignedOut; + signed_out.sync_state = SyncState::Disabled; + ui.accept_status(signed_out); + ui.signed_in = true; + assert!(ui.identity_available(), "marketplace login is enough"); + ui.saved_origin = "https://sync.example.com".into(); + assert!( + !ui.identity_available(), + "a custom server needs its own token sign-in" + ); + } + + #[test] + fn only_allow_listed_error_codes_reach_the_card() { + assert_eq!(error_code("invalid_password"), "invalidPassword"); + assert_eq!(error_code("outcome_unknown"), "outcomeUnknown"); + assert_eq!(error_code(" network "), "network"); + + // Anything else (including a path Core quoted) collapses to `unknown`. + assert_eq!(error_code("/home/user/.local/share/OpenLess"), "unknown"); + assert_eq!( + error_code("failed to read vault: permission denied"), + "unknown" + ); + } + + #[test] + fn custom_server_origin_must_be_a_bare_https_host() { + assert_eq!(normalize_origin(""), Ok(None)); + assert_eq!( + normalize_origin(" https://sync.example.com/ "), + Ok(Some("https://sync.example.com".into())) + ); + assert_eq!( + normalize_origin("https://sync.example.com:8443"), + Ok(Some("https://sync.example.com:8443".into())) + ); + // Cleartext to a remote host would expose the vault key over the wire. + assert!(normalize_origin("http://sync.example.com").is_err()); + assert_eq!( + normalize_origin("http://127.0.0.1:8787"), + Ok(Some("http://127.0.0.1:8787".into())) + ); + assert!(normalize_origin("https://sync.example.com/api").is_err()); + assert!(normalize_origin("ftp://sync.example.com").is_err()); + assert!(normalize_origin("sync.example.com").is_err()); + } + + #[test] + fn a_restore_cannot_be_applied_until_every_conflict_has_a_side() { + let mut ui = CloudSyncUi::default(); + let preview: RestorePreview = serde_json::from_value(serde_json::json!({ + "previewId": "preview-1", + "unconfirmedOperationId": null, + "observedRevision": "3", + "localGeneration": "3", + "counts": { "dictionary": 2 }, + "deviceSettingsToReview": ["locale"], + "conflicts": [ + { "id": "a", "kind": "style_pack", "reason": "both_changed" }, + { "id": "b", "kind": "dictionary", "reason": "both_changed" } + ] + })) + .unwrap(); + ui.open(CloudSyncDialog::Restore { + preview: Box::new(preview), + enable_after: false, + }); + assert!(!ui.conflicts_resolved()); + ui.resolve_conflict("a", Some(ConflictSide::Local)); + assert!(!ui.conflicts_resolved(), "one conflict is still open"); + ui.resolve_conflict("b", Some(ConflictSide::Cloud)); + assert!(ui.conflicts_resolved()); + let choices = ui.conflict_choices(); + assert_eq!(choices.len(), 2); + assert_eq!(choices[0].side, ConflictSide::Local); + assert_eq!(choices[1].side, ConflictSide::Cloud); + } + + #[test] + fn closing_a_dialog_forgets_the_typed_password() { + let mut ui = CloudSyncUi::default(); + ui.accept_status(status("1")); + ui.open(CloudSyncDialog::ChangePassword); + ui.password = "new".into(); + ui.confirmation = "new".into(); + ui.current_password = "old".into(); + ui.close(); + assert!(ui.password.is_empty()); + assert!(ui.confirmation.is_empty()); + assert!(ui.current_password.is_empty()); + } + + /// 界面上不允许出现裸键:每个可能的失败代码都必须映射到 catalog 里存在的 + /// 文案。之前用 `format!("cloudSyncE2ee.errors.{code}")` 拼键,catalog 里没有 + /// 这批条目,用户看到的就是 `cloudSyncE2ee.errors.service_unavailable`。 + #[test] + fn every_failure_code_resolves_to_a_real_message() { + let codes = [ + // Core 侧会发出的代码(cloud_sync_e2ee 全部 `error(...)` 字面量) + "account_changed", + "busy", + "cancelled", + "choices_required", + "cloud_deleted", + "conflict", + "consent_required", + "crypto_worker_failed", + "delete_confirmation_required", + "denied", + "expired", + "invalid_data", + "invalid_password", + "invalid_response", + "local_storage_unavailable", + "locked", + "metadata_required", + "network", + "not_signed_in", + "outcome_unknown", + "password_mismatch", + "rate_limited", + "recovery_required", + "restore_review_required", + "review_required", + "revision_conflict", + "revision_rollback", + "secure_random_unavailable", + "secure_storage", + "secure_storage_denied", + "server", + "service_unavailable", + "sign_in_required", + "stale_preview", + "stale_task", + "too_large", + "unlock_required", + "unsupported_protocol", + "weak_password", + // 本地生成的代码 + "unknown", + "unsupported", + ]; + for code in codes { + let key = error_key(code); + let text = crate::tr_l10n(crate::Lang::ZhCn, key); + assert_ne!(text, key, "{code} → {key} is missing from the catalog"); + assert!( + !text.starts_with("cloudSyncE2ee."), + "{code} rendered a raw key: {text}" + ); + } + } +} diff --git a/openless-all/app/linux-egui/src/credentials.rs b/openless-all/app/linux-egui/src/credentials.rs index 595c2fdf0..00092f792 100644 --- a/openless-all/app/linux-egui/src/credentials.rs +++ b/openless-all/app/linux-egui/src/credentials.rs @@ -531,7 +531,7 @@ impl CredentialStore for LinuxCredentialStore { } #[cfg(target_os = "linux")] { - tokio::task::spawn_blocking(move || read_secret(&key)) + tokio::task::spawn_blocking(move || require_committed_value(read_secret(&key)?)) .await .map_err(join_error)? } @@ -737,8 +737,19 @@ fn persist_metadata(path: &Path, state: &PersistedCredentialMetadata) -> Result< }) } +#[cfg(any(target_os = "linux", test))] +fn require_committed_value( + value: Option, +) -> Result, BackendError> { + value.map(Some).ok_or_else(|| BackendError::new( + BackendErrorCode::Persistence, + "A saved credential is missing from the active Secret Service vault. Restore the original wallet before editing this channel; its metadata has been retained.", + )) +} + #[cfg(target_os = "linux")] fn keyring_entry(key: &CredentialKey) -> Result { + crate::linux_access::ensure_persistent_vault()?; let account = serde_json::to_string(key).map_err(|error| { BackendError::new( BackendErrorCode::Internal, @@ -750,6 +761,7 @@ fn keyring_entry(key: &CredentialKey) -> Result { #[cfg(target_os = "linux")] fn read_legacy_secret(account: &str) -> Result, BackendError> { + crate::linux_access::ensure_persistent_vault()?; let entry = keyring::Entry::new( openless_core::credentials_legacy::LEGACY_CREDENTIAL_SERVICE, account, @@ -831,6 +843,13 @@ fn unsupported_keyring() -> Result { mod tests { use super::*; + #[test] + fn a_committed_key_missing_from_the_current_vault_is_not_an_empty_form() { + let error = require_committed_value(None).unwrap_err(); + assert_eq!(error.code, BackendErrorCode::Persistence); + assert!(error.message.contains("original wallet")); + } + fn temporary_store() -> LinuxCredentialStore { let root = std::env::temp_dir().join(format!( "openless-linux-credential-test-{}", diff --git a/openless-all/app/linux-egui/src/dictation_feedback.rs b/openless-all/app/linux-egui/src/dictation_feedback.rs index 2500dee48..24ab63ee4 100644 --- a/openless-all/app/linux-egui/src/dictation_feedback.rs +++ b/openless-all/app/linux-egui/src/dictation_feedback.rs @@ -124,6 +124,15 @@ pub fn capsule_needs_fallback_dismissal( Some(capsule_session.to_string()) } +/// 「要记住这个词吗?」卡片能不能弹。 +/// +/// Tauri `show_vocab_suggestion_card` 的最后一道闸:卡片与录音胶囊**共用同一个 +/// 窗口**,会话在飞时弹出来会把这次会话的胶囊顶掉(尺寸、位置、输入区都不同), +/// 所以只允许在没有会话(Idle)时显示。 +pub fn vocab_card_allowed(phase: Option) -> bool { + matches!(phase, None | Some(DictationPhase::Idle)) +} + /// 这个相位是否需要胶囊在屏幕上:只有进行中的相位才该按需拉起弹窗。 /// /// 终态不再拉起——否则一个迟到的终态事件会把刚刚自动收起的药丸又喊回来; @@ -166,6 +175,31 @@ pub fn normalize_stop_result( } } +#[cfg(test)] +mod vocab_card_tests { + use super::vocab_card_allowed; + use openless_core::DictationPhase; + + /// 卡片只在没有会话时出现:卡片与录音胶囊共用一个窗口。 + #[test] + fn the_vocab_card_only_shows_without_a_dictation_session() { + assert!(vocab_card_allowed(None)); + assert!(vocab_card_allowed(Some(DictationPhase::Idle))); + for phase in [ + DictationPhase::Starting, + DictationPhase::Recording, + DictationPhase::Transcribing, + DictationPhase::Polishing, + DictationPhase::Inserting, + ] { + assert!( + !vocab_card_allowed(Some(phase)), + "{phase:?} is a live session and must keep the capsule" + ); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/openless-all/app/linux-egui/src/fcitx5.rs b/openless-all/app/linux-egui/src/fcitx5.rs index b18f04427..748e10cf3 100644 --- a/openless-all/app/linux-egui/src/fcitx5.rs +++ b/openless-all/app/linux-egui/src/fcitx5.rs @@ -18,6 +18,17 @@ 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); +/// Budget for the addon interface to answer once a reload was requested. Only a +/// missing interface consumes it; a daemon that refuses to replace itself does +/// not. +#[cfg(target_os = "linux")] +const READY_TIMEOUT: Duration = Duration::from_secs(15); +/// How long a replaced daemon gets to hand the `org.fcitx.Fcitx5` name over +/// before the host accepts the addon copy that is already serving. +#[cfg(target_os = "linux")] +const OWNER_CHANGE_GRACE: Duration = Duration::from_secs(3); +#[cfg(target_os = "linux")] +const POLL_INTERVAL: Duration = Duration::from_millis(100); /// 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`). @@ -186,7 +197,13 @@ pub(crate) fn file_fingerprint(path: &Path) -> Option { let bytes = std::fs::read(path).ok()?; let mut hasher = Sha256::new(); hasher.update(&bytes); - Some(format!("{:x}", hasher.finalize())) + Some( + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(), + ) } /// Drop a per-user copy that would shadow the package's addon, so a package @@ -271,6 +288,10 @@ fn system_plugin_library() -> Option { /// `/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| { + // The kernel appends " (deleted)" to a mapping whose inode was replaced + // underneath the daemon — the package-upgrade case this check exists + // for — so the suffix must not hide the copy fcitx5 actually loaded. + let line = line.strip_suffix(" (deleted)").unwrap_or(line); let path = line.split_whitespace().last()?; path.ends_with("/libopenless.so") .then(|| PathBuf::from(path)) @@ -868,6 +889,7 @@ pub(crate) fn apply_selection_target( source: &str, replacement: &str, ) -> Result<(), BackendError> { + crate::selection::validate_selection_replacement_source(source)?; if send_bool_message("ApplySelectionTarget", |message| { message.append3(session_id, source, replacement) })? { @@ -1018,8 +1040,47 @@ pub fn available() -> bool { .is_ok() } +/// What the barrier does with the daemon's `org.fcitx.Fcitx5` name while a +/// reload is in flight. Extracted so the rule is testable without a live bus. +#[cfg(any(target_os = "linux", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReloadWait { + /// Keep waiting: the replacement may still be taking the name over. + Wait, + /// The replacement arrived; probe the addon interface. + Probe, + /// The daemon kept the addon copy it was started with. + KeptPreviousCopy, +} + +/// Whether a requested reload still waits for the daemon to hand the name over. +/// +/// fcitx5 started by a session launcher reports `CanRestart` = false and +/// dispatches `Restart` without replacing itself, so an unchanged owner after +/// the grace window means "the previous addon copy keeps serving the +/// interface", not "the addon is missing". `current` is `None` while the name +/// has no owner, which is the normal transient state during a replacement. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn reload_wait( + previous: &str, + current: Option<&str>, + grace_elapsed: bool, +) -> ReloadWait { + match current { + Some(current) if current != previous => ReloadWait::Probe, + Some(_) if grace_elapsed => ReloadWait::KeptPreviousCopy, + Some(_) | None => ReloadWait::Wait, + } +} + /// Production startup barrier: install, request at most one reload, then wait -/// for the new owner's addon interface before registering any shortcuts. +/// for the addon interface before registering any shortcuts. +/// +/// Readiness is the addon *interface*, not a replaced daemon: the running copy +/// serves the interface the host registers hotkeys against, so a reload the +/// daemon refuses to perform is a warning (`ReloadWait::KeptPreviousCopy`) +/// rather than a startup failure. Only an interface that never answers within +/// `READY_TIMEOUT` stops startup. #[cfg(target_os = "linux")] pub fn prepare_fcitx5(plan: &FcitxPluginInstallPlan, data_dir: &Path) -> Result<(), BackendError> { use dbus::blocking::BlockingSender; @@ -1042,42 +1103,61 @@ pub fn prepare_fcitx5(plan: &FcitxPluginInstallPlan, data_dir: &Path) -> Result< }; 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); + let interface_ready = |timeout: Duration| -> bool { + let Ok(message) = dbus::Message::new_method_call( + DESTINATION, + OBJECT_PATH, + "org.freedesktop.DBus.Introspectable", + "Introspect", + ) else { + return false; + }; + connection + .send_with_reply_and_block(message, timeout) + .ok() + .and_then(|reply| reply.read1::().ok()) + .is_some_and(|xml| xml.contains(INTERFACE)) + }; + let deadline = std::time::Instant::now() + READY_TIMEOUT; + let owner_grace = std::time::Instant::now() + OWNER_CHANGE_GRACE; + let mut awaiting_replacement = reloaded; 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")); + return Err(BackendError::new( + BackendErrorCode::Platform, + format!( + "fcitx5 OpenLess interface did not become ready within {} seconds; enable the \ + addon and restart fcitx5", + READY_TIMEOUT.as_secs() + ), + )); } - 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(()); + if awaiting_replacement { + let current = owner(TIMEOUT.min(remaining)).ok(); + match reload_wait( + &previous_owner, + current.as_deref(), + std::time::Instant::now() >= owner_grace, + ) { + ReloadWait::Probe => awaiting_replacement = false, + ReloadWait::KeptPreviousCopy => { + log::warn!( + "[fcitx] fcitx5 kept the addon copy it was started with (the daemon did \ + not replace itself); the running copy still serves the interface" + ); + awaiting_replacement = false; + } + ReloadWait::Wait => { + std::thread::sleep(POLL_INTERVAL.min(remaining)); + continue; + } } } - std::thread::sleep( - Duration::from_millis(100) - .min(deadline.saturating_duration_since(std::time::Instant::now())), - ); + if interface_ready(TIMEOUT.min(remaining)) { + return Ok(()); + } + std::thread::sleep(POLL_INTERVAL.min(remaining)); } } @@ -1086,6 +1166,38 @@ pub fn available() -> bool { false } +/// What the host does after a plugin update was detected. +#[cfg(any(target_os = "linux", test))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ReloadPlan { + /// fcitx5 accepted `Restart` and replaces itself. + Replaced, + /// The session owns fcitx5 and it does not replace itself: the running addon + /// copy keeps serving until that daemon next starts. + KeepRunningCopy, + /// The controller refused the request; use the detached `fcitx5 -r`. + CliFallback, +} + +/// Which reload path applies, given fcitx5's `CanRestart` (`None` when the method +/// is missing) and whether `Restart` was accepted. +/// +/// A daemon launched by the desktop session reports `CanRestart` = false and then +/// dispatches `Restart` *without* replacing itself. Spanning a competing +/// `fcitx5 -r` would fight the session manager for the addon, so that case keeps +/// the running copy: the interface is what the host needs, and the installed +/// copy loads when the session next starts the daemon. +#[cfg(any(target_os = "linux", test))] +pub(crate) fn reload_plan(can_restart: Option, dispatched: bool) -> ReloadPlan { + if can_restart == Some(false) { + return ReloadPlan::KeepRunningCopy; + } + if dispatched { + return ReloadPlan::Replaced; + } + ReloadPlan::CliFallback +} + /// Ask a running fcitx5 daemon to reload so it loads a freshly written /// OpenLess addon, mirroring the legacy Tauri `linux_fcitx` adapter. /// @@ -1096,26 +1208,58 @@ pub fn available() -> bool { /// semantics are preserved). On an update the running instance is restarted so /// the new `.so` is actually loaded (restart semantics). /// -/// 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. +/// Returns true only when the daemon is going to replace itself, because that is +/// what makes the owner name change; the startup barrier accepts the running +/// copy either way (see `prepare_fcitx5`). #[cfg(target_os = "linux")] pub fn reload_running_fcitx5() -> bool { if !fcitx5_name_has_owner() { return false; } - if restart_fcitx5_via_dbus() { - log::info!("[fcitx] reloaded fcitx5 after addon update"); - return true; + let can_restart = fcitx5_can_restart(); + let dispatched = can_restart != Some(false) && dispatch_fcitx5_restart(); + match reload_plan(can_restart, dispatched) { + ReloadPlan::Replaced => { + log::info!("[fcitx] reloaded fcitx5 after addon update"); + true + } + ReloadPlan::KeepRunningCopy => { + log::info!( + "[fcitx] the session owns fcitx5 and it does not replace itself; the running addon \ + copy keeps serving and the installed copy loads when the daemon next starts" + ); + false + } + ReloadPlan::CliFallback => spawn_detached_fcitx5_restart(), } - spawn_detached_fcitx5_restart() +} + +/// `CanRestart` on the fcitx5 controller. A missing method or an unreadable reply +/// returns `None`, which keeps the previous behaviour and lets `Restart` decide. +#[cfg(target_os = "linux")] +fn fcitx5_can_restart() -> Option { + use dbus::blocking::BlockingSender; + let connection = dbus::blocking::Connection::new_session().ok()?; + let message = dbus::Message::new_method_call( + DESTINATION, + CONTROLLER_PATH, + CONTROLLER_INTERFACE, + "CanRestart", + ) + .ok()?; + connection + .send_with_reply_and_block(message, CONTROLLER_TIMEOUT) + .ok()? + .read1::() + .ok() } /// `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. +/// ours is inherited. A dispatched call is still not proof of a replacement, +/// which is why the caller also watches the owner name. #[cfg(target_os = "linux")] -pub(crate) fn restart_fcitx5_via_dbus() -> bool { +fn dispatch_fcitx5_restart() -> bool { use dbus::blocking::BlockingSender; let Ok(connection) = dbus::blocking::Connection::new_session() else { return false; @@ -1299,6 +1443,69 @@ mod tests { assert_eq!(reload_reason(Some("abc"), "abc", false, true), None); } + #[test] + fn a_daemon_that_cannot_replace_itself_keeps_serving_the_installed_addon() { + // fcitx5 started by a session launcher reports CanRestart = false and + // dispatches `Restart` without replacing itself, so the owner name stays + // put. That is a warning — the running addon copy still answers — never + // the 15 second startup failure this barrier used to raise. + assert_eq!( + reload_wait(":1.27", Some(":1.27"), true), + ReloadWait::KeptPreviousCopy + ); + // A replacement that is still in flight keeps waiting, even past the + // grace window: the name is briefly unowned while the daemon swaps. + assert_eq!(reload_wait(":1.27", None, true), ReloadWait::Wait); + assert_eq!(reload_wait(":1.27", Some(":1.27"), false), ReloadWait::Wait); + // A new owner means the replacement arrived and the interface may be + // probed. + assert_eq!( + reload_wait(":1.27", Some(":1.471"), true), + ReloadWait::Probe + ); + } + + #[test] + fn a_session_owned_daemon_is_not_replaced_by_a_competing_one() { + // A compositor-launched fcitx5 reports CanRestart = false: respect it + // instead of spawning a second daemon that would fight the session for + // the addon. The running copy keeps serving. + assert_eq!(reload_plan(Some(false), false), ReloadPlan::KeepRunningCopy); + assert_eq!(reload_plan(Some(false), true), ReloadPlan::KeepRunningCopy); + // A dispatched Restart replaces the daemon... + assert_eq!(reload_plan(Some(true), true), ReloadPlan::Replaced); + // ...while a refused controller request falls back to the CLI. + assert_eq!(reload_plan(Some(true), false), ReloadPlan::CliFallback); + // An older daemon without the method keeps the previous behaviour. + assert_eq!(reload_plan(None, true), ReloadPlan::Replaced); + assert_eq!(reload_plan(None, false), ReloadPlan::CliFallback); + } + + #[test] + fn a_deleted_plugin_mapping_still_reports_the_copy_the_daemon_loaded() { + // The kernel marks a mapping whose inode a package upgrade replaced; + // that is the stale-copy case the reload check exists for, so it must + // not be misread as "fcitx5 is not running". + let replaced = "7f8e028d1000-7f8e028e1000 r--p 00000000 08:01 1056766 \ + /usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so (deleted)\n"; + assert_eq!( + parse_maps_plugin_path(replaced), + Some(PathBuf::from( + "/usr/lib/x86_64-linux-gnu/fcitx5/libopenless.so" + )) + ); + let live = + "7f8e028d1000-7f8e028e1000 r--p 00000000 08:01 1056766 /usr/lib/fcitx5/libopenless.so\n"; + assert_eq!( + parse_maps_plugin_path(live), + Some(PathBuf::from("/usr/lib/fcitx5/libopenless.so")) + ); + assert_eq!( + parse_maps_plugin_path("1-2 r--p 00000000 00:00 0 /usr/lib/libgtk.so\n"), + None + ); + } + #[test] fn the_packaged_addon_is_searched_before_a_manual_install() { let dirs = plugin_library_search_dirs(&["x86_64-linux-gnu".to_string()]); diff --git a/openless-all/app/linux-egui/src/frame_stats.rs b/openless-all/app/linux-egui/src/frame_stats.rs new file mode 100644 index 000000000..fe42a7faf --- /dev/null +++ b/openless-all/app/linux-egui/src/frame_stats.rs @@ -0,0 +1,156 @@ +//! 帧率探针(QA 用,默认关闭)。 +//! +//! `OPENLESS_FPS_LOG=1` 时每个渲染进程每秒往日志写一行帧统计(帧数、fps、最慢一帧), +//! 用来现场回答「这台机器能跑到多少帧」。`OPENLESS_FPS_SETTINGS=1` 额外让主窗口 +//! 启动时就打开设置面板,直接测最重的那条路径(磨砂背板:离屏 4×MSAA 重绘 + 高斯模糊)。 +//! +//! 两个开关都只读环境变量;未开启时每次调用只做一次 `OnceLock` 读,日志一条不发。 + +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; + +/// 统计窗口:每满这么久写一行。1s 足够稳,又能在一次测量里拿到多行。 +const REPORT_WINDOW: Duration = Duration::from_secs(1); + +const FPS_LOG_ENV: &str = "OPENLESS_FPS_LOG"; +const SETTINGS_ENV: &str = "OPENLESS_FPS_SETTINGS"; + +struct Window { + label: &'static str, + started: Instant, + last: Instant, + frames: u32, + worst: Duration, +} + +impl Window { + fn new(label: &'static str, now: Instant) -> Self { + Self { + label, + started: now, + last: now, + frames: 0, + worst: Duration::ZERO, + } + } +} + +/// 记一帧;跨过 [`REPORT_WINDOW`] 时返回 `(帧数, 窗口时长, 最慢一帧)` 并重置窗口。 +fn record(window: &mut Window, now: Instant) -> Option<(u32, Duration, Duration)> { + window.worst = window.worst.max(now.saturating_duration_since(window.last)); + window.last = now; + window.frames += 1; + let elapsed = now.saturating_duration_since(window.started); + if elapsed < REPORT_WINDOW { + return None; + } + let report = (window.frames, elapsed, window.worst); + *window = Window::new(window.label, now); + Some(report) +} + +fn env_flag(name: &str) -> bool { + matches!(std::env::var(name).as_deref(), Ok("1") | Ok("true")) +} + +/// 帧率日志是否开启。 +pub fn enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| env_flag(FPS_LOG_ENV)) +} + +/// 启动即打开设置面板(只影响测量环境)。 +pub fn settings_open_on_start() -> bool { + env_flag(SETTINGS_ENV) +} + +/// 每个渲染帧调用一次。`label` 区分进程(主窗 / 弹窗 / 胶囊)。 +/// +/// 每个进程只有一个渲染面,所以内部只保留一份窗口;`label` 变了就重新开始统计。 +pub fn tick(label: &'static str) { + if !enabled() { + return; + } + let now = Instant::now(); + let mut slot = WINDOW + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let stale = !matches!(slot.as_ref(), Some(window) if window.label == label); + if stale { + *slot = Some(Window::new(label, now)); + } + let Some(window) = slot.as_mut() else { + return; + }; + if let Some((frames, elapsed, worst)) = record(window, now) { + let message = format!( + "[fps] {label}: {frames} frames / {:.0}ms = {:.1} fps, worst frame {:.1}ms", + elapsed.as_secs_f64() * 1000.0, + frames as f64 / elapsed.as_secs_f64(), + worst.as_secs_f64() * 1000.0, + ); + // 胶囊走原生 layer-shell 路径时不装文件日志器,`log::info!` 会被直接丢弃; + // 那种情况下探针退回 stderr,保证任何启动方式都能拿到数字。 + if log::log_enabled!(log::Level::Info) { + log::info!("{message}"); + } else { + eprintln!("{message}"); + } + } +} + +static WINDOW: Mutex> = Mutex::new(None); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frames_are_reported_once_per_window() { + let start = Instant::now(); + let mut window = Window::new("test", start); + // 第 1 帧只是开窗,不满一秒不报。 + assert!(record(&mut window, start).is_none()); + for frame in 1..100 { + assert!(record( + &mut window, + start + Duration::from_millis(frame as u64 * 10) + ) + .is_none()); + } + let report = record(&mut window, start + Duration::from_millis(1000)); + let (frames, elapsed, worst) = report.expect("the window closes after one second"); + assert_eq!(frames, 101); + assert_eq!(elapsed, Duration::from_millis(1000)); + assert_eq!(worst, Duration::from_millis(10)); + // 窗口被重置:下一帧重新计数,不会把上一秒的帧数带上。 + assert!(record(&mut window, start + Duration::from_millis(1010)).is_none()); + let (frames, _, _) = record(&mut window, start + Duration::from_millis(2020)) + .expect("the next window reports its own frames"); + assert_eq!(frames, 2); + } + + #[test] + fn the_worst_frame_is_the_longest_interval_in_the_window() { + let start = Instant::now(); + let mut window = Window::new("test", start); + record(&mut window, start); + record(&mut window, start + Duration::from_millis(10)); + // 一帧慢了 200ms。 + record(&mut window, start + Duration::from_millis(210)); + let (frames, _, worst) = + record(&mut window, start + Duration::from_millis(1000)).expect("report"); + assert_eq!(frames, 4); + // 最慢间隔包含「进入这次上报自身的那个间隔」:静止态不重绘时它会被拉长, + // 这是真实语义(慢帧与空闲都看得见),读数字时和 fps 一起看。 + assert_eq!(worst, Duration::from_millis(790)); + } + + #[test] + fn a_new_label_starts_a_fresh_window() { + let start = Instant::now(); + let slot = Some(Window::new("main", start)); + let stale = !matches!(slot.as_ref(), Some(window) if window.label == "popup"); + assert!(stale, "a different label must reset the statistics"); + } +} diff --git a/openless-all/app/linux-egui/src/hotkeys.rs b/openless-all/app/linux-egui/src/hotkeys.rs index e182f9edb..239d0653c 100644 --- a/openless-all/app/linux-egui/src/hotkeys.rs +++ b/openless-all/app/linux-egui/src/hotkeys.rs @@ -15,8 +15,6 @@ static NEXT_PRESS_ID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU6 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))] @@ -81,6 +79,7 @@ pub enum LinuxHotkeyEvent { pub struct Fcitx5HotkeyListener { receiver: Mutex>, error: Arc>>, + waker: Arc>>, #[cfg(target_os = "linux")] stop: Arc, #[cfg(target_os = "linux")] @@ -102,6 +101,10 @@ impl Fcitx5HotkeyListener { } } + pub fn set_waker(&self, thread: std::thread::Thread) { + *self.waker.lock().expect("hotkey waker lock poisoned") = Some(thread); + } + pub fn drain(&self, mut apply: impl FnMut(LinuxHotkeyEvent)) -> usize { let receiver = self .receiver @@ -143,10 +146,18 @@ fn start_linux_listener() -> Result { let error = Arc::new(Mutex::new(None)); let stop_for_thread = Arc::clone(&stop); let error_for_thread = Arc::clone(&error); + let waker = Arc::new(Mutex::new(None)); + let waker_for_thread = Arc::clone(&waker); let thread = std::thread::Builder::new() .name("openless-fcitx5-hotkeys".to_string()) .spawn(move || { - run_listener(event_tx, startup_tx, stop_for_thread, error_for_thread); + run_listener( + event_tx, + startup_tx, + stop_for_thread, + error_for_thread, + waker_for_thread, + ); }) .map_err(|error| { BackendError::new( @@ -158,6 +169,7 @@ fn start_linux_listener() -> Result { Ok(Ok(())) => Ok(Fcitx5HotkeyListener { receiver: Mutex::new(event_rx), error, + waker, stop, thread: Some(thread), }), @@ -181,6 +193,7 @@ fn run_listener( startup: std::sync::mpsc::SyncSender>, stop: Arc, error: Arc>>, + waker: Arc>>, ) { let connection = match dbus::blocking::SyncConnection::new_session() { Ok(connection) => connection, @@ -223,6 +236,9 @@ fn run_listener( stop_for_match.store(true, std::sync::atomic::Ordering::Release); return false; } + if let Some(thread) = waker.lock().expect("hotkey waker lock poisoned").as_ref() { + thread.unpark(); + } } true }) { @@ -334,32 +350,11 @@ fn event_from_signal( .load(std::sync::atomic::Ordering::Acquire), at, }), - ("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 - } + // Like the Tauri Linux bridge, action shortcuts are press notifications, + // not held-key generations. Focus changes and modifier-first release can + // omit key-up; latching here would disable the shortcut indefinitely. + ("QaShortcutEvent", true) => Some(LinuxHotkeyEvent::QaPressed), + ("QuickNoteEvent", true) => Some(LinuxHotkeyEvent::QuickNotePressed), ("SelectionPolishEvent", true) => Some(LinuxHotkeyEvent::SelectionPolishPressed), ("TranslationModifierEvent", true) => { Some(LinuxHotkeyEvent::TranslationPressed { symbol, states }) diff --git a/openless-all/app/linux-egui/src/i18n.rs b/openless-all/app/linux-egui/src/i18n.rs index 65374d7d1..cb7d5b198 100644 --- a/openless-all/app/linux-egui/src/i18n.rs +++ b/openless-all/app/linux-egui/src/i18n.rs @@ -149,6 +149,194 @@ const fn row( // that the zh-CN column is fully populated and that every key actually // referenced resolves. pub const CATALOG: &[Msg] = &[ + Msg { + key: "providers.extra_headers_hint", + text: row( + "不读取已保存的请求头。留空保留;清除密钥时一并移除。", + "不讀取已儲存的請求標頭。留空保留;清除密鑰時一併移除。", + "Saved headers are never read back. Blank keeps them; Clear Secret removes them.", + "保存済みヘッダーは読み戻しません。空欄なら保持し、秘密情報の削除時に一緒に削除します。", + "저장된 헤더는 다시 읽지 않습니다. 비워 두면 유지되며 비밀 정보 삭제 시 함께 제거됩니다.", + ), + }, + Msg { + key: "selection.source_truncated", + text: row( + "选区包含截断占位,未替换,以免删除未读取的原文。", + "選取範圍含有截斷佔位,未替換,以免刪除未讀取的原文。", + "The selection contains a truncated stand-in. Nothing was replaced, to protect the omitted text.", + "選択範囲に省略されたテキストが含まれています。未取得の原文を保護するため、置換しませんでした。", + "선택 영역에 잘린 대체 텍스트가 포함되어 있습니다. 생략된 원문을 보호하기 위해 교체하지 않았습니다.", + ), + }, + Msg { + key: "settings.recording.capsuleTranscriptLabel", + text: row( + "显示流式原文", + "顯示串流原文", + "Show live transcript", + "文字起こしをリアルタイム表示", + "실시간 원문 표시", + ), + }, + Msg { + key: "settings.recording.capsuleTranscriptDesc", + text: row( + "在胶囊中显示语音识别原文,替换音频条;关闭后恢复原有显示。非流式识别将在结果返回后显示。", + "在膠囊中顯示語音辨識原文,取代音訊條;關閉後恢復原有顯示。非串流辨識將於結果傳回後顯示。", + "Show recognized speech in the capsule instead of audio bars. Turn off to restore the original display. Non-streaming recognition appears when results arrive.", + "音声バーの代わりに認識した原文を表示します。オフにすると元の表示に戻ります。非ストリーミング認識は結果の受信後に表示します。", + "오디오 막대 대신 인식된 원문을 표시합니다. 끄면 기존 표시로 돌아갑니다. 비스트리밍 인식은 결과 수신 후 표시됩니다.", + ), + }, + Msg { + key: "settings.recording.capsuleTranscriptFontSize", + text: row( + "原文字号", + "原文字級", + "Transcript font size", + "文字起こしの文字サイズ", + "원문 글자 크기", + ), + }, + Msg { + key: "settings.providers.requestFormatLabel", + text: row( + "请求格式", + "請求格式", + "Request format", + "リクエスト形式", + "요청 형식", + ), + }, + Msg { + key: "settings.providers.responsesThinkingHint", + text: row( + "部分模型只能降低思考,不能完全关闭。推理请求不发送温度参数。", + "部分模型只能降低思考,無法完全關閉。推理請求不傳送溫度引數。", + "Some models can only reduce thinking, not turn it off. Reasoning requests omit temperature.", + "一部のモデルでは思考を軽減できますが、完全には無効にできません。推論リクエストでは温度を送信しません。", + "일부 모델은 사고를 줄일 수만 있으며 완전히 끌 수 없습니다. 추론 요청에는 온도를 보내지 않습니다.", + ), + }, + Msg { + key: "settings.providers.serviceTierLabel", + text: row( + "方舟低延迟推理(fast)", + "方舟低延遲推理(fast)", + "Ark low-latency inference (fast)", + "Ark 低レイテンシ推論(fast)", + "Ark 저지연 추론(fast)", + ), + }, + Msg { + key: "settings.providers.serviceTierHint", + text: row( + "仅适用于支持低延迟的方舟模型;不支持的模型可能返回 400。", + "僅適用於支援低延遲的方舟模型;不支援的模型可能回傳 400。", + "Only supported Ark models can use this option; unsupported models may return 400.", + "対応する Ark モデルでのみ利用できます。非対応モデルでは 400 が返る場合があります。", + "지원되는 Ark 모델에서만 사용할 수 있으며, 지원되지 않는 모델은 400을 반환할 수 있습니다.", + ), + }, + Msg { + key: "settings.providers.messagesThinkingLabel", + text: row( + "思考方式", + "思考方式", + "Thinking mode", + "思考方式", + "사고 방식", + ), + }, + Msg { + key: "settings.providers.thinkingAdaptive", + text: row("自适应", "自適應", "Adaptive", "適応型", "적응형"), + }, + Msg { + key: "settings.providers.thinkingBudget", + text: row("固定预算", "固定預算", "Fixed budget", "固定予算", "고정 예산"), + }, + Msg { + key: "settings.providers.maxTokensLabel", + text: row( + "最大输出 tokens", + "最大輸出 tokens", + "Maximum output tokens", + "最大出力トークン数", + "최대 출력 토큰", + ), + }, + Msg { + key: "settings.providers.thinkingBudgetLabel", + text: row( + "思考预算 tokens", + "思考預算 tokens", + "Thinking budget tokens", + "思考トークン予算", + "사고 토큰 예산", + ), + }, + Msg { + key: "settings.providers.messagesThinkingHint", + text: row( + "旧模型或兼容网关可能需要固定预算;思考预算必须小于最大输出。开启思考时不发送温度参数。", + "舊模型或相容閘道可能需要固定預算;思考預算必須小於最大輸出。開啟思考時不傳送溫度引數。", + "Older models or compatible gateways may need a fixed budget below the output limit. Thinking requests omit temperature.", + "旧モデルや互換ゲートウェイでは固定予算が必要な場合があります。予算は最大出力未満にしてください。思考時は温度を送信しません。", + "이전 모델이나 호환 게이트웨이는 고정 예산이 필요할 수 있습니다. 예산은 출력 한도보다 작아야 합니다. 사고 요청에는 온도를 보내지 않습니다.", + ), + }, + Msg { + key: "settings.providers.llmServiceTierInvalid", + text: row( + "低延迟推理选项无效,请重新选择。", + "低延遲推理選項無效,請重新選擇。", + "Invalid low-latency inference option. Select it again.", + "低レイテンシ推論の設定が無効です。もう一度選択してください。", + "저지연 추론 옵션이 잘못되었습니다. 다시 선택하세요.", + ), + }, + Msg { + key: "settings.providers.llmRequestFormatInvalid", + text: row( + "请求格式无效,请重新选择。", + "請求格式無效,請重新選擇。", + "Invalid request format. Select a supported format.", + "リクエスト形式が無効です。選択し直してください。", + "잘못된 요청 형식입니다. 다시 선택하세요.", + ), + }, + Msg { + key: "settings.providers.llmThinkingModeInvalid", + text: row( + "思考方式无效,请重新选择。", + "思考方式無效,請重新選擇。", + "Invalid thinking mode. Select a supported mode.", + "思考方式が無効です。選択し直してください。", + "잘못된 사고 방식입니다. 다시 선택하세요.", + ), + }, + Msg { + key: "settings.providers.llmTokenLimitInvalid", + text: row( + "Token 上限必须为正整数。", + "Token 上限必須為正整數。", + "Token limits must be positive integers.", + "トークン上限は正の整数にしてください。", + "토큰 한도는 양의 정수여야 합니다.", + ), + }, + Msg { + key: "settings.providers.llmThinkingBudgetInvalid", + text: row( + "思考预算至少为 1024,且固定预算必须小于最大输出。", + "思考預算至少為 1024,且固定預算必須小於最大輸出。", + "Thinking budget must be at least 1024 and below the output limit in fixed-budget mode.", + "思考予算は1024以上、固定予算では最大出力未満にしてください。", + "사고 예산은 1024 이상이며 고정 예산 모드에서는 출력 한도보다 작아야 합니다.", + ), + }, // ---- Shell / navigation ------------------------------------------------ Msg { @@ -1120,16 +1308,43 @@ pub const CATALOG: &[Msg] = &[ ), }, Msg { - key: "startup.fcitx_help", + key: "startup.input_method_unavailable_title", + text: row( + "输入法服务不可用", + "輸入法服務無法使用", + "Input method unavailable", + "入力メソッドを利用できません", + "입력기 서비스를 사용할 수 없음", + ), + }, + Msg { + key: "startup.input_method_unavailable", 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를 다시 설치하세요. 환경을 수정한 후 이 창을 닫고 앱을 다시 시작하세요。", + "输入法服务当前不可用。应用仍会继续运行,但全局快捷键和划词功能暂不可用。请安装并启动 fcitx5、启用 OpenLess 插件,并确认当前桌面会话的 D-Bus 可用。详情:{}", + "輸入法服務目前無法使用。應用程式仍會繼續執行,但全域快捷鍵和選取文字功能暫時無法使用。請安裝並啟動 fcitx5、啟用 OpenLess 插件,並確認目前桌面工作階段的 D-Bus 可用。詳情:{}", + "The input method service is unavailable. The app will continue running, but global hotkeys and selection tools are temporarily unavailable. Install and start fcitx5, enable the OpenLess addon, and check the desktop session's D-Bus. Details: {}", + "入力メソッドサービスを利用できません。アプリは起動を続けますが、グローバルショートカットと選択ツールは一時的に利用できません。fcitx5 と OpenLess アドオンを有効にし、デスクトップセッションの D-Bus を確認してください。詳細:{}", + "입력기 서비스를 사용할 수 없습니다. 앱은 계속 실행되지만 전역 단축키와 선택 도구를 일시적으로 사용할 수 없습니다. fcitx5와 OpenLess 애드온을 설치·시작하고 데스크톱 세션의 D-Bus를 확인하세요. 세부 정보: {}", ), }, - Msg { key: "common.saving", text: row("正在保存…", "正在儲存…", "Saving…", "保存中…", "저장 중…") }, + Msg { + key: "common.load_more", + text: row("加载更多", "載入更多", "Load more", "もっと読み込む", "더 불러오기"), + }, + Msg { + key: "common.loaded_of_total", + text: row( + "已载入 {} / 共 {}", + "已載入 {} / 共 {}", + "Loaded {} of {}", + "{} / {} 件を読み込み済み", + "{} / {}개 불러옴", + ), + }, + 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) @@ -1608,14 +1823,32 @@ pub const CATALOG: &[Msg] = &[ key: "vocab.kicker", text: row("词典", "VOCABULARY", "DICTIONARY", "辞書", "사전"), }, - Msg { + Msg { + key: "vocabCard.title", + text: row( + "要记住这个词吗?", + "要記住這個詞嗎?", + "Remember this word?", + "この語を覚えますか?", + "이 단어를 기억할까요?", + ), + }, + Msg { + key: "vocabCard.accept", + text: row("记住", "記住", "Remember", "覚える", "기억하기"), + }, + Msg { + key: "vocabCard.reject", + text: row("不用", "不用", "Skip", "不要", "건너뛰기"), + }, + Msg { key: "vocab.learned_section", text: row( - "自动收集({})", - "自動收集({})", - "Auto-collected ({})", - "自動収集({})", - "자동 수집 ({})", + "确认收集({})", + "確認收集({})", + "Confirmed ({})", + "確認して追加({})", + "확인 후 수집 ({})", ), }, Msg { @@ -2040,6 +2273,219 @@ pub const CATALOG: &[Msg] = &[ "시스템 프록시 사용", ), }, + // Linux-only settings: session services and the real data/credential split. + Msg { + key: "settings.linux_privacy.refresh", + text: row("重新检查会话与网络", "重新檢查工作階段與網路", "Recheck session & network", "セッションとネットワークを再確認", "세션 및 네트워크 다시 확인"), + }, + Msg { + key: "settings.linux_privacy.not_checked", + text: row("尚未检查", "尚未檢查", "Not checked", "未確認", "확인 전"), + }, + Msg { + key: "settings.linux_privacy.input_ready", + text: row("OpenLess 插件接口可达", "OpenLess 外掛介面可達", "OpenLess addon responds", "OpenLess プラグイン応答あり", "OpenLess 플러그인 응답 확인"), + }, + Msg { + key: "settings.linux_privacy.input_unavailable", + text: row("插件接口不可达", "外掛介面無法連線", "Addon interface unavailable", "プラグインに接続できません", "플러그인 인터페이스 사용 불가"), + }, + Msg { + key: "settings.linux_privacy.network_label", + text: row("OpenLess 网络连接", "OpenLess 網路連線", "OpenLess network connection", "OpenLess ネットワーク接続", "OpenLess 네트워크 연결"), + }, + Msg { + key: "settings.linux_privacy.network_online", + text: row("HTTP 可达({} ms)", "HTTP 可達({} ms)", "HTTP reachable ({} ms)", "HTTP 接続確認({} ms)", "HTTP 연결 확인 ({} ms)"), + }, + Msg { + key: "settings.linux_privacy.network_unavailable", + text: row("OpenLess 服务不可达", "OpenLess 服務無法連線", "OpenLess endpoint unreachable", "OpenLess サービスに接続できません", "OpenLess 서비스 연결 불가"), + }, + Msg { + key: "settings.linux_privacy.network_probe_desc", + text: row( + "与 Tauri 相同:GET OpenLess /packs,8 秒超时,收到 HTTP 响应才算可达。每 30 秒刷新;不代表 ASR/LLM 渠道鉴权有效,请在 AI 服务页验证渠道。", + "與 Tauri 相同:GET OpenLess /packs,8 秒逾時,收到 HTTP 回應才算可達。每 30 秒更新;不代表 ASR/LLM 渠道驗證有效,請在 AI 服務頁驗證渠道。", + "Same as Tauri: GET OpenLess /packs, with an 8-second timeout; an HTTP response proves reachability. Refreshes every 30 seconds. This does not validate ASR/LLM authentication; verify channels in AI Services.", + "Tauri と同じく OpenLess /packs に GET し、8 秒でタイムアウトします。HTTP 応答で接続を確認し、30 秒ごとに更新します。ASR/LLM 認証は AI サービスで別途検証してください。", + "Tauri와 동일하게 OpenLess /packs에 GET 요청하며 제한 시간은 8초입니다. HTTP 응답으로 연결을 확인하고 30초마다 갱신합니다. ASR/LLM 인증은 AI 서비스에서 별도로 확인하세요.", + ), + }, + Msg { + key: "settings.linux_privacy.keyring_ready", + text: row("默认保险库已解锁", "預設保管庫已解鎖", "Default vault unlocked", "既定保管庫は解除済み", "기본 보관소 잠금 해제됨"), + }, + Msg { + key: "settings.linux_privacy.keyring_locked", + text: row("默认保险库已锁定", "預設保管庫已鎖定", "Default vault locked", "既定保管庫はロック中", "기본 보관소 잠김"), + }, + Msg { + key: "settings.linux_privacy.keyring_missing_service", + text: row("Secret Service 未运行", "Secret Service 未執行", "Secret Service not running", "Secret Service 未起動", "Secret Service 실행 안 됨"), + }, + Msg { + key: "settings.linux_privacy.keyring_missing_collection", + text: row("缺少默认保险库", "缺少預設保管庫", "No default vault", "既定保管庫がありません", "기본 보관소 없음"), + }, + Msg { + key: "settings.linux_privacy.keyring_temporary", + text: row("默认保险库仅临时保存,原凭据可能在其他钱包中", "預設保管庫僅暫存,原憑據可能在其他錢包中", "Temporary default vault; existing credentials may be in another wallet", "既定保管庫は一時保存です。元の認証情報は別の保管庫にある可能性があります", "임시 기본 보관소입니다. 기존 자격 증명은 다른 보관소에 있을 수 있습니다"), + }, + Msg { + key: "settings.linux_privacy.keyring_unavailable", + text: row("会话或保险库检查失败", "工作階段或保管庫檢查失敗", "Session/vault check failed", "セッション・保管庫の確認失敗", "세션/보관소 확인 실패"), + }, + Msg { + key: "settings.linux_privacy.keyring_restart", + text: row("服务已恢复,需重启 OpenLess", "服務已恢復,需重新啟動 OpenLess", "Service restored; restart OpenLess", "サービス復旧済み・OpenLess 再起動が必要", "서비스 복구됨, OpenLess 재시작 필요"), + }, + Msg { + key: "settings.linux_privacy.keyring_probe_desc", + text: row( + "只检查会话 D-Bus、默认保险库的锁定状态与适配器初始化结果。不读取密钥、不自动解锁或写入测试密钥;实际保存权限仍以保存结果为准。", + "僅檢查工作階段 D-Bus、預設保管庫的鎖定狀態與介面初始化結果。不讀取金鑰、不自動解鎖或寫入測試金鑰;實際儲存權限仍以儲存結果為準。", + "Checks session D-Bus, default-vault lock state and adapter initialization only. No secret reads, automatic unlocks or test writes; actual write access is determined when saving.", + "セッション D-Bus、既定保管庫のロック状態、接続初期化のみ確認します。キーの読み取り、自動解除、テスト書き込みは行いません。書き込み権限は実際の保存結果で確認してください。", + "세션 D-Bus, 기본 보관소 잠금 상태 및 어댑터 초기화만 확인합니다. 키 읽기, 자동 잠금 해제, 테스트 쓰기는 하지 않습니다. 실제 쓰기 권한은 저장 결과로 확인하세요.", + ), + }, + Msg { + key: "settings.linux_privacy.section_desc", + text: row( + "Linux 会话、Keyring / Secret Service,以及本地历史、录音和偏好。", + "Linux 工作階段、Keyring / Secret Service,以及本機歷史、錄音和偏好。", + "Linux session services, Keyring / Secret Service, local history, recordings and preferences.", + "Linux セッション、Keyring / Secret Service、ローカル履歴、録音、設定。", + "Linux 세션, Keyring / Secret Service, 로컬 기록, 녹음 및 설정.", + ), + }, + Msg { + key: "settings.linux_privacy.intro_title", + text: row("Linux 数据与隐私", "Linux 資料與隱私", "Linux data & privacy", "Linux データとプライバシー", "Linux 데이터 및 개인정보"), + }, + Msg { + key: "settings.linux_privacy.intro_desc", + text: row( + "记录保存在本机;使用云端服务时,相应音频、文本及已启用的上下文会发送至配置的服务商。", + "記錄儲存在本機;使用雲端服務時,相關音訊、文字及已啟用的上下文會傳送至設定的服務商。", + "Records are stored locally. Cloud services receive the relevant audio, text and enabled context at the providers you configure.", + "記録はローカルに保存されます。クラウド利用時は、関連する音声、テキスト、有効なコンテキストが設定したプロバイダーに送信されます。", + "기록은 로컬에 저장됩니다. 클라우드 사용 시 관련 오디오, 텍스트 및 활성화된 컨텍스트가 설정한 제공업체로 전송됩니다.", + ), + }, + Msg { + key: "settings.linux_privacy.title", + text: row("Linux 会话与访问", "Linux 工作階段與存取", "Linux session & access", "Linux セッションとアクセス", "Linux 세션 및 접근"), + }, + Msg { + key: "settings.linux_privacy.desc", + text: row( + "Linux 使用桌面会话服务;设备检测和后端就绪不代表每次录音或输入均获准。", + "Linux 使用桌面工作階段服務;偵測到裝置及後端就緒不代表每次錄音或輸入皆獲准。", + "Linux uses desktop session services. Device detection and backend initialization do not guarantee capture or input access.", + "Linux はデスクトップのセッションサービスを使います。デバイス検出やバックエンド初期化は録音・入力アクセスの保証ではありません。", + "Linux는 데스크톱 세션 서비스를 사용합니다. 장치 감지 및 백엔드 초기화가 녹음이나 입력 접근을 보장하지는 않습니다.", + ), + }, + Msg { + key: "settings.linux_privacy.mic_desc", + text: row( + "经 PipeWire/PulseAudio/ALSA 录音;检测到设备不等于允许录音,访问限制需在音频或沙盒设置中处理。", + "透過 PipeWire/PulseAudio/ALSA 錄音;偵測到裝置不等於允許錄音,存取限制需在音訊或沙箱設定中處理。", + "Capture uses PipeWire/PulseAudio/ALSA. Detecting a device does not grant capture access; check audio or sandbox restrictions if recording fails.", + "録音は PipeWire/PulseAudio/ALSA を使います。デバイス検出は録音許可ではありません。失敗時は音声やサンドボックスの制限を確認してください。", + "녹음은 PipeWire/PulseAudio/ALSA를 사용합니다. 장치 감지는 녹음 허가가 아닙니다. 녹음 실패 시 오디오 또는 샌드박스 제한을 확인하세요.", + ), + }, + Msg { + key: "settings.linux_privacy.mic_detected", + text: row("已检测到设备", "已偵測到裝置", "Device detected", "デバイス検出済み", "장치 감지됨"), + }, + Msg { + key: "settings.linux_privacy.hotkey_desc", + text: row( + "Fcitx5/OpenLess 全局快捷键监听后端须初始化;窗口内按键生效不代表全局监听可用。", + "Fcitx5/OpenLess 全域快捷鍵監聽後端須初始化;視窗內按鍵生效不代表全域監聽可用。", + "Global shortcuts require the Fcitx5/OpenLess listener backend. Keys working inside the app do not prove global listening is available.", + "グローバルショートカットには Fcitx5/OpenLess の監視バックエンドが必要です。アプリ内のキー動作だけではグローバル監視を確認できません。", + "전역 단축키에는 Fcitx5/OpenLess 수신 백엔드가 필요합니다. 앱 내 키 동작만으로 전역 수신이 가능하다고 판단할 수 없습니다.", + ), + }, + Msg { + key: "settings.linux_privacy.hotkey_ready", + text: row("监听后端已初始化", "監聽後端已初始化", "Listener initialized", "監視バックエンド初期化済み", "수신 백엔드 초기화됨"), + }, + Msg { + key: "settings.linux_privacy.input_label", + text: row("选区与文字插入", "選取範圍與文字插入", "Selection & text insertion", "選択範囲とテキスト挿入", "선택 영역 및 텍스트 삽입"), + }, + Msg { + key: "settings.linux_privacy.input_desc", + text: row( + "跨应用文本输入和选区服务由 Fcitx5/OpenLess 插件提供;Wayland 限制模拟按键和选区访问。后端不可用时可能只能复制到剪贴板。", + "跨應用程式文字輸入和選取服務由 Fcitx5/OpenLess 外掛提供;Wayland 限制模擬按鍵和選取存取。後端無法使用時可能只能複製到剪貼簿。", + "Fcitx5/OpenLess provides cross-app text and selection services. Wayland restricts synthetic keys and selection access; an unavailable backend may leave only clipboard copying.", + "Fcitx5/OpenLess プラグインがアプリ間のテキスト入力と選択を提供します。Wayland はキー送信と選択へのアクセスを制限します。バックエンド不在時はコピーのみになる場合があります。", + "Fcitx5/OpenLess 플러그인이 앱 간 텍스트 입력과 선택 서비스를 제공합니다. Wayland는 키 합성과 선택 접근을 제한합니다. 백엔드를 사용할 수 없으면 클립보드 복사만 가능할 수 있습니다.", + ), + }, + Msg { + key: "settings.linux_privacy.network_desc", + text: row( + "云端 ASR/LLM 需能访问服务端;请在 AI 服务页验证渠道连接,而不是寻找统一的系统网络授权按钮。", + "雲端 ASR/LLM 需能連線至服務端;請在 AI 服務頁驗證渠道連線,而不是尋找統一的系統網路授權按鈕。", + "Cloud ASR/LLM needs access to the provider endpoint. Verify each channel in AI Services rather than looking for a generic system network permission button.", + "クラウド ASR/LLM にはプロバイダーへの接続が必要です。汎用のネットワーク許可ボタンではなく、AI サービスで各チャネルを検証してください。", + "클라우드 ASR/LLM은 제공업체 엔드포인트에 접근해야 합니다. 일반 시스템 네트워크 권한 버튼 대신 AI 서비스에서 각 채널을 확인하세요.", + ), + }, + Msg { + key: "settings.linux_privacy.verify_channels", + text: row("按渠道验证", "依渠道驗證", "Verify per channel", "チャネルごとに検証", "채널별 확인"), + }, + Msg { + key: "settings.linux_privacy.keyring_title", + text: row("凭据保险库(Keyring)", "憑證保管庫(Keyring)", "Credential vault (Keyring)", "認証情報保管庫(Keyring)", "자격 증명 보관소 (Keyring)"), + }, + Msg { + key: "settings.linux_privacy.keyring_desc", + text: row( + "API 密钥和敏感请求头保存在用户会话的 Secret Service(org.freedesktop.secrets)。需启用 GNOME Keyring 或提供兼容接口的 KWallet,并解锁保险库。", + "API 金鑰和敏感請求標頭儲存在使用者工作階段的 Secret Service(org.freedesktop.secrets)。需啟用 GNOME Keyring 或提供相容介面的 KWallet,並解鎖保管庫。", + "API keys and sensitive headers are stored in your session's Secret Service (org.freedesktop.secrets). Enable GNOME Keyring or a compatible KWallet service and unlock the vault.", + "API キーと機密ヘッダーはセッションの Secret Service(org.freedesktop.secrets)に保存されます。GNOME Keyring または互換の KWallet サービスを有効にして保管庫を解除してください。", + "API 키와 민감한 헤더는 사용자 세션의 Secret Service (org.freedesktop.secrets)에 저장됩니다. GNOME Keyring 또는 호환 KWallet 서비스를 활성화하고 보관소 잠금을 해제하세요.", + ), + }, + Msg { + key: "settings.linux_privacy.keyring_required", + text: row("需运行并解锁", "需執行並解鎖", "Must run and be unlocked", "起動とロック解除が必要", "실행 및 잠금 해제 필요"), + }, + Msg { + key: "settings.linux_privacy.keyring_recovery", + text: row( + "会话 D-Bus/服务不可用或保险库锁定时,凭据保存可能失败。恢复服务并解锁后重启 OpenLess 再重试;错误不代表已有密钥被删除。", + "工作階段 D-Bus/服務無法使用或保管庫鎖定時,憑證儲存可能失敗。恢復服務並解鎖後重新啟動 OpenLess 再重試;錯誤不代表既有金鑰被刪除。", + "An unavailable session D-Bus/service or locked vault can prevent saving. Restore the service, unlock the vault, restart OpenLess and retry. An error does not mean existing keys were deleted.", + "セッションの D-Bus・サービスが使えない場合や保管庫がロック中の場合、保存に失敗することがあります。サービスを復旧し、保管庫を解除して OpenLess を再起動してください。エラーは既存キーの削除を意味しません。", + "세션 D-Bus/서비스를 사용할 수 없거나 보관소가 잠기면 저장이 실패할 수 있습니다. 서비스를 복구하고 잠금을 해제한 뒤 OpenLess를 다시 시작하여 재시도하세요. 오류가 기존 키 삭제를 의미하지는 않습니다.", + ), + }, + Msg { + key: "settings.linux_privacy.storage_desc", + text: row( + "历史、偏好、词库与已保存的录音位于应用数据目录。API 密钥使用 Keyring 存储,不写入 credential-metadata.json;录音留存设置不清除保险库。", + "歷史、偏好、詞庫與已儲存的錄音位於應用程式資料目錄。API 金鑰使用 Keyring 儲存,不寫入 credential-metadata.json;錄音保留設定不清除保管庫。", + "History, preferences, vocabulary and saved recordings live in the app data directory. API keys use Keyring, not credential-metadata.json; recording retention does not clear the vault.", + "履歴、設定、辞書、保存した録音はアプリのデータディレクトリにあります。API キーは credential-metadata.json ではなく Keyring に保存されます。録音の保持設定は保管庫を消去しません。", + "기록, 설정, 어휘 및 저장된 녹음은 앱 데이터 디렉터리에 있습니다. API 키는 credential-metadata.json이 아닌 Keyring에 저장됩니다. 녹음 보존 설정은 보관소를 지우지 않습니다.", + ), + }, + Msg { + key: "settings.linux_privacy.data_directory", + text: row("应用数据目录", "應用程式資料目錄", "App data directory", "アプリデータの場所", "앱 데이터 디렉터리"), + }, Msg { key: "settings.permissions.title", text: row("权限", "權限", "Permissions", "権限", "권한"), @@ -2236,22 +2682,16 @@ pub const CATALOG: &[Msg] = &[ "AI 서비스 및 모델", ), }, - Msg { + Msg { key: "modal.sections.shortcuts", - text: row( - "快捷键与选区", - "快捷鍵與選取文字", - "Shortcuts & selection", - "ショートカットと選択", - "단축키 및 선택", - ), + text: row("快捷键", "快捷鍵", "Shortcuts", "ショートカットと選択", "단축키 및 선택"), }, - Msg { + 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.", + "With a selection, one shortcut polishes (voice edit off) or takes a spoken instruction to ask or edit. With no selection but focus in a text field, speak to Help me write a draft or ask a question.", "テキスト選択後、同じショートカットで:音声編集オフ時は推敲、オン時は押しながら話してから「質問」か「編集」を選択。", "텍스트 선택 후 같은 단축키: 음성 편집 끄면 바로 다듬기, 켜면 누른 채 말한 뒤 「질문」 또는 「편집」 선택.", ), @@ -2342,9 +2782,9 @@ pub const CATALOG: &[Msg] = &[ key: "vocab.filter_all", text: row("所有", "所有", "All", "すべて", "전체"), }, - Msg { + Msg { key: "vocab.filter_auto", - text: row("自动添加", "自動新增", "Auto-Added", "自動追加", "자동 추가"), + text: row("确认收集", "確認收集", "Confirmed", "確認して追加", "확인 후 수집"), }, Msg { key: "vocab.filter_manual", @@ -2699,6 +3139,60 @@ pub const CATALOG: &[Msg] = &[ "확인 실패 · {}", ), }, + Msg { + key: "settings.channels.connection_title", + text: row( + "服务连接", + "服務連線", + "Service connection", + "サービス接続", + "서비스 연결", + ), + }, + Msg { + key: "settings.channels.edit_title", + text: row( + "编辑渠道", + "編輯渠道", + "Edit channel", + "チャネルを編集", + "채널 편집", + ), + }, + Msg { + key: "settings.channels.create_title", + text: row( + "添加渠道", + "新增渠道", + "Add channel", + "チャネルを追加", + "채널 추가", + ), + }, + Msg { + key: "settings.channels.auto_save_hint", + text: row( + "字段修改后自动保存;完成配置后,可手动验证连接。", + "修改欄位後會自動儲存;完成設定後,可手動驗證連線。", + "Changes save automatically. Once configured, you can check the connection manually.", + "変更は自動保存されます。設定が終わったら、手動で接続を確認できます。", + "변경 사항은 자동으로 저장됩니다. 설정을 마친 후 연결을 직접 확인할 수 있습니다.", + ), + }, + Msg { + key: "settings.channels.name_label", + text: row( + "渠道名称(可选)", + "名稱", + "Channel name (optional)", + "チャネル名(任意)", + "채널 이름 (선택)", + ), + }, + Msg { + key: "settings.channels.done", + text: row("完成", "完成", "Done", "完了", "완료"), + }, Msg { key: "settings.channels.llm_title", text: row( @@ -2992,53 +3486,205 @@ pub const CATALOG: &[Msg] = &[ ), }, Msg { - key: "settings.recording.audio_cue_label", - text: row( - "录音提示音", - "錄音提示音", - "Recording start sound", - "録音開始音", - "녹음 시작음", - ), + key: "settings.channels.provider_label", + text: row("供应商", "供應商", "Provider", "プロバイダー", "공급자"), }, Msg { - key: "settings.recording.audio_recording_max_entries_label", + key: "settings.channels.drag_hint", text: row( - "原始录音保留条数", - "原始錄音保留條數", - "Max raw recordings", - "元音声の保持件数", - "원본 녹음 보관 개수", + "按住拖动可调整优先级", + "按住拖曳可調整優先順序", + "Drag to change priority", + "ドラッグで優先順位を変更", + "드래그해서 우선순위 변경", ), }, Msg { - key: "settings.recording.history_group_title", + key: "settings.channels.delete_confirm", text: row( - "历史与上下文", - "歷史與上下文", - "History & context", - "履歴とコンテキスト", - "기록 및 컨텍스트", + "删除后该渠道保存的密钥也会一并清除。", + "刪除後該渠道儲存的金鑰也會一併清除。", + "Deleting also clears the keys stored for this channel.", + "削除するとこのチャネルに保存された鍵も消去されます。", + "삭제하면 이 채널에 저장된 키도 함께 지워집니다.", ), }, Msg { - key: "settings.recording.history_max_entries_label", + key: "settings.channels.confirm_delete", + text: row("确认删除", "確認刪除", "Delete", "削除する", "삭제"), + }, + Msg { + key: "settings.channels.last_check", text: row( - "历史条数上限", - "歷史條數上限", - "Max history entries", - "履歴件数の上限", - "기록 개수 상한", + "上次验证", + "上次驗證", + "Last check", + "前回の接続確認", + "마지막 확인", ), }, Msg { - key: "settings.recording.history_retention_label", + key: "settings.channels.verifying", text: row( - "历史保留天数", - "歷史保留天數", - "History retention (days)", - "履歴保持期間(日)", - "기록 보관 기간(일)", + "正在验证…", + "正在驗證…", + "Checking…", + "確認中…", + "확인 중…", + ), + }, + Msg { + key: "settings.channels.reverify", + text: row("重新验证", "重新驗證", "Check again", "再確認", "다시 확인"), + }, + Msg { + key: "settings.channels.failed_plain", + text: row("验证失败", "驗證失敗", "Check failed", "確認に失敗", "확인 실패"), + }, + Msg { + key: "settings.channels.failure_keeps_enabled", + text: row( + "验证失败不会自动停用。请求仍使用列表中第一个已启用的渠道,不会自动改用下一个。", + "驗證失敗不會自動停用。請求仍使用列表中第一個已啟用的渠道,不會自動改用下一個。", + "A failed check does not turn this service off. Requests still use the first enabled service and do not switch to the next one.", + "確認に失敗しても、このサービスは自動では停止しません。リクエストは有効な一覧の先頭を使い、次へは自動で切り替わりません。", + "확인에 실패해도 이 서비스는 자동으로 꺼지지 않습니다. 요청은 켜진 목록의 첫 항목을 쓰며, 다음 항목으로 자동 전환되지 않습니다.", + ), + }, + Msg { + key: "settings.channels.stale_result", + text: row( + "结果已超过 24 小时", + "結果已超過 24 小時", + "Result is over 24 hours old", + "24 時間以上前の結果", + "24시간이 지난 결과", + ), + }, + Msg { + key: "settings.channels.just_now", + text: row("刚刚", "剛剛", "just now", "たった今", "방금"), + }, + Msg { + key: "settings.channels.minutes_ago", + text: row("{} 分钟前", "{} 分鐘前", "{}m ago", "{}分前", "{}분 전"), + }, + Msg { + key: "settings.channels.hours_ago", + text: row("{} 小时前", "{} 小時前", "{}h ago", "{}時間前", "{}시간 전"), + }, + Msg { + key: "settings.channels.days_ago", + text: row("{} 天前", "{} 天前", "{}d ago", "{}日前", "{}일 전"), + }, + Msg { + key: "settings.channels.model_not_set", + text: row( + "模型未单独设置", + "未單獨設定模型", + "No model set explicitly", + "モデルの個別設定なし", + "모델을 별도로 설정하지 않음", + ), + }, + Msg { + key: "settings.providers.pipeline_mode_label", + text: row( + "识别管线", + "識別管線", + "Pipeline mode", + "認識パイプライン", + "인식 파이프라인", + ), + }, + Msg { + key: "settings.providers.pipeline_mode_hint", + text: row( + "传统 = ASR 转写 + LLM 润色两段式;多模态 = 单个多模态模型一次完成。", + "傳統 = ASR 轉寫 + LLM 潤色兩段式;多模態 = 單一多模態模型一次完成。", + "Traditional = ASR + LLM two-stage. Multimodal = a single audio-capable model in one pass.", + "従来 = ASR 文字起こし + LLM 整形の2段式。マルチモーダル = 音声対応モデルが1回で完了。", + "전통 = ASR 전사 + LLM 다듬기 2단계. 멀티모달 = 오디오 지원 모델이 한 번에 처리.", + ), + }, + Msg { + key: "settings.providers.pipeline_mode_traditional", + text: row( + "传统模式", + "傳統模式", + "Traditional", + "従来モード", + "전통 모드", + ), + }, + Msg { + key: "settings.providers.pipeline_mode_multimodal", + text: row( + "多模态模式", + "多模態模式", + "Multimodal", + "マルチモーダルモード", + "멀티모달 모드", + ), + }, + Msg { + key: "settings.providers.pipeline_isolation_notice", + text: row( + "两种模式使用完全独立的凭据配置。切换模式不会删除另一套配置,只是暂时停用;切回即恢复。", + "兩種模式使用完全獨立的憑證設定。切換模式不會刪除另一套設定,只是暫時停用;切回即還原。", + "The two modes keep fully separate credentials. Switching modes keeps the other set stored but unused; switching back restores it.", + "2つのモードは完全に独立した認証情報を使用します。切り替えてももう一方の設定は削除されず、切り戻せば復元されます。", + "두 모드는 완전히 분리된 자격 증명을 사용합니다. 전환해도 다른 쪽 설정은 삭제되지 않으며, 다시 전환하면 복원됩니다.", + ), + }, + 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 { @@ -3670,10 +4316,10 @@ pub const CATALOG: &[Msg] = &[ "로컬 Qwen3-ASR(C / CPU)", ), }, - Msg { + Msg { key: "settings.providers.presets.asrAppleSpeech", text: row( - "Apple 语音(macOS)", + "苹果识别(macOS)", "Apple 語音(macOS)", "Apple Speech (macOS)", "Apple 音声認識 (macOS)", @@ -3803,14 +4449,14 @@ pub const CATALOG: &[Msg] = &[ "현재 버전, 업데이트 채널 및 자동 업데이트 설정을 확인합니다.", ), }, - Msg { + Msg { key: "modal.descriptions.advanced", text: row( - "按需配置测试功能、多模态与调试工具。", - "依需要設定測試功能、多模態與除錯工具。", - "Configure testing features, multimodal processing, and debugging tools.", - "テスト機能、マルチモーダル処理、デバッグツールを設定します。", - "테스트 기능, 멀티모달 처리 및 디버깅 도구를 설정합니다.", + "按需配置测试功能与调试工具。", + "依需要設定測試功能與除錯工具。", + "Configure testing features and debugging tools as needed.", + "テスト機能とデバッグツールを設定します。", + "테스트 기능과 디버깅 도구를 설정합니다.", ), }, Msg { @@ -3853,12 +4499,12 @@ pub const CATALOG: &[Msg] = &[ "음성 인식과 텍스트 처리 서비스, 채널, 로컬 모델 및 연결을 관리합니다.", ), }, - Msg { + Msg { key: "modal.descriptions.shortcuts", text: row( - "设置各功能的触发方式,以及选中文字后的操作。", - "設定各功能的觸發方式,以及選取文字後的操作。", - "Set up shortcuts and choose what happens when you select text.", + "搜索、分类与自定义各功能的快捷键。", + "搜尋、分類與自訂各功能的快捷鍵。", + "Search, browse and customize keyboard shortcuts.", "各機能のショートカットと、テキスト選択後の操作を設定します。", "기능별 단축키와 텍스트 선택 후 동작을 설정합니다.", ), @@ -4043,14 +4689,14 @@ pub const CATALOG: &[Msg] = &[ "대화 컨텍스트 윈도(분)", ), }, - Msg { + Msg { key: "settings.recording.polish_skip_max_chars_desc", text: row( "转写不超过 N 个字(不计空白)时直接插入原文,不调用润色模型。0 = 关闭。翻译模式始终润色。", "轉寫不超過 N 個字(不計空白)時直接插入原文,不呼叫潤色模型。0 = 關閉。翻譯模式始終潤色。", "Transcripts with at most N characters (spaces not counted) are inserted as raw text without calling the LLM. 0 = off. Translation always polishes.", - "空白を除く文字数が N 以下の転写は LLM を呼ばず原文のまま挿入します。0 = 無効。翻訳モードは常に整形します。", - "공백을 제외한 글자 수가 N 이하인 전사는 LLM 없이 원문으로 삽입합니다. 0 = 끔. 번역 모드는 항상 다듬습니다.", + "空白を除いて N 文字以下の認識結果は、LLM を呼び出さずそのまま挿入します。0 = オフ。翻訳は常に整形します。", + "공백을 제외하고 N자 이하의 인식 결과는 LLM 호출 없이 원문 그대로 삽입합니다. 0 = 끔. 번역은 항상 다듬습니다.", ), }, Msg { @@ -5772,6 +6418,1693 @@ pub const CATALOG: &[Msg] = &[ text: row("书面润色", "書面潤色", "Written polish", "書面の推敲", "서면 다듬기"), }, + Msg { + key: "settings.channels.edit", + text: row("编辑", "編輯", "Edit", "編集", "편집"), + }, + Msg { + key: "settings.providers.extraHeadersLabel", + text: row( + "额外 Headers", + "額外 Headers", + "Extra headers", + "追加 Headers", + "추가 Headers", + ), + }, + Msg { + key: "settings.providers.extraHeadersPlaceholder", + text: row( + "{\"custom-head\":\"...\"}", + "{\"custom-head\":\"...\"}", + "{\"custom-head\":\"...\"}", + "{\"custom-head\":\"...\"}", + "{\"custom-head\":\"...\"}", + ), + }, + Msg { + key: "settings.providers.temperatureLabel", + text: row("Temperature", "Temperature", "Temperature", "Temperature", "Temperature"), + }, + Msg { + key: "settings.providers.temperaturePlaceholder", + text: row( + "留空则不发送;范围 0~2(含边界),例如 0.3", + "留空則不傳送;範圍 0~2(含邊界),例如 0.3", + "Leave empty to omit; range 0–2 inclusive, e.g. 0.3", + "空欄なら送信しません。範囲は 0〜2(両端を含む)。例: 0.3", + "비워 두면 보내지 않음. 범위 0~2(양 끝 포함), 예: 0.3", + ), + }, + Msg { + key: "settings.providers.thinkingModeHint", + text: row( + "按所选请求格式和模型支持的参数启用、关闭或降低思考,不向提示词注入控制指令。", + "依所選請求格式與模型支援的引數啟用、關閉或降低思考,不在提示詞注入控制指令。", + "Enable, disable, or reduce thinking using parameters supported by the selected request format and model. No control instructions are injected into prompts.", + "選択したリクエスト形式とモデルが対応するパラメータで思考を有効化、無効化または軽減します。プロンプトに制御指示は追加しません。", + "선택한 요청 형식과 모델이 지원하는 매개변수로 사고를 켜거나 끄거나 줄입니다. 프롬프트에 제어 지시를 추가하지 않습니다.", + ), + }, + Msg { + key: "settings.providers.thinkingModeLabel", + text: row("思考", "思考", "Thinking", "思考", "사고"), + }, + Msg { + key: "common.operationFailed", + text: row( + "操作失败", + "操作失敗", + "Operation failed", + "操作に失敗しました", + "작업 실패", + ), + }, + Msg { + key: "common.saved", + text: row("已保存", "已儲存", "Saved", "保存しました", "저장됨"), + }, + Msg { + key: "modal.service_views.inactive", + text: row( + "当前模式未使用", + "目前模式未使用", + "Not used in this mode", + "現在のモードでは未使用", + "현재 모드에서 사용 안 함", + ), + }, + Msg { + key: "settings.providers.apiKeyLabel", + text: row("API 密钥", "API 金鑰", "API Key", "API キー", "API 키"), + }, + Msg { + key: "settings.providers.baseUrlLabel", + text: row("接口地址", "介面地址", "Base URL", "エンドポイント", "엔드포인트"), + }, + Msg { + key: "settings.providers.omniTitle", + text: row( + "多模态模型", + "多模態模型", + "Multimodal model", + "マルチモーダルモデル", + "멀티모달 모델", + ), + }, + Msg { + key: "settings.providers.providerLabel", + text: row("供应商", "供應商", "Provider", "サプライヤー", "공급자"), + }, + Msg { + key: "settings.providers.validateFailed", + text: row( + "连接检查未通过。", + "連線檢查未透過。", + "Connection check failed.", + "接続チェックに失敗しました。", + "연결 확인에 실패했습니다.", + ), + }, + Msg { + key: "settings.providers.validateSuccessOmni", + text: row( + "文本连通性通过。这不代表音频听写可用,请再做一次短录音确认。", + "文字連通性通過。這不代表音訊聽寫可用,請再做一次短錄音確認。", + "Text connectivity passed. This does not mean audio dictation works—please confirm with a short recording.", + "テキスト疎通は成功しました。これは音声ディクテーションが使えることを意味しません。短い録音で確認してください。", + "텍스트 연결은 통과했습니다. 오디오 받아쓰기가 된다는 뜻은 아닙니다. 짧은 녹음으로 확인하세요.", + ), + }, + Msg { + key: "settings.providers.tencentCloudAppIdLabel", + text: row( + "腾讯云 AppID", + "騰訊雲 AppID", + "Tencent Cloud AppID", + "Tencent Cloud AppID", + "Tencent Cloud AppID", + ), + }, + Msg { + key: "settings.providers.tencentCloudNote", + text: row( + "使用腾讯云「语音识别」服务的 API 密钥。默认 Hy-ASR-3.0-preview 支持中英与 20 种方言;Preview 仅支持 60 秒以内的 16kHz 单声道 PCM,暂不支持上下文或热词增强。", + "使用騰訊雲「語音辨識」服務的 API 金鑰。預設 Hy-ASR-3.0-preview 支援中英與 20 種方言;Preview 僅支援 60 秒內的 16kHz 單聲道 PCM,暫不支援上下文或熱詞增強。", + "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.", + "Tencent Cloud 音声認識 API の認証情報を使用します。既定の Hy-ASR-3.0-preview は中国語・英語・20 方言に対応します。Preview は 60 秒以内の 16kHz モノラル PCM のみ対応し、コンテキストとホットワード強化は未対応です。", + "Tencent Cloud 음성 인식 API 자격 증명을 사용합니다. 기본 Hy-ASR-3.0-preview는 중국어·영어·20개 방언을 지원합니다. Preview는 60초 이내의 16kHz 모노 PCM만 지원하며, 컨텍스트와 핫워드 강화는 아직 지원하지 않습니다.", + ), + }, + Msg { + key: "settings.providers.tencentCloudSecretIdLabel", + text: row("SecretID", "SecretID", "SecretID", "SecretID", "SecretID"), + }, + Msg { + key: "settings.providers.tencentCloudSecretKeyLabel", + text: row("SecretKey", "SecretKey", "SecretKey", "SecretKey", "SecretKey"), + }, + Msg { + key: "settings.providers.volcengineAccessKeyLabel", + text: row("Access Token", "Access Token", "Access Token", "Access Token", "Access Token"), + }, + Msg { + key: "settings.providers.volcengineApiKeyLabel", + text: row("API Key", "API Key", "API Key", "API Key", "API Key"), + }, + Msg { + key: "settings.providers.volcengineAppKeyLabel", + text: row("APP ID", "APP ID", "APP ID", "APP ID", "APP ID"), + }, + Msg { + key: "settings.providers.volcengineAuthModeLabel", + text: row("鉴权模式", "鑑權模式", "Auth mode", "認証モード", "인증 모드"), + }, + Msg { + key: "settings.providers.volcengineResourceIdLabel", + text: row("Resource ID", "Resource ID", "Resource ID", "Resource ID", "Resource ID"), + }, + Msg { + key: "settings.providers.xfyunApiKeyLabel", + text: row("API Key", "API Key", "API Key", "API Key", "API Key"), + }, + Msg { + key: "settings.providers.xfyunAppIdLabel", + text: row("AppID", "AppID", "AppID", "AppID", "AppID"), + }, + Msg { + key: "settings.permissions.checking", + text: row("检查中…", "檢查中…", "Checking…", "確認中…", "확인 중…"), + }, + Msg { + key: "settings.permissions.hotkey_failed", + text: row( + "监听失败", + "監聽失敗", + "Listener failed", + "監視失敗", + "감지 실패", + ), + }, + Msg { + key: "settings.providers.apiKeyOptionalLabel", + text: row( + "API 密钥(可选)", + "API 密鑰(選填)", + "API Key (optional)", + "API キー(任意)", + "API 키 (선택 사항)", + ), + }, + Msg { + key: "settings.providers.volcengineServiceLabel", + text: row("服务", "服務", "Service", "サービス", "서비스"), + }, + Msg { + key: "cloudSyncE2ee.account", + text: row("同步账号", "同步帳號", "Sync account", "同期アカウント", "동기화 계정"), + }, + Msg { + key: "cloudSyncE2ee.backupRetention", + text: row( + "删除后,服务备份副本最多保留 {} 天。", + "刪除後,服務備份副本最多保留 {} 天。", + "After deletion, service backup copies may be retained for up to {} days.", + "削除後も、サービスのバックアップコピーは最長 {} 日間保持される場合があります。", + "삭제 후에도 서비스의 백업 사본이 최대 {}일 동안 보관될 수 있습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.cancelTask", + text: row( + "取消当前任务", + "取消目前工作", + "Cancel current task", + "現在の処理をキャンセル", + "현재 작업 취소", + ), + }, + Msg { + key: "cloudSyncE2ee.changePassword", + text: row( + "更改同步密码", + "變更同步密碼", + "Change sync password", + "同期パスワードを変更", + "동기화 비밀번호 변경", + ), + }, + Msg { + key: "cloudSyncE2ee.confirmDelete", + text: row( + "删除云端备份", + "刪除雲端備份", + "Delete cloud backup", + "クラウドバックアップを削除", + "클라우드 백업 삭제", + ), + }, + Msg { + key: "cloudSyncE2ee.confirmDisable", + text: row( + "关闭同步", + "關閉同步", + "Turn off sync", + "同期をオフにする", + "동기화 끄기", + ), + }, + Msg { + key: "cloudSyncE2ee.confirmPassword", + text: row( + "再次输入新密码", + "再次輸入新密碼", + "Confirm new password", + "新しいパスワードを再入力", + "새 비밀번호 확인", + ), + }, + Msg { + key: "cloudSyncE2ee.consentCheck", + text: row( + "我已了解完整范围,并同意加密同步这些资料。", + "我已了解完整範圍,並同意加密同步這些資料。", + "I understand the full scope and agree to sync this data in encrypted form.", + "対象範囲を理解し、これらのデータの暗号化同期に同意します。", + "전체 범위를 이해했으며 이 데이터를 암호화해 동기화하는 데 동의합니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.consentDescription", + text: row( + "同步内容包含服务凭据和私密文本历史,资料会在本机加密后上传。请先了解完整范围。", + "同步內容包含服務憑證和私人文字歷史,資料會在本機加密後上傳。請先了解完整範圍。", + "This sync includes service credentials and private text history. Data is encrypted on your device before upload. Review the full scope first.", + "サービスの認証情報と非公開のテキスト履歴も同期します。アップロード前に端末で暗号化します。対象範囲を確認してください。", + "서비스 자격 증명과 비공개 텍스트 기록도 동기화됩니다. 업로드 전에 기기에서 암호화합니다. 전체 범위를 확인하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.consentTitle", + text: row( + "启用端到端加密同步", + "啟用端對端加密同步", + "Enable end-to-end encrypted sync", + "エンドツーエンド暗号化同期を有効にする", + "종단 간 암호화 동기화 사용", + ), + }, + Msg { + key: "cloudSyncE2ee.continue", + text: row("继续", "繼續", "Continue", "続ける", "계속"), + }, + Msg { + key: "cloudSyncE2ee.currentPassword", + text: row( + "当前同步密码", + "目前同步密碼", + "Current sync password", + "現在の同期パスワード", + "현재 동기화 비밀번호", + ), + }, + Msg { + key: "cloudSyncE2ee.customServerHint", + text: row( + "留空则使用官方同步服务器。地址必须是 https:// 开头的根地址。", + "留空則使用官方同步伺服器。位址必須以 https:// 開頭的根位址。", + "Leave blank to use the official sync server. The address must start with https:// and be a root URL.", + "空欄の場合は公式の同期サーバーを使用します。アドレスは https:// で始まるルート URL である必要があります。", + "비워두면 공식 동기화 서버를 사용합니다. 주소는 https://로 시작하는 루트 URL이어야 합니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.customServerSave", + text: row("保存", "儲存", "Save", "保存", "저장"), + }, + Msg { + key: "cloudSyncE2ee.customServerTitle", + text: row( + "自建同步服务器(高级)", + "自建同步伺服器(進階)", + "Self-hosted sync server (advanced)", + "自前の同期サーバー(上級者向け)", + "자체 동기화 서버 (고급)", + ), + }, + Msg { + key: "cloudSyncE2ee.customServerToken", + text: row( + "登录 Token", + "登入 Token", + "Sign-in token", + "サインイン Token", + "로그인 Token", + ), + }, + Msg { + key: "cloudSyncE2ee.delete", + text: row( + "删除云端备份", + "刪除雲端備份", + "Delete cloud backup", + "クラウドバックアップを削除", + "클라우드 백업 삭제", + ), + }, + Msg { + key: "cloudSyncE2ee.deleteDescription", + text: row( + "仅删除当前账号的云端备份,本机资料保留。以后在其他设备恢复前,需要重新创建备份。", + "僅刪除目前帳號的雲端備份,本機資料保留。之後在其他裝置還原前,需要重新建立備份。", + "Only this account’s cloud backup is deleted. Local data is kept. You will need a new backup to restore on another device.", + "このアカウントのクラウドバックアップのみ削除します。ローカルデータは残ります。別の端末で復元するには新しいバックアップが必要です。", + "이 계정의 클라우드 백업만 삭제합니다. 로컬 데이터는 유지됩니다. 다른 기기에서 복원하려면 새 백업이 필요합니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.deleteTitle", + text: row( + "删除云端加密备份?", + "刪除雲端加密備份?", + "Delete the encrypted cloud backup?", + "暗号化クラウドバックアップを削除しますか?", + "암호화된 클라우드 백업을 삭제할까요?", + ), + }, + Msg { + key: "cloudSyncE2ee.description", + text: row( + "在本机加密后,通过 GitHub 账号或 Token 跨设备同步。", + "在本機加密後,透過 GitHub 帳號或 Token 跨裝置同步。", + "Encrypt on this device before syncing with your GitHub account.", + "この端末で暗号化してから、GitHub アカウントで端末間を同期します。", + "이 기기에서 암호화한 후 GitHub 계정으로 기기 간에 동기화합니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.disableDescription", + text: row( + "关闭后停止后续同步,云端备份和本机资料都会保留。删除云端备份是另一项独立操作。", + "關閉後停止後續同步,雲端備份和本機資料都會保留。刪除雲端備份是另一項獨立操作。", + "This stops further sync. Your cloud backup and local data are kept. Deleting the cloud backup is a separate action.", + "以後の同期を停止します。クラウドのバックアップとローカルのデータは残ります。削除は別の操作です。", + "이후 동기화를 중지합니다. 클라우드 백업과 로컬 데이터는 유지됩니다. 백업 삭제는 별도 작업입니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.disableTitle", + text: row( + "关闭加密同步?", + "關閉加密同步?", + "Turn off encrypted sync?", + "暗号化同期をオフにしますか?", + "암호화 동기화를 끌까요?", + ), + }, + Msg { + key: "cloudSyncE2ee.enable", + text: row( + "启用加密同步", + "啟用加密同步", + "Enable encrypted sync", + "暗号化同期を有効にする", + "암호화 동기화 사용", + ), + }, + Msg { + key: "cloudSyncE2ee.hasSnapshot", + text: row( + "云端已有加密备份。", + "雲端已有加密備份。", + "An encrypted cloud backup is available.", + "暗号化されたクラウドバックアップがあります。", + "암호화된 클라우드 백업이 있습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.keyLocked", + text: row( + "此设备尚未解锁", + "此裝置尚未解鎖", + "Locked on this device", + "この端末ではロック中", + "이 기기에서 잠김", + ), + }, + Msg { + key: "cloudSyncE2ee.keyUnlocked", + text: row( + "此设备已解锁", + "此裝置已解鎖", + "Unlocked on this device", + "この端末でロック解除済み", + "이 기기에서 잠금 해제됨", + ), + }, + Msg { + key: "cloudSyncE2ee.loading", + text: row( + "正在读取同步状态…", + "正在讀取同步狀態…", + "Checking sync status…", + "同期状態を確認中…", + "동기화 상태 확인 중…", + ), + }, + Msg { + key: "cloudSyncE2ee.lock", + text: row( + "锁定此设备", + "鎖定此裝置", + "Lock this device", + "この端末をロック", + "이 기기 잠그기", + ), + }, + Msg { + key: "cloudSyncE2ee.noSnapshot", + text: row( + "云端尚无加密备份。", + "雲端尚無加密備份。", + "No encrypted cloud backup yet.", + "暗号化クラウドバックアップはまだありません。", + "아직 암호화된 클라우드 백업이 없습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.passwordTitle", + text: row( + "更改同步密码", + "變更同步密碼", + "Change your sync password", + "同期パスワードを変更", + "동기화 비밀번호 변경", + ), + }, + Msg { + key: "cloudSyncE2ee.passwordWarning", + text: row( + "请妥善保管同步密码。密码无法找回,遗失后可能无法读取云端资料。", + "請妥善保管同步密碼。密碼無法找回,遺失後可能無法讀取雲端資料。", + "Keep your sync password safe. It cannot be recovered; losing it can make cloud data unreadable.", + "同期パスワードは安全に保管してください。再発行できず、紛失するとクラウドのデータを読めなくなる場合があります。", + "동기화 비밀번호를 안전하게 보관하세요. 복구할 수 없으며, 분실하면 클라우드 데이터를 읽지 못할 수 있습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.protocolTitle", + text: row( + "云同步协议与隐私提醒", + "雲端同步協議與隱私提醒", + "Cloud sync agreement and privacy notice", + "クラウド同期の同意事項とプライバシー", + "클라우드 동기화 약관 및 개인정보 안내", + ), + }, + Msg { + key: "cloudSyncE2ee.refresh", + text: row( + "刷新状态", + "重新整理狀態", + "Refresh status", + "状態を更新", + "상태 새로고침", + ), + }, + Msg { + key: "cloudSyncE2ee.rememberKey", + text: row( + "在此设备的安全存储中记住解锁密钥", + "在此裝置的安全儲存空間記住解鎖金鑰", + "Remember the unlock key in this device’s secure storage", + "この端末の安全なストレージに解除キーを記憶する", + "이 기기의 보안 저장소에 잠금 해제 키 기억", + ), + }, + Msg { + key: "cloudSyncE2ee.restore", + text: row( + "审阅云端恢复", + "檢閱雲端還原", + "Review cloud restore", + "クラウドからの復元を確認", + "클라우드 복원 검토", + ), + }, + Msg { + key: "cloudSyncE2ee.restoreDescription", + text: row( + "请检查下列资料类别,敏感值不会展示。确认之前不会恢复任何内容。", + "請檢查下列資料類別,敏感值不會顯示。確認之前不會還原任何內容。", + "Review the categories below. Sensitive values stay hidden. Nothing is restored until you confirm.", + "データの種類を確認してください。機密の値は表示しません。確認するまで復元しません。", + "데이터 종류를 확인하세요. 민감한 값은 표시하지 않습니다. 확인 전에는 복원하지 않습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.restoreTitle", + text: row( + "恢复前审阅", + "還原前檢閱", + "Review before restoring", + "復元前に確認", + "복원 전 검토", + ), + }, + Msg { + key: "cloudSyncE2ee.signIn", + text: row( + "使用 GitHub 登录", + "使用 GitHub 登入", + "Sign in with GitHub", + "GitHub でログイン", + "GitHub로 로그인", + ), + }, + Msg { + key: "cloudSyncE2ee.signOut", + text: row("退出登录", "登出", "Sign out", "ログアウト", "로그아웃"), + }, + Msg { + key: "cloudSyncE2ee.snapshotUnknown", + text: row( + "尚未检查云端备份。", + "尚未檢查雲端備份。", + "Cloud backup has not been checked yet.", + "クラウドバックアップは未確認です。", + "클라우드 백업을 아직 확인하지 않았습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.title", + text: row( + "加密云同步", + "加密雲端同步", + "Encrypted cloud sync", + "暗号化クラウド同期", + "암호화 클라우드 동기화", + ), + }, + Msg { + key: "cloudSyncE2ee.unlock", + text: row("解锁", "解鎖", "Unlock", "ロック解除", "잠금 해제"), + }, + Msg { + key: "cloudSyncE2ee.working", + text: row( + "正在处理…", + "正在處理…", + "Working securely…", + "処理中…", + "처리 중…", + ), + }, + Msg { + key: "cloudSyncE2ee.conflictProgress", + text: row( + "已处理 {} / {} 项冲突", + "已處理 {} / {} 項衝突", + "{} of {} conflicts resolved", + "競合を {} / {} 件解決済み", + "충돌 {} / {}개 해결", + ), + }, + Msg { + key: "cloudSyncE2ee.deviceReview", + text: row( + "恢复后请核对 {} 项设备配置,包括快捷键、模型目录、麦克风及系统授权。", + "還原後請核對 {} 項裝置設定,包括快捷鍵、模型目錄、麥克風及系統授權。", + "After restoring, review {} device settings, including shortcuts, model paths, microphones and system permissions.", + "復元後は、ショートカット、モデルの場所、マイク、システム権限など {} 件の端末設定を確認してください。", + "복원 후 단축키, 모델 경로, 마이크, 시스템 권한 등 기기 설정 {}개를 확인하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.lastSync", + text: row( + "上次同步:{}", + "上次同步:{}", + "Last synced {}", + "最終同期:{}", + "마지막 동기화: {}", + ), + }, + Msg { + key: "cloudSyncE2ee.merge", + text: row( + "合并,并逐项处理冲突", + "合併,並逐項處理衝突", + "Merge and resolve conflicts", + "マージして競合を解決", + "병합하고 충돌 해결", + ), + }, + Msg { + key: "cloudSyncE2ee.passwordPolicy", + text: row( + "使用 12–128 个字符,包含大写字母、小写字母和数字,避免常见弱密码。", + "使用 12–128 個字元,包含大寫字母、小寫字母和數字,避免常見弱密碼。", + "Use 12–128 characters with uppercase and lowercase letters and a number. Avoid common passwords.", + "12~128 文字で英大文字・英小文字・数字を含めてください。よくあるパスワードは避けてください。", + "12~128자로 영문 대문자, 소문자, 숫자를 포함하세요. 흔한 비밀번호는 피하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.replace", + text: row( + "使用云端资料替换本机同步资料", + "使用雲端資料取代本機同步資料", + "Replace synced local data with cloud data", + "同期対象のローカルデータをクラウドの内容で置換", + "동기화 대상 로컬 데이터를 클라우드 데이터로 교체", + ), + }, + Msg { + key: "cloudSyncE2ee.restoreCheck", + text: row( + "我已检查恢复方式和冲突处理选择。", + "我已檢查還原方式和衝突處理選擇。", + "I have reviewed the restore mode and conflict choices.", + "復元方法と競合の選択を確認しました。", + "복원 방식과 충돌 선택을 확인했습니다.", + ), + }, + Msg { + key: "uiRefinement.family", + text: row("字体系列", "字型系列", "Font family", "フォントファミリー", "글꼴 모음"), + }, + Msg { + key: "uiRefinement.fontHint", + text: row( + "仅使用已安装字体,缺失时回退到系统字体。不下载、不扫描,也不发起字体网络请求。", + "僅使用已安裝的字型,找不到時改用系統字型。不下載、不掃描,也不發出字型網路請求。", + "Only installed fonts are used, with system fallbacks. No font downloads, scanning or network requests.", + "インストール済みのフォントのみを使用し、見つからない場合はシステムフォントに切り替えます。フォントのダウンロード、スキャン、ネットワーク通信は行いません。", + "설치된 글꼴만 사용하며, 없으면 시스템 글꼴을 사용합니다. 글꼴 다운로드, 스캔 또는 네트워크 요청은 하지 않습니다.", + ), + }, + Msg { + key: "uiRefinement.fontSize", + text: row( + "界面大小", + "介面大小", + "Interface size", + "インターフェースのサイズ", + "인터페이스 크기", + ), + }, + Msg { + key: "uiRefinement.storageError", + text: row( + "本地保存失败,本次会话仍使用当前选择。", + "無法儲存至本機,本次工作階段仍會使用目前的選擇。", + "Could not save locally. Your current session still uses this choice.", + "このデバイスに保存できませんでした。現在のセッションではこの設定を引き続き使用します。", + "이 기기에 저장하지 못했습니다. 현재 세션에서는 선택한 설정이 계속 적용됩니다.", + ), + }, + Msg { + key: "uiRefinement.typography", + text: row("字体与排版", "字型與排版", "Typography", "フォントと組版", "글꼴 및 조판"), + }, + Msg { + key: "uiRefinement.width", + text: row( + "阅读宽度", + "閱讀寬度", + "Reading width", + "テキストの表示幅", + "읽기 영역 너비", + ), + }, + Msg { + key: "uiRefinement.back", + text: row("返回应用", "返回應用程式", "Back to app", "アプリに戻る", "앱으로 돌아가기"), + }, + Msg { + key: "uiRefinement.noResults", + text: row( + "没有匹配的命令", + "沒有符合的命令", + "No matching commands", + "一致するコマンドがありません", + "일치하는 명령이 없습니다", + ), + }, + Msg { + key: "uiRefinement.shortcutHint", + text: row( + "点击按键即可重新录制。Esc 取消;保存前会检查快捷键冲突。", + "點選按鍵即可重新錄製。Esc 取消;儲存前會檢查快速鍵衝突。", + "Select the keys to record a shortcut. Escape cancels; conflicts are checked before saving.", + "キーを選択してショートカットを記録します。Esc でキャンセルします。保存前に競合を確認します。", + "키를 선택해 단축키를 기록하세요. Esc 키로 취소하며, 저장 전에 충돌을 확인합니다.", + ), + }, + Msg { + key: "uiRefinement.shortcutsSearch", + text: row( + "搜索命令或按键", + "搜尋命令或按鍵", + "Search commands or keys", + "コマンドまたはキーを検索", + "명령 또는 키 검색", + ), + }, + Msg { + key: "uiRefinement.unassigned", + text: row("未分配", "未指派", "Unassigned", "未割り当て", "미지정"), + }, + Msg { + key: "uiRefinement.all", + text: row("全部", "全部", "All", "すべて", "전체"), + }, + Msg { + key: "uiRefinement.assigned", + text: row("已分配", "已指派", "Assigned", "割り当て済み", "지정됨"), + }, + Msg { + key: "uiRefinement.category", + text: row("分类", "分類", "Category", "カテゴリ", "분류"), + }, + Msg { + key: "uiRefinement.recording", + text: row("语音与文本", "語音與文字", "Voice & text", "音声とテキスト", "음성 및 텍스트"), + }, + Msg { + key: "uiRefinement.status", + text: row("分配状态", "指派狀態", "Assignment", "割り当て状況", "지정 상태"), + }, + Msg { + key: "uiRefinement.styles", + text: row("风格", "風格", "Styles", "スタイル", "스타일"), + }, + Msg { + key: "uiRefinement.system", + text: row("系统", "系統", "System", "システム", "시스템"), + }, + Msg { + key: "uiRefinement.comfortable", + text: row("舒适", "舒適", "Comfortable", "ゆったり", "편안하게"), + }, + Msg { + key: "uiRefinement.narrow", + text: row("聚焦", "聚焦", "Focused", "集中表示", "집중"), + }, + Msg { + key: "uiRefinement.native", + text: row("系统默认", "系統預設", "System", "システム既定", "시스템 기본값"), + }, + Msg { + key: "uiRefinement.wide", + text: row("宽阔", "寬闊", "Wide", "広め", "넓게"), + }, + Msg { + key: "settings.recording.auto_update_check_desc", + text: row( + "启动时及每 60 分钟自动检查更新。", + "啟動時及每 60 分鐘自動檢查更新。", + "Check for updates on launch and every 60 minutes.", + "起動時および 60 分ごとに自動チェック。", + "시작 시 및 60 분마다 자동 확인.", + ), + }, + Msg { + key: "settings.recording.start_minimized_desc", + text: row( + "所有启动路径都不弹主窗口,仅菜单栏 / 托盘运行。", + "所有啟動路徑都不彈主視窗,僅選單欄 / 託盤執行。", + "No main window on any launch path — menu bar / tray only.", + "どの起動経路でもメインウィンドウを開かず、メニューバー / トレイのみで動作。", + "모든 시작 경로에서 메인 창을 열지 않고 메뉴 막대 / 트레이에서만 실행합니다.", + ), + }, + Msg { + key: "settings.recording.startup_at_boot_desc", + text: row( + "登录系统时自动启动 OpenLess。", + "登入系統時自動啟動 OpenLess。", + "Start OpenLess automatically when you sign in.", + "ログイン時に OpenLess を自動起動。", + "로그인 시 OpenLess 자동 시작.", + ), + }, + Msg { + key: "settings.selection_workspace.auto_intent", + text: row( + "自动判断意图", + "自動判斷意圖", + "Auto-classify intent", + "意図を自動判定", + "의도 자동 판별", + ), + }, + Msg { + key: "settings.selection_workspace.auto_intent_desc", + text: row( + "开启后默认用服务配置的模型判断问句 vs 编辑;模型不可用或解析失败时回退到问句启发式。", + "開啟後預設用服務配置的模型判斷問句 vs 編輯;模型不可用或解析失敗時回退到問句啟發式。", + "When on, the configured model classifies question vs edit by default; falls back to question-word heuristics if the model fails.", + "オン時は設定モデルが質問/編集を判定。モデル失敗時のみ?/疑問語ヒューリスティックにフォールバック。", + "켜면 설정된 모델이 질문/편집을 판별합니다. 모델 실패 시에만 의문사 휴리스틱으로 폴백합니다.", + ), + }, + Msg { + key: "settings.selection_workspace.edit_keywords", + text: row( + "额外问句线索", + "額外問句線索", + "Extra question cues", + "追加の疑問手がかり", + "추가 의문 단서", + ), + }, + Msg { + key: "settings.selection_workspace.edit_keywords_desc", + text: row( + "关闭自动判断时生效;每行一个,指令中包含则视为提问,否则仍按问句启发式(?/吗/什么…)判定。", + "關閉自動判斷時生效;每行一個,指令含則視為提問,否則仍按問句啟發式判定。", + "Only when auto-classify is off; one cue per line forces Ask; otherwise use ? / question-word heuristics.", + "自動判定オフ時のみ。1行1語で質問扱い。なければ?/疑問語ヒューリスティック。", + "자동 판별 끔일 때만. 한 줄에 하나면 질문. 없으면 ?/의문사 휴리스틱.", + ), + }, + Msg { + key: "settings.selection_workspace.edit_plan_format", + text: row( + "编辑方案格式", + "編輯方案格式", + "Edit plan format", + "編集プラン形式", + "편집 계획 형식", + ), + }, + Msg { + key: "settings.selection_workspace.edit_plan_format_desc", + text: row( + "模型优先按所选格式输出 EditPlan;解析失败时再尝试另一种格式。", + "模型優先按所選格式輸出 EditPlan;解析失敗時再嘗試另一種格式。", + "Prefer this EditPlan format from the model; if parsing fails, try the other format.", + "モデルはこの形式の EditPlan を優先出力。解析失敗時はもう一方を試します。", + "모델이 선택한 형식으로 EditPlan을 우선 출력합니다. 파싱 실패 시 다른 형식을 시도합니다.", + ), + }, + Msg { + key: "settings.selection_workspace.edit_plan_format_json", + text: row("JSON", "JSON", "JSON", "JSON", "JSON"), + }, + Msg { + key: "settings.selection_workspace.edit_plan_format_xml", + text: row("XML", "XML", "XML", "XML", "XML"), + }, + Msg { + key: "settings.selection_workspace.edit_system_prompt", + text: row( + "编辑方案系统提示词", + "編輯方案系統提示詞", + "Edit plan system prompt", + "編集プランのシステムプロンプト", + "편집 계획 시스템 프롬프트", + ), + }, + Msg { + key: "settings.selection_workspace.edit_system_prompt_desc", + text: row( + "覆盖风格包 / 内置默认的 EditPlan system prompt。留空则按「自定义 → 风格包 → 内置默认」回退。", + "覆蓋風格包 / 內建預設的 EditPlan system prompt。留空則按「自訂 → 風格包 → 內建預設」回退。", + "Overrides the style-pack / built-in EditPlan system prompt. Leave empty to fall back: custom → pack → built-in.", + "スタイルパック / 内蔵デフォルトの EditPlan システムプロンプトを上書き。空なら カスタム → パック → 内蔵 の順でフォールバック。", + "스타일 팩 / 내장 기본 EditPlan 시스템 프롬프트를 덮어씁니다. 비우면 사용자 지정 → 팩 → 내장 순으로 폴백합니다.", + ), + }, + Msg { + key: "settings.selection_workspace.edit_system_prompt_reset", + text: row( + "恢复默认", + "恢復預設", + "Reset to default", + "デフォルトに戻す", + "기본값으로 재설정", + ), + }, + Msg { + key: "settings.selection_workspace.voice_delivery_desc", + text: row( + "语音编辑完成后:直接替换选区,或在华词面板中预览后再确认。", + "語音編輯完成後:直接替換選區,或在華詞面板中預覽後再確認。", + "After voice edit: replace selection directly, or preview in Ask panel then confirm.", + "音声編集後:選択範囲を直接置換するか、Ask パネルで確認してから置換します。", + "음성 편집 후: 선택 영역을 바로 교체하거나 Ask 패널에서 확인 후 교체합니다.", + ), + }, + Msg { + key: "settings.selection_workspace.voice_enable_desc", + text: row( + "与上方同一快捷键;录音方式跟随全局设置(当前:{})。", + "與上方同一快捷鍵;錄音方式跟隨全域設定(目前:{})。", + "Uses the same shortcut above; recording follows global settings (current: {}).", + "上と同じショートカットを使用。録音方式はグローバル設定に従います(現在:{})。", + "위와 같은 단축키 사용. 녹음 방식은 전역 설정을 따릅니다(현재: {}).", + ), + }, + Msg { + key: "settings.selection_workspace.edit_system_prompt_placeholder", + text: row( + "留空 = 使用风格包或内置默认", + "留空 = 使用風格包或內建預設", + "Empty = use style pack or built-in default", + "空 = スタイルパックまたは内蔵デフォルト", + "비움 = 스타일 팩 또는 내장 기본값", + ), + }, + // 加密云同步的错误文案。键名与 Tauri `cloudSyncE2ee.errors.*` 对齐;Core 里 + // Linux 侧独有的代码(conflict / denied / expired / locked / server / + // unsupported / service_unavailable…)在这里补齐,避免把裸 key 画到界面上。 + Msg { + key: "cloudSyncE2ee.errors.accountChanged", + text: row( + "GitHub 账号已改变,请刷新后再继续。", + "GitHub 帳號已變更,請重新整理後再繼續。", + "The GitHub account changed. Refresh before continuing.", + "GitHub アカウントが変わりました。更新してから続けてください。", + "GitHub 계정이 변경되었습니다. 새로고침한 후 계속하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.busy", + text: row( + "已有同步任务在运行,请等待或先取消。", + "已有同步工作在執行,請等待或先取消。", + "Another sync task is running. Wait or cancel it first.", + "別の同期処理が実行中です。待つか先にキャンセルしてください。", + "다른 동기화 작업이 실행 중입니다. 기다리거나 먼저 취소하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.cancelled", + text: row( + "任务已取消。", + "工作已取消。", + "The task was cancelled.", + "処理をキャンセルしました。", + "작업이 취소되었습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.changed", + text: row( + "审阅期间资料已改变。请关闭弹窗、刷新状态,并重新获取预览。", + "檢閱期間資料已變更。請關閉對話框、重新整理狀態,並重新取得預覽。", + "Data changed during review. Close this dialog, refresh, and review a new preview.", + "確認中にデータが変わりました。閉じて状態を更新し、再度プレビューを取得してください。", + "검토 중 데이터가 변경되었습니다. 창을 닫고 새로고침한 후 미리보기를 다시 확인하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.choicesRequired", + text: row( + "请为每项冲突选择本机或云端版本。", + "請為每項衝突選擇本機或雲端版本。", + "Choose local or cloud for every conflict.", + "すべての競合でローカルかクラウドを選択してください。", + "각 충돌에 대해 로컬 또는 클라우드를 선택하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.invalidData", + text: row( + "加密备份未通过校验,本机资料未被替换。", + "加密備份未通過驗證,本機資料未被取代。", + "The encrypted backup could not be verified. Local data was not replaced.", + "暗号化バックアップを検証できませんでした。ローカルデータは置き換えていません。", + "암호화 백업을 검증하지 못했습니다. 로컬 데이터는 교체되지 않았습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.invalidPassword", + text: row( + "密码无法解锁这份备份,或加密资料校验未通过。", + "密碼無法解鎖這份備份,或加密資料驗證未通過。", + "The password could not unlock this backup, or the encrypted data could not be verified.", + "このパスワードで解除できないか、暗号化データを検証できませんでした。", + "비밀번호로 백업을 잠금 해제할 수 없거나 암호화 데이터를 검증하지 못했습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.localData", + text: row( + "这台设备还没准备好本机资料。点「刷新状态」,然后再试一次。", + "這台裝置還沒準備好本機資料。點「重新整理狀態」,然後再試一次。", + "This device could not prepare its data. Choose Refresh status, then try again.", + "このデバイスのデータを準備できませんでした。「状態を更新」を押して、もう一度試してください。", + "이 기기의 데이터를 준비하지 못했습니다. 상태 새로고침을 누른 뒤 다시 시도하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.network", + text: row( + "暂时无法连接同步服务,请检查网络后重试。", + "暫時無法連線同步服務,請檢查網路後重試。", + "Could not reach the sync service. Check your connection and try again.", + "同期サービスに接続できません。ネットワークを確認して再試行してください。", + "동기화 서비스에 연결할 수 없습니다. 네트워크를 확인한 후 다시 시도하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.outcomeUnknown", + text: row( + "云端结果尚未确认。请先核对待确认结果,不要重复创建备份。", + "雲端結果尚未確認。請先核對待確認結果,不要重複建立備份。", + "The cloud result is not confirmed. Check the pending result before creating another backup.", + "クラウドの結果が未確定です。新しいバックアップを作る前に未確定の結果を確認してください。", + "클라우드 결과가 확인되지 않았습니다. 새 백업을 만들기 전에 대기 중인 결과를 확인하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.passwordMismatch", + text: row( + "两次输入的密码不一致。", + "兩次輸入的密碼不一致。", + "The two passwords do not match.", + "パスワードが一致しません。", + "두 비밀번호가 일치하지 않습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.rateLimited", + text: row( + "请求过于频繁,请稍后重试。", + "請求過於頻繁,請稍後重試。", + "Too many requests. Wait before retrying.", + "リクエストが多すぎます。しばらく待ってから再試行してください。", + "요청이 너무 많습니다. 잠시 후 다시 시도하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.recovery", + text: row( + "本机同步状态需要恢复。请保留本机资料,不要覆盖云端备份。", + "本機同步狀態需要復原。請保留本機資料,不要覆寫雲端備份。", + "Local recovery is needed. Keep this device’s data and do not overwrite the cloud backup.", + "ローカル状態の復旧が必要です。この端末のデータを保持し、クラウドを上書きしないでください。", + "로컬 상태 복구가 필요합니다. 이 기기의 데이터를 보관하고 클라우드 백업을 덮어쓰지 마세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.reviewRequired", + text: row( + "请先确认完整同步范围,并审阅云端恢复预览。", + "請先確認完整同步範圍,並檢閱雲端還原預覽。", + "Review the full sync scope and the cloud restore preview before continuing.", + "続ける前に同期対象と復元プレビューを確認してください。", + "계속하기 전에 동기화 범위와 복원 미리보기를 확인하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.rolledBack", + text: row( + "恢复失败,本机更改已回滚。请刷新状态后重试。", + "還原失敗,本機變更已回復。請重新整理狀態後重試。", + "Restore failed and local changes were rolled back. Refresh before trying again.", + "復元に失敗し、ローカルの変更を元に戻しました。更新してから再試行してください。", + "복원에 실패해 로컬 변경을 되돌렸습니다. 새로고침한 후 다시 시도하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.secureStorage", + text: row( + "安全存储拒绝访问。请检查设备授权,或取消记住密钥后解锁。", + "安全儲存空間拒絕存取。請檢查裝置授權,或取消記住金鑰後解鎖。", + "Secure storage denied access. Check device permissions, or unlock without remembering the key.", + "安全なストレージへのアクセスが拒否されました。権限を確認するか、キーを記憶せずに解除してください。", + "보안 저장소 접근이 거부되었습니다. 권한을 확인하거나 키를 기억하지 않고 잠금 해제하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.signIn", + text: row( + "请重新登录 GitHub。", + "請重新登入 GitHub。", + "Sign in to GitHub again.", + "GitHub に再ログインしてください。", + "GitHub에 다시 로그인하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.tooLarge", + text: row( + "加密备份超出服务的大小限制。", + "加密備份超出服務的大小限制。", + "The encrypted backup exceeds the service’s size limit.", + "暗号化バックアップがサービスのサイズ上限を超えています。", + "암호화 백업이 서비스 크기 제한을 초과합니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.unavailable", + text: row( + "加密同步暂不可用,请使用原生客户端并稍后检查服务。", + "加密同步暫不可用,請使用原生用戶端並稍後檢查服務。", + "Encrypted sync is unavailable here. Use the native app and check the service later.", + "暗号化同期は利用できません。ネイティブアプリで後ほどサービスを確認してください。", + "암호화 동기화를 사용할 수 없습니다. 네이티브 앱에서 나중에 서비스를 확인하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.unknown", + text: row( + "操作未能完成,请刷新状态后重试。", + "操作未能完成,請重新整理狀態後重試。", + "The operation could not be completed. Refresh the status and try again.", + "操作を完了できませんでした。状態を更新して再試行してください。", + "작업을 완료하지 못했습니다. 상태를 새로고침한 후 다시 시도하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.unlock", + text: row( + "请先使用同步密码解锁此设备。", + "請先使用同步密碼解鎖此裝置。", + "Unlock this device with your sync password first.", + "同期パスワードでこの端末を解除してください。", + "먼저 동기화 비밀번호로 이 기기를 잠금 해제하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.weakPassword", + text: row( + "请使用满足长度和字符要求、且不常见的强密码。", + "請使用符合長度和字元要求、且不常見的強密碼。", + "Choose a stronger password meeting the length and character requirements.", + "長さと文字種の条件を満たす、推測されにくいパスワードにしてください。", + "길이와 문자 조건을 충족하는 더 강력한 비밀번호를 사용하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.conflict", + text: row( + "本地与云端的数据版本冲突,请刷新状态后再试。", + "本機與雲端的資料版本衝突,請重新整理狀態後再試。", + "Local and cloud data versions conflict — refresh the status and retry.", + "ローカルとクラウドのデータ版が競合しています。状態を更新してから再試行してください。", + "로컬과 클라우드 데이터 버전이 충돌합니다. 상태를 새로 고친 뒤 다시 시도하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.denied", + text: row( + "服务拒绝了这次请求(权限或访问受限)。", + "服務拒絕了這次請求(權限或存取受限)。", + "The service rejected the request (permission or access restriction).", + "サービスが要求を拒否しました(権限またはアクセス制限)。", + "서비스가 요청을 거부했습니다(권한 또는 접근 제한).", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.expired", + text: row( + "这次操作已过期,请重新发起。", + "這次操作已過期,請重新發起。", + "This operation expired — start it again.", + "この操作は期限切れです。もう一度実行してください。", + "이 작업이 만료되었습니다. 다시 시작하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.locked", + text: row( + "同步密钥处于锁定状态,请先解锁。", + "同步金鑰處於鎖定狀態,請先解鎖。", + "The sync key is locked — unlock it first.", + "同期キーがロックされています。先にロック解除してください。", + "동기화 키가 잠겨 있습니다. 먼저 잠금을 해제하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.server", + text: row( + "同步服务返回了错误,请稍后重试。", + "同步服務回傳錯誤,請稍後重試。", + "The sync service returned an error — retry later.", + "同期サービスがエラーを返しました。後で再試行してください。", + "동기화 서비스가 오류를 반환했습니다. 나중에 다시 시도하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.unsupported", + text: row( + "服务端协议不受支持,请更新 OpenLess 或改用自建服务器。", + "伺服器端協定不受支援,請更新 OpenLess 或改用自建伺服器。", + "The server protocol is unsupported — update OpenLess or use your own server.", + "サーバー側プロトコルが未対応です。OpenLess を更新するか自前サーバーを使用してください。", + "서버 프로토콜이 지원되지 않습니다. OpenLess를 업데이트하거나 자체 서버를 사용하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.invalidResponse", + text: row( + "服务返回的内容无法解析,请稍后重试。", + "服務回傳的內容無法解析,請稍後重試。", + "The service returned an unreadable response — retry later.", + "サービスの応答を解析できませんでした。後で再試行してください。", + "서비스 응답을 해석할 수 없습니다. 나중에 다시 시도하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.cloudDeleted", + text: row( + "云端备份已被删除;如需继续请重新启用同步。", + "雲端備份已被刪除;如需繼續請重新啟用同步。", + "The cloud backup was deleted — re-enable sync to continue.", + "クラウドバックアップは削除されました。続けるには同期を再度有効にしてください。", + "클라우드 백업이 삭제되었습니다. 계속하려면 동기화를 다시 활성화하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.consentRequired", + text: row( + "请先确认完整同步范围再继续。", + "請先確認完整同步範圍再繼續。", + "Confirm the full sync scope before continuing.", + "続行する前に同期範囲全体を確認してください。", + "계속하기 전에 전체 동기화 범위를 확인하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.cryptoWorkerFailed", + text: row( + "加密运算失败,请重试;若持续出现请导出日志。", + "加密運算失敗,請重試;若持續出現請匯出日誌。", + "Encryption failed — retry; export the logs if it keeps happening.", + "暗号処理に失敗しました。再試行し、続く場合はログを書き出してください。", + "암호화 연산이 실패했습니다. 다시 시도하고 계속되면 로그를 내보내세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.deleteConfirmationRequired", + text: row( + "删除云端备份需要再次确认。", + "刪除雲端備份需要再次確認。", + "Deleting the cloud backup needs another confirmation.", + "クラウドバックアップの削除には再確認が必要です。", + "클라우드 백업 삭제에는 다시 확인이 필요합니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.metadataRequired", + text: row( + "缺少同步元数据,请刷新状态后重试。", + "缺少同步中繼資料,請重新整理狀態後重試。", + "Sync metadata is missing — refresh the status and retry.", + "同期メタデータがありません。状態を更新して再試行してください。", + "동기화 메타데이터가 없습니다. 상태를 새로 고친 뒤 다시 시도하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.secureRandomUnavailable", + text: row( + "系统安全随机源不可用,无法生成加密密钥。", + "系統安全隨機源不可用,無法產生加密金鑰。", + "The system secure random source is unavailable; cannot derive keys.", + "安全な乱数源が利用できず、鍵を生成できません。", + "시스템 보안 난수원을 사용할 수 없어 키를 만들 수 없습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.staleTask", + text: row( + "该同步任务已失效,请刷新状态。", + "該同步任務已失效,請重新整理狀態。", + "That sync task is no longer valid — refresh the status.", + "その同期タスクは無効です。状態を更新してください。", + "해당 동기화 작업이 더 이상 유효하지 않습니다. 상태를 새로 고치세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.errors.serviceUnavailable", + text: row( + "加密同步服务暂不可用,请稍后重试。", + "加密同步服務暫時無法使用,請稍後重試。", + "Encrypted sync is temporarily unavailable — retry later.", + "暗号化同期は一時的に利用できません。後で再試行してください。", + "암호화 동기화를 일시적으로 사용할 수 없습니다. 나중에 다시 시도하세요.", + ), + }, + + // 加密同步卡片的步骤标题 / 状态 / 按钮文案。这些键在 `cloud_sync_ui.rs` 里 + // 是静态字符串常量,之前也整批缺失,界面上直接画出了 `cloudSyncE2ee.states.disabled`。 + Msg { + key: "cloudSyncE2ee.checkPending", + text: row( + "核对待确认结果", + "核對待確認結果", + "Check pending result", + "未確定の結果を確認", + "확인 대기 결과 조회", + ), + }, + Msg { + key: "cloudSyncE2ee.conflictCloud", + text: row( + "使用云端", + "使用雲端", + "Use cloud", + "クラウドを使用", + "클라우드 사용", + ), + }, + Msg { + key: "cloudSyncE2ee.conflictLocal", + text: row( + "保留本机", + "保留本機", + "Keep local", + "ローカルを使用", + "로컬 유지", + ), + }, + Msg { + key: "cloudSyncE2ee.createTitle", + text: row( + "设置同步密码", + "設定同步密碼", + "Create a sync password", + "同期パスワードを設定", + "동기화 비밀번호 설정", + ), + }, + Msg { + key: "cloudSyncE2ee.protocolEncryption", + text: row( + "配置、服务 API 密钥和历史文本会先在本机加密,发往云端和保存在云端的备份内容均为密文。云同步服务无法据此读取或调用您的服务 API 密钥,也不会使用这些内容执行模型请求。", + "設定、服務 API 金鑰及歷史文字會先在本機加密,傳送與儲存在雲端的備份內容均為密文。雲端同步服務無法據此讀取或使用您的 API 金鑰,也不會使用這些內容執行模型請求。", + "Settings, service API keys and text history are encrypted locally. Backup content is sent and stored as ciphertext. The sync service cannot read or use your API keys from it, and does not use this content to run model requests.", + "設定、API キー、テキスト履歴はこの端末で暗号化されます。バックアップは暗号文として送信・保存されます。同期サービスはその内容から API キーを読み取ったり使用したりせず、モデルへのリクエストにも利用しません。", + "설정, API 키, 텍스트 기록은 이 기기에서 암호화됩니다. 백업은 암호문으로 전송되고 저장됩니다. 동기화 서비스는 이를 통해 API 키를 읽거나 사용하지 못하며 모델 요청에도 이용하지 않습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.protocolEncryptionTitle", + text: row( + "先在本机加密,再上传", + "先在本機加密,再上傳", + "Encrypt on this device before uploading", + "端末で暗号化してから送信", + "기기에서 암호화한 후 업로드", + ), + }, + Msg { + key: "cloudSyncE2ee.protocolExcluded", + text: row( + "同步密码、解密密钥、OpenLess 管理的 GitHub/OAuth 登录状态、访问令牌及设备私钥不进入备份。账号认证会单独处理必要凭据;服务器仍可见账号标识、密文大小、版本及同步时间。", + "同步密碼、解密金鑰、OpenLess 管理的 GitHub/OAuth 登入狀態、存取權杖及裝置私鑰不進入備份。帳號驗證會另外處理必要憑證;伺服器仍可見帳號識別碼、密文大小、版本及同步時間。", + "The backup excludes your sync password, decryption keys, OpenLess-managed GitHub/OAuth sign-in state, access tokens and device private keys. Account authentication handles necessary credentials separately. The server can still see account identifiers, ciphertext size, versions and sync times.", + "同期パスワード、復号キー、OpenLess が管理する GitHub/OAuth ログイン状態、アクセストークン、端末の秘密鍵はバックアップに含まれません。アカウント認証には必要な認証情報を別途使用します。サーバーにはアカウント識別子、暗号文のサイズ、版、同期日時が見えます。", + "동기화 비밀번호, 복호화 키, OpenLess가 관리하는 GitHub/OAuth 로그인 상태, 액세스 토큰, 기기 개인 키는 백업에 포함되지 않습니다. 계정 인증에는 필요한 자격 증명을 별도로 사용합니다. 서버는 계정 식별자, 암호문 크기, 버전 및 동기화 시간을 볼 수 있습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.protocolExcludedTitle", + text: row( + "登录状态与可见信息", + "登入狀態與可見資訊", + "Sign-in state and visible metadata", + "ログイン状態と確認可能な情報", + "로그인 상태와 확인 가능한 정보", + ), + }, + Msg { + key: "cloudSyncE2ee.protocolIntro", + text: row( + "OpenLess 及个人开发者尊重您的隐私,并致力于保护您的资料。请阅读以下说明,再决定是否继续。", + "OpenLess 及個人開發者尊重您的隱私,並致力於保護您的資料。請閱讀以下說明,再決定是否繼續。", + "OpenLess and its independent developers respect your privacy and work to protect your data. Read this notice before continuing.", + "OpenLess と開発者はプライバシーを尊重し、データの保護に取り組んでいます。続行する前に、以下をご確認ください。", + "OpenLess와 독립 개발자는 개인정보를 존중하고 데이터를 보호하기 위해 노력합니다. 계속하기 전에 아래 안내를 읽어 주세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.protocolPassword", + text: row( + "同步密码和解密密钥仅用于本机加密与解锁,不会发送给云同步服务。若密码遗失且没有仍可解锁的设备,我们无法找回加密内容。", + "同步密碼及解密金鑰僅用於本機加密與解鎖,不會傳送給雲端同步服務。若密碼遺失且沒有仍可解鎖的裝置,我們無法找回加密內容。", + "Your sync password and decryption key are used locally and are never sent to the sync service. If you lose the password and have no device that can still unlock the backup, we cannot recover its contents.", + "同期パスワードと復号キーは端末内で使用され、同期サービスには送信されません。パスワードを紛失し、解除できる端末もない場合、内容を復元することはできません。", + "동기화 비밀번호와 복호화 키는 기기에서만 사용되며 동기화 서비스로 전송되지 않습니다. 비밀번호를 잃고 백업을 잠금 해제할 기기도 없다면 내용을 복구할 수 없습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.protocolPasswordTitle", + text: row( + "请妥善保存同步密码", + "請妥善保存同步密碼", + "Keep your sync password safe", + "同期パスワードを大切に保管してください", + "동기화 비밀번호를 안전하게 보관하세요", + ), + }, + Msg { + key: "cloudSyncE2ee.states.conflict", + text: row( + "请审阅冲突更改", + "請檢閱衝突變更", + "Review conflicting changes", + "競合する変更を確認してください", + "충돌하는 변경 검토 필요", + ), + }, + Msg { + key: "cloudSyncE2ee.states.disabled", + text: row( + "同步已关闭", + "同步已關閉", + "Sync is off", + "同期はオフです", + "동기화 꺼짐", + ), + }, + Msg { + key: "cloudSyncE2ee.states.failed", + text: row( + "同步需要处理", + "同步需要處理", + "Sync needs attention", + "同期の確認が必要です", + "동기화 확인 필요", + ), + }, + Msg { + key: "cloudSyncE2ee.states.outcome_unknown", + text: row( + "云端结果待确认", + "雲端結果待確認", + "Waiting to confirm the cloud result", + "クラウドの結果は未確定です", + "클라우드 결과 확인 대기 중", + ), + }, + Msg { + key: "cloudSyncE2ee.states.pending", + text: row( + "本机更改待同步", + "本機變更待同步", + "Local changes are waiting to sync", + "ローカルの変更は同期待ちです", + "로컬 변경 동기화 대기 중", + ), + }, + Msg { + key: "cloudSyncE2ee.states.ready", + text: row( + "已同步", + "已同步", + "Up to date", + "同期済み", + "최신 상태", + ), + }, + Msg { + key: "cloudSyncE2ee.states.recovery_required", + text: row( + "需要恢复本机同步状态", + "需要復原本機同步狀態", + "Local recovery is required", + "ローカル状態の復旧が必要です", + "로컬 상태 복구 필요", + ), + }, + Msg { + key: "cloudSyncE2ee.states.sign_in_required", + text: row( + "需要登录 GitHub", + "需要登入 GitHub", + "GitHub sign-in required", + "GitHub ログインが必要です", + "GitHub 로그인 필요", + ), + }, + Msg { + key: "cloudSyncE2ee.states.syncing", + text: row( + "正在同步", + "正在同步", + "Sync in progress", + "同期中", + "동기화 중", + ), + }, + Msg { + key: "cloudSyncE2ee.states.unlock_required", + text: row( + "请输入密码解锁", + "請輸入密碼解鎖", + "Enter your password to unlock", + "パスワードを入力して解除してください", + "비밀번호를 입력해 잠금을 해제하세요", + ), + }, + Msg { + key: "cloudSyncE2ee.stepEnableDetail", + text: row( + "打开右侧开关。确认协议之后,下一步是设置同步密码。", + "打開右側開關。確認協議之後,下一步是設定同步密碼。", + "Use the switch. After you confirm the notice, the next step is a sync password.", + "スイッチを使います。確認のあと、次は同期パスワードの設定です。", + "스위치를 사용하세요. 확인 후 다음 단계는 동기화 비밀번호입니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.stepEnableTitle", + text: row( + "第 1 步,共 3 步:开启同步", + "第 1 步,共 3 步:開啟同步", + "Step 1 of 3: Turn sync on", + "手順 1/3:同期をオンにする", + "1/3단계: 동기화 켜기", + ), + }, + Msg { + key: "cloudSyncE2ee.stepFollowDetail", + text: row( + "按这一行旁边的按钮继续。", + "按這一列旁邊的按鈕繼續。", + "Use the button on this row to continue.", + "この行のボタンで続けてください。", + "이 줄의 버튼으로 계속하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.stepUnlockDetail", + text: row( + "还差一步。输入同步密码,解锁之后才能同步。", + "還差一步。輸入同步密碼,解鎖之後才能同步。", + "One step left. Enter the sync password to unlock this device.", + "あと 1 ステップです。同期パスワードを入力して、このデバイスのロックを解除します。", + "한 단계 남았습니다. 동기화 비밀번호로 이 기기의 잠금을 해제하세요.", + ), + }, + Msg { + key: "cloudSyncE2ee.stepUnlockTitle", + text: row( + "第 2 步,共 3 步:解锁这台设备", + "第 2 步,共 3 步:解鎖這台裝置", + "Step 2 of 3: Unlock this device", + "手順 2/3:このデバイスのロックを解除", + "2/3단계: 이 기기 잠금 해제", + ), + }, + Msg { + key: "cloudSyncE2ee.syncNow", + text: row( + "立即同步", + "立即同步", + "Sync now", + "今すぐ同期", + "지금 동기화", + ), + }, + Msg { + key: "cloudSyncE2ee.unlockTitle", + text: row( + "解锁云端备份", + "解鎖雲端備份", + "Unlock your cloud backup", + "クラウドバックアップを解除", + "클라우드 백업 잠금 해제", + ), + }, + Msg { + key: "cloudSyncE2ee.stepRestoreTitle", + text: row( + "第 3 步,共 3 步:恢复云端备份", + "第 3 步,共 3 步:還原雲端備份", + "Step 3 of 3: restore the cloud backup", + "ステップ 3/3:クラウドバックアップを復元", + "3/3단계: 클라우드 백업 복원", + ), + }, + Msg { + key: "cloudSyncE2ee.stepRestoreDetail", + text: row( + "选择一份云端备份并先预览;确认后才会替换本机资料。", + "選擇一份雲端備份並先預覽;確認後才會取代本機資料。", + "Pick a cloud backup and preview it first; local data is only replaced after you confirm.", + "クラウドバックアップを選んでプレビューし、確認後にのみローカルを置き換えます。", + "클라우드 백업을 선택해 먼저 미리 본 뒤 확인하면 로컬 데이터가 교체됩니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.noticeDone", + text: row( + "已完成。", + "已完成。", + "Done.", + "完了しました。", + "완료되었습니다.", + ), + }, + Msg { + key: "cloudSyncE2ee.noticeRestored", + text: row( + "云端备份已恢复到本机。", + "雲端備份已還原到本機。", + "The cloud backup was restored to this device.", + "クラウドバックアップをこの端末に復元しました。", + "클라우드 백업을 이 기기로 복원했습니다.", + ), + }, + + // 提供方设置里以字面量引用的两个键;egui 目录此前缺失,界面上会画成裸键。 + Msg { + key: "settings.providers.fillDefault", + text: row( + "填入默认值", + "填入預設值", + "Fill default value", + "デフォルト値を入力", + "기본값 입력", + ), + }, + Msg { + key: "settings.providers.llmProtocolHeaderConflict", + text: row( + "Messages 已自动设置鉴权和版本请求头,请移除额外 Headers 中的 x-api-key 和 anthropic-version。", + "Messages 已自動設定驗證與版本標頭,請移除額外 Headers 中的 x-api-key 與 anthropic-version。", + "Messages sets authentication and version headers automatically. Remove x-api-key and anthropic-version from extra headers.", + "Messages の認証とバージョンヘッダーは自動設定されます。追加ヘッダーから x-api-key と anthropic-version を削除してください。", + "Messages 인증 및 버전 헤더는 자동 설정됩니다. 추가 헤더에서 x-api-key와 anthropic-version을 제거하세요.", + ), + }, + Msg { + key: "settings.providers.showSecret", + text: row("显示", "顯示", "Show", "表示", "표시"), + }, + Msg { + key: "settings.providers.hideSecret", + text: row("隐藏", "隱藏", "Hide", "隠す", "숨기기"), + }, + ]; fn lang_index(lang: Lang) -> usize { diff --git a/openless-all/app/linux-egui/src/lib.rs b/openless-all/app/linux-egui/src/lib.rs index c0bac3b03..4924cf687 100644 --- a/openless-all/app/linux-egui/src/lib.rs +++ b/openless-all/app/linux-egui/src/lib.rs @@ -10,20 +10,26 @@ mod audio_mute; mod audio_player; mod backend; mod capabilities; +pub mod cloud_sync_ui; + mod coding_agent; mod credentials; mod desktop; mod dictation_feedback; mod fcitx5; +pub mod frame_stats; mod host_actions; mod hotkeys; mod i18n; +pub mod linux_access; pub mod local_hotkeys; mod logging; +pub mod omni_settings; mod popup; mod popup_layer; mod popup_window; mod preference_patch; +pub mod provider_protocol; mod qa; mod recordings; mod remote_input; @@ -33,7 +39,14 @@ mod selection; mod settings; mod single_instance; mod tray; +pub mod typography; mod ui_state; +pub mod wgpu_device; + +// Shared by the eframe windows and the native Wayland layer-shell renderer so +// every Vulkan surface uses the exact same WGPU callback resource type. +#[path = "ui/frontend/siri_wgpu.rs"] +pub mod siri_wgpu; pub use audio::LinuxCpalRecorder; pub use audio_cue::{play_cue_start, play_cue_stop, CueTone}; @@ -49,7 +62,7 @@ pub use desktop::{ 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, + phase_shows_capsule, vocab_card_allowed, CapsuleOutcome, CAPSULE_AUTO_HIDE_DELAY_MS, }; #[cfg(target_os = "linux")] pub use fcitx5::prepare_fcitx5; @@ -84,24 +97,24 @@ pub use local_hotkeys::{ pub use logging::{export_error_log, init_file_logger, log_path}; pub use popup::{ force_x11_for, popup_command, read_jsonl, run_popup, write_jsonl, - ApplyOutcome as PopupApplyOutcome, CapsulePopupState, HostToPopup, LessComputerApproval, - LessComputerEntry, LessComputerPopupState, PopupActionGuard, PopupChatMessage, PopupKind, - PopupSendError, PopupState, PopupSupervisor, PopupSupervisorEvent, PopupToHost, - ProtocolError as PopupProtocolError, ProtocolErrorKind as PopupProtocolErrorKind, + ApplyOutcome as PopupApplyOutcome, CapsulePopupState, CapsuleSuggestion, 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 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, + wayland_display_available, 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, + bottom_center, clamp_to_area, monitor_containing, place_overlay, place_panel, 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, }; @@ -122,7 +135,8 @@ pub use single_instance::{ }; pub use tray::{LinuxTray, TrayCommand, TrayError, TrayMicrophone}; pub use ui_state::{ - load_locale_pref, load_quick_note_shortcut_hidden, save_locale_pref, + learn_capsule_warmup_ms, load_capsule_warmup_ms, load_locale_pref, + load_quick_note_shortcut_hidden, save_capsule_warmup_ms, save_locale_pref, save_quick_note_shortcut_hidden, ui_state_dir, ui_state_path, UiStateError, }; diff --git a/openless-all/app/linux-egui/src/linux_access.rs b/openless-all/app/linux-egui/src/linux_access.rs new file mode 100644 index 000000000..ac989bfdc --- /dev/null +++ b/openless-all/app/linux-egui/src/linux_access.rs @@ -0,0 +1,260 @@ +//! Read-only desktop session diagnostics. Never read secrets, unlock a wallet, +//! create a collection, or capture audio/text as part of a settings refresh. +use serde::{Deserialize, Serialize}; +use std::time::{Duration, Instant}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CheckState { + #[default] + Unknown, + Ready, + Unavailable, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum KeyringState { + #[default] + Unknown, + Ready, + Locked, + MissingService, + MissingCollection, + /// A session-only default cannot safely hold persistent channel credentials. + TemporaryVault, + Unavailable, + RestartRequired, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct LinuxAccessSnapshot { + pub checking: bool, + pub keyring: KeyringState, + pub input: CheckState, + pub network: CheckState, + pub network_latency_ms: Option, +} + +/// Same bounded GET probe as Tauri's `check_network`. Any HTTP response proves +/// reachability to this OpenLess endpoint, NOT ASR/LLM authentication or health. +pub async fn probe_network() -> (CheckState, Option) { + let start = Instant::now(); + let url = format!("{}/packs?limit=1", openless_core::MARKETPLACE_BASE_URL); + match openless_core::net::http() + .get(url) + .timeout(Duration::from_secs(8)) + .send() + .await + { + Ok(_) => (CheckState::Ready, Some(start.elapsed().as_millis() as u64)), + Err(_) => (CheckState::Unavailable, None), + } +} + +#[cfg(target_os = "linux")] +pub fn probe_session() -> (KeyringState, CheckState) { + use dbus::blocking::stdintf::org_freedesktop_dbus::Properties; + use dbus::blocking::Connection; + + let Ok(connection) = Connection::new_session() else { + return (KeyringState::Unavailable, CheckState::Unavailable); + }; + let bus = connection.with_proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + Duration::from_secs(1), + ); + // Resolve unique owners so a disappeared service isn't auto-started by a + // later properties/introspection query, and we don't mix two daemon owners. + let owner = |name: &str| -> Result { + let (owner,): (String,) = + bus.method_call("org.freedesktop.DBus", "GetNameOwner", (name,))?; + Ok(owner) + }; + let keyring = match owner("org.freedesktop.secrets") { + Err(error) if error.name() == Some("org.freedesktop.DBus.Error.NameHasNoOwner") => { + KeyringState::MissingService + } + Err(_) => KeyringState::Unavailable, + Ok(owner) => { + let service = connection.with_proxy( + owner.as_str(), + "/org/freedesktop/secrets", + Duration::from_secs(1), + ); + let alias: Result<(dbus::Path<'static>,), _> = + service.method_call("org.freedesktop.Secret.Service", "ReadAlias", ("default",)); + match alias { + Err(_) => KeyringState::Unavailable, + Ok((path,)) if path == "/" => KeyringState::MissingCollection, + Ok((path,)) if temporary_collection(&path.to_string()) => { + KeyringState::TemporaryVault + } + Ok((path,)) => { + let collection = + connection.with_proxy(owner.as_str(), path, Duration::from_secs(1)); + let locked: Result = + collection.get("org.freedesktop.Secret.Collection", "Locked"); + match locked { + Err(_) => KeyringState::Unavailable, + Ok(true) => KeyringState::Locked, + // v1 caches initialization errors for the process lifetime. + // This checks only the adapter's session initialization; + // no Entry, secret read/write or Unlock call is involved. + Ok(false) if keyring::Entry::store_status().is_err() => { + KeyringState::RestartRequired + } + Ok(false) => KeyringState::Ready, + } + } + } + } + }; + let input = owner(crate::fcitx5::DESTINATION) + .ok() + .and_then(|owner| { + let addon = + connection.with_proxy(owner, crate::fcitx5::OBJECT_PATH, Duration::from_secs(1)); + let (xml,): (String,) = addon + .method_call("org.freedesktop.DBus.Introspectable", "Introspect", ()) + .ok()?; + Some(input_interface_ready(&xml)) + }) + .unwrap_or(false); + ( + keyring, + if input { + CheckState::Ready + } else { + CheckState::Unavailable + }, + ) +} + +fn temporary_collection(path: &str) -> bool { + path == "/org/freedesktop/secrets/collection/session" +} + +/// Refuse to read/write persistent channel configuration through an ephemeral +/// default. Never unlock, create a collection, or choose a different daemon. +#[cfg(target_os = "linux")] +pub(crate) fn ensure_persistent_vault() -> Result<(), openless_core::BackendError> { + use dbus::blocking::stdintf::org_freedesktop_dbus::Properties; + use dbus::blocking::Connection; + let inspect = || -> Result { + let connection = Connection::new_session()?; + let bus = connection.with_proxy( + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + Duration::from_secs(1), + ); + let (owner,): (String,) = bus.method_call( + "org.freedesktop.DBus", + "GetNameOwner", + ("org.freedesktop.secrets",), + )?; + let service = connection.with_proxy( + owner.as_str(), + "/org/freedesktop/secrets", + Duration::from_secs(1), + ); + let (path,): (dbus::Path<'static>,) = + service.method_call("org.freedesktop.Secret.Service", "ReadAlias", ("default",))?; + if path == "/" { + return Ok(KeyringState::MissingCollection); + } + if temporary_collection(&path.to_string()) { + return Ok(KeyringState::TemporaryVault); + } + let collection = connection.with_proxy(owner, path, Duration::from_secs(1)); + let locked: bool = collection.get("org.freedesktop.Secret.Collection", "Locked")?; + Ok(if locked { + KeyringState::Locked + } else { + KeyringState::Ready + }) + }; + match inspect() { + Ok(KeyringState::Ready) => Ok(()), + Ok(KeyringState::TemporaryVault) => Err(openless_core::BackendError::new(openless_core::BackendErrorCode::Persistence, + "The Secret Service default vault is temporary (session-only). Existing credentials may be in another wallet. Select the original persistent Secret Service vault and restart OpenLess; credentials have not been deleted or migrated.")), + Ok(_) | Err(_) => Err(openless_core::BackendError::new(openless_core::BackendErrorCode::Persistence, + "The session Secret Service default vault could not be inspected. Restore the original persistent vault and restart OpenLess; no credentials were changed.")), + } +} + +#[cfg(not(target_os = "linux"))] +pub fn probe_session() -> (KeyringState, CheckState) { + (KeyringState::Unavailable, CheckState::Unavailable) +} + +#[cfg(target_os = "linux")] +fn input_interface_ready(xml: &str) -> bool { + // fcitx5 的 introspection XML 带 DOCTYPE(D-Bus 标准格式),roxmltree 默认 + // 拒绝 DTD,解析失败会让这里永远返回 false——「权限与数据」页就会把正常的 + // 插件报成「不可用」。必须显式允许 DTD。 + let Ok(document) = roxmltree::Document::parse_with_options( + xml, + roxmltree::ParsingOptions { + allow_dtd: true, + ..Default::default() + }, + ) else { + return false; + }; + document.descendants().any(|node| { + node.has_tag_name("interface") + && node.attribute("name") == Some(crate::fcitx5::INTERFACE) + && ["CommitText", "GetSelectionText"].iter().all(|name| { + node.children().any(|method| { + method.has_tag_name("method") && method.attribute("name") == Some(*name) + }) + }) + }) +} + +#[cfg(all(test, target_os = "linux"))] +mod tests { + use super::*; + + #[test] + fn temporary_defaults_are_never_reported_as_persistent_vaults() { + assert!(temporary_collection( + "/org/freedesktop/secrets/collection/session" + )); + assert!(!temporary_collection( + "/org/freedesktop/secrets/collection/kdewallet" + )); + assert!(!temporary_collection( + "/org/freedesktop/secrets/collection/login" + )); + } + + #[test] + fn a_dbus_peer_or_wrong_addon_is_not_a_text_service() { + assert!(!input_interface_ready( + "" + )); + assert!(!input_interface_ready("")); + assert!(!input_interface_ready("invalid XML")); + assert!(input_interface_ready("")); + } + + /// fcitx5 的 introspection 回复自带 DOCTYPE。解析器默认拒绝 DTD,一旦这里 + /// 静默失败,「权限与数据」页就会把正常工作的插件报成「不可用」。 + #[test] + fn the_standard_dbus_doctype_does_not_break_the_probe() { + let xml = r#" + + + + + + +"#; + assert!( + input_interface_ready(xml), + "a DOCTYPE-prefixed reply from fcitx5 must still be understood" + ); + } +} diff --git a/openless-all/app/linux-egui/src/linux_app/history.rs b/openless-all/app/linux-egui/src/linux_app/history.rs index 7facbd5de..33b5b6022 100644 --- a/openless-all/app/linux-egui/src/linux_app/history.rs +++ b/openless-all/app/linux-egui/src/linux_app/history.rs @@ -14,6 +14,10 @@ pub(super) struct HistoryCache { } impl HistoryCache { + pub(super) fn entry(&self, id: &str) -> Option<&openless_core::DictationSession> { + self.entries.iter().find(|entry| entry.id == id) + } + pub(super) fn new(preferences: &UserPreferences) -> Self { let mut cache = Self::default(); cache.policy_changed(preferences); @@ -140,6 +144,30 @@ fn spawn_history_read( mod tests { use super::*; + #[test] + fn second_page_actions_resolve_ids_after_cache_changes() { + let entry = |index| { + serde_json::from_value::(serde_json::json!({ + "id": format!("entry-{index}"), "createdAt": "2026-10-19T00:00:00Z", + "rawTranscript": format!("raw-{index}"), "finalText": format!("final-{index}"), + "mode": "raw", "insertStatus": "notRequested" + })) + .unwrap() + }; + let mut cache = HistoryCache { + entries: (0..250).map(entry).collect(), + ..Default::default() + }; + let id = cache.entries[220].id.clone(); + assert!(cache.entries[..200].iter().all(|entry| entry.id != id)); + assert_eq!(cache.entry(&id).unwrap().raw_transcript, "raw-220"); + cache.entries.insert(0, entry(999)); + cache.entries.remove(100); + assert_eq!(cache.entry(&id).unwrap().final_text, "final-220"); + cache.entries.retain(|entry| entry.id != id); + assert!(cache.entry(&id).is_none()); + } + #[test] fn idle_ticks_do_not_read_and_refreshes_coalesce_while_blocked() { let mut cache = HistoryCache::default(); 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 index eec5a543b..d7e83d1b4 100644 --- a/openless-all/app/linux-egui/src/linux_app/settings_save.rs +++ b/openless-all/app/linux-egui/src/linux_app/settings_save.rs @@ -2,6 +2,13 @@ use super::*; use serde_json::Value; use std::collections::BTreeMap; +/// 设置页里的外链目标,与 Tauri 的 `AboutSection` / `SettingsModal` 一字不差 +/// (`HELP_URL` / `RELEASE_NOTES_URL` 及仓库、issue 页)。 +pub(crate) const REPOSITORY_URL: &str = "https://github.com/Open-Less/openless"; +pub(crate) const HELP_URL: &str = "https://github.com/Open-Less/openless#readme"; +pub(crate) const RELEASE_NOTES_URL: &str = "https://github.com/Open-Less/openless/releases"; +pub(crate) const ISSUES_URL: &str = "https://github.com/Open-Less/openless/issues"; + type Patch = BTreeMap; #[derive(Default)] @@ -177,6 +184,20 @@ impl OpenLessEguiApp { frontend::view_model::SettingsField::LessComputer => { preferences.coding_agent_enabled = !preferences.coding_agent_enabled; } + frontend::view_model::SettingsField::SelectionVoiceEnabled => { + preferences.selection_voice_enabled = !preferences.selection_voice_enabled; + } + frontend::view_model::SettingsField::SelectionVoiceAutoIntent => { + // Tauri 的开关只有两态:当前是 Auto 就关掉(退回启发式),否则打开。 + preferences.selection_voice_intent_mode = if matches!( + preferences.selection_voice_intent_mode, + openless_core::shared_types::SelectionVoiceIntentMode::Auto + ) { + openless_core::shared_types::SelectionVoiceIntentMode::Heuristic + } else { + openless_core::shared_types::SelectionVoiceIntentMode::Auto + }; + } frontend::view_model::SettingsField::TestingFeatures => { preferences.testing_features_enabled = !preferences.testing_features_enabled; } @@ -186,6 +207,12 @@ impl OpenLessEguiApp { frontend::view_model::SettingsField::AudioCue => { preferences.audio_cue_on_record = !preferences.audio_cue_on_record; } + frontend::view_model::SettingsField::LlmThinking => { + preferences.llm_thinking_enabled = !preferences.llm_thinking_enabled; + } + frontend::view_model::SettingsField::CapsuleTranscript => { + preferences.capsule_transcript_enabled = !preferences.capsule_transcript_enabled; + } frontend::view_model::SettingsField::ShowCapsule => { preferences.show_capsule = !preferences.show_capsule; @@ -279,9 +306,29 @@ impl OpenLessEguiApp { _ => openless_core::shared_types::SelectionPolishOutputMode::DirectReplace, }; } + frontend::view_model::SettingsComboField::SelectionVoicePlanFormat => { + preferences.selection_voice_edit_plan_format = if index == 1 { + openless_core::edit_plan::EditPlanFormat::Json + } else { + openless_core::edit_plan::EditPlanFormat::Xml + }; + } + frontend::view_model::SettingsComboField::PipelineMode => { + // Tauri's selector enables the pipeline flag in both modes; + // only the selected mode is consumed, and both namespaces survive. + preferences.multimodal_pipeline_enabled = true; + preferences.pipeline_mode = if index == 1 { + openless_core::shared_types::PipelineMode::Multimodal + } else { + openless_core::shared_types::PipelineMode::Traditional + }; + } frontend::view_model::SettingsComboField::SilenceSeconds => { preferences.silence_auto_stop_seconds = index as f32 + 1.0; } + frontend::view_model::SettingsComboField::CapsuleTranscriptFontSize => { + preferences.capsule_transcript_font_size = (12 + 2 * index.min(4)) as u8; + } frontend::view_model::SettingsComboField::CapsuleStyle => { preferences.capsule_style = match index { 1 => openless_core::shared_types::CapsuleStyle::Classic, @@ -374,7 +421,6 @@ impl OpenLessEguiApp { } self.frontend_vm.shortcut_recording = None; - self.frontend_vm.shortcut_menu = None; self.save_settings_if_dirty(); } @@ -413,7 +459,40 @@ impl OpenLessEguiApp { } } - self.frontend_vm.shortcut_menu = None; + self.save_settings_if_dirty(); + } + + /// 恢复出厂默认绑定:默认值来自 `UserPreferences::default()`,与 Core 的 + /// 序列化默认保持一致(不在这里另抄一份键位)。 + pub(super) fn apply_shortcut_reset(&mut self, field: frontend::view_model::ShortcutField) { + let defaults = openless_core::shared_types::UserPreferences::default(); + let Some(preferences) = self.preferences.as_mut() else { + return; + }; + use frontend::view_model::ShortcutField; + match field { + ShortcutField::Dictation => preferences.dictation_hotkey = defaults.dictation_hotkey, + ShortcutField::Translation => { + preferences.translation_hotkey = defaults.translation_hotkey + } + ShortcutField::Qa => preferences.qa_hotkey = defaults.qa_hotkey, + ShortcutField::QuickNote => preferences.quick_note_hotkey = defaults.quick_note_hotkey, + ShortcutField::SwitchStyle => { + preferences.switch_style_hotkey = defaults.switch_style_hotkey + } + ShortcutField::OpenApp => preferences.open_app_hotkey = defaults.open_app_hotkey, + ShortcutField::CodingAgentVoice => { + preferences.coding_agent_voice_hotkey = defaults.coding_agent_voice_hotkey + } + ShortcutField::SelectionPolish => { + preferences.selection_polish_hotkey = defaults.selection_polish_hotkey + } + // 风格直达键与草稿行没有出厂默认。 + ShortcutField::StylePack(_) | ShortcutField::StyleDraft => { + return; + } + } + self.frontend_vm.shortcut_recording = None; self.save_settings_if_dirty(); } @@ -433,7 +512,6 @@ impl OpenLessEguiApp { .retain(|entry| entry.pack_id != pack_id); } - self.frontend_vm.shortcut_menu = None; self.save_settings_if_dirty(); } @@ -548,6 +626,22 @@ impl OpenLessEguiApp { self.frontend_vm.settings.coding_agent_exe = text; } + frontend::view_model::SettingsTextField::SelectionVoiceKeywords => { + // Tauri:一行一条,空行丢弃。 + preferences.selection_voice_edit_keywords = text + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect(); + + self.frontend_vm.settings.selection_voice_keywords = text; + } + frontend::view_model::SettingsTextField::SelectionVoiceSystemPrompt => { + preferences.selection_voice_edit_system_prompt = text.clone(); + + self.frontend_vm.settings.selection_voice_system_prompt = text; + } frontend::view_model::SettingsTextField::HistoryMaxEntries => { preferences.history_max_entries = text .trim() @@ -567,6 +661,12 @@ impl OpenLessEguiApp { field: frontend::view_model::SettingsActionField, ) { match field { + frontend::view_model::SettingsActionField::RefreshLinuxAccess => { + if !self.frontend_vm.linux_access.checking { + self.refresh_linux_access(); + self.load_microphones(); + } + } frontend::view_model::SettingsActionField::RetrySave => { self.settings_save.error = None; self.save_settings_if_dirty(); @@ -642,21 +742,28 @@ impl OpenLessEguiApp { } } } + frontend::view_model::SettingsActionField::ResetSelectionVoicePrompt => { + // 留空 = 按「自定义 → 风格包 → 内置默认」回退。 + if let Some(preferences) = self.preferences.as_mut() { + preferences.selection_voice_edit_system_prompt = String::new(); + } + self.frontend_vm.settings.selection_voice_system_prompt = String::new(); + } 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"); + let _ = open_external(REPOSITORY_URL); } frontend::view_model::SettingsActionField::OpenHelp => { - let _ = open_external("https://github.com/earendil-works/openless"); + let _ = open_external(HELP_URL); } frontend::view_model::SettingsActionField::OpenReleaseNotes => { - let _ = open_external("https://github.com/earendil-works/openless/releases"); + let _ = open_external(RELEASE_NOTES_URL); } frontend::view_model::SettingsActionField::OpenFeedback => { - let _ = open_external("https://github.com/earendil-works/openless/issues"); + let _ = open_external(ISSUES_URL); } frontend::view_model::SettingsActionField::CopyQQ => { match fcitx5_copy_to_clipboard("1078960553") { @@ -814,4 +921,16 @@ mod tests { ); assert!(edits.contains_key("/hotkey/mode")); } + + /// 外链必须指向与 Tauri 相同的仓库/页面(回归:曾经指向 earendil-works)。 + #[test] + fn settings_links_match_the_tauri_targets() { + assert_eq!(REPOSITORY_URL, "https://github.com/Open-Less/openless"); + assert_eq!(HELP_URL, "https://github.com/Open-Less/openless#readme"); + assert_eq!( + RELEASE_NOTES_URL, + "https://github.com/Open-Less/openless/releases" + ); + assert_eq!(ISSUES_URL, "https://github.com/Open-Less/openless/issues"); + } } diff --git a/openless-all/app/linux-egui/src/linux_app/window.rs b/openless-all/app/linux-egui/src/linux_app/window.rs index 8f83f4792..34dd03e56 100644 --- a/openless-all/app/linux-egui/src/linux_app/window.rs +++ b/openless-all/app/linux-egui/src/linux_app/window.rs @@ -1,4 +1,5 @@ use super::*; +use std::collections::HashSet; pub(super) struct WindowState { pub(super) should_be_open: bool, @@ -47,6 +48,34 @@ pub(super) const MAIN_MSAA_SAMPLES: u16 = 4; /// 旧的 Linux 专用窗口配置不作为依据。 pub(super) const MAIN_WINDOW_INNER_SIZE: [f32; 2] = [1300.0, 835.0]; +/// 快照的软上限:到这个体积就先裁历史,仍留出余量给桥层的硬上限([`crate::ui::bridge`] 的帧上限)。 +/// 视图模型里唯一会随使用无限膨胀的就是历史列表,所以超限时只裁它。 +pub(super) const SNAPSHOT_SOFT_LIMIT: usize = 12 * 1024 * 1024; + +/// 软上限的实际取值。`OPENLESS_UI_SNAPSHOT_LIMIT=<字节>` 可以压低它,用来在设备上 +/// 演练「历史过大」的降级路径(否则要攒到 12 MiB 历史才能看到横幅)。 +fn snapshot_soft_limit() -> usize { + std::env::var("OPENLESS_UI_SNAPSHOT_LIMIT") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(SNAPSHOT_SOFT_LIMIT) +} + +/// 第一页的降档阶梯:正常情况下快照只带每个集合的第一页(很小), +/// 只有病态数据(单条超大的转写、巨大的图标 data URL)才会走到这里。 +/// 一档不够就再降一档,直到载荷落回软上限;`*_total` 不动,条目仍可被加载回来。 +pub(super) const FIRST_PAGE_LADDER: [usize; 5] = [200, 100, 40, 10, 0]; + +/// 把某个集合的第一页缩到 `keep` 条。返回是否真的裁了。 +fn trim_page(list: &mut Vec, keep: usize) -> bool { + if list.len() <= keep { + return false; + } + list.truncate(keep); + true +} + /// 视图模型载荷指纹(FNV-1a 64)。够快,用来判断「要不要重发快照」: /// 内容没变就不发,UI 慢的时候也不会被无意义的帧糊住。 pub(super) fn snapshot_fingerprint(payload: &[u8]) -> u64 { @@ -206,12 +235,13 @@ impl OpenLessEguiApp { 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 Some(payload) = encode_view_model( + &mut self.frontend_vm, + snapshot_soft_limit(), + &FIRST_PAGE_LADDER, + ) else { + log::warn!("[ui-host] view model serialization failed"); + return; }; let fingerprint = snapshot_fingerprint(&payload); let keepalive = self.window.last_snapshot_at.elapsed() >= Duration::from_secs(2); @@ -224,6 +254,48 @@ impl OpenLessEguiApp { } } +/// 序列化视图模型,必要时把五个可增长集合的第一页一起降档,让快照回到 +/// `soft_limit` 以内。 +/// +/// 裁的是宿主持有的这份 VM,下一 tick 的 [`OpenLessEguiApp::sync_view_model`] 会从 +/// 真实缓存重新填满第一页,所以裁掉的内容只是「这一帧不发」;总数仍在,窗口可用 +/// `LoadMore` 按需取回,不是永久丢弃。`ladder` 的最后一项应当能裁到空列表。 +/// +/// 返回 `None` 只表示序列化本身失败;裁完仍然超过软上限时会照常返回载荷,由调用方 +/// 交给桥层按硬上限处理(丢帧,但不断连)。 +fn encode_view_model( + view_model: &mut FrontendViewModel, + soft_limit: usize, + ladder: &[usize], +) -> Option> { + let mut payload = serde_json::to_vec(view_model).ok()?; + if payload.len() <= soft_limit { + return Some(payload); + } + for keep in ladder.iter().copied() { + let mut trimmed = trim_page(&mut view_model.history_entries, keep); + trimmed |= trim_page(&mut view_model.vocab_entries, keep); + trimmed |= trim_page(&mut view_model.vocab_rules, keep); + trimmed |= trim_page(&mut view_model.marketplace_packs, keep); + trimmed |= trim_page(&mut view_model.style_packs, keep); + if !trimmed { + continue; + } + if view_model.history_selected >= view_model.history_entries.len() { + // 选中的那条这一帧没发出去:回到最新一条,别让详情栏空着。 + view_model.history_selected = 0; + } + log::warn!( + "[ui-host] snapshot is over {soft_limit} bytes; first pages cut to {keep} entries" + ); + payload = serde_json::to_vec(view_model).ok()?; + if payload.len() <= soft_limit { + return Some(payload); + } + } + Some(payload) +} + /// UI 窗口进程入口:只渲染。 /// /// 它不构造 Core 后端、不打开数据目录、不抢单实例锁、不注册托盘与热键 —— @@ -231,6 +303,10 @@ impl OpenLessEguiApp { 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; + // 显存优先的设备描述,与原生 layer-shell 胶囊同源(见 `wgpu_device`): + // wgpu 默认的 `Performance` hints 会让每个渲染进程预占约 180 MiB。 + setup.device_descriptor = + std::sync::Arc::new(|_adapter| openless_linux_egui::wgpu_device::device_descriptor()); } options } @@ -266,7 +342,14 @@ pub(super) fn run_ui_client(socket: std::path::PathBuf) -> Result<(), String> { "OpenLess", options, Box::new(move |cc| { - theme::install(&cc.egui_ctx); + theme::install(&cc.egui_ctx, openless_linux_egui::typography::load().font); + // 字号缩放(90/100/110%)跟着外观页的偏好走:egui 的 zoom factor + // 缩放整棵 UI,等价于 Tauri 那边改根节点字号。 + cc.egui_ctx + .set_zoom_factor(openless_linux_egui::typography::load().scale.factor()); + if let Some(state) = cc.wgpu_render_state.as_ref() { + crate::ui::frontend::siri_wgpu::install_wgpu(state, MAIN_MSAA_SAMPLES.into()); + } Ok(Box::new(UiClientApp::new( client, cc.wgpu_render_state.as_ref(), @@ -329,6 +412,7 @@ pub(super) struct UiClientApp { pub(super) view_model: FrontendViewModel, /// 已采纳的最大快照序号。 last_sequence: u64, + applied_typography: Option, /// 宿主上一份快照的 JSON:用来分辨「宿主改了这个字段」和「用户还没提交的 /// 本地输入」。 last_adopted: Option, @@ -344,6 +428,8 @@ pub(super) struct UiClientApp { hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher, /// 本地热键边沿的发送序号(与动作序号分开,便于日志区分)。 hotkey_sequence: u64, + /// 自动加载时去重尚未回来的页请求,避免一帧一个重复 action。 + pending_collection_pages: HashSet<(frontend::paging::Collection, usize)>, /// 设置页模糊背板;只有拿到 wgpu 渲染状态(正常 GUI 进程)时存在。 backdrop: Option, } @@ -353,6 +439,14 @@ impl UiClientApp { client: UiBridgeClient, render_state: Option<&eframe::egui_wgpu::RenderState>, ) -> Self { + let mut view_model = FrontendViewModel { + typography: openless_linux_egui::typography::load(), + ..Default::default() + }; + // QA:OPENLESS_FPS_SETTINGS=1 时开机就打开设置面板,直接测最重的渲染路径 + // (磨砂背板 + 离屏 4×MSAA 重绘 + 动画预览)。见 `frame_stats` 模块文档。 + // 真正的置位在每帧采纳快照之后(见 `ui()`):宿主快照会把这里覆盖掉。 + view_model.settings_open |= openless_linux_egui::frame_stats::settings_open_on_start(); Self { client, connecting_since: std::time::Instant::now(), @@ -361,8 +455,9 @@ impl UiClientApp { backdrop: render_state.map(|state| { crate::ui::backdrop::BackdropBlur::new(state, MAIN_MSAA_SAMPLES.into()) }), - view_model: FrontendViewModel::default(), + view_model, last_sequence: 0, + applied_typography: None, last_adopted: None, action_sequence: 0, ping_sequence: 0, @@ -373,6 +468,21 @@ impl UiClientApp { hotkeys: None, hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher::default(), hotkey_sequence: 0, + pending_collection_pages: HashSet::new(), + } + } + + /// 把单槽位里最新的快照搬进视图模型。 + /// + /// 正常情况下一帧只有一份;`while` 是为了把极端情况下「取的过程中又来了 + /// 一份」也收干净(旧的那份会被 `snapshot_supersedes` 丢掉)。 + fn adopt_latest_snapshot(&mut self) { + while let Some((sequence, view_model)) = self.client.take_snapshot() { + if snapshot_supersedes(self.last_sequence, sequence) { + self.adopt_snapshot(sequence, *view_model); + } else { + log::debug!("[ui-client] dropped stale snapshot #{sequence}"); + } } } @@ -394,9 +504,31 @@ impl UiClientApp { self.last_adopted = incoming_json; } + fn apply_typography(&mut self, ctx: &egui::Context) { + let typography = self.view_model.typography; + if self + .applied_typography + .is_none_or(|previous| previous.font != typography.font) + { + theme::install(ctx, typography.font); + } + if self + .applied_typography + .is_none_or(|previous| previous.scale != typography.scale) + { + ctx.set_zoom_factor(typography.scale.factor()); + } + self.applied_typography = Some(typography); + } + /// 收宿主的帧。快照按序号采纳;`Shutdown` 与断连都表示「宿主走了」, /// 此时 UI 必须自己退出(没有宿主就没有数据可渲染)。 + /// + /// 快照不在通道里(见 `UiBridgeClient` 的快照单槽位),所以本函数只在 + /// egui pass 真正跑的时候被调用 —— 窗口被遮挡时宿主发多少保活快照都不会 + /// 堆内存。 pub(super) fn drain_host(&mut self, ctx: &egui::Context) { + self.adopt_latest_snapshot(); loop { match self.client.try_recv() { Ok(HostToWindow::Ready { version }) => { @@ -415,14 +547,33 @@ impl UiClientApp { log::info!("[ui-client] local hotkey bindings received"); self.hotkeys = Some(*bindings); } + Ok(HostToWindow::CollectionPage { + collection, + offset, + total, + items, + .. + }) => { + self.pending_collection_pages.remove(&(collection, offset)); + if self + .view_model + .apply_page(collection, offset, total, *items) + { + log::debug!( + "[ui-client] {} page at {offset} applied ({} loaded)", + collection.tag(), + collection.loaded(&self.view_model) + ); + } + } + // 快照正常不进通道(见 `UiBridgeClient` 的快照单槽位);这里兜底: + // 万一哪条路径真把它推进来了,也不能丢状态。 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 }) => { @@ -525,6 +676,13 @@ impl UiClientApp { ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(!maximized)); } other => { + if let frontend::view_model::FrontendAction::LoadMore { collection, offset } = + &other + { + if !self.pending_collection_pages.insert((*collection, *offset)) { + continue; + } + } self.action_sequence += 1; if let Err(error) = self.client.send(WindowToHost::Action { sequence: self.action_sequence, @@ -560,10 +718,17 @@ impl eframe::App for UiClientApp { } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + openless_linux_egui::frame_stats::tick("ui-client"); let ctx = ui.ctx().clone(); if self.connection_error.is_none() { self.drain_host(&ctx); } + self.apply_typography(&ctx); + // QA 开关要在**采纳快照之后**再压一次:宿主每帧/每次保活都会重发 view model, + // 只在初始化时置位会被随后到达的快照覆盖掉(这个开关以前就是这么失效的)。 + if openless_linux_egui::frame_stats::settings_open_on_start() { + self.view_model.settings_open = true; + } 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 @@ -653,59 +818,197 @@ impl eframe::App for UiClientApp { } } -struct StartupErrorApp { - error: String, - lang: Lang, - broker: Arc, -} +#[cfg(test)] +mod tests { + use super::*; + use crate::ui::frontend::view_model::{FrontendViewModel, HistoryEntry, VocabEntry}; + + /// 一条体量接近真实长转写的假历史(列表最新在前,id 递增 = 越来越旧)。 + fn fake_entry(index: usize) -> HistoryEntry { + HistoryEntry { + id: format!("entry-{index}"), + raw_transcript: "transcript ".repeat(300), + final_text: "polished draft ".repeat(300), + ..Default::default() + } + } -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); + fn view_model_with(entries: usize) -> FrontendViewModel { + FrontendViewModel { + history_entries: (0..entries).map(fake_entry).collect(), + ..Default::default() + } + } + + #[test] + fn a_snapshot_that_fits_is_sent_whole() { + let mut view_model = view_model_with(4); + let payload = encode_view_model(&mut view_model, usize::MAX, &FIRST_PAGE_LADDER).unwrap(); + assert_eq!( + view_model.history_entries.len(), + 4, + "nothing may be dropped" + ); + assert!(!payload.is_empty()); + } + + #[test] + fn an_oversized_snapshot_cuts_the_first_pages_but_keeps_the_totals() { + // 软上限只装得下 8 条左右:第一档 25 太大,第二档 5 刚好。 + let empty = serde_json::to_vec(&FrontendViewModel::default()) + .unwrap() + .len(); + let one_entry = serde_json::to_vec(&fake_entry(0)).unwrap().len(); + let soft_limit = empty + one_entry * 8; + let mut view_model = view_model_with(30); + view_model.history_list_total = 30; + view_model.history_selected = 29; + + let payload = encode_view_model(&mut view_model, soft_limit, &[25, 5, 0]).unwrap(); + + assert!( + payload.len() <= soft_limit, + "a cut snapshot must fit the soft limit" + ); + assert_eq!( + view_model.history_entries.len(), + 5, + "the first ladder step that fits wins" + ); + // 总数不动:切掉的条目仍然能通过「加载更多」拉回来。 + assert_eq!(view_model.history_list_total, 30); + // 列表最新在前:切掉的是尾部(最旧),最新的 5 条必须留下。 + assert_eq!(view_model.history_entries[0].id, "entry-0"); + assert_eq!(view_model.history_entries[4].id, "entry-4"); + // 选中的那条这一帧没发出去:回到最新一条。 + assert_eq!(view_model.history_selected, 0); + } + + #[test] + fn every_collection_shares_the_same_ladder_step() { + // 病态数据可能出在任何一个集合上,降档必须一起切。 + let mut view_model = view_model_with(30); + view_model.vocab_entries = (0..30) + .map(|index| VocabEntry { + phrase: "x".repeat(2_000), + hits: index, + enabled: true, + learned: false, + }) + .collect(); + view_model.vocab_total = 30; + let empty = serde_json::to_vec(&FrontendViewModel::default()) + .unwrap() + .len(); + // 只装得下 5 条左右:第一档 25 还是太大,第二档 5 刚好 —— 两个集合 + // 必须落在同一个档位上,不能一个切到 5、另一个还留着 25。 + let soft_limit = empty + 80_000; + + let payload = encode_view_model(&mut view_model, soft_limit, &[25, 5, 0]).unwrap(); + + assert!(payload.len() <= soft_limit); + assert_eq!(view_model.history_entries.len(), 5); + assert_eq!(view_model.vocab_entries.len(), 5); + // 总数不动:切掉的条目仍能通过「加载更多」拉回来。 + assert_eq!(view_model.history_list_total, 0); + assert_eq!(view_model.vocab_total, 30); + } + + #[test] + fn an_exactly_full_first_page_continues_past_the_unchanged_first_step() { + let mut view_model = view_model_with(200); + for entry in &mut view_model.history_entries { + entry.final_text = "x".repeat(100 * 1024); } - 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); + view_model.history_list_total = 1_000; + assert!(serde_json::to_vec(&view_model).unwrap().len() >= 20 * 1024 * 1024); + + let payload = + encode_view_model(&mut view_model, SNAPSHOT_SOFT_LIMIT, &FIRST_PAGE_LADDER).unwrap(); + assert!(payload.len() <= SNAPSHOT_SOFT_LIMIT); + assert_eq!(view_model.history_entries.len(), 100); + let received: FrontendViewModel = serde_json::from_slice(&payload).unwrap(); + assert_eq!(received.history_entries.len(), 100); + assert_eq!(received.history_list_total, 1_000); + assert_eq!(received.history_entries[0].id, "entry-0"); + assert_eq!(received.history_entries[99].id, "entry-99"); + } + + #[test] + fn a_short_first_page_skips_unchanged_steps_until_it_fits() { + let mut view_model = view_model_with(20); + for entry in &mut view_model.history_entries { + entry.final_text = "x".repeat(1024 * 1024); } - ui.ctx().request_repaint_after(Duration::from_millis(100)); + view_model.history_list_total = 700; + + let payload = + encode_view_model(&mut view_model, SNAPSHOT_SOFT_LIMIT, &FIRST_PAGE_LADDER).unwrap(); + assert!(payload.len() <= SNAPSHOT_SOFT_LIMIT); + assert_eq!(view_model.history_entries.len(), 10); + let received: FrontendViewModel = serde_json::from_slice(&payload).unwrap(); + assert_eq!(received.history_entries.len(), 10); + assert_eq!(received.history_list_total, 700); } -} -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; + #[test] + fn one_huge_entry_reaches_the_empty_page_step_without_losing_totals() { + let mut view_model = view_model_with(1); + view_model.history_entries[0].final_text = "x".repeat(20 * 1024 * 1024); + view_model.history_list_total = 700; + view_model.vocab_total = 800; + view_model.correction_rule_total = 900; + view_model.marketplace_total = 1_000; + view_model.style_pack_total = 1_100; + + let payload = + encode_view_model(&mut view_model, SNAPSHOT_SOFT_LIMIT, &FIRST_PAGE_LADDER).unwrap(); + assert!(payload.len() <= SNAPSHOT_SOFT_LIMIT); + let received: FrontendViewModel = serde_json::from_slice(&payload).unwrap(); + assert!(received.history_entries.is_empty()); + assert_eq!(received.history_list_total, 700); + assert_eq!(received.vocab_total, 800); + assert_eq!(received.correction_rule_total, 900); + assert_eq!(received.marketplace_total, 1_000); + assert_eq!(received.style_pack_total, 1_100); } - 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}"); + + #[test] + fn adopted_typography_updates_the_real_ui_context_even_with_a_save_error() { + use openless_linux_egui::typography::{FontScale, Typography}; + + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ui.sock"); + let mut host = UiBridgeHost::bind(path.clone()).unwrap(); + let client = UiBridgeClient::connect(&path).unwrap(); + host.accept_pending(); + let mut app = UiClientApp::new(client, None); + // 只验证缩放更新,不让系统字体探测影响并行的默认 Context 测试。 + app.applied_typography = Some(Typography::default()); + let ctx = egui::Context::default(); + + for (sequence, scale) in [(1, FontScale::Large), (2, FontScale::Small)] { + let typography = Typography { + scale, + ..Default::default() + }; + app.adopt_snapshot( + sequence, + FrontendViewModel { + typography, + settings_save_error: Some("Local save failed".into()), + ..Default::default() + }, + ); + app.apply_typography(&ctx); + ctx.run_ui(egui::RawInput::default(), |_| {}) + .drop_without_applying_deltas(); + assert_eq!(ctx.zoom_factor(), scale.factor()); + assert_eq!(app.applied_typography, Some(typography)); + assert_eq!( + app.view_model.settings_save_error.as_deref(), + Some("Local save failed") + ); + } } } diff --git a/openless-all/app/linux-egui/src/local_hotkeys.rs b/openless-all/app/linux-egui/src/local_hotkeys.rs index 6a89dd5f6..47b91229b 100644 --- a/openless-all/app/linux-egui/src/local_hotkeys.rs +++ b/openless-all/app/linux-egui/src/local_hotkeys.rs @@ -1,7 +1,7 @@ //! 我们自己的窗口有焦点时,fcitx5 插件收不到按键 —— 本地兜底匹配的**判定层**。 //! //! 插件把热键匹配挂在 fcitx5 的 `EventType::InputContextKeyEvent` 上 -//! (`scripts/linux-fcitx5-plugin/openless.cpp`),而 fcitx5 只有在聚焦的客户端 +//! (`app/scripts/linux-fcitx5-plugin/openless.cpp`),而 fcitx5 只有在聚焦的客户端 //! 注册了 text-input 时才会收到那个客户端的按键。我们的 winit/egui 窗口从不注册 //! (`ime_allowed` 默认 false → 不会调用 `zwp_text_input_v3.enable()`),于是 //! **主窗口、选区助手、Less Computer 面板有焦点时插件一个信号都不会发**:用户 @@ -296,19 +296,26 @@ impl HotkeyDeduplicator { /// 会被丢弃,这里的判定方向相反:插件信号刚处理过,本地边沿不再重复触发。 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 + let accepted = stamps .signal - .is_some_and(|signal| at.saturating_duration_since(signal) < HOTKEY_DEDUPE_WINDOW) + .is_none_or(|signal| at.saturating_duration_since(signal) >= HOTKEY_DEDUPE_WINDOW); + // A rejected echo must not suppress the originating source's key-up. + if accepted { + stamps.local = Some(at); + } + accepted } /// 采纳一条插件信号?本地刚命中过(同一窗口还是 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 + let accepted = stamps .local - .is_some_and(|local| at.saturating_duration_since(local) < HOTKEY_DEDUPE_WINDOW) + .is_none_or(|local| at.saturating_duration_since(local) >= HOTKEY_DEDUPE_WINDOW); + if accepted { + stamps.signal = Some(at); + } + accepted } } diff --git a/openless-all/app/linux-egui/src/main.rs b/openless-all/app/linux-egui/src/main.rs index 6379202e5..bb6022bc0 100644 --- a/openless-all/app/linux-egui/src/main.rs +++ b/openless-all/app/linux-egui/src/main.rs @@ -48,11 +48,19 @@ mod linux_app { enum UiResult { Message(String), + /// 选区助手动作的异步结果:`generation` 是发起时面板进程的代次, + /// 换过窗口后的迟到结果不得回写到新面板。 QaAction { generation: u64, context: Option, result: Result, }, + HotkeyError(BackendError), + ProviderSaved { + editor: Box, + result: Result<(), String>, + }, + ProviderSecretsCleared(Result<(), String>), HistoryLoaded { generation: u64, revision: u64, @@ -67,14 +75,41 @@ mod linux_app { session_id: String, }, Remote(Result<(openless_core::RemoteInputStatus, String), String>), - Providers(Result), + Providers { + kind: openless_core::ChannelKind, + request: u64, + result: Result, + }, /// Credential channels for the settings modal's AI-services tab. - SettingsChannels(Result, String>), + SettingsChannels { + kind: openless_core::ChannelKind, + request: u64, + result: Result, String>, + }, ChannelCreated { kind: openless_core::ChannelKind, result: Result, }, + ChannelsChanged { + kind: openless_core::ChannelKind, + result: Result, + }, + ProviderTypeChanged { + kind: openless_core::ChannelKind, + channel_id: String, + result: Box>, + }, ServiceConfigured([bool; 2]), + LinuxAccess(openless_linux_egui::linux_access::LinuxAccessSnapshot), + OmniFinished { + operation: openless_linux_egui::omni_settings::OmniAction, + submitted: Box, + result: Box>, + }, + /// 云同步卡片的一次往返:成功带回最新状态,失败带回稳定错误码。 + CloudSyncDone { + result: Box>, + }, ProviderEditor { kind: openless_core::ChannelKind, channel_id: String, @@ -83,6 +118,9 @@ mod linux_app { ProviderModels { kind: openless_core::ChannelKind, channel_id: String, + endpoint: String, + provider_type: String, + request_format: openless_core::llm_protocol::LlmRequestFormat, result: Result, String>, }, Library(Result), @@ -140,6 +178,8 @@ mod linux_app { last_ok: Option, last_latency_ms: Option, last_error: Option, + /// 最近一次验证的时间(epoch **秒**,与 Tauri `lastTest.at` 同一量纲)。 + last_test_at: Option, } /// 追问编辑态:Core 只在变化时下发 `Some(..)`,所以逐字段合并。 @@ -380,6 +420,7 @@ mod linux_app { endpoint: String, model: String, volcengine_service: String, + protocol: openless_linux_egui::provider_protocol::ProviderProtocolDraft, auth_mode: String, resource_id: String, // Secret inputs are intentionally write-only. Loading an editor never @@ -402,6 +443,8 @@ mod linux_app { /// clobbers what the user is typing. struct ProviderEditorForm { channel_id: String, + /// 渠道种类(LLM / ASR):弹窗副标题「文字处理渠道 / 语音识别渠道」用它。 + kind: openless_core::ChannelKind, provider_type: String, label: String, auth: frontend::view_model::SettingsProviderAuth, @@ -410,24 +453,26 @@ mod linux_app { 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. + // Write-only drafts start empty on load; clear only after Core's save receipt. primary_secret: String, secondary_secret: String, models: Vec, models_loading: bool, + catalog_format: Option, /// Core descriptor 的高阶字段:模型块的「预设 / 默认 / 文档页」全部由它决定。 static_models: Vec, default_model: String, models_url: Option, /// 「自定义模型…」手输模式(仅 UI 状态,Tauri `customModelMode`)。 custom_model: bool, + protocol: openless_linux_egui::provider_protocol::ProviderProtocolDraft, } impl ProviderEditorForm { fn from_editor(editor: &ProviderEditor, lang: Lang) -> Self { Self { channel_id: editor.channel.id.clone(), + kind: editor.kind, provider_type: editor.descriptor.provider_type.as_str().to_string(), label: localized_provider_label( lang, @@ -448,10 +493,12 @@ mod linux_app { secondary_secret: String::new(), models: Vec::new(), models_loading: false, + catalog_format: None, 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, + protocol: editor.protocol.clone(), } } } @@ -475,6 +522,14 @@ mod linux_app { } } + /// 选区助手面板当前呈现的协议状态:代次在每次重开窗口时递增, + /// `session_id` 记录实际上屏的会话(`qa` 空态也计入)。 + #[derive(Default)] + struct QaPopupPresentation { + generation: u64, + session_id: Option, + } + #[derive(Default, Clone, Copy)] struct PopupRestartBudget { attempts: usize, @@ -501,12 +556,6 @@ mod linux_app { } } - #[derive(Default)] - struct QaPopupPresentation { - generation: u64, - session_id: Option, - } - pub struct OpenLessEguiApp { tokio: Arc, native: Option, @@ -517,6 +566,9 @@ mod linux_app { settings_channel_kind: openless_core::ChannelKind, settings_channels: Vec, settings_channels_loading: bool, + settings_channels_request: u64, + settings_channels_refresh_requested: bool, + linux_access_last_check: Option, /// 语言模型 / 语音识别是否各自有启用的渠道(AI 服务页的状态点)。 service_configured: [bool; 2], /// 文本型设置行(端口/条数/路径…)只在偏好刚载入或外部变更时回灌, @@ -557,10 +609,27 @@ mod linux_app { remote_access: Option<(openless_core::RemoteInputStatus, String)>, provider_kind: openless_core::ChannelKind, providers: ProvidersState, + providers_request: u64, selected_channel_id: Option, + /// 用户(或刚建好的草稿)要求打开渠道编辑器。面板加载时只有当它为真才打开: + /// 以前只要面板加载就会为「当前选中的渠道」把编辑器拉起来,改成整栏覆盖后就是 + /// 「一进设置只见模型编辑器、别的设置都改不了」。 + editor_requested: bool, provider_editor: ProviderEditorState, + /// 当前编辑器是「添加渠道」草稿(Tauri `isDraft`):标题换成「添加渠道」, + /// 且完全没交互就关闭时要把这张空渠道回收掉。 + editor_draft: bool, + /// 草稿是否被用户动过(任一字段/保存/供应商切换)。 + draft_touched: bool, /// Draft mirrored into the view model while the editor is open. provider_editor_form: Option, + provider_jobs: tokio::sync::mpsc::UnboundedSender, + provider_save_pending: bool, + provider_switch_pending: bool, + channel_create_pending: bool, + provider_save_again: bool, + provider_close_pending: bool, + provider_after_save: Option, provider_models: Vec, new_provider_type: String, pending_channel_delete: Option, @@ -575,11 +644,18 @@ mod linux_app { vocab_presets: Vec, qa_popup: Option, capsule_popup: Option, + capsule_popup_generation: u64, popup_action_guard: PopupActionGuard, /// 当前胶囊展示的会话 id(用于「会话消失但没收到终态」的兜底收起)。 capsule_session: Option, /// 已经为哪个会话排过收起计时,避免重复计时。 capsule_dismissal_scheduled: Option, + /// 「要记住这个词吗?」卡片当前内容(Core `pending_corrections`)。非空时 + /// 胶囊窗只画这张卡;见 `refresh_vocab_suggestions`。 + vocab_suggestions: Vec, + vocab_card_session: Option, + /// 卡片上一轮的重算时刻,用来让过期的候选自己消失(Core 只按需清理)。 + vocab_suggestions_checked: std::time::Instant, tray: Option, exit_requested: bool, /// 上次打「泵心跳」日志的时间。 @@ -594,8 +670,10 @@ mod linux_app { pending_local_hotkeys: Vec<(std::time::Instant, openless_linux_egui::LocalHotkeyEdge)>, /// 本地边沿与插件信号之间的去重(同一个物理按键可能两个来源都报)。 hotkey_dedupe: openless_linux_egui::HotkeyDeduplicator, + hotkey_jobs: tokio::sync::mpsc::UnboundedSender, /// 已在 60s 内重开过的面板次数(索引见 `popup_kind_index`)。 popup_restarts: [PopupRestartBudget; POPUP_KIND_COUNT], + /// 宿主自己下发的弹窗序号(每个面板一条),与 Core 事件序号无关。 popup_sequences: [u64; POPUP_KIND_COUNT], qa_popup_presentation: QaPopupPresentation, /// 最近一次下发给窗口/面板的热键配置;变了才重发。 @@ -604,6 +682,9 @@ mod linux_app { quick_note_shortcut_hidden: bool, /// 本帧从 UI 收到、待回包的延迟探针序号。 pending_ui_pongs: Vec, + /// `LoadMore` 触发的分页回包:动作处理阶段拿不到桥,先排队, + /// 由宿主循环在 `apply_frontend_actions` 之后发出(同 `pending_ui_pongs`)。 + pending_collection_pages: Vec, /// Currently playing history recording (session id + player handle). history: HistoryCache, history_clip: Option<(String, openless_linux_egui::ClipPlayer)>, @@ -631,6 +712,8 @@ mod linux_app { style_editor: Option, status: String, startup_error: Option, + /// Non-fatal startup/runtime warning shown in the UI overlay. + runtime_warning: Option, locale_pref: LocalePref, lang: Lang, active_page: shell::Page, @@ -665,6 +748,9 @@ mod linux_app { || vm.style_examples != saved.examples } + type HotkeyJob = std::pin::Pin> + Send>>; + type ProviderJob = std::pin::Pin + Send>>; + impl OpenLessEguiApp { fn new( tokio: Arc, @@ -675,6 +761,47 @@ mod linux_app { let (tx, rx) = mpsc::channel(); let locale_pref = load_locale_pref(); let lang = locale_pref.resolve(); + // 外观页的排版偏好:宿主这份视图模型要在首帧就带上已存的选择, + // 否则组合框会先画默认值再跳一次。 + let typography = openless_linux_egui::typography::load(); + let (provider_jobs, mut provider_rx) = + tokio::sync::mpsc::unbounded_channel::(); + tokio.spawn(async move { + while let Some(job) = provider_rx.recv().await { + job.await; + } + }); + let (hotkey_jobs, mut hotkey_rx) = tokio::sync::mpsc::unbounded_channel::(); + let hotkey_results = tx.clone(); + let host_thread = std::thread::current(); + tokio.spawn(async move { + // Admit edges FIFO: the first poll registers each Core gate + // waiter before the next edge. Do not serialize completion: + // Combined must bypass an in-flight start, and model/network + // finalization must not block opening QA or cancelling input. + while let Some(mut job) = hotkey_rx.recv().await { + let ready = std::future::poll_fn(|cx| { + std::task::Poll::Ready(match job.as_mut().poll(cx) { + std::task::Poll::Ready(result) => Some(result), + std::task::Poll::Pending => None, + }) + }) + .await; + let results = hotkey_results.clone(); + let wake = host_thread.clone(); + tokio::spawn(async move { + let result = match ready { + Some(result) => result, + None => job.await, + }; + let _ = results.send(match result { + Ok(message) => UiResult::Message(message), + Err(error) => UiResult::HotkeyError(error), + }); + wake.unpark(); + }); + } + }); // 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(); @@ -691,7 +818,7 @@ mod linux_app { let preferences = backend.get_preferences(); let history = HistoryCache::new(&preferences); let subscription = backend.subscribe(); - let app = Self { + let mut app = Self { tokio, native: Some(native), subscription: Some(subscription), @@ -701,6 +828,9 @@ mod linux_app { settings_channel_kind: openless_core::ChannelKind::Llm, settings_channels: Vec::new(), settings_channels_loading: false, + settings_channels_request: 0, + settings_channels_refresh_requested: false, + linux_access_last_check: None, service_configured: [false; 2], hydrate_text_fields: true, overview: OverviewState::Loading, @@ -729,9 +859,20 @@ mod linux_app { remote_access: None, provider_kind: openless_core::ChannelKind::Asr, providers: ProvidersState::Loading, + providers_request: 0, selected_channel_id: None, + editor_requested: false, provider_editor: ProviderEditorState::Idle, + editor_draft: false, + draft_touched: false, provider_editor_form: None, + provider_jobs, + provider_save_pending: false, + provider_switch_pending: false, + channel_create_pending: false, + provider_save_again: false, + provider_close_pending: false, + provider_after_save: None, provider_models: Vec::new(), new_provider_type: String::new(), pending_channel_delete: None, @@ -743,9 +884,13 @@ mod linux_app { vocab_presets: Vec::new(), qa_popup: None, capsule_popup: None, + capsule_popup_generation: 0, popup_action_guard: PopupActionGuard::default(), capsule_session: None, capsule_dismissal_scheduled: None, + vocab_suggestions: Vec::new(), + vocab_card_session: None, + vocab_suggestions_checked: std::time::Instant::now(), tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), @@ -753,12 +898,14 @@ mod linux_app { pending_ui_actions: Vec::new(), pending_local_hotkeys: Vec::new(), hotkey_dedupe: openless_linux_egui::HotkeyDeduplicator::default(), + hotkey_jobs, quick_note_shortcut_hidden, popup_restarts: [PopupRestartBudget::default(); POPUP_KIND_COUNT], popup_sequences: [0; POPUP_KIND_COUNT], qa_popup_presentation: QaPopupPresentation::default(), hotkeys_sent: None, pending_ui_pongs: Vec::new(), + pending_collection_pages: Vec::new(), history, history_clip: None, marketplace_items: Vec::new(), @@ -775,6 +922,7 @@ mod linux_app { style_editor: None, status: tr_l10n(lang, "status.core_started").to_string(), startup_error: None, + runtime_warning: None, locale_pref, lang, active_page: shell::Page::Overview, @@ -800,6 +948,9 @@ mod linux_app { settings_channel_kind: openless_core::ChannelKind::Llm, settings_channels: Vec::new(), settings_channels_loading: false, + settings_channels_request: 0, + settings_channels_refresh_requested: false, + linux_access_last_check: None, service_configured: [false; 2], overview: OverviewState::Loading, microphones: Vec::new(), @@ -827,9 +978,20 @@ mod linux_app { remote_access: None, provider_kind: openless_core::ChannelKind::Asr, providers: ProvidersState::Loading, + providers_request: 0, selected_channel_id: None, + editor_requested: false, provider_editor: ProviderEditorState::Idle, + editor_draft: false, + draft_touched: false, provider_editor_form: None, + provider_jobs, + provider_save_pending: false, + provider_switch_pending: false, + channel_create_pending: false, + provider_save_again: false, + provider_close_pending: false, + provider_after_save: None, provider_models: Vec::new(), new_provider_type: String::new(), pending_channel_delete: None, @@ -841,9 +1003,13 @@ mod linux_app { vocab_presets: Vec::new(), qa_popup: None, capsule_popup: None, + capsule_popup_generation: 0, popup_action_guard: PopupActionGuard::default(), capsule_session: None, capsule_dismissal_scheduled: None, + vocab_suggestions: Vec::new(), + vocab_card_session: None, + vocab_suggestions_checked: std::time::Instant::now(), tray, exit_requested: false, last_pump_heartbeat: std::time::Instant::now(), @@ -851,12 +1017,14 @@ mod linux_app { pending_ui_actions: Vec::new(), pending_local_hotkeys: Vec::new(), hotkey_dedupe: openless_linux_egui::HotkeyDeduplicator::default(), + hotkey_jobs, quick_note_shortcut_hidden, popup_restarts: [PopupRestartBudget::default(); POPUP_KIND_COUNT], popup_sequences: [0; POPUP_KIND_COUNT], qa_popup_presentation: QaPopupPresentation::default(), hotkeys_sent: None, pending_ui_pongs: Vec::new(), + pending_collection_pages: Vec::new(), history: HistoryCache::default(), history_clip: None, marketplace_items: Vec::new(), @@ -873,10 +1041,14 @@ mod linux_app { style_editor: None, status: tr_l10n(lang, "status.startup_failed").to_string(), startup_error: Some(error), + runtime_warning: None, locale_pref, lang, active_page: shell::Page::Overview, - frontend_vm: FrontendViewModel::default(), + frontend_vm: FrontendViewModel { + typography, + ..Default::default() + }, tx, rx, }, @@ -1035,10 +1207,19 @@ mod linux_app { match std::env::current_exe() { Ok(executable) => { if kind == PopupKind::Qa { + // 新进程 = 新代次:旧进程排队的按键/错误不得作用到新窗口。 self.qa_popup_presentation.generation = self.qa_popup_presentation.generation.saturating_add(1); self.qa_popup_presentation.session_id = None; } + if kind == PopupKind::Capsule { + self.capsule_popup_generation = + self.capsule_popup_generation.saturating_add(1); + self.vocab_card_session = self + .vocab_card_session + .as_ref() + .map(|_| format!("vocab:{}", uuid::Uuid::new_v4())); + } self.popup_action_guard.reset(kind); let supervisor = PopupSupervisor::spawn(self.tokio.handle(), executable, kind); *self.popup_slot(kind) = Some(supervisor); @@ -1054,7 +1235,8 @@ mod linux_app { | HostToPopup::PolishPreview { session_id, .. } => Some(session_id.clone()), _ => None, }; - // Window actions and failures can occur without a new Core event. + // 窗口动作与失败可能在**没有新 Core 事件**时发生,只有递增序号才能让 + // 弹窗接受新的快照(同序号会被当成旧消息丢掉)。 let sequence = &mut self.popup_sequences[popup_kind_index(kind)]; *sequence = sequence.saturating_add(1); message.set_sequence(*sequence); @@ -1062,12 +1244,25 @@ mod linux_app { if let HostToPopup::Capsule { session_id, .. } = &message { self.capsule_session = Some(session_id.clone()); } - let retry = message.clone(); + let mut retry = message.clone(); if let Some(supervisor) = self.popup_slot(kind) { if let Err(error) = supervisor.try_send(message) { self.status = fmt_l10n(lang, "popup.channel_rebuild", &[&format!("{error:?}")]); *self.popup_slot(kind) = None; self.ensure_popup(kind); + if let ( + HostToPopup::Capsule { + session_id, + suggestions, + .. + }, + Some(token), + ) = (&mut retry, &self.vocab_card_session) + { + if !suggestions.is_empty() { + session_id.clone_from(token); + } + } if let Some(supervisor) = self.popup_slot(kind) { if let Err(retry_error) = supervisor.try_send(retry) { self.status = fmt_l10n( @@ -1087,6 +1282,8 @@ mod linux_app { } } + /// 收起选区助手面板:清掉宿主可见标志并**结束弹窗进程** + /// (X11/Wayland 上都得真的销毁窗口,Core 继续持有对话)。 fn close_qa_popup(&mut self) { self.qa_visible = false; self.polish_result_visible = false; @@ -1100,6 +1297,8 @@ mod linux_app { fn hide_popup(&mut self, kind: PopupKind, session_id: String, sequence: u64) { if kind == PopupKind::Qa { + // 仅当隐藏请求针对**当前上屏的会话**时才动窗口:后台 QA 的 + // 隐藏请求不能把正在显示的润色预览关掉。 if (self.qa_visible || self.polish_result_visible) && self .qa_popup_presentation @@ -1179,7 +1378,9 @@ mod linux_app { fn expected_popup_session(&self, kind: PopupKind) -> Option { match kind { + // 面板已经收起:任何排队消息都不再有合法目标。 PopupKind::Qa if !self.qa_visible && !self.polish_result_visible => None, + // 润色结果模式下,面板承载的是选区会话,而不是 QA 会话。 PopupKind::Qa if self.polish_result_visible => self .selection .as_ref() @@ -1189,11 +1390,12 @@ mod linux_app { .qa_state .as_ref() .map(|state| state.session_id.clone().unwrap_or_else(|| "qa".to_string())), - PopupKind::Capsule => self - .snapshot - .as_ref() - .and_then(|snapshot| snapshot.dictation.session_id) - .map(|session_id| session_id.to_string()), + PopupKind::Capsule => self.vocab_card_session.clone().or_else(|| { + 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()), @@ -1201,6 +1403,7 @@ mod linux_app { } fn show_qa_popup(&mut self) { + // 收起状态或润色结果模式下不得用 QA 快照把面板拉回来。 if !self.qa_visible || self.polish_result_visible { return; } @@ -1359,7 +1562,187 @@ mod linux_app { .to_string() } + /// Core 的待确认纠正变了:没有会话时把卡片顶到胶囊窗上,有会话就丢掉候选。 + /// + /// Tauri `show_vocab_suggestion_card` / `hide_vocab_suggestion_card`:卡片与 + /// 录音胶囊**共用同一个窗口**,所以「会话在飞」时必须让路,否则卡片的尺寸/位置 + /// 会把这次会话的胶囊顶掉。 + fn refresh_vocab_suggestions(&mut self) { + let Some(backend) = self.backend() else { + return; + }; + self.vocab_suggestions_checked = std::time::Instant::now(); + let pending = backend.pending_corrections(); + let phase = self + .snapshot + .as_ref() + .map(|snapshot| snapshot.dictation.phase); + if !openless_linux_egui::vocab_card_allowed(phase) { + if !pending.is_empty() { + backend.dismiss_pending_corrections(); + } + self.vocab_suggestions.clear(); + self.hide_vocab_card(); + return; + } + self.vocab_suggestions = pending + .iter() + .map(|item| openless_linux_egui::CapsuleSuggestion { + id: item.id.clone(), + pattern: item.pattern.clone(), + replacement: item.replacement.clone(), + }) + .collect(); + if self.vocab_suggestions.is_empty() { + self.hide_vocab_card(); + } else { + self.show_vocab_card(); + } + } + + /// 丢弃当前候选(新会话开始 / 胶囊开关关掉时)。 + fn dismiss_vocab_suggestions(&mut self) { + if self.vocab_suggestions.is_empty() && self.vocab_card_session.is_none() { + return; + } + if let Some(backend) = self.backend() { + backend.dismiss_pending_corrections(); + } + self.vocab_suggestions.clear(); + self.hide_vocab_card(); + } + + /// 卡片独立于听写会话;token 同时标记可见状态并隔离过期动作。 + fn show_vocab_card(&mut self) { + if !self.capsule_enabled() { + self.dismiss_vocab_suggestions(); + return; + } + self.ensure_popup(PopupKind::Capsule); + let session_id = self + .vocab_card_session + .get_or_insert_with(|| format!("vocab:{}", uuid::Uuid::new_v4())) + .clone(); + self.send_popup( + PopupKind::Capsule, + HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence: self.last_event_sequence.saturating_mul(2), + phase: String::new(), + text: String::new(), + transcript: String::new(), + transcript_font_size: 14, + audio_level: None, + translation_active: false, + style: self.capsule_style_tag(), + suggestions: self.vocab_suggestions.clone(), + }, + ); + // 卡片不参加听写会话的兜底收起。 + self.capsule_session = None; + } + + fn hide_vocab_card(&mut self) { + let Some(session_id) = self.vocab_card_session.take() else { + return; + }; + self.send_popup( + PopupKind::Capsule, + HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence: 0, + }, + ); + self.capsule_session = None; + } + + /// 卡片上有 Core 给的死线(`PendingCorrection::expires_at_ms`),Core 只按需 + /// 清理过期项、不发事件;这里按秒重算,过期的行自己消失。 + fn tick_vocab_suggestions(&mut self) { + if self.vocab_suggestions.is_empty() { + return; + } + if self.vocab_suggestions_checked.elapsed() < std::time::Duration::from_millis(500) { + return; + } + self.refresh_vocab_suggestions(); + } + + fn capsule_transcript(&self) -> String { + // Selection polish uses the capsule too, but must never expose the + // previous dictation's raw text (Tauri selectionPolish gating). + if self.selection.as_ref().is_some_and(|selection| { + matches!( + selection.phase, + SelectionPhase::Capturing | SelectionPhase::Preview | SelectionPhase::Applying + ) + }) { + return String::new(); + } + if self + .preferences + .as_ref() + .is_none_or(|prefs| prefs.capsule_transcript_enabled) + { + self.transcript_state.text().to_owned() + } else { + String::new() + } + } + + fn capsule_transcript_font_size(&self) -> u8 { + self.preferences + .as_ref() + .map_or(14, |prefs| prefs.capsule_transcript_font_size.clamp(12, 20)) + } + + fn sync_capsule_transcript(&mut self) { + if !self.capsule_enabled() { + return; + } + let Some(backend) = self.backend() else { + return; + }; + let snapshot = backend.snapshot().dictation; + let Some(session_id) = snapshot + .session_id + .filter(|id| Some(*id) == self.transcript_session) + else { + return; + }; + if !matches!( + snapshot.phase, + DictationPhase::Recording + | DictationPhase::Transcribing + | DictationPhase::Polishing + ) { + return; + } + self.send_popup( + PopupKind::Capsule, + HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.to_string(), + sequence: self.last_event_sequence.saturating_mul(2).saturating_add(1), + phase: format!("{:?}", snapshot.phase), + text: String::new(), + transcript: self.capsule_transcript(), + transcript_font_size: self.capsule_transcript_font_size(), + audio_level: Some(snapshot.level), + translation_active: snapshot.translation_active, + style: self.capsule_style_tag(), + suggestions: Vec::new(), + }, + ); + } + fn show_capsule_popup(&mut self) { + if !self.vocab_suggestions.is_empty() { + self.show_vocab_card(); + return; + } if !self.capsule_enabled() { return; } @@ -1394,9 +1777,12 @@ mod linux_app { sequence: self.last_event_sequence.saturating_mul(2), phase: format!("{:?}", snapshot.phase), text, + transcript: self.capsule_transcript(), + transcript_font_size: self.capsule_transcript_font_size(), audio_level: Some(snapshot.level), translation_active: snapshot.translation_active, style, + suggestions: self.vocab_suggestions.clone(), }, ); self.schedule_capsule_dismissal(&session_id.to_string(), snapshot.phase); @@ -1421,9 +1807,12 @@ mod linux_app { sequence: self.last_event_sequence.saturating_mul(2), phase: "failed".to_string(), text: tr_l10n(lang, "capsule.selectionPolish.noSelection").to_string(), + transcript: String::new(), + transcript_font_size: 14, audio_level: None, translation_active: false, style: self.capsule_style_tag(), + suggestions: self.vocab_suggestions.clone(), }, ); self.schedule_capsule_dismissal(session_id, DictationPhase::Failed); @@ -1462,6 +1851,7 @@ mod linux_app { } *self.popup_slot(PopupKind::Capsule) = None; self.capsule_session = None; + self.vocab_card_session = None; self.capsule_dismissal_scheduled = None; // 这条日志缺失 = 收起决定没走到「结束弹窗进程」这一环。 log::info!("capsule: dismissal applied (popup process was running: {had_process})"); @@ -1470,7 +1860,10 @@ mod linux_app { fn poll_popup_supervisors(&mut self) { let mut events = Vec::new(); for kind in [PopupKind::Qa, PopupKind::Capsule] { - let generation = self.qa_popup_presentation.generation; + let generation = match kind { + PopupKind::Capsule => self.capsule_popup_generation, + _ => self.qa_popup_presentation.generation, + }; if let Some(supervisor) = self.popup_slot(kind) { while let Ok(event) = supervisor.try_recv() { events.push((kind, generation, event)); @@ -1488,8 +1881,13 @@ mod linux_app { generation: u64, event: PopupSupervisorEvent, ) { - // A handler may replace the process while draining a queued batch. - if kind == PopupKind::Qa && generation != self.qa_popup_presentation.generation { + // 处理一条排队消息时可能已经把进程换掉了:旧进程剩下的消息全部作废。 + let stale = match kind { + PopupKind::Qa => generation != self.qa_popup_presentation.generation, + PopupKind::Capsule => generation != self.capsule_popup_generation, + PopupKind::LessComputer => false, + }; + if stale { return; } self.apply_popup_event(kind, event); @@ -1498,6 +1896,20 @@ mod linux_app { fn apply_popup_event(&mut self, kind: PopupKind, event: PopupSupervisorEvent) { let lang = self.lang; if let PopupSupervisorEvent::Message(message) = &event { + if let PopupToHost::VocabSuggestion { session_id, id, .. } = message { + let current = self.vocab_card_session.as_deref() == Some(session_id.as_str()) + && self.vocab_suggestions.iter().any(|item| item.id == *id) + && self.backend().is_none_or(|backend| { + backend + .pending_corrections() + .iter() + .any(|item| item.id == *id) + }); + if !current { + self.status = tr_l10n(lang, "popup.ignore_stale").to_string(); + return; + } + } let Some(expected_session) = self.expected_popup_session(kind) else { self.status = tr_l10n(lang, "popup.ignore_no_session").to_string(); return; @@ -1517,6 +1929,8 @@ mod linux_app { == Some(session_id.as_str()) => { if let Some(backend) = self.backend() { + // 空态面板没有 Core 会话:仍然允许发送,由 Core 在 + // **当前上下文**里检查并创建新轮次。 let context = self .qa_state .as_ref() @@ -1563,9 +1977,9 @@ mod linux_app { if self.expected_popup_session(PopupKind::Qa).as_deref() == Some(session_id.as_str()) => { - // 面板 ✕:立刻清掉宿主侧的可见标志,不等 Core 的 HideQa 回环 - // (那条被图钉门禁拦着,pinned 时不清 → 标志残留会让录音热键 - // 之后又把面板弹出来)。 + // 面板 ✕:收起 = 结束弹窗进程(Core 继续持有对话), + // 不等 Core 的 HideQa 回环(那条被图钉门禁拦着,pinned 时 + // 不清 → 标志残留会让录音热键之后又把面板弹出来)。 self.close_qa_popup(); if let Some(backend) = self.backend() { self.spawn(async move { @@ -1592,10 +2006,8 @@ mod linux_app { == Some(session_id.as_str()) => { if let Some(backend) = self.backend() { - let context = self - .qa_state - .as_ref() - .and_then(|state| state.session_id.clone()); + // 代次与上下文一起带上:返回时若面板已换窗口,结果作废。 + let context = self.expected_popup_session(PopupKind::Qa); self.spawn_qa_action(context, async move { backend .services() @@ -1607,10 +2019,7 @@ mod linux_app { } } PopupSupervisorEvent::Message(PopupToHost::RevertEdit { session_id, .. }) - if self - .qa_state - .as_ref() - .and_then(|state| state.session_id.as_deref()) + if self.expected_popup_session(PopupKind::Qa).as_deref() == Some(session_id.as_str()) => { if let Ok(qa_session) = session_id.parse::() { @@ -1629,10 +2038,7 @@ mod linux_app { } } PopupSupervisorEvent::Message(PopupToHost::ApplyEdit { session_id, .. }) - if self - .qa_state - .as_ref() - .and_then(|state| state.session_id.as_deref()) + if self.expected_popup_session(PopupKind::Qa).as_deref() == Some(session_id.as_str()) => { if let Ok(qa_session) = session_id.parse::() { @@ -1709,6 +2115,26 @@ mod linux_app { self.pending_local_hotkeys .push((std::time::Instant::now(), edge)); } + PopupSupervisorEvent::Message(PopupToHost::VocabSuggestion { + id, accept, .. + }) => { + // 点一下就是一次词库写入(同步 IO),放到后台线程,别卡这一帧。 + if let Some(backend) = self.backend() { + let result = if accept { + backend + .accept_pending_correction(&id) + .map(|_| String::new()) + .map_err(|error| error.to_string()) + } else { + backend.reject_pending_correction(&id); + Ok(String::new()) + }; + if let Err(error) = result { + log::warn!("[vocab-card] resolve failed: {error}"); + } + self.refresh_vocab_suggestions(); + } + } PopupSupervisorEvent::Message(PopupToHost::DismissCapsule { .. }) => { if let Some(snapshot) = self.snapshot.as_mut() { snapshot.dictation.message = None; @@ -1830,9 +2256,9 @@ mod linux_app { 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() - ); + "[popup] {kind:?} crashed {POPUP_RESTART_LIMIT} times within {}s; not restarting", + POPUP_RESTART_WINDOW.as_secs() + ); self.forget_qa_panel_visibility(kind); return; } @@ -1852,6 +2278,26 @@ mod linux_app { } } + fn spawn_hotkey(&self, future: F) + where + F: Future> + Send + 'static, + { + if self.hotkey_jobs.send(Box::pin(future)).is_err() { + log::error!("[hotkey] ordered dispatcher stopped"); + } + } + + fn spawn(&self, future: F) + where + F: Future> + Send + 'static, + { + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let message = future.await.unwrap_or_else(|error| error.to_string()); + let _ = tx.send(UiResult::Message(message)); + }); + } + fn spawn_qa_action(&self, context: Option, future: F) where F: Future> + Send + 'static, @@ -1868,6 +2314,8 @@ mod linux_app { }); } + /// 选区助手动作的异步结果:只有当前代次、且仍属于同上下文的失败才回写到面板 + /// (早于本轮的失败不能覆盖新轮次)。 fn apply_qa_action_result( &mut self, generation: u64, @@ -1897,17 +2345,6 @@ mod linux_app { } } - fn spawn(&self, future: F) - where - F: Future> + Send + 'static, - { - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let message = future.await.unwrap_or_else(|error| error.to_string()); - let _ = tx.send(UiResult::Message(message)); - }); - } - /// `spawn` variant for cancellable flows: an empty message means /// "nothing happened", so no notice is posted. fn spawn_reporting(&self, future: F) @@ -1949,10 +2386,12 @@ mod linux_app { }); } - fn load_providers(&self, kind: openless_core::ChannelKind) { + fn load_providers(&mut self, kind: openless_core::ChannelKind) { let Some(backend) = self.backend() else { return; }; + self.providers_request = self.providers_request.wrapping_add(1); + let request = self.providers_request; let tx = self.tx.clone(); self.tokio.spawn(async move { let result = async { @@ -1970,7 +2409,11 @@ mod linux_app { } .await .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::Providers(result)); + let _ = tx.send(UiResult::Providers { + kind, + request, + result, + }); }); } @@ -2023,7 +2466,10 @@ mod linux_app { return; }; let kind = self.settings_channel_kind; - self.settings_channels_loading = true; + // 已有列表时后台静默刷新,不把界面切成“加载中”(会造成整页跳动)。 + self.settings_channels_loading = self.settings_channels.is_empty(); + self.settings_channels_request = self.settings_channels_request.wrapping_add(1); + let request = self.settings_channels_request; let tx = self.tx.clone(); self.tokio.spawn(async move { let result = async { @@ -2049,13 +2495,18 @@ mod linux_app { .last_test .as_ref() .and_then(|test| test.error.clone()), + last_test_at: channel.last_test.as_ref().map(|test| test.at), }); } Ok::<_, BackendError>(rows) } .await .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::SettingsChannels(result)); + let _ = tx.send(UiResult::SettingsChannels { + kind, + request, + result, + }); }); } @@ -2152,41 +2603,422 @@ mod linux_app { }); } - fn start_marketplace_oauth(&mut self) { + /// 读一次云同步状态(顺带刷新市场登录态,决定能否走「用 GitHub 登录」这条路)。 + fn load_cloud_sync(&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 + let signed_in = backend .services() .marketplace - .start_device_flow() + .auth_status() .await - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::MarketplaceOAuthStarted(result)); + .map(|status| status.signed_in) + .unwrap_or(false); + let _ = tx.send(UiResult::MarketplaceAuth(Ok(signed_in))); + match backend.cloud_sync_e2ee_status() { + Ok(status) => { + let _ = tx.send(UiResult::CloudSyncDone { + result: Box::new(Ok( + openless_linux_egui::cloud_sync_ui::SyncOutcome::Status(Box::new( + status, + )), + )), + }); + } + Err(error) => { + let _ = tx.send(UiResult::CloudSyncDone { + result: Box::new(Err(error.to_string())), + }); + } + } }); } - 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; + /// 云同步卡片的唯一入口:UI 只发动作,所有 Core 调用都在这里发生。 + fn apply_cloud_sync_action( + &mut self, + action: openless_linux_egui::cloud_sync_ui::CloudSyncAction, + ) { + use openless_linux_egui::cloud_sync_ui::{ + CloudSyncAction, CloudSyncDialog, CloudSyncIntent, CloudSyncOp, CONSENT_VERSION, }; - let tx = self.tx.clone(); - self.tokio.spawn(async move { - let result = backend + + // 纯 UI 动作:改草稿、开关对话框,不碰 Core。 + match &action { + CloudSyncAction::Password(value) => { + self.frontend_vm.cloud_sync.password = value.clone(); + return; + } + CloudSyncAction::Confirmation(value) => { + self.frontend_vm.cloud_sync.confirmation = value.clone(); + return; + } + CloudSyncAction::CurrentPassword(value) => { + self.frontend_vm.cloud_sync.current_password = value.clone(); + return; + } + CloudSyncAction::RememberKey(value) => { + self.frontend_vm.cloud_sync.remember_key = *value; + return; + } + CloudSyncAction::ConsentChecked(value) => { + self.frontend_vm.cloud_sync.consent_checked = *value; + return; + } + CloudSyncAction::CustomOrigin(value) => { + self.frontend_vm.cloud_sync.custom_origin = value.clone(); + return; + } + CloudSyncAction::CustomToken(value) => { + self.frontend_vm.cloud_sync.custom_token = value.clone(); + return; + } + CloudSyncAction::OpenDialog(dialog) => { + self.frontend_vm.cloud_sync.open(dialog.clone()); + return; + } + CloudSyncAction::CloseDialog => { + self.frontend_vm.cloud_sync.close(); + return; + } + CloudSyncAction::Toggle(enabled) => { + if !*enabled { + self.frontend_vm.cloud_sync.open(CloudSyncDialog::Disable); + return; + } + // 启用前必须过「同意 + 协议警告」两屏(除非该版本已经同意过)。 + let consent_stale = self + .frontend_vm + .cloud_sync + .status + .as_ref() + .and_then(|status| status.consent_version.clone()) + .as_deref() + != Some(CONSENT_VERSION); + self.frontend_vm.cloud_sync.open(if consent_stale { + CloudSyncDialog::Consent { + intent: CloudSyncIntent::Enable, + } + } else { + CloudSyncDialog::Protocol { + intent: CloudSyncIntent::Enable, + } + }); + return; + } + CloudSyncAction::ResolveConflict(id, side) => { + self.frontend_vm.cloud_sync.resolve_conflict(id, *side); + return; + } + CloudSyncAction::Reload => { + self.load_cloud_sync(); + return; + } + _ => {} + } + + if self.frontend_vm.cloud_sync.busy { + return; + } + + // 把「要做什么」压成一个纯数据操作,再交给同一个异步任务执行: + // 每个 Core 调用返回的类型都不同,只有一个 await 点时才不会互相打架。 + let state = &self.frontend_vm.cloud_sync; + let dialog = state.dialog.clone(); + let status = state.status.clone(); + let remember_key = state.remember_key; + let passwords = ( + state.current_password.clone(), + state.password.clone(), + state.confirmation.clone(), + ); + let custom = (state.custom_origin.clone(), state.custom_token.clone()); + let origin = state.normalized_origin(); + let conflicts = state.conflict_choices(); + let conflicts_ready = state.conflicts_resolved(); + + let operation = match action { + CloudSyncAction::Prepare(intent) => { + self.frontend_vm.cloud_sync.intent = Some(intent); + CloudSyncOp::Prepare { + consent_version: CONSENT_VERSION.to_string(), + } + } + CloudSyncAction::SubmitPassword(intent) => { + self.frontend_vm.cloud_sync.intent = Some(intent); + let (current, password, confirmation) = passwords; + match &dialog { + CloudSyncDialog::Create { observed_revision } => CloudSyncOp::Create { + password, + confirmation, + remember_key, + consent_version: CONSENT_VERSION.to_string(), + observed_revision: observed_revision.clone(), + }, + CloudSyncDialog::ChangePassword => CloudSyncOp::ChangePassword { + current_password: current, + password, + confirmation, + remember_key, + }, + _ => CloudSyncOp::Unlock { + password, + remember_key, + }, + } + } + CloudSyncAction::SetEnabled(enabled) => CloudSyncOp::SetEnabled(enabled), + CloudSyncAction::Lock => CloudSyncOp::Lock, + CloudSyncAction::SyncNow => CloudSyncOp::SyncNow, + CloudSyncAction::Cancel(task) => CloudSyncOp::Cancel(task), + CloudSyncAction::PreviewRestore => { + let Some(observed) = status + .as_ref() + .and_then(|status| status.remote_revision.clone()) + else { + self.frontend_vm.cloud_sync.apply_error("stale_preview"); + return; + }; + CloudSyncOp::PreviewRestore { observed } + } + CloudSyncAction::ApplyRestore(mode) => { + let CloudSyncDialog::Restore { preview, .. } = &dialog else { + return; + }; + if !conflicts_ready { + self.frontend_vm.cloud_sync.apply_error("conflict"); + return; + } + let stale = status.as_ref().is_none_or(|status| { + status.remote_revision.as_deref() + != Some(preview.observed_revision.as_str()) + || status.local_generation != preview.local_generation + }); + if stale { + self.frontend_vm.cloud_sync.apply_error("stale_preview"); + return; + } + CloudSyncOp::ApplyRestore { + preview_id: preview.preview_id.clone(), + mode, + conflicts, + } + } + CloudSyncAction::DeleteRemote => { + let CloudSyncDialog::Delete { vault_id, revision } = &dialog else { + return; + }; + CloudSyncOp::DeleteRemote { + vault_id: vault_id.clone(), + revision: revision.clone(), + } + } + CloudSyncAction::SignOut => CloudSyncOp::SignOut, + CloudSyncAction::ConfigureServer => { + let (_, token) = custom; + let origin = match origin { + Ok(origin) => origin, + Err(code) => { + self.frontend_vm.cloud_sync.apply_error(code); + return; + } + }; + CloudSyncOp::ConfigureServer { origin, token } + } + _ => return, + }; + + let Some(backend) = self.backend() else { + return; + }; + self.frontend_vm.cloud_sync.busy = true; + self.frontend_vm.cloud_sync.notice = None; + self.frontend_vm.cloud_sync.error = None; + // 密码框在派发后立刻清空:宿主不再持有用户刚输入的密钥材料。 + self.frontend_vm.cloud_sync.forget_secrets_keep_origin(); + let tx = self.tx.clone(); + self.tokio.spawn(async move { + let result = CloudSyncOp::run(backend, operation).await; + let _ = tx.send(UiResult::CloudSyncDone { + result: Box::new(result), + }); + }); + } + + /// 处理一次云同步往返:状态、启用准备、恢复预览各有不同后续。 + fn apply_cloud_sync_result( + &mut self, + result: Result, + ) { + use openless_linux_egui::cloud_sync_ui::{ + CloudSyncAction, CloudSyncDialog, CloudSyncIntent, SyncOutcome, + }; + self.frontend_vm.cloud_sync.busy = false; + self.frontend_vm.cloud_sync.loading = false; + let outcome = match result { + Ok(outcome) => outcome, + Err(error) => { + self.frontend_vm.cloud_sync.apply_error(&error); + return; + } + }; + match outcome { + SyncOutcome::Status(status) => { + if !self.frontend_vm.cloud_sync.accept_status(*status) { + return; + } + let dialog = self.frontend_vm.cloud_sync.dialog.clone(); + match dialog { + // 解锁只是启用流程中的一步:解锁成功后继续准备。 + CloudSyncDialog::Unlock { intent } => { + self.apply_cloud_sync_action(CloudSyncAction::Prepare(intent)); + } + CloudSyncDialog::Disable | CloudSyncDialog::Delete { .. } => { + self.frontend_vm.cloud_sync.dialog = CloudSyncDialog::None; + self.frontend_vm.cloud_sync.notice = + Some("cloudSyncE2ee.noticeDone".to_string()); + } + CloudSyncDialog::Create { .. } | CloudSyncDialog::ChangePassword => { + self.frontend_vm.cloud_sync.dialog = CloudSyncDialog::None; + self.frontend_vm.cloud_sync.intent = None; + self.frontend_vm.cloud_sync.notice = + Some("cloudSyncE2ee.noticeDone".to_string()); + } + CloudSyncDialog::Restore { enable_after, .. } => { + if enable_after + && !self + .frontend_vm + .cloud_sync + .status + .as_ref() + .is_some_and(|status| status.enabled) + { + self.apply_cloud_sync_action(CloudSyncAction::SetEnabled(true)); + } else { + self.frontend_vm.cloud_sync.dialog = CloudSyncDialog::None; + self.frontend_vm.cloud_sync.notice = + Some("cloudSyncE2ee.noticeRestored".to_string()); + } + self.load_settings_channels(); + self.load_service_configured(); + } + CloudSyncDialog::None if self.frontend_vm.cloud_sync.notice.is_none() => { + // 「立即同步」「锁定」「登出」这类单步动作:成功后给个提示。 + self.frontend_vm.cloud_sync.notice = + Some("cloudSyncE2ee.noticeDone".to_string()); + } + _ => {} + } + } + SyncOutcome::Prepared(prepared) => { + if !self + .frontend_vm + .cloud_sync + .accept_status(prepared.status.clone()) + { + return; + } + let intent = self.frontend_vm.cloud_sync.intent; + let status = prepared.status.clone(); + match prepared.next_step { + openless_core::cloud_sync_e2ee::EnableStep::Create => { + // 只有在「云端还没有快照」且确实拿到了 revision 时才能建库。 + let Some(revision) = status.remote_revision.clone() else { + self.frontend_vm.cloud_sync.apply_error("invalid_response"); + return; + }; + if status.has_cloud_snapshot != Some(false) + || intent != Some(CloudSyncIntent::Enable) + { + self.frontend_vm.cloud_sync.apply_error("invalid_response"); + return; + } + self.frontend_vm.cloud_sync.open(CloudSyncDialog::Create { + observed_revision: revision, + }); + } + openless_core::cloud_sync_e2ee::EnableStep::Unlock => { + self.frontend_vm.cloud_sync.open(CloudSyncDialog::Unlock { + intent: intent.unwrap_or(CloudSyncIntent::Unlock), + }); + } + openless_core::cloud_sync_e2ee::EnableStep::RestoreReview => { + self.frontend_vm.cloud_sync.intent = Some(CloudSyncIntent::Restore); + self.apply_cloud_sync_action(CloudSyncAction::PreviewRestore); + } + openless_core::cloud_sync_e2ee::EnableStep::Ready => { + if let Some(action) = intent.and_then(CloudSyncIntent::ready_action) { + self.apply_cloud_sync_action(action); + } else { + self.frontend_vm.cloud_sync.dialog = CloudSyncDialog::None; + self.frontend_vm.cloud_sync.intent = None; + self.frontend_vm.cloud_sync.notice = + Some("cloudSyncE2ee.noticeDone".to_string()); + } + } + } + } + SyncOutcome::Preview(preview) => { + let Some(status) = self.frontend_vm.cloud_sync.status.clone() else { + self.frontend_vm.cloud_sync.apply_error("stale_preview"); + return; + }; + // 预览必须和刚读到的 revision/generation 对齐,否则是过期预览。 + if status.remote_revision.as_deref() != Some(preview.observed_revision.as_str()) + || status.local_generation != preview.local_generation + { + self.frontend_vm.cloud_sync.apply_error("stale_preview"); + return; + } + let enable_after = + self.frontend_vm.cloud_sync.intent == Some(CloudSyncIntent::Enable); + self.frontend_vm.cloud_sync.open(CloudSyncDialog::Restore { + preview, + enable_after, + }); + } + SyncOutcome::Done => {} + } + } + + 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()) @@ -2331,6 +3163,7 @@ mod linux_app { editor.auth_mode = form.auth_mode.clone(); editor.primary_secret = form.primary_secret.clone(); editor.secondary_secret = form.secondary_secret.clone(); + editor.protocol = form.protocol.clone(); Some(editor) } @@ -2338,6 +3171,7 @@ mod linux_app { /// 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) { + self.editor_requested = true; let Some(channel_id) = self .settings_channels .get(index) @@ -2360,72 +3194,383 @@ mod linux_app { self.load_provider_editor(kind, channel, descriptor); } - fn close_provider_editor(&mut self) { - self.provider_editor = ProviderEditorState::Idle; - self.provider_editor_form = None; - self.frontend_vm.provider_editor = None; + /// 关闭渠道编辑器;完全没交互过的草稿在这条路径上被回收 + /// (Tauri `shouldRecycleDraft` + `deleteChannelIfBlank`)。 + fn resolved_services_view(&self, requested: usize) -> usize { + let multimodal = self.preferences.as_ref().is_some_and(|p| { + p.multimodal_pipeline_enabled + && p.pipeline_mode == openless_core::shared_types::PipelineMode::Multimodal + }); + match requested { + 3 => 3, + 4 if multimodal => 4, + 0 | 1 if !multimodal => requested, + 2 if !multimodal && self.frontend_vm.supports_local_asr => 2, + _ => { + if multimodal { + 4 + } else { + 0 + } + } + } } - /// Core owns the model catalog; the host only forwards the request. - fn request_provider_models(&self, kind: openless_core::ChannelKind, channel_id: String) { + fn select_services_view(&mut self, requested: usize) { + let view = self.resolved_services_view(requested); + self.frontend_vm.services_view = view; + if !matches!(view, 0 | 1) { + self.close_provider_editor(); + } + if view == 4 { + if !self.frontend_vm.omni.loaded + && !self.frontend_vm.omni.busy + && self.frontend_vm.omni.notice.is_none() + { + self.apply_omni_action(openless_linux_egui::omni_settings::OmniAction::Reload); + } + return; + } + if !matches!(view, 0 | 1) { + return; + } + let kind = if view == 1 { + openless_core::ChannelKind::Asr + } else { + openless_core::ChannelKind::Llm + }; + if self.settings_channel_kind != kind { + self.close_provider_editor(); + self.selected_channel_id = None; + self.settings_channel_kind = kind; + self.provider_kind = kind; + self.load_settings_channels(); + self.load_service_configured(); + self.load_providers(kind); + } else if self.settings_channels.is_empty() && !self.settings_channels_loading { + self.provider_kind = kind; + self.load_settings_channels(); + self.load_service_configured(); + self.load_providers(kind); + } + } + + fn apply_omni_action(&mut self, operation: openless_linux_egui::omni_settings::OmniAction) { + use openless_linux_egui::omni_settings::{OmniAction, OmniSettings}; + let state = &mut self.frontend_vm.omni; + if let OmniAction::Field(field, value) = operation { + if state.loaded && !state.switching { + state.edit(field, value); + } + return; + } + if state.busy { + return; + } + if !matches!(operation, OmniAction::Reload) && !state.loaded { + return; + } + if !matches!(operation, OmniAction::Reload | OmniAction::ClearSecrets) + && state.validate().is_err() + { + return; + } let Some(backend) = self.backend() else { return; }; + let host = self.native.as_ref().map(|native| native.host_arc()); + let mut snapshot = self.frontend_vm.omni.clone(); + let submitted = Box::new(snapshot.clone()); + self.frontend_vm.omni.busy = true; + self.frontend_vm.omni.switching = + matches!(operation, OmniAction::Provider(_) | OmniAction::Reload); let tx = self.tx.clone(); - self.tokio.spawn(async move { - let result = backend - .services() - .provider - .list_models(openless_core::ProviderRequest { - kind: provider_kind(kind), - thinking_enabled: false, - channel_id: Some(channel_id.clone()), - }) - .await - .map(|models| models.models) - .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::ProviderModels { - kind, - channel_id, - result, + let wake = std::thread::current(); + let job = Box::pin(async move { + let result = async { + if snapshot.dirty + && !matches!(operation, OmniAction::Reload | OmniAction::ClearSecrets) + { + snapshot.save(&backend).await.map_err(|e| e.to_string())?; + snapshot.api_key.clear(); + snapshot.extra_headers.clear(); + snapshot.dirty = false; + } + match &operation { + OmniAction::Provider(id) => { + OmniSettings::select(&backend, id) + .await + .map_err(|e| e.to_string())?; + let host = host + .ok_or_else(|| "native settings host is unavailable".to_owned())?; + let edits = [("/activeOmniProvider".to_owned(), serde_json::json!(id))] + .into_iter() + .collect(); + tokio::task::spawn_blocking(move || { + host.update_preference_fields(&edits) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + snapshot = OmniSettings::load(&backend) + .await + .map_err(|e| e.to_string())?; + } + OmniAction::Reload => { + snapshot = OmniSettings::load(&backend) + .await + .map_err(|e| e.to_string())?; + } + OmniAction::ClearSecrets => { + snapshot + .clear_secrets(&backend) + .await + .map_err(|e| e.to_string())?; + snapshot.api_key.clear(); + snapshot.extra_headers.clear(); + } + OmniAction::Models => { + // Omni 没有「渠道」这一层:Core 对 + // `kind == Omni && channel_id.is_some()` 直接返回 + // `omni provider does not support channel id`, + // 于是「测试 / 拉模型」永远是失败的。传 None 让 Core + // 用当前激活的服务商。 + snapshot.models = backend + .services() + .provider + .list_models(omni_provider_request( + backend.get_preferences().llm_thinking_enabled, + )) + .await + .map_err(|e| e.to_string())? + .models; + } + OmniAction::Validate => { + // 同上:带 channel_id 会被 Core 拒绝,测试必然失败。 + let check = backend + .services() + .provider + .validate(omni_provider_request( + backend.get_preferences().llm_thinking_enabled, + )) + .await + .map_err(|e| e.to_string())?; + if !check.ok { + return Err("validateFailed".to_owned()); + } + } + _ => {} + } + snapshot.busy = false; + snapshot.switching = false; + Ok(snapshot) + } + .await; + let _ = tx.send(UiResult::OmniFinished { + operation, + submitted, + result: Box::new(result), }); + wake.unpark(); }); + if self.provider_jobs.send(job).is_err() { + self.frontend_vm.omni.busy = false; + self.frontend_vm.omni.switching = false; + self.frontend_vm.omni.notice = + Some(tr_l10n(self.lang, "common.operationFailed").to_owned()); + } } - fn load_provider_editor( - &self, - kind: openless_core::ChannelKind, - channel: openless_core::ChannelSummary, - descriptor: openless_core::ProviderDescriptor, - ) { + fn queue_provider_save(&mut self) -> bool { + if self.provider_switch_pending { + return false; + } + if self.provider_save_pending { + self.provider_save_again = true; + return true; + } + let Some(editor) = self.editor_from_form() else { + return false; + }; + if let Err(error) = editor.protocol.validate() { + self.status = error.to_string(); + return false; + } let Some(backend) = self.backend() else { - return; + return false; }; 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) + let wake = std::thread::current(); + self.provider_save_pending = true; + self.draft_touched = true; + let job = Box::pin(async move { + let result = save_provider_editor(backend, editor.clone()) .await .map_err(|error| error.to_string()); - let _ = tx.send(UiResult::ProviderEditor { - kind, - channel_id, - result: Box::new(result), + let _ = tx.send(UiResult::ProviderSaved { + editor: Box::new(editor), + result, }); + wake.unpark(); }); + if self.provider_jobs.send(job).is_err() { + self.provider_save_pending = false; + self.status = tr_l10n(self.lang, "common.operationFailed").to_owned(); + return false; + } + true } - /// Play the native recording start/stop cue on a worker thread, gated by - /// the `audio_cue_on_record` preference. The start cue is additionally - /// suppressed while `mute_during_recording` is active: playing into a - /// deliberately muted sink is both inaudible and a needless PipeWire/ - /// KDE sink-input blip. The stop cue plays after output has been - /// restored. Absent preferences default to Core's defaults (cue on, - /// mute off). - fn play_record_cue(&self, at_start: bool) { - let enabled = self - .preferences - .as_ref() + fn request_provider_editor_close(&mut self) { + if self.provider_switch_pending { + return; + } + if self.provider_save_pending { + self.provider_close_pending = true; + return; + } + if self + .provider_editor_form + .as_ref() + .is_some_and(|form| form.protocol.validate().is_err()) + { + return; + } + // Flush host-side edits even when no UI debounce timer was armed + // (e.g. a default-model button, navigation, or window teardown). + if self.draft_touched { + self.provider_close_pending = true; + if !self.queue_provider_save() { + self.provider_close_pending = false; + } + return; + } + let recycle = self.editor_draft && !self.draft_touched; + let channel_id = self + .provider_editor_form + .as_ref() + .map(|form| form.channel_id.clone()); + if recycle { + if let (Some(backend), Some(channel_id)) = (self.backend(), channel_id) { + let kind = self.settings_channel_kind; + let lang = self.lang; + self.spawn_channel_change(kind, async move { + backend.delete_channel_if_blank(kind, channel_id).await?; + Ok(tr_l10n(lang, "status.channel_deleted").to_string()) + }); + } + } + self.close_provider_editor(); + } + + fn close_provider_editor(&mut self) { + self.provider_close_pending = false; + self.provider_after_save = None; + self.provider_save_again = false; + self.provider_editor = ProviderEditorState::Idle; + self.provider_editor_form = None; + self.frontend_vm.provider_editor = None; + self.editor_draft = false; + self.draft_touched = false; + self.editor_requested = false; + } + + /// Core owns the model catalog; the host only forwards the request. + fn request_provider_models(&self, kind: openless_core::ChannelKind, channel_id: String) { + if self.provider_switch_pending { + return; + } + let Some(backend) = self.backend() else { + return; + }; + let editor = self + .editor_from_form() + .filter(|editor| editor.kind == kind && editor.channel.id == channel_id); + let Some(editor) = editor else { + return; + }; + let endpoint = editor.endpoint.clone(); + let request_format = editor.protocol.format(); + let provider_type = editor.descriptor.provider_type.as_str().to_owned(); + let tx = self.tx.clone(); + let wake = std::thread::current(); + let job = Box::pin(async move { + let result = async { + save_provider_editor(backend.clone(), editor).await?; + backend + .services() + .provider + .list_models(openless_core::ProviderRequest { + kind: provider_kind(kind), + thinking_enabled: backend.get_preferences().llm_thinking_enabled, + channel_id: Some(channel_id.clone()), + }) + .await + .map(|models| models.models) + } + .await + .map_err(|error: BackendError| error.to_string()); + let _ = tx.send(UiResult::ProviderModels { + kind, + channel_id, + endpoint, + provider_type, + request_format, + result, + }); + wake.unpark(); + }); + let _ = self.provider_jobs.send(job); + } + + fn spawn_channel_change(&self, kind: openless_core::ChannelKind, future: F) + where + F: std::future::Future> + Send + 'static, + { + let tx = self.tx.clone(); + let wake = std::thread::current(); + let job = Box::pin(async move { + let result = future.await.map_err(|error| error.to_string()); + let _ = tx.send(UiResult::ChannelsChanged { kind, result }); + wake.unpark(); + }); + let _ = self.provider_jobs.send(job); + } + + fn load_provider_editor( + &self, + kind: openless_core::ChannelKind, + channel: openless_core::ChannelSummary, + descriptor: openless_core::ProviderDescriptor, + ) { + let Some(backend) = self.backend() else { + return; + }; + let tx = self.tx.clone(); + let channel_id = channel.id.clone(); + self.tokio.spawn(async move { + let result = load_provider_editor(backend, kind, channel, descriptor) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::ProviderEditor { + kind, + channel_id, + result: Box::new(result), + }); + }); + } + + /// Play the native recording start/stop cue on a worker thread, gated by + /// the `audio_cue_on_record` preference. The start cue is additionally + /// suppressed while `mute_during_recording` is active: playing into a + /// deliberately muted sink is both inaudible and a needless PipeWire/ + /// KDE sink-input blip. The stop cue plays after output has been + /// restored. Absent preferences default to Core's defaults (cue on, + /// mute off). + fn play_record_cue(&self, at_start: bool) { + let enabled = self + .preferences + .as_ref() .map(|prefs| prefs.audio_cue_on_record) .unwrap_or(true); if !enabled { @@ -2464,11 +3609,14 @@ mod linux_app { let was_recording = self.recording_phase_active; self.recording_phase_active = state.phase == DictationPhase::Recording; if state.phase == DictationPhase::Recording && !was_recording { + // 卡片与录音胶囊共用一个窗口:会话一起来就得让路。 + self.dismiss_vocab_suggestions(); self.play_record_cue(true); } else if !self.recording_phase_active && was_recording { self.play_record_cue(false); } if state.phase == DictationPhase::Starting { + self.dismiss_vocab_suggestions(); self.transcript_state = TranscriptAccumulator::default(); self.transcript.clear(); self.transcript_session = state.session_id; @@ -2510,9 +3658,12 @@ mod linux_app { sequence: event_sequence.saturating_mul(2), phase: format!("{:?}", state.phase), text, + transcript: self.capsule_transcript(), + transcript_font_size: self.capsule_transcript_font_size(), audio_level: Some(state.level), translation_active: state.translation_active, style, + suggestions: self.vocab_suggestions.clone(), }, ); // 终态:按 Tauri 时序安排自动收起,否则药丸会一直贴在屏幕上。 @@ -2524,6 +3675,7 @@ mod linux_app { && self.transcript_state.apply(&delta).is_ok() => { self.transcript = self.transcript_state.text().to_string(); + self.sync_capsule_transcript(); } BackendEventKind::PolishDelta(delta) if delta.is_final => { self.transcript = delta.text; @@ -2678,10 +3830,20 @@ mod linux_app { } } } + BackendEventKind::VocabularySuggestionsChanged(_) => { + // Core 只负责攒候选;「要不要记住」的确认入口是胶囊窗上的卡片 + // (Tauri `VocabSuggestionCard`)。没有这张卡,自动学词永远落不了库。 + self.refresh_vocab_suggestions(); + } BackendEventKind::HistoryChanged(change) => { self.history.observe(change.revision); self.load_overview(); } + BackendEventKind::CredentialsChanged(_) => { + // Defer and coalesce credential writes; refresh after the mutation, + // not only before its async worker commits it. + self.settings_channels_refresh_requested = true; + } BackendEventKind::PreferencesChanged(_) => { // 外部改动(Core 事件 / 托盘 / 另一窗口)要重新灌一次文本行。 self.hydrate_text_fields = true; @@ -2794,6 +3956,66 @@ mod linux_app { } BackendEventKind::RemoteInputStatusChanged(_) | BackendEventKind::RemoteInputFailed(_) => self.load_remote_status(), + BackendEventKind::CloudSyncStateChanged(event) => { + // 未经请求的事件不得把界面切到另一个账号/仓库。 + let same_scope = + self.frontend_vm + .cloud_sync + .status + .as_ref() + .is_some_and(|current| { + current + .account + .as_ref() + .map(|account| account.github_id.clone()) + == event + .status + .account + .as_ref() + .map(|account| account.github_id.clone()) + && current.vault_id == event.status.vault_id + && current.service_origin == event.status.service_origin + }); + if same_scope { + self.frontend_vm.cloud_sync.accept_status(event.status); + } else { + self.load_cloud_sync(); + } + } + BackendEventKind::CloudSyncConflictDetected(event) => { + let same_scope = + self.frontend_vm + .cloud_sync + .status + .as_ref() + .is_some_and(|current| { + current.vault_id.as_deref() == Some(event.vault_id.as_str()) + && current.account.as_ref().is_some_and(|account| { + account.github_id == event.account_id + }) + }); + if same_scope { + self.frontend_vm.cloud_sync.open( + openless_linux_egui::cloud_sync_ui::CloudSyncDialog::Restore { + preview: Box::new(event.preview), + enable_after: false, + }, + ); + self.frontend_vm.cloud_sync.notice = + Some("cloudSyncE2ee.states.conflict".to_string()); + } else { + self.load_cloud_sync(); + } + } + BackendEventKind::CloudSyncRestoreCompleted(_) => { + self.frontend_vm.cloud_sync.dialog = + openless_linux_egui::cloud_sync_ui::CloudSyncDialog::None; + self.frontend_vm.cloud_sync.notice = + Some("cloudSyncE2ee.noticeRestored".to_string()); + self.load_cloud_sync(); + self.load_settings_channels(); + self.load_service_configured(); + } _ => {} } } @@ -2808,6 +4030,7 @@ mod linux_app { /// 停了就说明热键消费与弹窗拉起也没在跑(这正是当初「关窗后热键失效」 /// 的判据),用户/支持可以直接看日志确认。 fn log_pump_heartbeat(&mut self, ctx: &egui::Context) { + self.tick_vocab_suggestions(); if self.last_pump_heartbeat.elapsed() < std::time::Duration::from_secs(10) { return; } @@ -2858,7 +4081,7 @@ mod linux_app { self.last_event_sequence.saturating_mul(2).saturating_add(1), ); if let Some(backend) = self.backend() { - self.spawn(async move { + self.spawn_hotkey(async move { backend.services().qa.dismiss().await?; Ok(tr_l10n(lang, "qa.closed").to_string()) }); @@ -2866,8 +4089,9 @@ mod linux_app { } else { log::info!("[hotkey] QA panel toggle: showing"); if let Some(backend) = self.backend() { + self.qa_visible = true; // `show()` 只发 HostAction::ShowQa:Core 仍停在 Idle,不录音。 - self.spawn(async move { + self.spawn_hotkey(async move { backend.services().qa.show().await?; Ok(String::new()) }); @@ -2879,7 +4103,7 @@ mod linux_app { fn toggle_qa_recording(&mut self) { let lang = self.lang; if let Some(backend) = self.backend() { - self.spawn(async move { + self.spawn_hotkey(async move { backend.services().qa.toggle_recording().await?; Ok(tr_l10n(lang, "qa.recording_updated").to_string()) }); @@ -2912,8 +4136,7 @@ mod linux_app { /// 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 { + self.spawn_hotkey(async move { let result = if backend.dictation_output_target() == Some(openless_core::DictationOutputTarget::QuickNote) { @@ -2934,9 +4157,8 @@ mod linux_app { .await .map(|_| ()) }; - if let Err(error) = result { - let _ = tx.send(UiResult::Message(error.to_string())); - } + result?; + Ok(String::new()) }); } } @@ -3010,6 +4232,8 @@ mod linux_app { log::info!("[hotkey] stale QA panel flag (no popup process): clearing"); self.qa_visible = false; self.polish_result_visible = false; + self.qa_popup_presentation.generation = + self.qa_popup_presentation.generation.saturating_add(1); } let mut events = Vec::new(); for (at, edge) in edges { @@ -3033,8 +4257,10 @@ mod linux_app { } } LocalHotkey::Qa => { - log::info!("[hotkey] local selection-ask hotkey: toggling the panel"); - self.toggle_qa_panel(); + if single_shot { + log::info!("[hotkey] local selection-ask hotkey: toggling the panel"); + self.toggle_qa_panel(); + } } LocalHotkey::Dictation if edge.kind == LocalHotkeyEdgeKind::Pressed @@ -3230,7 +4456,7 @@ mod linux_app { 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 { + self.spawn_hotkey(async move { host.dispatch_hotkey_event(event).await?; Ok(tr_l10n(lang, "status.hotkey_handled").to_string()) }); @@ -3244,7 +4470,7 @@ mod linux_app { continue; } let host = Arc::clone(&host); - self.spawn(async move { + self.spawn_hotkey(async move { host.dispatch_hotkey_event(event).await?; Ok(tr_l10n(lang, "status.hotkey_handled").to_string()) }); @@ -3425,12 +4651,23 @@ mod linux_app { } } } + UiResult::LinuxAccess(snapshot) => { + self.frontend_vm.linux_access = snapshot; + self.linux_access_last_check = Some(std::time::Instant::now()); + } UiResult::Message(message) => self.status = message, UiResult::QaAction { generation, context, result, } => self.apply_qa_action_result(generation, context, result), + UiResult::HotkeyError(error) => { + self.status = if error.message == "selectionPolishSelectionTooLong" { + tr_l10n(self.lang, "selection.source_truncated").to_owned() + } else { + error.to_string() + }; + } UiResult::HistoryRepolish { id, result } => { self.frontend_vm.history_repolish_running = false; match result { @@ -3463,65 +4700,174 @@ mod linux_app { } UiResult::Remote(Ok(remote)) => self.remote_access = Some(remote), UiResult::Remote(Err(error)) => self.status = error, - UiResult::Providers(Ok(panel)) => { - if panel.kind != self.provider_kind { + UiResult::Providers { + kind, + request, + result, + } => { + if kind != self.provider_kind || request != self.providers_request { continue; } - if !panel.descriptors.iter().any(|descriptor| { - descriptor.provider_type.as_str() == self.new_provider_type - }) { - self.new_provider_type = panel - .descriptors - .first() - .map(|descriptor| descriptor.provider_type.as_str().to_string()) - .unwrap_or_default(); + match result { + Ok(panel) => { + if panel.kind != self.provider_kind { + continue; + } + if !panel.descriptors.iter().any(|descriptor| { + descriptor.provider_type.as_str() == self.new_provider_type + }) { + self.new_provider_type = panel + .descriptors + .first() + .map(|descriptor| { + descriptor.provider_type.as_str().to_string() + }) + .unwrap_or_default(); + } + let selected = self + .selected_channel_id + .as_ref() + .filter(|id| { + panel.channels.iter().any(|channel| &channel.id == *id) + }) + .cloned() + .or_else(|| { + panel + .channels + .iter() + .find(|channel| channel.id == panel.active_provider) + .map(|channel| channel.id.clone()) + }) + .or_else(|| { + panel.channels.first().map(|channel| channel.id.clone()) + }); + self.selected_channel_id = selected.clone(); + if self.pending_channel_delete.as_ref().is_some_and(|id| { + !panel.channels.iter().any(|channel| &channel.id == id) + }) { + self.pending_channel_delete = None; + } + self.providers = ProvidersState::Loaded(panel.clone()); + self.provider_models.clear(); + // 只把「面板刚加载好」当成一个时机,是否打开编辑器看用户意图: + // 保存 / 验证 / 切换页面都会刷新面板,不能顺手把编辑器拉起来。 + let editor_wanted = self.editor_requested; + self.editor_requested = false; + if let Some(channel_id) = selected { + // A refresh (enable toggle, save, validation) must not + // throw away the editor draft the user is editing: only + // a different channel re-reads the descriptor. + let already_loaded = matches!( + &self.provider_editor, + ProviderEditorState::Loaded(editor) + if editor.channel.id == channel_id + ); + if !already_loaded && editor_wanted { + 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; + } + } + Err(error) => { + self.providers = ProvidersState::Failed; + self.frontend_vm.settings_notice = Some(error.clone()); + self.status = error; + } } - let selected = self - .selected_channel_id - .as_ref() - .filter(|id| panel.channels.iter().any(|channel| &channel.id == *id)) - .cloned() - .or_else(|| { - panel - .channels - .iter() - .find(|channel| channel.id == panel.active_provider) - .map(|channel| channel.id.clone()) - }) - .or_else(|| panel.channels.first().map(|channel| channel.id.clone())); - self.selected_channel_id = selected.clone(); - if self.pending_channel_delete.as_ref().is_some_and(|id| { - !panel.channels.iter().any(|channel| &channel.id == id) - }) { - self.pending_channel_delete = None; - } - self.providers = ProvidersState::Loaded(panel.clone()); - self.provider_models.clear(); - if let Some(channel_id) = selected { - // A refresh (enable toggle, save, validation) must not - // throw away the editor draft the user is editing: only - // a different channel re-reads the descriptor. - let already_loaded = matches!( - &self.provider_editor, - ProviderEditorState::Loaded(editor) - if editor.channel.id == channel_id - ); - if !already_loaded { - if let Some((channel, descriptor)) = - provider_channel_descriptor(&panel, &channel_id) + } + UiResult::ProviderSaved { editor, result } => { + self.provider_save_pending = false; + match result { + Err(error) => { + self.provider_save_again = false; + self.provider_close_pending = false; + self.provider_after_save = None; + self.frontend_vm.settings_notice = Some(error.clone()); + self.status = error; + } + Ok(()) => { + let mut changed = false; + if let Some(form) = + self.provider_editor_form.as_mut().filter(|form| { + form.channel_id == editor.channel.id + && form.kind == editor.kind + && form.provider_type + == editor.descriptor.provider_type.as_str() + }) + { + changed = form.name != editor.name + || form.endpoint != editor.endpoint + || form.model != editor.model + || form.resource_id != editor.resource_id + || form.auth_mode != editor.auth_mode + || form.protocol.values != editor.protocol.values + || form.protocol.temperature != editor.protocol.temperature + || (!form.primary_secret.is_empty() + && form.primary_secret != editor.primary_secret) + || (!form.secondary_secret.is_empty() + && form.secondary_secret != editor.secondary_secret) + || (!form.protocol.extra_headers.is_empty() + && form.protocol.extra_headers + != editor.protocol.extra_headers); + // 保存成功后**不要**清空密钥草稿。 + // + // 这里原本会「擦掉已被确认的草稿」(`form.secret == + // editor.secret` 时 clear),当时密钥从不回填,所以看不 + // 出问题。现在编辑器会像 Tauri 的 `CredentialField` 一样 + // 把已存的密钥读进输入框再打码:自动保存一次之后草稿被清 + // 空,输入框就变空白、连掩码点都没有——用户看到的就是 + // 「填进去的密钥几秒后被删掉了」。 + // 密钥仍然只存在本地表单里,写盘路径不变。 + } + self.frontend_vm.settings_notice = None; + self.status = tr_l10n(self.lang, "status.channel_saved").to_owned(); + self.load_settings_channels(); + self.load_service_configured(); + let again = std::mem::take(&mut self.provider_save_again); + if again + || ((self.provider_close_pending + || self.provider_after_save.is_some()) + && changed) + { + if !self.queue_provider_save() { + self.provider_close_pending = false; + self.provider_after_save = None; + } + } else if let Some(action) = self.provider_after_save.take() { + self.close_provider_editor(); + self.pending_ui_actions.push(action); + } else if self.provider_close_pending { + self.close_provider_editor(); + } else if let Some(form) = + self.provider_editor_form.as_mut().filter(|form| { + form.provider_type == "orcarouter" + && !form.models_loading + && form.catalog_format != Some(form.protocol.format()) + }) { - self.provider_editor = ProviderEditorState::Loading; - self.provider_editor_form = None; - self.load_provider_editor(panel.kind, channel, descriptor); + form.models_loading = true; + let kind = form.kind; + let channel_id = form.channel_id.clone(); + self.request_provider_models(kind, channel_id); } } - } else { - self.provider_editor = ProviderEditorState::Idle; } } - UiResult::Providers(Err(error)) => { - self.providers = ProvidersState::Failed; - self.status = error; + UiResult::ProviderSecretsCleared(result) => { + self.status = match result { + Ok(()) => tr_l10n(self.lang, "status.secret_cleared").to_owned(), + Err(error) => error, + }; + self.load_service_configured(); } UiResult::ProviderEditor { kind, @@ -3530,6 +4876,7 @@ mod linux_app { } => { if kind != self.provider_kind || self.selected_channel_id.as_deref() != Some(channel_id.as_str()) + || !matches!(self.provider_editor, ProviderEditorState::Loading) { continue; } @@ -3559,6 +4906,7 @@ mod linux_app { Err(error) => { self.provider_editor = ProviderEditorState::Failed; self.provider_editor_form = None; + self.frontend_vm.settings_notice = Some(error.clone()); self.status = error; } } @@ -3566,10 +4914,18 @@ mod linux_app { UiResult::ProviderModels { kind, channel_id, + endpoint, + provider_type, + request_format, result, } => { if kind == self.provider_kind && self.selected_channel_id.as_deref() == Some(channel_id.as_str()) + && self.provider_editor_form.as_ref().is_some_and(|form| { + form.endpoint == endpoint + && form.provider_type == provider_type + && form.protocol.format() == request_format + }) { match result { Ok(models) => { @@ -3586,8 +4942,10 @@ mod linux_app { }; if let Some(form) = self.provider_editor_form.as_mut() { form.models = models.clone(); + form.catalog_format = Some(request_format); form.models_loading = false; } + self.frontend_vm.settings_notice = None; self.provider_models = models; } Err(error) => { @@ -3595,6 +4953,7 @@ mod linux_app { form.models.clear(); form.models_loading = false; } + self.frontend_vm.settings_notice = Some(error.clone()); self.status = error; } } @@ -3743,29 +5102,178 @@ mod linux_app { } Err(error) => self.frontend_vm.marketplace_notice = Some(error), }, - UiResult::SettingsChannels(Ok(rows)) => { - self.settings_channels = rows; + UiResult::SettingsChannels { + kind, + request, + result, + } => { + if kind != self.settings_channel_kind + || request != self.settings_channels_request + { + continue; + } self.settings_channels_loading = false; + match result { + Ok(rows) => { + self.settings_channels = rows; + self.frontend_vm.settings_notice = None; + } + Err(error) => self.frontend_vm.settings_notice = Some(error), + } + } + UiResult::CloudSyncDone { result } => { + self.apply_cloud_sync_result(*result); + } + UiResult::OmniFinished { + operation, + submitted, + result, + } => { + use openless_linux_egui::omni_settings::OmniAction; + match *result { + Ok(state) => { + let mut state = + self.frontend_vm.omni.merge_receipt(&submitted, state); + let save_again = state.dirty; + state.notice = if save_again + || matches!(operation, OmniAction::Reload) + { + None + } else { + Some(match operation { + OmniAction::Validate => tr_l10n( + self.lang, + "settings.providers.validateSuccessOmni", + ) + .to_owned(), + OmniAction::Models if state.models.is_empty() => { + tr_l10n(self.lang, "settings.providers.modelsEmpty") + .to_owned() + } + OmniAction::Models => fmt_l10n( + self.lang, + "settings.providers.modelsLoaded", + &[&state.models.len().to_string()], + ), + OmniAction::ClearSecrets => { + tr_l10n(self.lang, "status.secret_cleared").to_owned() + } + _ => tr_l10n(self.lang, "common.saved").to_owned(), + }) + }; + self.frontend_vm.omni = state; + if let Some(notice) = &self.frontend_vm.omni.notice { + self.status = notice.clone(); + } + if save_again { + self.apply_omni_action(OmniAction::Save); + } + } + Err(error) => { + self.frontend_vm.omni.busy = false; + self.frontend_vm.omni.switching = false; + let message = if error == "validateFailed" { + tr_l10n(self.lang, "settings.providers.validateFailed") + .to_owned() + } else { + format!( + "{}: {error}", + tr_l10n(self.lang, "common.operationFailed") + ) + }; + self.frontend_vm.omni.notice = Some(message.clone()); + self.status = message; + } + } } 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(); + UiResult::ChannelsChanged { kind, result } => { + match result { + Ok(message) => { + self.frontend_vm.settings_notice = None; + self.status = message; + } + Err(error) => self.frontend_vm.settings_notice = Some(error), + } + if kind == self.settings_channel_kind { self.load_settings_channels(); self.load_service_configured(); self.load_providers(kind); } - Err(error) => self.frontend_vm.settings_notice = Some(error), - }, + } + UiResult::ProviderTypeChanged { + kind, + channel_id, + result, + } => { + self.provider_switch_pending = false; + let selected = self + .provider_editor_form + .as_ref() + .is_some_and(|form| form.kind == kind && form.channel_id == channel_id); + match *result { + Ok(editor) => { + self.frontend_vm.settings_notice = None; + if selected { + self.provider_editor_form = + Some(ProviderEditorForm::from_editor(&editor, self.lang)); + self.provider_editor = + ProviderEditorState::Loaded(Box::new(editor)); + 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); + } + } + self.load_settings_channels(); + self.load_service_configured(); + self.load_providers(self.provider_kind); + if let Some(action) = self.provider_after_save.take() { + self.close_provider_editor(); + self.pending_ui_actions.push(action); + } + } + Err(error) => { + self.provider_after_save = None; + self.frontend_vm.settings_notice = Some(error); + } + } + } + UiResult::ChannelCreated { kind, result } => { + self.channel_create_pending = false; + match result { + Ok(channel_id) => { + if kind != self.settings_channel_kind + || !self.frontend_vm.settings_open + || !matches!(self.frontend_vm.services_view, 0 | 1) + { + if kind == self.settings_channel_kind { + self.load_settings_channels(); + } + continue; + } + self.settings_channel_kind = kind; + self.provider_kind = kind; + // 草稿:面板加载完就自动打开编辑器,未交互关闭时回收。 + self.close_provider_editor(); + self.selected_channel_id = Some(channel_id); + self.editor_draft = true; + self.draft_touched = false; + self.editor_requested = true; + self.load_settings_channels(); + self.load_service_configured(); + self.load_providers(kind); + } + Err(error) => self.frontend_vm.settings_notice = Some(error), + } + } UiResult::MarketplaceDetail(Ok(detail)) => { self.status = fmt_l10n(lang, "status.detail_loaded", &[&detail.summary.name]); @@ -3890,17 +5398,57 @@ mod linux_app { }); } + fn refresh_linux_access(&mut self) { + if self.frontend_vm.linux_access.checking { + return; + } + self.frontend_vm.linux_access.checking = true; + let tx = self.tx.clone(); + self.tokio.spawn(async move { + use openless_linux_egui::linux_access::{ + self, CheckState, KeyringState, LinuxAccessSnapshot, + }; + let session = tokio::task::spawn_blocking(linux_access::probe_session); + let (session, (network, network_latency_ms)) = + tokio::join!(session, linux_access::probe_network(),); + let (keyring, input) = + session.unwrap_or((KeyringState::Unavailable, CheckState::Unavailable)); + let _ = tx.send(UiResult::LinuxAccess(LinuxAccessSnapshot { + checking: false, + keyring, + input, + network, + network_latency_ms, + })); + }); + } + // ── 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) { + // Only one diagnostic job can run, and only refresh while the + // privacy page is visible. Match Tauri's 30-second network cadence. + if self.frontend_vm.settings_open + && self.frontend_vm.settings_section + == frontend::view_model::SettingsSection::Privacy + && self + .linux_access_last_check + .is_none_or(|checked| checked.elapsed() >= Duration::from_secs(30)) + { + self.refresh_linux_access(); + } 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(); + self.frontend_vm.application_data_directory = backend + .as_ref() + .map(|backend| backend.config().data_dir.display().to_string()) + .unwrap_or_default(); let lang = self.lang; let permissions = self.permission_snapshot(); // Style-pack icons must be read before `vm` borrows `frontend_vm`. @@ -3948,6 +5496,7 @@ mod linux_app { }; vm.status = self.status.clone(); + vm.runtime_warning = self.runtime_warning.clone(); vm.quick_note_recording = backend.as_ref().is_some_and(|backend| { backend.dictation_output_target() == Some(openless_core::DictationOutputTarget::QuickNote) @@ -4094,6 +5643,9 @@ mod linux_app { s.microphone_error = self.microphone_error.clone(); // 胶囊开关与样式(Tauri `showCapsule` / `capsuleStyle`)。 s.show_capsule = prefs.show_capsule; + s.capsule_transcript = prefs.capsule_transcript_enabled; + s.capsule_transcript_font_size = + usize::from(prefs.capsule_transcript_font_size.clamp(12, 20)); s.capsule_style = match prefs.capsule_style { openless_core::shared_types::CapsuleStyle::Classic => 1, openless_core::shared_types::CapsuleStyle::Typeless => 2, @@ -4120,6 +5672,7 @@ mod linux_app { } s.remote_default_mode = usize::from(prefs.remote_input_default_mode == "hold"); s.system_proxy = prefs.use_system_proxy; + s.llm_thinking_enabled = prefs.llm_thinking_enabled; s.multimodal = prefs.multimodal_pipeline_enabled; s.less_computer = prefs.coding_agent_enabled; s.testing_features = prefs.testing_features_enabled; @@ -4144,6 +5697,21 @@ mod linux_app { openless_core::shared_types::SelectionPolishOutputMode::DirectReplace => 0, openless_core::shared_types::SelectionPolishOutputMode::PreviewConfirm => 1, }; + // 选区助手(Tauri SelectionWorkspaceSection) + s.selection_voice_enabled = prefs.selection_voice_enabled; + s.selection_voice_auto_intent = matches!( + prefs.selection_voice_intent_mode, + openless_core::shared_types::SelectionVoiceIntentMode::Auto + ); + s.selection_voice_plan_format = match prefs.selection_voice_edit_plan_format { + openless_core::edit_plan::EditPlanFormat::Json => 1, + openless_core::edit_plan::EditPlanFormat::Xml => 0, + }; + if hydrate_text { + s.selection_voice_keywords = prefs.selection_voice_edit_keywords.join("\n"); + s.selection_voice_system_prompt = + prefs.selection_voice_edit_system_prompt.clone(); + } // 多模态 / 平台能力:决定 AI 服务页的视图与更新控件。 // 远程输入的实时状态:配对码 / 访问网址 / 证书指纹。 if let Some((status, pin)) = &self.remote_access { @@ -4162,8 +5730,8 @@ mod linux_app { // 必配服务的状态点由 `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; + vm.pipeline_multimodal = prefs.multimodal_pipeline_enabled + && prefs.pipeline_mode == openless_core::shared_types::PipelineMode::Multimodal; // Linux 宿主没有本地推理引擎。 vm.supports_local_asr = false; // 热键后端是否真的起来了:没有 fcitx5 监听器时隐藏「快捷键」分区, @@ -4217,7 +5785,7 @@ mod linux_app { ), }) .collect(); - vm.channels_loading = self.settings_channels_loading; + vm.channels_loading = self.settings_channels_loading || self.channel_create_pending; let active_channel = self .settings_channels .iter() @@ -4228,6 +5796,7 @@ mod linux_app { .iter() .enumerate() .map(|(index, channel)| frontend::view_model::SettingsChannel { + id: channel.id.clone(), name: channel.name.clone(), provider: localized_provider_label( lang, @@ -4238,26 +5807,16 @@ mod linux_app { model: channel.model.clone(), is_active: index == active_channel, enabled: channel.enabled, - last_check: match ( - channel.last_ok, - channel.last_latency_ms, - channel.last_error.as_deref(), - ) { - (Some(true), Some(ms), _) => Some(format!( - "{} · {}", - tr_l10n(lang, "settings.channels.passed"), - fmt_l10n(lang, "settings.channels.elapsed", &[&ms]), - )), - (Some(true), None, _) => { - Some(tr_l10n(lang, "settings.channels.passed").to_string()) - } - (Some(false), _, error) => Some(fmt_l10n( - lang, - "settings.channels.failed", - &[&error.unwrap_or_default()], - )), - _ => None, - }, + last_ok: channel.last_ok, + last_error: channel.last_error.clone(), + last_latency_ms: channel.last_latency_ms, + last_check_age_seconds: channel.last_test_at.map(|at| { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_secs() as i64) + .unwrap_or(at); + (now - at).max(0) + }), }) .collect(); @@ -4266,8 +5825,27 @@ mod linux_app { vm.provider_editor = self.provider_editor_form.as_ref().map(|form| { frontend::view_model::SettingsProviderEditor { channel_id: form.channel_id.clone(), + is_asr: matches!(form.kind, openless_core::ChannelKind::Asr), + is_draft: self.editor_draft, provider: form.label.clone(), provider_type: form.provider_type.clone(), + endpoint_presets: match &self.provider_editor { + ProviderEditorState::Loaded(editor) if form.provider_type == "ark" => { + let mut presets = editor.descriptor.endpoint_presets.clone(); + if let Some(endpoint) = &editor.descriptor.default_endpoint { + presets.insert(0, openless_core::provider_rules::ProviderEndpointPreset { + name: form.label.clone(), endpoint: endpoint.clone(), models_url: None, + }); + } + presets + } + _ => Vec::new(), + }, + default_endpoint: match &self.provider_editor { + ProviderEditorState::Loaded(editor) => editor.descriptor.default_endpoint.clone().unwrap_or_default(), + _ => String::new(), + }, + api_key_optional: matches!(&self.provider_editor, ProviderEditorState::Loaded(editor) if editor.descriptor.auth_requirement == openless_core::AuthRequirement::EndpointModelOptionalApiKey), name: form.name.clone(), endpoint: form.endpoint.clone(), model: form.model.clone(), @@ -4282,7 +5860,9 @@ mod linux_app { 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), + protocol: form.protocol.clone(), + busy: self.provider_switch_pending + || matches!(self.provider_editor, ProviderEditorState::Loading), } }); @@ -4291,57 +5871,28 @@ mod linux_app { .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() - } - }) - .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, - } - }) + // 历史按页下发:快照只带第一页,其余由窗口的 `LoadMore` 拉取 + // (见 `frontend::paging`),所以快照不会随历史增长而膨胀。 + vm.history_list_total = self.history.entries.len(); + vm.history_entries = self + .history + .entries + .iter() + .take(frontend::paging::page_limit()) + .cloned() + .map(|item| history_entry_view(item, &self.style_packs, lang)) .collect(); vm.history_loading = self.history.loading(); vm.history_error = self.history.error.clone(); - vm.history_selected = selected_id + // 分页之后宿主手里只有第一页:选中项在页内就按 id 重新定位; + // 不在页内(用户已经加载了更后面的页)就保持原下标不动 —— 那是窗口 + // 自己那份列表的下标,宿主无权按页去改,否则会把选中项弄丢。 + if let Some(index) = selected_id + .as_deref() .and_then(|id| vm.history_entries.iter().position(|entry| entry.id == id)) - .unwrap_or(0); + { + vm.history_selected = index; + } // In-app playback progress (dropped once the clip finishes). if self @@ -4363,25 +5914,19 @@ mod linux_app { // 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_total = self.vocabulary.len(); 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, - }) + .take(frontend::paging::page_limit()) + .map(vocab_entry_view) .collect(); + vm.correction_rule_total = self.correction_rules.len(); 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, - }) + .take(frontend::paging::page_limit()) + .map(correction_rule_view) .collect(); vm.vocab_saved_presets = self .vocab_presets @@ -4393,33 +5938,26 @@ mod linux_app { .collect(); // Style packs: wired too; an empty list is a valid state. + // 图标是 base64 data URL,最占体积,同样按页下发。 vm.style_unsupported = false; + let selection_polish_style_pack_id = self + .preferences + .as_ref() + .map(|prefs| prefs.selection_polish_style_pack_id.as_str()) + .filter(|id| !id.is_empty()); + vm.style_pack_total = self.style_packs.len(); vm.style_packs = self .style_packs .iter() + .take(frontend::paging::page_limit()) .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), + .map(|(index, pack)| { + style_pack_view( + pack, + style_icon_urls.get(index).and_then(|url| url.as_deref()), + selection_polish_style_pack_id, + lang, + ) }) .collect(); @@ -4433,21 +5971,13 @@ mod linux_app { vm.marketplace_loading = !self.marketplace_attempted; if !self.marketplace_items.is_empty() { vm.marketplace_loading = false; + vm.marketplace_total = self.marketplace_items.len(); 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), + .take(frontend::paging::page_limit()) + .map(|item| { + marketplace_pack_view(item, self.marketplace_my_likes.contains(&item.id)) }) .collect(); } @@ -4456,6 +5986,23 @@ mod linux_app { if let Some(error) = &self.startup_error { vm.status = format!("启动失败: {error}"); } + if self.settings_channels_refresh_requested && !self.settings_channels_loading { + self.settings_channels_refresh_requested = false; + self.load_settings_channels(); + self.load_service_configured(); + } + if self.frontend_vm.settings_section == frontend::view_model::SettingsSection::Services + { + let view = self.resolved_services_view(self.frontend_vm.services_view); + if view != self.frontend_vm.services_view + || (view == 4 + && !self.frontend_vm.omni.loaded + && !self.frontend_vm.omni.busy + && self.frontend_vm.omni.notice.is_none()) + { + self.select_services_view(view); + } + } } /// Returns the overview error string if the overview is in a failed state. @@ -4466,9 +6013,9 @@ mod linux_app { } } - /// Apply a settings toggle from the frontend to the live preferences. - /// 隐私分区的真实状态:Linux 没有系统级授权弹窗,能列出的设备 / 已启动的 - /// 热键适配器就是「已授权」,macOS 才有的辅助功能 / 本地网络一律「不适用」。 + /// Capture Linux capability hints, not authorization grants. Device + /// enumeration does not prove capture access, and a native runtime alone + /// does not imply that its optional global-hotkey listener initialized. fn permission_snapshot(&self) -> frontend::view_model::SettingsPermissions { use frontend::view_model::PermissionState; frontend::view_model::SettingsPermissions { @@ -4479,7 +6026,11 @@ mod linux_app { }, accessibility: PermissionState::Unsupported, network: PermissionState::Unsupported, - hotkey: if self.native.is_some() { + hotkey: if self + .native + .as_ref() + .is_some_and(LinuxNativeRuntime::hotkeys_available) + { PermissionState::Granted } else { PermissionState::Unknown @@ -4488,16 +6039,132 @@ mod linux_app { } /// Dispatch frontend actions to existing Core / backend methods. + /// 切一页给窗口。顺序必须与快照第一页完全一致(都是宿主存储的前缀), + /// 否则窗口按 `offset` 拼接会把两段不同的数据接在一起。 + fn collection_page( + &mut self, + collection: frontend::paging::Collection, + offset: usize, + ) -> (usize, frontend::paging::CollectionItems) { + use frontend::paging::{slice_page, Collection, CollectionItems}; + let lang = self.lang; + let limit = frontend::paging::page_limit(); + match collection { + Collection::History => { + let items = slice_page(&self.history.entries, offset, limit) + .into_iter() + .map(|item| history_entry_view(item, &self.style_packs, lang)) + .collect(); + (self.history.entries.len(), CollectionItems::History(items)) + } + Collection::Vocabulary => { + let items = slice_page(&self.vocabulary, offset, limit) + .iter() + .map(vocab_entry_view) + .collect(); + (self.vocabulary.len(), CollectionItems::Vocabulary(items)) + } + Collection::CorrectionRules => { + let items = slice_page(&self.correction_rules, offset, limit) + .iter() + .map(correction_rule_view) + .collect(); + ( + self.correction_rules.len(), + CollectionItems::CorrectionRules(items), + ) + } + Collection::Marketplace => { + let items = slice_page(&self.marketplace_items, offset, limit) + .iter() + .map(|item| { + marketplace_pack_view( + item, + self.marketplace_my_likes.contains(&item.id), + ) + }) + .collect(); + ( + self.marketplace_items.len(), + CollectionItems::Marketplace(items), + ) + } + Collection::StylePacks => { + // 图标 data URL 由 `style_icon_urls` 缓存,按 index 取。 + let icons = self.style_icon_urls(); + let selection = self + .preferences + .as_ref() + .map(|prefs| prefs.selection_polish_style_pack_id.clone()) + .filter(|id| !id.is_empty()); + let items = self + .style_packs + .iter() + .enumerate() + .skip(offset) + .take(limit) + .map(|(index, pack)| { + style_pack_view( + pack, + icons.get(index).and_then(|url| url.as_deref()), + selection.as_deref(), + lang, + ) + }) + .collect(); + (self.style_packs.len(), CollectionItems::StylePacks(items)) + } + } + } + fn apply_frontend_actions( &mut self, actions: Vec, // 宿主没有窗口:窗口类动作由 UI 进程就地处理,这里保留参数是为了 - // 让调用点保持「渲染层 → 动作 → 宿主」的形状。 - _ctx: &egui::Context, + // 让调用点保持「渲染层 → 动作 → 宿主」的形状。排版改动需要它重装字体。 + ctx_arg: &egui::Context, ) { for action in actions { + use frontend::view_model::FrontendAction as Action; + let leaving_editor = match &action { + Action::CloseSettings + | Action::Navigate(_) + | Action::SettingsChannelSelect(_) => true, + Action::ToggleSettings => self.frontend_vm.settings_open, + Action::SettingsServicesView(view) => *view != self.frontend_vm.services_view, + Action::SettingsSection(section) => { + *section != frontend::view_model::SettingsSection::Services + } + _ => false, + }; + if leaving_editor && self.provider_editor_form.is_some() { + if self.provider_switch_pending { + self.provider_after_save = Some(action); + continue; + } + if self + .provider_editor_form + .as_ref() + .is_some_and(|form| form.protocol.validate().is_err()) + { + continue; + } + if self.draft_touched { + self.provider_after_save = Some(action); + if !self.queue_provider_save() { + self.provider_after_save = None; + } + continue; + } + self.request_provider_editor_close(); + } match action { frontend::view_model::FrontendAction::Navigate(page) => { + if self.frontend_vm.omni.dirty { + self.apply_omni_action( + openless_linux_egui::omni_settings::OmniAction::Save, + ); + } self.active_page = match page { frontend::view_model::Page::Overview => shell::Page::Overview, frontend::view_model::Page::History => shell::Page::History, @@ -4517,10 +6184,16 @@ mod linux_app { } } frontend::view_model::FrontendAction::ToggleSettings => { + if self.frontend_vm.omni.dirty { + self.apply_omni_action( + openless_linux_egui::omni_settings::OmniAction::Save, + ); + } 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.linux_access_last_check = None; self.load_service_configured(); self.provider_kind = self.settings_channel_kind; self.load_providers(self.settings_channel_kind); @@ -4530,12 +6203,21 @@ mod linux_app { if self.settings_channels.is_empty() { self.load_settings_channels(); } + self.load_cloud_sync(); } } frontend::view_model::FrontendAction::CloseSettings => { + if self.frontend_vm.omni.dirty { + self.apply_omni_action( + openless_linux_egui::omni_settings::OmniAction::Save, + ); + } self.frontend_vm.settings_open = false; self.frontend_vm.active_page = frontend::view_model::Page::Overview; } + frontend::view_model::FrontendAction::DismissRuntimeWarning => { + self.runtime_warning = None; + } frontend::view_model::FrontendAction::SidebarToggleStyle => { self.frontend_vm.style_open = !self.frontend_vm.style_open; } @@ -4897,6 +6579,19 @@ mod linux_app { self.load_history(); self.frontend_vm.history_confirm = None; } + frontend::view_model::FrontendAction::LoadMore { collection, offset } => { + let (total, items) = self.collection_page(collection, offset); + self.pending_collection_pages + .push(HostToWindow::CollectionPage { + // 回显请求偏移:同一偏移重复请求幂等;窗口据此 + // 丢掉晚到的旧回包(见 `apply_page`)。 + request_id: offset as u64, + collection, + offset, + total, + items: Box::new(items), + }); + } frontend::view_model::FrontendAction::HistorySelect(index) => { self.frontend_vm.history_selected = index; } @@ -4934,8 +6629,8 @@ mod linux_app { None => {} } } - frontend::view_model::FrontendAction::HistoryPlay(index) => { - let Some(entry) = self.frontend_vm.history_entries.get(index) else { + frontend::view_model::FrontendAction::HistoryPlay(id) => { + let Some(entry) = self.history.entry(&id) else { return; }; let id = entry.id.clone(); @@ -4979,9 +6674,9 @@ mod linux_app { frontend::view_model::FrontendAction::HistoryRepolishClose => { self.frontend_vm.history_repolish_open = false; } - frontend::view_model::FrontendAction::HistoryRepolish(index, pack_index) => { + frontend::view_model::FrontendAction::HistoryRepolish(id, pack_index) => { if let (Some(backend), Some(entry)) = - (self.backend(), self.frontend_vm.history_entries.get(index)) + (self.backend(), self.history.entry(&id)) { if !entry.raw_transcript.trim().is_empty() { let request = openless_core::auxiliary::RepolishRequest { @@ -5007,9 +6702,9 @@ mod linux_app { } } } - frontend::view_model::FrontendAction::HistoryRetranscribe(index) => { + frontend::view_model::FrontendAction::HistoryRetranscribe(id) => { if let Some(backend) = self.backend() { - if let Some(entry) = self.frontend_vm.history_entries.get(index) { + if let Some(entry) = self.history.entry(&id) { let id = entry.id.clone(); let data_dir = backend.config().data_dir.clone(); let lang = self.lang; @@ -5060,9 +6755,9 @@ mod linux_app { } } } - frontend::view_model::FrontendAction::HistoryExport(index) => { + frontend::view_model::FrontendAction::HistoryExport(id) => { if let Some(backend) = self.backend() { - if let Some(entry) = self.frontend_vm.history_entries.get(index) { + if let Some(entry) = self.history.entry(&id) { let id = entry.id.clone(); let data_dir = backend.config().data_dir.clone(); let lang = self.lang; @@ -5604,7 +7299,22 @@ mod linux_app { self.apply_settings_toggle(field); } frontend::view_model::FrontendAction::SettingsCombo(field, index) => { + if matches!( + field, + frontend::view_model::SettingsComboField::PipelineMode + ) && self.frontend_vm.omni.dirty + { + self.apply_omni_action( + openless_linux_egui::omni_settings::OmniAction::Save, + ); + } self.apply_settings_combo(field, index); + if matches!( + field, + frontend::view_model::SettingsComboField::PipelineMode + ) { + self.select_services_view(self.frontend_vm.services_view); + } } frontend::view_model::FrontendAction::SettingsText(field, text) => { self.apply_settings_text(field, text); @@ -5613,80 +7323,88 @@ mod linux_app { self.apply_settings_action(field); } frontend::view_model::FrontendAction::SettingsSection(section) => { + // 编辑器只属于「AI 服务与模型」这一页:切到别的分区就收起 + // (Tauri 的 modal 宿主挂在服务页里,切页即卸载)。 + if section != frontend::view_model::SettingsSection::Services + && self.provider_editor_form.is_some() + { + self.request_provider_editor_close(); + } + if self.frontend_vm.omni.dirty { + self.apply_omni_action( + openless_linux_egui::omni_settings::OmniAction::Save, + ); + } self.frontend_vm.settings_section = section; + if section == frontend::view_model::SettingsSection::Privacy { + self.refresh_linux_access(); + // 云端同步卡片在这一页:切进来就读一次真实状态, + // 不要让卡片停在「加载中」。 + self.load_cloud_sync(); + } + if section == frontend::view_model::SettingsSection::Services { + self.select_services_view(self.frontend_vm.services_view); + } } 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); + if self.frontend_vm.omni.dirty { + self.apply_omni_action( + openless_linux_egui::omni_settings::OmniAction::Save, + ); } + self.select_services_view(view); } - 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::SettingsOmni(action) => { + self.apply_omni_action(action); } - frontend::view_model::FrontendAction::SettingsChannelProvider(index) => { - self.frontend_vm.channel_provider_index = index; + frontend::view_model::FrontendAction::SettingsCloudSync(action) => { + self.apply_cloud_sync_action(action); } - frontend::view_model::FrontendAction::SettingsChannelName(name) => { - self.frontend_vm.channel_form_name = name; + frontend::view_model::FrontendAction::SettingsTypography(field) => { + // 排版是纯 UI 偏好:落盘后立刻重装字体并应用缩放, + // 不需要经过 Core。 + let mut typography = self.frontend_vm.typography; + typography.apply(field); + self.frontend_vm.typography = typography; + if openless_linux_egui::typography::save(typography).is_err() { + self.status = + tr_l10n(self.lang, "uiRefinement.storageError").to_owned(); + } + ctx_arg.request_repaint(); } - frontend::view_model::FrontendAction::SettingsChannelCreate => { + // Tauri `startCreate`:点「添加渠道」先在后台建一张空渠道(名字留空), + // 再打开同一个编辑器——草稿卡片只是为了让凭据有渠道 id 可落盘; + // 用户完全没交互就关掉时会被回收(见 `SettingsProviderClose`)。 + frontend::view_model::FrontendAction::SettingsChannelDraft => { + if self.channel_create_pending || self.provider_switch_pending { + continue; + } let kind = self.settings_channel_kind; + // 供应商面板还没加载完时也照 Tauri 的 `presets[0]?.id ?? ''` 建 + // 一张空供应商的草稿:弹窗里可以再选。 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) - { + .first() + .map(|provider| provider.provider_type.clone()) + .unwrap_or_default(); + if let Some(backend) = self.backend() { + self.channel_create_pending = true; let tx = self.tx.clone(); + let wake = std::thread::current(); self.tokio.spawn(async move { let result = backend - .create_channel(kind, provider_type, name) + .create_channel(kind, provider_type, String::new()) .await .map_err(|error| error.to_string()); let _ = tx.send(UiResult::ChannelCreated { kind, result }); + wake.unpark(); }); - 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( @@ -5699,6 +7417,9 @@ mod linux_app { frontend::view_model::FrontendAction::ShortcutDisable(field) => { self.apply_shortcut_disable(field); } + frontend::view_model::FrontendAction::ShortcutReset(field) => { + self.apply_shortcut_reset(field); + } frontend::view_model::FrontendAction::StyleHotkeyDraft(open) => { self.frontend_vm.style_hotkey_draft_open = open; if open { @@ -5729,51 +7450,74 @@ mod linux_app { self.apply_style_hotkey_repack(index, pack_index); } frontend::view_model::FrontendAction::SettingsChannelSelect(index) => { + self.editor_draft = false; + self.draft_touched = false; self.open_provider_editor(index); } - frontend::view_model::FrontendAction::SettingsChannelMove { index, delta } => { + frontend::view_model::FrontendAction::SettingsChannelMove { + channel_id, + neighbor_id, + 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 frontend::view_model::move_channel( + &mut ids, + &channel_id, + &neighbor_id, + delta, + ) { if let Some(backend) = self.backend() { let lang = self.lang; - self.spawn(async move { + self.spawn_channel_change(kind, 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, + channel_id: id, 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()) + if let Some(backend) = self.backend() { + if self.provider_switch_pending { + continue; + } + let Some(editor) = self + .editor_from_form() + .filter(|editor| editor.channel.id == id) + else { + continue; + }; + if editor.protocol.validate().is_err() { + continue; + } + self.draft_touched = true; + self.provider_switch_pending = true; + let tx = self.tx.clone(); + let wake = std::thread::current(); + let job = Box::pin(async move { + let result = switch_provider_editor(backend, editor, provider_type) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::ProviderTypeChanged { + kind, + channel_id: id, + result: Box::new(result), + }); + wake.unpark(); }); - // 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); + if self.provider_jobs.send(job).is_err() { + self.provider_switch_pending = false; + self.frontend_vm.settings_notice = + Some(tr_l10n(self.lang, "common.operationFailed").to_owned()); + } } } frontend::view_model::FrontendAction::SettingsChannelActivate(index) => { @@ -5784,21 +7528,33 @@ mod linux_app { .map(|channel| channel.id.clone()); if let (Some(backend), Some(id)) = (self.backend(), id) { let lang = self.lang; - self.spawn(async move { + self.spawn_channel_change(kind, async move { backend.set_active_provider(provider_slot(kind), id).await?; Ok(tr_l10n(lang, "status.channel_active").to_string()) }); - self.load_settings_channels(); - self.load_service_configured(); } } frontend::view_model::FrontendAction::SettingsProviderField(field, value) => { + if self.provider_switch_pending { + continue; + } + self.draft_touched = true; 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 => { + if form.endpoint != value { + form.models.clear(); + form.models_loading = false; + if let ProviderEditorState::Loaded(editor) = + &self.provider_editor + { + form.models_url = + provider_models_url(&editor.descriptor, &value); + } + } form.endpoint = value } frontend::view_model::SettingsProviderField::Model => { @@ -5816,45 +7572,72 @@ mod linux_app { frontend::view_model::SettingsProviderField::SecondarySecret => { form.secondary_secret = value } + frontend::view_model::SettingsProviderField::RequestFormat => { + if form.protocol.values[0] != value { + form.models.clear(); + form.models_loading = false; + form.catalog_format = None; + } + form.protocol.values[0] = value + } + frontend::view_model::SettingsProviderField::MessagesThinking => { + form.protocol.values[1] = value + } + frontend::view_model::SettingsProviderField::MaxTokens => { + form.protocol.values[2] = value + } + frontend::view_model::SettingsProviderField::ThinkingBudget => { + form.protocol.values[3] = value + } + frontend::view_model::SettingsProviderField::ServiceTier => { + form.protocol.values[4] = value + } + frontend::view_model::SettingsProviderField::Temperature => { + form.protocol.temperature = value + } + frontend::view_model::SettingsProviderField::ExtraHeaders => { + form.protocol.extra_headers = value + } } } } 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(); - } - } + self.queue_provider_save(); } frontend::view_model::FrontendAction::SettingsProviderClearSecrets => { + if self.provider_switch_pending { + continue; + } 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()) + let tx = self.tx.clone(); + let wake = std::thread::current(); + let job = Box::pin(async move { + let result = clear_provider_secrets(backend, &editor) + .await + .map_err(|error| error.to_string()); + let _ = tx.send(UiResult::ProviderSecretsCleared(result)); + wake.unpark(); }); + if self.provider_jobs.send(job).is_err() { + self.status = + tr_l10n(self.lang, "common.operationFailed").to_owned(); + continue; + } + self.provider_save_again = false; if let Some(form) = self.provider_editor_form.as_mut() { form.primary_secret.clear(); form.secondary_secret.clear(); + form.protocol.extra_headers.clear(); } self.load_service_configured(); } } } frontend::view_model::FrontendAction::SettingsProviderModels => { + if self.provider_switch_pending { + continue; + } let kind = self.settings_channel_kind; let channel_id = self .provider_editor_form @@ -5868,33 +7651,21 @@ mod linux_app { } } 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 self.provider_switch_pending { + continue; + } + self.draft_touched = true; if let Some(form) = self.provider_editor_form.as_mut() { - form.model = model.clone(); + form.model = model; form.custom_model = false; } - if let (Some(backend), Some(channel_id)) = (self.backend(), channel_id) { - let lang = self.lang; - self.spawn(async move { - write_or_remove_provider_value( - &backend, - kind, - &channel_id, - model_account(kind), - &model, - ) - .await?; - Ok(fmt_l10n(lang, "settings.providers.modelSaved", &[&model])) - }); - } + // Use the same FIFO and revision receipt as typed edits. + self.queue_provider_save(); } frontend::view_model::FrontendAction::SettingsProviderModelCustom(custom) => { + if self.provider_switch_pending { + continue; + } if let Some(form) = self.provider_editor_form.as_mut() { form.custom_model = custom; } @@ -5909,7 +7680,7 @@ mod linux_app { } } frontend::view_model::FrontendAction::SettingsProviderClose => { - self.close_provider_editor(); + self.request_provider_editor_close(); } frontend::view_model::FrontendAction::SettingsChannelToggle(index) => { let kind = self.settings_channel_kind; @@ -5919,12 +7690,10 @@ mod linux_app { .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 { + self.spawn_channel_change(kind, 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::SettingsChannelValidate(index) => { @@ -5935,11 +7704,15 @@ mod linux_app { .map(|channel| channel.id.clone()); if let (Some(backend), Some(id)) = (self.backend(), id) { let lang = self.lang; - self.spawn(async move { + let editor = self + .editor_from_form() + .filter(|editor| editor.kind == kind && editor.channel.id == id); + self.spawn_channel_change(kind, async move { + if let Some(editor) = editor { + save_provider_editor(backend.clone(), editor).await?; + } validate_provider_channel(lang, backend, kind, id).await }); - self.load_settings_channels(); - self.load_service_configured(); } } frontend::view_model::FrontendAction::SettingsChannelDelete(index) => { @@ -5950,12 +7723,11 @@ mod linux_app { .map(|channel| channel.id.clone()); if let (Some(backend), Some(id)) = (self.backend(), id) { let lang = self.lang; - self.spawn(async move { + self.close_provider_editor(); + self.spawn_channel_change(kind, 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) => { @@ -6049,6 +7821,122 @@ mod linux_app { } /// Core polish mode -> frontend display enum. + /// 历史记录 → 窗口展示条目。每帧只投影第一页,后续页按需投影 + /// (见 `frontend::paging`);两处共用这一份映射,字段与顺序不会走偏。 + fn history_entry_view( + item: openless_core::DictationSession, + style_packs: &[openless_core::StylePack], + lang: Lang, + ) -> frontend::view_model::HistoryEntry { + 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| 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() + } + }) + .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, + } + } + + fn vocab_entry_view( + entry: &openless_core::DictionaryEntry, + ) -> frontend::view_model::VocabEntry { + frontend::view_model::VocabEntry { + phrase: entry.phrase.clone(), + hits: entry.hits as usize, + enabled: entry.enabled, + learned: false, + } + } + + fn correction_rule_view( + rule: &openless_core::CorrectionRule, + ) -> frontend::view_model::CorrectionRule { + frontend::view_model::CorrectionRule { + pattern: rule.pattern.clone(), + replacement: rule.replacement.clone(), + enabled: rule.enabled, + learned: false, + } + } + + fn style_pack_view( + pack: &openless_core::StylePack, + icon_data_url: Option<&str>, + selection_polish_style_pack_id: Option<&str>, + lang: Lang, + ) -> frontend::view_model::StylePack { + frontend::view_model::StylePack { + id: pack.id.clone(), + icon_path: pack.icon_path.clone(), + icon_data_url: icon_data_url.map(str::to_string), + 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: selection_polish_style_pack_id == Some(pack.id.as_str()), + } + } + + fn marketplace_pack_view( + item: &openless_core::MarketplaceListItem, + liked: bool, + ) -> frontend::view_model::MarketplacePack { + 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, + } + } + fn overview_mode(mode: openless_core::PolishMode) -> frontend::view_model::OverviewMode { match mode { openless_core::PolishMode::Raw => frontend::view_model::OverviewMode::Raw, @@ -6068,6 +7956,20 @@ mod linux_app { } } + /// Omni 的「测试 / 拉模型」请求:**不能带 channel_id**。 + /// + /// Core 对 `kind == Omni && channel_id.is_some()` 直接报 + /// `omni provider does not support channel id`;带上频道 id 会让每一次测试都 + /// 失败,用户看到的就是「密钥填进去用不了」。Core 会自己按当前激活的服务商 + /// 解析凭据。 + fn omni_provider_request(thinking_enabled: bool) -> openless_core::ProviderRequest { + openless_core::ProviderRequest { + kind: openless_core::ProviderKind::Omni, + channel_id: None, + thinking_enabled, + } + } + fn provider_kind(kind: openless_core::ChannelKind) -> openless_core::ProviderKind { match kind { openless_core::ChannelKind::Asr => openless_core::ProviderKind::Asr, @@ -6168,7 +8070,7 @@ mod linux_app { Core::Volcengine => Ui::Volcengine, Core::Xfyun => Ui::Xfyun, Core::OAuth => Ui::OAuth, - Core::TencentCloud => Ui::Other, + Core::TencentCloud => Ui::TencentCloud, Core::ApiKey | Core::EndpointModelOptionalApiKey | Core::ApiKeyUnlessCustomEndpoint => { Ui::ApiKey } @@ -6275,6 +8177,9 @@ mod linux_app { backend: Arc, editor: ProviderEditor, ) -> Result<(), BackendError> { + if !editor.protocol.formats.is_empty() { + editor.protocol.validate()?; + } let channel_id = editor.channel.id.as_str(); backend .rename_channel(editor.kind, channel_id.to_string(), editor.name) @@ -6360,6 +8265,32 @@ mod linux_app { ) .await?; } + openless_core::AuthRequirement::TencentCloud => { + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, + &editor.resource_id, + ) + .await?; + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT, + &editor.primary_secret, + ) + .await?; + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + &editor.secondary_secret, + ) + .await?; + } _ => { write_or_remove_provider_value( &backend, @@ -6387,27 +8318,78 @@ mod linux_app { .await?; } } - Ok(()) - } - - /// Drop every credential of the selected descriptor shape for one channel. - async fn clear_provider_secrets( - backend: Arc, - editor: &ProviderEditor, - ) -> Result<(), BackendError> { - let accounts: &[&str] = match editor.descriptor.auth_requirement { - openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => &[], - openless_core::AuthRequirement::Volcengine => &[ - openless_core::credentials::VOLCENGINE_APP_KEY_ACCOUNT, - openless_core::credentials::VOLCENGINE_ACCESS_KEY_ACCOUNT, + if matches!( + editor.descriptor.provider_type.as_str(), + "custom" | "custom_responses" | "custom_messages" + ) { + write_or_remove_provider_value( + &backend, + editor.kind, + channel_id, + openless_core::credentials::LLM_TEMPERATURE_ACCOUNT, + &editor.protocol.temperature, + ) + .await?; + write_secret_if_entered( + &backend, + editor.kind, + channel_id, + openless_core::credentials::LLM_EXTRA_HEADERS_ACCOUNT, + &editor.protocol.extra_headers, + ) + .await?; + } + if !editor.protocol.formats.is_empty() { + for (account, value) in openless_core::llm_protocol::CONFIG_ACCOUNTS + .iter() + .zip(&editor.protocol.values) + { + if *account == openless_core::llm_protocol::SERVICE_TIER_ACCOUNT + && editor.descriptor.provider_type.as_str() != "ark" + { + continue; + } + write_or_remove_provider_value(&backend, editor.kind, channel_id, account, value) + .await?; + } + } + Ok(()) + } + + /// Drop every credential of the selected descriptor shape for one channel. + async fn clear_provider_secrets( + backend: Arc, + editor: &ProviderEditor, + ) -> Result<(), BackendError> { + let accounts: &[&str] = match editor.descriptor.auth_requirement { + openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => &[], + openless_core::AuthRequirement::Volcengine => &[ + openless_core::credentials::VOLCENGINE_APP_KEY_ACCOUNT, + openless_core::credentials::VOLCENGINE_ACCESS_KEY_ACCOUNT, openless_core::credentials::VOLCENGINE_API_KEY_ACCOUNT, ], openless_core::AuthRequirement::Xfyun => &[ openless_core::credentials::XFYUN_APP_ID_ACCOUNT, openless_core::credentials::XFYUN_API_KEY_ACCOUNT, ], + openless_core::AuthRequirement::TencentCloud => &[ + openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT, + openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT, + ], _ => &[api_key_account(editor.kind)], }; + if matches!( + editor.descriptor.provider_type.as_str(), + "custom" | "custom_responses" | "custom_messages" + ) { + backend + .remove_credential(provider_credential_key( + editor.kind, + &editor.channel.id, + openless_core::credentials::LLM_EXTRA_HEADERS_ACCOUNT, + )?) + .await?; + } for account in accounts { backend .remove_credential(provider_credential_key( @@ -6420,19 +8402,82 @@ mod linux_app { Ok(()) } + /// Drain the old form before replacing its descriptor. Never refresh the + /// editor from a list requested before the provider mutation completed. + async fn switch_provider_editor( + backend: Arc, + editor: ProviderEditor, + provider_type: String, + ) -> Result { + let descriptor = openless_core::provider_rules::provider_descriptor( + provider_kind(editor.kind), + &provider_type, + ) + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::InvalidArgument, + "unknown provider type", + ) + })?; + let kind = editor.kind; + let id = editor.channel.id.clone(); + save_provider_editor(backend.clone(), editor).await?; + backend + .set_channel_provider_type(kind, id.clone(), provider_type.clone()) + .await?; + for (account, default) in [ + ( + endpoint_account(kind), + descriptor.default_endpoint.as_deref(), + ), + (model_account(kind), descriptor.default_model.as_deref()), + ] { + if let Some(default) = default.filter(|value| !value.trim().is_empty()) { + let current = read_provider_value(&backend, kind, &id, account).await?; + if provider_type == "orcarouter" + || current + .as_deref() + .is_none_or(|value| value.trim().is_empty()) + { + write_or_remove_provider_value(&backend, kind, &id, account, default).await?; + } + } + } + let mut channel = backend + .list_channels(kind) + .await? + .into_iter() + .find(|channel| channel.id == id) + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::InvalidArgument, + "unknown channel", + ) + })?; + if provider_type == "orcarouter" && channel.name.trim().is_empty() { + backend + .rename_channel(kind, id, "OrcaRouter".to_owned()) + .await?; + channel.name = "OrcaRouter".to_owned(); + } + load_provider_editor(backend, kind, channel, descriptor).await + } + async fn load_provider_editor( backend: Arc, kind: openless_core::ChannelKind, channel: openless_core::ChannelSummary, descriptor: openless_core::ProviderDescriptor, ) -> Result { + // Descriptor defaults are placeholders, not persisted field values. + // Otherwise flushing before a provider switch writes the OLD provider's + // defaults into empty slots, preventing the next provider's defaults + // from being installed (and sending its key to the previous endpoint). 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 { @@ -6467,10 +8512,109 @@ mod linux_app { .await? .unwrap_or_default(), ) + } else if descriptor.auth_requirement == openless_core::AuthRequirement::TencentCloud { + ( + String::new(), + read_provider_value( + &backend, + kind, + &channel.id, + openless_core::credentials::TENCENT_CLOUD_APP_ID_ACCOUNT, + ) + .await? + .unwrap_or_default(), + ) } else { (String::new(), String::new()) }; + let mut protocol = openless_linux_egui::provider_protocol::ProviderProtocolDraft { + default_format: descriptor.default_request_format.unwrap_or_default(), + formats: descriptor.supported_request_formats.clone(), + ..Default::default() + }; + if kind == openless_core::ChannelKind::Llm && !protocol.formats.is_empty() { + for (index, account) in openless_core::llm_protocol::CONFIG_ACCOUNTS + .iter() + .enumerate() + { + if *account == openless_core::llm_protocol::SERVICE_TIER_ACCOUNT + && descriptor.provider_type.as_str() != "ark" + { + continue; + } + protocol.values[index] = read_provider_value(&backend, kind, &channel.id, account) + .await? + .unwrap_or_default(); + } + } + if matches!( + descriptor.provider_type.as_str(), + "custom" | "custom_responses" | "custom_messages" + ) { + protocol.extra_headers = read_provider_value( + &backend, + kind, + &channel.id, + openless_core::credentials::LLM_EXTRA_HEADERS_ACCOUNT, + ) + .await? + .unwrap_or_default(); + protocol.temperature = read_provider_value( + &backend, + kind, + &channel.id, + openless_core::credentials::LLM_TEMPERATURE_ACCOUNT, + ) + .await? + .unwrap_or_default(); + } + // 密钥也要读回来:Tauri 的 `CredentialField` 就是把已存的值读进输入框再打码, + // 用户能看到「这里有东西」。之前这里写死成空串,编辑已有渠道时密钥框永远空白, + // 看起来像没保存。 + let (primary_account, secondary_account) = match descriptor.auth_requirement { + openless_core::AuthRequirement::None | openless_core::AuthRequirement::OAuth => { + (None, None) + } + openless_core::AuthRequirement::Volcengine => { + if auth_mode == "api_key" { + ( + Some(openless_core::credentials::VOLCENGINE_API_KEY_ACCOUNT), + None, + ) + } else { + ( + Some(openless_core::credentials::VOLCENGINE_APP_KEY_ACCOUNT), + Some(openless_core::credentials::VOLCENGINE_ACCESS_KEY_ACCOUNT), + ) + } + } + openless_core::AuthRequirement::Xfyun => ( + Some(openless_core::credentials::XFYUN_APP_ID_ACCOUNT), + Some(openless_core::credentials::XFYUN_API_KEY_ACCOUNT), + ), + openless_core::AuthRequirement::TencentCloud => ( + Some(openless_core::credentials::TENCENT_CLOUD_SECRET_ID_ACCOUNT), + Some(openless_core::credentials::TENCENT_CLOUD_SECRET_KEY_ACCOUNT), + ), + _ => (Some(api_key_account(kind)), None), + }; + let channel_id = channel.id.clone(); + let read_optional = |account: Option<&'static str>| { + let backend = Arc::clone(&backend); + let channel_id = channel_id.clone(); + async move { + match account { + Some(account) => read_provider_value(&backend, kind, &channel_id, account) + .await + .map(Option::unwrap_or_default), + None => Ok(String::new()), + } + } + }; + let primary_secret = read_optional(primary_account).await?; + let secondary_secret = read_optional(secondary_account).await?; Ok(ProviderEditor { + protocol, kind, name: channel.name.clone(), channel, @@ -6480,8 +8624,8 @@ mod linux_app { volcengine_service, auth_mode, resource_id, - primary_secret: String::new(), - secondary_secret: String::new(), + primary_secret, + secondary_secret, }) } @@ -6497,7 +8641,7 @@ mod linux_app { .provider .validate(openless_core::ProviderRequest { kind: provider_kind(kind), - thinking_enabled: false, + thinking_enabled: backend.get_preferences().llm_thinking_enabled, channel_id: Some(channel_id.clone()), }) .await; @@ -6683,8 +8827,8 @@ mod linux_app { mod popup_overlay { use super::*; use openless_linux_egui::{ - place_overlay, popup_position, OverlayEnvironment, OverlayPlacement, OverlayX11, - X11Overlay, + place_overlay, place_panel, popup_position, OverlayEnvironment, OverlayPlacement, + OverlayX11, X11Overlay, }; pub struct PopupOverlay { @@ -6698,11 +8842,20 @@ mod linux_app { attempts: u8, focus_window: Option, focus_watch_remaining: u8, + last_capsule_layout: Option<(u32, u32, bool)>, } impl PopupOverlay { pub fn probe(kind: PopupKind) -> Option { - // 原生 Wayland 不做任何 X11 处理。 + // XWayland may expose DISPLAY in a native Wayland session, but + // an xdg-toplevel is not an X11 window. Do not feed its + // coordinates to winit or pretend that X11 can place it. + if openless_linux_egui::wayland_display_available( + std::env::var("WAYLAND_DISPLAY").ok().as_deref(), + ) { + log::debug!("popup x11: native Wayland session, keeping compositor placement"); + return None; + } 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; @@ -6739,6 +8892,7 @@ mod linux_app { attempts: 0, focus_window: None, focus_watch_remaining: 0, + last_capsule_layout: None, }) } @@ -6749,6 +8903,7 @@ mod linux_app { } fn apply(&mut self, reason: &str) -> OverlayPlacement { + self.last_capsule_layout = None; let placement = place_overlay( &mut self.connection, std::process::id(), @@ -6790,11 +8945,74 @@ focus_was_stolen={} focus_restored={} warnings={:?}", placement } + pub fn capsule_layout(&mut self, ctx: &egui::Context, size: (u32, u32), card: bool) { + if self.kind != PopupKind::Capsule + || self.last_capsule_layout == Some((size.0, size.1, card)) + { + return; + } + self.last_capsule_layout = Some((size.0, size.1, card)); + ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2( + size.0 as f32, + size.1 as f32, + ))); + let position = if card { + self.environment.placement_area().map(|area| { + openless_linux_egui::clamp_to_area( + area.x + area.width as i32 - size.0 as i32 - 24, + area.y + area.height as i32 - size.1 as i32 - 24, + size, + area, + ) + }) + } else { + self.environment + .position_for(size, openless_linux_egui::CAPSULE_BOTTOM_GAP) + }; + if let Some((x, y)) = position { + ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2( + x as f32, y as f32, + ))); + } + } + pub fn place(&mut self, ctx: &egui::Context, visible: bool) { - // 只有胶囊需要「永不聚焦 + 置顶 + 不进任务栏」;两个面板要键盘输入, - // 位置已经由 `with_position` 在创建时给过,X11 变更一概不做。 if self.kind != PopupKind::Capsule { - self.placed = true; + // Interactive panels keep normal focus/taskbar behaviour, but + // winit's initial position is only a hint. Re-apply the + // work-area position once after the X11 window is managed so + // a panel cannot be placed over a reserved taskbar. + if !self.placed { + self.attempts = self.attempts.saturating_add(1); + let placement = place_panel( + &mut self.connection, + std::process::id(), + &self.environment, + self.kind, + ); + if placement.applied() { + self.placed = true; + log::info!( + "popup x11 (panel-post-map): kind={:?} window={:?} matched={} moved_to={:?} warnings={:?}", + self.kind, + placement.window, + placement + .matched + .map(openless_linux_egui::WindowMatch::as_str) + .unwrap_or("none"), + placement.moved_to, + placement.warnings + ); + } else if self.attempts >= 100 { + self.placed = true; + log::warn!( + "popup x11: panel window not found, keeping compositor placement kind={:?}", + self.kind + ); + } else { + ctx.request_repaint_after(std::time::Duration::from_millis(50)); + } + } return; } if !self.placed { @@ -6867,6 +9085,8 @@ focus_was_stolen={} focus_restored={} warnings={:?}", } pub fn place(&mut self, _ctx: &egui::Context, _visible: bool) {} + pub fn capsule_layout(&mut self, _ctx: &egui::Context, _size: (u32, u32), _card: bool) { + } } } @@ -6888,6 +9108,10 @@ focus_was_stolen={} focus_restored={} warnings={:?}", overlay: Option, /// 面板有焦点时插件收不到按键,所以面板自己也要匹配本地热键。 hotkey_matcher: crate::ui::local_hotkeys::LocalHotkeyMatcher, + /// 本次会话「预备态(麦克风还没吐第一帧 PCM)」的开始时刻:用来学习 warmupMs。 + warming_started: Option, + /// 学出来的「预备 → 就绪」平均耗时(ms),驱动光条的预测式展开。 + warmup_ms: f32, } impl NativePopupApp { @@ -7030,6 +9254,30 @@ focus_was_stolen={} focus_restored={} warnings={:?}", } } + /// 学习「预备态持续多久」= 麦克风就绪耗时,EMA 更新并落盘(Tauri 用 + /// localStorage 的 `ol-capsule-warmup-ms`,公式一致;异常样本在 + /// `learn_capsule_warmup_ms` 里被过滤)。 + fn track_capsule_warmup(&mut self, warming: bool, phase: &str) { + let warming = warming + && matches!( + phase.to_ascii_lowercase().as_str(), + "starting" | "recording" + ); + if warming { + if self.warming_started.is_none() { + self.warming_started = Some(std::time::Instant::now()); + } + return; + } + let Some(started) = self.warming_started.take() else { + return; + }; + let observed_ms = started.elapsed().as_secs_f32() * 1000.0; + if let Some(next) = openless_linux_egui::learn_capsule_warmup_ms(observed_ms) { + self.warmup_ms = next; + } + } + /// 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). @@ -7040,10 +9288,12 @@ focus_was_stolen={} focus_restored={} warnings={:?}", first: bool, ) -> openless_linux_egui::LayerFrame { if first { - theme::install(ctx); + theme::install(ctx, openless_linux_egui::typography::load().font); + } + let exit = self.pump(None) || (self.state.session_id.is_some() && !self.state.visible); + if !exit { + self.send_ready_if_needed(); } - 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" @@ -7052,10 +9302,37 @@ focus_was_stolen={} focus_restored={} warnings={:?}", 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); + if !exit && self.state.visible { + self.track_capsule_warmup(capsule.audio_level.is_none(), &capsule.phase); + action = + frontend::popups::dictation_capsule(ui, &capsule, lang, self.warmup_ms); + } }); match action { frontend::popups::CapsuleAction::None => {} + frontend::popups::CapsuleAction::AcceptSuggestion(id) => { + // 卡片与听写会话无关:id 才是要处理的候选。 + let sequence = self.next_sequence(); + let session_id = self.session_id().unwrap_or_default(); + self.send(PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + id, + accept: true, + }); + } + frontend::popups::CapsuleAction::RejectSuggestion(id) => { + let sequence = self.next_sequence(); + let session_id = self.session_id().unwrap_or_default(); + self.send(PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id, + sequence, + id, + accept: false, + }); + } frontend::popups::CapsuleAction::Cancel | frontend::popups::CapsuleAction::Confirm => { if let Some(session_id) = self.session_id() { @@ -7075,12 +9352,28 @@ focus_was_stolen={} focus_restored={} warnings={:?}", }; self.send(message); } - exit = true; + // The host owns cancellation/stop and the terminal hide. + // Keep the process alive so stdout is flushed and the + // recording-to-processing animation remains visible. } } + let size = frontend::popups::capsule_window_size(&capsule); + let card = !capsule.suggestions.is_empty(); openless_linux_egui::LayerFrame { output, exit, + capsule_layout: Some(( + openless_linux_egui::capsule_geometry( + size.0, + size.1, + if card { + 24 + } else { + openless_linux_egui::CAPSULE_BOTTOM_GAP + }, + ), + card, + )), // Same cadence as the windowed popup: animate fast, idle slowly. repaint_after: Duration::from_millis(if self.state.visible && animated { 33 @@ -7097,6 +9390,7 @@ focus_was_stolen={} focus_restored={} warnings={:?}", } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + openless_linux_egui::frame_stats::tick("popup"); 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. @@ -7106,6 +9400,15 @@ focus_was_stolen={} focus_restored={} warnings={:?}", if self.pump(Some(&ctx)) { return; } + if self.kind == PopupKind::Capsule { + if let Some(overlay) = self.overlay.as_mut() { + overlay.capsule_layout( + &ctx, + frontend::popups::capsule_window_size(&self.state.capsule), + !self.state.capsule.suggestions.is_empty(), + ); + } + } self.send_ready_if_needed(); self.poll_local_hotkeys(&ctx); if ctx.input(|input| input.key_pressed(egui::Key::Escape)) { @@ -7288,7 +9591,15 @@ focus_was_stolen={} focus_restored={} warnings={:?}", capsule_phase.as_str(), "starting" | "recording" | "transcribing" | "polishing" | "inserting" ); - let action = frontend::popups::dictation_capsule(ui, &self.state.capsule, lang); + let warming = self.state.capsule.audio_level.is_none(); + let warming_phase = self.state.capsule.phase.clone(); + self.track_capsule_warmup(warming, &warming_phase); + let action = frontend::popups::dictation_capsule( + ui, + &self.state.capsule, + lang, + self.warmup_ms, + ); let message = match action { frontend::popups::CapsuleAction::Cancel => { Some(PopupToHost::CancelDictation { @@ -7297,6 +9608,24 @@ focus_was_stolen={} focus_restored={} warnings={:?}", sequence: 0, }) } + frontend::popups::CapsuleAction::AcceptSuggestion(id) => { + Some(PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 0, + id, + accept: true, + }) + } + frontend::popups::CapsuleAction::RejectSuggestion(id) => { + Some(PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 0, + id, + accept: false, + }) + } frontend::popups::CapsuleAction::Confirm => { Some(PopupToHost::StopDictation { version: POPUP_PROTOCOL_VERSION, @@ -7315,6 +9644,11 @@ focus_was_stolen={} focus_restored={} warnings={:?}", sequence: seq, .. } + | PopupToHost::VocabSuggestion { + session_id: id, + sequence: seq, + .. + } | PopupToHost::StopDictation { session_id: id, sequence: seq, @@ -7325,8 +9659,16 @@ focus_was_stolen={} focus_restored={} warnings={:?}", } _ => {} } + let keep_open = matches!( + &message, + PopupToHost::CancelDictation { .. } + | PopupToHost::StopDictation { .. } + | PopupToHost::VocabSuggestion { .. } + ); self.send(message); - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + if !keep_open { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } } } } @@ -7404,7 +9746,7 @@ focus_was_stolen={} focus_restored={} warnings={:?}", } /// 胶囊在实现了 `zwlr_layer_shell_v1` 的合成器上跑原生 layer surface - /// (贴底居中、键盘焦点不可能、不占工作区);协议缺失、EGL 起不来或 + /// (贴底居中、键盘焦点不可能、不占工作区);协议缺失、Vulkan 初始化失败或 /// configure 超时都会返回 Err;不会回退到普通焦点窗口或 XWayland。 fn run_capsule_layer_process() -> Result<(), LayerCapsuleFailure> { let geometry = openless_linux_egui::capsule_geometry( @@ -7414,13 +9756,14 @@ focus_was_stolen={} focus_restored={} warnings={:?}", ); // 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 + // and Vulkan 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| { + openless_linux_egui::frame_stats::tick("capsule-layer"); if app.is_none() { match popup_stdio() { Ok((incoming, outgoing)) => { @@ -7434,6 +9777,8 @@ focus_was_stolen={} focus_restored={} warnings={:?}", less_computer_input: String::new(), outgoing_sequence: 0, ready_sent: false, + warming_started: None, + warmup_ms: openless_linux_egui::load_capsule_warmup_ms(), avatar: QaAvatar::default(), lang: load_locale_pref().resolve(), overlay: None, @@ -7446,6 +9791,7 @@ focus_was_stolen={} focus_restored={} warnings={:?}", return openless_linux_egui::LayerFrame { output, exit: true, + capsule_layout: None, repaint_after: std::time::Duration::from_millis(0), }; } @@ -7522,8 +9868,8 @@ focus_was_stolen={} focus_restored={} warnings={:?}", 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 高度)。 + // Recording keeps a fixed 460x180 Siri stage plus the transcript rail; + // only an idle vocabulary card changes the host size and placement. PopupKind::Capsule => [ openless_linux_egui::CAPSULE_WINDOW_SIZE.0 as f32, openless_linux_egui::CAPSULE_WINDOW_SIZE.1 as f32, @@ -7574,7 +9920,10 @@ focus_was_stolen={} focus_restored={} warnings={:?}", "OpenLess Popup", options, Box::new(move |cc| { - theme::install(&cc.egui_ctx); + theme::install(&cc.egui_ctx, openless_linux_egui::typography::load().font); + if let Some(state) = cc.wgpu_render_state.as_ref() { + crate::ui::frontend::siri_wgpu::install_wgpu(state, 4); + } Ok(Box::new(NativePopupApp { kind, state: PopupState::default(), @@ -7584,6 +9933,8 @@ focus_was_stolen={} focus_restored={} warnings={:?}", less_computer_input: String::new(), outgoing_sequence: 0, ready_sent: false, + warming_started: None, + warmup_ms: openless_linux_egui::load_capsule_warmup_ms(), avatar: QaAvatar::default(), // The popup is a separate process, so it re-reads the // persisted UI-locale preference rather than sharing state. @@ -7620,13 +9971,16 @@ focus_was_stolen={} focus_restored={} warnings={:?}", native: LinuxNativeRuntime, tray: Option, start_minimized: bool, + runtime_warning: Option, ) -> 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(); + native.set_hotkey_waker(std::thread::current()); let mut app = OpenLessEguiApp::new(tokio, Ok(native), tray, window_should_be_open); + app.runtime_warning = runtime_warning; let mut ui_bridge = UiBridgeHost::bind(socket.clone()) .map_err(|error| format!("UI bridge bind failed: {error}"))?; log::info!( @@ -7656,6 +10010,10 @@ focus_was_stolen={} focus_restored={} warnings={:?}", for sequence in std::mem::take(&mut app.pending_ui_pongs) { ui_bridge.send(HostToWindow::Pong { sequence }); } + // 分页回包与请求同一个 tick 送出:列表不会因为等下一帧而闪一下。 + for page in std::mem::take(&mut app.pending_collection_pages) { + ui_bridge.send(page); + } app.sync_view_model(); app.sync_hotkey_bindings(&mut ui_bridge); app.publish_view_model(&mut ui_bridge); @@ -7663,7 +10021,7 @@ focus_was_stolen={} focus_restored={} warnings={:?}", if app.exit_requested { break; } - std::thread::sleep(HOST_TICK_INTERVAL); + std::thread::park_timeout(HOST_TICK_INTERVAL); } log::info!("[ui-host] host exiting; asking the UI window to close"); ui_bridge.shutdown(); @@ -7757,12 +10115,38 @@ Internal flags (set by OpenLess itself, not for regular use): eprintln!("OpenLess file logger unavailable: {error}"); } if let Some(error) = tray_error { + // 没有托盘 ⇒ 关窗就退出:这是用户行为差异,必须写进启动日志。 log::warn!( "OpenLess tray unavailable: {error}; closing the main window will exit the app" ); } - ensure_fcitx5_ready(&config)?; - let hotkeys = Some(Fcitx5HotkeyListener::start().map_err(|error| error.to_string())?); + // fcitx5 is an optional integration: a missing daemon, addon, or + // session bus must not prevent the Core/UI from starting. + let lang = load_locale_pref().resolve(); + let mut input_method_error = match ensure_fcitx5_ready(&config) { + Ok(()) => None, + Err(error) => { + log::warn!( + "[fcitx] degraded startup (readiness check failed); global hotkeys disabled: {error}" + ); + Some(error) + } + }; + let hotkeys = if input_method_error.is_none() { + match Fcitx5HotkeyListener::start() { + Ok(listener) => Some(listener), + Err(error) => { + let error = error.to_string(); + log::warn!( + "[fcitx] degraded startup (listener failed); global hotkeys disabled: {error}" + ); + input_method_error = Some(error); + None + } + } + } else { + None + }; config.platform = LinuxCapabilitySnapshot::detect(tray_available).capabilities; let backend = { // Construction captures the existing executor for cpal/native @@ -7774,25 +10158,42 @@ Internal flags (set by OpenLess itself, not for regular use): .build() .map_err(|error| error.to_string())? }; - tokio + let native = tokio .block_on(LinuxNativeRuntime::start( backend, Some(Arc::clone(&broker)), hotkeys, )) - .map_err(|error| error.to_string()) + .map_err(|error| error.to_string())?; + let runtime_warning = input_method_error + .map(|error| fmt_l10n(lang, "startup.input_method_unavailable", &[&error])); + Ok::<_, String>((native, runtime_warning)) })(); // 常驻宿主:本进程不再创建窗口,窗口交给独立的 UI 进程。 - let native = match native { + let (native, runtime_warning) = match native { Ok(native) => native, Err(error) => { drop(tray); - window::show_startup_error(&error, Arc::clone(&broker)); + // Fatal startup errors are reported to stderr/journal only. Do + // not create a second egui window: recoverable integration + // failures (such as fcitx5) are rendered by the main UI's + // runtime-warning overlay instead. + log::error!("OpenLess startup failed: {error}"); return Err(error); } }; - if let Err(error) = run_host(&runtime_dir, tokio, native, tray, start_minimized) { - window::show_startup_error(&error, Arc::clone(&broker)); + if let Err(error) = run_host( + &runtime_dir, + tokio, + native, + tray, + start_minimized, + runtime_warning, + ) { + // The UI process owns all user-facing recoverable warnings. A + // fatal host/UI failure must not fall back to a standalone popup; + // that popup was a second, misleading application window. + log::error!("OpenLess host failed: {error}"); return Err(error); } Ok(()) @@ -7941,213 +10342,69 @@ Internal flags (set by OpenLess itself, not for regular use): } #[test] - fn closing_qa_drops_its_window_and_late_idle_does_not_reopen_it() { - let mut app = fixture_app(true); - app.qa_visible = true; - app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); - app.qa_popup = Some(PopupSupervisor::spawn_command( - app.tokio.handle(), - tokio::process::Command::new("/bin/cat"), - )); - app.toggle_qa_panel(); - assert!(!app.qa_visible); - assert!(app.qa_popup.is_none()); - app.apply_event(BackendEvent { - sequence: 1, - session_id: None, - kind: BackendEventKind::QaState(QaStateEvent::simple(QaStateKind::Idle)), + fn capsule_uses_raw_accumulator_not_polished_output_and_respects_preferences() { + let mut app = fixture_app(false); + app.transcript_state + .apply(&openless_core::TranscriptDelta { + text: "raw words".into(), + offset: 0, + is_final: false, + }) + .unwrap(); + app.transcript = "polished output".into(); + app.preferences = Some(UserPreferences::default()); + assert_eq!(app.capsule_transcript(), "raw words"); + assert_eq!(app.capsule_transcript_font_size(), 14); + let prefs = app.preferences.as_mut().unwrap(); + prefs.capsule_transcript_enabled = false; + prefs.capsule_transcript_font_size = 255; + assert!(app.capsule_transcript().is_empty()); + assert_eq!(app.capsule_transcript_font_size(), 20); + } + + #[test] + 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!(app.qa_popup.is_none()); - assert!(app.expected_popup_session(PopupKind::Qa).is_none()); + assert!(style_editor_is_dirty(&vm)); } #[test] - fn late_qa_action_failures_do_not_overwrite_a_new_turn() { - let mut app = fixture_app(true); - let mut state = QaStateEvent::simple(QaStateKind::Thinking); - state.session_id = Some(openless_core::SessionId::new().to_string()); - app.qa_state = Some(state); - app.qa_visible = true; - app.apply_qa_action_result( - app.qa_popup_presentation.generation, - None, - Err("old failure".into()), + 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")) ); - assert!(app.qa_state.as_ref().unwrap().error.is_none()); - } - - #[test] - fn a_previous_preview_cannot_close_the_current_qa_window() { - let mut app = fixture_app(true); - let current = openless_core::SessionId::new().to_string(); - app.qa_visible = true; - app.qa_popup_presentation.session_id = Some(current.clone()); - app.qa_popup = Some(PopupSupervisor::spawn_command( - app.tokio.handle(), - tokio::process::Command::new("/bin/cat"), - )); - app.hide_popup( - PopupKind::Qa, - openless_core::SessionId::new().to_string(), - 1, - ); - assert!(app.qa_popup.is_some()); - assert!(app.qa_visible); - assert_eq!(app.qa_popup_presentation.session_id, Some(current)); - } - - #[test] - fn a_previous_qa_turn_cannot_close_the_current_preview() { - let mut app = fixture_app(true); - let current = openless_core::SessionId::new(); - app.polish_result_visible = true; - app.selection = Some(SelectionSnapshot { - session_id: Some(current), - phase: SelectionPhase::Preview, - ..Default::default() - }); - app.qa_popup_presentation.session_id = Some(current.to_string()); - app.qa_popup = Some(PopupSupervisor::spawn_command( - app.tokio.handle(), - tokio::process::Command::new("/bin/cat"), - )); - app.hide_popup( - PopupKind::Qa, - openless_core::SessionId::new().to_string(), - 1, - ); - assert!(app.qa_popup.is_some()); - assert!(app.polish_result_visible); - assert_eq!( - app.expected_popup_session(PopupKind::Qa), - Some(current.to_string()) - ); - } - - #[test] - fn closing_the_current_preview_also_clears_qa_visibility() { - let mut app = fixture_app(true); - let current = openless_core::SessionId::new().to_string(); - app.qa_visible = true; - app.qa_popup_presentation.session_id = Some(current.clone()); - app.qa_popup = Some(PopupSupervisor::spawn_command( - app.tokio.handle(), - tokio::process::Command::new("/bin/cat"), - )); - app.hide_popup(PopupKind::Qa, current, 1); - assert!(app.qa_popup.is_none()); - assert!(!app.qa_visible); - assert!(!app.polish_result_visible); - } - - #[test] - fn background_qa_updates_leave_the_current_preview_visible() { - let mut app = fixture_app(true); - let preview = openless_core::SessionId::new().to_string(); - app.qa_visible = true; - app.polish_result_visible = true; - app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); - app.qa_popup_presentation.session_id = Some(preview.clone()); - app.qa_popup = Some(PopupSupervisor::spawn_command( - app.tokio.handle(), - tokio::process::Command::new("/bin/cat"), - )); - let generation = app.qa_popup_presentation.generation; - app.show_qa_popup(); - assert!(app.qa_popup.is_some()); - assert_eq!(app.qa_popup_presentation.session_id, Some(preview)); - assert_eq!(app.qa_popup_presentation.generation, generation); - } - - #[test] - fn an_old_process_exit_cannot_clear_a_reopened_qa_window() { - let mut app = fixture_app(true); - let old_generation = app.qa_popup_presentation.generation; - app.close_qa_popup(); - app.qa_visible = true; - app.qa_popup = Some(PopupSupervisor::spawn_command( - app.tokio.handle(), - tokio::process::Command::new("/bin/cat"), - )); - app.apply_popup_event_in_generation( - PopupKind::Qa, - old_generation, - PopupSupervisorEvent::Exited { - code: Some(0), - crashed: false, - }, - ); - assert!(app.qa_popup.is_some()); - assert!(app.qa_visible); - } - - #[test] - fn an_old_idle_failure_cannot_appear_in_a_reopened_idle_panel() { - let mut app = fixture_app(true); - app.qa_visible = true; - app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); - let old_generation = app.qa_popup_presentation.generation; - app.close_qa_popup(); - app.qa_visible = true; - app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); - let status = app.status.clone(); - app.apply_qa_action_result(old_generation, None, Err("old failure".into())); - assert!(app.qa_state.as_ref().unwrap().error.is_none()); - assert_eq!(app.status, status); - app.apply_qa_action_result( - app.qa_popup_presentation.generation, - None, - Err("current failure".into()), - ); - assert_eq!( - app.qa_state.as_ref().unwrap().error.as_deref(), - Some("current failure") - ); - } - - #[test] - 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); + // 普通启动(宿主)不是 UI 进程。 + assert_eq!(ui_client_socket(&["openless".to_string()]), None); + // 带了开关却没给路径:当作普通启动,不要装作是 UI 进程。 + assert_eq!(ui_client_socket(&[UI_CLIENT_FLAG.to_string()]), None); } #[test] @@ -8168,6 +10425,17 @@ Internal flags (set by OpenLess itself, not for regular use): assert_ne!(snapshot_fingerprint(&a), snapshot_fingerprint(&b)); } + #[test] + fn the_runtime_warning_can_be_dismissed_without_stopping_the_host() { + let mut app = fixture_app(true); + app.runtime_warning = Some("fixture warning".into()); + app.apply_frontend_actions( + vec![frontend::view_model::FrontendAction::DismissRuntimeWarning], + &egui::Context::default(), + ); + assert!(app.runtime_warning.is_none()); + } + #[test] fn the_host_keeps_running_when_the_window_says_goodbye() { let mut app = fixture_app(true); @@ -8262,6 +10530,12 @@ Internal flags (set by OpenLess itself, not for regular use): let (mut ready, mut hotkeys, mut snapshot, mut focus) = (false, false, false, false); while !(ready && hotkeys && snapshot && focus) { + // 快照走单槽位,不从通道出。 + if let Some((sequence, _)) = client.take_snapshot() { + assert!(ready && sequence > previous_sequence); + previous_sequence = sequence; + snapshot = true; + } match client.try_recv() { Ok(HostToWindow::Ready { .. }) => ready = true, Ok(HostToWindow::Hotkeys { .. }) => { @@ -8471,73 +10745,418 @@ Internal flags (set by OpenLess itself, not for regular use): 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, - ) + fn fixture_editor(provider_type: &str) -> ProviderEditor { + ProviderEditor { + kind: openless_core::ChannelKind::Llm, + channel: openless_core::ChannelSummary { + id: "draft-channel".into(), + name: "typed name".into(), + provider_type: provider_type.into(), + enabled: true, + order: 0, + last_test: None, + }, + descriptor: openless_core::provider_rules::provider_descriptor( + openless_core::ProviderKind::Llm, + provider_type, + ) + .unwrap(), + name: "typed name".into(), + endpoint: "https://example.invalid".into(), + model: "custom-model".into(), + volcengine_service: String::new(), + protocol: Default::default(), + auth_mode: String::new(), + resource_id: String::new(), + primary_secret: String::new(), + secondary_secret: String::new(), + } } - /// 快捷键卡片上的每一行都必须从 Core 偏好取真值。宿主以前只填了 - /// 听写/翻译/QA/速记四行,其余行无论偏好里有没有绑定都画成空键帽 —— - /// 看起来是「未设置」,录制却实实在在地写进了偏好。 #[test] - fn shortcut_rows_show_the_bindings_core_holds() { + fn untouched_descriptor_defaults_do_not_become_saved_connection_values() { let mut app = fixture_app(true); - app.lang = Lang::En; - app.preferences = Some(UserPreferences { - dictation_hotkey: openless_core::shared_types::ShortcutBinding { - primary: "RightOption".into(), - modifiers: Vec::new(), - }, - 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; - assert_eq!(vm.dictation_hotkey, "Right Alt"); - 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" - ); - } + let mut editor = fixture_editor("ark"); + assert!(editor.descriptor.default_endpoint.is_some()); + assert!(editor.descriptor.default_model.is_some()); + editor.endpoint.clear(); + editor.model.clear(); + app.provider_editor_form = Some(ProviderEditorForm::from_editor(&editor, app.lang)); + app.provider_editor = ProviderEditorState::Loaded(Box::new(editor)); + let snapshot = app.editor_from_form().unwrap(); + assert!(snapshot.endpoint.is_empty()); + assert!(snapshot.model.is_empty()); + assert!(!app + .provider_editor_form + .as_ref() + .unwrap() + .default_model + .is_empty()); } + /// 保存成功后,编辑器里的密钥不能被清空。 + /// + /// 旧逻辑会「擦掉已被确认的草稿」,而自动保存是输入后 300ms 触发的:用户刚 + /// 填好密钥,保存一回就把它从输入框里抹掉,看起来就是「填进去的密钥几秒后 + /// 被删了」。密钥本身仍在保险库里,只是界面上连掩码点都没有。 #[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"); + fn a_saved_secret_stays_in_the_editor() { + let mut app = fixture_app(true); + let editor = fixture_editor("custom"); + app.frontend_vm.settings_open = true; + app.frontend_vm.services_view = 0; + let mut form = ProviderEditorForm::from_editor(&editor, app.lang); + form.primary_secret = "sk-typed-secret".into(); + app.provider_editor_form = Some(form); + app.provider_editor = ProviderEditorState::Loaded(Box::new(editor.clone())); + app.draft_touched = true; + app.provider_save_pending = true; + // 宿主提交的正是这份草稿(`editor_from_form`),回执带回同一份快照。 + let mut submitted = editor.clone(); + submitted.primary_secret = "sk-typed-secret".into(); + app.tx + .send(UiResult::ProviderSaved { + editor: Box::new(submitted), + result: Ok(()), + }) + .unwrap(); + app.poll(&egui::Context::default()); + assert_eq!( + app.provider_editor_form + .as_ref() + .map(|form| form.primary_secret.clone()), + Some("sk-typed-secret".to_string()), + "a successful save must not erase the key the user just typed" + ); + } + + #[test] + fn leaving_a_channel_waits_for_save_and_failure_keeps_the_draft() { + let mut app = fixture_app(true); + let editor = fixture_editor("custom"); + app.frontend_vm.settings_open = true; + app.frontend_vm.services_view = 0; + app.provider_editor_form = Some(ProviderEditorForm::from_editor(&editor, app.lang)); + app.provider_editor = ProviderEditorState::Loaded(Box::new(editor.clone())); + app.draft_touched = true; + app.provider_save_pending = true; + app.apply_frontend_actions( + vec![frontend::view_model::FrontendAction::SettingsServicesView( + 1, + )], + &egui::Context::default(), + ); + assert_eq!(app.frontend_vm.services_view, 0); + assert!(app.provider_after_save.is_some()); + app.tx + .send(UiResult::ProviderSaved { + editor: Box::new(editor), + result: Err("vault unavailable".into()), + }) + .unwrap(); + app.poll(&egui::Context::default()); + assert!(app.provider_after_save.is_none()); + assert_eq!(app.frontend_vm.services_view, 0); + assert_eq!( + app.provider_editor_form.as_ref().unwrap().model, + "custom-model" + ); + assert!(app.frontend_vm.settings_notice.is_some()); + } + + #[test] + fn changing_protocol_discards_the_previous_model_catalog() { + let mut app = fixture_app(true); + let editor = fixture_editor("orcarouter"); + let mut form = ProviderEditorForm::from_editor(&editor, app.lang); + form.models = vec!["previous-format-model".into()]; + form.catalog_format = Some(form.protocol.format()); + app.provider_editor_form = Some(form); + app.provider_editor = ProviderEditorState::Loaded(Box::new(editor)); + app.apply_frontend_actions( + vec![frontend::view_model::FrontendAction::SettingsProviderField( + frontend::view_model::SettingsProviderField::RequestFormat, + "messages".into(), + )], + &egui::Context::default(), + ); + let form = app.provider_editor_form.as_ref().unwrap(); + assert!(form.models.is_empty()); + assert!(form.catalog_format.is_none()); + } + + #[test] + fn stale_provider_panels_cannot_consume_the_new_draft_open_request() { + let mut app = fixture_app(true); + app.providers_request = 2; + app.editor_requested = true; + app.selected_channel_id = Some("draft-channel".into()); + app.tx + .send(UiResult::Providers { + kind: app.provider_kind, + request: 1, + result: Err("stale panel".into()), + }) + .unwrap(); + app.poll(&egui::Context::default()); + assert!(matches!(app.providers, ProvidersState::Loading)); + assert!(app.editor_requested); + assert_eq!(app.selected_channel_id.as_deref(), Some("draft-channel")); + assert!(app.frontend_vm.settings_notice.is_none()); + } + + #[test] + fn provider_switch_receipt_keeps_the_touched_draft_editor_open() { + let mut app = fixture_app(true); + let old = fixture_editor("custom"); + app.provider_kind = old.kind; + app.settings_channel_kind = old.kind; + app.selected_channel_id = Some(old.channel.id.clone()); + app.provider_editor_form = Some(ProviderEditorForm::from_editor(&old, app.lang)); + app.provider_editor = ProviderEditorState::Loaded(Box::new(old)); + app.editor_draft = true; + app.draft_touched = true; + app.provider_switch_pending = true; + let next = fixture_editor("custom_messages"); + app.tx + .send(UiResult::ProviderTypeChanged { + kind: next.kind, + channel_id: next.channel.id.clone(), + result: Box::new(Ok(next)), + }) + .unwrap(); + app.poll(&egui::Context::default()); + assert!(!app.provider_switch_pending); + assert!(app.editor_draft && app.draft_touched); + let form = app.provider_editor_form.as_ref().unwrap(); + assert_eq!(form.provider_type, "custom_messages"); + assert_eq!(form.name, "typed name"); + assert_eq!(form.model, "custom-model"); + assert!(matches!( + app.provider_editor, + ProviderEditorState::Loaded(_) + )); + } + + #[test] + fn model_selection_marks_new_channels_touched_and_switching_blocks_stale_input() { + let mut app = fixture_app(true); + let editor = fixture_editor("custom"); + app.provider_editor_form = Some(ProviderEditorForm::from_editor(&editor, app.lang)); + app.provider_editor = ProviderEditorState::Loaded(Box::new(editor)); + app.editor_draft = true; + app.apply_frontend_actions( + vec![ + frontend::view_model::FrontendAction::SettingsProviderModelSelected( + "selected-model".into(), + ), + ], + &egui::Context::default(), + ); + assert!(app.draft_touched); + assert_eq!( + app.provider_editor_form.as_ref().unwrap().model, + "selected-model" + ); + app.provider_switch_pending = true; + app.apply_frontend_actions( + vec![frontend::view_model::FrontendAction::SettingsProviderField( + frontend::view_model::SettingsProviderField::Model, + "stale-input".into(), + )], + &egui::Context::default(), + ); + assert_eq!( + app.provider_editor_form.as_ref().unwrap().model, + "selected-model" + ); + } + + #[test] + fn model_catalog_for_the_previous_endpoint_is_ignored() { + let mut app = fixture_app(true); + let editor = fixture_editor("custom"); + app.provider_kind = editor.kind; + app.selected_channel_id = Some(editor.channel.id.clone()); + app.provider_editor_form = Some(ProviderEditorForm::from_editor(&editor, app.lang)); + app.provider_editor = ProviderEditorState::Loaded(Box::new(editor.clone())); + app.tx + .send(UiResult::ProviderModels { + request_format: editor.protocol.format(), + kind: editor.kind, + channel_id: editor.channel.id, + endpoint: "https://old.example.invalid".into(), + provider_type: "custom".into(), + result: Ok(vec!["old-model".into()]), + }) + .unwrap(); + app.poll(&egui::Context::default()); + assert!(app.provider_editor_form.as_ref().unwrap().models.is_empty()); + } + + 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, + ) + } + + /// Omni 的测试与拉模型请求不带 channel_id:Core 明确拒绝 + /// `omni + channel_id`,带上就等于每次测试都失败。 + #[test] + fn omni_requests_never_carry_a_channel_id() { + let request = omni_provider_request(false); + assert_eq!(request.kind, openless_core::ProviderKind::Omni); + assert!( + request.channel_id.is_none(), + "Core rejects omni requests that carry a channel id" + ); + } + + /// 编辑已有渠道时,凭据要读回表单(输入框按密码掩码显示)。写死空串会让用户 + /// 以为密钥没保存,测试之后刷新一次就"消失"。 + #[test] + fn the_channel_editor_hydrates_the_stored_secret() { + use std::sync::Arc; + let runtime = tokio::runtime::Runtime::new().unwrap(); + let dir = std::env::temp_dir().join(format!( + "openless-editor-hydrate-{}", + uuid::Uuid::new_v4().simple() + )); + let store = Arc::new(openless_linux_egui::InMemoryCredentialStore::default()); + let mut deps = openless_core::BackendDependencies::unsupported(); + deps.credential_store = store.clone(); + deps.task_spawner = Arc::new(openless_core::TokioTaskSpawner); + let backend = Arc::new( + openless_core::OpenLessBackend::new( + openless_core::BackendConfig { + data_dir: dir.clone(), + ..Default::default() + }, + deps, + ) + .unwrap(), + ); + let channel = openless_core::ChannelSummary { + id: "deepseek".into(), + name: "DeepSeek".into(), + provider_type: "deepseek".into(), + enabled: true, + order: 0, + last_test: None, + }; + let descriptor = openless_core::provider_rules::provider_descriptor( + openless_core::ProviderKind::Llm, + "deepseek", + ) + .expect("deepseek descriptor"); + runtime.block_on(async { + for (account, value) in [ + ( + openless_core::credentials::LLM_ENDPOINT_ACCOUNT, + // `check-linux-public-surface.ps1` 禁止 linux UI 源码里出现 + // 默认服务地址的字面量(默认端点属于 Core)。用 concat! 拼出来 + // 只是为了让用例的 URL 形状真实,不构成任何默认值。 + concat!("https://api.deepseek.com", "/v1"), + ), + ( + openless_core::credentials::LLM_MODEL_ACCOUNT, + "deepseek-chat", + ), + ( + openless_core::credentials::LLM_API_KEY_ACCOUNT, + "sk-stored-key", + ), + ] { + let key = provider_credential_key( + openless_core::ChannelKind::Llm, + &channel.id, + account, + ) + .unwrap(); + backend + .set_credential(key, openless_core::SecretValue::new(value)) + .await + .unwrap(); + } + }); + let editor = runtime + .block_on(load_provider_editor( + Arc::clone(&backend), + openless_core::ChannelKind::Llm, + channel, + descriptor, + )) + .expect("editor loads"); + assert_eq!(editor.endpoint, concat!("https://api.deepseek.com", "/v1")); + assert_eq!(editor.model, "deepseek-chat"); + assert_eq!( + editor.primary_secret, "sk-stored-key", + "the stored API key must be hydrated so the masked field is not empty" + ); + let _ = std::fs::remove_dir_all(dir); + } + + /// 快捷键卡片上的每一行都必须从 Core 偏好取真值。宿主以前只填了 + /// 听写/翻译/QA/速记四行,其余行无论偏好里有没有绑定都画成空键帽 —— + /// 看起来是「未设置」,录制却实实在在地写进了偏好。 + #[test] + fn shortcut_rows_show_the_bindings_core_holds() { + let mut app = fixture_app(true); + app.lang = Lang::En; + app.preferences = Some(UserPreferences { + dictation_hotkey: openless_core::shared_types::ShortcutBinding { + primary: "RightOption".into(), + modifiers: Vec::new(), + }, + 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; + assert_eq!(vm.dictation_hotkey, "Right Alt"); + 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] @@ -8593,6 +11212,8 @@ Internal flags (set by OpenLess itself, not for regular use): less_computer_input: String::new(), outgoing_sequence: 0, ready_sent: false, + warming_started: None, + warmup_ms: openless_linux_egui::load_capsule_warmup_ms(), avatar: QaAvatar::default(), lang: Lang::ZhCn, overlay: None, @@ -8600,6 +11221,290 @@ Internal flags (set by OpenLess itself, not for regular use): } } + fn vocab_card_message(session_id: &str, sequence: u64) -> HostToPopup { + HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.into(), + sequence, + phase: String::new(), + text: String::new(), + transcript: String::new(), + transcript_font_size: 14, + audio_level: None, + translation_active: false, + style: "siri".into(), + suggestions: vec![openless_linux_egui::CapsuleSuggestion { + id: "candidate".into(), + pattern: "old".into(), + replacement: "new".into(), + }], + } + } + + fn vocab_host_app() -> (tempfile::TempDir, OpenLessEguiApp) { + let dir = tempfile::tempdir().unwrap(); + let mut app = fixture_app(false); + let host_actions = Arc::new(openless_linux_egui::LinuxHostActions::default()); + let mut dependencies = openless_core::BackendDependencies::unsupported(); + dependencies.host_actions = host_actions.clone(); + dependencies.credential_store = + Arc::new(openless_core::InMemoryCredentialStore::default()); + dependencies.services.remote_input = Arc::new( + openless_core::RemoteInputService::new( + Arc::new(openless_core::testing::RecordingRemoteInputRuntime::default()), + 8443, + "en", + ) + .unwrap(), + ); + let backend = Arc::new( + openless_core::OpenLessBackend::new( + BackendConfig { + data_dir: dir.path().to_path_buf(), + ..Default::default() + }, + dependencies, + ) + .unwrap(), + ); + let runtime = openless_linux_egui::LinuxBackendRuntime { + backend: backend.clone(), + host_actions, + settings_runtime: Arc::new(openless_core::settings::NoopSettingsRuntime), + }; + app.native = Some( + app.tokio + .block_on(LinuxNativeRuntime::start(runtime, None, None)) + .unwrap(), + ); + app.snapshot = Some(backend.snapshot()); + app.preferences = Some(backend.get_preferences()); + app.capsule_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + (dir, app) + } + + #[test] + fn layer_capsule_waits_for_content_and_exits_only_for_an_accepted_hide() { + let (tx, rx) = mpsc::channel(); + let mut app = popup_app(PopupKind::Capsule, rx); + let ctx = egui::Context::default(); + let raw = || egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(640.0, 240.0), + )), + ..Default::default() + }; + // 无 GPU 提交的测试要清掉纹理增量,避免析构掩盖断言结果。 + let mut waiting = app.layer_frame(&ctx, raw(), false); + waiting.output.textures_delta.clear(); + assert!(!waiting.exit); + assert!(waiting.output.shapes.is_empty()); + tx.send(vocab_card_message("card", 1)).unwrap(); + let mut visible = app.layer_frame(&ctx, raw(), false); + visible.output.textures_delta.clear(); + assert!(!visible.exit); + assert!(!visible.output.shapes.is_empty()); + for (session_id, sequence) in [("card", 1), ("old-card", 2)] { + tx.send(HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id: session_id.into(), + sequence, + }) + .unwrap(); + let mut frame = app.layer_frame(&ctx, raw(), false); + frame.output.textures_delta.clear(); + assert!(!frame.exit); + } + tx.send(HostToPopup::Hide { + version: POPUP_PROTOCOL_VERSION, + session_id: "card".into(), + sequence: 2, + }) + .unwrap(); + let mut hidden = app.layer_frame(&ctx, raw(), false); + hidden.output.textures_delta.clear(); + assert!(hidden.exit); + assert!(!app.state.visible); + assert!(hidden.output.shapes.is_empty()); + } + + #[test] + fn dictation_starting_clears_the_vocab_card_before_recording() { + let (_dir, mut app) = vocab_host_app(); + let backend = app.backend().unwrap(); + backend + .queue_pending_correction("old".into(), "new".into()) + .unwrap(); + app.refresh_vocab_suggestions(); + assert!(app.vocab_card_session.is_some()); + let session = openless_core::SessionId::new(); + app.apply_event(BackendEvent { + sequence: 1, + session_id: Some(session), + kind: BackendEventKind::DictationStateChanged( + openless_core::DictationStateSnapshot { + phase: DictationPhase::Starting, + session_id: Some(session), + ..Default::default() + }, + ), + }); + assert!(app.vocab_suggestions.is_empty()); + assert!(app.vocab_card_session.is_none()); + assert!(backend.pending_corrections().is_empty()); + assert_eq!(app.capsule_session, Some(session.to_string())); + } + + #[test] + fn vocab_actions_use_card_identity_and_reject_duplicates_and_old_cards() { + let (_dir, mut app) = vocab_host_app(); + let backend = app.backend().unwrap(); + let first = backend + .queue_pending_correction("first".into(), "First".into()) + .unwrap() + .unwrap(); + let second = backend + .queue_pending_correction("second".into(), "Second".into()) + .unwrap() + .unwrap(); + app.refresh_vocab_suggestions(); + let token = app.vocab_card_session.clone().unwrap(); + assert_eq!( + app.expected_popup_session(PopupKind::Capsule), + Some(token.clone()) + ); + app.snapshot.as_mut().unwrap().dictation.session_id = + Some(openless_core::SessionId::new()); + assert_eq!( + app.expected_popup_session(PopupKind::Capsule), + Some(token.clone()) + ); + let action = |token: &str, id: &str, sequence, accept| { + PopupSupervisorEvent::Message(PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id: token.into(), + sequence, + id: id.into(), + accept, + }) + }; + for (card, id, sequence) in [ + ("old-card", first.id.as_str(), 100), + (token.as_str(), "missing", 100), + (token.as_str(), first.id.as_str(), 0), + ] { + app.apply_popup_event(PopupKind::Capsule, action(card, id, sequence, true)); + assert_eq!(backend.pending_corrections().len(), 2); + assert!(backend.list_vocabulary().unwrap().is_empty()); + } + app.apply_popup_event(PopupKind::Qa, action(&token, &first.id, 1, true)); + assert!(backend.list_vocabulary().unwrap().is_empty()); + app.apply_popup_event(PopupKind::Capsule, action(&token, &first.id, 1, true)); + assert_eq!(backend.list_vocabulary().unwrap()[0].phrase, "First"); + assert_eq!(app.vocab_card_session.as_ref(), Some(&token)); + app.apply_popup_event(PopupKind::Capsule, action(&token, &second.id, 1, false)); + assert_eq!(backend.pending_corrections().len(), 1); + app.apply_popup_event(PopupKind::Capsule, action(&token, &second.id, 2, false)); + assert!(backend.pending_corrections().is_empty()); + assert!(app.vocab_card_session.is_none()); + let next = backend + .queue_pending_correction("third".into(), "Third".into()) + .unwrap() + .unwrap(); + app.refresh_vocab_suggestions(); + let next_token = app.vocab_card_session.clone().unwrap(); + assert_ne!(next_token, token); + app.apply_popup_event(PopupKind::Capsule, action(&token, &next.id, 100, true)); + assert_eq!(backend.pending_corrections().len(), 1); + app.apply_popup_event(PopupKind::Capsule, action(&next_token, &next.id, 1, false)); + assert!(backend.pending_corrections().is_empty()); + assert!(app.vocab_card_session.is_none()); + } + + #[test] + fn an_empty_core_card_hides_even_after_the_host_candidates_are_cleared() { + let (_dir, mut app) = vocab_host_app(); + let backend = app.backend().unwrap(); + let candidate = backend + .queue_pending_correction("old".into(), "new".into()) + .unwrap() + .unwrap(); + app.refresh_vocab_suggestions(); + let token = app.vocab_card_session.clone().unwrap(); + let sequence = app.popup_sequences[popup_kind_index(PopupKind::Capsule)]; + backend.dismiss_pending_corrections(); + app.apply_popup_event( + PopupKind::Capsule, + PopupSupervisorEvent::Message(PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id: token, + sequence: 1, + id: candidate.id, + accept: true, + }), + ); + assert!(backend.list_vocabulary().unwrap().is_empty()); + app.refresh_vocab_suggestions(); + assert!(app.vocab_suggestions.is_empty()); + assert!(app.vocab_card_session.is_none()); + assert_eq!( + app.popup_sequences[popup_kind_index(PopupKind::Capsule)], + sequence + 1 + ); + } + + #[test] + fn capsule_rebuild_rotates_card_identity_and_rejects_old_process_events() { + let mut app = fixture_app(false); + let HostToPopup::Capsule { suggestions, .. } = vocab_card_message("old-card", 1) else { + unreachable!() + }; + app.vocab_suggestions = suggestions; + app.vocab_card_session = Some("old-card".into()); + let old_generation = app.capsule_popup_generation; + app.ensure_popup(PopupKind::Capsule); + let token = app.vocab_card_session.clone().unwrap(); + assert_ne!(token, "old-card"); + assert_ne!(app.capsule_popup_generation, old_generation); + app.capsule_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + app.apply_popup_event_in_generation( + PopupKind::Capsule, + old_generation, + PopupSupervisorEvent::Exited { + code: Some(0), + crashed: false, + }, + ); + app.apply_popup_event_in_generation( + PopupKind::Capsule, + old_generation, + PopupSupervisorEvent::SpawnFailed("old failure".into()), + ); + assert!(app.capsule_popup.is_some()); + let sequence = app.popup_sequences[popup_kind_index(PopupKind::Capsule)]; + app.apply_popup_event( + PopupKind::Capsule, + PopupSupervisorEvent::Message(PopupToHost::Ready { + version: POPUP_PROTOCOL_VERSION, + session_id: token.clone(), + sequence: 1, + kind: PopupKind::Capsule, + }), + ); + assert_eq!(app.vocab_card_session, Some(token)); + assert_eq!( + app.popup_sequences[popup_kind_index(PopupKind::Capsule)], + sequence + 1 + ); + } + #[test] fn a_closed_host_pipe_exits_the_popup() { // 宿主进程崩溃/被杀时 stdin 到 EOF、发送端析构。以前 Empty 与 @@ -8621,9 +11526,12 @@ Internal flags (set by OpenLess itself, not for regular use): sequence: 1, phase: "Recording".into(), text: String::new(), + transcript: String::new(), + transcript_font_size: 14, audio_level: Some(0.2), translation_active: false, style: "siri".into(), + suggestions: Vec::new(), }) .expect("channel is open"); assert!(!app.pump(None), "a progress frame must not exit"); @@ -8652,6 +11560,195 @@ Internal flags (set by OpenLess itself, not for regular use): assert!(!qa_hides_on_host_action(true)); } + #[test] + fn closing_qa_drops_its_window_and_late_idle_does_not_reopen_it() { + let mut app = fixture_app(true); + app.qa_visible = true; + app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + app.toggle_qa_panel(); + assert!(!app.qa_visible); + assert!(app.qa_popup.is_none()); + // 收起后迟到的 Idle 快照不得把窗口拉回来。 + app.apply_event(BackendEvent { + sequence: 1, + session_id: None, + kind: BackendEventKind::QaState(QaStateEvent::simple(QaStateKind::Idle)), + }); + assert!(app.qa_popup.is_none()); + assert!(app.expected_popup_session(PopupKind::Qa).is_none()); + } + + #[test] + fn late_qa_action_failures_do_not_overwrite_a_new_turn() { + let mut app = fixture_app(true); + let mut state = QaStateEvent::simple(QaStateKind::Thinking); + state.session_id = Some(openless_core::SessionId::new().to_string()); + app.qa_state = Some(state); + app.qa_visible = true; + // 空态(context 为 None)的失败不能写到已有会话的面板上。 + app.apply_qa_action_result( + app.qa_popup_presentation.generation, + None, + Err("old failure".into()), + ); + assert!(app.qa_state.as_ref().unwrap().error.is_none()); + } + + #[test] + fn a_previous_preview_cannot_close_the_current_qa_window() { + let mut app = fixture_app(true); + let current = openless_core::SessionId::new().to_string(); + app.qa_visible = true; + app.qa_popup_presentation.session_id = Some(current.clone()); + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + app.hide_popup( + PopupKind::Qa, + openless_core::SessionId::new().to_string(), + 1, + ); + assert!(app.qa_popup.is_some()); + assert!(app.qa_visible); + assert_eq!(app.qa_popup_presentation.session_id, Some(current)); + } + + #[test] + fn a_previous_qa_turn_cannot_close_the_current_preview() { + let mut app = fixture_app(true); + let current = openless_core::SessionId::new(); + app.polish_result_visible = true; + app.selection = Some(SelectionSnapshot { + session_id: Some(current), + phase: SelectionPhase::Preview, + ..Default::default() + }); + app.qa_popup_presentation.session_id = Some(current.to_string()); + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + app.hide_popup( + PopupKind::Qa, + openless_core::SessionId::new().to_string(), + 1, + ); + assert!(app.qa_popup.is_some()); + assert!(app.polish_result_visible); + assert_eq!( + app.expected_popup_session(PopupKind::Qa), + Some(current.to_string()) + ); + } + + #[test] + fn closing_the_current_preview_also_clears_qa_visibility() { + let mut app = fixture_app(true); + let current = openless_core::SessionId::new().to_string(); + app.qa_visible = true; + app.qa_popup_presentation.session_id = Some(current.clone()); + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + app.hide_popup(PopupKind::Qa, current, 1); + assert!(app.qa_popup.is_none()); + assert!(!app.qa_visible); + assert!(!app.polish_result_visible); + } + + #[test] + fn background_qa_updates_leave_the_current_preview_visible() { + let mut app = fixture_app(true); + let preview = openless_core::SessionId::new().to_string(); + app.qa_visible = true; + app.polish_result_visible = true; + app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); + app.qa_popup_presentation.session_id = Some(preview.clone()); + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + let generation = app.qa_popup_presentation.generation; + app.show_qa_popup(); + assert!(app.qa_popup.is_some()); + assert_eq!(app.qa_popup_presentation.session_id, Some(preview)); + assert_eq!(app.qa_popup_presentation.generation, generation); + } + + #[test] + fn an_old_process_exit_cannot_clear_a_reopened_qa_window() { + let mut app = fixture_app(true); + let old_generation = app.qa_popup_presentation.generation; + app.close_qa_popup(); + app.qa_visible = true; + app.qa_popup = Some(PopupSupervisor::spawn_command( + app.tokio.handle(), + tokio::process::Command::new("/bin/cat"), + )); + app.apply_popup_event_in_generation( + PopupKind::Qa, + old_generation, + PopupSupervisorEvent::Exited { + code: Some(0), + crashed: false, + }, + ); + assert!(app.qa_popup.is_some()); + assert!(app.qa_visible); + } + + #[test] + fn an_old_idle_failure_cannot_appear_in_a_reopened_idle_panel() { + let mut app = fixture_app(true); + app.qa_visible = true; + app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); + let old_generation = app.qa_popup_presentation.generation; + app.close_qa_popup(); + app.qa_visible = true; + app.qa_state = Some(QaStateEvent::simple(QaStateKind::Idle)); + let status = app.status.clone(); + app.apply_qa_action_result(old_generation, None, Err("old failure".into())); + assert!(app.qa_state.as_ref().unwrap().error.is_none()); + assert_eq!(app.status, status); + app.apply_qa_action_result( + app.qa_popup_presentation.generation, + None, + Err("current failure".into()), + ); + assert_eq!( + app.qa_state.as_ref().unwrap().error.as_deref(), + Some("current failure") + ); + } + + #[test] + fn popup_sequences_advance_without_new_core_events() { + // 同一 Core 事件下重复发快照:序号必须递增,否则弹窗会把新快照当旧消息丢掉。 + let mut message = HostToPopup::QaSnapshot { + version: POPUP_PROTOCOL_VERSION, + session_id: "qa".into(), + sequence: 0, + phase: "Idle".into(), + messages: Vec::new(), + selection_preview: None, + streaming_answer: String::new(), + error: None, + edit_instruction_mode: false, + edit_apply_available: false, + edit_revert_available: false, + pinned: false, + viewer_login: String::new(), + }; + message.set_sequence(3); + assert_eq!(message.sequence(), 3); + } + #[test] fn qa_edit_flags_merge_partial_core_updates() { let mut flags = QaEditFlags::default(); @@ -8983,6 +12080,9 @@ Internal flags (set by OpenLess itself, not for regular use): #[cfg(target_os = "linux")] fn main() { + // Select a process-wide provider before HTTPS or secure WebSocket clients start. + // reqwest and the native TLS listener enable different rustls provider features. + let _ = rustls::crypto::ring::default_provider().install_default(); if let Err(error) = linux_app::run() { eprintln!("OpenLess Linux UI failed: {error}"); std::process::exit(1); diff --git a/openless-all/app/linux-egui/src/omni_settings.rs b/openless-all/app/linux-egui/src/omni_settings.rs new file mode 100644 index 000000000..a0ae24845 --- /dev/null +++ b/openless-all/app/linux-egui/src/omni_settings.rs @@ -0,0 +1,243 @@ +use openless_core::credentials::{ + OMNI_API_KEY_ACCOUNT, OMNI_ENDPOINT_ACCOUNT, OMNI_EXTRA_HEADERS_ACCOUNT, OMNI_MODEL_ACCOUNT, + OMNI_TEMPERATURE_ACCOUNT, +}; +use openless_core::{ + BackendError, CredentialKey, CredentialNamespace, OpenLessBackend, ProviderKind, ProviderSlot, + SecretValue, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum OmniField { + Model, + Endpoint, + ApiKey, + Temperature, + ExtraHeaders, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum OmniAction { + Field(OmniField, String), + Provider(String), + Save, + ClearSecrets, + Models, + Validate, + Reload, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OmniPreset { + pub id: String, + pub label_key: String, + pub endpoint: String, + pub model: String, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OmniSettings { + pub provider: String, + pub presets: Vec, + pub model: String, + pub endpoint: String, + pub api_key: String, + pub temperature: String, + pub extra_headers: String, + pub models: Vec, + pub loaded: bool, + pub busy: bool, + pub switching: bool, + pub revision: u64, + pub dirty: bool, + pub notice: Option, +} +impl Default for OmniSettings { + fn default() -> Self { + Self { + provider: "custom".into(), + presets: openless_core::provider_rules::provider_descriptors(ProviderKind::Omni) + .into_iter() + .map(|d| OmniPreset { + id: d.provider_type.as_str().into(), + label_key: d.label_key, + endpoint: d.default_endpoint.unwrap_or_default(), + model: d.default_model.unwrap_or_default(), + }) + .collect(), + model: String::new(), + endpoint: String::new(), + api_key: String::new(), + temperature: String::new(), + extra_headers: String::new(), + models: Vec::new(), + loaded: false, + busy: false, + switching: false, + revision: 0, + dirty: false, + notice: None, + } + } +} +impl OmniSettings { + pub fn validate(&self) -> Result<(), BackendError> { + if self.provider == "custom" { + openless_core::parse_temperature(&self.temperature)?; + openless_core::provider_rules::parse_extra_headers(&self.extra_headers)?; + } + Ok(()) + } + pub fn edit(&mut self, field: OmniField, value: String) { + match field { + OmniField::Model => self.model = value, + OmniField::Endpoint => { + self.endpoint = value; + self.models.clear(); + } + OmniField::ApiKey => self.api_key = value, + OmniField::Temperature => self.temperature = value, + OmniField::ExtraHeaders => self.extra_headers = value, + } + self.revision = self.revision.wrapping_add(1); + self.dirty = true; + self.notice = None; + } + pub fn merge_receipt(&self, submitted: &Self, mut stored: Self) -> Self { + if self.provider == stored.provider && self.revision != submitted.revision { + if self.model != submitted.model { + stored.model = self.model.clone(); + } + if self.endpoint != submitted.endpoint { + stored.endpoint = self.endpoint.clone(); + stored.models.clear(); + } + if self.api_key != submitted.api_key { + stored.api_key = self.api_key.clone(); + } + if self.temperature != submitted.temperature { + stored.temperature = self.temperature.clone(); + } + if self.extra_headers != submitted.extra_headers { + stored.extra_headers = self.extra_headers.clone(); + } + stored.revision = self.revision; + stored.dirty = true; + } + stored + } + pub async fn load(backend: &OpenLessBackend) -> Result { + let mut state = Self::default(); + let provider = backend.get_preferences().active_omni_provider; + if state.presets.iter().any(|p| p.id == provider) { + state.provider = provider; + } + state.model = read(backend, &state.provider, OMNI_MODEL_ACCOUNT).await?; + state.endpoint = read(backend, &state.provider, OMNI_ENDPOINT_ACCOUNT).await?; + if state.provider == "custom" { + state.temperature = read(backend, &state.provider, OMNI_TEMPERATURE_ACCOUNT).await?; + } + // 与 Tauri 的 `CredentialField` 一致:把已存的密钥读进表单(输入框按密码 + // 掩码显示),否则用户每次打开都以为「密钥没保存」,测试完刷新一次就变空。 + state.api_key = read(backend, &state.provider, OMNI_API_KEY_ACCOUNT).await?; + if state.provider == "custom" { + state.extra_headers = + read(backend, &state.provider, OMNI_EXTRA_HEADERS_ACCOUNT).await?; + } + state.loaded = true; + Ok(state) + } + pub async fn save(&self, backend: &OpenLessBackend) -> Result<(), BackendError> { + self.validate()?; + put(backend, &self.provider, OMNI_MODEL_ACCOUNT, &self.model).await?; + put( + backend, + &self.provider, + OMNI_ENDPOINT_ACCOUNT, + &self.endpoint, + ) + .await?; + if !self.api_key.trim().is_empty() { + put(backend, &self.provider, OMNI_API_KEY_ACCOUNT, &self.api_key).await?; + } + if self.provider == "custom" { + put( + backend, + &self.provider, + OMNI_TEMPERATURE_ACCOUNT, + &self.temperature, + ) + .await?; + if !self.extra_headers.trim().is_empty() { + put( + backend, + &self.provider, + OMNI_EXTRA_HEADERS_ACCOUNT, + &self.extra_headers, + ) + .await?; + } + } + Ok(()) + } + pub async fn select(backend: &OpenLessBackend, id: &str) -> Result<(), BackendError> { + let descriptor = openless_core::provider_rules::provider_descriptor(ProviderKind::Omni, id) + .ok_or_else(|| { + BackendError::new( + openless_core::BackendErrorCode::InvalidArgument, + "unknown Omni provider", + ) + })?; + backend + .set_active_provider(ProviderSlot::Omni, id.into()) + .await?; + if id != "custom" { + if let Some(endpoint) = descriptor.default_endpoint { + put(backend, id, OMNI_ENDPOINT_ACCOUNT, &endpoint).await?; + } + if let Some(model) = descriptor.default_model { + put(backend, id, OMNI_MODEL_ACCOUNT, &model).await?; + } + } + Ok(()) + } + pub async fn clear_secrets(&self, backend: &OpenLessBackend) -> Result<(), BackendError> { + for account in [OMNI_API_KEY_ACCOUNT, OMNI_EXTRA_HEADERS_ACCOUNT] { + put(backend, &self.provider, account, "").await?; + } + Ok(()) + } +} +fn key(provider: Option, account: &str) -> Result { + CredentialKey::new(CredentialNamespace::Omni, provider, account) +} +async fn read( + backend: &OpenLessBackend, + provider: &str, + account: &str, +) -> Result { + // Use the same provider-scoped key as the production Omni pipeline. + // Legacy import is handled by the credential store, not by UI fallbacks. + let value = backend + .read_credential(key(Some(provider.into()), account)?) + .await?; + Ok(value.map(SecretValue::into_exposed).unwrap_or_default()) +} +async fn put( + backend: &OpenLessBackend, + provider: &str, + account: &str, + value: &str, +) -> Result<(), BackendError> { + if value.trim().is_empty() { + backend + .remove_credential(key(Some(provider.into()), account)?) + .await?; + } else { + backend + .set_credential( + key(Some(provider.into()), account)?, + SecretValue::new(value.trim()), + ) + .await?; + } + Ok(()) +} diff --git a/openless-all/app/linux-egui/src/popup.rs b/openless-all/app/linux-egui/src/popup.rs index ba66b379d..0cb3d09ff 100644 --- a/openless-all/app/linux-egui/src/popup.rs +++ b/openless-all/app/linux-egui/src/popup.rs @@ -121,6 +121,10 @@ pub enum HostToPopup { phase: String, #[serde(default)] text: String, + #[serde(default)] + transcript: String, + #[serde(default = "default_transcript_font_size")] + transcript_font_size: u8, #[serde(default, skip_serializing_if = "Option::is_none")] audio_level: Option, /// 正在翻译:药丸上方显示「正在翻译」徽章(Tauri `capsule.translating`)。 @@ -129,6 +133,11 @@ pub enum HostToPopup { /// 胶囊样式:`siri` / `classic` / `typeless`(Tauri `capsuleStyle`)。 #[serde(default)] style: String, + /// 「要记住这个词吗?」卡片:非空时胶囊窗改画确认卡(Tauri + /// `VocabSuggestionCard`)。音频会话已经结束时也会发(phase 为空), + /// 因为这张卡只在**没有**会话时出现。 + #[serde(default)] + suggestions: Vec, }, Hide { version: u16, @@ -169,6 +178,8 @@ pub enum HostToPopup { } impl HostToPopup { + /// 宿主是弹窗序列号的唯一所有者:窗口动作与失败可能在**没有新 Core 事件**时 + /// 发生,只有递增序号才能让弹窗接受新的快照(同序号会被当成旧消息丢弃)。 pub fn set_sequence(&mut self, value: u64) { match self { Self::PolishPreview { sequence, .. } @@ -256,6 +267,15 @@ pub enum PopupToHost { sequence: u64, text: String, }, + /// 卡片上的一次「记住 / 不用」(Tauri `acceptPendingCorrection` / + /// `rejectPendingCorrection`)。`id` 是 Core 给的候选 id。 + VocabSuggestion { + version: u16, + session_id: String, + sequence: u64, + id: String, + accept: bool, + }, /// 取消本次润色(由选区助手面板发出)。 CancelPolish { version: u16, @@ -361,6 +381,7 @@ impl PopupToHost { | Self::ToggleQaRecording { version, .. } | Self::DismissQa { version, .. } | Self::DismissCapsule { version, .. } + | Self::VocabSuggestion { version, .. } | Self::CancelDictation { version, .. } | Self::StopDictation { version, .. } | Self::SetPinned { version, .. } @@ -384,6 +405,7 @@ impl PopupToHost { | Self::ToggleQaRecording { session_id, .. } | Self::DismissQa { session_id, .. } | Self::DismissCapsule { session_id, .. } + | Self::VocabSuggestion { session_id, .. } | Self::CancelDictation { session_id, .. } | Self::StopDictation { session_id, .. } | Self::SetPinned { session_id, .. } @@ -407,6 +429,7 @@ impl PopupToHost { | Self::ToggleQaRecording { sequence, .. } | Self::DismissQa { sequence, .. } | Self::DismissCapsule { sequence, .. } + | Self::VocabSuggestion { sequence, .. } | Self::CancelDictation { sequence, .. } | Self::StopDictation { sequence, .. } | Self::SetPinned { sequence, .. } @@ -435,6 +458,7 @@ impl PopupToHost { | Self::ApplyEdit { .. } | Self::RevertEdit { .. } => PopupKind::Qa, Self::DismissCapsule { .. } + | Self::VocabSuggestion { .. } | Self::CancelDictation { .. } | Self::StopDictation { .. } => PopupKind::Capsule, Self::SubmitLessComputer { .. } @@ -677,14 +701,49 @@ pub struct QaPopupState { pub viewer_login: String, } -#[derive(Debug, Clone, Default, PartialEq)] +fn default_transcript_font_size() -> u8 { + 14 +} + +#[derive(Debug, Clone, PartialEq)] pub struct CapsulePopupState { pub phase: String, pub text: String, pub audio_level: Option, + pub transcript: String, + pub transcript_font_size: u8, pub translation_active: bool, /// 胶囊样式(`siri` / `classic` / `typeless`)。 pub style: String, + /// 「要记住这个词吗?」卡片的内容(Tauri `PendingCorrection`)。非空时整窗只画 + /// 这张卡 —— 它就是「确认式词库学习」的确认入口,没有它自动学词永远不会落库。 + pub suggestions: Vec, +} + +impl Default for CapsulePopupState { + fn default() -> Self { + Self { + phase: String::new(), + text: String::new(), + audio_level: None, + transcript: String::new(), + transcript_font_size: 14, + translation_active: false, + style: String::new(), + suggestions: Vec::new(), + } + } +} + +/// 卡片上的一行:用户手改出来的「错误写法 → 想要的写法」(Core +/// `PendingCorrection`,只在这里展示,不落库;接受才写进词库)。 +#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct CapsuleSuggestion { + pub id: String, + /// 手改前的写法,只用于让用户看清改了什么。 + pub pattern: String, + /// 用户最终想要的写法 —— 点「记住」后进词库的是它。 + pub replacement: String, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -790,17 +849,23 @@ impl PopupState { HostToPopup::Capsule { phase, text, + transcript, + transcript_font_size, audio_level, translation_active, style, + suggestions, .. } => { self.capsule = CapsulePopupState { phase, text, + transcript, + transcript_font_size: transcript_font_size.clamp(12, 20), audio_level, translation_active, style, + suggestions, }; self.visible = true; } @@ -950,7 +1015,14 @@ impl PopupSupervisor { .stdout(Stdio::piped()) .stderr(Stdio::inherit()) .kill_on_drop(true); - runtime.spawn(supervise(command, command_rx, event_tx)); + runtime.spawn(supervise( + command, + command_rx, + PopupEventSender { + sender: event_tx, + waker: std::thread::current(), + }, + )); Self { commands: command_tx, events: event_rx, @@ -986,10 +1058,26 @@ impl Drop for PopupSupervisor { } } +struct PopupEventSender { + sender: mpsc::SyncSender, + waker: std::thread::Thread, +} + +impl PopupEventSender { + fn try_send( + &self, + event: PopupSupervisorEvent, + ) -> Result<(), mpsc::TrySendError> { + let result = self.sender.try_send(event); + self.waker.unpark(); + result + } +} + async fn supervise( mut command: Command, mut commands: tokio_mpsc::Receiver, - events: mpsc::SyncSender, + events: PopupEventSender, ) { let mut child = match command.spawn() { Ok(child) => child, @@ -1455,6 +1543,54 @@ mod tests { assert!(state.qa.viewer_login.is_empty()); } + #[test] + fn capsule_carries_the_vocab_suggestion_card() { + let message = HostToPopup::Capsule { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 5, + phase: String::new(), + text: String::new(), + transcript: String::new(), + transcript_font_size: 14, + audio_level: None, + translation_active: false, + style: "siri".to_owned(), + suggestions: vec![CapsuleSuggestion { + id: "c1".to_owned(), + pattern: "banana".to_owned(), + replacement: "bananas".to_owned(), + }], + }; + let mut state = PopupState::default(); + assert_eq!(state.apply(message), ApplyOutcome::Applied); + assert_eq!(state.capsule.suggestions.len(), 1); + assert_eq!(state.capsule.suggestions[0].replacement, "bananas"); + // 卡片与听写会话无关:消息不带会话 id 也要能落地。 + assert!(state.capsule.phase.is_empty()); + // 决策消息归胶囊窗,宿主按它回写词库。 + let decision = PopupToHost::VocabSuggestion { + version: POPUP_PROTOCOL_VERSION, + session_id: String::new(), + sequence: 0, + id: "c1".to_owned(), + accept: true, + }; + assert_eq!(decision.kind(), PopupKind::Capsule); + assert_eq!(decision.version(), POPUP_PROTOCOL_VERSION); + } + + /// 老宿主(协议 v2)不带 `suggestions` 时按空卡片解析,不能报错。 + #[test] + fn a_capsule_without_suggestions_still_parses() { + let legacy = + r#"{"type":"capsule","version":3,"session_id":"s","sequence":1,"phase":"Recording"}"#; + let message: HostToPopup = serde_json::from_str(legacy).expect("legacy capsule"); + let mut state = PopupState::default(); + assert_eq!(state.apply(message), ApplyOutcome::Applied); + assert!(state.capsule.suggestions.is_empty()); + } + #[test] fn capsule_carries_translation_active_and_style() { let message = HostToPopup::Capsule { @@ -1463,13 +1599,18 @@ mod tests { sequence: 3, phase: "Recording".to_owned(), text: String::new(), + transcript: "raw words".into(), + transcript_font_size: 18, audio_level: Some(0.5), translation_active: true, style: "typeless".to_owned(), + suggestions: Vec::new(), }; let mut state = PopupState::default(); assert_eq!(state.apply(message), ApplyOutcome::Applied); assert!(state.capsule.translation_active); + assert_eq!(state.capsule.transcript, "raw words"); + assert_eq!(state.capsule.transcript_font_size, 18); assert_eq!( state.capsule.style, "typeless", "the capsule style must travel with the frame to the popup process" diff --git a/openless-all/app/linux-egui/src/popup_layer.rs b/openless-all/app/linux-egui/src/popup_layer.rs index 03e51f7c8..be9676099 100644 --- a/openless-all/app/linux-egui/src/popup_layer.rs +++ b/openless-all/app/linux-egui/src/popup_layer.rs @@ -18,21 +18,23 @@ //! 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. +//! the runner below owns a Vulkan/WGPU surface 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::collections::BTreeMap; use std::ffi::c_void; -use std::num::NonZeroU32; +use std::os::fd::AsRawFd; 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::protocol::{ + wl_compositor, wl_pointer, wl_registry, wl_seat, wl_surface, wl_touch, +}; use wayland_client::{ globals::{registry_queue_init, GlobalListContents}, Connection, Dispatch, Proxy, QueueHandle, WEnum, @@ -250,6 +252,7 @@ pub struct LayerFrame { pub exit: bool, /// When to draw the next frame. pub repaint_after: Duration, + pub capsule_layout: Option<(CapsuleGeometry, bool)>, } // ── compositor probe ──────────────────────────────────────────────────────── @@ -275,6 +278,8 @@ struct LayerState { layer_shell: Option, seat: Option, pointer: Option, + touch: Option, + touch_input: TouchInput, 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. @@ -302,6 +307,7 @@ impl LayerState { } } all.extend(events); + all.append(&mut self.touch_input.events); egui::RawInput { screen_rect: Some(rect), events: all, @@ -311,6 +317,94 @@ impl LayerState { } } +/// Native touchscreen contacts also need pointer emulation for egui buttons, +/// just as egui-winit does. Do not promote another finger during a gesture. +#[derive(Default)] +struct TouchInput { + contacts: BTreeMap, + primary: Option, + events: Vec, +} + +impl TouchInput { + fn emit(&mut self, id: i32, phase: egui::TouchPhase, pos: egui::Pos2) { + self.events.push(egui::Event::Touch { + device_id: egui::TouchDeviceId(0), + id: egui::TouchId::from(id), + phase, + pos, + force: None, + }); + } + + fn button(&mut self, pos: egui::Pos2, pressed: bool) { + self.events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::default(), + }); + } + + fn cancel_pointer(&mut self) { + if self.primary.take().is_some() { + // PointerGone alone leaves egui's button held. Move off-surface + // before releasing, so cancellation cannot become a button click. + let outside = egui::pos2(-100_000.0, -100_000.0); + self.events.push(egui::Event::PointerMoved(outside)); + self.button(outside, false); + self.events.push(egui::Event::PointerGone); + } + } + + fn down(&mut self, id: i32, pos: egui::Pos2) { + let single = self.contacts.is_empty(); + let std::collections::btree_map::Entry::Vacant(contact) = self.contacts.entry(id) else { + return; + }; + contact.insert(pos); + self.emit(id, egui::TouchPhase::Start, pos); + if single { + self.primary = Some(id); + self.events.push(egui::Event::PointerMoved(pos)); + self.button(pos, true); + } else { + // Multi-finger gestures must not accidentally stop dictation. + self.cancel_pointer(); + } + } + + fn motion(&mut self, id: i32, pos: egui::Pos2) { + let Some(contact) = self.contacts.get_mut(&id) else { + return; + }; + *contact = pos; + self.emit(id, egui::TouchPhase::Move, pos); + if self.primary == Some(id) { + self.events.push(egui::Event::PointerMoved(pos)); + } + } + + fn up(&mut self, id: i32) { + let Some(pos) = self.contacts.remove(&id) else { + return; + }; + self.emit(id, egui::TouchPhase::End, pos); + if self.primary == Some(id) { + self.primary = None; + self.button(pos, false); + self.events.push(egui::Event::PointerGone); + } + } + + fn cancel(&mut self) { + self.cancel_pointer(); + for (id, pos) in std::mem::take(&mut self.contacts) { + self.emit(id, egui::TouchPhase::Cancel, pos); + } + } +} + fn button_from_code(button: u32) -> Option { match button { 0x110 => Some(egui::PointerButton::Primary), @@ -389,6 +483,16 @@ impl Dispatch for LayerState { if capabilities.contains(wl_seat::Capability::Pointer) && state.pointer.is_none() { state.pointer = Some(seat.get_pointer(qh, ())); } + if capabilities.contains(wl_seat::Capability::Touch) { + if state.touch.is_none() { + state.touch = Some(seat.get_touch(qh, ())); + } + } else if let Some(touch) = state.touch.take() { + state.touch_input.cancel(); + if touch.version() >= 3 { + touch.release(); + } + } } } } @@ -442,6 +546,29 @@ impl Dispatch for LayerState { } } +impl Dispatch for LayerState { + fn event( + state: &mut Self, + _touch: &wl_touch::WlTouch, + event: wl_touch::Event, + _data: &(), + _connection: &Connection, + _qh: &QueueHandle, + ) { + match event { + wl_touch::Event::Down { id, x, y, .. } => { + state.touch_input.down(id, egui::pos2(x as f32, y as f32)); + } + wl_touch::Event::Motion { id, x, y, .. } => { + state.touch_input.motion(id, egui::pos2(x as f32, y as f32)); + } + wl_touch::Event::Up { id, .. } => state.touch_input.up(id), + wl_touch::Event::Cancel => state.touch_input.cancel(), + _ => {} + } + } +} + ignore_events!(zwlr_layer_shell_v1::ZwlrLayerShellV1); impl Dispatch for LayerState { @@ -468,119 +595,230 @@ impl Dispatch for LayerState { } } -// ── EGL + egui_glow plumbing ──────────────────────────────────────────────── +// ── Vulkan/WGPU plumbing ──────────────────────────────────────────────────── + +fn capsule_alpha_mode( + supported: &[eframe::egui_wgpu::wgpu::CompositeAlphaMode], +) -> Result { + use eframe::egui_wgpu::wgpu::CompositeAlphaMode; + if supported.contains(&CompositeAlphaMode::PreMultiplied) { + Ok(CompositeAlphaMode::PreMultiplied) + } else { + Err(format!( + "Vulkan capsule surface lacks premultiplied transparency: {supported:?}" + )) + } +} -struct GlSurface { - _display: glutin::display::Display, - surface: glutin::surface::Surface, - context: glutin::context::PossiblyCurrentContext, - gl: Arc, +struct WgpuSurface { + _instance: eframe::egui_wgpu::wgpu::Instance, + surface: eframe::egui_wgpu::wgpu::Surface<'static>, + device: eframe::egui_wgpu::wgpu::Device, + queue: eframe::egui_wgpu::wgpu::Queue, + config: eframe::egui_wgpu::wgpu::SurfaceConfiguration, + renderer: eframe::egui_wgpu::Renderer, } -impl GlSurface { +impl WgpuSurface { 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 eframe::egui_wgpu::wgpu; 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 - } + 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, + }); + // The Wayland connection and wl_surface are owned by run_layer_capsule + // and outlive this surface. RawHandle is therefore the correct unsafe + // constructor: no legacy graphics context is created or retained. + let surface = unsafe { + instance.create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(raw_display), + raw_window_handle: raw_window, }) - .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")?, + } + .map_err(|error| format!("vulkan surface: {error}"))?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|error| format!("wgpu runtime: {error}"))?; + let adapter = runtime + .block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + apply_limit_buckets: false, + })) + .map_err(|error| format!("vulkan adapter: {error}"))?; + let (device, queue) = runtime + .block_on(adapter.request_device(&crate::wgpu_device::device_descriptor())) + .map_err(|error| format!("vulkan device: {error}"))?; + let mut config = surface + .get_default_config(&adapter, size.0.max(1), size.1.max(1)) + .ok_or("Wayland surface has no Vulkan format")?; + // Auto may select Opaque: clearing to transparent would then present + // a black rectangle. Both egui and Siri output premultiplied alpha. + config.alpha_mode = capsule_alpha_mode(&surface.get_capabilities(&adapter).alpha_modes)?; + // Zero-delay animation requests are paced by the compositor, never an + // immediate/mailbox swapchain followed by an unrelated fixed timer. + config.present_mode = wgpu::PresentMode::Fifo; + surface.configure(&device, &config); + let format = config.format; + let mut renderer = eframe::egui_wgpu::Renderer::new( + &device, + format, + eframe::egui_wgpu::RendererOptions { + msaa_samples: 1, + ..Default::default() + }, ); - 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()) - }) - }; + crate::siri_wgpu::install_renderer(&mut renderer, &device, format, 1); Ok(Self { - _display: display, + _instance: instance, surface, - context, - gl: Arc::new(gl), + device, + queue, + config, + renderer, }) } fn paint( - &self, - painter: &mut egui_glow::Painter, + &mut self, 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, + ) -> Result { + let width = size.0.max(1); + let height = size.1.max(1); + if self.config.width != width || self.config.height != height { + self.config.width = width; + self.config.height = height; + self.surface.configure(&self.device, &self.config); + } + // Texture deltas are incremental (not re-sent next frame). Upload them + // even if surface acquisition times out, so fonts/icons aren't lost. + for (id, deltas) in &textures_delta.set { + for delta in deltas { + self.renderer + .update_texture(&self.device, &self.queue, *id, delta); + } + } + let output = match self.surface.get_current_texture() { + eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Success(texture) + | eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Suboptimal(texture) => texture, + eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Timeout + | eframe::egui_wgpu::wgpu::CurrentSurfaceTexture::Occluded => { + for id in &textures_delta.free { + self.renderer.free_texture(id); + } + textures_delta.clear(); + return Ok(false); + } + other => return Err(format!("vulkan acquire: {other:?}")), + }; + let view = output + .texture + .create_view(&eframe::egui_wgpu::wgpu::TextureViewDescriptor::default()); + let screen = eframe::egui_wgpu::ScreenDescriptor { + size_in_pixels: [width, height], + pixels_per_point: scale, + }; + let mut encoder = self.device.create_command_encoder( + &eframe::egui_wgpu::wgpu::CommandEncoderDescriptor { + label: Some("openless-capsule-wgpu"), + }, + ); + let callbacks = self.renderer.update_buffers( + &self.device, + &self.queue, + &mut encoder, primitives, - textures_delta, + &screen, ); + { + let mut pass = encoder + .begin_render_pass(&eframe::egui_wgpu::wgpu::RenderPassDescriptor { + label: Some("openless-capsule"), + color_attachments: &[Some( + eframe::egui_wgpu::wgpu::RenderPassColorAttachment { + view: &view, + resolve_target: None, + ops: eframe::egui_wgpu::wgpu::Operations { + load: eframe::egui_wgpu::wgpu::LoadOp::Clear( + eframe::egui_wgpu::wgpu::Color::TRANSPARENT, + ), + store: eframe::egui_wgpu::wgpu::StoreOp::Store, + }, + depth_slice: None, + }, + )], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }) + .forget_lifetime(); + self.renderer.render(&mut pass, primitives, &screen); + } + self.queue.submit(callbacks); + self.queue.submit(std::iter::once(encoder.finish())); + // In wgpu 30 presentation belongs to the queue, not SurfaceTexture. + self.queue.present(output); + for id in &textures_delta.free { + self.renderer.free_texture(id); + } + textures_delta.clear(); + Ok(true) } } +/// Honor egui's earliest root-viewport request, and budget rendering/acquisition +/// inside the frame interval instead of adding a full sleep after presentation. +fn frame_pause(frame: &LayerFrame, spent: Duration, presented: bool) -> Duration { + let requested = frame + .output + .viewport_output + .get(&egui::ViewportId::ROOT) + .map_or(frame.repaint_after, |root| { + root.repaint_delay.min(frame.repaint_after) + }); + let interval = if !presented && requested.is_zero() { + // Occluded surfaces can return immediately without FIFO backpressure. + // Keep pumping IPC/input, but do not spin a hidden animation at full CPU. + Duration::from_millis(16) + } else { + requested + }; + interval.min(MAX_FRAME_PAUSE).saturating_sub(spent) +} + // ── 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 +/// compositor lacks the protocol, the configure never arrives, or Vulkan/WGPU could not +/// be initialised — the caller then uses the X11 overlay instead. The renderer 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). @@ -616,7 +854,7 @@ fn bind_globals( state } -pub fn run_layer_capsule(geometry: CapsuleGeometry, mut frame: F) -> Result<(), String> +pub fn run_layer_capsule(mut geometry: CapsuleGeometry, mut frame: F) -> Result<(), String> where F: FnMut(&egui::Context, egui::RawInput, bool) -> LayerFrame, { @@ -683,21 +921,49 @@ where (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 mut renderer = WgpuSurface::new(&connection, &wl_surface, geometry.buffer_size())?; + eprintln!("OpenLess capsule: Vulkan/WGPU ready on the layer surface"); let context = egui::Context::default(); - // This EGL layer surface is single-sample; feather egui geometry instead. + // This 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; + let mut right_aligned = false; + let animation_started = Instant::now(); loop { + let frame_started = Instant::now(); + // dispatch_pending alone never reads the display socket. Without this, + // pointer events arriving after initial configure cannot reach egui. + if let Some(reader) = queue.prepare_read() { + let mut fd = libc::pollfd { + fd: reader.connection_fd().as_raw_fd(), + events: libc::POLLIN, + revents: 0, + }; + // SAFETY: fd is a valid, borrowed display descriptor and the pollfd + // remains live for this non-blocking call. The reader owns the read. + let ready = unsafe { libc::poll(&mut fd, 1, 0) }; + if ready < 0 { + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { + return Err(format!("wayland input poll: {error}")); + } + } else if fd.revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 { + match reader.read() { + Ok(_) => {} + Err(wayland_client::backend::WaylandError::Io(error)) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::Interrupted + ) => {} + Err(error) => return Err(format!("wayland input read: {error}")), + } + } + } queue .dispatch_pending(&mut state) .map_err(|error| format!("wayland dispatch: {error}"))?; @@ -705,25 +971,49 @@ where return Ok(()); } let _ = connection.flush(); - let rect = geometry.rect_for_configure(state.configure.unwrap_or((width, height))); - let input = state.take_input(rect); + let rect = geometry.rect_for_configure(state.configure.unwrap_or(geometry.buffer_size())); + let mut input = state.take_input(rect); + input.time = Some(animation_started.elapsed().as_secs_f64()); let mut frame = frame(&context, input, first); first = false; let primitives = context.tessellate(frame.output.shapes.clone(), scale); - gl.paint( - &mut painter, + let presented = renderer.paint( (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)); + if let Some((next, align_right)) = frame.capsule_layout { + if next != geometry || align_right != right_aligned { + geometry = next; + right_aligned = align_right; + let (width, height) = geometry.buffer_size(); + layer_surface.set_size(width, height); + layer_surface.set_anchor(if right_aligned { + zwlr_layer_surface_v1::Anchor::Bottom | zwlr_layer_surface_v1::Anchor::Right + } else { + zwlr_layer_surface_v1::Anchor::Bottom + }); + layer_surface.set_margin( + 0, + if right_aligned { 24 } else { 0 }, + geometry.bottom_gap, + 0, + ); + state.configure = None; + wl_surface.commit(); + connection + .flush() + .map_err(|error| format!("wayland resize flush: {error}"))?; + } + } + let pause = frame_pause(&frame, frame_started.elapsed(), presented); + if !pause.is_zero() { + std::thread::sleep(pause); + } } } @@ -850,6 +1140,23 @@ mod tests { } } + #[test] + fn capsule_requires_explicit_premultiplied_transparency() { + use eframe::egui_wgpu::wgpu::CompositeAlphaMode as Alpha; + assert_eq!( + capsule_alpha_mode(&[Alpha::Opaque, Alpha::PreMultiplied]).unwrap(), + Alpha::PreMultiplied, + ); + for modes in [ + vec![], + vec![Alpha::Opaque], + vec![Alpha::Auto], + vec![Alpha::PostMultiplied], + ] { + assert!(capsule_alpha_mode(&modes).is_err()); + } + } + #[test] fn capsule_geometry_clamps_to_a_paintable_surface() { let geometry = capsule_geometry(200, 100, 12); @@ -875,6 +1182,64 @@ mod tests { ); } + fn pacing_frame(delay: Duration) -> LayerFrame { + let mut output = egui::FullOutput::default(); + output.viewport_output.insert( + egui::ViewportId::ROOT, + egui::ViewportOutput { + parent: egui::ViewportId::ROOT, + class: egui::ViewportClass::Root, + builder: egui::ViewportBuilder::default(), + viewport_ui_cb: None, + commands: Vec::new(), + repaint_delay: delay, + }, + ); + LayerFrame { + output, + exit: false, + capsule_layout: None, + repaint_after: Duration::from_millis(33), + } + } + + #[test] + fn repaint_requests_win_and_render_time_is_not_added_twice() { + let frame = pacing_frame(Duration::from_millis(16)); + assert_eq!( + frame_pause(&frame, Duration::from_millis(5), true), + Duration::from_millis(11) + ); + assert_eq!( + frame_pause(&frame, Duration::from_millis(20), true), + Duration::ZERO + ); + let immediate = pacing_frame(Duration::ZERO); + assert_eq!( + frame_pause(&immediate, Duration::from_millis(5), true), + Duration::ZERO + ); + let mut idle = pacing_frame(Duration::MAX); + idle.repaint_after = Duration::from_secs(2); + assert_eq!( + frame_pause(&idle, Duration::from_millis(4), true), + Duration::from_millis(96) + ); + } + + #[test] + fn an_occluded_immediate_animation_does_not_busy_spin() { + let frame = pacing_frame(Duration::ZERO); + assert_eq!( + frame_pause(&frame, Duration::from_millis(3), false), + Duration::from_millis(13) + ); + assert_eq!( + frame_pause(&frame, Duration::from_millis(20), false), + Duration::ZERO + ); + } + #[test] fn pointer_samples_become_egui_events() { let position = Some(egui::pos2(4.0, 5.0)); @@ -914,6 +1279,60 @@ mod tests { assert_eq!(button_from_code(0x113), None); } + fn touch_button_frame(ctx: &egui::Context, state: &mut LayerState) -> bool { + let rect = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(200.0, 100.0)); + let mut clicked = false; + let mut output = ctx.run_ui(state.take_input(rect), |ui| { + clicked |= ui + .put( + egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(80.0, 40.0)), + egui::Button::new("Stop"), + ) + .clicked(); + }); + output.textures_delta.clear(); + clicked + } + + #[test] + fn a_native_touch_tap_clicks_once_and_drains_input() { + let ctx = egui::Context::default(); + let mut state = LayerState::default(); + touch_button_frame(&ctx, &mut state); + state.touch_input.down(7, egui::pos2(30.0, 25.0)); + state.touch_input.motion(7, egui::pos2(31.0, 25.0)); + state.touch_input.up(7); + assert!(touch_button_frame(&ctx, &mut state)); + assert!(!touch_button_frame(&ctx, &mut state)); + assert!(state.touch_input.contacts.is_empty()); + assert!(state.touch_input.primary.is_none()); + assert!(state.touch_input.events.is_empty()); + } + + #[test] + fn cancelling_or_adding_a_finger_cannot_click_or_leave_a_button_held() { + let ctx = egui::Context::default(); + let mut state = LayerState::default(); + touch_button_frame(&ctx, &mut state); + for multi in [false, true] { + state.touch_input.down(1, egui::pos2(30.0, 25.0)); + assert!(!touch_button_frame(&ctx, &mut state)); + if multi { + state.touch_input.down(2, egui::pos2(40.0, 25.0)); + state.touch_input.up(1); + state.touch_input.up(2); + } else { + state.touch_input.cancel(); + } + assert!(!touch_button_frame(&ctx, &mut state)); + assert!(!ctx.input(|input| input.pointer.any_down())); + assert!(state.touch_input.contacts.is_empty()); + state.touch_input.down(3, egui::pos2(30.0, 25.0)); + state.touch_input.up(3); + assert!(touch_button_frame(&ctx, &mut state)); + } + } + #[test] fn input_carries_the_viewport_and_pending_clicks() { let mut state = LayerState { diff --git a/openless-all/app/linux-egui/src/popup_window.rs b/openless-all/app/linux-egui/src/popup_window.rs index a0f4a6b16..10be695ed 100644 --- a/openless-all/app/linux-egui/src/popup_window.rs +++ b/openless-all/app/linux-egui/src/popup_window.rs @@ -27,8 +27,9 @@ //! 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); +/// Tauri's 460×180 Siri canvas plus the 40px raw-transcript rail and 8px gap. +/// Classic/Typeless still paint at the bottom; phases never resize the host. +pub const CAPSULE_WINDOW_SIZE: (u32, u32) = (460, 228); /// 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; @@ -302,6 +303,52 @@ impl OverlayPlacement { } } +/// Place an interactive panel in the requested work area without changing its +/// focus or taskbar policy. This is only a best-effort X11 post-map correction: +/// Wayland clients cannot choose an xdg-toplevel position. +pub fn place_panel( + 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.mark_self_placed(window) { + placement + .warnings + .push(format!("position hint 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()); + } + placement +} + /// Point the popup's own X11 window at the bottom centre of the work area and /// make sure it never holds the keyboard. /// @@ -971,6 +1018,7 @@ mod tests { #[test] fn popup_size_maps_every_kind() { assert_eq!(popup_size(PopupKind::Capsule), CAPSULE_WINDOW_SIZE); + assert_eq!(CAPSULE_WINDOW_SIZE, (460, 228)); assert_eq!(popup_size(PopupKind::Qa), QA_WINDOW_SIZE); // Tauri 的 `less-computer` 窗口与 qa 同为 420×540。 assert_eq!( @@ -985,7 +1033,7 @@ mod tests { fn popup_position_puts_the_capsule_at_the_bottom_centre() { assert_eq!( popup_position(&environment(), PopupKind::Capsule), - Some((860, 968)) + Some((730, 840)) ); } @@ -1029,7 +1077,7 @@ mod tests { assert_eq!(popup_position(&environment, PopupKind::Qa), Some((0, 0)),); assert_eq!( popup_position(&environment, PopupKind::Capsule), - Some((100, 188)), + Some((0, 60)), ); } @@ -1052,20 +1100,38 @@ mod tests { "window_type(42)", "self_placed(42)", "states(42)", - "move(42,860,968)", + "move(42,730,840)", "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_eq!(placement.moved_to, Some((730, 840))); assert!(placement.focus_was_stolen); assert!(placement.focus_restored); assert!(placement.warnings.is_empty()); assert!(placement.applied()); } + #[test] + fn place_panel_moves_without_changing_focus_or_overlay_state() { + let mut x11 = FakeX11 { + window: Some(0x2a), + ..Default::default() + }; + let placement = place_panel(&mut x11, 4242, &environment(), PopupKind::Qa); + assert_eq!( + x11.calls, + vec!["find(4242)", "self_placed(42)", "move(42,750,270)"] + ); + assert_eq!(placement.window, Some(0x2a)); + assert_eq!(placement.moved_to, Some((750, 270))); + assert!(!placement.focus_was_stolen); + assert!(!placement.focus_restored); + assert!(placement.warnings.is_empty()); + } + #[test] fn place_overlay_keeps_going_when_the_input_hint_fails() { let mut x11 = FakeX11 { @@ -1075,7 +1141,7 @@ mod tests { }; let placement = place_overlay(&mut x11, 1, &environment(), PopupKind::Capsule); assert!(placement.applied()); - assert_eq!(placement.moved_to, Some((860, 968))); + assert_eq!(placement.moved_to, Some((730, 840))); assert_eq!(placement.warnings, vec!["input hint failed: nope"]); } diff --git a/openless-all/app/linux-egui/src/provider_protocol.rs b/openless-all/app/linux-egui/src/provider_protocol.rs new file mode 100644 index 000000000..4b6b13ede --- /dev/null +++ b/openless-all/app/linux-egui/src/provider_protocol.rs @@ -0,0 +1,71 @@ +//! Channel protocol drafts shared by the host and settings renderer. +//! Extra headers are write-only and are never hydrated from stored credentials. +use openless_core::llm_protocol::{LlmProtocolConfig, LlmRequestFormat, CONFIG_ACCOUNTS}; + +#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)] +pub struct ProviderProtocolDraft { + /// Same account order as Core's CONFIG_ACCOUNTS; none of these are API keys. + pub values: [String; 5], + pub default_format: LlmRequestFormat, + pub formats: Vec, + #[serde(default)] + pub temperature: String, + #[serde(default)] + pub extra_headers: String, +} + +impl ProviderProtocolDraft { + pub fn format(&self) -> LlmRequestFormat { + LlmRequestFormat::parse(&self.values[0]).unwrap_or(self.default_format) + } + + pub fn validate(&self) -> Result<(), openless_core::BackendError> { + let mut config = LlmProtocolConfig { + format: self.default_format, + ..Default::default() + }; + for (account, value) in CONFIG_ACCOUNTS.iter().zip(&self.values) { + config.apply(account, value)?; + } + config.validate()?; + openless_core::parse_temperature(&self.temperature)?; + let headers = openless_core::provider_rules::parse_extra_headers(&self.extra_headers)?; + config.validate_headers(&headers)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protocol_drafts_use_core_validation_and_provider_defaults() { + let mut draft = ProviderProtocolDraft { + default_format: LlmRequestFormat::Messages, + ..Default::default() + }; + assert_eq!(draft.format(), LlmRequestFormat::Messages); + assert!(draft.validate().is_ok()); + draft.values[1] = "budget".into(); + draft.values[2] = "1024".into(); + assert_eq!( + draft.validate().unwrap_err().message, + "llmThinkingBudgetInvalid" + ); + draft.values[2] = "8192".into(); + draft.values[4] = "fast".into(); + assert!(draft.validate().is_ok()); + draft.values[4] = "turbo".into(); + assert_eq!( + draft.validate().unwrap_err().message, + "llmServiceTierInvalid" + ); + draft.values[4].clear(); + draft.values[2] = "4294967296".into(); + assert_eq!( + draft.validate().unwrap_err().message, + "llmTokenLimitInvalid" + ); + } +} diff --git a/openless-all/app/linux-egui/src/remote_input.rs b/openless-all/app/linux-egui/src/remote_input.rs index 44b675c6b..3f745eed8 100644 --- a/openless-all/app/linux-egui/src/remote_input.rs +++ b/openless-all/app/linux-egui/src/remote_input.rs @@ -588,7 +588,7 @@ async fn websocket_session(mut socket: WebSocket, state: Arc, peer: Ip } _ = keepalive.tick() => { if last_received.elapsed() > Duration::from_secs(IDLE_TIMEOUT_SECS) - || socket.send(Message::Ping(Vec::new())).await.is_err() + || socket.send(Message::Ping(Vec::new().into())).await.is_err() { break; } @@ -634,7 +634,7 @@ async fn websocket_session(mut socket: WebSocket, state: Arc, peer: Ip #[cfg(target_os = "linux")] fn json_message(value: serde_json::Value) -> Message { - Message::Text(value.to_string()) + Message::Text(value.to_string().into()) } #[cfg(target_os = "linux")] diff --git a/openless-all/app/linux-egui/src/runtime.rs b/openless-all/app/linux-egui/src/runtime.rs index 1f1fc0ae7..0b31c73a1 100644 --- a/openless-all/app/linux-egui/src/runtime.rs +++ b/openless-all/app/linux-egui/src/runtime.rs @@ -34,23 +34,29 @@ impl LinuxNativeRuntime { 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)?; + // settings transactions before accepting any input. When the optional + // listener is unavailable, skip this effect entirely: attempting a + // registration against a missing DBus service would make app startup + // fatal, which defeats the degraded-mode contract. + if hotkeys.is_some() { + 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)?; + } else { + log::debug!("[fcitx] skipped native hotkey hydration; listener unavailable"); + } let startup = backend.backend.start().await?; openless_core::require_backend_contract_version(&startup.contract_version)?; let preferences = backend.backend.get_preferences(); @@ -124,6 +130,12 @@ impl LinuxNativeRuntime { self.hotkeys.is_some() } + pub fn set_hotkey_waker(&self, thread: std::thread::Thread) { + if let Some(listener) = &self.hotkeys { + listener.set_waker(thread); + } + } + pub fn drain_native_events( &self, ) -> ( @@ -200,18 +212,18 @@ mod tests { use super::*; #[derive(Default)] - struct StartupHotkeys( - std::sync::Mutex>, - bool, - ); + struct RecordingSettingsEffects { + targets: std::sync::Mutex>, + fail_registration: bool, + } - impl crate::LinuxSettingsEffects for StartupHotkeys { + impl crate::LinuxSettingsEffects for RecordingSettingsEffects { fn apply_hotkeys( &self, target: &openless_core::HotkeyRuntimeTarget, ) -> Result<(), BackendError> { - self.0.lock().unwrap().push(target.clone()); - if self.1 { + self.targets.lock().unwrap().push(target.clone()); + if self.fail_registration { return Err(BackendError::new( BackendErrorCode::Platform, "required hotkey registration failed", @@ -275,7 +287,7 @@ mod tests { .unwrap(), ); let backend = fixture_backend(&data_dir, services); - let hotkeys = Arc::new(StartupHotkeys::default()); + let hotkeys = Arc::new(RecordingSettingsEffects::default()); let runtime = LinuxNativeRuntime::start( LinuxBackendRuntime { backend: Arc::clone(&backend), @@ -291,12 +303,9 @@ mod tests { .unwrap(); assert!(backend.snapshot().running); - assert_eq!( - hotkeys.0.lock().unwrap().as_slice(), - &[openless_core::HotkeyRuntimeTarget::from( - &backend.get_preferences() - )] - ); + // No listener was supplied: native registration is intentionally skipped + // so a missing fcitx5 service cannot make Core startup fatal. + assert!(hotkeys.targets.lock().unwrap().is_empty()); assert_eq!( runtime.startup_snapshot().contract_version, openless_core::BACKEND_CONTRACT_VERSION @@ -311,56 +320,41 @@ 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(); + async fn missing_hotkeys_do_not_block_core_startup() { + let dir = + std::env::temp_dir().join(format!("openless-missing-hotkeys-{}", uuid::Uuid::new_v4())); + let mut services = BackendServices::unsupported(); + services.remote_input = Arc::new( + RemoteInputService::new(Arc::new(RecordingRemoteInputRuntime::default()), 8443, "en") + .unwrap(), + ); + let backend = fixture_backend(&dir, services); + let mut events = backend.subscribe(); + let runtime = LinuxNativeRuntime::start( + LinuxBackendRuntime { + backend: backend.clone(), + host_actions: Arc::new(LinuxHostActions::default()), + settings_runtime: Arc::new(crate::LinuxSettingsRuntime::with_effects(Arc::new( + RecordingSettingsEffects { + fail_registration: true, + ..Default::default() + }, + ))), + }, + None, + None, + ) + .await + .expect("Core must start without the optional fcitx5 listener"); + assert!(backend.snapshot().running); + let mut started = false; + while let Ok(event) = events.try_recv() { + started |= matches!(event.kind, openless_core::BackendEventKind::BackendStarted); } + assert!(started); + runtime.shutdown().await.unwrap(); + assert!(!backend.snapshot().running); + let _ = std::fs::remove_dir_all(dir); } } diff --git a/openless-all/app/linux-egui/src/selection.rs b/openless-all/app/linux-egui/src/selection.rs index f7deb043f..3a954f8cc 100644 --- a/openless-all/app/linux-egui/src/selection.rs +++ b/openless-all/app/linux-egui/src/selection.rs @@ -6,6 +6,20 @@ use openless_core::{ SessionId, }; +pub(crate) fn validate_selection_replacement_source(text: &str) -> Result<(), BackendError> { + // Same fixed head/tail stand-in as Tauri; merely mentioning the marker + // in ordinary text is not a reason to reject a real selection. + const MARKER: &str = "\n[\u{2026}truncated\u{2026}]\n"; + let stand_in_length = 4000 + MARKER.chars().count(); + if text.contains(MARKER) && text.chars().take(stand_in_length + 1).count() == stand_in_length { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "selectionPolishSelectionTooLong", + )); + } + Ok(()) +} + trait LinuxSelectionBridge: Send + Sync + 'static { fn capture_target(&self, session_id: SessionId) -> Result; fn apply_target( @@ -112,6 +126,10 @@ impl SelectionRuntimeAdapter for LinuxSelectionRuntime { let _ = bridge.cancel_target(previous.session_id); } let text = bridge.capture_target(session_id)?; + if let Err(error) = validate_selection_replacement_source(&text) { + let _ = bridge.cancel_target(session_id); + return Err(error); + } *target = Some(LinuxSelectionTarget { session_id, source_text: text.clone(), @@ -152,6 +170,7 @@ impl SelectionRuntimeAdapter for LinuxSelectionRuntime { let bridge = Arc::clone(&self.bridge); let target = Arc::clone(&self.target); Box::pin(async move { + validate_selection_replacement_source(&source_text)?; tokio::task::spawn_blocking(move || { let mut target = target.lock().expect("Linux selection target lock poisoned"); let Some(active) = target.as_mut() else { diff --git a/openless-all/app/linux-egui/src/settings.rs b/openless-all/app/linux-egui/src/settings.rs index 19d90a383..09b2473f6 100644 --- a/openless-all/app/linux-egui/src/settings.rs +++ b/openless-all/app/linux-egui/src/settings.rs @@ -9,7 +9,8 @@ use openless_core::{ use crate::LinuxCredentialStore; -/// Render legacy modifier names as the keys registered on Linux. +/// Linux 上实际注册的是 X11 keysym,而不是 macOS 的「Option」命名:界面上显示 +/// `RightOption` 会与「按下右 Alt」对不上,所以把旧名换成实际按键名。 pub fn shortcut_display_label(binding: &ShortcutBinding) -> String { let primary = match legacy_modifier_trigger(binding) { Some(HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt) => "Right Alt", diff --git a/openless-all/app/linux-egui/src/tray.rs b/openless-all/app/linux-egui/src/tray.rs index f325ab34a..46e3618d7 100644 --- a/openless-all/app/linux-egui/src/tray.rs +++ b/openless-all/app/linux-egui/src/tray.rs @@ -10,7 +10,7 @@ use std::fmt; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{mpsc, Arc, Mutex}; use std::thread::JoinHandle; -use std::time::Duration; +use std::time::{Duration, Instant}; use crate::{tr_l10n, Lang}; @@ -115,10 +115,17 @@ impl LinuxTray { let shutdown = Arc::new(AtomicBool::new(false)); let worker_error = Arc::clone(&last_error); let worker_shutdown = Arc::clone(&shutdown); + let command_waker = std::thread::current(); let worker = std::thread::Builder::new() .name("openless-tray".into()) .spawn(move || { - let result = run_dbus_worker(command_tx, control_rx, worker_shutdown, ready_tx); + let result = run_dbus_worker( + command_tx, + control_rx, + worker_shutdown, + ready_tx, + command_waker, + ); if let Err(error) = result { *worker_error.lock().expect("tray error lock poisoned") = Some(error.to_string()); @@ -204,12 +211,14 @@ fn run_dbus_worker( control_rx: mpsc::Receiver, shutdown: Arc, ready: mpsc::SyncSender>, + command_waker: std::thread::Thread, ) -> Result<(), TrayError> { use dbus::blocking::stdintf::org_freedesktop_dbus::RequestNameReply; use dbus::blocking::Connection; use dbus::channel::{MatchingReceiver, Sender}; use dbus::message::MatchRule; + let icon = Arc::new(tray_icon_pixmaps()?); let connection = Connection::new_session().map_err(dbus_error)?; let service_name = format!("org.kde.StatusNotifierItem-{}-1", std::process::id()); let ownership = connection @@ -223,17 +232,41 @@ fn run_dbus_worker( let menu = Arc::new(Mutex::new(TrayMenuState::default())); let callback_menu = Arc::clone(&menu); + let callback_icon = Arc::clone(&icon); let callback_commands = command_tx; connection.start_receive( MatchRule::new_method_call(), Box::new(move |message, connection| { - if let Some(reply) = handle_method_call(&message, &callback_menu, &callback_commands) { + if let Some(reply) = handle_method_call( + &message, + &callback_menu, + &callback_commands, + &callback_icon, + &command_waker, + ) { let _ = connection.send(reply); } true }), ); + // The panel/extension can replace its watcher while the application stays + // alive. Keep the item registered with the new owner, without restarting UI. + let registration_pending = Arc::new(AtomicBool::new(false)); + let owner_pending = Arc::clone(®istration_pending); + connection + .add_match( + MatchRule::new_signal("org.freedesktop.DBus", "NameOwnerChanged") + .with_sender("org.freedesktop.DBus") + .with_path("/org/freedesktop/DBus"), + move |(name, _old, new): (String, String, String), _, _| { + if name == WATCHER_NAME { + owner_pending.store(!new.is_empty(), Ordering::Release); + } + true + }, + ) + .map_err(dbus_error)?; let watcher = connection.with_proxy(WATCHER_NAME, WATCHER_PATH, REGISTRATION_TIMEOUT); let registration: Result<(), dbus::Error> = watcher.method_call( WATCHER_INTERFACE, @@ -248,6 +281,7 @@ fn run_dbus_worker( return Err(error); } let _ = ready.send(Ok(())); + let mut registration_retry = Instant::now(); while !shutdown.load(Ordering::Acquire) { while let Ok(control) = control_rx.try_recv() { @@ -286,6 +320,21 @@ fn run_dbus_worker( } } connection.process(PROCESS_INTERVAL).map_err(dbus_error)?; + if registration_pending.swap(false, Ordering::AcqRel) { + if Instant::now() >= registration_retry { + let result: Result<(), dbus::Error> = watcher.method_call( + WATCHER_INTERFACE, + "RegisterStatusNotifierItem", + (service_name.as_str(),), + ); + if result.is_err() { + registration_pending.store(true, Ordering::Release); + registration_retry = Instant::now() + Duration::from_secs(1); + } + } else { + registration_pending.store(true, Ordering::Release); + } + } } Ok(()) } @@ -295,6 +344,31 @@ fn dbus_error(error: dbus::Error) -> TrayError { TrayError::Dbus(error.to_string()) } +#[cfg(target_os = "linux")] +type IconPixmaps = Vec<(i32, i32, Vec)>; + +#[cfg(target_os = "linux")] +fn tray_icon_pixmaps() -> Result { + // Same AppIcon as macOS, decoded once. SNI's a(iiay) payload requires + // unpremultiplied ARGB32 in network byte order, NOT RGBA or native-endian u32. + let source = image::load_from_memory(include_bytes!("../../public/AppIcon.png")) + .map_err(|error| TrayError::Worker(format!("tray icon decoding failed: {error}")))? + .into_rgba8(); + Ok([16, 22, 24, 32, 48, 64, 96, 128] + .into_iter() + .map(|size| { + let resized = + image::imageops::resize(&source, size, size, image::imageops::FilterType::Lanczos3); + let mut argb = Vec::with_capacity((size * size * 4) as usize); + for pixel in resized.pixels() { + let [r, g, b, a] = pixel.0; + argb.extend_from_slice(&[a, r, g, b]); + } + (size as i32, size as i32, argb) + }) + .collect()) +} + #[cfg(target_os = "linux")] type Properties = HashMap>>; @@ -318,16 +392,87 @@ fn menu_properties(label: &str) -> Properties { } #[cfg(target_os = "linux")] -fn menu_item( +struct MenuNode { id: i32, properties: Properties, - children: Children, -) -> dbus::arg::Variant> { - property((id, properties, children)) + children: Vec, } #[cfg(target_os = "linux")] -fn menu_layout(state: &TrayMenuState) -> (i32, Properties, Children) { +impl MenuNode { + fn contains(&self, id: i32) -> bool { + self.id == id || self.children.iter().any(|node| node.contains(id)) + } + + fn select(self, id: i32) -> Option { + if self.id == id { + Some(self) + } else { + self.children.into_iter().find_map(|node| node.select(id)) + } + } + + fn layout(mut self, depth: i32, names: &[String]) -> (i32, Properties, Children) { + filter_menu_properties(&mut self.properties, names); + let children = if depth == 0 { + Vec::new() + } else { + let next_depth = if depth < 0 { -1 } else { depth - 1 }; + self.children + .into_iter() + .map(|node| property(node.layout(next_depth, names))) + .collect() + }; + (self.id, self.properties, children) + } + + fn group_properties( + mut self, + ids: &[i32], + names: &[String], + entries: &mut Vec<(i32, Properties)>, + ) { + if ids.is_empty() || ids.contains(&self.id) { + filter_menu_properties(&mut self.properties, names); + entries.push((self.id, self.properties)); + } + for child in self.children { + child.group_properties(ids, names, entries); + } + } +} + +#[cfg(target_os = "linux")] +fn filter_menu_properties(properties: &mut Properties, names: &[String]) { + if !names.is_empty() { + properties.retain(|name, _| names.contains(name)); + } +} + +#[cfg(target_os = "linux")] +fn menu_default_property(name: &str) -> Option>> { + match name { + "type" | "disposition" => Some(property("normal".to_owned())), + "label" | "icon-name" | "toggle-type" | "children-display" => Some(property(String::new())), + "enabled" | "visible" => Some(property(true)), + "toggle-state" => Some(property(0i32)), + "icon-data" => Some(property(Vec::::new())), + "shortcut" => Some(property(Vec::>::new())), + _ => None, + } +} + +#[cfg(target_os = "linux")] +fn menu_item(id: i32, properties: Properties, children: Vec) -> MenuNode { + MenuNode { + id, + properties, + children, + } +} + +#[cfg(target_os = "linux")] +fn menu_tree(state: &TrayMenuState) -> MenuNode { let lang = state.lang.unwrap_or(Lang::ZhCn); let mut microphone_children = Vec::new(); let default_selected = state.microphones.iter().all(|device| !device.selected); @@ -351,7 +496,7 @@ fn menu_layout(state: &TrayMenuState) -> (i32, Properties, Children) { let mut microphone_props = menu_properties(tr_l10n(lang, "settings.microphone")); microphone_props.insert("children-display".into(), property("submenu".to_string())); let separator = HashMap::from([("type".into(), property("separator".to_string()))]); - ( + menu_item( 0, HashMap::new(), vec![ @@ -376,11 +521,21 @@ fn menu_layout(state: &TrayMenuState) -> (i32, Properties, Children) { ) } +#[cfg(target_os = "linux")] +fn menu_error(message: &dbus::Message, name: &str, text: &std::ffi::CStr) -> dbus::Message { + message.error( + &dbus::strings::ErrorName::new(name).expect("valid static D-Bus error name"), + text, + ) +} + #[cfg(target_os = "linux")] fn handle_method_call( message: &dbus::Message, menu: &Arc>, commands: &mpsc::Sender, + icon: &IconPixmaps, + command_waker: &std::thread::Thread, ) -> Option { use dbus::arg::Variant; @@ -392,25 +547,88 @@ fn handle_method_call( return Some(message.method_return().append1(INTROSPECTION_XML)); } if path == ITEM_PATH && interface == ITEM_INTERFACE { - if member == "Activate" || member == "SecondaryActivate" { - let _ = commands.send(TrayCommand::ShowMain); + // Match macOS: primary click opens the window; right click belongs to + // the desktop's DBusMenu, and middle click has no application action. + if member == "Activate" && commands.send(TrayCommand::ShowMain).is_ok() { + command_waker.unpark(); } return Some(message.method_return()); } if path == MENU_PATH && interface == MENU_INTERFACE { match member.as_str() { "GetLayout" => { + let Ok((parent, depth, names)) = message.read3::>() else { + return Some(menu_error( + message, + "org.freedesktop.DBus.Error.InvalidArgs", + c"Expected parent ID, recursion depth and property names", + )); + }; + if depth < -1 { + return Some(menu_error( + message, + "org.freedesktop.DBus.Error.InvalidArgs", + c"Recursion depth must be -1 or nonnegative", + )); + } let state = menu.lock().expect("tray menu lock poisoned"); + let Some(node) = menu_tree(&state).select(parent) else { + return Some(menu_error( + message, + "com.canonical.dbusmenu.Error.InvalidMenuItem", + c"Unknown menu item", + )); + }; return Some( message .method_return() - .append2(state.revision, menu_layout(&state)), + .append2(state.revision, node.layout(depth, &names)), ); } "GetGroupProperties" => { - let entries: Vec<(i32, Properties)> = Vec::new(); + let Ok((ids, names)) = message.read2::, Vec>() else { + return Some(menu_error( + message, + "org.freedesktop.DBus.Error.InvalidArgs", + c"Expected item IDs and property names", + )); + }; + let state = menu.lock().expect("tray menu lock poisoned"); + let mut entries = Vec::new(); + menu_tree(&state).group_properties(&ids, &names, &mut entries); return Some(message.method_return().append1(entries)); } + "GetProperty" => { + let Ok((id, name)) = message.read2::() else { + return Some(menu_error( + message, + "org.freedesktop.DBus.Error.InvalidArgs", + c"Expected item ID and property name", + )); + }; + let state = menu.lock().expect("tray menu lock poisoned"); + let Some(mut node) = menu_tree(&state).select(id) else { + return Some(menu_error( + message, + "com.canonical.dbusmenu.Error.InvalidMenuItem", + c"Unknown menu item", + )); + }; + return Some( + match node + .properties + .remove(&name) + .or_else(|| menu_default_property(&name)) + { + Some(value) => message.method_return().append1(value), + None => menu_error( + message, + "com.canonical.dbusmenu.Error.UnknownProperty", + c"Unknown menu property", + ), + }, + ); + } "Event" => { if let Ok((id, event, _data, _timestamp)) = message.read4::>, u32>() @@ -421,19 +639,79 @@ fn handle_method_call( .expect("tray menu lock poisoned") .command_for_id(id) { - let _ = commands.send(command); + if commands.send(command).is_ok() { + command_waker.unpark(); + } } } } return Some(message.method_return()); } - "AboutToShow" => return Some(message.method_return().append1(false)), + "EventGroup" => { + type MenuEvent = (i32, String, Variant>, u32); + let Ok(events) = message.read1::>() else { + return Some(menu_error( + message, + "org.freedesktop.DBus.Error.InvalidArgs", + c"Expected menu events", + )); + }; + let state = menu.lock().expect("tray menu lock poisoned"); + let tree = menu_tree(&state); + let mut errors = Vec::new(); + for (id, event, _, _) in events { + if !tree.contains(id) { + errors.push(id); + continue; + } + if event == "clicked" { + if let Some(command) = state.command_for_id(id) { + if commands.send(command).is_ok() { + command_waker.unpark(); + } + } + } + } + return Some(message.method_return().append1(errors)); + } + "AboutToShow" => { + let Ok(id) = message.read1::() else { + return Some(menu_error( + message, + "org.freedesktop.DBus.Error.InvalidArgs", + c"Expected item ID", + )); + }; + let state = menu.lock().expect("tray menu lock poisoned"); + return Some(if menu_tree(&state).contains(id) { + message.method_return().append1(false) + } else { + menu_error( + message, + "com.canonical.dbusmenu.Error.InvalidMenuItem", + c"Unknown menu item", + ) + }); + } + "AboutToShowGroup" => { + let Ok(ids) = message.read1::>() else { + return Some(menu_error( + message, + "org.freedesktop.DBus.Error.InvalidArgs", + c"Expected item IDs", + )); + }; + let state = menu.lock().expect("tray menu lock poisoned"); + let tree = menu_tree(&state); + let errors: Vec = ids.into_iter().filter(|id| !tree.contains(*id)).collect(); + return Some(message.method_return().append2(Vec::::new(), errors)); + } _ => return Some(message.method_return()), } } if interface == DBUS_PROPERTIES && member == "Get" { if let Ok((requested_interface, name)) = message.read2::() { - let value = item_property(&requested_interface, &name) + let value = item_property(&requested_interface, &name, icon) .or_else(|| menu_property(&requested_interface, &name)); if let Some(value) = value { return Some(message.method_return().append1(value)); @@ -443,7 +721,7 @@ fn handle_method_call( if interface == DBUS_PROPERTIES && member == "GetAll" { let requested_interface = message.read1::().unwrap_or_default(); let properties = if requested_interface == ITEM_INTERFACE { - item_properties() + item_properties(icon) } else if requested_interface == MENU_INTERFACE { menu_properties_all() } else { @@ -458,6 +736,7 @@ fn handle_method_call( fn item_property( interface: &str, name: &str, + icon: &IconPixmaps, ) -> Option>> { if interface != ITEM_INTERFACE { return None; @@ -467,7 +746,19 @@ fn item_property( "Id" => Some(property("openless".to_string())), "Title" => Some(property("OpenLess".to_string())), "Status" => Some(property("Active".to_string())), - "IconName" => Some(property("openless".to_string())), + // Empty IconName forces the host to use our actual full-color pixels, + // not a theme override (in particular, a symbolic/monochrome icon). + "IconName" | "OverlayIconName" | "AttentionIconName" | "AttentionMovieName" + | "IconThemePath" => Some(property(String::new())), + "IconPixmap" => Some(property(icon.clone())), + "OverlayIconPixmap" | "AttentionIconPixmap" => Some(property(IconPixmaps::new())), + "WindowId" => Some(property(0u32)), + "ToolTip" => Some(property(( + String::new(), + IconPixmaps::new(), + "OpenLess".to_owned(), + String::new(), + ))), "Menu" => Some(property( dbus::Path::new(MENU_PATH).expect("static D-Bus path"), )), @@ -477,18 +768,27 @@ fn item_property( } #[cfg(target_os = "linux")] -fn item_properties() -> Properties { +fn item_properties(icon: &IconPixmaps) -> Properties { [ "Category", "Id", "Title", "Status", "IconName", + "IconPixmap", + "IconThemePath", + "WindowId", + "OverlayIconName", + "OverlayIconPixmap", + "AttentionIconName", + "AttentionIconPixmap", + "AttentionMovieName", + "ToolTip", "Menu", "ItemIsMenu", ] .into_iter() - .filter_map(|name| item_property(ITEM_INTERFACE, name).map(|value| (name.into(), value))) + .filter_map(|name| item_property(ITEM_INTERFACE, name, icon).map(|value| (name.into(), value))) .collect() } @@ -520,13 +820,44 @@ fn menu_properties_all() -> Properties { #[cfg(target_os = "linux")] const INTROSPECTION_XML: &str = r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/openless-all/app/linux-egui/src/typography.rs b/openless-all/app/linux-egui/src/typography.rs new file mode 100644 index 000000000..f133f48d3 --- /dev/null +++ b/openless-all/app/linux-egui/src/typography.rs @@ -0,0 +1,224 @@ +//! Reading typography: font family, scale and content width. +//! +//! The Tauri build writes CSS custom properties (`--ol-font-ui`, +//! `--ol-reading-width`) and a `localStorage` entry. egui has no CSS, so the same +//! three choices are stored in the Linux UI state document and applied to +//! fontconfig resolution, `Context::set_zoom_factor` and the main content width. + +use serde::{Deserialize, Serialize}; + +/// Font choices offered by the appearance page, in the same order as Tauri's +/// `FONT_CATALOG`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UiFont { + /// Whatever the desktop resolves for `sans-serif`. + #[default] + System, + /// Humanist sans (Avenir Next / Aptos class). + Humanist, + /// CJK-first sans (PingFang SC / 微软雅黑 class). + Cjk, + /// Serif (New York / Georgia / 宋体 class). + Serif, + /// CJK serif. + Song, + /// Monospace (SF Mono / Cascadia class). + Mono, + /// Menlo / Consolas class. + Menlo, +} + +impl UiFont { + pub const ALL: [Self; 7] = [ + Self::System, + Self::Humanist, + Self::Cjk, + Self::Serif, + Self::Song, + Self::Mono, + Self::Menlo, + ]; + + /// State-document tag. Kept stable so an existing file keeps working. + pub fn tag(self) -> &'static str { + match self { + Self::System => "system", + Self::Humanist => "humanist", + Self::Cjk => "cjk", + Self::Serif => "serif", + Self::Song => "song", + Self::Mono => "mono", + Self::Menlo => "menlo", + } + } + + pub fn from_tag(tag: &str) -> Option { + Self::ALL.into_iter().find(|font| font.tag() == tag) + } + + /// fontconfig query used to resolve the actual face file. + pub fn fontconfig_query(self) -> &'static str { + match self { + Self::System | Self::Humanist => "sans-serif", + Self::Cjk => "sans-serif:lang=zh-cn", + Self::Serif => "serif", + Self::Song => "serif:lang=zh-cn", + Self::Mono | Self::Menlo => "monospace", + } + } +} + +/// 90% / 100% / 110%, matching Tauri's `fontScale` ids. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FontScale { + Small, + #[default] + Medium, + Large, +} + +impl FontScale { + pub const ALL: [Self; 3] = [Self::Small, Self::Medium, Self::Large]; + + pub fn tag(self) -> &'static str { + match self { + Self::Small => "small", + Self::Medium => "medium", + Self::Large => "large", + } + } + + pub fn from_tag(tag: &str) -> Option { + Self::ALL.into_iter().find(|scale| scale.tag() == tag) + } + + pub fn factor(self) -> f32 { + match self { + Self::Small => 0.9, + Self::Medium => 1.0, + Self::Large => 1.1, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Small => "90%", + Self::Medium => "100%", + Self::Large => "110%", + } + } +} + +/// Reading width: Tauri maps these onto 56/72/88 `ch`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ContentWidth { + Narrow, + #[default] + Comfortable, + Wide, +} + +impl ContentWidth { + pub const ALL: [Self; 3] = [Self::Narrow, Self::Comfortable, Self::Wide]; + + pub fn tag(self) -> &'static str { + match self { + Self::Narrow => "narrow", + Self::Comfortable => "comfortable", + Self::Wide => "wide", + } + } + + pub fn from_tag(tag: &str) -> Option { + Self::ALL.into_iter().find(|width| width.tag() == tag) + } + + /// Approximate Tauri's `ch` budget in logical pixels at the default text size + /// (1ch ≈ 0.5em for the UI stack). + pub fn max_width(self) -> f32 { + match self { + Self::Narrow => 620.0, + Self::Comfortable => 800.0, + Self::Wide => 980.0, + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Typography { + pub font: UiFont, + pub scale: FontScale, + pub width: ContentWidth, +} + +/// Which control the appearance page changed. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TypographyField { + Font(UiFont), + Scale(FontScale), + Width(ContentWidth), +} + +impl Typography { + pub fn apply(&mut self, field: TypographyField) { + match field { + TypographyField::Font(font) => self.font = font, + TypographyField::Scale(scale) => self.scale = scale, + TypographyField::Width(width) => self.width = width, + } + } +} + +/// Read the persisted typography (Linux UI state document). +pub fn load() -> Typography { + crate::ui_state::load_typography() +} + +/// Persist the typography document. +pub fn save(typography: Typography) -> Result<(), crate::ui_state::UiStateError> { + crate::ui_state::save_typography(typography) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tags_round_trip_so_a_saved_state_file_keeps_working() { + for font in UiFont::ALL { + assert_eq!(UiFont::from_tag(font.tag()), Some(font)); + } + for scale in FontScale::ALL { + assert_eq!(FontScale::from_tag(scale.tag()), Some(scale)); + } + for width in ContentWidth::ALL { + assert_eq!(ContentWidth::from_tag(width.tag()), Some(width)); + } + assert_eq!(UiFont::from_tag("nope"), None); + } + + #[test] + fn the_scale_factors_match_tauri_percentages() { + assert_eq!(FontScale::Small.factor(), 0.9); + assert_eq!(FontScale::Medium.factor(), 1.0); + assert_eq!(FontScale::Large.factor(), 1.1); + } + + #[test] + fn reading_widths_grow_monotonically() { + assert!(ContentWidth::Narrow.max_width() < ContentWidth::Comfortable.max_width()); + assert!(ContentWidth::Comfortable.max_width() < ContentWidth::Wide.max_width()); + } + + #[test] + fn applying_a_field_only_touches_that_field() { + let mut typography = Typography::default(); + typography.apply(TypographyField::Font(UiFont::Serif)); + assert_eq!(typography.font, UiFont::Serif); + assert_eq!(typography.scale, FontScale::Medium); + assert_eq!(typography.width, ContentWidth::Comfortable); + } +} diff --git a/openless-all/app/linux-egui/src/ui/backdrop.rs b/openless-all/app/linux-egui/src/ui/backdrop.rs index 34102ef68..8a1a2f8f8 100644 --- a/openless-all/app/linux-egui/src/ui/backdrop.rs +++ b/openless-all/app/linux-egui/src/ui/backdrop.rs @@ -88,16 +88,16 @@ pub type BackdropImage = egui::TextureId; 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); + data.insert_temp(egui::Id::unique(IMAGE_KEY), image); } else { - data.remove::(egui::Id::new(IMAGE_KEY)); + data.remove::(egui::Id::unique(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))) + ctx.data(|data| data.get_temp::(egui::Id::unique(IMAGE_KEY))) } /// Texture coordinates for `rect` (window points) inside the published backdrop. @@ -521,7 +521,7 @@ fn backdrop_layers(ctx: &egui::Context) -> Vec { // 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"), + egui::Id::unique("openless-window-background"), )]; ctx.memory(|memory| { for layer in memory.layer_ids() { @@ -627,7 +627,7 @@ mod tests { (egui::Order::Foreground, "openless-settings-modal"), (egui::Order::Tooltip, "tooltip"), ] { - egui::Area::new(egui::Id::new(id)) + egui::Area::new(egui::Id::unique(id)) .order(order) .fixed_pos(egui::pos2(10.0, 10.0)) .show(&ctx, |ui| { @@ -637,14 +637,14 @@ mod tests { let layers = backdrop_layers(&ctx); assert!(layers.contains(&egui::LayerId::new( egui::Order::Middle, - egui::Id::new("page") + egui::Id::unique("page") ))); assert!(!layers .iter() - .any(|layer| layer.id == egui::Id::new("openless-settings-modal"))); + .any(|layer| layer.id == egui::Id::unique("openless-settings-modal"))); assert!(!layers .iter() - .any(|layer| layer.id == egui::Id::new("tooltip"))); + .any(|layer| layer.id == egui::Id::unique("tooltip"))); end_pass(&ctx); } @@ -691,7 +691,7 @@ mod tests { // 把首帧画成 `Shape::Noop`,那样这里就什么都测不到了。 let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, - egui::Id::new("openless-window-background"), + egui::Id::unique("openless-window-background"), )); painter.rect_filled( egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(256.0, 256.0)), diff --git a/openless-all/app/linux-egui/src/ui/bridge.rs b/openless-all/app/linux-egui/src/ui/bridge.rs index e61285d66..ffa4a156f 100644 --- a/openless-all/app/linux-egui/src/ui/bridge.rs +++ b/openless-all/app/linux-egui/src/ui/bridge.rs @@ -17,6 +17,7 @@ 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::sync::{Arc, Mutex}; use std::thread::JoinHandle; use std::time::Duration; @@ -24,8 +25,14 @@ use serde::{Deserialize, Serialize}; use super::frontend::view_model::{FrontendAction, FrontendViewModel}; +/// 最新一份快照的单一槽位:`(sequence, view_model)`。 +/// +/// 取名是为了表达意图,也让 clippy 的 `type_complexity` 不再对 +/// `Arc>>` 报错。 +pub type LatestSnapshot = Arc)>>>; + /// 协议版本:宿主与 UI 进程对不上就直接拒绝启动 UI(避免半懂不懂地渲染)。 -pub const UI_BRIDGE_VERSION: u32 = 2; +pub const UI_BRIDGE_VERSION: u32 = 4; /// 单帧上限。视图模型快照含历史列表,比弹窗协议大得多。 const MAX_FRAME_BYTES: usize = 16 * 1024 * 1024; @@ -53,10 +60,24 @@ pub enum HostToWindow { bindings: Box, }, /// 完整视图模型快照;`sequence` 单调递增。 + /// + /// 里面的大集合只是**第一页**(见 `frontend::paging`),其余由窗口用 + /// `FrontendAction::LoadMore` 按需拉取 —— 快照因此不会随数据增长而膨胀。 Snapshot { sequence: u64, view_model: Box, }, + /// 某个集合的一页数据。 + /// + /// 与快照分开走,因为写出线程只合并快照(丢帧安全);分页回包一旦被丢掉, + /// 这一页就永远缺了。`request_id` 回显请求,便于日志对账。 + CollectionPage { + request_id: u64, + collection: crate::ui::frontend::paging::Collection, + offset: usize, + total: usize, + items: Box, + }, /// 延迟探针回包。 Pong { sequence: u64, @@ -338,9 +359,15 @@ impl UiBridgeHost { /// /// 宿主为了判断「视图模型变没变」已经序列化过一次,这里直接拼帧、不再二次 /// 序列化;`sequence` 由桥推进,保证单调递增。 - pub fn send_snapshot_encoded(&mut self, payload: &[u8]) { + /// 发一份编码好的快照。返回是否真的发出去了。 + /// + /// 超过 [`MAX_FRAME_BYTES`] 的快照**不能**发:读端以同样的上限拒收,所以发出去的 + /// 结果是窗口读帧失败后退出。但这里也**不能**因此断开连接 —— 宿主会把窗口退出 + /// 当成「用户关窗」,紧接着重开一个,于是「超限 → 断连 → 窗口退出 → 重开」变成 + /// 每秒闪一次的循环。丢一帧只是让窗口继续显示上一份状态,宿主还会按保活节奏重试。 + pub fn send_snapshot_encoded(&mut self, payload: &[u8]) -> bool { if !self.is_connected() { - return; + return false; } let sequence = self.next_sequence; self.next_sequence += 1; @@ -351,15 +378,20 @@ impl UiBridgeHost { 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; + log::error!( + "[ui-host] snapshot of {} bytes exceeds the {} byte UI frame limit; kept the connection, skipped this frame", + frame.len(), + MAX_FRAME_BYTES + ); + return false; } if let Some(connection) = self.connection.as_ref() { if connection.outgoing.send(Outgoing::Encoded(frame)).is_err() { self.connection = None; + return false; } } + true } pub fn send(&mut self, frame: HostToWindow) { @@ -453,6 +485,7 @@ fn spawn_connection(stream: UnixStream) -> std::io::Result { let (incoming_tx, incoming_rx) = mpsc::channel(); let (outgoing_tx, outgoing_rx) = mpsc::channel::(); let reader_outgoing = outgoing_tx.clone(); + let host_thread = std::thread::current(); let reader = std::thread::Builder::new() .name("openless-ui-host-reader".into()) @@ -464,6 +497,7 @@ fn spawn_connection(stream: UnixStream) -> std::io::Result { if incoming_tx.send(frame).is_err() { break; } + host_thread.unpark(); } // EOF:UI 进程退出(正常关闭或被杀)。 Ok(None) => break, @@ -474,6 +508,7 @@ fn spawn_connection(stream: UnixStream) -> std::io::Result { } } let _ = reader_outgoing.send(Outgoing::Stop); + host_thread.unpark(); let _ = reader.get_ref().shutdown(std::net::Shutdown::Both); })?; @@ -509,6 +544,15 @@ fn spawn_connection(stream: UnixStream) -> std::io::Result { /// UI 进程侧连接:连上宿主、收快照、发动作。 pub struct UiBridgeClient { incoming: Receiver, + /// 最新一份视图模型快照的单一槽位。 + /// + /// 快照**不走** `incoming`:宿主每 2 秒发一份保活快照 + /// (`publish_view_model`),而窗口被遮挡 / 最小化时 eframe 会完全跳过 + /// egui pass(eframe 的 `if !show_ui`),`ui()` 根本不会被调用,也就不会 + /// 排空任何队列。快照若是进无界通道,遮挡一晚上就是上万份堆在内存里 + /// (现场实测:21 小时 5.8 GB,窗口恢复可见后还要逐份消化,表现为卡死)。 + /// 只留最新一份即可 —— 中间那些本来就会被后一份覆盖,没有任何价值。 + latest_snapshot: LatestSnapshot, outgoing: Sender, reader: Option>, writer: Option>, @@ -555,6 +599,8 @@ impl UiBridgeClient { let (incoming_tx, incoming_rx) = mpsc::channel(); let (outgoing_tx, outgoing_rx) = mpsc::channel::(); let reader_outgoing = outgoing_tx.clone(); + let latest_snapshot: LatestSnapshot = Arc::new(Mutex::new(None)); + let reader_snapshot = Arc::clone(&latest_snapshot); let reader = std::thread::Builder::new() .name("openless-ui-client-reader".into()) .spawn(move || { @@ -587,11 +633,22 @@ impl UiBridgeClient { } loop { match read_frame::(&mut reader) { - Ok(Some(frame)) => { - if incoming_tx.send(frame).is_err() { - break; + Ok(Some(frame)) => match frame { + // 快照只进单槽位,覆盖旧的那份;其余帧照走通道。 + HostToWindow::Snapshot { + sequence, + view_model, + } => { + if let Ok(mut slot) = reader_snapshot.lock() { + *slot = Some((sequence, view_model)); + } } - } + other => { + if incoming_tx.send(other).is_err() { + break; + } + } + }, Ok(None) => break, Err(error) => { log::warn!("[ui-client] host frame error: {error}"); @@ -620,6 +677,7 @@ impl UiBridgeClient { }; Ok(Self { incoming: incoming_rx, + latest_snapshot, outgoing: outgoing_tx, reader: Some(reader), writer: Some(writer), @@ -645,6 +703,17 @@ impl UiBridgeClient { Ok(frame) } + /// 取走当前最新的视图模型快照;没有新快照就返回 `None`。 + /// + /// 取走即清空槽位,所以窗口每帧调用一次就能拿到「最后一次覆盖后的状态」, + /// 内存占用与「宿主发过多少份」无关。 + pub fn take_snapshot(&mut self) -> Option<(u64, Box)> { + self.latest_snapshot + .lock() + .ok() + .and_then(|mut slot| slot.take()) + } + pub fn send(&mut self, frame: WindowToHost) -> Result<(), String> { if !self.ready { return Err("UI handshake is not ready".into()); @@ -730,23 +799,14 @@ mod tests { }; 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:?}"), - } + // 快照从单槽位取(不进通道),所以这里轮询 `take_snapshot`。 + let (sequence, view_model) = wait_for(|| client.take_snapshot()); + assert_eq!(sequence, 1, "first snapshot is sequence 1"); + assert_eq!(view_model.active_page, Page::History); + assert!( + client.take_snapshot().is_none(), + "taking a snapshot must empty the slot" + ); host.shutdown(); client.shutdown(); let _ = std::fs::remove_dir_all(&dir); @@ -776,11 +836,10 @@ mod tests { // 连发三份快照:写线程可能合并掉中间那些(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 let Some((sequence, _)) = client.take_snapshot() { + sequences.push(sequence); + } else { + std::thread::sleep(Duration::from_millis(10)); } if sequences.last().copied() == Some(3) { break; @@ -800,6 +859,196 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn an_undrained_window_keeps_only_the_newest_snapshot() { + // 现场:窗口被遮挡时 eframe **完全跳过 egui pass**(`if !show_ui`), + // `ui()` 不会被调用,而宿主每 2 秒发一份保活快照。21 小时≈3.7 万份, + // 堆在无界通道里就是 5.8 GB。这里连发 40 份且一份不取,验证内存只由 + // 「最新一份」决定:槽位里只有一份,通道里一句快照也没有。 + let dir = temp_dir("undrained"); + 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 { .. } + )); + + let payload = serde_json::to_vec(&FrontendViewModel { + active_page: Page::History, + ..Default::default() + }) + .unwrap(); + for _ in 0..40 { + host.send_snapshot_encoded(&payload); + } + // 中间那些必须被覆盖掉,只留最后一份(序号 40)。 + let (sequence, _) = wait_for(|| client.take_snapshot().filter(|(seq, _)| *seq == 40)); + assert_eq!(sequence, 40); + assert!( + client.take_snapshot().is_none(), + "taking a snapshot must empty the slot" + ); + assert!( + matches!(client.try_recv(), Err(TryRecvError::Empty)), + "snapshots must never queue in the unbounded channel" + ); + host.shutdown(); + client.shutdown(); + let _ = std::fs::remove_dir_all(&dir); + } + + /// 常驻内存(Linux)。用来把「现场 21 小时 5.8 GB」变成一条能自己复现的断言: + /// 只断言语义(槽位里只有一份)是不够的,得看真实 RSS。 + fn rss_bytes() -> usize { + let statm = std::fs::read_to_string("/proc/self/statm").expect("read statm"); + let pages: usize = statm + .split_whitespace() + .nth(1) + .expect("resident field") + .parse() + .expect("resident pages"); + pages * 4096 + } + + #[test] + fn an_undrained_window_survives_hours_of_keepalive_snapshots() { + // /proc/self/statm measures the entire process. Run the same regression + // alone in a child test harness so concurrent UI/font/GPU tests cannot + // inflate its RSS. Keep all pressure, delivery and 8 MiB assertions. + const CHILD_ENV: &str = "OPENLESS_KEEPALIVE_RSS_CHILD"; + if std::env::var_os(CHILD_ENV).as_deref() != Some(std::ffi::OsStr::new("1")) { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "ui::bridge::tests::an_undrained_window_survives_hours_of_keepalive_snapshots", + "--nocapture", + "--test-threads=1", + ]) + .env(CHILD_ENV, "1") + .output() + .expect("launch isolated keepalive RSS regression"); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "isolated RSS regression failed: {}\n{stdout}\n{stderr}", + output.status + ); + assert!( + stdout.contains("test result: ok. 1 passed; 0 failed"), + "the isolated harness must execute exactly the RSS regression:\n{stdout}\n{stderr}" + ); + return; + } + // 现场(21 小时 5.8 GB):窗口被遮挡时 eframe 完全跳过 egui pass + // (`if !show_ui`),`ui()` 一次都不跑,也就没有任何东西排空队列;而宿主每 2 + // 秒发一份保活快照。旧代码把快照放进无界通道 —— 21 小时≈3.7 万份全堆在内存里, + // 窗口恢复可见后还要逐份消化,表现为卡死。 + // + // 这里用**裸 socket 当宿主**,绕过宿主侧的合并槽(那是修复的另一半),单独验证 + // 客户端这一半:连发 1000 份 32 KiB 快照、一份都不取,断言 RSS 不跟着份数涨。 + // 旧代码在这条测试里会先撞上 8 MiB 的线。 + const SNAPSHOTS: u64 = 1000; + const PAYLOAD_BYTES: usize = 32 * 1024; + const GROWTH_LIMIT: usize = 8 * 1024 * 1024; + let dir = temp_dir("keepalive-pressure"); + let path = ui_socket_path(&dir); + let listener = std::os::unix::net::UnixListener::bind(&path).unwrap(); + let mut client = UiBridgeClient::connect(&path).unwrap(); + let (mut stream, _) = listener.accept().unwrap(); + write_frame( + &mut stream, + &HostToWindow::Ready { + version: UI_BRIDGE_VERSION, + }, + ) + .unwrap(); + assert!(matches!( + wait_for(|| client.try_recv().ok()), + HostToWindow::Ready { .. } + )); + let template = FrontendViewModel { + active_page: Page::History, + history_query: "x".repeat(PAYLOAD_BYTES), + ..Default::default() + }; + // 先发一份热的,再取基线。 + write_frame( + &mut stream, + &HostToWindow::Snapshot { + sequence: 1, + view_model: Box::new(template.clone()), + }, + ) + .unwrap(); + // 不等 `take_snapshot`:旧代码里快照根本不进槽位,先等它只会以超时失败, + // 把真正的内存堆积掩盖过去。 + std::thread::sleep(Duration::from_millis(150)); + let baseline = rss_bytes(); + let snapshot = |stream: &mut std::os::unix::net::UnixStream, sequence: u64| { + write_frame( + stream, + &HostToWindow::Snapshot { + sequence, + view_model: Box::new(template.clone()), + }, + ) + .unwrap(); + }; + for sequence in 2..=SNAPSHOTS { + snapshot(&mut stream, sequence); + if sequence % 100 == 0 { + let growth = rss_bytes().saturating_sub(baseline); + assert!( + growth < GROWTH_LIMIT, + "undrained snapshots must not pile up: {sequence} sent, RSS grew {} MiB", + growth / (1024 * 1024) + ); + } + } + // 不排空任何队列,只等最新那份可用;等待期间继续量内存。 + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let sequence = loop { + if let Some((sequence, _)) = client.take_snapshot() { + if sequence == SNAPSHOTS { + break sequence; + } + } + let growth = rss_bytes().saturating_sub(baseline); + assert!( + growth < GROWTH_LIMIT, + "undrained snapshots must not pile up: RSS grew {} MiB before the newest arrived", + growth / (1024 * 1024) + ); + assert!( + std::time::Instant::now() < deadline, + "the newest snapshot never arrived" + ); + std::thread::sleep(Duration::from_millis(5)); + }; + assert_eq!(sequence, SNAPSHOTS); + let growth = rss_bytes().saturating_sub(baseline); + assert!( + growth < GROWTH_LIMIT, + "RSS grew {} MiB over {SNAPSHOTS} keepalive snapshots", + growth / (1024 * 1024) + ); + assert!(client.take_snapshot().is_none()); + assert!( + matches!(client.try_recv(), Err(TryRecvError::Empty)), + "snapshots must never queue in the unbounded channel" + ); + drop(stream); + client.shutdown(); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn a_dead_bridge_socket_is_replaced_on_bind() { let dir = temp_dir("stale"); @@ -959,6 +1208,41 @@ mod tests { )); assert!(read_frame::(&mut reader).unwrap().is_none()); } + /// 分页回包不能被「只留最新快照」的合并逻辑吃掉:丢掉一页就意味着那段 + /// 数据在窗口里永远缺一块(快照只带第一页)。 + #[test] + fn a_page_frame_is_never_coalesced_away_by_a_pending_snapshot() { + use crate::ui::frontend::paging::{Collection, CollectionItems}; + + let (mut writer, reader) = UnixStream::pair().unwrap(); + let (tx, rx) = mpsc::channel(); + tx.send(Outgoing::Encoded(b"first snapshot".to_vec())) + .unwrap(); + tx.send(Outgoing::Frame(HostToWindow::CollectionPage { + request_id: 200, + collection: Collection::History, + offset: 200, + total: 900, + items: Box::new(CollectionItems::History(Vec::new())), + })) + .unwrap(); + tx.send(Outgoing::Frame(HostToWindow::Shutdown)).unwrap(); + write_outgoing(&mut writer, rx); + drop(writer); + + let mut reader = BufReader::new(reader); + // 页面帧抢在待发快照之前发出(快照被 Shutdown 丢掉,页面帧不受影响)。 + assert!(matches!( + read_frame::(&mut reader).unwrap(), + Some(HostToWindow::CollectionPage { offset: 200, .. }) + )); + 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"); diff --git a/openless-all/app/linux-egui/src/ui/frontend/capsule_motion.rs b/openless-all/app/linux-egui/src/ui/frontend/capsule_motion.rs new file mode 100644 index 000000000..d6d002a9e --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/capsule_motion.rs @@ -0,0 +1,161 @@ +//! A single capsule timeline: ribbon -> gathered point -> fluid dots. +//! Shape progress uses monotonic time, not two independently eased clocks. + +pub const SHINE_BURST_SECONDS: f64 = 2.0; +pub const SHINE_FAST_CYCLE_SECONDS: f64 = 0.9; +pub const SHINE_SLOW_CYCLE_SECONDS: f64 = 2.4; +const CONTRACT_SECONDS: f64 = 0.7; +const SPREAD_SECONDS: f64 = 0.9; +const MERGE_SECONDS: f64 = 0.45; + +fn recording(phase: &str) -> bool { + matches!(phase, "starting" | "recording") +} + +fn terminal(phase: &str) -> bool { + matches!( + phase, + "completed" | "done" | "inserted" | "cancelled" | "failed" | "error" + ) +} + +fn ease(age: f64, duration: f64) -> f32 { + let t = (age / duration).clamp(0.0, 1.0) as f32; + t * t * (3.0 - 2.0 * t) +} + +#[derive(Clone, Copy, Debug)] +pub struct SiriShape { + pub resolved: f32, + pub gather: f32, +} + +#[derive(Clone, Debug, Default)] +pub struct CapsuleMotion { + phase: String, + phase_at: f64, + left_recording_at: Option, + llm_at: Option, + ending: Option<(f64, f32)>, + recording_resolution: f32, +} + +impl CapsuleMotion { + pub fn update(&mut self, phase: &str, now: f64) -> bool { + let new_session = recording(phase) && !recording(&self.phase); + if self.phase == phase { + return false; + } + if new_session { + self.left_recording_at = None; + self.llm_at = None; + self.ending = None; + self.recording_resolution = 0.0; + } else if recording(&self.phase) && !recording(phase) { + self.left_recording_at = Some(now); + } + if matches!(phase, "polishing" | "inserting") && self.llm_at.is_none() { + self.llm_at = Some(now); + } + if terminal(phase) && self.ending.is_none() { + self.ending = Some((now, self.gather(now))); + } + self.phase = phase.to_owned(); + self.phase_at = now; + new_session + } + + pub fn phase_age(&self, now: f64) -> f64 { + (now - self.phase_at).max(0.0) + } + + pub fn shine_cycle_seconds(&self, now: f64) -> f64 { + if self.phase_age(now) < SHINE_BURST_SECONDS { + SHINE_FAST_CYCLE_SECONDS + } else { + SHINE_SLOW_CYCLE_SECONDS + } + } + + fn gather(&self, now: f64) -> f32 { + if let Some((at, from)) = self.ending { + return from + (1.0 - from) * ease(now - at, MERGE_SECONDS); + } + let Some(llm_at) = self.llm_at else { + return 1.0; + }; + let start = self + .left_recording_at + .map_or(llm_at, |at| llm_at.max(at + CONTRACT_SECONDS)); + 1.0 - ease(now - start, SPREAD_SECONDS) + } + + pub fn siri_shape(&mut self, now: f64, recording_resolution: f32) -> SiriShape { + let resolved = if recording(&self.phase) { + self.recording_resolution = recording_resolution.clamp(0.0, 1.0); + self.recording_resolution + } else { + self.left_recording_at.map_or(0.0, |at| { + self.recording_resolution * (1.0 - ease(now - at, CONTRACT_SECONDS)) + }) + }; + SiriShape { + resolved, + gather: self.gather(now), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transcription_waits_as_one_point_and_llm_opens_six_dots() { + let mut motion = CapsuleMotion::default(); + assert!(motion.update("recording", 0.0)); + motion.siri_shape(0.0, 0.8); + motion.update("transcribing", 1.0); + assert_eq!(motion.siri_shape(1.0, 0.0).resolved, 0.8); + assert!((motion.siri_shape(1.35, 0.0).resolved - 0.4).abs() < 0.001); + let point = motion.siri_shape(10.0, 0.0); + assert_eq!((point.resolved, point.gather), (0.0, 1.0)); + motion.update("polishing", 10.0); + assert_eq!(motion.siri_shape(10.0, 0.0).gather, 1.0); + assert!(motion.siri_shape(10.1, 0.0).gather < 1.0); + assert_eq!(motion.siri_shape(11.0, 0.0).gather, 0.0); + } + + #[test] + fn an_early_llm_cannot_spread_until_contraction_finishes() { + let mut motion = CapsuleMotion::default(); + motion.update("recording", 0.0); + motion.siri_shape(0.0, 1.0); + motion.update("polishing", 1.0); + assert_eq!(motion.siri_shape(1.4, 0.0).gather, 1.0); + assert_eq!(motion.siri_shape(1.7, 0.0).resolved, 0.0); + assert!(motion.siri_shape(1.8, 0.0).gather < 1.0); + } + + #[test] + fn ending_merges_continuously_and_a_new_session_resets() { + let mut motion = CapsuleMotion::default(); + motion.update("polishing", 0.0); + let before = motion.siri_shape(0.4, 0.0).gather; + motion.update("completed", 0.4); + assert_eq!(motion.siri_shape(0.4, 0.0).gather, before); + assert_eq!(motion.siri_shape(1.0, 0.0).gather, 1.0); + assert!(motion.update("starting", 2.0)); + assert!(!motion.update("recording", 2.1)); + assert_eq!(motion.siri_shape(2.1, 0.2).resolved, 0.2); + assert_eq!(motion.siri_shape(2.1, 0.2).gather, 1.0); + } + + #[test] + fn the_caption_sweep_retains_its_fast_then_slow_cadence() { + let mut motion = CapsuleMotion::default(); + motion.update("transcribing", 1.0); + assert_eq!(motion.shine_cycle_seconds(1.1), SHINE_FAST_CYCLE_SECONDS); + assert_eq!(motion.shine_cycle_seconds(4.0), SHINE_SLOW_CYCLE_SECONDS); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/cloud_sync.rs b/openless-all/app/linux-egui/src/ui/frontend/cloud_sync.rs new file mode 100644 index 000000000..3f654f16a --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/cloud_sync.rs @@ -0,0 +1,775 @@ +//! Encrypted cloud sync card + its dialogs (Tauri `CloudSyncSection` parity). +//! +//! The card is a status mirror: everything it shows comes from +//! `CloudSyncUi::status`, and every gesture is a [`CloudSyncAction`] the host +//! turns into a Core call. Passwords live in `CloudSyncUi` only while the form is +//! open and are cleared on dispatch or close. + +use openless_core::cloud_sync_e2ee::{ConflictSide, KeyState, RestoreMode, RestorePreview}; +use openless_linux_egui::cloud_sync_ui::{ + CloudSyncAction, CloudSyncDialog, CloudSyncIntent, CloudSyncUi, +}; +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang}; + +use super::layout; +use super::settings::{card, row_desc, toggle_row}; +use super::view_model::{FrontendAction, FrontendViewModel}; +use crate::ui::theme; + +fn act(action: CloudSyncAction) -> FrontendAction { + FrontendAction::SettingsCloudSync(action) +} + +/// The privacy section's cloud sync card. +pub fn cloud_sync_card( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + let state = &vm.cloud_sync; + card(ui, "", "", |ui| { + // ── header ──────────────────────────────────────────────────────── + row_desc( + ui, + tr_l10n(lang, "cloudSyncE2ee.title"), + tr_l10n(lang, "cloudSyncE2ee.description"), + |ui| { + if small_button(ui, tr_l10n(lang, "cloudSyncE2ee.refresh"), false) && !state.loading + { + actions.push(act(CloudSyncAction::Reload)); + } + }, + ); + + // ── master toggle ──────────────────────────────────────────────── + let toggle_enabled = state.toggle_enabled(); + toggle_row( + ui, + tr_l10n(lang, "cloudSyncE2ee.enable"), + "", + state.status.as_ref().is_some_and(|status| status.enabled), + || { + if toggle_enabled { + let current = state.status.as_ref().is_some_and(|status| status.enabled); + actions.push(act(CloudSyncAction::Toggle(!current))); + } + }, + ); + + if state.loading { + note(ui, tr_l10n(lang, "cloudSyncE2ee.loading"), theme::INK_3); + } + + // ── identity ───────────────────────────────────────────────────── + let custom_configured = !state.saved_origin.trim().is_empty(); + if !custom_configured + && !state.signed_in_to_sync() + && !state.signed_in + && small_button(ui, tr_l10n(lang, "cloudSyncE2ee.signIn"), true) + { + actions.push(FrontendAction::MarketplaceAuthStart); + } + if state.signed_in_to_sync() { + let login = state + .status + .as_ref() + .and_then(|status| status.account.as_ref()) + .map(|account| account.login.clone()) + .unwrap_or_default(); + row_desc(ui, tr_l10n(lang, "cloudSyncE2ee.account"), "", |ui| { + ui.label( + egui::RichText::new(if login.is_empty() { + "GitHub".to_string() + } else { + format!("@{login}") + }) + .size(12.0) + .color(theme::INK_2), + ); + if small_button(ui, tr_l10n(lang, "cloudSyncE2ee.signOut"), false) { + actions.push(act(CloudSyncAction::SignOut)); + } + }); + } + + // ── status block ───────────────────────────────────────────────── + if let Some(status) = &state.status { + let (title_key, detail_key) = state.focus_keys(); + ui.label( + egui::RichText::new(tr_l10n(lang, title_key)) + .size(12.5) + .strong() + .color(theme::INK), + ); + if !detail_key.is_empty() { + note(ui, tr_l10n(lang, detail_key), theme::INK_3); + } + let key_line = if status.key_state == KeyState::Unlocked { + tr_l10n(lang, "cloudSyncE2ee.keyUnlocked") + } else { + tr_l10n(lang, "cloudSyncE2ee.keyLocked") + }; + note(ui, key_line, theme::INK_2); + let snapshot_line = match status.has_cloud_snapshot { + Some(true) => tr_l10n(lang, "cloudSyncE2ee.hasSnapshot"), + Some(false) => tr_l10n(lang, "cloudSyncE2ee.noSnapshot"), + None => tr_l10n(lang, "cloudSyncE2ee.snapshotUnknown"), + }; + note(ui, snapshot_line, theme::INK_2); + if let Some(at) = &status.last_successful_sync_at { + note( + ui, + &fmt_l10n(lang, "cloudSyncE2ee.lastSync", &[at]), + theme::INK_3, + ); + } + note(ui, tr_l10n(lang, state.state_key()), theme::INK_3); + + // ── actions ────────────────────────────────────────────────── + ui.horizontal_wrapped(|ui| { + let locked = status.key_state != KeyState::Unlocked; + if locked + && status.has_cloud_snapshot == Some(true) + && button( + ui, + tr_l10n(lang, "cloudSyncE2ee.unlock"), + ButtonKind::Ghost, + !state.working(), + ) + { + actions.push(act(CloudSyncAction::Prepare(CloudSyncIntent::Unlock))); + } + let sync_label = if status.pending_operation_id.is_some() { + "cloudSyncE2ee.checkPending" + } else { + "cloudSyncE2ee.syncNow" + }; + if button( + ui, + tr_l10n(lang, sync_label), + ButtonKind::Blue, + state.can_sync(), + ) { + actions.push(act(CloudSyncAction::SyncNow)); + } + if button( + ui, + tr_l10n(lang, "cloudSyncE2ee.restore"), + ButtonKind::Ghost, + status.has_cloud_snapshot == Some(true) + && !state.working() + && !status.recovery_required, + ) { + actions.push(act(CloudSyncAction::OpenDialog(CloudSyncDialog::Consent { + intent: CloudSyncIntent::Restore, + }))); + } + if !locked { + if button( + ui, + tr_l10n(lang, "cloudSyncE2ee.lock"), + ButtonKind::Ghost, + !state.working(), + ) { + actions.push(act(CloudSyncAction::Lock)); + } + if button( + ui, + tr_l10n(lang, "cloudSyncE2ee.changePassword"), + ButtonKind::Ghost, + !state.working() && !status.recovery_required, + ) { + actions.push(act(CloudSyncAction::OpenDialog( + CloudSyncDialog::ChangePassword, + ))); + } + } + if button( + ui, + tr_l10n(lang, "cloudSyncE2ee.delete"), + ButtonKind::Ghost, + status.has_cloud_snapshot == Some(true) + && status.vault_id.is_some() + && status.remote_revision.is_some() + && !state.working(), + ) { + if let (Some(vault_id), Some(revision)) = + (status.vault_id.clone(), status.remote_revision.clone()) + { + actions.push(act(CloudSyncAction::OpenDialog(CloudSyncDialog::Delete { + vault_id, + revision, + }))); + } + } + }); + } + + // ── working / notices ──────────────────────────────────────────── + if state.working() { + ui.horizontal(|ui| { + note(ui, tr_l10n(lang, "cloudSyncE2ee.working"), theme::INK_2); + if let Some(task) = state + .status + .as_ref() + .and_then(|status| status.task_id.clone()) + { + if small_button(ui, tr_l10n(lang, "cloudSyncE2ee.cancelTask"), false) { + actions.push(act(CloudSyncAction::Cancel(task))); + } + } + }); + } + if let Some(notice) = &state.notice { + // Dynamic key: `tr_l10n` only takes `&'static str`. + note(ui, &fmt_l10n(lang, notice, &[]), theme::BLUE); + } + // 键必须是 catalog 里真实存在的字面量:之前用 `format!` 拼键,界面上 + // 出现的是裸键(`cloudSyncE2ee.errors.service_unavailable`)。 + if let Some(error) = &state.error { + let key = openless_linux_egui::cloud_sync_ui::error_key(error); + note(ui, tr_l10n(lang, key), theme::ERR); + } else if let Some(last) = state + .status + .as_ref() + .and_then(|status| status.last_error.as_ref()) + { + let key = openless_linux_egui::cloud_sync_ui::error_key(&last.code); + note(ui, tr_l10n(lang, key), theme::ERR); + } + note( + ui, + tr_l10n(lang, "cloudSyncE2ee.passwordWarning"), + theme::INK_3, + ); + + // ── custom server ──────────────────────────────────────────────── + row_desc( + ui, + tr_l10n(lang, "cloudSyncE2ee.customServerTitle"), + tr_l10n(lang, "cloudSyncE2ee.customServerHint"), + |ui| { + let mut origin = state.custom_origin.clone(); + let response = layout::text_input( + ui, + &mut origin, + egui::Id::unique("openless-cloud-sync-origin"), + "https://sync.example.com", + ui.available_width().min(280.0), + false, + ); + if response.changed() { + actions.push(act(CloudSyncAction::CustomOrigin(origin.clone()))); + } + }, + ); + row_desc( + ui, + tr_l10n(lang, "cloudSyncE2ee.customServerToken"), + "", + |ui| { + let mut token = state.custom_token.clone(); + let response = layout::text_input( + ui, + &mut token, + egui::Id::unique("openless-cloud-sync-token"), + "", + ui.available_width().min(280.0), + true, + ); + if response.changed() { + actions.push(act(CloudSyncAction::CustomToken(token.clone()))); + } + if small_button(ui, tr_l10n(lang, "cloudSyncE2ee.customServerSave"), true) + && !state.working() + { + actions.push(act(CloudSyncAction::ConfigureServer)); + } + }, + ); + }); +} + +#[derive(Clone, Copy)] +enum ButtonKind { + Ghost, + Blue, +} + +fn button(ui: &mut egui::Ui, label: &str, kind: ButtonKind, enabled: bool) -> bool { + let text = egui::RichText::new(label).size(11.5); + let text = match kind { + ButtonKind::Blue => text.color(theme::SURFACE), + ButtonKind::Ghost => text, + }; + let mut button = egui::Button::new(text) + .corner_radius(egui::CornerRadius::same(8)) + .min_size(egui::vec2(0.0, 26.0)); + button = match kind { + ButtonKind::Blue => button.fill(theme::BLUE), + ButtonKind::Ghost => button + .fill(theme::SURFACE_2) + .stroke(egui::Stroke::new(0.8, theme::LINE)), + }; + ui.add_enabled(enabled, button).clicked() +} + +fn small_button(ui: &mut egui::Ui, label: &str, primary: bool) -> bool { + button( + ui, + label, + if primary { + ButtonKind::Blue + } else { + ButtonKind::Ghost + }, + true, + ) +} + +fn note(ui: &mut egui::Ui, text: &str, color: egui::Color32) { + ui.add( + egui::Label::new(egui::RichText::new(text).size(11.5).color(color)) + .wrap() + .halign(egui::Align::Min), + ); +} + +/// Modal shell for every cloud-sync dialog. Painted above the settings sheet so +/// the card underneath cannot be clicked while a decision is pending. +pub fn cloud_sync_overlay( + ctx: &egui::Context, + body: egui::Rect, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + if !vm.cloud_sync.dialog.is_open() { + return; + } + let lang = vm.lang; + let state = &vm.cloud_sync; + egui::Area::new(egui::Id::unique("openless-cloud-sync-dialog")) + .order(egui::Order::Foreground) + .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 width = body.width().min(460.0); + let max_height = body.height() - 80.0; + let panel = egui::Rect::from_center_size( + body.center(), + egui::vec2(width, max_height.min(560.0)), + ); + 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( + &mut ui.new_child(egui::UiBuilder::new().max_rect(panel)), + |ui| { + ui.set_width(panel.width() - 36.0); + egui::ScrollArea::vertical().show(ui, |ui| { + dialog_contents(ui, lang, state, actions); + }); + }, + ); + }); +} + +fn dialog_contents( + ui: &mut egui::Ui, + lang: Lang, + state: &CloudSyncUi, + actions: &mut Vec, +) { + let dialog = state.dialog.clone(); + // `busy` freezes the forms: a second click must not queue another Core call. + let busy = state.busy; + match dialog { + CloudSyncDialog::None => {} + CloudSyncDialog::Consent { intent } => { + ui.label( + egui::RichText::new(tr_l10n(lang, "cloudSyncE2ee.consentTitle")) + .size(13.5) + .strong(), + ); + note( + ui, + tr_l10n(lang, "cloudSyncE2ee.consentDescription"), + theme::INK_2, + ); + let mut checked = state.consent_checked; + if ui + .add(egui::Checkbox::new( + &mut checked, + tr_l10n(lang, "cloudSyncE2ee.consentCheck"), + )) + .changed() + { + actions.push(act(CloudSyncAction::ConsentChecked(checked))); + } + ui.horizontal(|ui| { + if small_button(ui, tr_l10n(lang, "common.cancel"), false) { + actions.push(act(CloudSyncAction::CloseDialog)); + } + if button( + ui, + tr_l10n(lang, "cloudSyncE2ee.continue"), + ButtonKind::Blue, + state.consent_checked && !busy, + ) { + // 同意之后还要过一屏协议警告,形态与 Tauri 一致。 + actions.push(act(CloudSyncAction::OpenDialog( + CloudSyncDialog::Protocol { intent }, + ))); + } + }); + } + CloudSyncDialog::Protocol { intent } => { + ui.label( + egui::RichText::new(tr_l10n(lang, "cloudSyncE2ee.protocolTitle")) + .size(13.5) + .strong(), + ); + for key in [ + "cloudSyncE2ee.protocolIntro", + "cloudSyncE2ee.protocolPasswordTitle", + "cloudSyncE2ee.protocolPassword", + "cloudSyncE2ee.protocolEncryptionTitle", + "cloudSyncE2ee.protocolEncryption", + "cloudSyncE2ee.protocolExcludedTitle", + "cloudSyncE2ee.protocolExcluded", + ] { + note(ui, tr_l10n(lang, key), theme::INK_2); + } + ui.horizontal(|ui| { + if small_button(ui, tr_l10n(lang, "common.cancel"), false) { + actions.push(act(CloudSyncAction::CloseDialog)); + } + if button( + ui, + tr_l10n(lang, "cloudSyncE2ee.continue"), + ButtonKind::Blue, + !busy, + ) { + actions.push(act(CloudSyncAction::Prepare(intent))); + } + }); + } + CloudSyncDialog::Create { .. } => { + password_form( + ui, + lang, + state, + actions, + "cloudSyncE2ee.createTitle", + "创建", + true, + false, + ); + } + CloudSyncDialog::Unlock { intent } => { + password_form( + ui, + lang, + state, + actions, + "cloudSyncE2ee.unlockTitle", + "解锁", + false, + false, + ); + // `intent` 只用于解锁后的下一步,表单本身不区分。 + let _ = intent; + } + CloudSyncDialog::ChangePassword => { + ui.label( + egui::RichText::new(tr_l10n(lang, "cloudSyncE2ee.passwordTitle")) + .size(13.5) + .strong(), + ); + current_password_row(ui, lang, state, actions); + password_form(ui, lang, state, actions, "", "保存", true, true); + } + CloudSyncDialog::Restore { + preview, + enable_after, + } => restore_review(ui, lang, state, actions, &preview, enable_after), + CloudSyncDialog::Disable => { + ui.label( + egui::RichText::new(tr_l10n(lang, "cloudSyncE2ee.disableTitle")) + .size(13.5) + .strong(), + ); + note( + ui, + tr_l10n(lang, "cloudSyncE2ee.disableDescription"), + theme::INK_2, + ); + ui.horizontal(|ui| { + if small_button(ui, tr_l10n(lang, "common.cancel"), false) { + actions.push(act(CloudSyncAction::CloseDialog)); + } + if button( + ui, + tr_l10n(lang, "cloudSyncE2ee.confirmDisable"), + ButtonKind::Blue, + !busy, + ) { + actions.push(act(CloudSyncAction::SetEnabled(false))); + } + }); + } + CloudSyncDialog::Delete { .. } => { + ui.label( + egui::RichText::new(tr_l10n(lang, "cloudSyncE2ee.deleteTitle")) + .size(13.5) + .strong(), + ); + note( + ui, + tr_l10n(lang, "cloudSyncE2ee.deleteDescription"), + theme::INK_2, + ); + if let Some(days) = state + .status + .as_ref() + .and_then(|status| status.backup_retention_days) + { + note( + ui, + &fmt_l10n(lang, "cloudSyncE2ee.backupRetention", &[&days]), + theme::INK_3, + ); + } + ui.horizontal(|ui| { + if small_button(ui, tr_l10n(lang, "common.cancel"), false) { + actions.push(act(CloudSyncAction::CloseDialog)); + } + if button( + ui, + tr_l10n(lang, "cloudSyncE2ee.confirmDelete"), + ButtonKind::Blue, + !busy, + ) { + actions.push(act(CloudSyncAction::DeleteRemote)); + } + }); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn password_form( + ui: &mut egui::Ui, + lang: Lang, + state: &CloudSyncUi, + actions: &mut Vec, + title_key: &str, + submit: &str, + with_confirmation: bool, + _changing: bool, +) { + if !title_key.is_empty() { + ui.label( + egui::RichText::new(fmt_l10n(lang, title_key, &[])) + .size(13.5) + .strong(), + ); + } + let busy = state.busy; + let intent = state.intent.unwrap_or(CloudSyncIntent::Unlock); + + let mut password = state.password.clone(); + if layout::text_input( + ui, + &mut password, + egui::Id::unique("openless-cloud-sync-password"), + tr_l10n(lang, "cloudSyncE2ee.passwordPolicy"), + ui.available_width().min(320.0), + true, + ) + .changed() + { + actions.push(act(CloudSyncAction::Password(password.clone()))); + } + + let mut confirmation = state.confirmation.clone(); + if with_confirmation + && layout::text_input( + ui, + &mut confirmation, + egui::Id::unique("openless-cloud-sync-confirmation"), + tr_l10n(lang, "cloudSyncE2ee.confirmPassword"), + ui.available_width().min(320.0), + true, + ) + .changed() + { + actions.push(act(CloudSyncAction::Confirmation(confirmation.clone()))); + } + + let mut remember = state.remember_key; + if ui + .add(egui::Checkbox::new( + &mut remember, + tr_l10n(lang, "cloudSyncE2ee.rememberKey"), + )) + .changed() + { + actions.push(act(CloudSyncAction::RememberKey(remember))); + } + + let ready = + !state.password.is_empty() && (!with_confirmation || state.password == state.confirmation); + ui.horizontal(|ui| { + if small_button(ui, tr_l10n(lang, "common.cancel"), false) { + actions.push(act(CloudSyncAction::CloseDialog)); + } + if button(ui, submit, ButtonKind::Blue, ready && !busy) { + actions.push(act(CloudSyncAction::SubmitPassword(intent))); + } + }); +} + +fn current_password_row( + ui: &mut egui::Ui, + lang: Lang, + state: &CloudSyncUi, + actions: &mut Vec, +) { + let mut current = state.current_password.clone(); + if layout::text_input( + ui, + &mut current, + egui::Id::unique("openless-cloud-sync-current-password"), + tr_l10n(lang, "cloudSyncE2ee.currentPassword"), + ui.available_width().min(320.0), + true, + ) + .changed() + { + actions.push(act(CloudSyncAction::CurrentPassword(current.clone()))); + } +} + +fn restore_review( + ui: &mut egui::Ui, + lang: Lang, + state: &CloudSyncUi, + actions: &mut Vec, + preview: &RestorePreview, + enable_after: bool, +) { + ui.label( + egui::RichText::new(tr_l10n(lang, "cloudSyncE2ee.restoreTitle")) + .size(13.5) + .strong(), + ); + note( + ui, + tr_l10n(lang, "cloudSyncE2ee.restoreDescription"), + theme::INK_2, + ); + for (kind, count) in &preview.counts { + note(ui, &format!("{kind}: {count}"), theme::INK_2); + } + if !preview.device_settings_to_review.is_empty() { + note( + ui, + &fmt_l10n( + lang, + "cloudSyncE2ee.deviceReview", + &[&preview.device_settings_to_review.len()], + ), + theme::INK_3, + ); + note( + ui, + &preview.device_settings_to_review.join(", "), + theme::INK_3, + ); + } + + // 冲突必须先逐条选定一侧,否则 Core 会拒绝应用。 + if !preview.conflicts.is_empty() { + note( + ui, + &fmt_l10n( + lang, + "cloudSyncE2ee.conflictProgress", + &[ + &state + .conflicts + .iter() + .filter(|(_, side)| side.is_some()) + .count(), + &preview.conflicts.len(), + ], + ), + theme::INK, + ); + for item in &preview.conflicts { + let current = state + .conflicts + .iter() + .find(|(id, _)| id == &item.id) + .and_then(|(_, side)| *side); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(&item.id).size(11.5)); + for (label_key, side) in [ + ("cloudSyncE2ee.conflictLocal", ConflictSide::Local), + ("cloudSyncE2ee.conflictCloud", ConflictSide::Cloud), + ] { + let selected = current == Some(side); + let label = tr_l10n(lang, label_key); + let kind = if selected { + ButtonKind::Blue + } else { + ButtonKind::Ghost + }; + if button(ui, label, kind, true) { + actions.push(act(CloudSyncAction::ResolveConflict( + item.id.clone(), + Some(side), + ))); + } + } + }); + } + } + + let ready = !state.busy && state.conflicts_resolved(); + ui.horizontal(|ui| { + if small_button(ui, tr_l10n(lang, "common.cancel"), false) { + actions.push(act(CloudSyncAction::CloseDialog)); + } + if button( + ui, + tr_l10n(lang, "cloudSyncE2ee.replace"), + ButtonKind::Ghost, + ready, + ) { + actions.push(act(CloudSyncAction::ApplyRestore(RestoreMode::Replace))); + } + if button( + ui, + tr_l10n(lang, "cloudSyncE2ee.merge"), + ButtonKind::Blue, + ready, + ) { + actions.push(act(CloudSyncAction::ApplyRestore(RestoreMode::Merge))); + } + }); + if enable_after { + note( + ui, + tr_l10n(lang, "cloudSyncE2ee.restoreCheck"), + theme::INK_3, + ); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/corrections.rs b/openless-all/app/linux-egui/src/ui/frontend/corrections.rs index f376ef4c4..e2d2e5181 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/corrections.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/corrections.rs @@ -169,6 +169,14 @@ pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec(menu_id).unwrap_or(false)); @@ -757,7 +772,7 @@ fn detail_body( ); let toggle = ui.interact( button, - ui.id().with(("playback-toggle", &entry.id)), + ui.scope_id().with(("playback-toggle", &entry.id)), egui::Sense::click(), ); if toggle.hovered() { @@ -816,7 +831,7 @@ fn detail_body( let hit = track.expand2(egui::vec2(0.0, 10.0)); let response = ui.interact( hit, - ui.id().with(("playback-seek", &entry.id)), + ui.scope_id().with(("playback-seek", &entry.id)), egui::Sense::click_and_drag(), ); let seek = if response.dragged() || response.clicked() { @@ -905,7 +920,7 @@ fn detail_body( // 「查看原文 / 隐藏原文」开关:状态挂在条目 id 上(Tauri 是组件 state, // 切条目就重置;这里按条目记,行为等价且省一次跳变)。 - let raw_id = egui::Id::new(("openless-history-raw", entry.id.as_str())); + let raw_id = egui::Id::unique(("openless-history-raw", entry.id.as_str())); let mut raw_open = ui .ctx() .data(|data| data.get_temp::(raw_id).unwrap_or(false)); @@ -1016,20 +1031,20 @@ fn history_action_menu( icon: IconName::Play, label: tr_l10n(lang, "history.play_recording").to_string(), danger: false, - action: Box::new(FrontendAction::HistoryPlay(index)), + action: Box::new(FrontendAction::HistoryPlay(entry.id.clone())), }); items.push(HistoryMenuItem::Action { icon: IconName::Download, label: tr_l10n(lang, "history.export").to_string(), danger: false, - action: Box::new(FrontendAction::HistoryExport(index)), + action: Box::new(FrontendAction::HistoryExport(entry.id.clone())), }); 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)), + action: Box::new(FrontendAction::HistoryRetranscribe(entry.id.clone())), }); } if !entry.raw_transcript.trim().is_empty() { @@ -1061,7 +1076,7 @@ fn history_action_menu( egui::vec2(MENU_WIDTH, height), ); - egui::Area::new(egui::Id::new(( + egui::Area::new(egui::Id::unique(( "openless-history-menu-area", entry.id.as_str(), ))) @@ -1103,7 +1118,7 @@ fn history_action_menu( ); let response = ui.interact( item_rect, - ui.id().with(label.as_str()), + ui.scope_id().with(label.as_str()), egui::Sense::click(), ); if response.hovered() { @@ -1131,7 +1146,7 @@ fn history_action_menu( actions.push(*action); ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new(("openless-history-menu", entry.id.as_str())), + egui::Id::unique(("openless-history-menu", entry.id.as_str())), false, ) }); @@ -1227,7 +1242,7 @@ fn text_card_with_action( let mut toggled = false; let mut right = rect.right() - DETAIL_PADDING; if let Some((salt, text)) = copy { - let id = egui::Id::new(salt); + let id = egui::Id::unique(salt); let now = ui.input(|input| input.time); let copied = ui .ctx() @@ -1297,7 +1312,7 @@ fn confirm_overlay( lang: Lang, actions: &mut Vec, ) { - egui::Area::new(egui::Id::new("openless-history-confirm")) + egui::Area::new(egui::Id::unique("openless-history-confirm")) .order(egui::Order::Foreground) // `click` 而不是 `hover`:遮罩必须**吃掉**点击,否则点遮罩会漏到下方页面(列表行 // 会在确认框打开时被选中)。Tauri 这里用的是原生 `window.confirm()`——点外面 @@ -1315,7 +1330,7 @@ fn confirm_overlay( ); // 对话框矩形写进 memory 供测试查询(Area 覆盖整个 body,面积已不等于卡片)。 ctx.data_mut(|data| { - data.insert_temp(egui::Id::new("openless-history-confirm-card-rect"), dialog) + data.insert_temp(egui::Id::unique("openless-history-confirm-card-rect"), dialog) }); paint_card(ui.painter(), dialog); let painter = ui.painter().with_clip_rect(dialog); 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 373917e7f..e4f44afae 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/icons.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/icons.rs @@ -34,10 +34,14 @@ pub enum IconName { Github, More, ChevronRight, + ChevronLeft, Feather, Layout, Doc, Pencil, + /// 渠道卡片左侧的拖拽手柄(Tauri 用字符 `⠿`;egui 这边用同一套 Lucide + /// 图标的 GripVertical,字形风险比外字符小)。 + Grip, Cloud, Shield, Info, @@ -102,6 +106,10 @@ fn lucide_svg(icon: IconName) -> Option<(&'static str, &'static str)> { "ChevronRight", include_str!("../../../assets/lucide/ChevronRight.svg"), ), + IconName::ChevronLeft => ( + "ChevronLeft", + include_str!("../../../assets/lucide/ChevronLeft.svg"), + ), IconName::Feather => ( "Feather", include_str!("../../../assets/lucide/Feather.svg"), @@ -109,6 +117,7 @@ fn lucide_svg(icon: IconName) -> Option<(&'static str, &'static str)> { 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::Grip => ("Grip", include_str!("../../../assets/lucide/Grip.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")), @@ -138,9 +147,14 @@ fn rasterize_mask(source: &str) -> Option { ); // tiny-skia returns premultiplied white; ColorImage expects straight RGBA. // SVGs contain only white strokes, so opacity is the entire mask. + // + // 覆盖率做一次 0.75 次幂:Lucide 是 24px 下 2px 描边,缩到 13px 只剩约 1px, + // 边缘半透明像素一多,图标看着又细又尖。抬一下 alpha 让笔画更饱满、边缘更圆。 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]]); + let alpha = pixel[3] as f32 / 255.0; + let lifted = (alpha.powf(0.75) * 255.0).round().clamp(0.0, 255.0) as u8; + pixels.extend_from_slice(&[255, 255, 255, lifted]); } Some(egui::ColorImage::from_rgba_unmultiplied( [ICON_PIXELS, ICON_PIXELS], @@ -151,6 +165,17 @@ fn rasterize_mask(source: &str) -> Option { /// 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) { + draw_icon_sized(ui, center, icon, color, ICON_SIZE); +} + +/// Draw an icon at a caller-selected CSS-equivalent size. +pub fn draw_icon_sized( + ui: &egui::Ui, + center: egui::Pos2, + icon: IconName, + color: egui::Color32, + size: f32, +) { 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. @@ -160,7 +185,7 @@ pub fn draw_icon(ui: &egui::Ui, center: egui::Pos2, icon: IconName, color: egui: return; }; let ctx = ui.ctx(); - let id = egui::Id::new(("lucide-icon", name)); + let id = egui::Id::unique(("lucide-icon", name)); let texture = ctx .data(|data| data.get_temp::(id)) .or_else(|| { @@ -176,7 +201,7 @@ pub fn draw_icon(ui: &egui::Ui, center: egui::Pos2, icon: IconName, color: egui: 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_center_size(center, egui::vec2(size, size)), egui::Rect::from_min_max(egui::Pos2::ZERO, egui::pos2(1.0, 1.0)), color, ); @@ -217,6 +242,7 @@ mod tests { IconName::Chat, IconName::More, IconName::ChevronRight, + IconName::ChevronLeft, IconName::Feather, IconName::Layout, IconName::Doc, 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 4d0b84fb8..d091250dd 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/layout.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/layout.rs @@ -113,7 +113,7 @@ pub fn body_rect(ctx: &egui::Context) -> egui::Rect { // ── App icon ──────────────────────────────────────────────────────────────── pub fn load_app_icon(ctx: &egui::Context) -> egui::TextureHandle { - let id = egui::Id::new("openless-frontend-app-icon"); + let id = egui::Id::unique("openless-frontend-app-icon"); if let Some(texture) = ctx.data(|data| data.get_temp::(id)) { return texture; } @@ -137,7 +137,7 @@ pub fn style_pack_icon_texture( data_url: &str, ) -> Option { use base64::Engine as _; - let key = egui::Id::new(("openless-style-icon", pack_id)); + let key = egui::Id::unique(("openless-style-icon", pack_id)); if let Some((cached_url, texture)) = ctx.data(|data| data.get_temp::<(String, egui::TextureHandle)>(key)) { @@ -181,7 +181,7 @@ pub fn paint_window_background(ctx: &egui::Context) { let body = body_rect(ctx); let painter = ctx.layer_painter(egui::LayerId::new( egui::Order::Background, - egui::Id::new("openless-window-background"), + egui::Id::unique("openless-window-background"), )); // 整张窗口底板:白。内容区就是这块白(Tauri 的 `ol-console-main` 是 // `--ol-surface`),不要再用灰画 body——否则侧栏/内容的白灰层次正好搞反。 @@ -226,7 +226,7 @@ pub fn titlebar(ctx: &egui::Context, actions: &mut Vec) { egui::pos2(window.max.x, window.min.y + TITLEBAR_HEIGHT), ); - egui::Area::new(egui::Id::new("openless-titlebar")) + egui::Area::new(egui::Id::unique("openless-titlebar")) .order(egui::Order::Middle) // The titlebar defines its own drag zone and window-control buttons. // Keep the area in the hit-test stack for its children, without adding @@ -246,7 +246,7 @@ pub fn titlebar(ctx: &egui::Context, actions: &mut Vec) { ); let drag = ui.interact( drag_rect, - ui.id().with("titlebar-drag"), + ui.scope_id().with("titlebar-drag"), egui::Sense::click_and_drag(), ); // Ask the compositor to move the window on the *press* frame: on @@ -306,11 +306,11 @@ pub fn titlebar(ctx: &egui::Context, actions: &mut Vec) { ); 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 close_response = ui.interact(close, ui.scope_id().with("close"), egui::Sense::click()); let maximize_response = - ui.interact(maximize, ui.id().with("maximize"), egui::Sense::click()); + ui.interact(maximize, ui.scope_id().with("maximize"), egui::Sense::click()); let minimize_response = - ui.interact(minimize, ui.id().with("minimize"), egui::Sense::click()); + ui.interact(minimize, ui.scope_id().with("minimize"), egui::Sense::click()); if close_response.clicked() { actions.push(FrontendAction::WindowClose); } @@ -488,7 +488,7 @@ pub fn resize_handles(ctx: &egui::Context) { // become the top hit-test layer for the entire UI, including its transparent // interior, and would swallow every button click. for (index, (rect, direction)) in zones.into_iter().enumerate() { - let response = egui::Area::new(egui::Id::new(("openless-resize", index))) + let response = egui::Area::new(egui::Id::unique(("openless-resize", index))) // 条带要在设置遮罩(Foreground)之上:遮罩盖住了 body 范围内的外圈像素, // 否则弹窗打开时拖边缘会被遮罩吃掉、窗口改不了大小(用户报「设置页面下 // 无法修改窗口大小」)。Tooltip 只是层级,条带本身仍只占外圈 6px/18px。 @@ -532,7 +532,7 @@ fn resize_cursor(direction: egui::ResizeDirection) -> egui::CursorIcon { pub fn sidebar(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Vec) { let body = body_rect(ctx); - egui::Area::new(egui::Id::new("openless-sidebar")) + egui::Area::new(egui::Id::unique("openless-sidebar")) .order(egui::Order::Middle) // Navigation rows own their input. A hover-only area preserves their // layer while avoiding an invisible area-wide click target. @@ -891,7 +891,7 @@ const ORPHANED_PRESS_TIMEOUT: f64 = 0.4; /// 而 `ScrollArea` 正好用 `ctx.dragged_id().is_none()` 当作吃滚轮的前置条件——「拖过 /// 窗口/缩完窗口后整页滚不动,点一下页面(那一次点击的释放)才恢复」。 pub fn note_window_gesture_handoff(ctx: &egui::Context) { - let id = egui::Id::new(WINDOW_GESTURE_HANDOFF); + let id = egui::Id::unique(WINDOW_GESTURE_HANDOFF); let now = ctx.input(|input| input.time); ctx.data_mut(|data| { // 已经有一次等待中的交接就别把截止时间往后推(`holding && !focused` 那条 @@ -920,7 +920,7 @@ fn pointer_release_event(event: &egui::Event) -> bool { } fn release_orphaned_press(ctx: &egui::Context, raw_input: &mut egui::RawInput) { - let id = egui::Id::new(WINDOW_GESTURE_HANDOFF); + let id = egui::Id::unique(WINDOW_GESTURE_HANDOFF); let Some(handed_off_at) = ctx.data(|data| data.get_temp::(id)) else { return; }; @@ -990,7 +990,7 @@ pub fn content_panel(ctx: &egui::Context, add_contents: impl FnOnce(&mut egui::U ), egui::pos2(body.right() - 2.0, body.bottom() - PAGE_BOTTOM_PADDING), ); - egui::Area::new(egui::Id::new("openless-content")) + egui::Area::new(egui::Id::unique("openless-content")) .order(egui::Order::Middle) // Buttons and text fields inside the panel register their own hit targets. .sense(egui::Sense::hover()) @@ -1009,7 +1009,7 @@ pub fn content_panel(ctx: &egui::Context, add_contents: impl FnOnce(&mut egui::U ui.set_clip_rect(content); #[cfg(test)] ctx.data_mut(|data| { - data.insert_temp(egui::Id::new("content-available-size"), ui.available_size()); + data.insert_temp(egui::Id::unique("content-available-size"), ui.available_size()); }); let scroll = &mut ui.style_mut().spacing.scroll; scroll.floating = true; @@ -1105,8 +1105,6 @@ pub enum ButtonKind { 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, } @@ -1120,7 +1118,7 @@ pub fn action_button( icon: Option, kind: ButtonKind, ) -> egui::Response { - let id = ui.id().with(( + let id = ui.scope_id().with(( "openless-action-button", label, rect.left().round() as i32, @@ -1146,18 +1144,6 @@ pub fn action_button( 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)), @@ -1266,7 +1252,7 @@ pub fn toggle( on: bool, id_salt: impl std::hash::Hash + std::fmt::Debug, ) -> egui::Response { - let response = ui.interact(rect, ui.id().with(id_salt), egui::Sense::click()); + let response = ui.interact(rect, ui.scope_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() @@ -1341,7 +1327,7 @@ pub fn segmented( egui::pos2(x, rect.top() + 2.0), egui::vec2(width, rect.height() - 4.0), ); - let id = ui.id().with(( + let id = ui.scope_id().with(( "openless-segment", index, rect.left().round() as i32, @@ -1413,8 +1399,30 @@ pub fn text_input( 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()); + text_input_sized( + ui, + value, + id, + hint, + width.clamp(80.0, INPUT_MAX_WIDTH), + INPUT_HEIGHT, + password, + ) +} + +/// 与 [`text_input`] 相同,但宽高由调用方给:渠道编辑弹窗里的字段照 Tauri 是 +/// 整列宽、38px 高,比设置行里的紧凑输入框大一号。 +pub fn text_input_sized( + ui: &mut egui::Ui, + value: &mut String, + id: egui::Id, + hint: &str, + width: f32, + height: f32, + password: bool, +) -> egui::Response { + let width = width.clamp(80.0, 640.0); + let (outer, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); ui.painter() .rect_filled(outer, egui::CornerRadius::same(8), theme::SURFACE); ui.painter().rect_stroke( @@ -1438,7 +1446,7 @@ pub fn text_input( .text_color(theme::INK) .frame(egui::Frame::NONE) .desired_width(inner.width()) - .vertical_align(egui::Align::Center); + .align(egui::Align2::CENTER_CENTER); if password { edit = edit.password(true); } 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 e1a8dd286..ec2f8ad60 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/marketplace.rs @@ -24,7 +24,7 @@ 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"), + ui.scope_id().with("marketplace-my-packs"), egui::Sense::click(), ); let painter = ui.painter().with_clip_rect(rect); @@ -60,7 +60,7 @@ fn my_packs_button(ui: &mut egui::Ui, rect: egui::Rect, label: &str) -> egui::Re #[cfg(test)] ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new("openless-mine-button-test-rects"), + egui::Id::unique("openless-mine-button-test-rects"), (rect, badge, label_pos), ) }); @@ -80,7 +80,7 @@ fn close_control( glyph_size: f32, radius: u8, ) -> egui::Response { - let id = ui.id().with(( + let id = ui.scope_id().with(( "openless-close-control", rect.left().round() as i32, rect.top().round() as i32, @@ -192,7 +192,7 @@ pub fn marketplace_page( // so the search box looked like it ignored typing. let resp = ui.add( egui::TextEdit::singleline(&mut vm.marketplace_query) - .id(egui::Id::new("openless-marketplace-search")) + .id(egui::Id::unique("openless-marketplace-search")) .hint_text(tr_l10n(lang, "marketplace.search_placeholder")) .text_color(theme::INK) .frame(egui::Frame::NONE) @@ -294,6 +294,14 @@ pub fn marketplace_page( .map(|(index, _)| index) .collect(); + super::paging::load_more_footer( + ui, + lang, + super::paging::Collection::Marketplace, + vm, + false, + actions, + ); if visible.is_empty() { let (title, hint) = if liked_only { ( @@ -390,7 +398,7 @@ fn marketplace_card( rect, ui.interact( rect, - ui.id().with(("marketplace-card", index)), + ui.scope_id().with(("marketplace-card", index)), egui::Sense::click(), ), ); @@ -526,7 +534,7 @@ fn marketplace_detail( 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")) + let modal = egui::Area::new(egui::Id::unique("openless-marketplace-detail-modal")) // 与设置弹窗同一套层级策略:遮罩、点击拦截与卡片必须同属**一个** `Area`(同一个 // LayerId)。各自独立 Area 时,egui 会在按下后把被点到的 Area 抬到同层最上 // (`move_to_top`),遮罩一旦被抬起就会盖住卡片;同一图层里先画遮罩、再画卡片在 @@ -697,7 +705,7 @@ fn marketplace_detail( }); ctx.data_mut(|data| { data.insert_temp( - egui::Id::new("openless-marketplace-detail-card-rect"), + egui::Id::unique("openless-marketplace-detail-card-rect"), modal.inner.rect, ); }); @@ -718,9 +726,9 @@ fn marketplace_oauth( 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) + data.insert_temp(egui::Id::unique("openless-marketplace-oauth-card-rect"), card) }); - egui::Area::new(egui::Id::new("openless-marketplace-oauth-modal")) + egui::Area::new(egui::Id::unique("openless-marketplace-oauth-modal")) .order(egui::Order::Tooltip) .fixed_pos(body.min) .constrain(false) @@ -767,7 +775,7 @@ fn marketplace_oauth( #[cfg(test)] ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new("openless-marketplace-oauth-close-rect"), + egui::Id::unique("openless-marketplace-oauth-close-rect"), close.rect, ) }); @@ -827,7 +835,7 @@ fn marketplace_oauth( #[cfg(test)] ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new("openless-marketplace-oauth-code-box"), + egui::Id::unique("openless-marketplace-oauth-code-box"), ui.min_rect().expand2(egui::vec2(12.0, 10.0)), ) }); @@ -861,7 +869,7 @@ fn marketplace_oauth( ); let copy = ui.interact( copy_rect, - ui.id().with("openless-oauth-copy-code"), + ui.scope_id().with("openless-oauth-copy-code"), egui::Sense::click(), ); let painter = ui.painter().with_clip_rect(copy_rect); @@ -959,7 +967,7 @@ fn marketplace_oauth( #[cfg(test)] ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new("openless-marketplace-oauth-status-row"), + egui::Id::unique("openless-marketplace-oauth-status-row"), row, ) }); @@ -981,7 +989,7 @@ fn marketplace_upload( 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")) + egui::Area::new(egui::Id::unique("openless-marketplace-upload-modal")) .order(egui::Order::Foreground) .fixed_pos(body.min) .constrain(false) @@ -1153,7 +1161,7 @@ fn marketplace_withdraw_confirm( 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")) + egui::Area::new(egui::Id::unique("openless-marketplace-withdraw-confirm")) .order(egui::Order::Tooltip) .fixed_pos(body.min) .constrain(false) @@ -1241,9 +1249,9 @@ fn marketplace_mine( 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) + data.insert_temp(egui::Id::unique("openless-marketplace-mine-card-rect"), card) }); - egui::Area::new(egui::Id::new("openless-marketplace-mine-modal")) + egui::Area::new(egui::Id::unique("openless-marketplace-mine-modal")) .order(egui::Order::Foreground) .fixed_pos(body.min) .constrain(false) @@ -1742,7 +1750,7 @@ mod tests { }, ); let (outer, avatar, text): (egui::Rect, egui::Rect, egui::Pos2) = ctx.data(|data| { - data.get_temp(egui::Id::new("openless-mine-button-test-rects")) + data.get_temp(egui::Id::unique("openless-mine-button-test-rects")) .unwrap() }); assert!(outer.contains_rect(avatar)); @@ -1778,7 +1786,7 @@ mod tests { 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"))) + .data(|data| data.get_temp(egui::Id::unique("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); @@ -1787,7 +1795,7 @@ mod tests { 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"))) + .data(|data| data.get_temp(egui::Id::unique("openless-marketplace-oauth-status-row"))) .expect("the pending phase must paint the status row"); assert!( (row.center().x - card.center().x).abs() <= 2.0, @@ -1799,7 +1807,7 @@ mod tests { // 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"))) + .data(|data| data.get_temp(egui::Id::unique("openless-marketplace-oauth-code-box"))) .expect("the pending phase must paint the code tile"); assert_eq!(code_box.width(), card.width() - 44.0); assert!( @@ -1810,7 +1818,7 @@ mod tests { // 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"))) + .data(|data| data.get_temp(egui::Id::unique("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 81fa5e324..a7a3906bc 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/mod.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/mod.rs @@ -1,3 +1,5 @@ +pub mod capsule_motion; +pub mod cloud_sync; pub mod corrections; pub mod format; pub mod history; @@ -6,16 +8,19 @@ pub mod layout; pub mod marketplace; pub mod overview; pub mod pages; +pub mod paging; pub mod popups; pub mod selection_ask; pub mod settings; -pub mod siri_gl; +mod transcript_motion; +pub use openless_linux_egui::siri_wgpu; pub mod style; pub mod translation; pub mod view_model; pub mod vocab; use eframe::egui; +use openless_linux_egui::tr_l10n; use view_model::{FrontendAction, FrontendViewModel, Page}; /// Re-export the theme module from the parent ui module. @@ -75,38 +80,32 @@ pub fn render(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Vec ) .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. - } + // 阅读宽度(外观 → 排版):Tauri 用 `--ol-reading-width` + // 限制内容列,这里等价地把滚动内容收窄并居中。 + let content_width = + openless_linux_egui::typography::load().width.max_width(); + if ui.available_width() > content_width { + let margin = (ui.available_width() - content_width) / 2.0; + ui.horizontal(|ui| { + ui.add_space(margin); + ui.vertical(|ui| { + ui.set_max_width(content_width); + page_content(ui, vm, actions, page, body); + }); + }); + } else { + page_content(ui, vm, actions, page, body); } ui.add_space(32.0); }); #[cfg(test)] ctx.data_mut(|data| { data.insert_temp( - egui::Id::new("main-scroll-measure"), + egui::Id::unique("main-scroll-measure"), ( scroll_output.content_size.y, scroll_output.inner_rect.height(), - scroll_output.state.offset.y, + scroll_output.state.clamped_offset().y, ), ) }); @@ -117,9 +116,12 @@ pub fn render(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Vec // Settings overlay (rendered on top of everything). if vm.settings_open { - settings::settings_overlay(ctx, vm, actions, body); + settings::settings_page(ctx, vm, actions, body); + // 云同步的模态框必须在设置卡片之后创建:同层 Area 后创建的在上, + // 否则点「恢复/改密码」弹出的对话框会被设置面板盖住。 + cloud_sync::cloud_sync_overlay(ctx, body, vm, actions); } else if vm.settings_saving || vm.settings_save_error.is_some() { - egui::Area::new(egui::Id::new("preference-save-state")) + egui::Area::new(egui::Id::unique("preference-save-state")) .order(egui::Order::Foreground) .anchor(egui::Align2::RIGHT_TOP, egui::vec2(-20.0, 52.0)) .show(ctx, |ui| { @@ -130,6 +132,119 @@ pub fn render(ctx: &egui::Context, vm: &mut FrontendViewModel, actions: &mut Vec }); } }); + + // 快捷键录制中:「点别处」=取消(Tauri 的 Popover 点外部关闭)。 + // 录制面板在 `recording_panel` 里把自己的矩形记进 memory,这里只负责判定。 + // 进入录制的那一帧就是按下鼠标的那一帧,所以先"武装"一帧再开始判外点。 + if vm.shortcut_recording.is_some() { + let id = egui::Id::unique("openless-shortcut-recording"); + let state = ctx.data(|data| data.get_temp::<(egui::Rect, bool)>(id)); + let pressed = ctx.input(|input| { + input + .pointer + .any_pressed() + .then(|| input.pointer.interact_pos()) + .flatten() + }); + match state { + None => {} + Some((rect, armed)) => { + if !armed { + ctx.data_mut(|data| data.insert_temp(id, (rect, true))); + } else if pressed.is_some_and(|pos| !rect.contains(pos)) { + actions.push(FrontendAction::ShortcutRecording(None)); + } + } + } + } else { + // 录制结束后清掉陈旧矩形,避免下次录制首帧读到旧位置。 + ctx.data_mut(|data| { + data.remove::<(egui::Rect, bool)>(egui::Id::unique("openless-shortcut-recording")) + }); + } + + if vm.runtime_warning.is_some() { + runtime_warning_overlay(ctx, vm, actions); + } +} + +/// Body of one non-overlay page: everything that scrolls inside the main area. +fn page_content( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, + page: Page, + body: egui::Rect, +) { + 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 the settings overlay. + } + } +} + +fn runtime_warning_overlay( + ctx: &egui::Context, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let Some(warning) = vm.runtime_warning.as_deref() else { + return; + }; + let screen = ctx.input(|input| input.viewport_rect()); + let card_width = screen.width().clamp(280.0, 600.0); + let card_height = screen.height().clamp(220.0, 360.0); + let card = egui::Rect::from_center_size(screen.center(), egui::vec2(card_width, card_height)); + + egui::Area::new(egui::Id::unique("openless-runtime-warning-overlay")) + .order(egui::Order::Foreground) + .fixed_pos(screen.min) + .constrain(false) + .show(ctx, |ui| { + ui.set_min_size(screen.size()); + ui.set_max_size(screen.size()); + let _ = ui.allocate_rect(screen, egui::Sense::click()); + ui.painter().rect_filled( + screen, + egui::CornerRadius::ZERO, + egui::Color32::from_black_alpha(150), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(card), |ui| { + 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, 10], + blur: 24, + spread: 0, + color: egui::Color32::from_black_alpha(48), + }) + .show(ui, |ui| { + ui.set_min_size(card.size()); + ui.set_max_size(card.size()); + ui.vertical_centered(|ui| { + ui.add_space(28.0); + ui.heading(tr_l10n(vm.lang, "startup.input_method_unavailable_title")); + ui.add_space(16.0); + ui.label(warning); + ui.add_space(20.0); + if ui.button(tr_l10n(vm.lang, "common.close")).clicked() { + actions.push(FrontendAction::DismissRuntimeWarning); + } + }); + }); + }); + }); } /// egui 0.36 起 `FullOutput` 里的 `TexturesDelta` 必须被消费,未应用就 drop 会 @@ -204,6 +319,38 @@ mod tests { } } + #[test] + fn unavailable_input_method_renders_a_localized_dismissible_overlay() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::En, + runtime_warning: Some("fixture input method error".into()), + ..Default::default() + }; + let mut actions = Vec::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + render(&ctx, &mut vm, &mut actions); + let _ = end_pass(&ctx); + } + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + render(&ctx, &mut vm, &mut actions); + let painted = painted_text(&end_pass(&ctx)); + assert!( + painted.contains(tr_l10n(vm.lang, "startup.input_method_unavailable_title")), + "painted output: {painted}" + ); + assert!(painted.contains("fixture input method error")); + assert!(painted.contains(tr_l10n(vm.lang, "common.close"))); + assert!(actions.is_empty(), "the overlay only dismisses on a click"); + } + #[test] fn overview_page_renders_populated_dashboard_without_panicking() { let ctx = egui::Context::default(); @@ -308,7 +455,7 @@ mod tests { ); } let heatmap: egui::Rect = ctx.data(|data| { - data.get_temp(egui::Id::new("openless-overview-heatmap-rect")) + data.get_temp(egui::Id::unique("openless-overview-heatmap-rect")) .expect("populated overview should show the heatmap") }); let content_bottom = layout::body_rect(&ctx).bottom() - layout::PAGE_BOTTOM_PADDING; @@ -328,7 +475,7 @@ mod tests { 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")) + data.get_temp(egui::Id::unique("overview-scroll-measure")) .expect("provider cards plus heatmap must enable page scrolling") }); assert!( @@ -505,7 +652,7 @@ mod tests { active_page: Page::Marketplace, ..Default::default() }; - let id = egui::Id::new("openless-marketplace-search"); + let id = egui::Id::unique("openless-marketplace-search"); // warm up: egui needs a frame before the widget exists / accepts focus for _ in 0..3 { ctx.begin_pass(egui::RawInput { @@ -543,10 +690,13 @@ mod tests { }; let label = openless_linux_egui::tr_l10n(zh, "settings.recording.history_max_entries_label"); - let id = egui::Id::new(("openless-settings-text", label)); + let id = egui::Id::unique(("openless-settings-text", label)); + // 权限卡现在排在数据卡之前(与 Tauri 同序),留存输入框落到首屏之外。 + // 这个用例验证的是「输入不丢字」,与首屏布局无关,所以放大视口。 + let tall = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1240.0, 1600.0)); for _ in 0..2 { ctx.begin_pass(egui::RawInput { - screen_rect: Some(viewport()), + screen_rect: Some(tall), ..Default::default() }); let mut actions = Vec::new(); @@ -557,7 +707,7 @@ mod tests { for step in ["7", "7"] { ctx.memory_mut(|m| m.request_focus(id)); ctx.begin_pass(egui::RawInput { - screen_rect: Some(viewport()), + screen_rect: Some(tall), events: vec![egui::Event::Text(step.into())], ..Default::default() }); @@ -574,7 +724,9 @@ mod tests { "the typed value must actually be painted" ); - // 3) 添加渠道表单里的名称输入框(AI 服务与模型 → 语音识别) + // 3) 渠道编辑器(右栏覆盖)里的「渠道名称」输入框:输入立刻推 + // `SettingsProviderField`,宿主写回草稿并自动保存;这里按宿主的行为把 + // 动作回灌到 VM,验证字段吃字、并且画得出来。 let ctx = egui::Context::default(); let mut vm = FrontendViewModel { lang: zh, @@ -582,10 +734,40 @@ mod tests { settings_open: true, settings_section: super::view_model::SettingsSection::Services, services_view: 1, - channel_form_open: true, + provider_editor: Some(super::view_model::SettingsProviderEditor { + channel_id: "channel".to_string(), + is_asr: false, + is_draft: true, + provider: "DeepSeek".to_string(), + provider_type: "deepseek".to_string(), + endpoint_presets: Vec::new(), + api_key_optional: false, + default_endpoint: String::new(), + name: String::new(), + endpoint: String::new(), + model: String::new(), + resource_id: String::new(), + auth_mode: String::new(), + auth: super::view_model::SettingsProviderAuth::ApiKey, + primary_secret: String::new(), + secondary_secret: String::new(), + models: Vec::new(), + models_loading: false, + static_models: Vec::new(), + default_model: String::new(), + has_models_url: false, + custom_model: false, + protocol: Default::default(), + busy: false, + }), ..Default::default() }; - let id = egui::Id::new("openless-settings-channel-name"); + // 字段缓冲的键含渠道 id(不同渠道的同一字段互不串味)。 + let id = egui::Id::unique(( + "openless-settings-provider-field", + "channel", + "Name".to_string(), + )); let mut painted = String::new(); for _ in 0..2 { ctx.begin_pass(egui::RawInput { @@ -606,102 +788,70 @@ mod tests { let mut actions = Vec::new(); render(&ctx, &mut vm, &mut actions); painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + // 宿主行为:把字段写回草稿(自动保存只负责落盘,不改草稿值)。 + for action in actions { + if let super::view_model::FrontendAction::SettingsProviderField( + super::view_model::SettingsProviderField::Name, + value, + ) = action + { + if let Some(editor) = vm.provider_editor.as_mut() { + editor.name = value; + } + } + } } assert_eq!( - vm.channel_form_name, "my", - "the add-channel form must accept typed characters" + vm.provider_editor + .as_ref() + .map(|editor| editor.name.clone()), + Some("my".to_string()), + "the channel editor must accept typed characters" ); assert!( painted.contains("my"), - "the add-channel form must paint what was typed" + "the channel editor must paint what was typed" ); } + /// 回归:渠道编辑器只能盖住「AI 服务与模型」这一页的右栏。以前只要 + /// `provider_editor` 有值就整栏覆盖,用户一进设置只看得到模型编辑器, + /// 其它设置页全都改不了。 #[test] - fn settings_modal_tessellates_after_opening_and_resizing() { - use super::view_model::SettingsSection; - for section in [ - SettingsSection::General, - SettingsSection::Shortcuts, - SettingsSection::Services, - SettingsSection::Appearance, - SettingsSection::Privacy, - SettingsSection::Advanced, - SettingsSection::About, - ] { - let ctx = egui::Context::default(); - let mut vm = FrontendViewModel { - lang: openless_linux_egui::Lang::En, - settings_open: true, - settings_section: section, - ..Default::default() - }; - for size in [egui::vec2(1300.0, 835.0), egui::vec2(960.0, 640.0)] { - for _ in 0..3 { - 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 output = end_pass(&ctx); - let primitives = ctx.tessellate(output.shapes, output.pixels_per_point); - assert!(!primitives.is_empty(), "{section:?} must paint a frame"); - for primitive in primitives { - if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive { - assert!( - mesh.vertices.iter().all(|vertex| vertex.pos.is_finite()), - "{section:?} produced non-finite vertices at {size:?}" - ); - } - } - } - } - } - } - - #[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() { + fn the_channel_editor_never_covers_other_settings_sections() { let ctx = egui::Context::default(); let zh = openless_linux_egui::Lang::ZhCn; + let editor = || { + Some(super::view_model::SettingsProviderEditor { + channel_id: "channel".to_string(), + is_asr: false, + is_draft: false, + provider: "DeepSeek".to_string(), + provider_type: "deepseek".to_string(), + endpoint_presets: Vec::new(), + api_key_optional: false, + default_endpoint: String::new(), + name: "DeepSeek".to_string(), + endpoint: String::new(), + model: "deepseek-v4-flash".to_string(), + resource_id: String::new(), + auth_mode: String::new(), + auth: super::view_model::SettingsProviderAuth::ApiKey, + primary_secret: String::new(), + secondary_secret: String::new(), + models: Vec::new(), + models_loading: false, + static_models: Vec::new(), + default_model: String::new(), + has_models_url: false, + custom_model: false, + protocol: Default::default(), + busy: false, + }) + }; 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 { @@ -709,10 +859,10 @@ mod tests { active_page: Page::Settings, settings_open: true, settings_section: section, + provider_editor: editor(), ..Default::default() }; - // The shortcuts section renders key caps for the live bindings. - vm.dictation_hotkey = "Ctrl+Shift+Z".to_string(); + let mut painted = String::new(); for _ in 0..2 { ctx.begin_pass(egui::RawInput { screen_rect: Some(viewport()), @@ -720,55 +870,15 @@ mod tests { }); 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:?})" - ); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); } - // 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:?})" + !painted.contains(openless_linux_egui::tr_l10n( + zh, + "settings.channels.edit_title" + )), + "an open channel editor must not cover the {section:?} section" ); - 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" - ); - } - } } } @@ -905,31 +1015,678 @@ mod tests { lines } + /// The pipeline selector remains available in both modes, matching Tauri. + #[test] + fn the_pipeline_mode_row_is_available_in_both_modes() { + let zh = openless_linux_egui::Lang::ZhCn; + for multimodal in [false, true] { + let mut vm = FrontendViewModel { + multimodal_view: multimodal, + pipeline_multimodal: multimodal, + ..Default::default() + }; + let lines = + painted_settings_lines(super::view_model::SettingsSection::Services, &mut vm); + for key in [ + "settings.providers.pipeline_mode_label", + "settings.providers.pipeline_mode_traditional", + "settings.providers.pipeline_mode_multimodal", + "settings.providers.pipeline_isolation_notice", + ] { + let text = openless_linux_egui::tr_l10n(zh, key); + assert!( + lines.iter().any(|line| line == text), + "{key} must remain available, multimodal={multimodal}: {lines:?}" + ); + } + } + } + + /// 渠道卡片的状态块(Tauri `ChannelTestStatus`):失败的渠道要给出错误、 + /// 「不会自动停用」的说明、多久以前,以及超过 24 小时的过期提示;有结果 + /// 时验证按钮变成「重新验证」。 #[test] - fn shortcut_menu_reveals_record_and_disable() { + fn a_failed_channel_card_explains_the_failure_and_the_stale_result() { 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), + channels: vec![super::view_model::SettingsChannel { + id: "deepseek-1".into(), + name: "DeepSeek".into(), + provider: "DeepSeek".into(), + provider_type: "deepseek".into(), + model: "deepseek-v4-flash".into(), + is_active: true, + enabled: true, + last_ok: Some(false), + last_error: Some("-401".into()), + last_latency_ms: None, + // 25 小时前:既要有「1 天前」,也要有过期提示。 + last_check_age_seconds: Some(25 * 60 * 60), + }], ..Default::default() }; - let open = painted_settings_lines(super::view_model::SettingsSection::Shortcuts, &mut vm); + let lines = painted_settings_lines(super::view_model::SettingsSection::Services, &mut vm); + let painted = |text: &str| lines.iter().any(|line| line.contains(text)); + for text in [ + openless_linux_egui::tr_l10n(zh, "settings.channels.last_check"), + &openless_linux_egui::fmt_l10n(zh, "settings.channels.failed", &[&"-401"]), + openless_linux_egui::tr_l10n(zh, "settings.channels.failure_keeps_enabled"), + openless_linux_egui::tr_l10n(zh, "settings.channels.reverify"), + &openless_linux_egui::fmt_l10n(zh, "settings.channels.days_ago", &[&1]), + openless_linux_egui::tr_l10n(zh, "settings.channels.stale_result"), + ] { + assert!(painted(text), "{text:?} must be painted: {lines:?}"); + } assert!( - open.iter().any(|line| line == record), - "the record button must be painted" + !lines + .iter() + .any(|line| line.trim() + == openless_linux_egui::tr_l10n(zh, "settings.channels.verify")), + "a channel with a past result offers a re-check, not a first check" ); + + // 从未验证过的渠道:只说「尚未验证」,不给出过期/失败字样。 + vm.channels[0].last_ok = None; + vm.channels[0].last_error = None; + vm.channels[0].last_check_age_seconds = None; + let fresh = painted_settings_lines(super::view_model::SettingsSection::Services, &mut vm); + let painted = |text: &str| fresh.iter().any(|line| line.contains(text)); + assert!(painted(openless_linux_egui::tr_l10n( + zh, + "settings.channels.not_verified" + ))); + assert!(!painted(openless_linux_egui::tr_l10n( + zh, + "settings.channels.stale_result" + ))); + assert!(!painted(openless_linux_egui::tr_l10n( + zh, + "settings.channels.reverify" + ))); + } + + /// 录制器(键帽 + ↻ + ✕)必须贴内容区右边缘:之前用 `allocate_ui_with_layout` + /// 的两列,`top_down` 会按内容收缩,标签列只占文字宽,录制器停在中途。 + #[test] + fn shortcut_recorder_hugs_the_right_edge() { + 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, + ..Default::default() + }; + let viewport = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1321.0, 856.0)); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let _ = crate::ui::frontend::end_pass(&ctx); + } + let dictation = openless_linux_egui::tr_l10n( + openless_linux_egui::Lang::ZhCn, + "settings.shortcuts.start_stop", + ); + let (row, recorder) = ctx + .data(|data| { + data.get_temp::<(egui::Rect, egui::Rect)>(egui::Id::unique(( + "shortcut-row-rects", + dictation, + ))) + }) + .expect("the dictation row must publish its rects"); + // 录制器右边缘 == 行的右边缘(也就是内容区右边缘)。 assert!( - open.iter().any(|line| line == disable), - "the disable button must be painted" + (recorder.right() - row.right()).abs() <= 1.0, + "the recorder must hug the right edge: row={row:?} recorder={recorder:?}" ); - // 收起菜单后两个按钮都要消失。 - 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)); + // 录制器有固定上限,不能把标签区吃掉。 + assert!( + (recorder.width() - 260.0).abs() <= 1.0, + "the recorder column must cap at 260: {recorder:?}" + ); + } + + /// 设置页任何分区都不能把 i18n key 当文案画出来。 + /// + /// 之前「字体与排版」的三行显示成 `uiRefinement.native` / `.comfortable`: + /// 标签是通过 `font.label_key()` 取到的,i18n 同步脚本只扫字面量, + /// key 没进 egui 目录,`tr_l10n` 就原样回吐。这里用「点后面带大写字母」 + /// 的启发式认 key(域名不会长成这样),把这类回归钉住。 + #[test] + fn no_settings_section_paints_a_raw_i18n_key() { + fn looks_like_key(text: &str) -> bool { + let Some((head, tail)) = text.split_once('.') else { + return false; + }; + !head.is_empty() + && head.chars().all(|c| c.is_ascii_alphanumeric()) + && tail.chars().any(|c| c.is_ascii_uppercase()) + && tail + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_') + } + fn collect(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => out.push(text.galley.text().trim().to_string()), + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, out); + } + } + _ => {} + } + } + let sections = [ + super::view_model::SettingsSection::General, + super::view_model::SettingsSection::Shortcuts, + super::view_model::SettingsSection::Services, + super::view_model::SettingsSection::Appearance, + super::view_model::SettingsSection::Privacy, + super::view_model::SettingsSection::Advanced, + super::view_model::SettingsSection::About, + ]; + for section in sections { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::Settings, + settings_open: true, + settings_section: section, + ..Default::default() + }; + let mut actions = Vec::new(); + for _ in 0..2 { + // 视口放宽到整页高度,否则 ScrollArea 只渲染首屏,检查不到下面 + // 的分区(这条测试因此可能漏掉真正的原始 key)。 + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1321.0, 4000.0), + )), + ..Default::default() + }); + render(&ctx, &mut vm, &mut actions); + let output = crate::ui::frontend::end_pass(&ctx); + let mut lines = Vec::new(); + for clipped in &output.shapes { + collect(&clipped.shape, &mut lines); + } + let raw: Vec<&String> = lines.iter().filter(|line| looks_like_key(line)).collect(); + assert!(raw.is_empty(), "{section:?} painted raw i18n keys: {raw:?}"); + } + } } + /// 选区助手卡片的行为设置只在「语音编辑」开启后出现(对齐 Tauri: + /// 自动判断意图 / 线索词 / 编辑方案格式 / 系统提示词都在 voiceEnabled 里)。 + #[test] + fn selection_assistant_rows_follow_the_voice_edit_toggle() { + use openless_linux_egui::{tr_l10n, Lang}; + let ctx = egui::Context::default(); + let render_section = |voice: bool, auto_intent: bool| -> String { + let mut vm = FrontendViewModel { + lang: Lang::ZhCn, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::General, + ..Default::default() + }; + vm.settings.selection_voice_enabled = voice; + vm.settings.selection_voice_auto_intent = auto_intent; + let mut out = String::new(); + for _ in 0..2 { + // 「录音与输入」很长,选区助手在首屏之外;视口放大到能容纳整页, + // 否则 ScrollArea 会把卡片裁掉(不是没画,是裁了)。 + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1321.0, 4000.0), + )), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let output = crate::ui::frontend::end_pass(&ctx); + 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); + } + } + _ => {} + } + } + if voice { + // 只在第二轮(滚动到分区后)收集,避免重复。 + } + let mut text = String::new(); + for clipped in &output.shapes { + collect(&clipped.shape, &mut text); + } + out = text; + } + out + }; + let auto_intent = tr_l10n(Lang::ZhCn, "settings.selection_workspace.auto_intent"); + let plan_format = tr_l10n(Lang::ZhCn, "settings.selection_workspace.edit_plan_format"); + let keywords = tr_l10n(Lang::ZhCn, "settings.selection_workspace.edit_keywords"); + let prompt = tr_l10n( + Lang::ZhCn, + "settings.selection_workspace.edit_system_prompt", + ); + let voice_enable = tr_l10n(Lang::ZhCn, "settings.selection_workspace.voice_enable"); + + let off = render_section(false, false); + assert!( + off.contains(voice_enable), + "the voice-edit toggle must always be visible: {off}" + ); + assert!( + !off.contains(auto_intent), + "auto-intent must stay hidden while voice edit is off: {off}" + ); + assert!( + !off.contains(plan_format), + "edit plan format must stay hidden while voice edit is off" + ); + assert!( + !off.contains(prompt), + "the system prompt must stay hidden while voice edit is off" + ); + + let on = render_section(true, false); + assert!( + on.contains(auto_intent), + "auto-intent must appear once voice edit is on" + ); + assert!( + on.contains(keywords), + "keyword cues must appear while auto-intent is off" + ); + assert!( + on.contains(plan_format), + "edit plan format must appear once voice edit is on" + ); + assert!( + on.contains(prompt), + "the system prompt must appear once voice edit is on" + ); + + let auto = render_section(true, true); + assert!( + !auto.contains(keywords), + "keyword cues must hide once auto-intent is on" + ); + } + + /// 渠道编辑器的输入框草稿按「渠道 + 字段」保存,并在宿主值变化时重新播种。 + /// + /// 早期只按字段类型做 key:编辑器第一次渲染时 `value` 还是空的,这个空缓冲会 + /// 被每帧写回,之后加载出来的 endpoint / model 再也进不了输入框——表现就是 + /// 「编辑已有渠道读不到配置」。同一份缓冲还会在渠道之间串味。 + #[test] + fn provider_editor_fields_reseed_when_the_channel_config_arrives() { + let ctx = egui::Context::default(); + let render_services = |vm: &mut FrontendViewModel| -> String { + let mut text = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1321.0, 1400.0), + )), + ..Default::default() + }); + render(&ctx, vm, &mut Vec::new()); + let output = crate::ui::frontend::end_pass(&ctx); + 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); + } + } + _ => {} + } + } + text.clear(); + for clipped in &output.shapes { + collect(&clipped.shape, &mut text); + } + } + text + }; + let base = || FrontendViewModel { + lang: openless_linux_egui::Lang::ZhCn, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Services, + services_view: 0, + ..Default::default() + }; + let editor = |channel_id: &str, endpoint: &str, model: &str| { + super::view_model::SettingsProviderEditor { + channel_id: channel_id.to_string(), + is_asr: false, + is_draft: false, + provider: "DeepSeek".into(), + provider_type: "deepseek".into(), + endpoint_presets: Vec::new(), + api_key_optional: false, + default_endpoint: String::new(), + name: channel_id.to_string(), + endpoint: endpoint.to_string(), + model: model.to_string(), + resource_id: String::new(), + auth_mode: String::new(), + auth: super::view_model::SettingsProviderAuth::ApiKey, + primary_secret: String::new(), + secondary_secret: String::new(), + models: Vec::new(), + models_loading: false, + static_models: Vec::new(), + default_model: String::new(), + has_models_url: false, + custom_model: true, + protocol: Default::default(), + busy: false, + } + }; + + // 1) 先开一个「添加渠道」草稿(字段是空的)——用户先新建再编辑已有的路径。 + let mut vm = base(); + let mut draft = editor("new-channel", "", ""); + draft.is_draft = true; + vm.provider_editor = Some(draft); + let painted = render_services(&mut vm); + assert!(painted.contains("new-channel") || painted.contains("DeepSeek")); + // 编辑器还没加载完时面板不渲染,但字段缓冲不应因此被"锁定"为空。 + vm.provider_editor = None; + let _ = render_services(&mut vm); + + // 2) 加载出已有渠道的真实配置:输入框必须显示它们。 + vm.provider_editor = Some(editor( + "deepseek", + "https://api.deepseek.com/v1", + "deepseek-chat", + )); + let painted = render_services(&mut vm); + assert!( + painted.contains("https://api.deepseek.com/v1"), + "the saved endpoint must be painted:\n{painted}" + ); + assert!( + painted.contains("deepseek-chat"), + "the saved model must be painted:\n{painted}" + ); + + // 3) 换一个渠道:不能继续显示上一个渠道的文字。 + vm.provider_editor = Some(editor( + "ark", + "https://ark.cn-beijing.volces.com/api/v3", + "ep-2024", + )); + let painted = render_services(&mut vm); + assert!(painted.contains("https://ark.cn-beijing.volces.com/api/v3")); + assert!( + !painted.contains("https://api.deepseek.com/v1"), + "the previous channel's endpoint must not leak into the next one" + ); + } + + /// 「权限与数据」页的卡片顺序必须与 Tauri 一致:权限 → 数据与存储 → 云端同步。 + /// 之前把数据卡放在最前面,用户先看到留存输入框,找不到「哪里报错了」。 + #[test] + fn privacy_page_lists_permissions_before_storage_and_sync() { + use openless_linux_egui::Lang; + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: Lang::ZhCn, + active_page: Page::Settings, + settings_open: true, + settings_section: super::view_model::SettingsSection::Privacy, + ..Default::default() + }; + let mut text = String::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(1321.0, 4000.0), + )), + ..Default::default() + }); + render(&ctx, &mut vm, &mut Vec::new()); + let output = crate::ui::frontend::end_pass(&ctx); + 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); + } + } + _ => {} + } + } + text.clear(); + for clipped in &output.shapes { + collect(&clipped.shape, &mut text); + } + } + // 用具名函数而不是闭包:闭包捕获 `text` 会让借用逃逸出闭包体。 + fn position(text: &str, key: &'static str) -> Option { + text.find(openless_linux_egui::tr_l10n( + openless_linux_egui::Lang::ZhCn, + key, + )) + } + let permissions = position(&text, "settings.permissions.title") + .unwrap_or_else(|| panic!("permissions card missing:\n{text}")); + let storage = position(&text, "settings.data_storage.title") + .unwrap_or_else(|| panic!("storage card missing:\n{text}")); + let sync = position(&text, "cloudSyncE2ee.title") + .unwrap_or_else(|| panic!("cloud sync card missing:\n{text}")); + assert!( + permissions < storage && storage < sync, + "permissions ({permissions}) then storage ({storage}) then sync ({sync})" + ); + // 权限卡必须先给出网络结论,再谈留存;否则「不可用」藏在页面底部。 + let network = position(&text, "settings.permissions.network_label") + .unwrap_or_else(|| panic!("network row missing:\n{text}")); + let retention = position(&text, "settings.recording.history_retention_label") + .unwrap_or_else(|| panic!("retention row missing:\n{text}")); + assert!(network < retention, "network status must precede retention"); + } + + /// 录制中点击别处必须停止录制(Tauri 的 Popover 点外部关闭)。 /// 录制中点击别处必须停止录制(Tauri 的 Popover 点外部关闭)。 + #[test] + fn clicking_outside_the_recording_panel_stops_recording() { + 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 mut actions = Vec::new(); + for _ in 0..2 { + actions.clear(); + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + } + let cancels = |actions: &Vec| { + actions + .iter() + .any(|action| matches!(action, FrontendAction::ShortcutRecording(None))) + }; + assert!( + !cancels(&actions), + "a plain frame must not cancel recording" + ); + // 第三帧:在左上角(录制面板之外)按下鼠标。 + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events: vec![egui::Event::PointerButton { + pos: egui::pos2(40.0, 60.0), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }], + ..Default::default() + }); + actions.clear(); + render(&ctx, &mut vm, &mut actions); + let _ = crate::ui::frontend::end_pass(&ctx); + assert!( + cancels(&actions), + "clicking outside must stop recording: {} actions", + actions.len() + ); + } + + /// 回归:连点筛选必须生效,尤其要从某个分类点回「全部」。 + /// 之前筛选按钮的 id 跟着 `ui.scope_id()` 走,切换分类会改变 auto-id, + /// 点击落到别的控件上,表现就是「点全部没反应」。 + #[test] + fn shortcut_filters_switch_and_return_to_all() { + 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, + ..Default::default() + }; + let click = |ctx: &egui::Context, vm: &mut FrontendViewModel, group: &str, label: &str| { + let key = (group.to_string(), label.to_string()); + let rect = ctx + .data(|data| { + data.get_temp::>( + egui::Id::unique("shortcut-filter-rects"), + ) + }) + .and_then(|rects| rects.get(&key).copied()); + // 先在上一帧里找到按钮位置,再按下并抬起。 + let rect = rect.unwrap_or_else(|| panic!("no rect painted for {group}/{label}")); + let pos = rect.center(); + for events in [ + vec![egui::Event::PointerMoved(pos)], + vec![ + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: Default::default(), + }, + egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }, + ], + ] { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + events, + ..Default::default() + }); + render(ctx, vm, &mut Vec::new()); + let _ = crate::ui::frontend::end_pass(ctx); + } + }; + // 注意:必须在**同一个 Context** 里渲染 —— 筛选状态存在 egui 的临时记忆里, + // 换一个 Context 就等于换了份状态(`painted_settings_lines` 自带 Context, + // 这里不能用它)。 + let lines = |ctx: &egui::Context, vm: &mut FrontendViewModel| -> Vec { + let mut out = Vec::new(); + for _ in 0..2 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + render(ctx, vm, &mut Vec::new()); + out = painted_text(&crate::ui::frontend::end_pass(ctx)) + .lines() + .map(|line| line.trim().to_string()) + .filter(|line| !line.is_empty()) + .collect(); + } + out + }; + let all_rows = lines(&ctx, &mut vm); + // 用行标签而不是「翻译」二字:左侧导航栏也有「翻译」,会误判。 + assert!(all_rows.iter().any(|line| line == "翻译修饰键")); + + // 切到「风格」:只剩切风格那条 + 添加动作。 + click(&ctx, &mut vm, "分类", "风格"); + let filtered = lines(&ctx, &mut vm); + assert!( + !filtered.iter().any(|line| line == "翻译修饰键"), + "风格分类不该包含翻译行: {filtered:?}" + ); + + // 关键:点回「全部」,所有行必须回来。 + click(&ctx, &mut vm, "分类", "全部"); + let back = lines(&ctx, &mut vm); + assert!( + back.iter().any(|line| line == "翻译修饰键"), + "点『全部』必须恢复完整列表: {back:?}" + ); + } + + /// Tauri beta 4+ 的快捷键页:录制器内联(键帽 + ↻ + ✕), + /// 不再有 chevron 菜单;每行都要画出重置/清空两个图标按钮。 + #[test] + fn every_shortcut_row_has_inline_reset_and_clear() { + use super::view_model::ShortcutField; + 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 record = openless_linux_egui::tr_l10n( + openless_linux_egui::Lang::ZhCn, + "settings.recording.combo_record_btn", + ); + assert!(!lines.iter().any(|line| line == record)); + // 点击键帽开始录制后,同一行换成录制提示。 + vm.shortcut_recording = Some(ShortcutField::Qa); + let recording = + painted_settings_lines(super::view_model::SettingsSection::Shortcuts, &mut vm); + assert!( + recording.iter().any(|line| line.contains("Esc")), + "the recording panel must offer Esc to cancel: {recording:?}" + ); + } + + /// 新快捷键页的行序(Tauri `ShortcutsSection` 的 commands 顺序): + /// 语音与文本 → 风格 → 系统,最后是只读的 Esc。 #[test] fn shortcut_rows_follow_the_video_order() { let zh = openless_linux_egui::Lang::ZhCn; @@ -948,19 +1705,24 @@ mod tests { let start = index_of("settings.shortcuts.start_stop"); let translation = index_of("hotkey.translation"); let qa = index_of("selection_ask.hotkey_title"); + let polish = index_of("settings.selection_workspace.polish_hotkey"); 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 + && qa < polish + && polish < switch_style + && switch_style < open_app && open_app < cancel, "shortcut rows must keep the Tauri order, painted: {lines:?}" ); + let add = openless_linux_egui::tr_l10n(zh, "settings.shortcuts.style_pack_add"); + assert!( + lines.iter().any(|line| line.contains(add)), + "the add-style-shortcut action must be painted: {lines:?}" + ); } #[test] @@ -1306,17 +2068,14 @@ mod tests { } } - /// 设置页遮罩用的是离屏模糊背板,而不是一层暗色:发布的纹理必须真的落到弹窗图层 - /// 上,否则用户看到的还是旧的黑遮罩。 + /// 设置现在是整页:不再有磨砂遮罩、没有压暗层,页本身也不能退化成弹窗。 #[test] - fn the_settings_overlay_paints_the_published_blurred_backdrop() { + fn the_settings_page_paints_no_backdrop_any_more() { 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 { @@ -1330,45 +2089,32 @@ mod tests { render(&ctx, &mut vm, &mut Vec::new()); let layer = egui::LayerId::new( egui::Order::Foreground, - egui::Id::new("openless-settings-modal"), + egui::Id::unique("openless-settings-page"), ); - 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) + let paints_backdrop = ctx.graphics(|graphics| { + graphics.get(layer).is_some_and(|list| { + list.all_entries().any(|entry| match &entry.shape { + // 模糊背板(带纹理的矩形)与 `--ol-overlay-bg` 压暗层都算遮罩。 + egui::Shape::Rect(rect) => { + rect.fill_texture_id() == egui::TextureId::User(9) + || rect.fill == theme::OVERLAY } - _ => None, + _ => false, }) }) }); - 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()); + assert!( + !paints_backdrop, + "the settings page must not paint a modal backdrop" + ); + let card = ctx + .data(|data| { + data.get_temp::(egui::Id::unique("openless-settings-card-rect")) + }) + .expect("the settings page still publishes its surface rect"); + // 整页:surface 就是内容区本身,不再缩成居中卡片。 + // 整页贴满内容区(不再内缩,避免多出白边)。 + assert_eq!(card, layout::body_rect(&ctx)); let _ = end_pass(&ctx); } } @@ -1396,7 +2142,7 @@ mod tests { }); render(&ctx, &mut vm, &mut Vec::new()); let available: egui::Vec2 = ctx.data(|data| { - data.get_temp(egui::Id::new("content-available-size")) + data.get_temp(egui::Id::unique("content-available-size")) .unwrap() }); let expected = layout::body_rect(&ctx).size() @@ -1430,7 +2176,7 @@ mod tests { let _ = end_pass(&ctx); } let (list, detail): (egui::Rect, egui::Rect) = ctx.data(|data| { - data.get_temp(egui::Id::new("history-column-rects")) + data.get_temp(egui::Id::unique("history-column-rects")) .unwrap() }); assert!( @@ -1486,6 +2232,7 @@ mod tests { delta: egui::vec2(0.0, -180.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, }); } ctx.begin_pass(egui::RawInput { @@ -1497,7 +2244,7 @@ mod tests { let _ = end_pass(&ctx); } let (content, visible, offset): (f32, f32, f32) = - ctx.data(|data| data.get_temp(egui::Id::new(measure)).unwrap()); + ctx.data(|data| data.get_temp(egui::Id::unique(measure)).unwrap()); assert!( content > visible + 10.0, "{page:?} {measure} no scroll extent: {content}/{visible}" @@ -1544,6 +2291,7 @@ mod tests { delta: egui::vec2(0.0, -180.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, }); } frame_with_pointer_routing( @@ -1558,7 +2306,7 @@ mod tests { ); } let (content, visible, offset): (f32, f32, f32) = ctx.data(|data| { - data.get_temp(egui::Id::new("main-scroll-measure")) + data.get_temp(egui::Id::unique("main-scroll-measure")) .expect("the translation page must have a scroll container") }); assert!( @@ -1641,6 +2389,7 @@ mod tests { delta: egui::vec2(0.0, -180.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, }); } let size = if frame >= 4 { small } else { large }; @@ -1656,7 +2405,7 @@ mod tests { ); } let (content, visible, offset): (f32, f32, f32) = ctx.data(|data| { - data.get_temp(egui::Id::new("overview-scroll-measure")) + data.get_temp(egui::Id::unique("overview-scroll-measure")) .expect("the overview must offer page scrolling at the minimum size") }); assert!( @@ -1686,6 +2435,7 @@ mod tests { delta: egui::vec2(0.0, -120.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, }], ..Default::default() }; @@ -1712,6 +2462,7 @@ mod tests { delta: egui::vec2(0.0, -120.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, }], ..Default::default() }; @@ -1826,6 +2577,7 @@ mod tests { delta: egui::vec2(0.0, -180.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, }, ] } else { @@ -1845,7 +2597,7 @@ mod tests { "main-scroll-measure" }; let (content, visible, offset): (f32, f32, f32) = ctx.data(|data| { - data.get_temp(egui::Id::new(id)) + data.get_temp(egui::Id::unique(id)) .expect("scroll container must exist") }); assert!( @@ -1875,7 +2627,7 @@ mod tests { 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")) + data.get_temp::<(f32, f32, f32)>(egui::Id::unique("main-scroll-measure")) .unwrap() .2 }) @@ -1903,6 +2655,7 @@ mod tests { delta: egui::vec2(0.0, -130.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, }, ], ); @@ -1958,7 +2711,7 @@ mod tests { 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))) + egui::Area::new(egui::Id::unique(("translation-layout", index))) .fixed_pos(viewport.min) .show(&ctx, |ui| { ui.set_min_size(viewport.size()); @@ -1975,7 +2728,7 @@ mod tests { }); 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")) + data.get_temp(egui::Id::unique("translation-style-badge")) .expect("translation style has a badge") }); assert!( @@ -2145,7 +2898,7 @@ mod tests { ctx.layer_id_at(pointer), Some(egui::LayerId::new( egui::Order::Middle, - egui::Id::new("openless-sidebar"), + egui::Id::unique("openless-sidebar"), )), "the sidebar must be the top input layer over its own column" ); @@ -2324,7 +3077,7 @@ mod tests { // 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"))) + .data(|data| data.get_temp(egui::Id::unique("openless-style-card-icon-rects"))) .expect("the style cards must publish their icon tiles"); assert_eq!( published.len(), @@ -2411,12 +3164,10 @@ mod tests { ); } - /// 用户报告「缩放窗口时并不是始终居中」:模态卡片此前用 `Area::anchor` - /// 定位,垂直方向稳定偏下 19.5px,且卡片被内容撑宽 22px(横向偏 11px)。 - /// 现在位置由 `body.center()` 显式算出,两种偏差都必须消失,并且**改变窗口 - /// 尺寸后的第一帧**就要居中(不能靠后续帧收敛)。 + /// 设置页整页铺满内容区:改窗口尺寸后的**第一帧**就得贴住内容区四边, + /// 不能再有「居中卡片」留下的边距。 #[test] - fn the_settings_modal_stays_centred_across_window_resizes() { + fn the_settings_page_fills_the_content_area_across_resizes() { let ctx = egui::Context::default(); let zh = openless_linux_egui::Lang::ZhCn; let mut vm = FrontendViewModel { @@ -2432,7 +3183,6 @@ mod tests { (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), @@ -2442,33 +3192,20 @@ mod tests { 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 + let page = ctx .data(|data| { - data.get_temp::(egui::Id::new("openless-settings-card-rect")) + data.get_temp::(egui::Id::unique("openless-settings-card-rect")) }) - .expect("the settings card rect must be published while the overlay is open"); + .expect("the settings page must publish its surface rect"); + let expected = body; assert!( - (modal.center().x - body.center().x).abs() <= 1.5, - "modal must be horizontally centred at {width}x{height}: modal={modal:?} body={body:?}" + (page.min - expected.min).length() <= 1.5, + "the settings sheet must start at the content origin at {width}x{height}: page={page:?} 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:?}" + (page.size() - expected.size()).length() <= 1.5, + "the settings sheet must fill the content area at {width}x{height}: page={page:?} body={body:?}" ); } } @@ -2516,7 +3253,7 @@ mod tests { card_rect_key: &str, placement: CardPlacement, ) { - let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::new(area_id)); + let layer = egui::LayerId::new(egui::Order::Foreground, egui::Id::unique(area_id)); // Areas 用第一帧建立屏幕矩形,第二帧才可查询。 for _ in 0..2 { overlay_frame(ctx, vm, Vec::new()); @@ -2524,10 +3261,10 @@ mod tests { let body = layout::body_rect(ctx); // 遮罩区就是弹窗 `Area` 自己的矩形(遮罩 + 点击拦截 + 卡片同属它)。 let overlay = ctx - .memory(|mem| mem.area_rect(egui::Id::new(area_id))) + .memory(|mem| mem.area_rect(egui::Id::unique(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))) + .data(|data| data.get_temp::(egui::Id::unique(card_rect_key))) .unwrap_or_else(|| { panic!("{card_rect_key} must be published while the overlay is open") }); @@ -2612,7 +3349,7 @@ mod tests { }], ); let after = ctx - .data(|data| data.get_temp::(egui::Id::new(card_rect_key))) + .data(|data| data.get_temp::(egui::Id::unique(card_rect_key))) .unwrap_or_else(|| panic!("{card_rect_key} must stay published")); assert_eq!( after, card, @@ -2679,7 +3416,7 @@ mod tests { let body = layout::body_rect(&ctx); let card = ctx .data(|data| { - data.get_temp::(egui::Id::new("openless-marketplace-mine-card-rect")) + data.get_temp::(egui::Id::unique("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:?}"); @@ -2689,7 +3426,7 @@ mod tests { ctx.layer_id_at(card.center()), Some(egui::LayerId::new( egui::Order::Foreground, - egui::Id::new("openless-marketplace-mine-modal"), + egui::Id::unique("openless-marketplace-mine-modal"), )) ); } @@ -2788,11 +3525,11 @@ mod tests { } let output = output.unwrap(); let card: egui::Rect = ctx.data(|data| { - data.get_temp(egui::Id::new("openless-style-editor-card-rect")) + data.get_temp(egui::Id::unique("openless-style-editor-card-rect")) .unwrap() }); let scroll: egui::Rect = ctx.data(|data| { - data.get_temp(egui::Id::new("style-editor-scroll-rect")) + data.get_temp(egui::Id::unique("style-editor-scroll-rect")) .unwrap() }); assert!( @@ -2933,7 +3670,7 @@ mod tests { let _ = end_pass(&ctx); } let content = ctx.data(|data| { - data.get_temp::(egui::Id::new("style-editor-scroll-content-height")) + data.get_temp::(egui::Id::unique("style-editor-scroll-content-height")) .unwrap() }); let fields = if selection { @@ -2945,7 +3682,7 @@ mod tests { .iter() .map(|id| { ctx.data(|data| { - data.get_temp::<(f32, f32, f32)>(egui::Id::new(( + data.get_temp::<(f32, f32, f32)>(egui::Id::unique(( "style-editor-textarea-measure", id, ))) @@ -2996,7 +3733,7 @@ mod tests { for frame in 0..6 { let rect: egui::Rect = ctx .data(|data| { - data.get_temp(egui::Id::new(( + data.get_temp(egui::Id::unique(( "style-editor-textarea-rect", "dictation-prompt", ))) @@ -3013,6 +3750,7 @@ mod tests { delta: egui::vec2(0.0, -100.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, }, ] } else { @@ -3027,7 +3765,7 @@ mod tests { let _ = end_pass(&ctx); } let (_, visible, offset): (f32, f32, f32) = ctx.data(|data| { - data.get_temp(egui::Id::new(( + data.get_temp(egui::Id::unique(( "style-editor-textarea-measure", "dictation-prompt", ))) @@ -3038,7 +3776,7 @@ mod tests { "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")) + data.get_temp(egui::Id::unique("style-editor-scroll-offset")) .unwrap() }); assert!( @@ -3125,6 +3863,7 @@ mod tests { delta: egui::vec2(0.0, -220.0), phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE, + source: egui::MouseWheelSource::Wheel, }, ] } else { @@ -3288,14 +4027,27 @@ mod tests { 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 { + // The full-aspect Siri preview makes this section taller. Verify the + // rows across scrolling rather than assuming they all fit above fold. + for frame in 0..8 { + let mut events = vec![egui::Event::PointerMoved(egui::pos2(900.0, 500.0))]; + if frame == 2 { + events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -240.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::default(), + source: egui::MouseWheelSource::Wheel, + }); + } ctx.begin_pass(egui::RawInput { screen_rect: Some(viewport()), + events, ..Default::default() }); let mut actions = Vec::new(); render(&ctx, &mut vm, &mut actions); - painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + painted.push_str(&painted_text(&crate::ui::frontend::end_pass(&ctx))); } for key in [ "settings.recording.hotkey_label", @@ -3482,4 +4234,48 @@ mod tests { "a focused window must not re-ask every frame: {focused:?}" ); } + /// 宿主分页下发历史时,**整页**必须画出「已载入 X / 共 Y」并给出继续加载的入口。 + /// 只测 `load_more_footer` 自己不够:真正会漏掉的是 `list_card` 里那句接线。 + #[test] + fn history_page_paints_the_paging_footer_when_more_entries_exist() { + 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, + history_list_total: 1_234, + history_entries: vec![super::view_model::HistoryEntry { + id: "a".into(), + created_at: "2026-01-15T12:34:00+00:00".into(), + mode: super::view_model::OverviewMode::Raw, + style_label: "raw".into(), + raw_transcript: "kept transcript".into(), + final_text: "kept final".into(), + ..Default::default() + }], + ..Default::default() + }; + let mut painted = String::new(); + let mut actions = Vec::new(); + // 滚动区域要一帧才发布尺寸,所以多跑两帧再取最终画面。 + for _ in 0..3 { + ctx.begin_pass(egui::RawInput { + screen_rect: Some(viewport()), + ..Default::default() + }); + actions.clear(); + render(&ctx, &mut vm, &mut actions); + painted = painted_text(&crate::ui::frontend::end_pass(&ctx)); + } + let expected = openless_linux_egui::fmt_l10n(zh, "common.loaded_of_total", &[&1, &1_234]); + assert!( + painted.contains(&expected), + "the history page must show paging progress ({expected:?}): {painted}" + ); + assert!( + painted.contains(openless_linux_egui::tr_l10n(zh, "common.load_more")), + "the history page must offer the way to load the rest" + ); + } } diff --git a/openless-all/app/linux-egui/src/ui/frontend/overview.rs b/openless-all/app/linux-egui/src/ui/frontend/overview.rs index 3965a6b2d..182a19975 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/overview.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/overview.rs @@ -72,11 +72,11 @@ pub fn page(ui: &mut egui::Ui, vm: &FrontendViewModel, actions: &mut Vec` 可以压低它,用来在设备上 +/// 演练「已载入 X / 共 Y + 加载更多」(否则要攒够 200 条才有分页可看)。 +pub fn page_limit() -> usize { + std::env::var("OPENLESS_UI_PAGE_ITEMS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(PAGE_MAX_ITEMS) +} + +/// 从 `items[offset..]` 取一页。宿主发第一页和后续页都走这里,保证两边 +/// 看到的是同一个顺序前缀 —— 窗口按下标拼接、宿主按下标解析动作,靠的就是它。 +pub fn slice_page(items: &[T], offset: usize, limit: usize) -> Vec { + items.iter().skip(offset).take(limit).cloned().collect() +} + +/// 自动加载的上限:搜索/筛选把可见条目压得很小时窗口会替用户往下拉, +/// 但拉到这个条数就停在按钮上,避免一次查询把整个库都灌进来。 +pub const AUTO_LOAD_MAX_ITEMS: usize = 5_000; + +/// 五个可增长的集合。 +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)] +pub enum Collection { + History, + Vocabulary, + CorrectionRules, + Marketplace, + StylePacks, +} + +impl Collection { + /// 一页数据回包时用的名字(日志与测试里可读)。 + pub fn tag(self) -> &'static str { + match self { + Collection::History => "history", + Collection::Vocabulary => "vocabulary", + Collection::CorrectionRules => "correction_rules", + Collection::Marketplace => "marketplace", + Collection::StylePacks => "style_packs", + } + } + + /// 窗口当前已载入这个集合的多少条。 + pub fn loaded(self, view_model: &FrontendViewModel) -> usize { + match self { + Collection::History => view_model.history_entries.len(), + Collection::Vocabulary => view_model.vocab_entries.len(), + Collection::CorrectionRules => view_model.vocab_rules.len(), + Collection::Marketplace => view_model.marketplace_packs.len(), + Collection::StylePacks => view_model.style_packs.len(), + } + } + + /// 宿主侧一共有多少条(快照带来的真实条数)。 + pub fn total(self, view_model: &FrontendViewModel) -> usize { + match self { + Collection::History => view_model.history_list_total, + Collection::Vocabulary => view_model.vocab_total, + Collection::CorrectionRules => view_model.correction_rule_total, + Collection::Marketplace => view_model.marketplace_total, + Collection::StylePacks => view_model.style_pack_total, + } + } +} + +/// 一页的数据。每个变体对应 [`Collection`] 的一个集合。 +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum CollectionItems { + History(Vec), + Vocabulary(Vec), + CorrectionRules(Vec), + Marketplace(Vec), + StylePacks(Vec), +} + +impl CollectionItems { + pub fn collection(&self) -> Collection { + match self { + CollectionItems::History(_) => Collection::History, + CollectionItems::Vocabulary(_) => Collection::Vocabulary, + CollectionItems::CorrectionRules(_) => Collection::CorrectionRules, + CollectionItems::Marketplace(_) => Collection::Marketplace, + CollectionItems::StylePacks(_) => Collection::StylePacks, + } + } +} + +impl FrontendViewModel { + /// 收下一页数据。返回是否真的并进去了。 + /// + /// * `offset == loaded`:正常追加(用户点了「加载更多」或自动加载)。 + /// * `offset < loaded`:回包比手上更旧 —— 截断到 `offset` 再补上新的一页, + /// 这样「先发出去的请求晚回来」也不会把列表拼歪。 + /// * `offset > loaded`:中间那页还没到(帧被跳过或请求乱序),先丢掉; + /// 下一帧的快照会给回正确的前缀。 + pub fn apply_page( + &mut self, + collection: Collection, + offset: usize, + total: usize, + items: CollectionItems, + ) -> bool { + if items.collection() != collection { + log::warn!( + "[ui-client] page for {} carried {} items; ignored", + collection.tag(), + items.collection().tag() + ); + return false; + } + let loaded = collection.loaded(self); + if offset > loaded { + log::debug!( + "[ui-client] {} page at {offset} arrived with only {loaded} loaded; ignored", + collection.tag() + ); + return false; + } + let changed = match (collection, items) { + (Collection::History, CollectionItems::History(items)) => { + rewrite(&mut self.history_entries, offset, items) + } + (Collection::Vocabulary, CollectionItems::Vocabulary(items)) => { + rewrite(&mut self.vocab_entries, offset, items) + } + (Collection::CorrectionRules, CollectionItems::CorrectionRules(items)) => { + rewrite(&mut self.vocab_rules, offset, items) + } + (Collection::Marketplace, CollectionItems::Marketplace(items)) => { + rewrite(&mut self.marketplace_packs, offset, items) + } + (Collection::StylePacks, CollectionItems::StylePacks(items)) => { + rewrite(&mut self.style_packs, offset, items) + } + _ => false, + }; + match collection { + Collection::History => self.history_list_total = total, + Collection::Vocabulary => self.vocab_total = total, + Collection::CorrectionRules => self.correction_rule_total = total, + Collection::Marketplace => self.marketplace_total = total, + Collection::StylePacks => self.style_pack_total = total, + } + changed + } +} + +/// 把 `items` 放到 `list[offset..]`:先截断到 `offset`(等于追加时是空操作)。 +fn rewrite(list: &mut Vec, offset: usize, items: Vec) -> bool { + if items.is_empty() && offset == list.len() { + return false; + } + list.truncate(offset); + list.extend(items); + true +} + +/// 列表底部的「已载入 X / 共 Y」+「加载更多」。 +/// +/// 只有还有没载入的条目时才画;`auto` 为真表示窗口正在替用户自动加载 +/// (搜索/筛选把可见条目压得很小时),这时只显示进度、不画按钮。 +pub fn load_more_footer( + ui: &mut egui::Ui, + lang: Lang, + collection: Collection, + view_model: &FrontendViewModel, + auto: bool, + actions: &mut Vec, +) { + let loaded = collection.loaded(view_model); + let total = collection.total(view_model); + if total <= loaded { + return; + } + let width = ui.available_width(); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 34.0), egui::Sense::hover()); + let progress = fmt_l10n(lang, "common.loaded_of_total", &[&loaded, &total]); + let painter = ui.painter().with_clip_rect(rect); + painter.text( + egui::pos2(rect.left() + 2.0, rect.center().y), + egui::Align2::LEFT_CENTER, + &progress, + egui::FontId::proportional(11.0), + theme::INK_4, + ); + if auto && loaded < AUTO_LOAD_MAX_ITEMS { + painter.text( + egui::pos2(rect.right() - 2.0, rect.center().y), + egui::Align2::RIGHT_CENTER, + tr_l10n(lang, "common.loading"), + egui::FontId::proportional(11.0), + theme::INK_4, + ); + // 搜索/筛选下的自动加载:宿主一个 tick 内就回包,下一帧 loaded 变大, + // 条件自然不再成立;到上限后停在按钮上,交回给用户。 + actions.push(FrontendAction::LoadMore { + collection, + offset: loaded, + }); + return; + } + let more = tr_l10n(lang, "common.load_more"); + let button_width = layout::text_width(ui, more, 12.5) + 30.0; + let button_rect = egui::Rect::from_min_size( + egui::pos2(rect.right() - button_width, rect.top() + 3.0), + egui::vec2(button_width, 28.0), + ); + if layout::action_button(ui, button_rect, more, None, ButtonKind::Ghost).clicked() { + actions.push(FrontendAction::LoadMore { + collection, + offset: loaded, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn history_entries(count: usize) -> Vec { + (0..count) + .map(|index| HistoryEntry { + id: format!("entry-{index}"), + ..Default::default() + }) + .collect() + } + + #[test] + fn a_page_appends_at_the_end() { + let mut view_model = FrontendViewModel { + history_entries: history_entries(2), + ..Default::default() + }; + let applied = view_model.apply_page( + Collection::History, + 2, + 5, + CollectionItems::History(history_entries(3)), + ); + + assert!(applied); + assert_eq!(view_model.history_entries.len(), 5); + assert_eq!(view_model.history_list_total, 5); + assert_eq!(view_model.history_entries[2].id, "entry-0"); + assert_eq!(view_model.history_entries[4].id, "entry-2"); + } + + #[test] + fn a_late_page_replaces_the_tail_instead_of_duplicating() { + // 先发出的请求晚回来:offset 比手上更旧,必须截断重写,不能拼歪。 + let mut view_model = FrontendViewModel { + history_entries: history_entries(5), + history_list_total: 9, + ..Default::default() + }; + let applied = view_model.apply_page( + Collection::History, + 3, + 9, + CollectionItems::History(history_entries(2)), + ); + + assert!(applied); + assert_eq!(view_model.history_entries.len(), 5, "3 + 2"); + assert_eq!(view_model.history_entries[3].id, "entry-0"); + } + + #[test] + fn a_page_with_a_gap_is_ignored() { + let mut view_model = FrontendViewModel { + history_entries: history_entries(2), + ..Default::default() + }; + let applied = view_model.apply_page( + Collection::History, + 4, + 9, + CollectionItems::History(history_entries(2)), + ); + + assert!( + !applied, + "a page with a gap must be dropped and wait for the next snapshot" + ); + assert_eq!(view_model.history_entries.len(), 2); + } + + #[test] + fn a_page_for_another_collection_is_refused() { + let mut view_model = FrontendViewModel::default(); + let applied = view_model.apply_page( + Collection::History, + 0, + 1, + CollectionItems::Vocabulary(Vec::new()), + ); + + assert!(!applied); + assert!(view_model.history_entries.is_empty()); + } + + #[test] + fn pages_are_prefixes_of_the_same_list() { + let items: Vec = (0..5).map(|index| format!("i{index}")).collect(); + + assert_eq!(slice_page(&items, 0, 2), vec!["i0", "i1"]); + assert_eq!(slice_page(&items, 2, 2), vec!["i2", "i3"]); + // 越界只给剩下的那几条,不会报错也不会绕回开头。 + assert_eq!(slice_page(&items, 4, 9), vec!["i4"]); + assert!(slice_page(&items, 9, 2).is_empty()); + } + + #[test] + fn totals_track_each_collection() { + let view_model = FrontendViewModel { + history_entries: history_entries(3), + history_list_total: 300, + vocab_entries: Vec::new(), + vocab_total: 12, + ..Default::default() + }; + + assert_eq!(Collection::History.loaded(&view_model), 3); + assert_eq!(Collection::History.total(&view_model), 300); + assert_eq!(Collection::Vocabulary.loaded(&view_model), 0); + assert_eq!(Collection::Vocabulary.total(&view_model), 12); + } + + fn footer_frame( + ctx: &egui::Context, + view_model: &FrontendViewModel, + events: Vec, + actions: &mut Vec, + ) -> egui::FullOutput { + ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(400.0, 200.0), + )), + events, + ..Default::default() + }, + |ui| load_more_footer(ui, Lang::En, Collection::History, view_model, true, actions), + ) + } + + #[test] + fn footer_auto_loads_before_the_cap() { + let ctx = egui::Context::default(); + let view_model = FrontendViewModel { + history_entries: history_entries(4_999), + history_list_total: 6_000, + ..Default::default() + }; + let mut actions = Vec::new(); + footer_frame(&ctx, &view_model, Vec::new(), &mut actions).drop_without_applying_deltas(); + assert!(!actions.is_empty()); + assert!(actions.iter().all(|action| matches!( + action, + FrontendAction::LoadMore { + collection: Collection::History, + offset: 4_999 + } + ))); + } + + #[test] + fn footer_at_or_above_the_cap_offers_a_working_manual_button() { + for loaded in [5_000, 5_200] { + let ctx = egui::Context::default(); + let view_model = FrontendViewModel { + history_entries: history_entries(loaded), + history_list_total: 6_000, + ..Default::default() + }; + let mut actions = Vec::new(); + let output = footer_frame(&ctx, &view_model, Vec::new(), &mut actions); + assert!( + actions.is_empty(), + "automatic loading must stop at {loaded}" + ); + let label = tr_l10n(Lang::En, "common.load_more"); + let position = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.text() == label => { + Some(text.visual_bounding_rect().center()) + } + _ => None, + }) + .expect("the manual load button must actually be painted"); + output.drop_without_applying_deltas(); + + footer_frame( + &ctx, + &view_model, + vec![egui::Event::PointerMoved(position)], + &mut actions, + ) + .drop_without_applying_deltas(); + for pressed in [true, false] { + footer_frame( + &ctx, + &view_model, + vec![egui::Event::PointerButton { + pos: position, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }], + &mut actions, + ) + .drop_without_applying_deltas(); + } + assert!(matches!( + actions.as_slice(), + [FrontendAction::LoadMore { + collection: Collection::History, + offset + }] if *offset == loaded + )); + } + } + + #[test] + fn footer_draws_nothing_when_every_item_is_loaded() { + for loaded in [200, 201] { + let ctx = egui::Context::default(); + let view_model = FrontendViewModel { + history_entries: history_entries(loaded), + history_list_total: 200, + ..Default::default() + }; + let mut actions = Vec::new(); + let output = footer_frame(&ctx, &view_model, Vec::new(), &mut actions); + assert!(actions.is_empty()); + assert!(output.shapes.is_empty()); + output.drop_without_applying_deltas(); + } + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/popups.rs b/openless-all/app/linux-egui/src/ui/frontend/popups.rs index eab3b32fe..8269dd5ba 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/popups.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/popups.rs @@ -14,7 +14,7 @@ use eframe::egui; -use super::{icons, layout, siri_gl, theme}; +use super::{capsule_motion, icons, layout, siri_wgpu, theme}; use openless_linux_egui::{ fmt_l10n, tr_l10n, CapsulePopupState, Lang, LessComputerPopupState, PopupChatMessage, QaPolishState, QaPopupState, @@ -62,6 +62,97 @@ pub enum CapsuleAction { Cancel, /// ✓ → the host stops the dictation and inserts. Confirm, + /// 「要记住这个词吗?」卡片上点了「记住」(Tauri `acceptPendingCorrection`)。 + AcceptSuggestion(String), + /// 卡片上点了「不用」(Tauri `rejectPendingCorrection`)。 + RejectSuggestion(String), +} + +/// 「要记住这个词吗?」卡片的尺寸,与 Tauri `VocabSuggestionCard` 一致: +/// 宽 320、标题区 72、每行 36(`VOCAB_CARD_*`)。 +const CARD_WIDTH: f32 = 320.0; +const CARD_CHROME: f32 = 72.0; +const CARD_ROW: f32 = 36.0; +// The card fills its dynamically sized window; the host applies the 24px screen-edge gap. +const CARD_EDGE: f32 = 0.0; +// Fallback while a compositor has not acknowledged the new window size yet. +const CARD_CHROME_MIN: f32 = 44.0; +const CARD_BUTTON: f32 = 28.0; +/// 标题占的高度(画的时候用固定值,几何才能是纯函数、测试才点得中按钮)。 +const CARD_TITLE_BLOCK: f32 = 24.0; + +/// 卡片几何:一列行 + 每行右侧「不用 / 记住」两颗圆钮。 +pub(crate) struct VocabCardLayout { + pub card: egui::Rect, + pub rows: Vec, +} + +pub(crate) struct VocabCardRow { + pub text: egui::Rect, + pub accept: egui::Rect, + pub reject: egui::Rect, +} + +// Tauri VocabSuggestionCard window size; recording keeps the fixed Siri stage. +pub(crate) fn capsule_window_size(state: &CapsulePopupState) -> (u32, u32) { + if state.suggestions.is_empty() { + openless_linux_egui::CAPSULE_WINDOW_SIZE + } else { + ( + CARD_WIDTH as u32, + CARD_CHROME as u32 + CARD_ROW as u32 * state.suggestions.len() as u32, + ) + } +} + +pub(crate) fn vocab_card_layout(stage: egui::Rect, rows: usize) -> VocabCardLayout { + let count = rows.max(1) as f32; + let max_height = (stage.height() - 2.0 * CARD_EDGE).max(CARD_CHROME_MIN); + let mut row_height = CARD_ROW; + let mut height = CARD_CHROME + CARD_ROW * count; + if height > max_height { + row_height = ((max_height - CARD_CHROME_MIN) / count).max(18.0); + height = (CARD_CHROME_MIN + row_height * count).min(max_height); + } + let width = CARD_WIDTH.min(stage.width() - 2.0 * CARD_EDGE).max(1.0); + let card = egui::Rect::from_min_size( + egui::pos2( + stage.right() - CARD_EDGE - width, + stage.bottom() - CARD_EDGE - height, + ), + egui::vec2(width, height), + ); + let inner = card.shrink(12.0); + let top = inner.top() + CARD_TITLE_BLOCK; + let row_height = row_height.min(((inner.bottom() - top) / count).max(1.0)); + // 行被压缩时按钮必须跟着缩:Core 允许一张卡 5 条,硬塞 28px 圆钮会让相邻两行叠在一起。 + let button = CARD_BUTTON.min((row_height - 2.0).max(14.0)); + let rows = (0..rows) + .map(|index| { + let row = egui::Rect::from_min_size( + egui::pos2(inner.left(), top + row_height * index as f32), + egui::vec2(inner.width(), row_height), + ); + // 两颗 28 宽圆钮 + 8 间距(Tauri 的 `CardButton` 就是胶囊确认/取消那一对)。 + let accept = egui::Rect::from_center_size( + egui::pos2(row.right() - button / 2.0, row.center().y), + egui::vec2(button, button), + ); + let reject = egui::Rect::from_center_size( + egui::pos2(accept.left() - 8.0 - button / 2.0, row.center().y), + egui::vec2(button, button), + ); + VocabCardRow { + text: egui::Rect::from_min_max( + row.min, + egui::pos2(reject.left() - 8.0, row.bottom()), + ), + accept, + reject, + } + }) + .collect(); + VocabCardLayout { card, rows } } /// Tauri `selection-polish-preview` 面板的边距(`padding: 18`)。 @@ -78,10 +169,14 @@ const PILL_HEIGHT: f32 = 42.0; 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; +/// Linux egui 让 Typeless 复用 OpenLess 经典药丸的可见 footprint,避免录音、thinking、 +/// 完成和错误状态之间发生尺寸跳变;颜色、11 根波形和状态按钮仍保留 Typeless 风格。 +const TYPELESS_SCALE: f32 = 1.0; +const TYPELESS_WIDTH: f32 = PILL_WIDTH; +const TYPELESS_HEIGHT: f32 = PILL_HEIGHT; +const TYPELESS_BUTTON: f32 = ROUND_BUTTON; +const TYPELESS_STOP_BUTTON: f32 = 24.0; +const TYPELESS_TEXT_SIZE: f32 = 16.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); @@ -532,22 +627,22 @@ pub fn selection_ask( } else { color_to_f32(theme::INK) }; - let drive = siri_gl::SiriDrive { + let drive = siri_wgpu::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, + ..Default::default() }; 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( + let clock = siri_wgpu::tick(ui.ctx(), "qa-composer-ring", drive, dt); + let effect = siri_wgpu::SiriEffect::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) { + if !siri_wgpu::paint(ui, rect.expand(3.0), effect) { spinner_ring(ui, rect, if recording { theme::ERR } else { theme::INK }); } } @@ -573,7 +668,7 @@ pub fn selection_ask( 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")) + .id(egui::Id::unique("openless-qa-composer-input")) .frame(egui::Frame::NONE) .text_color(theme::INK) .font(egui::FontId::proportional(13.5)) @@ -588,7 +683,7 @@ pub fn selection_ask( } let mic = ui.interact( mic_rect, - ui.id().with("openless-qa-mic"), + ui.scope_id().with("openless-qa-mic"), egui::Sense::click(), ); if recording { @@ -624,7 +719,7 @@ pub fn selection_ask( let can_send = !composer.trim().is_empty() && !thinking; let send = ui.interact( send_rect, - ui.id().with("openless-qa-send"), + ui.scope_id().with("openless-qa-send"), egui::Sense::click(), ); ui.painter().circle_filled( @@ -978,7 +1073,7 @@ fn rounded_rect_points(rect: egui::Rect, radius: f32, segments: usize) -> Vec [f32; 3] { [ f32::from(color.r()) / 255.0, @@ -1001,16 +1096,14 @@ fn hairline(ui: &mut egui::Ui, color: egui::Color32) { /// 药丸上方的「正在翻译」徽章(Tauri `ClassicCapsule` 的 `capsule.translating`): /// 蓝点 + 蓝字、圆角胶囊、`--ol-capsule-badge-bg` 底、`--ol-capsule-badge-border` 边。 -fn translating_badge(ui: &mut egui::Ui, pill: egui::Rect, lang: Lang) { +fn translating_badge(ui: &mut egui::Ui, pill: egui::Rect, lang: Lang, scale: f32) { 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 text_width = layout::text_width(ui, label, 10.5 * scale); + let width = text_width + (5.0 + 5.0 + 20.0) * scale; + let height = 19.0 * scale; + let gap = CAPSULE_BADGE_GAP * scale; let rect = egui::Rect::from_center_size( - egui::pos2( - pill.center().x, - pill.top() - CAPSULE_BADGE_GAP - height / 2.0, - ), + egui::pos2(pill.center().x, pill.top() - gap - height / 2.0), egui::vec2(width, height), ); let painter = ui.painter(); @@ -1025,25 +1118,39 @@ fn translating_badge(ui: &mut egui::Ui, pill: egui::Rect, lang: Lang) { 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); + let dot = egui::pos2(rect.left() + 10.0 * scale, rect.center().y); + painter.circle_filled(dot, 2.5 * scale, theme::BLUE); painter.text( - egui::pos2(dot.x + 5.0 + 2.5, rect.center().y), + egui::pos2(dot.x + (5.0 + 2.5) * scale, rect.center().y), egui::Align2::LEFT_CENTER, label, - egui::FontId::proportional(10.5), + egui::FontId::proportional(10.5 * scale), theme::BLUE, ); } /// 录音胶囊:经典药丸(Tauri `ClassicPill`)—— 左 ✕、中间状态、右 ✓。 +/// +/// `warmup_ms` 是学出来的「预备 → 就绪」平均耗时,驱动光条的预测式展开(Tauri `warmupMs` +/// prop;那边存在 localStorage,这边由宿主量、存在 UI 状态文档里)。 pub fn dictation_capsule( root_ui: &mut egui::Ui, state: &CapsulePopupState, lang: Lang, + warmup_ms: f32, ) -> CapsuleAction { let mut action = CapsuleAction::None; let phase = state.phase.to_ascii_lowercase(); + // 「要记住这个词吗?」卡片与录音胶囊共用一个窗口:有候选时整窗只画这张卡。 + if !state.suggestions.is_empty() { + super::transcript_motion::clear(root_ui); + egui::CentralPanel::default() + .frame(egui::Frame::NONE) + .show(root_ui, |ui| { + action = capsule_suggestion_card(ui, state, lang); + }); + return action; + } egui::CentralPanel::default() .frame(egui::Frame::NONE) .show(root_ui, |ui| { @@ -1081,21 +1188,25 @@ pub fn dictation_capsule( egui::vec2(pill_width, pill_height), ); let _ = ui.allocate_rect(rect, egui::Sense::hover()); + let transcript_visible = paint_capsule_transcript(ui, available, rect, state); if state.translation_active { - translating_badge(ui, rect, lang); + let badge_anchor = if transcript_visible && !siri { + rect.translate(egui::vec2(0.0, -60.0)) + } else { + rect + }; + translating_badge( + ui, + badge_anchor, + lang, + if typeless { TYPELESS_SCALE } else { 1.0 }, + ); } - // 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)); + // The capsule footprint is invariant across recording, thinking and + // terminal frames. The old volume scale made the pill visibly jump + // smaller when thinking ended, just before the hide deadline. + // Motion belongs to the centre effect, never to the host geometry. + let pill = rect; if !siri { ui.painter().rect_filled( pill, @@ -1105,14 +1216,102 @@ pub fn dictation_capsule( ui.painter().rect_stroke( pill, egui::CornerRadius::same((pill_height / 2.0) as u8), - egui::Stroke::new(1.0, pill_border), + egui::Stroke::new(if typeless { 0.5 } else { 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 processing = matches!( + phase.as_str(), + "starting" | "transcribing" | "polishing" | "inserting" + ); + // 终态:光点做「六点合回一颗圆」的收尾,转速回落 1.0(Tauri `VoiceOrbStage` + // 的 `merging` / `speed`)。 + let terminal = matches!( + phase.as_str(), + "completed" | "done" | "inserted" | "cancelled" | "failed" | "error" + ); + // One capsule timeline and one animation clock survive every phase. + // Only a new recording session resets them. + let now = ui.input(|input| input.time); + let motion_id = egui::Id::unique("openless-capsule-motion"); + let mut motion = ui.ctx().data_mut(|data| { + data.get_temp::(motion_id) + .unwrap_or_default() + }); + let new_session = motion.update(&phase, now); + let shine_cycle = motion.shine_cycle_seconds(now); + if new_session { + siri_wgpu::reset(ui.ctx(), "capsule-siri"); + } + let listening = matches!(phase.as_str(), "starting" | "recording"); + let siri_effect = if siri && (listening || processing || terminal) { + let clock = siri_wgpu::tick( + ui.ctx(), + "capsule-siri", + siri_wgpu::SiriDrive { + level: state.audio_level.unwrap_or_default(), + resolved: if listening { 1.0 } else { 0.0 }, + speed: if processing { 1.5 } else { 1.0 }, + warming: listening && state.audio_level.is_none(), + warmup_ms, + merging: terminal, + }, + ui.input(|input| input.stable_dt), + ); + let shape = motion.siri_shape(now, clock.resolved); + Some(siri_wgpu::SiriEffect::capsule( + clock.time, + clock.level, + shape.resolved, + shape.gather, + )) + } else { + None + }; + ui.ctx() + .data_mut(|data| data.insert_temp(motion_id, motion)); + // Siri is a clean, transparent listening indicator. Classic keeps + // both actions visible; Typeless follows Tauri and shows the two + // large actions only while recording, then only its small stop + // action while processing. let center = if siri { - rect.shrink(3.0) + // Keep the light stage at 460×180; the sibling transcript rail + // owns the extra 48px above it, never stretches the shader. + egui::Rect::from_min_max( + egui::pos2( + available.left(), + (available.bottom() - 180.0).max(available.top()), + ), + available.right_bottom(), + ) + } else if typeless && phase != "recording" { + if processing { + let stop_rect = egui::Rect::from_center_size( + egui::pos2( + rect.right() - 7.0 - TYPELESS_STOP_BUTTON / 2.0, + rect.center().y, + ), + egui::vec2(TYPELESS_STOP_BUTTON, TYPELESS_STOP_BUTTON), + ); + let stop = ui.interact( + stop_rect, + ui.scope_id().with("openless-typeless-stop"), + egui::Sense::click(), + ); + round_button( + ui, + stop_rect, + icons::IconName::Close, + stop.hovered(), + TYPELESS_BUTTON_BG, + TYPELESS_INK, + (0.0, 13.0 * TYPELESS_SCALE), + ); + if stop.clicked() { + action = CapsuleAction::Cancel; + } + } + rect.shrink(4.0) } else { let inset = if typeless { 7.0 } else { 8.0 }; let cancel_rect = egui::Rect::from_center_size( @@ -1121,7 +1320,7 @@ pub fn dictation_capsule( ); let cancel = ui.interact( cancel_rect, - ui.id().with("openless-capsule-cancel"), + ui.scope_id().with("openless-capsule-cancel"), egui::Sense::click(), ); let cancel_fill = if typeless { @@ -1136,6 +1335,14 @@ pub fn dictation_capsule( cancel.hovered(), cancel_fill, pill_ink, + ( + if typeless { 0.0 } else { 0.8 }, + if typeless { + 24.0 * TYPELESS_SCALE + } else { + 13.0 + }, + ), ); if cancel.clicked() { action = CapsuleAction::Cancel; @@ -1146,7 +1353,7 @@ pub fn dictation_capsule( ); let confirm = ui.interact( confirm_rect, - ui.id().with("openless-capsule-confirm"), + ui.scope_id().with("openless-capsule-confirm"), egui::Sense::click(), ); let (confirm_fill, confirm_ink) = if typeless { @@ -1161,6 +1368,14 @@ pub fn dictation_capsule( confirm.hovered(), confirm_fill, confirm_ink, + ( + if typeless { 0.0 } else { 0.8 }, + if typeless { + 24.0 * TYPELESS_SCALE + } else { + 13.0 + }, + ), ); if confirm.clicked() { action = CapsuleAction::Confirm; @@ -1170,61 +1385,34 @@ pub fn dictation_capsule( 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), - ); + if siri { + if let Some(effect) = siri_effect { + let _ = siri_wgpu::paint(ui, center, effect); 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, - ); + if matches!(phase.as_str(), "failed" | "error") { + paint_siri_error(ui, center, state, lang); } + } else if phase == "recording" { + audio_bars( + ui, + center, + state.audio_level.unwrap_or_default(), + bar_count, + pill_ink, + ); + } else if processing { + // 经典 / typeless 的「思考中」:文案 + 扫光(Tauri `cap-shine`)。 + paint_thinking_label( + ui, + center, + tr_l10n(lang, "capsule.thinking"), + egui::FontId::proportional(if typeless { TYPELESS_TEXT_SIZE } else { 17.0 }), + if typeless { TYPELESS_INK } else { theme::INK }, + shine_cycle, + now, + ); } else if state.text.is_empty() { let label = if processing { tr_l10n(lang, "capsule.thinking") @@ -1235,7 +1423,17 @@ pub fn dictation_capsule( } else { tr_l10n(lang, "capsule.thinking") }; - let size = if processing { 17.0 } else { 11.0 }; + let size = if typeless { + TYPELESS_TEXT_SIZE + } else if processing || matches!(phase.as_str(), "completed" | "done" | "idle") { + // A terminal snapshot can briefly have no result text. Keep + // its thinking placeholder at the processing size until the + // popup is actually dismissed; never render a tiny final + // "thinking" frame. + 17.0 + } else { + 11.0 + }; ui.painter().text( center.center(), egui::Align2::CENTER_CENTER, @@ -1251,8 +1449,14 @@ pub fn dictation_capsule( ); } else { // 11px/500 单行居中,超长省略(Tauri `getCapsuleMessageLayout`)。 - let galley = - layout::text_galley(ui, &state.text, pill_ink, 11.0, center.width(), 1); + let galley = layout::text_galley( + ui, + &state.text, + pill_ink, + if typeless { TYPELESS_TEXT_SIZE } else { 11.0 }, + center.width().min(84.0), + 1, + ); ui.painter().galley( egui::pos2( center.center().x - galley.rect.width() / 2.0, @@ -1266,7 +1470,256 @@ pub fn dictation_capsule( action } -/// 28×28 圆形按钮(Tauri `CircleButton`)。 +/// Siri 舞台的错误提示(Tauri `VoiceOrbStage` 的 `errorGlowTextStyle`):底部居中的 +/// 红字药丸;文案优先用宿主给的 `message`(例如选区润色的「未选中内容」),缺省才是 +/// 通用的「出现错误」。 +/// Raw ASR belongs above the light/pill, never replaces audio feedback or controls. +fn paint_capsule_transcript( + ui: &egui::Ui, + stage: egui::Rect, + pill: egui::Rect, + state: &CapsulePopupState, +) -> bool { + let phase = state.phase.to_ascii_lowercase(); + if !matches!(phase.as_str(), "recording" | "transcribing" | "polishing") + || state.transcript.trim().is_empty() + { + super::transcript_motion::clear(ui); + return false; + } + let dark = state.style == "typeless"; + let siri = state.style != "classic" && !dark; + let height = if siri { 40.0 } else { 52.0 }; + let bottom = if siri { + stage.bottom() - 180.0 - 8.0 + } else { + pill.top() - 8.0 + }; + let font = egui::FontId::proportional(state.transcript_font_size.clamp(12, 20) as f32); + let ink = if dark { TYPELESS_INK } else { theme::INK }; + super::transcript_motion::paint( + ui, + state.transcript.trim(), + super::transcript_motion::RailStyle { + stage, + bottom, + height, + font, + ink, + background: if dark { TYPELESS_BG } else { theme::SURFACE }, + border: if dark { TYPELESS_BORDER } else { theme::LINE }, + }, + ); + true +} + +pub fn paint_siri_error(ui: &egui::Ui, center: egui::Rect, state: &CapsulePopupState, lang: Lang) { + let text = if state.text.trim().is_empty() { + tr_l10n(lang, "capsule.error").to_string() + } else { + state.text.clone() + }; + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = center.width().clamp(1.0, 400.0); + job.wrap.max_rows = 2; + job.append( + &text, + 0.0, + egui::text::TextFormat { + font_id: theme::medium_font(12.0), + color: theme::ERR, + ..Default::default() + }, + ); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + let rect = egui::Rect::from_center_size( + egui::pos2( + center.center().x, + center.bottom() - 24.0 - galley.rect.height() / 2.0, + ), + galley.rect.size() + egui::vec2(24.0, 12.0), + ); + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(12), theme::SURFACE_2); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(12), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + ui.painter().galley( + egui::pos2( + rect.center().x - galley.rect.width() / 2.0, + rect.center().y - galley.rect.height() / 2.0, + ), + galley, + theme::ERR, + ); +} + +/// 经典药丸「思考中」文案 + 扫光(Tauri `cap-shine`):把同一段文字在一条移动的高光带内 +/// 用蓝色重绘一遍。带子由窄到宽分三层(越宽越淡),扫过字面 —— 等价于 Tauri 用 +/// `background-clip: text` 做的高光;进入流式的头 2 秒走快周期,之后回落稳态。 +fn paint_thinking_label( + ui: &egui::Ui, + center: egui::Rect, + text: &str, + font: egui::FontId, + ink: egui::Color32, + cycle_seconds: f64, + now: f64, +) { + let painter = ui.painter(); + let galley = painter.layout_no_wrap(text.to_string(), font.clone(), ink); + let rect = egui::Rect::from_center_size(center.center(), galley.size()); + painter.galley(rect.min, galley, ink); + + let cycle = cycle_seconds.max(0.05); + let progress = ((now % cycle) / cycle).clamp(0.0, 1.0) as f32; + let span = (rect.width() * 0.5).max(8.0); + let band_left = rect.left() - span + progress * (rect.width() + span); + for (width, alpha) in [(span * 0.35, 235_u8), (span * 0.7, 130), (span, 60)] { + let band = egui::Rect::from_min_max( + egui::pos2(rect.left(), rect.top() - 2.0), + egui::pos2(rect.left() + width, rect.bottom() + 2.0), + ) + .translate(egui::vec2(band_left - rect.left(), 0.0)); + let clip = band.intersect(rect); + if clip.width() <= 0.0 { + continue; + } + let shine = egui::Color32::from_rgba_unmultiplied( + theme::BLUE.r(), + theme::BLUE.g(), + theme::BLUE.b(), + alpha, + ); + let shine_galley = painter.layout_no_wrap(text.to_string(), font.clone(), shine); + painter + .with_clip_rect(clip) + .galley(rect.min, shine_galley, shine); + } +} + +/// 圆形按钮(classic 为 28×28;Typeless 按 Tauri 的 zoom 比例缩放)。 +/// 确认式词库学习的确认入口(Tauri `VocabSuggestionCard`)。 +/// +/// 用户手改一个词之后 Core 把它攒成候选(`pending_corrections`):「自动收集」在真机 +/// 上大约五条错四条,所以每一条都要人过一眼 —— 接受写进词库(词库页的「确认收集」 +/// 分段),拒绝只是丢掉,不留黑名单。 +fn capsule_suggestion_card( + ui: &mut egui::Ui, + state: &CapsulePopupState, + lang: Lang, +) -> CapsuleAction { + let mut action = CapsuleAction::None; + let stage = ui.max_rect(); + let layout = vocab_card_layout(stage, state.suggestions.len()); + let card = layout.card; + let painter = ui.painter().with_clip_rect(card.intersect(stage)); + painter.rect_filled(card, egui::CornerRadius::same(16), theme::SURFACE); + painter.rect_stroke( + card, + egui::CornerRadius::same(16), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + let inner = card.shrink(12.0); + let title = layout::text_galley( + ui, + tr_l10n(lang, "vocabCard.title"), + theme::INK_3, + 11.0, + inner.width(), + 1, + ); + painter.galley(inner.min, title, theme::INK_3); + for (index, suggestion) in state.suggestions.iter().enumerate() { + let Some(row) = layout.rows.get(index) else { + break; + }; + let text_rect = row.text; + let (reject_rect, accept_rect) = (row.reject, row.accept); + let mut job = egui::text::LayoutJob::default(); + job.wrap.max_width = text_rect.width(); + job.wrap.max_rows = 1; + let mono = egui::FontId::new(12.5, egui::FontFamily::Monospace); + // 改前(暗)→ 改后(亮):一眼能看出这次手改把什么改成了什么。 + job.append( + &suggestion.pattern, + 0.0, + egui::text::TextFormat { + font_id: mono.clone(), + color: theme::INK_4, + ..Default::default() + }, + ); + job.append( + " \u{2192} ", + 0.0, + egui::text::TextFormat { + font_id: mono.clone(), + color: theme::INK_4, + ..Default::default() + }, + ); + job.append( + &suggestion.replacement, + 0.0, + egui::text::TextFormat { + font_id: mono, + color: theme::INK, + ..Default::default() + }, + ); + let galley = ui.fonts_mut(|fonts| fonts.layout_job(job)); + painter.with_clip_rect(text_rect).galley( + egui::pos2( + text_rect.left(), + text_rect.center().y - galley.rect.height() / 2.0, + ), + galley, + theme::INK, + ); + let reject = ui.interact( + reject_rect, + ui.scope_id().with(("openless-vocab-reject", index)), + egui::Sense::click(), + ); + let icon = (reject_rect.width() * 0.46).clamp(9.0, 13.0); + round_button( + ui, + reject_rect, + icons::IconName::Close, + reject.hovered(), + theme::SURFACE_2, + theme::INK_2, + (0.8, icon), + ); + if reject.clicked() { + action = CapsuleAction::RejectSuggestion(suggestion.id.clone()); + } + let accept = ui.interact( + accept_rect, + ui.scope_id().with(("openless-vocab-accept", index)), + egui::Sense::click(), + ); + round_button( + ui, + accept_rect, + icons::IconName::Check, + accept.hovered(), + theme::BLUE_SOFT, + theme::BLUE, + (0.8, icon), + ); + if accept.clicked() { + action = CapsuleAction::AcceptSuggestion(suggestion.id.clone()); + } + } + action +} + fn round_button( ui: &egui::Ui, rect: egui::Rect, @@ -1274,7 +1727,9 @@ fn round_button( hovered: bool, fill: egui::Color32, ink: egui::Color32, + button_metrics: (f32, f32), ) { + let (stroke_width, icon_size) = button_metrics; ui.painter().circle_filled( rect.center(), rect.width() / 2.0, @@ -1287,9 +1742,9 @@ fn round_button( ui.painter().circle_stroke( rect.center(), rect.width() / 2.0, - egui::Stroke::new(0.8, theme::LINE), + egui::Stroke::new(stroke_width, theme::LINE), ); - icons::draw_icon(ui, rect.center(), icon, ink); + icons::draw_icon_sized(ui, rect.center(), icon, ink, icon_size); } /// 音量条:Tauri `AudioBars`(5 根 3px 竖条,包络 0.55/0.85/1/0.85/0.55, @@ -1305,9 +1760,14 @@ fn audio_bars(ui: &egui::Ui, rect: egui::Rect, level: f32, bar_count: usize, ink } else { &CLASSIC_ENVELOPE }; - const BASE: f32 = 2.0; + let scale = if bar_count > CLASSIC_ENVELOPE.len() { + TYPELESS_SCALE + } else { + 1.0 + }; + let base = 2.0 * scale; let max = if bar_count > CLASSIC_ENVELOPE.len() { - 26.0 + 28.0 * scale } else { 24.0 }; @@ -1315,12 +1775,12 @@ fn audio_bars(ui: &egui::Ui, rect: egui::Rect, level: f32, bar_count: usize, ink 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 bar_width = 3.0 * scale; + let gap = 3.0 * scale; 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; + 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), @@ -1728,7 +2188,7 @@ pub fn less_computer( let send = ui .interact( send_rect, - egui::Id::new("less-computer-send"), + egui::Id::unique("less-computer-send"), egui::Sense::click(), ) .on_hover_text(tr_l10n(lang, "less_computer.send")); @@ -1761,13 +2221,13 @@ pub fn less_computer( ); let response = child.add( egui::TextEdit::singleline(composer) - .id(egui::Id::new("less-computer-composer-input")) + .id(egui::Id::unique("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), + .align(egui::Align2::CENTER_CENTER), ); let submitted = response.lost_focus() && child.input(|input| input.key_pressed(egui::Key::Enter)); @@ -1911,7 +2371,7 @@ mod tests { /// 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. + // they share the process-global effect flags and must not run in parallel. let ctx = egui::Context::default(); let mut painted = String::new(); for _ in 0..2 { @@ -1930,6 +2390,145 @@ mod tests { painted } + /// 渲染胶囊并把结果动作交出来;`click` 给一个坐标时会合成一次点击(按下+抬起 + /// 同一帧,和真实点击等价)。 + fn run_capsule( + state: &CapsulePopupState, + size: egui::Vec2, + click: Option, + ) -> (CapsuleAction, String) { + let ctx = egui::Context::default(); + let mut action = CapsuleAction::None; + let mut painted = String::new(); + for frame in 0..2 { + let mut events = Vec::new(); + if frame == 1 { + if let Some(pos) = click { + for pressed in [true, false] { + events.push(egui::Event::PointerButton { + pos, + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }); + } + } + } + let output = 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, siri_wgpu::DEFAULT_WARMUP_MS); + }, + ); + painted = painted_text(&output); + } + (action, painted) + } + + /// 「要记住这个词吗?」卡片:有候选时整窗只画卡片(没有药丸),点「记住」把候选 id + /// 发回宿主,点「不用」发拒绝 —— 这就是确认式词库学习的确认入口(Tauri + /// `VocabSuggestionCard` + `PendingCorrection`)。 + #[test] + fn the_vocab_card_asks_before_remembering() { + let state = CapsulePopupState { + phase: String::new(), + suggestions: vec![ + openless_linux_egui::CapsuleSuggestion { + id: "first".to_string(), + pattern: "banana".to_string(), + replacement: "bananas".to_string(), + }, + openless_linux_egui::CapsuleSuggestion { + id: "second".to_string(), + pattern: "teh".to_string(), + replacement: "the".to_string(), + }, + ], + ..Default::default() + }; + let size = egui::vec2(460.0, 180.0); + let (action, painted) = run_capsule(&state, size, None); + assert!( + matches!(action, CapsuleAction::None), + "rendering alone must not resolve anything" + ); + assert!( + painted.contains(tr_l10n(Lang::ZhCn, "vocabCard.title")), + "the card must ask the question: {painted}" + ); + for text in ["banana", "bananas", "teh", "the"] { + assert!(painted.contains(text), "row text {text} missing: {painted}"); + } + // 每行两颗圆钮都在(图标画的是 Check / Close)。 + assert_eq!( + painted + .matches(tr_l10n(Lang::ZhCn, "vocabCard.title")) + .count(), + 1, + "the title is painted once" + ); + let layout = vocab_card_layout( + egui::Rect::from_min_size(egui::Pos2::ZERO, size), + state.suggestions.len(), + ); + assert_eq!(layout.rows.len(), 2, "one row per suggestion"); + // 两行不重叠:第二行在第一行下面。 + assert!( + layout.rows[0].accept.bottom() <= layout.rows[1].accept.top(), + "rows must not overlap" + ); + let (action, _) = run_capsule(&state, size, Some(layout.rows[0].accept.center())); + assert!( + matches!(&action, CapsuleAction::AcceptSuggestion(id) if id == "first"), + "accepting the first row must carry its id, got {action:?}" + ); + let (action, _) = run_capsule(&state, size, Some(layout.rows[1].reject.center())); + assert!( + matches!(&action, CapsuleAction::RejectSuggestion(id) if id == "second"), + "rejecting the second row must carry its id, got {action:?}" + ); + } + + /// 行数拉满(Core 上限 5 条)时卡片仍然整张落在舞台里,不露在窗外。 + #[test] + fn a_full_vocab_card_still_fits_the_capsule_stage() { + let stage = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 180.0)); + let layout = vocab_card_layout(stage, 5); + assert_eq!(layout.rows.len(), 5, "every candidate gets a row"); + assert!( + stage.contains_rect(layout.card), + "the card must stay inside the stage: {:?}", + layout.card + ); + for (index, row) in layout.rows.iter().enumerate() { + assert!( + layout.card.contains_rect(row.accept) && layout.card.contains_rect(row.reject), + "row {index} buttons must stay inside the card" + ); + assert!( + row.text.right() <= row.reject.left(), + "row {index} text must not overlap the buttons" + ); + } + assert!( + layout + .rows + .windows(2) + .all(|pair| pair[0].accept.bottom() <= pair[1].accept.top()), + "rows must not overlap at the cap" + ); + // 压缩后按钮也不能叠上去。 + assert!( + layout.rows.iter().all(|row| row.accept.height() >= 14.0), + "buttons must stay tappable when the rows are compressed" + ); + } + /// 与 `run` 同款流程,但把最后一帧的 `FullOutput` 交出来(要按形状断言时用)。 fn run_output( size: egui::Vec2, @@ -2189,16 +2788,10 @@ mod tests { ); } - /// 录音/思考的中心视觉现在由 CPU 画法承担(着色器路径已从渲染路径摘除,见 - /// `siri_gl` 文件头):这两种状态都不许再排 GPU 回调,但中心不能是空的; - /// 终态则一个光效都没有。 - /// - /// 以前录音还会多排一个**外圈红扫光**、思考多一个黑扫光。Tauri 的经典药丸只有 - /// 1px 中性描边(Capsule.tsx 的 `border: 1px var(--ol-capsule-pill-border)`), - /// 没有外圈扫光——用户报「语音输入弹窗有一个红边」就是它,所以这里继续锁死。 + /// Recording and thinking each queue exactly one WGPU centre effect. Terminal + /// states must not leave a callback behind while the popup waits to dismiss. #[test] - fn capsule_keeps_the_centre_glow_off_the_gpu_path() { - // The GPU state is process-global; take the shared test guard. + fn capsule_queues_only_the_active_wgpu_centre_effect() { let frame = |state: CapsulePopupState| { let ctx = egui::Context::default(); let output = crate::ui::frontend::run_pass( @@ -2211,7 +2804,7 @@ mod tests { ..Default::default() }, |ui| { - let _ = dictation_capsule(ui, &state, Lang::ZhCn); + let _ = dictation_capsule(ui, &state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); }, ); let callbacks = output @@ -2219,15 +2812,11 @@ mod tests { .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) + (callbacks,) }; for (label, state, wants_centre) in [ ( - "recording = CPU wave only, no perimeter ring", + "recording = one WGPU wave, no perimeter ring", CapsulePopupState { phase: "recording".into(), audio_level: Some(0.2), @@ -2236,7 +2825,7 @@ mod tests { true, ), ( - "thinking = CPU orb only, no perimeter ring", + "thinking = one WGPU orb, no perimeter ring", CapsulePopupState { phase: "transcribing".into(), ..Default::default() @@ -2244,23 +2833,27 @@ mod tests { true, ), ( - "terminal capsule paints no glow", + "terminal Siri capsule keeps the orb for the merge-out", CapsulePopupState { phase: "inserted".into(), text: "hello".into(), ..Default::default() }, + true, + ), + ( + "classic capsule never paints a WGPU effect", + CapsulePopupState { + phase: "polishing".into(), + style: "classic".into(), + suggestions: Vec::new(), + ..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"); - } + let (callbacks,) = frame(state); + assert_eq!(callbacks, usize::from(wants_centre), "{label}"); } } @@ -2274,7 +2867,7 @@ mod tests { ..Default::default() }; let painted = run(egui::vec2(200.0, 100.0), |ctx| { - dictation_capsule(ctx, &idle, Lang::ZhCn); + dictation_capsule(ctx, &idle, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!( @@ -2287,7 +2880,7 @@ mod tests { ..idle }; let painted = run(egui::vec2(200.0, 100.0), |ctx| { - dictation_capsule(ctx, &translating, Lang::ZhCn); + dictation_capsule(ctx, &translating, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!(has(&painted, badge), "{painted}"); @@ -2428,13 +3021,172 @@ mod tests { } } + /// 文本的声明颜色(扫光靠重绘另一份 galley 实现,所以要看 Text 的 section 颜色)。 + fn painted_text_colors(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Text(text) => { + for section in &text.galley.job.sections { + out.push(section.format.color); + } + } + egui::Shape::Vec(shapes) => { + for shape in shapes { + painted_text_colors(shape, out); + } + } + _ => {} + } + } + + /// 偏蓝 = 扫光那一遍(INK 是中性灰,TYPELESS 是白)。 + fn is_bluish(color: egui::Color32) -> bool { + color.b() > color.r().saturating_add(24) && color.b() > color.g() + } + + /// 经典药丸的「思考中」扫光(Tauri `cap-shine`):高光带扫过字面时,文字会被蓝色 + /// 重绘一遍;Siri 舞台没有这段文字,自然也不会有。 + #[test] + fn the_classic_thinking_label_sweeps_a_shine_band() { + let collect = |time: f64, style: &str| { + let ctx = egui::Context::default(); + let state = CapsulePopupState { + phase: "Polishing".into(), + style: style.into(), + suggestions: Vec::new(), + ..Default::default() + }; + let mut colors = Vec::new(); + // 两帧:第一帧确定相位起点,第二帧才是被测的那一帧。 + for frame in [0.0, time] { + 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, 60.0), + )), + time: Some(frame), + ..Default::default() + }, + |ui| { + let _ = + dictation_capsule(ui, &state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + }, + ); + colors.clear(); + for clipped in &output.shapes { + painted_text_colors(&clipped.shape, &mut colors); + } + } + colors + }; + // 高速 burst 周期 0.9s:t=0.45 时高光带正扫到字面中部。 + assert!( + collect(0.45, "classic").iter().copied().any(is_bluish), + "the sweep must repaint the label in blue" + ); + // 慢速阶段(>2s)同样扫,只是周期变长。 + assert!( + collect(2.7, "classic").iter().copied().any(is_bluish), + "the sweep must keep running after the initial burst" + ); + // Siri 舞台没有这段文案。 + assert!( + !collect(0.45, "siri").iter().copied().any(is_bluish), + "the Siri stage has no caption to sweep" + ); + } + + /// Siri 舞台出错时给的是宿主的具体文案(Tauri `message || t('capsule.error')`), + /// 不是笼统的「出现错误」。 + #[test] + fn the_siri_stage_prefers_the_host_error_message() { + let message = tr_l10n(Lang::ZhCn, "capsule.selectionPolish.noSelection"); + let failed = CapsulePopupState { + phase: "Failed".to_string(), + text: message.to_string(), + style: "siri".to_string(), + suggestions: Vec::new(), + ..Default::default() + }; + let painted = run(egui::vec2(460.0, 180.0), |ctx| { + dictation_capsule(ctx, &failed, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + String::new() + }); + assert!(has(&painted, message), "{painted}"); + assert!( + !has(&painted, tr_l10n(Lang::ZhCn, "capsule.error")), + "the generic error text must not replace the host message: {painted}" + ); + // 没有具体文案时才是通用文案。 + let bare = CapsulePopupState { + text: String::new(), + ..failed.clone() + }; + let painted = run(egui::vec2(460.0, 180.0), |ctx| { + dictation_capsule(ctx, &bare, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + String::new() + }); + assert!( + has(&painted, tr_l10n(Lang::ZhCn, "capsule.error")), + "{painted}" + ); + } + + /// The entire lifecycle uses one morph callback, including a fast LLM. + #[test] + fn the_capsule_morph_never_overlays_two_callbacks() { + let ctx = egui::Context::default(); + let stage = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 180.0)); + let render = |time: f64, phase: &str| { + let state = CapsulePopupState { + phase: phase.into(), + audio_level: Some(0.4), + style: "siri".into(), + suggestions: Vec::new(), + ..Default::default() + }; + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(stage), + time: Some(time), + ..Default::default() + }, + |ui| { + let _ = dictation_capsule(ui, &state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + }, + ); + output + .shapes + .iter() + .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) + .count() + }; + assert_eq!( + render(0.0, "recording"), + 1, + "recording paints the wave only" + ); + assert_eq!( + render(0.2, "polishing"), + 1, + "contraction must remain a single distance-field draw" + ); + assert_eq!( + render(2.0, "polishing"), + 1, + "fluid dots must use the same single callback" + ); + } + /// Render one capsule frame and collect the colours, the GPU callback count - /// and the number of centre glow strokes (the CPU wave lines). + /// and the number of centre effect 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; + let mut effect = 0; for _ in 0..2 { let output = crate::ui::frontend::run_pass( &ctx, @@ -2446,33 +3198,32 @@ mod tests { ..Default::default() }, |ui| { - let _ = dictation_capsule(ui, state, Lang::ZhCn); + let _ = dictation_capsule(ui, state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); }, ); colors.clear(); callbacks = 0; - glow = 0; + effect = 0; for clipped in &output.shapes { painted_colors(&clipped.shape, &mut colors); - count_centre_glow(&clipped.shape, &mut glow); + count_centre_effect(&clipped.shape, &mut effect); if matches!(clipped.shape, egui::Shape::Callback(_)) { callbacks += 1; } } } - (colors, callbacks, glow) + (colors, callbacks, effect) } - /// 录音相位的「中心运动感」现在由 CPU 画法承担(着色器路径已从渲染路径摘除, - /// 见 `siri_gl` 文件头):波形是一串 49 点的折线,思考是流体圆点,两者都 - /// 算中心光效(CPU 竖条走的是小圆角矩形,不在这里)。 - fn count_centre_glow(shape: &egui::Shape, out: &mut usize) { + /// Classic fallback primitives still expose a centre visual for tests; Siri + /// states are represented by one WGPU callback instead. + fn count_centre_effect(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); + count_centre_effect(shape, out); } } _ => {} @@ -2484,10 +3235,44 @@ mod tests { color.a() > 40 && color.r() > 150 && color.g() < 110 && color.b() < 110 } + #[test] + fn raw_transcript_rail_keeps_all_styles_and_hides_on_terminal_phases() { + for style in ["siri", "classic", "typeless"] { + for phase in [ + "Recording", + "Transcribing", + "Polishing", + "Completed", + "Failed", + "Cancelled", + ] { + let state = CapsulePopupState { + phase: phase.into(), + style: style.into(), + transcript: "raw ASR words".into(), + transcript_font_size: 18, + ..Default::default() + }; + let painted = run(egui::vec2(460.0, 228.0), |ui| { + // This checks visibility, not the separately animated birth + // of each grapheme. Render the settled/reduced-motion rail. + ui.style_mut().animation_time = 0.0; + dictation_capsule(ui, &state, Lang::En, siri_wgpu::DEFAULT_WARMUP_MS); + String::new() + }); + assert_eq!( + has(&painted, "raw ASR words"), + matches!(phase, "Recording" | "Transcribing" | "Polishing"), + "style={style}, phase={phase}" + ); + } + } + } + #[test] fn recording_capsule_paints_no_coloured_outline() { // Tauri 的经典药丸只有 1px 中性描边(Capsule.tsx:border 1px - // var(--ol-capsule-pill-border)),录音时只把药丸随音量放大 1.8%。 + // var(--ol-capsule-pill-border));录音音量只驱动中心波形。 // 外圈红/黑扫光是本仓自己加的,用户报「语音输入弹窗有一个红边」—— // 这条测试锁死它不许回来。 for phase in ["Recording", "Transcribing", "Polishing"] { @@ -2495,8 +3280,11 @@ mod tests { phase: phase.to_string(), text: String::new(), audio_level: Some(0.6), + transcript: String::new(), + transcript_font_size: 14, translation_active: false, style: "classic".to_string(), + suggestions: Vec::new(), }; let (colors, _, _) = capsule_frame(&state); let reddish: Vec<_> = colors @@ -2519,16 +3307,19 @@ mod tests { phase: "Recording".to_string(), text: String::new(), audio_level: Some(0.6), + transcript: String::new(), + transcript_font_size: 14, translation_active: false, style: "siri".to_string(), + suggestions: Vec::new(), }; - let (colors, callbacks, glow) = capsule_frame(&state); + let (colors, callbacks, effect) = capsule_frame(&state); // 音量竖条是 3px 宽的小圆角矩形:数一下细长条形的填充个数。 let fills = colors.iter().filter(|color| color.a() > 0).count(); assert!( - callbacks > 0 || fills >= 6 || glow > 0, + callbacks > 0 || fills >= 6 || effect > 0, "recording capsule must keep the centre visual \ - (callbacks={callbacks}, fills={fills}, glow={glow})" + (callbacks={callbacks}, fills={fills}, effect={effect})" ); } @@ -2542,52 +3333,127 @@ mod tests { 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), - ] { + for (style, inset) in [("classic", 8.0), ("typeless", 7.0)] { + let cancel = egui::pos2(pill_left + inset + ROUND_BUTTON / 2.0, centre_y); + let confirm = egui::pos2(pill_right - inset - 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: style.to_string(), + suggestions: Vec::new(), + ..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, + siri_wgpu::DEFAULT_WARMUP_MS, + ); + }, + ); + } + assert_eq!( + action, expected, + "clicking {position:?} must report {expected:?}, style={style}" + ); + } + } + } + + /// 经典药丸的「思考中」文案必须本地化并在每一个处理相位都在;Siri 舞台反过来 —— + /// 它没有文案(Tauri `VoiceOrbStage` 只有 error 才给字),反馈全靠光效。 + #[test] + fn classic_processing_caption_is_localized_and_siri_has_none() { + for lang in [Lang::ZhCn, Lang::En] { + for phase in ["starting", "transcribing", "polishing", "inserting"] { + let classic = CapsulePopupState { + phase: phase.into(), + style: "classic".into(), + suggestions: Vec::new(), + ..Default::default() + }; + let painted = run(egui::vec2(200.0, 60.0), |ui| { + dictation_capsule(ui, &classic, lang, siri_wgpu::DEFAULT_WARMUP_MS); + String::new() + }); + assert!( + has(&painted, tr_l10n(lang, "capsule.thinking")), + "{phase} must show the localized processing caption: {painted}" + ); + let siri = CapsulePopupState { + style: "siri".into(), + suggestions: Vec::new(), + ..classic.clone() + }; + let painted = run(egui::vec2(460.0, 180.0), |ui| { + dictation_capsule(ui, &siri, lang, siri_wgpu::DEFAULT_WARMUP_MS); + String::new() + }); + assert!( + !has(&painted, tr_l10n(lang, "capsule.thinking")), + "the Siri stage must not paint a caption (Tauri parity): {painted}" + ); + } + } + } + + #[test] + fn siri_recording_and_thinking_use_the_full_stage() { + for phase in ["recording", "transcribing"] { let ctx = egui::Context::default(); + let stage = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(460.0, 180.0)); let state = CapsulePopupState { - phase: "Recording".to_string(), - audio_level: Some(0.4), - style: "classic".to_string(), + phase: phase.into(), + style: "siri".into(), + suggestions: Vec::new(), ..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:?}" + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(stage), + ..Default::default() + }, + |ui| { + dictation_capsule(ui, &state, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + }, + ); + assert!( + output.shapes.iter().any(|shape| matches!( + &shape.shape, egui::Shape::Callback(callback) if callback.rect == stage + )), + "{phase} must not shrink the Siri callback into the classic pill" ); + output.textures_delta.clear(); } } @@ -2597,11 +3463,14 @@ mod tests { phase: "Recording".to_string(), text: String::new(), audio_level: Some(0.4), + transcript: String::new(), + transcript_font_size: 14, translation_active: false, style: "siri".to_string(), + suggestions: Vec::new(), }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { - dictation_capsule(ctx, &recording, Lang::ZhCn); + dictation_capsule(ctx, &recording, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!( @@ -2613,10 +3482,11 @@ mod tests { let transcribing = CapsulePopupState { phase: "Transcribing".to_string(), style: "classic".to_string(), + suggestions: Vec::new(), ..Default::default() }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { - dictation_capsule(ctx, &transcribing, Lang::ZhCn); + dictation_capsule(ctx, &transcribing, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!( @@ -2624,31 +3494,53 @@ mod tests { "{painted}" ); - // siri 样式:中心交给 Siri 光效(现在由 CPU 画,见 `siri_gl` 文件头), - // 所以不再叠一行「思考中」文字——与 GPU 就绪后的终态一致。 + // Siri 舞台没有文案:光点换乘「收尾合并」,字都交给光效(Tauri `VoiceOrbStage`)。 let transcribing_siri = CapsulePopupState { phase: "Transcribing".to_string(), style: "siri".to_string(), + suggestions: Vec::new(), ..Default::default() }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { - dictation_capsule(ctx, &transcribing_siri, Lang::ZhCn); + dictation_capsule( + ctx, + &transcribing_siri, + Lang::ZhCn, + siri_wgpu::DEFAULT_WARMUP_MS, + ); String::new() }); assert!( !has(&painted, tr_l10n(Lang::ZhCn, "capsule.thinking")), - "siri transcribing paints the orb instead of the label: {painted}" + "siri transcribing must not paint a caption: {painted}" ); + // 「已插入 N 字」是经典药丸的文案;Siri 舞台不画它。 let done = CapsulePopupState { phase: "Completed".to_string(), text: inserted_message(Lang::ZhCn, 12), + transcript: String::new(), + transcript_font_size: 14, audio_level: None, translation_active: false, style: "siri".to_string(), + suggestions: Vec::new(), + }; + let painted = run(egui::vec2(200.0, 60.0), |ctx| { + dictation_capsule(ctx, &done, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); + String::new() + }); + assert!( + !has(&painted, "12"), + "the Siri stage never paints the inserted-chars text: {painted}" + ); + let done_classic = CapsulePopupState { + style: "classic".to_string(), + suggestions: Vec::new(), + ..done.clone() }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { - dictation_capsule(ctx, &done, Lang::ZhCn); + dictation_capsule(ctx, &done_classic, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!(has(&painted, "12"), "{painted}"); @@ -2656,12 +3548,15 @@ mod tests { let failed = CapsulePopupState { phase: "Failed".to_string(), text: String::new(), + transcript: String::new(), + transcript_font_size: 14, audio_level: None, translation_active: false, style: "siri".to_string(), + suggestions: Vec::new(), }; let painted = run(egui::vec2(200.0, 60.0), |ctx| { - dictation_capsule(ctx, &failed, Lang::ZhCn); + dictation_capsule(ctx, &failed, Lang::ZhCn, siri_wgpu::DEFAULT_WARMUP_MS); String::new() }); assert!( diff --git a/openless-all/app/linux-egui/src/ui/frontend/settings.rs b/openless-all/app/linux-egui/src/ui/frontend/settings.rs index bcfa58334..e3266df04 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/settings.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/settings.rs @@ -8,6 +8,7 @@ use super::view_model::{ FrontendAction, FrontendViewModel, SettingsActionField, SettingsChannelProvider, SettingsComboField, SettingsField, SettingsProviderAuth, SettingsProviderEditor, SettingsProviderField, SettingsSection, SettingsTextField, ShortcutField, StylePack, + StylePackHotkeyRow, }; const RAIL_WIDTH: f32 = 214.0; @@ -49,7 +50,7 @@ impl SettingsSection { 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::Privacy => tr_l10n(lang, "settings.linux_privacy.section_desc"), Self::Advanced => tr_l10n(lang, "modal.descriptions.advanced"), Self::About => tr_l10n(lang, "modal.descriptions.about"), } @@ -69,97 +70,108 @@ impl SettingsSection { } /// Paint the in-window settings modal. Actions are pushed into the provided vec. -pub fn settings_overlay( +pub fn settings_page( 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,设置里所有「点开才出现」的下拉都会被弹窗整个盖住(用户报 - // 「需要点开的控件都打不开」)。 + // 设置是一整页:不铺磨砂遮罩、不压暗、没有落影,也**不内缩**。 + // 以前内缩 8px 让圆角"露出来",代价是上边和右边各多出一条白边(页面底色 + // 透出来)。现在贴满内容区,圆角只留在左栏的左上/左下(底色是 CONTENT_BG, + // 内缩的那一点由它填,不会露出白色),窗口自身的圆角负责外轮廓。 + const RADIUS: u8 = 14; + let surface = body; + let size = surface.size(); + ctx.data_mut(|data| data.insert_temp(egui::Id::unique("openless-settings-card-rect"), surface)); + egui::Area::new(egui::Id::unique("openless-settings-page")) + // 仍是 Foreground:页内还有下拉菜单(`Order::Foreground`)需要压在上面。 .order(egui::Order::Foreground) - .fixed_pos(body.min) + .fixed_pos(surface.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())); + ui.allocate_ui_with_layout(size, egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(size); + ui.set_max_size(size); egui::Frame::new() - // Tauri `--ol-settings-content-bg`:整块弹窗是浅灰底,卡片才是白色。 + // 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), - }) + .corner_radius(egui::CornerRadius::same(RADIUS)) .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.set_min_size(egui::vec2(RAIL_WIDTH, body_height)); + ui.set_max_size(egui::vec2(RAIL_WIDTH, body_height)); + let column = ui.max_rect(); + // 左栏只圆左上/左下:右边缘要和内容区拼在一起。 + ui.painter().rect_filled( + column, + egui::CornerRadius { + nw: RADIUS, + sw: RADIUS, + ne: 0, + se: 0, + }, + theme::RAIL_BG, + ); + ui.painter().rect_filled( + egui::Rect::from_min_max( + egui::pos2(column.right() - 0.5, column.top()), + column.right_bottom(), + ), + egui::CornerRadius::ZERO, + theme::LINE_SOFT, + ); + let inner = column.shrink2(egui::vec2(12.0, 16.0)); + ui.scope_builder( + egui::UiBuilder::new().max_rect(inner), + |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-settings-rail") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.set_width(inner.width()); + rail(ui, vm, actions); + }); + }, + ); }, ); - ui.separator(); - // 两列之间 egui 还会插入 item_spacing,分隔线自身也占宽:不减掉它们 - // 内容就比 size 宽十几像素,卡片会被撑宽、`fixed_pos` 居中就偏(实测 11px)。 - let gutters = ui.spacing().item_spacing.x * 3.0; + let gutters = ui.spacing().item_spacing.x; 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| { + // 内容区白底(Tauri `ol-settings-surface` 白、左栏 + // `--ol-settings-rail-bg` 灰)。整页统一用 CONTENT_BG + // 的话,白底上的灰色选中块会变成"灰底灰块"。 + let column = ui.max_rect(); + ui.painter().rect_filled( + column, + egui::CornerRadius { + nw: 0, + sw: 0, + ne: RADIUS, + se: RADIUS, + }, + theme::SURFACE, + ); ui.set_min_height(body_height); - panel(ui, vm, actions); + // 渠道编辑器 portal 进「AI 服务与模型」这一页的右栏, + // 覆盖范围必须跟着分区走。 + let editor_here = vm.provider_editor.is_some() + && vm.settings_section == SettingsSection::Services; + if editor_here { + channel_editor_panel(ui, vm, actions); + } else { + panel(ui, vm, actions); + } }, ); }); @@ -170,91 +182,117 @@ pub fn settings_overlay( 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, + // 底色与右侧分隔线由调用方按整栏高度铺好(见 settings_sheet)。 + ui.vertical(|ui| { + // Tauri `ol-settings-return`:关闭设置回到应用,位置在左栏最上方 + // (搜索框之上),不再是右栏顶栏的 ✕。 + let back = tr_l10n(lang, "uiRefinement.back"); + let (rect, _) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 26.0), egui::Sense::click()); + let response = ui.interact( + rect, + egui::Id::unique("openless-settings-back"), + egui::Sense::click(), + ); + if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(7), theme::SURFACE_2); + } + let chevron = egui::Stroke::new(1.4, theme::INK_2); + let c = egui::pos2(rect.left() + 9.0, rect.center().y); + ui.painter().line_segment( + [egui::pos2(c.x + 3.0, c.y - 5.0), egui::pos2(c.x - 2.5, c.y)], + chevron, + ); + ui.painter().line_segment( + [egui::pos2(c.x - 2.5, c.y), egui::pos2(c.x + 3.0, c.y + 5.0)], + chevron, + ); + ui.painter().text( + egui::pos2(rect.left() + 18.0, rect.center().y), + egui::Align2::LEFT_CENTER, + back, + egui::FontId::proportional(12.5), + theme::INK_2, + ); + if response.clicked() { + actions.push(FrontendAction::CloseSettings); + } + ui.add_space(6.0); + // 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::unique("openless-settings-search")) + .hint_text(tr_l10n(lang, "modal.search_placeholder")) + // 明确文字颜色:默认的控件前景色在浅底上过淡, + // 看上去像「输入了但没有显示字符」。 + .text_color(theme::INK) + .frame(egui::Frame::NONE) + .align(egui::Align2::CENTER_CENTER), ); - if response.clicked() { - actions.push(FrontendAction::SettingsAction( - SettingsActionField::OpenHelp, - )); - } + }); + 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)`:没有桌面热键后端时 @@ -390,23 +428,15 @@ fn panel(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec Option<(&'static s 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"), @@ -484,14 +510,15 @@ fn general(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec bool { fn shortcuts(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { let lang = vm.lang; - // Tauri `ShortcutsSection` 的行序:开始/停止 → 翻译 → 弹出浮窗 → 切换风格 → - // 风格直达快捷键(子块)→ 打开 OpenLess → Less Computer → 取消本次录音。 + // Tauri `ShortcutsSection`:搜索 + 计数 / 分类与分配状态筛选 / 命令行。 + // + // 搜索词与两个筛选是**纯 UI 状态**,存在 egui 自己的记忆里:以前它们走 + // `FrontendAction` 回宿主再整份快照推回来,连点筛选会明显卡顿。 + #[derive(Clone, Copy, Default)] + struct Filters { + category: u8, + status: u8, + } + let filters_id = egui::Id::unique("openless-shortcuts-filters"); + let query_id = egui::Id::unique("openless-shortcuts-query"); + let mut filters = ui + .data(|data| data.get_temp::(filters_id)) + .unwrap_or_default(); + let mut query = ui + .data(|data| data.get_temp::(query_id)) + .unwrap_or_default(); + let dictation_hint = recording_mode_hint(lang, vm.settings.recording_mode); - let rows: [ShortcutRow; 7] = [ + let mut rows: Vec = vec![ ShortcutRow { field: ShortcutField::Dictation, label: tr_l10n(lang, "settings.shortcuts.start_stop"), @@ -911,6 +1115,13 @@ fn shortcuts(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec 0, + ShortcutField::SwitchStyle => 1, + ShortcutField::OpenApp | ShortcutField::CodingAgentVoice => 2, + ShortcutField::StylePack(_) | ShortcutField::StyleDraft => 1, + }; + let matches = |label: &str, value: &str| -> bool { + let assigned = !value.trim().is_empty(); + let text = if assigned { + format!("{label} {value}").to_lowercase() + } else { + format!("{label} {}", tr_l10n(lang, "uiRefinement.unassigned")).to_lowercase() + }; + (trimmed.is_empty() || text.contains(&trimmed)) + && (filters.status == 0 || (filters.status == 1) == assigned) + }; + + let command_total = rows.len(); + let packs = vm.settings.style_pack_hotkeys.clone(); + let visible: Vec = rows + .into_iter() + .filter(|row| filters.category == 0 || filters.category == group_of(row.field) + 1) + .filter(|row| matches(row.label, &row.value)) + .collect(); + let visible_packs: Vec<(usize, StylePackHotkeyRow)> = packs + .iter() + .enumerate() + .filter(|(_, _row)| filters.category == 0 || filters.category == 2) + .filter(|(_, row)| matches(&row.name, &row.hotkey)) + .map(|(index, row)| (index, row.clone())) + .collect(); + let readonly_visible = (filters.category == 0 || filters.category == 4) + && filters.status != 2 + && (trimmed.is_empty() + || format!("{} Esc", tr_l10n(lang, "settings.shortcuts.cancel")) + .to_lowercase() + .contains(&trimmed)); + let total = command_total + packs.len() + 1; + let count = visible.len() + visible_packs.len() + usize::from(readonly_visible); + + // ── 工具栏 ──────────────────────────────────────────────────────── + ui.horizontal(|ui| { + if search_field(ui, &mut query, query_id).changed() { + ui.data_mut(|data| data.insert_temp(query_id, query.clone())); + } + ui.add_space(12.0); + ui.label( + egui::RichText::new(format!("{count} / {total}")) + .size(12.0) + .color(theme::INK_3), + ); + }); + ui.add_space(16.0); + + ui.horizontal_top(|ui| { + // ── 左:筛选(140px,不参与滚动) ─────────────────────────────── + ui.allocate_ui_with_layout( + egui::vec2(FILTER_COLUMN_WIDTH, 10.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.set_width(FILTER_COLUMN_WIDTH); + let mut next = filters; + filter_group( + ui, + tr_l10n(lang, "uiRefinement.category"), + &[ + (tr_l10n(lang, "uiRefinement.all"), 0), + (tr_l10n(lang, "uiRefinement.recording"), 1), + (tr_l10n(lang, "uiRefinement.styles"), 2), + (tr_l10n(lang, "uiRefinement.system"), 3), + ], + filters.category, + |value| next.category = value, + ); + ui.add_space(20.0); + filter_group( + ui, + tr_l10n(lang, "uiRefinement.status"), + &[ + (tr_l10n(lang, "uiRefinement.all"), 0), + (tr_l10n(lang, "uiRefinement.assigned"), 1), + (tr_l10n(lang, "uiRefinement.unassigned"), 2), + ], + filters.status, + |value| next.status = value, + ); + if next.category != filters.category || next.status != filters.status { + filters = next; + ui.data_mut(|data| data.insert_temp(filters_id, filters)); } - 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, - )); - }, + }, + ); + ui.add_space(28.0); + // ── 右:命令行 ────────────────────────────────────────────────── + ui.vertical(|ui| { + ui.set_width(ui.available_width()); + ui.label( + egui::RichText::new(tr_l10n(lang, "uiRefinement.shortcutHint")) + .size(12.0) + .color(theme::INK_3), ); - }, - ); + ui.add_space(8.0); + for row in &visible { + shortcut_recorder_row(ui, vm, actions, row, true); + } + for (index, row) in &visible_packs { + style_pack_recorder_row(ui, vm, actions, *index, row); + } + if readonly_visible { + readonly_keycap_row(ui, tr_l10n(lang, "settings.shortcuts.cancel"), "", "Esc"); + } + if count == 0 { + ui.add_space(12.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "uiRefinement.noResults")) + .size(13.0) + .color(theme::INK_3), + ); + } + if (filters.category == 0 || filters.category == 2) && trimmed.is_empty() { + ui.add_space(10.0); + style_pack_add_row(ui, vm, actions); + } + }); + }); } -/// 一行可编辑快捷键的展示数据。 -pub(super) struct ShortcutRow { - field: ShortcutField, - label: &'static str, - /// 已格式化的键帽文本(`Ctrl+Shift+;`),空串 = 未设置。 - value: String, - /// 已配置的快捷键均可清空;尚未保存的草稿不提供停用操作。 - can_disable: bool, - /// 行下方的补充说明(录音行显示当前录音方式后缀)。 - hint: String, +const FILTER_COLUMN_WIDTH: f32 = 140.0; +/// 录制器区域宽度上限(Tauri `.ol-shortcut-row` 的第二列 `minmax(150px, 260px)`)。 +const RECORDER_MAX_WIDTH: f32 = 260.0; +/// 命令行最小高度(Tauri `.ol-shortcut-row` 的 `min-height: 64px`)。 +const SHORTCUT_ROW_HEIGHT: f32 = 64.0; + +/// 设置页的搜索框(Tauri `.ol-settings-search-field`):36 高、1px 描边、r8、白底。 +fn search_field(ui: &mut egui::Ui, value: &mut String, id: egui::Id) -> egui::Response { + let width = ui.available_width().min(520.0); + let (outer, _) = ui.allocate_exact_size(egui::vec2(width, 36.0), egui::Sense::hover()); + ui.painter() + .rect_filled(outer, egui::CornerRadius::same(8), theme::SURFACE); + ui.painter().rect_stroke( + outer, + egui::CornerRadius::same(8), + egui::Stroke::new(1.0, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + let inner = outer.shrink2(egui::vec2(11.0, 1.0)); + let mut child = ui.new_child( + egui::UiBuilder::new() + .id_salt(id) + .max_rect(inner) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + child.add( + egui::TextEdit::singleline(value) + .id(id) + .hint_text(tr_l10n( + openless_linux_egui::Lang::ZhCn, + "uiRefinement.shortcutsSearch", + )) + .font(egui::FontId::proportional(13.0)) + .text_color(theme::INK) + .frame(egui::Frame::NONE) + .desired_width(inner.width()) + .align(egui::Align2::CENTER_CENTER), + ) } -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, +/// 一组筛选按钮(标题 + 竖排选项),选中项底色与字重都跟着变。 +fn filter_group( + ui: &mut egui::Ui, + title: &str, + options: &[(&str, u8)], + active: u8, + mut on_select: impl FnMut(u8), +) { + ui.add_space(4.0); + ui.label( + egui::RichText::new(title) + .size(11.0) + .color(theme::INK_3) + .strong(), + ); + ui.add_space(4.0); + for (label, value) in options { + let selected = active == *value; + // id 由「分组标题 + 选项」经 `push_id` 派生: + // * 不能跟着 `ui.scope_id()` 走 —— 切换筛选会改变可见行数、连带改变 auto-id; + // * 也不能只用选项文字 —— 分类与分配状态两组各有一个「全部」, + // 只按文字区分会让两个按钮抢同一个 id(日志里 `Second use of widget ID`)。 + // 交互用 `allocate_exact_size` 自己返回的 response:对同一矩形再 `interact` + // 会出现两个控件抢同一次点击,点击就丢了。 + let (rect, response) = ui + .push_id(("openless-shortcut-filter", title, *label), |ui| { + ui.allocate_exact_size(egui::vec2(ui.available_width(), 32.0), egui::Sense::click()) + }) + .inner; + if selected { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(7), theme::SURFACE_2); + } else if response.hovered() { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(7), + theme::SURFACE_2.gamma_multiply(0.55), + ); + } + let text = if selected { + egui::RichText::new(*label) + .size(13.0) + .strong() + .color(theme::INK) + } else { + egui::RichText::new(*label).size(13.0).color(theme::INK_2) + }; + ui.painter().text( + egui::pos2(rect.left() + 10.0, rect.center().y), + egui::Align2::LEFT_CENTER, + text.text(), + egui::FontId::proportional(13.0), + if selected { theme::INK } else { theme::INK_2 }, + ); + #[cfg(test)] + ui.ctx().data_mut(|data| { + // 按「分组 + 选项」记录:两组各有一个「全部」,只按文字存会互相覆盖。 + let mut rects: std::collections::BTreeMap<(String, String), egui::Rect> = data + .get_temp(egui::Id::unique("shortcut-filter-rects")) + .unwrap_or_default(); + rects.insert((title.to_string(), (*label).to_string()), rect); + data.insert_temp(egui::Id::unique("shortcut-filter-rects"), rects); + }); + if response.clicked() { + on_select(*value); } } } -/// 快捷键行:标签(+「?」)→ 键帽 → 录制控件行最右的 chevron;点 chevron 展开 -/// 「录制快捷键 / 停用」菜单,进入录制后键帽位置换成「请按下快捷键组合…」面板。 -pub(super) fn shortcut_row( +/// 一条命令行:左侧标签(+说明),右侧录制器(键帽 + ↻ + ✕),底部一条细线。 +/// +/// 布局用**显式矩形**而不是 `allocate_ui_with_layout`:后者按内容收缩 +/// (`top_down` 会 shrink-wrap),标签列只占文字宽度,录制器就贴不到右边缘 +/// (表现是"右对齐不对")。这里把行切成「标签区 + 20px 间距 + 录制器区」。 +fn shortcut_row_layout( + ui: &mut egui::Ui, + row_label: &str, + hint: &str, + body: impl FnOnce(&mut egui::Ui, egui::Rect), +) { + let width = ui.available_width(); + // 没有标签时(速记页的卡片只有键帽)用紧凑行高:64px 会把那张卡片撑出可视区。 + let height = if row_label.is_empty() { + 44.0 + } else { + SHORTCUT_ROW_HEIGHT + }; + let recorder_width = RECORDER_MAX_WIDTH.min((width * 0.5).max(150.0)); + let (row_rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + let recorder_rect = egui::Rect::from_min_size( + egui::pos2(row_rect.right() - recorder_width, row_rect.top()), + egui::vec2(recorder_width, height), + ); + let label_rect = egui::Rect::from_min_max( + row_rect.min, + egui::pos2( + (recorder_rect.left() - 20.0).max(row_rect.left()), + row_rect.bottom(), + ), + ); + let mut label_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(label_rect) + .layout(egui::Layout::top_down(egui::Align::Min)), + ); + label_ui.add_space(if row_label.is_empty() { 0.0 } else { 12.0 }); + label_ui.label(egui::RichText::new(row_label).size(13.5).color(theme::INK)); + if !hint.is_empty() { + label_ui.add_space(5.0); + label_ui.label(egui::RichText::new(hint).size(11.5).color(theme::INK_3)); + } + #[cfg(test)] + ui.ctx().data_mut(|data| { + data.insert_temp( + egui::Id::unique(("shortcut-row-rects", row_label.to_string())), + (row_rect, recorder_rect), + ) + }); + let mut recorder_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(recorder_rect) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); + body(&mut recorder_ui, recorder_rect); + row_divider(ui); +} + +pub(super) fn shortcut_recorder_row( ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec, row: &ShortcutRow, + with_reset: bool, ) { - row_desc(ui, row.label, "", |ui| { - shortcut_control(ui, vm, actions, row) + let recording = vm.shortcut_recording == Some(row.field); + shortcut_row_layout(ui, row.label, &row.hint, |ui, _rect| { + if recording { + recording_panel(ui, vm, actions, row.field); + return; + } + // 从右往左:✕ 清空 → ↻ 重置 → 键帽组。 + if row.can_disable + && bordered_icon_button(ui, IconName::Close, ("shortcut-clear", row.field)) + { + actions.push(FrontendAction::ShortcutDisable(row.field)); + } + ui.add_space(8.0); + if with_reset && bordered_icon_button(ui, IconName::Refresh, ("shortcut-reset", row.field)) + { + actions.push(FrontendAction::ShortcutReset(row.field)); + } + ui.add_space(8.0); + let assigned = !row.value.trim().is_empty(); + let width = chips_width(ui, &row.value).min(ui.available_width()); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 26.0), egui::Sense::click()); + let response = ui.interact( + rect, + egui::Id::unique(("openless-shortcut-record", format!("{:?}", row.field))), + egui::Sense::click(), + ); + if assigned { + keycaps_in_rect(ui, rect, &row.value); + } else { + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "+", + egui::FontId::proportional(13.0), + theme::INK_3, + ); + } + if response.clicked() { + actions.push(FrontendAction::ShortcutRecording(Some(row.field))); + } }); - shortcut_menu(ui, vm, actions, row); } -/// 键帽在行首、chevron 在行尾(录制中则换成录制面板)。单独抽出供速记页 -/// 的快捷键卡片复用,那一页没有 200px 标签列。 -pub(super) fn shortcut_control( +fn row_divider(ui: &mut egui::Ui) { + let (rect, _) = + ui.allocate_exact_size(egui::vec2(ui.available_width(), 0.5), egui::Sense::hover()); + ui.painter() + .rect_filled(rect, egui::CornerRadius::ZERO, theme::LINE_SOFT); +} + +fn style_pack_recorder_row( ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec, - row: &ShortcutRow, + index: usize, + row: &StylePackHotkeyRow, ) { - let recording = vm.shortcut_recording == Some(row.field); - let menu_open = vm.shortcut_menu == Some(row.field); + let recording = vm.shortcut_recording == Some(ShortcutField::StylePack(index)); + // 左侧是风格包选择器(不是纯文本),所以这一行用「选择器 + 录制器」两列, + // 录制器同样贴右边缘。 + let width = ui.available_width(); + let recorder_width = RECORDER_MAX_WIDTH.min((width * 0.5).max(150.0)); + let (row_rect, _) = + ui.allocate_exact_size(egui::vec2(width, SHORTCUT_ROW_HEIGHT), egui::Sense::hover()); + let recorder_rect = egui::Rect::from_min_size( + egui::pos2(row_rect.right() - recorder_width, row_rect.top()), + egui::vec2(recorder_width, SHORTCUT_ROW_HEIGHT), + ); + let label_rect = egui::Rect::from_min_max( + row_rect.min, + egui::pos2( + (recorder_rect.left() - 20.0).max(row_rect.left()), + row_rect.bottom(), + ), + ); + let mut picker_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(label_rect) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + style_pack_picker( + &mut picker_ui, + &vm.style_packs, + &row.pack_id, + &row.name, + Some(index), + actions, + vm.lang, + ); + let mut recorder_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(recorder_rect) + .layout(egui::Layout::right_to_left(egui::Align::Center)), + ); if recording { - recording_panel(ui, vm, actions, row.field); + recording_panel( + &mut recorder_ui, + vm, + actions, + ShortcutField::StylePack(index), + ); + } else { + if bordered_icon_button( + &mut recorder_ui, + IconName::Close, + ("style-hotkey-remove", index), + ) { + actions.push(FrontendAction::StyleHotkeyRemove(index)); + } + recorder_ui.add_space(8.0); + let chips = chips_width(&recorder_ui, &row.hotkey).min(recorder_ui.available_width()); + let (rect, _) = + recorder_ui.allocate_exact_size(egui::vec2(chips, 26.0), egui::Sense::click()); + let response = recorder_ui.interact( + rect, + egui::Id::unique(("openless-style-shortcut-record", index)), + egui::Sense::click(), + ); + if !row.hotkey.trim().is_empty() { + keycaps_in_rect(&mut recorder_ui, rect, &row.hotkey); + } else { + recorder_ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + "+", + egui::FontId::proportional(13.0), + theme::INK_3, + ); + } + if response.clicked() { + actions.push(FrontendAction::ShortcutRecording(Some( + ShortcutField::StylePack(index), + ))); + } + } + row_divider(ui); +} + +fn style_pack_add_row( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + let lang = vm.lang; + if !vm.style_hotkey_draft_open { + let label = format!("+ {}", tr_l10n(lang, "settings.shortcuts.style_pack_add")); + if ui + .add( + egui::Label::new(egui::RichText::new(label).size(13.0).color(theme::BLUE)) + .sense(egui::Sense::click()), + ) + .clicked() + { + actions.push(FrontendAction::StyleHotkeyDraft(true)); + } 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(), + ui.allocate_ui_with_layout( + egui::vec2(ui.available_width(), SHORTCUT_ROW_HEIGHT), + egui::Layout::left_to_right(egui::Align::Center), + |ui| { + let pack = vm.style_packs.get(vm.style_hotkey_draft_pack); + style_pack_picker( + ui, + &vm.style_packs, + pack.map(|pack| pack.id.as_str()).unwrap_or(""), + pack.map(|pack| pack.name.as_str()).unwrap_or(""), + None, + actions, + lang, + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if bordered_icon_button(ui, IconName::Close, ("style-draft-cancel", 0)) { + actions.push(FrontendAction::StyleHotkeyDraft(false)); + } + ui.add_space(8.0); + recording_panel(ui, vm, actions, ShortcutField::StyleDraft); + }); + }, ); - if response.hovered() { - ui.painter() - .rect_filled(arrow, egui::CornerRadius::same(6), theme::SURFACE_2); + row_divider(ui); +} + +/// 键帽组总宽度。未分配时给一个「+」的点击区。 +fn chips_width(ui: &egui::Ui, combo: &str) -> f32 { + let mut width = 0.0; + for part in combo + .split('+') + .map(str::trim) + .filter(|part| !part.is_empty()) + { + width += (layout::text_width(ui, part, 11.5) + 12.0).max(20.0) + 4.0; } - 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) - })); + if width <= 0.0 { + 24.0 + } else { + width - 4.0 } - let mut x = line.left(); - for part in row - .value +} + +/// 在给定矩形内画键帽:左→右、修饰键在前(`Alt` `Z`)。尺寸照 Tauri 的 `Kbd`: +/// 高 21、圆角 5、11.5px、底色 `SURFACE_2`、0.5px 描边 + 一条底边阴影线。 +fn keycaps_in_rect(ui: &mut egui::Ui, rect: egui::Rect, combo: &str) { + let mut x = rect.left(); + for part in combo .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 width = (layout::text_width(ui, part, 11.5) + 12.0).max(20.0); let key = egui::Rect::from_min_size( - egui::pos2(x, line.center().y - 11.0), - egui::vec2(key_width, 22.0), + egui::pos2(x, rect.center().y - 10.5), + egui::vec2(width, 21.0), ); ui.painter() - .rect_filled(key, egui::CornerRadius::same(6), theme::SURFACE_2); + .rect_filled(key, egui::CornerRadius::same(5), theme::SURFACE_2); ui.painter().rect_stroke( key, - egui::CornerRadius::same(6), + egui::CornerRadius::same(5), egui::Stroke::new(0.5, theme::LINE_STRONG), egui::StrokeKind::Inside, ); + // 键帽底边那一道更深的线(Tauri `Kbd` 的 `box-shadow: inset 0 -1px`)。 + ui.painter().rect_filled( + egui::Rect::from_min_max( + egui::pos2(key.left() + 1.0, key.bottom() - 1.0), + egui::pos2(key.right() - 1.0, key.bottom()), + ), + egui::CornerRadius::same(3), + theme::LINE, + ); ui.painter().text( key.center(), egui::Align2::CENTER_CENTER, part, - egui::FontId::proportional(11.0), + egui::FontId::proportional(11.5), 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); + x += width + 4.0; } } -/// 「请按下快捷键组合…」面板:读本帧输入,Escape 取消、其它键即完成录入。 -fn recording_panel( +/// 带边框的小图标按钮(Tauri `menuButtonStyle`:0.5px 描边 / r6 / 透明底)。 +fn bordered_icon_button( 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( + icon: IconName, + salt: impl std::hash::Hash + std::fmt::Debug, +) -> bool { + let (rect, _) = ui.allocate_exact_size(egui::vec2(28.0, 26.0), egui::Sense::click()); + let response = ui.interact( rect, - egui::CornerRadius::same(8), - egui::Stroke::new(1.0, egui::Color32::from_rgba_unmultiplied(37, 99, 235, 60)), - egui::StrokeKind::Inside, + egui::Id::unique(("openless-icon-button", format!("{salt:?}"))), + egui::Sense::click(), + ); + let fill = if response.hovered() { + theme::SURFACE_2 + } else { + egui::Color32::TRANSPARENT + }; + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(6), fill); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(6), + egui::Stroke::new(0.5, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + let color = if response.hovered() { + theme::INK + } else { + theme::INK_2 + }; + crate::ui::frontend::icons::draw_icon_sized(ui, rect.center(), icon, color, 13.0); + response.clicked() +} + +pub(super) struct ShortcutRow { + field: ShortcutField, + label: &'static str, + /// 已格式化的键帽文本(`Ctrl+Shift+;`),空串 = 未设置。 + value: String, + /// 已配置的快捷键均可清空;尚未保存的草稿不提供停用操作。 + 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, + } + } +} + +/// 「请按下快捷键组合…」面板:读本帧输入,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()); + // 录制面板的矩形交给主循环:点别处就取消录制(Tauri 的 Popover 点外部关闭)。 + // 记下"是否已经武装"——进入录制的那一帧是按下鼠标的那一帧,不能立刻取消。 + let cancel_id = egui::Id::unique("openless-shortcut-recording"); + let armed = ui + .data(|data| data.get_temp::<(egui::Rect, bool)>(cancel_id)) + .is_some_and(|(_, armed)| armed); + ui.data_mut(|data| data.insert_temp(cancel_id, (rect, armed))); + 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), @@ -1405,238 +2005,13 @@ fn shortcut_primary(key: egui::Key) -> Option { /// 只读键帽行(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); + shortcut_row_layout(ui, label, desc, |ui, _rect| { + let width = chips_width(ui, combo).min(ui.available_width()); + let (rect, _) = ui.allocate_exact_size(egui::vec2(width, 26.0), egui::Sense::hover()); + keycaps_in_rect(ui, rect, 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), @@ -1764,6 +2139,83 @@ fn appearance(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec = vec![ + tr_l10n(lang, "uiRefinement.native"), + "Avenir Next · Aptos", + "PingFang / Microsoft YaHei", + "New York · Georgia", + "Songti / SimSun", + "SF Mono · Cascadia Code", + "Menlo · Consolas", + ]; + let font_index = UiFont::ALL + .iter() + .position(|font| *font == typography.font) + .unwrap_or(0); + combo_index_row( + ui, + tr_l10n(lang, "uiRefinement.family"), + tr_l10n(lang, "uiRefinement.fontHint"), + font_index, + &fonts, + |val| { + if let Some(font) = UiFont::ALL.get(val) { + actions.push(FrontendAction::SettingsTypography(TypographyField::Font( + *font, + ))); + } + }, + ); + let scales: Vec<&str> = FontScale::ALL.iter().map(|scale| scale.label()).collect(); + let scale_index = FontScale::ALL + .iter() + .position(|scale| *scale == typography.scale) + .unwrap_or(1); + combo_index_row( + ui, + tr_l10n(lang, "uiRefinement.fontSize"), + "", + scale_index, + &scales, + |val| { + if let Some(scale) = FontScale::ALL.get(val) { + actions.push(FrontendAction::SettingsTypography(TypographyField::Scale( + *scale, + ))); + } + }, + ); + let widths: Vec<&str> = vec![ + tr_l10n(lang, "uiRefinement.narrow"), + tr_l10n(lang, "uiRefinement.comfortable"), + tr_l10n(lang, "uiRefinement.wide"), + ]; + let width_index = ContentWidth::ALL + .iter() + .position(|width| *width == typography.width) + .unwrap_or(1); + combo_index_row( + ui, + tr_l10n(lang, "uiRefinement.width"), + "", + width_index, + &widths, + |val| { + if let Some(width) = ContentWidth::ALL.get(val) { + actions.push(FrontendAction::SettingsTypography(TypographyField::Width( + *width, + ))); + } + }, + ); + }); card( ui, tr_l10n(lang, "settings.language.title"), @@ -1833,70 +2285,352 @@ impl ServiceView { } 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); - } + let mut views = vec![Self::Llm, Self::Asr, Self::Omni]; 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())); + fn inactive(self, multimodal: bool) -> bool { + if multimodal { + !matches!(self, Self::Omni | Self::Connections) + } else { + self == Self::Omni } } - 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( +fn omni_settings(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + use openless_linux_egui::omni_settings::{OmniAction, OmniField}; + let lang = vm.lang; + let mut state = vm.omni.clone(); + let timer = ui.scope_id().with("omni-autosave"); + let mut changed = false; + card( + ui, + tr_l10n(lang, "settings.providers.omniTitle"), + "", + |ui| { + if state.busy { + ui.label(tr_l10n(lang, "common.loading")); + } + ui.add_enabled_ui(state.loaded && !state.switching, |ui| { + ui.add_enabled_ui(!state.busy, |ui| { + modal_form_row( + ui, + tr_l10n(lang, "settings.providers.providerLabel"), + |ui| { + let selected = state + .presets + .iter() + .position(|p| p.id == state.provider) + .unwrap_or(0); + let labels: Vec = state + .presets + .iter() + .map(|preset| { + let key = + format!("settings.providers.presets.{}", preset.label_key); + let label = fmt_l10n(lang, &key, &[]); + if label == key { + preset.label_key.clone() + } else { + label + } + }) + .collect(); + egui::ComboBox::from_id_salt("omni-provider") + .width(ui.available_width()) + .selected_text( + labels.get(selected).map(String::as_str).unwrap_or_default(), + ) + .show_ui(ui, |ui| { + for (index, label) in labels.iter().enumerate() { + if ui + .selectable_label(index == selected, label.as_str()) + .clicked() + && index != selected + && state.validate().is_ok() + { + actions.push(FrontendAction::SettingsOmni( + OmniAction::Provider( + state.presets[index].id.clone(), + ), + )); + ui.data_mut(|data| data.remove_temp::(timer)); + } + } + }); + }, + ); + }); + let preset = state.presets.iter().find(|p| p.id == state.provider); + let model_hint = preset.map(|p| p.model.clone()).unwrap_or_default(); + let endpoint_hint = preset.map(|p| p.endpoint.clone()).unwrap_or_default(); + changed |= omni_text_field( + ui, + tr_l10n(lang, "settings.providers.modelLabel"), + &model_hint, + &mut state.model, + OmniField::Model, + false, + actions, + ); + changed |= omni_text_field( + ui, + tr_l10n(lang, "settings.providers.apiKeyLabel"), + tr_l10n(lang, "providers.api_key_hint"), + &mut state.api_key, + OmniField::ApiKey, + true, + actions, + ); + changed |= omni_text_field( + ui, + tr_l10n(lang, "settings.providers.baseUrlLabel"), + &endpoint_hint, + &mut state.endpoint, + OmniField::Endpoint, + false, + actions, + ); + if state.provider == "custom" { + changed |= omni_text_field( + ui, + tr_l10n(lang, "settings.providers.temperatureLabel"), + tr_l10n(lang, "settings.providers.temperaturePlaceholder"), + &mut state.temperature, + OmniField::Temperature, + false, + actions, + ); + changed |= omni_text_field( + ui, + tr_l10n(lang, "settings.providers.extraHeadersLabel"), + tr_l10n(lang, "settings.providers.extraHeadersPlaceholder"), + &mut state.extra_headers, + OmniField::ExtraHeaders, + true, + actions, + ); + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.extra_headers_hint")) + .size(11.5) + .color(theme::INK_4), + ); + } + if let Err(error) = state.validate() { + ui.colored_label(theme::ERR, tr_l10n(lang, "common.operationFailed")); + ui.label(error.to_string()); + } + ui.add_space(10.0); + ui.add_enabled_ui(state.validate().is_ok() && !state.busy, |ui| { + ui.horizontal_wrapped(|ui| { + if ui + .button(tr_l10n(lang, "settings.providers.fetchModels")) + .clicked() + { + actions.push(FrontendAction::SettingsOmni(OmniAction::Models)); + } + if !state.models.is_empty() { + egui::ComboBox::from_id_salt("omni-models") + .selected_text(tr_l10n(lang, "settings.providers.selectModel")) + .show_ui(ui, |ui| { + for model in &state.models { + if ui + .selectable_label(*model == state.model, model) + .clicked() + && *model != state.model + { + state.model = model.clone(); + actions.push(FrontendAction::SettingsOmni( + OmniAction::Field(OmniField::Model, model.clone()), + )); + changed = true; + } + } + }); + } + if ui + .button(tr_l10n(lang, "settings.channels.verify")) + .clicked() + { + actions.push(FrontendAction::SettingsOmni(OmniAction::Validate)); + } + }); + }); + if ui + .add_enabled( + !state.busy, + egui::Button::new(tr_l10n(lang, "btn.clear_secret")), + ) + .clicked() + { + actions.push(FrontendAction::SettingsOmni(OmniAction::ClearSecrets)); + ui.data_mut(|data| data.remove_temp::(timer)); + } + }); + if let Some(notice) = &state.notice { + ui.label(notice); + } + if !state.busy + && (!state.loaded || (state.dirty && state.notice.is_some())) + && ui.button(tr_l10n(lang, "common.retry")).clicked() + { + actions.push(FrontendAction::SettingsOmni(if state.loaded { + OmniAction::Save + } else { + OmniAction::Reload + })); + } + }, + ); + let now = ui.input(|i| i.time); + if changed { + state.dirty = true; + state.notice = None; + ui.data_mut(|data| data.insert_temp(timer, now)); + } + if !state.dirty { + ui.data_mut(|data| data.remove_temp::(timer)); + } + if let Some(at) = ui.data(|data| data.get_temp::(timer)) { + if now - at >= 0.3 && !state.busy && state.loaded && state.validate().is_ok() { + ui.data_mut(|data| data.remove_temp::(timer)); + actions.push(FrontendAction::SettingsOmni(OmniAction::Save)); + } else { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(50)); + } + } + vm.omni = state; +} + +fn omni_text_field( + ui: &mut egui::Ui, + label: &str, + hint: &str, + value: &mut String, + field: openless_linux_egui::omni_settings::OmniField, + secret: bool, + actions: &mut Vec, +) -> bool { + let mut changed = false; + modal_form_row(ui, label, |ui| { + changed = ui + .add( + egui::TextEdit::singleline(value) + .id(ui.scope_id().with(label)) + .password(secret) + .hint_text(hint) + .desired_width(ui.available_width()), + ) + .changed(); + if changed { + actions.push(FrontendAction::SettingsOmni( + openless_linux_egui::omni_settings::OmniAction::Field(field, value.clone()), + )); + } + }); + changed +} + +fn services(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { + let lang = vm.lang; + egui::Frame::new() + .fill(theme::SURFACE) + .stroke(egui::Stroke::new(1.0, theme::LINE)) + .corner_radius(12) + .inner_margin(16) + .show(ui, |ui| { + row_desc( + ui, + tr_l10n(lang, "settings.providers.pipeline_mode_label"), + tr_l10n(lang, "settings.providers.pipeline_mode_hint"), + |ui| { + let options = [ + tr_l10n(lang, "settings.providers.pipeline_mode_traditional"), + tr_l10n(lang, "settings.providers.pipeline_mode_multimodal"), + ]; + let (rect, _) = ui.allocate_exact_size( + egui::vec2(ui.available_width(), 38.0), + egui::Sense::hover(), + ); + if let Some(index) = + layout::segmented(ui, rect, &options, usize::from(vm.pipeline_multimodal)) + { + actions.push(FrontendAction::SettingsCombo( + SettingsComboField::PipelineMode, + index, + )); + } + }, + ); + ui.add_space(8.0); + ui.label( + egui::RichText::new(tr_l10n( + lang, + "settings.providers.pipeline_isolation_notice", + )) + .size(11.0) + .color(theme::INK_4), + ); + }); + ui.add_space(14.0); + let views = ServiceView::visible(vm); + let active = views + .iter() + .position(|view| view.id() == vm.services_view && !view.inactive(vm.pipeline_multimodal)) + .unwrap_or_else(|| { + views + .iter() + .position(|view| !view.inactive(vm.pipeline_multimodal)) + .unwrap_or(0) + }); + let items: Vec<(&str, Option, bool)> = views + .iter() + .map(|view| { + let dot = match view { + ServiceView::Llm | ServiceView::Asr if !view.inactive(vm.pipeline_multimodal) => { + let configured = vm.service_configured[usize::from(*view == ServiceView::Asr)]; + Some(if configured { theme::OK } else { theme::ERR }) + } + _ => None, + }; + (view.label(lang), dot, view.inactive(vm.pipeline_multimodal)) + }) + .collect(); + if let Some(index) = service_tabs(ui, &items, active, lang) { + 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::Omni => omni_settings(ui, vm, actions), + 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"), @@ -1926,7 +2660,6 @@ fn services(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec &[1], - ServiceView::Omni => &[0, 1], _ => &[0], }; for kind in kinds { @@ -1977,29 +2710,29 @@ fn services(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec(egui::Id::unique( + "openless-settings-channel-drag", + )) + }); + } for (index, channel) in vm.channels.iter().enumerate() { - channel_row( - ui, - channel, - index, - &vm.channel_providers, - lang, - actions, - ); + ui.push_id(&channel.id, |ui| { + channel_row(ui, channel, index, &vm.channels, 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)); + // Tauri `startCreate`:先建空渠道再开同一个弹窗(草稿模式)。 + actions.push(FrontendAction::SettingsChannelDraft); } } ui.label( @@ -2015,156 +2748,326 @@ fn services(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); + ui.with_layout(egui::Layout::top_down(egui::Align::Min), |ui| { + // 与 Tauri 的隐私页同序:权限 → 数据与存储 → 云端同步。 + // 权限卡先给结论(麦克风 / 快捷键 / 网络 / 文字插入 / 保险库), + // 再让用户改留存与同步设置。 + privacy_access(ui, vm, actions); + privacy_data(ui, vm, actions); + super::cloud_sync::cloud_sync_card(ui, vm, actions); + }); +} - // 权限:状态全部来自宿主快照(Linux 没有系统级授权弹窗,标为「不适用」)。 - card( +fn privacy_note(ui: &mut egui::Ui, text: &str) { + ui.add( + egui::Label::new(egui::RichText::new(text).size(11.5).color(theme::INK_3)) + .wrap() + .halign(egui::Align::Min), + ); +} + +fn privacy_access( + ui: &mut egui::Ui, + vm: &mut FrontendViewModel, + actions: &mut Vec, +) { + use openless_linux_egui::linux_access::{CheckState, KeyringState}; + let lang = vm.lang; + let checking = vm.linux_access.checking; + let mut refresh = false; + card_header( ui, tr_l10n(lang, "settings.permissions.title"), - tr_l10n(lang, "settings.permissions.desc_no_acc"), + "", // 说明放在卡片正文里,标题行只留结论与动作 + |ui| { + // 标题行右侧:重新检查(Tauri 的 Retry 按钮等价物) + refresh = ui + .add_enabled( + !checking, + egui::Button::new(if checking { + tr_l10n(lang, "settings.permissions.checking") + } else { + tr_l10n(lang, "settings.linux_privacy.refresh") + }), + ) + .clicked(); + }, |ui| { - permission_row( + privacy_note(ui, tr_l10n(lang, "settings.linux_privacy.desc")); + ui.add_space(6.0); + let microphone_detected = + vm.permissions.microphone == super::view_model::PermissionState::Granted; + status_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, + tr_l10n(lang, "settings.linux_privacy.mic_desc"), + if microphone_detected { + tr_l10n(lang, "settings.linux_privacy.mic_detected") + } else { + tr_l10n(lang, "settings.permissions.indeterminate") + }, + if microphone_detected { + theme::OK + } else { + theme::INK_4 + }, ); - permission_row( + let hotkey_ready = vm.permissions.hotkey == super::view_model::PermissionState::Granted + && vm.linux_access.input == CheckState::Ready; + status_row( ui, tr_l10n(lang, "settings.permissions.hotkey_label"), - "", - vm.permissions.hotkey, - lang, + tr_l10n(lang, "settings.linux_privacy.hotkey_desc"), + if checking { + tr_l10n(lang, "settings.permissions.checking") + } else if hotkey_ready { + tr_l10n(lang, "settings.linux_privacy.hotkey_ready") + } else if vm.linux_access.input == CheckState::Unavailable { + tr_l10n(lang, "settings.permissions.hotkey_failed") + } else { + tr_l10n(lang, "settings.linux_privacy.not_checked") + }, + if hotkey_ready && !checking { + theme::OK + } else { + theme::INK_4 + }, ); - permission_row( + let (network_text, network_color) = if checking { + ( + tr_l10n(lang, "settings.permissions.checking").to_string(), + theme::INK_4, + ) + } else { + match vm.linux_access.network { + CheckState::Ready => ( + fmt_l10n( + lang, + "settings.linux_privacy.network_online", + &[&vm.linux_access.network_latency_ms.unwrap_or(0)], + ), + theme::OK, + ), + CheckState::Unavailable => ( + tr_l10n(lang, "settings.linux_privacy.network_unavailable").to_string(), + theme::WARN, + ), + CheckState::Unknown => ( + tr_l10n(lang, "settings.linux_privacy.not_checked").to_string(), + theme::INK_4, + ), + } + }; + status_row( ui, tr_l10n(lang, "settings.permissions.network_label"), - "", - vm.permissions.network, - lang, + tr_l10n(lang, "settings.linux_privacy.network_probe_desc"), + &network_text, + network_color, + ); + let (input_text, input_color) = if checking { + (tr_l10n(lang, "settings.permissions.checking"), theme::INK_4) + } else { + match vm.linux_access.input { + CheckState::Ready => ( + tr_l10n(lang, "settings.linux_privacy.input_ready"), + theme::OK, + ), + CheckState::Unavailable => ( + tr_l10n(lang, "settings.linux_privacy.input_unavailable"), + theme::WARN, + ), + CheckState::Unknown => ( + tr_l10n(lang, "settings.linux_privacy.not_checked"), + theme::INK_4, + ), + } + }; + status_row( + ui, + tr_l10n(lang, "settings.linux_privacy.input_label"), + tr_l10n(lang, "settings.linux_privacy.input_desc"), + input_text, + input_color, ); }, ); - - // 数据存储(Tauri DataStorageSection:保留时长 / 上限 / 润色上下文 / 光标上下文)。 + if refresh { + actions.push(FrontendAction::SettingsAction( + SettingsActionField::RefreshLinuxAccess, + )); + } card( ui, - tr_l10n(lang, "settings.data_storage.title"), - tr_l10n(lang, "settings.data_storage.desc"), + tr_l10n(lang, "settings.linux_privacy.keyring_title"), + "", |ui| { - text_edit_row( - ui, - tr_l10n(lang, "settings.recording.history_retention_label"), - "", - &mut vm.settings.retention_days, - "0", - |text| { - actions.push(FrontendAction::SettingsText( - SettingsTextField::RetentionDays, - text.to_owned(), - )); - }, - ); - text_edit_row( - ui, - tr_l10n(lang, "settings.recording.history_max_entries_label"), - "", - &mut vm.settings.history_max_entries, - "200", - |text| { - actions.push(FrontendAction::SettingsText( - SettingsTextField::HistoryMaxEntries, - text.to_owned(), - )); - }, - ); - 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", - |text| { - actions.push(FrontendAction::SettingsText( - SettingsTextField::PolishContextWindow, - text.to_owned(), - )); - }, - ); - text_edit_row( + privacy_note(ui, tr_l10n(lang, "settings.linux_privacy.keyring_desc")); + ui.add_space(6.0); + let (text, color) = if checking { + (tr_l10n(lang, "settings.permissions.checking"), theme::INK_4) + } else { + match vm.linux_access.keyring { + KeyringState::Ready => ( + tr_l10n(lang, "settings.linux_privacy.keyring_ready"), + theme::OK, + ), + KeyringState::Locked => ( + tr_l10n(lang, "settings.linux_privacy.keyring_locked"), + theme::WARN, + ), + KeyringState::MissingService => ( + tr_l10n(lang, "settings.linux_privacy.keyring_missing_service"), + theme::WARN, + ), + KeyringState::MissingCollection => ( + tr_l10n(lang, "settings.linux_privacy.keyring_missing_collection"), + theme::WARN, + ), + KeyringState::TemporaryVault => ( + tr_l10n(lang, "settings.linux_privacy.keyring_temporary"), + theme::WARN, + ), + KeyringState::Unavailable => ( + tr_l10n(lang, "settings.linux_privacy.keyring_unavailable"), + theme::WARN, + ), + KeyringState::RestartRequired => ( + tr_l10n(lang, "settings.linux_privacy.keyring_restart"), + theme::WARN, + ), + KeyringState::Unknown => ( + tr_l10n(lang, "settings.linux_privacy.not_checked"), + theme::INK_4, + ), + } + }; + status_row(ui, "Secret Service", "", text, color); + privacy_note( ui, - tr_l10n(lang, "settings.recording.polish_skip_max_chars_label"), - tr_l10n(lang, "settings.recording.polish_skip_max_chars_desc"), - &mut vm.settings.polish_skip_max_chars, - "0", - |text| { - actions.push(FrontendAction::SettingsText( - SettingsTextField::PolishSkipMaxChars, - text.to_owned(), - )); - }, + tr_l10n(lang, "settings.linux_privacy.keyring_probe_desc"), ); + if !checking + && !matches!( + vm.linux_access.keyring, + KeyringState::Ready | KeyringState::Unknown + ) + { + privacy_note(ui, tr_l10n(lang, "settings.linux_privacy.keyring_recovery")); + } }, ); } -fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec) { +fn privacy_data(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"), - ), - ( - SettingsIcon::Sparkle, - tr_l10n(lang, "settings.testing_features.title"), - tr_l10n(lang, "modal.advanced_pages.testing_features"), - ), - ]; - if vm.advanced_open == 3 { - experimental_card(ui, rows[3].1, "Beta", "", |ui| { - toggle_row( - ui, + card(ui, tr_l10n(lang, "settings.data_storage.title"), "", |ui| { + privacy_note(ui, tr_l10n(lang, "settings.linux_privacy.intro_desc")); + ui.add_space(6.0); + privacy_note(ui, tr_l10n(lang, "settings.linux_privacy.storage_desc")); + ui.add_space(6.0); + row_desc( + ui, + tr_l10n(lang, "settings.linux_privacy.data_directory"), + "", + |ui| { + let directory = if vm.application_data_directory.is_empty() { + tr_l10n(lang, "settings.permissions.indeterminate") + } else { + &vm.application_data_directory + }; + ui.add( + egui::Label::new(egui::RichText::new(directory).monospace()) + .wrap() + .halign(egui::Align::Min), + ); + }, + ); + text_edit_row( + ui, + tr_l10n(lang, "settings.recording.history_retention_label"), + "", + &mut vm.settings.retention_days, + "0", + |text| { + actions.push(FrontendAction::SettingsText( + SettingsTextField::RetentionDays, + text.to_owned(), + )); + }, + ); + text_edit_row( + ui, + tr_l10n(lang, "settings.recording.history_max_entries_label"), + "", + &mut vm.settings.history_max_entries, + "200", + |text| { + actions.push(FrontendAction::SettingsText( + SettingsTextField::HistoryMaxEntries, + text.to_owned(), + )); + }, + ); + 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", + |text| { + actions.push(FrontendAction::SettingsText( + SettingsTextField::PolishContextWindow, + text.to_owned(), + )); + }, + ); + text_edit_row( + ui, + tr_l10n(lang, "settings.recording.polish_skip_max_chars_label"), + tr_l10n(lang, "settings.recording.polish_skip_max_chars_desc"), + &mut vm.settings.polish_skip_max_chars, + "0", + |text| { + actions.push(FrontendAction::SettingsText( + SettingsTextField::PolishSkipMaxChars, + text.to_owned(), + )); + }, + ); + }); +} + +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. + // Keep existing drill-in IDs, but the pipeline now lives only on Services. + let rows = [ + ( + 0, + SettingsIcon::Monitor, + tr_l10n(lang, "settings.coding_agent.title"), + tr_l10n(lang, "modal.advanced_pages.less_computer"), + ), + ( + 2, + SettingsIcon::Bolt, + tr_l10n(lang, "settings.debug.title"), + tr_l10n(lang, "modal.advanced_pages.debug"), + ), + ( + 3, + SettingsIcon::Sparkle, + tr_l10n(lang, "settings.testing_features.title"), + tr_l10n(lang, "modal.advanced_pages.testing_features"), + ), + ]; + // Beta 开关:Tauri 用 `ExperimentalSectionTitle`(标题 + Beta 徽章)单独成页。 + if vm.advanced_open == 3 { + experimental_card(ui, rows[2].2, "Beta", "", |ui| { + toggle_row( + ui, tr_l10n(lang, "settings.testing_features.enable"), tr_l10n(lang, "settings.testing_features.description"), vm.settings.testing_features, @@ -2174,40 +3077,19 @@ fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec { toggle_row( @@ -2333,15 +3215,19 @@ fn advanced(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec, +} + +/// 一个渠道卡片(Tauri `ChannelList` 的行)。 +/// +/// 结构照抄 Tauri:左侧拖拽手柄 → 名称 + 徽章 → `provider · model`(模型用等宽) +/// → 「上次验证 …」状态块;右侧「重新验证 / 启用 / 编辑 ›」。 +/// 换顺序靠手柄拖动 —— Tauri 那边同样是拖动重排,没有上下箭头按钮, +/// 渠道类型与删除都在编辑页里。 fn channel_row( ui: &mut egui::Ui, channel: &super::view_model::SettingsChannel, index: usize, - providers: &[SettingsChannelProvider], + channels: &[super::view_model::SettingsChannel], lang: Lang, actions: &mut Vec, ) { - ui.horizontal(|ui| { - ui.vertical(|ui| { + // 拖动身份不随排序变化;同一次交换等宿主确认后再提交下一次。 + let drag_id = egui::Id::unique("openless-settings-channel-drag"); + let dragging = ui.data(|data| data.get_temp::(drag_id)); + + let card = egui::Frame::new() + // 当前使用中的渠道:中性灰底 + 细描边。Tauri 特意不用蓝底竖条 —— + //「当前使用」徽章已经说明问题,整行染色太花哨。 + .fill(if channel.is_active { + theme::SURFACE_2 + } else { + egui::Color32::TRANSPARENT + }) + .stroke(if channel.is_active { + egui::Stroke::new(0.5, theme::LINE) + } else { + egui::Stroke::NONE + }) + .corner_radius(egui::CornerRadius::same(12)) + .inner_margin(egui::Margin::symmetric(12, 14)) + .show(ui, |ui| { ui.horizontal(|ui| { - ui.label( - egui::RichText::new(&channel.name) - .size(12.5) - .strong() - .color(theme::INK), + ui.spacing_mut().item_spacing.x = 10.0; + + // ── 拖拽手柄(Tauri 的 `⠿`) + let (handle_rect, handle) = + ui.allocate_exact_size(egui::vec2(16.0, 24.0), egui::Sense::drag()); + let lifted = dragging + .as_ref() + .is_some_and(|drag| drag.channel_id == channel.id); + icons::draw_icon_sized( + ui, + handle_rect.center(), + IconName::Grip, + if lifted || handle.hovered() { + theme::INK + } else { + theme::INK_4 + }, + 16.0, ); - 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| { + if handle.hovered() || lifted { + handle + .clone() + .on_hover_text(tr_l10n(lang, "settings.channels.drag_hint")); + } + if handle.drag_started() { + ui.data_mut(|data| { + data.insert_temp( + drag_id, + ChannelDrag { + channel_id: channel.id.clone(), + pending: None, + }, + ) + }); + } + if handle.drag_stopped() { + ui.data_mut(|data| data.remove::(drag_id)); + } + + ui.vertical(|ui| { + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 8.0; + // Tauri:`const label = channel.name.trim() || providerLabel` + // —— 用户没给渠道起名时,标题就是供应商名。 + let title = if channel.name.trim().is_empty() { + channel.provider.as_str() + } else { + channel.name.trim() + }; + ui.label( + egui::RichText::new(title) + .size(14.0) + .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 { + egui::Frame::new() + .stroke(egui::Stroke::new(0.5, theme::LINE)) + .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.disabled", + )) + .size(10.0) + .color(theme::INK_4), + ); + }); + } + }); + // 第二行:`provider · model`,模型用等宽(Tauri 的 ol-font-mono)。 + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + // 第二行的供应商前缀只在标题用了自定义名字时才加,否则 + // 会和标题重复(Tauri 的 `{channel.name.trim() && …}`)。 + if !channel.name.trim().is_empty() { ui.label( - egui::RichText::new(tr_l10n(lang, "settings.channels.current")) - .size(10.0) - .color(theme::BLUE), + egui::RichText::new(format!("{} ·", channel.provider)) + .size(12.0) + .color(theme::INK_3), ); - }); - } - if !channel.enabled { + } + let model = if channel.model.trim().is_empty() { + egui::RichText::new(tr_l10n(lang, "settings.channels.model_not_set")) + .size(12.0) + .color(theme::INK_3) + } else { + egui::RichText::new(channel.model.trim()) + .size(12.0) + .monospace() + .color(theme::INK_3) + }; + ui.label(model); + }); + channel_status_line(ui, channel, lang); + }); + + // ── 右侧动作:与 Tauri 同序(右到左压栈 → 屏幕顺序:验证、启用、编辑) + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + channel_edit_button(ui, lang, || { + actions.push(FrontendAction::SettingsChannelSelect(index)); + }); + ui.add_space(8.0); + 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.disabled")) - .size(10.5) - .color(theme::INK_4), + egui::RichText::new(tr_l10n(lang, "settings.channels.enabled")) + .size(11.0) + .color(theme::INK_3), ); - } - }); - 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)) + ui.add_space(8.0); + let testing = channel_testing(ui, channel); + let verify = if testing { + tr_l10n(lang, "settings.channels.verifying") + } else if channel.last_ok.is_some() { + tr_l10n(lang, "settings.channels.reverify") + } else { + tr_l10n(lang, "settings.channels.verify") + }; + let response = ui.add_enabled( + !testing, + egui::Button::new(egui::RichText::new(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(22.0, 24.0)), - ) - .clicked() - { - actions.push(FrontendAction::SettingsChannelMove { index, delta }); - } + .min_size(egui::vec2(0.0, 24.0)), + ); + if response.clicked() { + mark_channel_testing(ui, channel); + actions.push(FrontendAction::SettingsChannelValidate(index)); + } + }); + }); + }) + .response; + + if let Some(mut drag) = dragging.filter(|drag| drag.channel_id == channel.id) { + if let Some((neighbor, delta)) = &drag.pending { + if index + .checked_add_signed(*delta) + .and_then(|target| channels.get(target)) + .is_some_and(|channel| channel.id == *neighbor) + { + return; } - // 渠道的 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(), + drag.pending = None; + } + if let Some(pointer) = ui.ctx().pointer_latest_pos() { + let delta = if pointer.y > card.rect.bottom() + 2.0 { + 1 + } else if pointer.y < card.rect.top() - 2.0 { + -1 + } else { + 0 + }; + if delta != 0 { + if let Some(neighbor) = index + .checked_add_signed(delta) + .and_then(|target| channels.get(target)) + { + actions.push(FrontendAction::SettingsChannelMove { + channel_id: channel.id.clone(), + neighbor_id: neighbor.id.clone(), + delta, }); + drag.pending = Some((neighbor.id.clone(), delta)); } } - // 当前生效的渠道由 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(); + } + if ui.input(|input| { + input.focused && input.pointer.primary_down() && !input.key_pressed(egui::Key::Escape) + }) { + ui.data_mut(|data| data.insert_temp(drag_id, drag)); + } + } } -/// 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); +/// Tauri 的 `testingIds` 同样是**界面本地**状态(promise 落地即清除),所以这里 +/// 也放在 egui 的临时数据里:结果回包(`last_check_age_seconds` 变新)或超时即恢复。 +fn channel_testing(ui: &egui::Ui, channel: &super::view_model::SettingsChannel) -> bool { + /// 快照里出现这么新的结果就算回包了。 + const FRESH_RESULT_SECONDS: i64 = 30; - // 模型字段:预设下拉 ⇄ 自定义输入。 - ui.horizontal(|ui| { + let id = egui::Id::unique(("settings-channel-testing", &channel.id)); + let Some(deadline) = ui.data(|data| data.get_temp::(id)) else { + return false; + }; + let expired = ui.input(|input| input.time) >= deadline; + let answered = channel + .last_check_age_seconds + .is_some_and(|age| age <= FRESH_RESULT_SECONDS); + if expired || answered { + ui.data_mut(|data| data.remove_temp::(id)); + return false; + } + true +} + +fn mark_channel_testing(ui: &egui::Ui, channel: &super::view_model::SettingsChannel) { + /// 与 `channel_testing` 的上限一致。 + const MAX_TESTING_SECONDS: f64 = 30.0; + let id = egui::Id::unique(("settings-channel-testing", &channel.id)); + let now = ui.input(|input| input.time); + ui.data_mut(|data| data.insert_temp(id, now + MAX_TESTING_SECONDS)); +} + +/// Tauri 的 `.ol-channel-edit-button`:铅笔 + 「编辑」+ 右尖括号。 +fn channel_edit_button(ui: &mut egui::Ui, lang: Lang, mut on_click: impl FnMut()) { + let label = tr_l10n(lang, "settings.channels.edit"); + let width = 14.0 + 6.0 + layout::text_width(ui, label, 11.0) + 6.0 + 14.0 + 12.0; + let (rect, response) = ui.allocate_exact_size(egui::vec2(width, 24.0), egui::Sense::click()); + if response.hovered() { + ui.painter() + .rect_filled(rect, egui::CornerRadius::same(8), theme::SURFACE_2); + } + let icon_size = 14.0; + let mut cursor = rect.left() + 6.0; + icons::draw_icon_sized( + ui, + egui::pos2(cursor + icon_size / 2.0, rect.center().y), + IconName::Pencil, + theme::INK_2, + icon_size, + ); + cursor += icon_size + 6.0; + ui.painter().text( + egui::pos2(cursor, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(11.0), + theme::INK_2, + ); + cursor += layout::text_width(ui, label, 11.0) + 6.0; + icons::draw_icon_sized( + ui, + egui::pos2(cursor + icon_size / 2.0, rect.center().y), + IconName::ChevronRight, + theme::INK_4, + icon_size, + ); + if response.clicked() { + on_click(); + } +} + +/// 「上次验证 …」状态块(Tauri `ChannelList` 的 `ChannelTestStatus`)。 +/// +/// 结构:`上次验证` + 结果(通过 + 耗时,或失败 + 错误 + 「验证失败不会自动停用」 +/// 说明)+ 多久以前;结果超过 24 小时再加一条过期提示。 +fn channel_status_line( + ui: &mut egui::Ui, + channel: &super::view_model::SettingsChannel, + lang: Lang, +) { + /// 与 Tauri `STALE_TEST_SECONDS` 一致:一天前的验证结果不可信。 + const STALE_AFTER_SECONDS: i64 = 24 * 60 * 60; + + ui.add_space(2.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; ui.label( - egui::RichText::new(tr_l10n(lang, "settings.providers.modelLabel")) - .size(11.5) + egui::RichText::new(tr_l10n(lang, "settings.channels.last_check")) + .size(11.0) .color(theme::INK_3), ); + let Some(passed) = channel.last_ok else { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.not_verified")) + .size(11.0) + .color(theme::INK_3), + ); + return; + }; + let stale = channel + .last_check_age_seconds + .is_some_and(|age| age > STALE_AFTER_SECONDS); + if passed { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.passed")) + .size(11.0) + .color(if stale { theme::INK_2 } else { theme::OK }), + ); + if let Some(ms) = channel.last_latency_ms { + ui.label( + egui::RichText::new(fmt_l10n(lang, "settings.channels.elapsed", &[&ms])) + .size(11.0) + .color(theme::INK_3), + ); + } + } else { + let error = channel.last_error.as_deref().unwrap_or_default(); + let text = if error.trim().is_empty() { + tr_l10n(lang, "settings.channels.failed_plain").to_string() + } else { + fmt_l10n(lang, "settings.channels.failed", &[&error]) + }; + ui.label(egui::RichText::new(text).size(11.0).color(theme::ERR)); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.failure_keeps_enabled")) + .size(11.0) + .color(theme::INK_2), + ); + } + if let Some(age) = channel.last_check_age_seconds { + ui.label( + egui::RichText::new(relative_check_age(lang, age)) + .size(11.0) + .color(theme::INK_3), + ); + if stale { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.stale_result")) + .size(11.0) + .color(theme::INK_2), + ); + } + } + }); +} + +/// 「刚刚 / N 分钟前 / N 小时前 / N 天前」,与 Tauri `relativeTime` 同规则。 +fn relative_check_age(lang: Lang, seconds: i64) -> String { + if seconds < 60 { + return tr_l10n(lang, "settings.channels.just_now").to_string(); + } + let minutes = seconds / 60; + if minutes < 60 { + return fmt_l10n(lang, "settings.channels.minutes_ago", &[&minutes]); + } + let hours = minutes / 60; + if hours < 24 { + return fmt_l10n(lang, "settings.channels.hours_ago", &[&hours]); + } + fmt_l10n(lang, "settings.channels.days_ago", &[&(hours / 24)]) +} + +/// 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); + + // 模型字段:预设下拉 ⇄ 自定义输入。 + modal_form_row(ui, tr_l10n(lang, "settings.providers.modelLabel"), |ui| { let show_presets = !editor.static_models.is_empty() && !editor.custom_model; if show_presets { // Tauri 会把当前值(不在清单里时)也塞进选项,否则下拉会显示空白。 @@ -2720,7 +3832,11 @@ fn provider_model_block( } let mut pick = editor.model.clone(); egui::ComboBox::from_id_salt("settings-provider-model-preset") - .selected_text(current) + .selected_text(if current.is_empty() { + &editor.default_model + } else { + current + }) .show_ui(ui, |ui| { for option in &options { if ui.selectable_label(current == option, option).clicked() { @@ -2740,37 +3856,69 @@ fn provider_model_block( } }); if pick != editor.model { + editor_mark_dirty(ui); 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)); - } + // 手输框与「回到预设列表」是上下两行(Tauri 的模型工具都在字段下方), + // 同一行会让按钮被挤出右边界、根本画不出来。 + ui.vertical(|ui| { + let mut draft = editor.model.clone(); + let id = egui::Id::unique("openless-settings-provider-model"); + let width = ui.available_width().min(420.0); + if layout::text_input_sized( + ui, + &mut draft, + id, + &editor.default_model, + width, + 38.0, + false, + ) + .changed() + { + editor_mark_dirty(ui); + 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, + actions.push(FrontendAction::SettingsProviderModelSelected( editor.default_model.clone(), )); } }); + if editor.auth == SettingsProviderAuth::TencentCloud { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.providers.tencentCloudNote")) + .size(11.5) + .color(theme::INK_4), + ); + } + // Tauri disables catalog fetching for these ASR credential shapes. + if matches!( + editor.auth, + SettingsProviderAuth::Volcengine + | SettingsProviderAuth::Xfyun + | SettingsProviderAuth::TencentCloud + ) { + return; + } // 可用模型:拉取(或看文档)→ 选择 → 写入模型字段并保存。 ui.add_space(2.0); if editor.has_models_url { @@ -2800,7 +3948,7 @@ fn provider_model_block( ); // Tauri 的「可用模型」是可搜索下拉(OrcaRouter 这类目录很大);这里同样在下拉里 // 带一个搜索框,并把过滤词存在 egui 自己的临时存储里。 - let filter_id = egui::Id::new("openless-settings-provider-model-filter"); + let filter_id = egui::Id::unique("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")) @@ -2836,22 +3984,110 @@ fn provider_model_block( }); } +#[allow(clippy::too_many_arguments)] fn provider_field( ui: &mut egui::Ui, + channel_id: &str, label: &str, value: &str, field: SettingsProviderField, password: bool, + lang: Lang, 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)); + provider_field_hint( + ui, channel_id, label, value, "", field, password, lang, actions, + ); +} + +#[allow(clippy::too_many_arguments)] +fn provider_field_hint( + ui: &mut egui::Ui, + channel_id: &str, + label: &str, + value: &str, + hint: &str, + field: SettingsProviderField, + password: bool, + lang: Lang, + actions: &mut Vec, +) { + /// Tauri 渠道弹窗里的输入框:整列宽、38px 高。 + const MODAL_INPUT_HEIGHT: f32 = 38.0; + let mut row = |ui: &mut egui::Ui| { + // 草稿按「渠道 + 字段」保存:光标与编辑状态由同一个缓冲维护,不再每帧从 + // 宿主回显的值重建(那会让光标错位、吞字)。 + // + // 早期只按字段类型做 key,没有渠道维度,于是: + // * 第一次渲染时若编辑器还没加载完(value 为空),这个空缓冲会被一直写回, + // 之后加载出来的 endpoint/model 再也进不了输入框——「编辑已有渠道读不到配置」; + // * 编辑完 A 再编辑 B,B 显示的是 A 的文字。 + // 现在额外记一份「播种时的宿主值」:宿主值变了就重新播种,没变就保留用户输入。 + let id = egui::Id::unique(( + "openless-settings-provider-field", + channel_id, + format!("{field:?}"), + )); + let draft_id = id.with("draft"); + let seed_id = id.with("seed"); + let seed = ui.data(|data| data.get_temp::(seed_id)); + let mut draft = match (ui.data(|data| data.get_temp::(draft_id)), seed) { + (Some(draft), Some(seed)) if seed == value => draft, + _ => value.to_string(), + }; + let width = ui.available_width().min(420.0); + // 密钥默认打码,但可以点一下看明文(Tauri `CredentialField` 的眼睛按钮)。 + let reveal_id = id.with("reveal"); + let mut revealed = ui + .data(|data| data.get_temp::(reveal_id)) + .unwrap_or(false); + let response = if password { + let mut render = |ui: &mut egui::Ui, password: bool| { + layout::text_input_sized( + ui, + &mut draft, + id, + hint, + width - 44.0, + MODAL_INPUT_HEIGHT, + password, + ) + }; + let response = render(ui, !revealed); + ui.horizontal(|ui| { + let label = if revealed { + tr_l10n(lang, "settings.providers.hideSecret") + } else { + tr_l10n(lang, "settings.providers.showSecret") + }; + if ui + .add(egui::Button::new( + egui::RichText::new(label).size(11.0).color(theme::INK_2), + )) + .clicked() + { + revealed = !revealed; + ui.data_mut(|data| data.insert_temp(reveal_id, revealed)); + } + }); + response + } else { + layout::text_input_sized(ui, &mut draft, id, hint, width, MODAL_INPUT_HEIGHT, false) + }; + if response.changed() { + editor_mark_dirty(ui); + actions.push(FrontendAction::SettingsProviderField(field, draft.clone())); } - }); + ui.data_mut(|data| { + data.insert_temp(draft_id, draft); + data.insert_temp(seed_id, value.to_string()); + }); + }; + if label.is_empty() { + row(ui); + } else { + modal_form_row(ui, label, row); + } } fn provider_small_button(ui: &mut egui::Ui, lang: Lang, key: &'static str, primary: bool) -> bool { @@ -2874,267 +4110,1034 @@ fn provider_small_button(ui: &mut egui::Ui, lang: Lang, key: &'static str, prima .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( +/// 自动保存的临时状态:存在即「有未落盘的改动」,值是防抖截止时刻(egui 时间轴)。 +fn editor_autosave_id() -> egui::Id { + egui::Id::unique("openless-channel-editor-autosave") +} + +/// Match Tauri's debounce: persist 300ms after the last edit. +fn editor_mark_dirty(ui: &egui::Ui) { + const AUTO_SAVE_DELAY: f64 = 0.3; + let deadline = ui.input(|input| input.time) + AUTO_SAVE_DELAY; + ui.data_mut(|data| data.insert_temp(editor_autosave_id(), deadline)); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(300)); +} + +/// 到点就落盘。返回是否推了保存。 +fn editor_auto_save(ui: &egui::Ui, actions: &mut Vec) -> bool { + let Some(deadline) = ui.data(|data| data.get_temp::(editor_autosave_id())) else { + return false; + }; + let now = ui.input(|input| input.time); + if now < deadline { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis( + ((deadline - now) * 1000.0).clamp(16.0, 300.0) as u64, + )); + return false; + } + ui.data_mut(|data| data.remove_temp::(editor_autosave_id())); + actions.push(FrontendAction::SettingsProviderSave); + true +} + +/// 关闭编辑器:先把还没落盘的改动冲掉(Tauri 的 flush-on-leave),再关。 +fn editor_close(ui: &egui::Ui, actions: &mut Vec) { + if ui + .data(|data| data.get_temp::(editor_autosave_id())) + .is_some() + { + ui.data_mut(|data| data.remove_temp::(editor_autosave_id())); + actions.push(FrontendAction::SettingsProviderSave); + } + actions.push(FrontendAction::SettingsProviderClose); +} + +/// Tauri 的渠道编辑器在桌面端是 **embedded**:`ChannelModal` 被 portal 进右侧内容栏 +/// (`.ol-settings-content-pane`),靠 `.ol-channel-dialog-embedded { position: absolute; +/// inset: 0; background: var(--ol-sidebar-bg) }` 整栏盖住——不是全窗口遮罩加居中卡片。 +/// +/// 所以这里是「在右栏里覆盖一层」:灰底(`--ol-sidebar-bg`)+ 头部(返回按钮 + 标题 + +/// 渠道种类徽章 + 自动保存说明)+ 中部滚动区(白底圆角表单卡:服务连接 → 供应商 → +/// 渠道名称 → 凭据 → 模型)+ 底部(删除渠道 / 完成)。 +fn channel_editor_panel( + ui: &mut egui::Ui, + vm: &FrontendViewModel, + actions: &mut Vec, +) { + /// Tauri `.ol-channel-dialog-embedded .ol-channel-dialog-header { padding: 18px 24px }` + /// + 标题行与说明行。 + const HEADER_HEIGHT: f32 = 92.0; + /// Tauri `.ol-channel-dialog-embedded .ol-channel-dialog-footer { padding: 16px 24px }` + /// + 34px 按钮 + 1px 顶线。 + const FOOTER_HEIGHT: f32 = 67.0; + /// 左右内边距(Tauri 的 `24px`)。 + const SIDE_PADDING: f32 = 24.0; + /// `.ol-channel-dialog-embedded .ol-channel-form-sheet { padding: 18px }`。 + const SHEET_PADDING: f32 = 18.0; + + let Some(editor) = vm.provider_editor.as_ref() else { + return; + }; + let lang = vm.lang; + if editor.busy { + ui.disable(); + } + // Tauri:字段修改后自动保存(300ms 防抖 + 失焦/离开前 flush)。这里到点就推一次 + // `SettingsProviderSave`;没到点则请求下一帧再看。 + let protocol_valid = editor.protocol.validate().is_ok(); + if protocol_valid && !editor.busy { + editor_auto_save(ui, actions); + } + let edited = vm + .channels + .iter() + .position(|channel| channel.id == editor.channel_id); + let column = ui.max_rect(); + ui.painter().rect_filled( + column, + egui::CornerRadius { + nw: 0, + sw: 0, + ne: 14, + se: 14, + }, + theme::RAIL_BG, + ); + + // ── 头部:返回按钮 + 标题/徽章 + 说明 ──────────────────────────────────── + let header = egui::Rect::from_min_size( + column.min, + egui::vec2(column.width(), HEADER_HEIGHT.min(column.height())), + ); + ui.painter().rect_filled( + egui::Rect::from_min_max( + egui::pos2(header.left(), header.bottom() - 1.0), + header.right_bottom(), + ), + egui::CornerRadius::ZERO, + theme::LINE, + ); + // Tauri `.ol-settings-back`:32×30、白底、0.5px 强描边、圆角 8。 + let back = egui::Rect::from_min_size( + egui::pos2(column.left() + SIDE_PADDING, column.top() + 18.0), + egui::vec2(32.0, 30.0), + ); + let back_response = ui.interact( + back, + egui::Id::unique("openless-channel-editor-back"), + egui::Sense::click(), + ); + ui.painter().rect_filled( + back, + egui::CornerRadius::same(8), + if back_response.hovered() { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + back, + egui::CornerRadius::same(8), + egui::Stroke::new(0.8, theme::LINE_STRONG), + egui::StrokeKind::Inside, + ); + icons::draw_icon_sized( + ui, + back.center(), + IconName::ChevronLeft, + if back_response.hovered() { + theme::INK + } else { + theme::INK_2 + }, + 18.0, + ); + if back_response.clicked() && protocol_valid { + editor_close(ui, actions); + } + let heading_left = back.right() + 14.0; + let title_painter = ui.painter().clone(); + let title_y = column.top() + 18.0; + let title_key = if editor.is_draft { + "settings.channels.create_title" + } else { + "settings.channels.edit_title" + }; + title_painter.text( + egui::pos2(heading_left, title_y), + egui::Align2::LEFT_TOP, + tr_l10n(lang, title_key), + egui::FontId::proportional(21.0), + theme::INK, + ); + let title_width = layout::text_width(ui, tr_l10n(lang, title_key), 21.0); + let kind_title = tr_l10n( + lang, + if editor.is_asr { + "settings.channels.asr_title" + } else { + "settings.channels.llm_title" + }, + ); + let badge_galley = title_painter.layout_no_wrap( + kind_title.to_owned(), + egui::FontId::proportional(10.5), + theme::INK_3, + ); + let badge_padding = egui::vec2(7.0, 2.0); + let badge = egui::Rect::from_min_size( + egui::pos2(heading_left + title_width + 12.0, title_y + 6.0), + badge_galley.size() + badge_padding * 2.0, + ); + title_painter.rect_stroke( + badge, + egui::CornerRadius::same(5), + egui::Stroke::new(1.0, theme::LINE), + egui::StrokeKind::Inside, + ); + title_painter.galley(badge.min + badge_padding, badge_galley, theme::INK_3); + title_painter.text( + egui::pos2(heading_left, title_y + 32.0), + egui::Align2::LEFT_TOP, + tr_l10n(lang, "settings.channels.auto_save_hint"), + egui::FontId::proportional(12.0), + theme::INK_3, + ); + + // ── 底部:删除渠道(两步确认)+ 完成 ──────────────────────────────────── + let footer = egui::Rect::from_min_size( + egui::pos2( + column.left(), + column.bottom() - FOOTER_HEIGHT.min(column.height()), + ), + egui::vec2(column.width(), FOOTER_HEIGHT.min(column.height())), + ); + ui.painter() + .rect_filled(footer, egui::CornerRadius::ZERO, theme::RAIL_BG); + ui.painter().rect_filled( + egui::Rect::from_min_max( + footer.left_top(), + egui::pos2(footer.right(), footer.top() + 1.0), + ), + egui::CornerRadius::ZERO, + theme::LINE, + ); + + // ── 中部:滚动区里的白色表单卡 ───────────────────────────────────────── + let body = egui::Rect::from_min_max( + egui::pos2(column.left(), header.bottom()), + egui::pos2(column.right(), footer.top()), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(body), |ui| { + egui::ScrollArea::vertical() + .id_salt("openless-channel-editor-body") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.add_space(8.0); + let inner = egui::Rect::from_min_max( + egui::pos2(body.left() + SIDE_PADDING, ui.cursor().top()), + egui::pos2( + body.right() - SIDE_PADDING, + body.bottom().max(ui.cursor().top() + 40.0), + ), + ); + ui.scope_builder(egui::UiBuilder::new().max_rect(inner), |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(SHEET_PADDING as i8)) + .show(ui, |ui| { + ui.set_width((inner.width() - SHEET_PADDING * 2.0).max(80.0)); + provider_editor_body( + ui, + editor, + &vm.channel_providers, + edited, + lang, + vm.settings.llm_thinking_enabled, + actions, + ); + }); + }); + ui.add_space(22.0); + }); + }); + + // ── 底部按钮 ─────────────────────────────────────────────────────────── + let confirm_id = egui::Id::unique(("settings-channel-delete", &editor.channel_id)); + let confirming = ui.data(|data| data.get_temp::(confirm_id).unwrap_or(false)); + let mut footer_ui = ui.new_child( + egui::UiBuilder::new() + .max_rect(footer.shrink2(egui::vec2(SIDE_PADDING, 16.0))) + .layout(egui::Layout::left_to_right(egui::Align::Center)), + ); + if let Some(index) = edited { + if confirming { + footer_ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.delete_confirm")) + .size(12.0) + .color(theme::INK_2), + ); + footer_ui.add_space(12.0); + if danger_button( + &mut footer_ui, + tr_l10n(lang, "settings.channels.confirm_delete"), + Some(IconName::Trash), + ) { + actions.push(FrontendAction::SettingsChannelDelete(index)); + ui.data_mut(|data| data.remove_temp::(confirm_id)); + } + footer_ui.add_space(8.0); + if provider_small_button(&mut footer_ui, lang, "common.cancel", false) { + footer_ui.data_mut(|data| data.remove_temp::(confirm_id)); + } + } else if danger_button( + &mut footer_ui, + tr_l10n(lang, "settings.channels.delete"), + Some(IconName::Trash), + ) { + footer_ui.data_mut(|data| data.insert_temp(confirm_id, true)); + } + } + if editor.busy { + footer_ui.add_space(10.0); + footer_ui.label( + egui::RichText::new(tr_l10n(lang, "common.loading")) + .size(11.5) + .color(theme::INK_4), + ); + } + // 右侧组(Tauri `.ol-channel-dialog-footer-end`):自动保存提示 + 保存/清除 + 完成。 + let hint = footer_ui.painter().layout_no_wrap( + tr_l10n(lang, "modal.auto_save_hint").to_owned(), + egui::FontId::proportional(11.5), + theme::INK_3, + ); + let done_width = + layout::text_width(&footer_ui, tr_l10n(lang, "settings.channels.done"), 12.0) + 30.0; + let clear_width = + layout::text_width(&footer_ui, tr_l10n(lang, "btn.clear_secret"), 11.5) + 22.0; + let mut cursor = footer.right() - SIDE_PADDING - done_width; + let done_rect = egui::Rect::from_min_size( + egui::pos2(cursor, footer.center().y - 17.0), + egui::vec2(done_width, 34.0), + ); + cursor -= 16.0 + clear_width; + let clear_rect = egui::Rect::from_min_size( + egui::pos2(cursor, footer.center().y - 14.0), + egui::vec2(clear_width, 28.0), + ); + cursor -= 16.0 + hint.size().x; + if cursor > footer.left() + SIDE_PADDING + 200.0 { + footer_ui.painter().galley( + egui::pos2(cursor, footer.center().y - hint.size().y / 2.0), + hint, + theme::INK_3, + ); + } + let done = footer_ui.interact( + done_rect, + egui::Id::unique("openless-channel-editor-done"), + egui::Sense::click(), + ); + footer_ui.painter().rect_filled( + done_rect, + egui::CornerRadius::same(10), + if done.hovered() { + theme::BLUE_HOVER + } else { + theme::BLUE + }, + ); + footer_ui.painter().text( + done_rect.center(), + egui::Align2::CENTER_CENTER, + tr_l10n(lang, "settings.channels.done"), + egui::FontId::proportional(12.0), + theme::SURFACE, + ); + if done.clicked() && protocol_valid { + editor_close(ui, actions); + } + let clear = footer_ui.interact( + clear_rect, + egui::Id::unique("openless-channel-editor-clear"), + egui::Sense::click(), + ); + paint_ghost_small_button( + &footer_ui, + clear_rect, + clear.hovered(), + tr_l10n(lang, "btn.clear_secret"), + 11.5, + ); + if clear.clicked() { + actions.push(FrontendAction::SettingsProviderClearSecrets); + } +} + +/// 次要按钮(白底 + 0.8px 描边 + 圆角 8),用于弹窗底部的保存/清除。 +fn paint_ghost_small_button( + ui: &egui::Ui, + rect: egui::Rect, + hovered: bool, + label: &str, + size: f32, +) { + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + if hovered { + theme::SURFACE_2 + } else { + theme::SURFACE + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(0.8, theme::LINE), + egui::StrokeKind::Inside, + ); + ui.painter().text( + rect.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(size), + theme::INK_2, + ); +} + +/// 危险按钮(Tauri `.ol-channel-delete-button`:淡红底、红描边、红字 + 垃圾桶图标)。 +fn danger_button(ui: &mut egui::Ui, label: &str, icon: Option) -> bool { + let text_width = layout::text_width(ui, label, 12.5); + let icon_space = if icon.is_some() { 21.0 } else { 0.0 }; + let (rect, response) = ui.allocate_exact_size( + egui::vec2(text_width + icon_space + 24.0, 34.0), + egui::Sense::click(), + ); + ui.painter().rect_filled( + rect, + egui::CornerRadius::same(8), + if response.hovered() { + theme::DANGER_FILL_HOVER + } else { + theme::DANGER_FILL + }, + ); + ui.painter().rect_stroke( + rect, + egui::CornerRadius::same(8), + egui::Stroke::new(1.0, theme::DANGER_BORDER), + egui::StrokeKind::Inside, + ); + let mut text_left = rect.left() + 12.0; + if let Some(icon) = icon { + icons::draw_icon_sized( + ui, + egui::pos2(text_left + 7.5, rect.center().y), + icon, + theme::ERR, + 15.0, + ); + text_left += 21.0; + } + ui.painter().text( + egui::pos2(text_left, rect.center().y), + egui::Align2::LEFT_CENTER, + label, + egui::FontId::proportional(12.5), + theme::ERR, + ); + response.clicked() +} + +/// 弹窗正文:Tauri 的 `.ol-channel-form-sheet`——`服务连接` 分区 + 供应商 + 名称, +/// 之后是 Core 决定形状的凭据字段与模型块。 +fn provider_editor_body( ui: &mut egui::Ui, editor: &SettingsProviderEditor, + providers: &[SettingsChannelProvider], + _channel_index: Option, lang: Lang, + llm_thinking_enabled: bool, 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), + // 分区标题:云图标 + 「服务连接」(Tauri `ChannelSectionHeading`)。 + ui.horizontal(|ui| { + icons::draw_icon_sized( + ui, + ui.cursor().min + egui::vec2(7.0, 8.0), + IconName::Cloud, + theme::INK_3, + 14.0, + ); + ui.add_space(22.0); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.connection_title")) + .size(13.0) + .color(theme::INK) + .strong(), + ); + }); + ui.add_space(6.0); + if !providers.is_empty() { + modal_form_row( + ui, + tr_l10n(lang, "settings.channels.provider_label"), + |ui| { + let selected = providers + .iter() + .position(|provider| provider.provider_type == editor.provider_type) + .unwrap_or(0); + let mut picked = selected; + egui::ComboBox::from_id_salt(("settings-editor-provider", &editor.channel_id)) + .selected_text(&editor.provider) + .width(ui.available_width().min(420.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 { + channel_id: editor.channel_id.clone(), + provider_type: providers[picked].provider_type.clone(), + }); + } + }, + ); + } + modal_form_row(ui, tr_l10n(lang, "settings.channels.name_label"), |ui| { + provider_field( + ui, + &editor.channel_id, + "", + &editor.name, + SettingsProviderField::Name, + false, + lang, + actions, + ); + }); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.channels.name_hint")) + .size(11.5) + .color(theme::INK_3), + ); + ui.add_space(4.0); + provider_protocol_block(ui, editor, lang, actions); + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.credentials")) + .size(11.5) + .color(theme::INK_3), + ); + match editor.auth { + SettingsProviderAuth::None => { + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.no_cloud_note")) + .size(11.5) + .color(theme::INK_4), + ); + } + SettingsProviderAuth::OAuth => { + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.oauth_note")) + .size(11.5) + .color(theme::INK_4), + ); + } + SettingsProviderAuth::Volcengine => { + let mut mode = editor.auth_mode.clone(); + modal_form_row( + ui, + tr_l10n(lang, "settings.providers.volcengineAuthModeLabel"), + |ui| { + 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 { + editor_mark_dirty(ui); + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::AuthMode, + mode.clone(), + )); + } + if mode == "api_key" { + provider_field( + ui, + &editor.channel_id, + tr_l10n(lang, "settings.providers.volcengineApiKeyLabel"), + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + lang, + actions, ); - ui.label( - egui::RichText::new(tr_l10n(lang, "providers.editing")) - .size(10.5) - .color(theme::INK_4), + } else { + provider_field( + ui, + &editor.channel_id, + tr_l10n(lang, "settings.providers.volcengineAppKeyLabel"), + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + lang, + actions, ); - if editor.busy { - ui.label( - egui::RichText::new(tr_l10n(lang, "common.loading")) - .size(10.5) - .color(theme::INK_4), - ); - } - }); + provider_field( + ui, + &editor.channel_id, + tr_l10n(lang, "settings.providers.volcengineAccessKeyLabel"), + &editor.secondary_secret, + SettingsProviderField::SecondarySecret, + true, + lang, + actions, + ); + } provider_field( ui, - tr_l10n(lang, "providers.name"), - &editor.name, - SettingsProviderField::Name, + &editor.channel_id, + tr_l10n(lang, "settings.providers.volcengineResourceIdLabel"), + &editor.resource_id, + SettingsProviderField::ResourceId, false, + lang, + actions, + ); + } + SettingsProviderAuth::Xfyun => { + provider_field( + ui, + &editor.channel_id, + tr_l10n(lang, "settings.providers.xfyunAppIdLabel"), + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + lang, + actions, + ); + provider_field( + ui, + &editor.channel_id, + tr_l10n(lang, "settings.providers.xfyunApiKeyLabel"), + &editor.secondary_secret, + SettingsProviderField::SecondarySecret, + true, + lang, + actions, + ); + } + SettingsProviderAuth::TencentCloud => { + provider_field( + ui, + &editor.channel_id, + tr_l10n(lang, "settings.providers.tencentCloudAppIdLabel"), + &editor.resource_id, + SettingsProviderField::ResourceId, + false, + lang, + actions, + ); + provider_field( + ui, + &editor.channel_id, + tr_l10n(lang, "settings.providers.tencentCloudSecretIdLabel"), + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + lang, + actions, + ); + provider_field( + ui, + &editor.channel_id, + tr_l10n(lang, "settings.providers.tencentCloudSecretKeyLabel"), + &editor.secondary_secret, + SettingsProviderField::SecondarySecret, + true, + lang, actions, ); + } + SettingsProviderAuth::Other => { ui.label( - egui::RichText::new(tr_l10n(lang, "providers.credentials")) - .size(11.0) + egui::RichText::new(tr_l10n(lang, "providers.core_note")) + .size(11.5) .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(); - } + } + SettingsProviderAuth::ApiKey => { + provider_field( + ui, + &editor.channel_id, + if editor.api_key_optional { + tr_l10n(lang, "settings.providers.apiKeyOptionalLabel") + } else { + tr_l10n(lang, "settings.providers.apiKeyLabel") + }, + &editor.primary_secret, + SettingsProviderField::PrimarySecret, + true, + lang, + actions, + ); + if !editor.endpoint_presets.is_empty() { + modal_form_row( + ui, + tr_l10n(lang, "settings.providers.volcengineServiceLabel"), + |ui| { + let selected = editor.endpoint_presets.iter().position(|preset| { + openless_core::provider_rules::matches_endpoint_preset( + if editor.endpoint.trim().is_empty() { + &editor.default_endpoint + } else { + &editor.endpoint + }, + &preset.endpoint, + ) + }); + egui::ComboBox::from_id_salt(( + "provider-endpoint-preset", + &editor.channel_id, + )) + .selected_text( + selected + .map(|index| editor.endpoint_presets[index].name.as_str()) + .unwrap_or(&editor.endpoint), + ) + .show_ui(ui, |ui| { + for (index, preset) in editor.endpoint_presets.iter().enumerate() { + if ui + .selectable_label(selected == Some(index), &preset.name) + .clicked() + { + editor_mark_dirty(ui); + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::Endpoint, + preset.endpoint.clone(), + )); + ui.close(); } - }); - }); - if mode != editor.auth_mode { - actions.push(FrontendAction::SettingsProviderField( - SettingsProviderField::AuthMode, - mode.clone(), - )); - } - if mode == "api_key" { - provider_field( - ui, - "API Key", - &editor.primary_secret, - SettingsProviderField::PrimarySecret, - true, - actions, - ); - } else { - provider_field( - ui, - "APP ID", - &editor.primary_secret, - SettingsProviderField::PrimarySecret, - true, - actions, - ); - provider_field( - ui, - "Access Token", - &editor.secondary_secret, - SettingsProviderField::SecondarySecret, - true, - actions, - ); - } - provider_field( - ui, - "Resource ID", - &editor.resource_id, - SettingsProviderField::ResourceId, - false, - actions, - ); + } + }); + }, + ); + } + provider_field_hint( + ui, + &editor.channel_id, + tr_l10n(lang, "settings.providers.baseUrlLabel"), + &editor.endpoint, + &editor.default_endpoint, + SettingsProviderField::Endpoint, + false, + lang, + actions, + ); + if editor.endpoint.trim().is_empty() + && !editor.default_endpoint.is_empty() + && provider_small_button(ui, lang, "settings.providers.fillDefault", false) + { + editor_mark_dirty(ui); + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::Endpoint, + editor.default_endpoint.clone(), + )); + } + } + } + ui.add_space(6.0); + if !editor.is_asr + && matches!( + editor.provider_type.as_str(), + "custom" | "custom_responses" | "custom_messages" + ) + { + modal_form_row( + ui, + tr_l10n(lang, "settings.providers.extraHeadersLabel"), + |ui| { + let mut headers = editor.protocol.extra_headers.clone(); + if ui + .add( + egui::TextEdit::multiline(&mut headers) + .id(egui::Id::unique("settings-provider-extra-headers")) + .desired_width(ui.available_width().min(420.0)) + .desired_rows(3) + .hint_text(tr_l10n(lang, "settings.providers.extraHeadersPlaceholder")), + ) + .changed() + { + editor_mark_dirty(ui); + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::ExtraHeaders, + headers, + )); } - SettingsProviderAuth::Xfyun => { - provider_field( - ui, - "AppID", - &editor.primary_secret, - SettingsProviderField::PrimarySecret, - true, - actions, - ); + ui.label( + egui::RichText::new(tr_l10n(lang, "providers.extra_headers_hint")) + .size(11.5) + .color(theme::INK_4), + ); + }, + ); + } + provider_model_block(ui, editor, lang, actions); + if !editor.is_asr { + let mut changed = false; + toggle_row( + ui, + tr_l10n(lang, "settings.providers.thinkingModeLabel"), + tr_l10n(lang, "settings.providers.thinkingModeHint"), + llm_thinking_enabled, + || changed = true, + ); + if changed { + actions.push(FrontendAction::SettingsToggle(SettingsField::LlmThinking)); + } + if matches!( + editor.provider_type.as_str(), + "custom" | "custom_responses" | "custom_messages" + ) { + modal_form_row( + ui, + tr_l10n(lang, "settings.providers.temperatureLabel"), + |ui| { provider_field( ui, - "API Key", - &editor.secondary_secret, - SettingsProviderField::SecondarySecret, - true, + &editor.channel_id, + "", + &editor.protocol.temperature, + SettingsProviderField::Temperature, + false, + lang, 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, + egui::RichText::new(tr_l10n( + lang, + "settings.providers.temperaturePlaceholder", + )) + .size(11.5) + .color(theme::INK_4), ); - } - } - 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( +/// The same non-secret accounts and conditional controls as Tauri LlmProtocolFields. +fn provider_protocol_block( ui: &mut egui::Ui, - vm: &mut FrontendViewModel, + editor: &SettingsProviderEditor, + lang: Lang, 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(); + use openless_core::llm_protocol::LlmRequestFormat; + let draft = &editor.protocol; + if editor.is_asr || draft.formats.is_empty() { + return; + } + let format = draft.format(); + modal_form_row( + ui, + tr_l10n(lang, "settings.providers.requestFormatLabel"), + |ui| { + egui::ComboBox::from_id_salt("provider-request-format") + .selected_text(match format { + LlmRequestFormat::ChatCompletions => "Chat Completions", + LlmRequestFormat::Responses => "Responses", + LlmRequestFormat::Messages => "Messages", + }) + .show_ui(ui, |ui| { + for option in &draft.formats { + let (label, value) = match option { + LlmRequestFormat::ChatCompletions => { + ("Chat Completions", "chat_completions") + } + LlmRequestFormat::Responses => ("Responses", "responses"), + LlmRequestFormat::Messages => ("Messages", "messages"), + }; + if ui.selectable_label(format == *option, label).clicked() { + editor_mark_dirty(ui); + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::RequestFormat, + value.into(), + )); + ui.close(); + } } - } - }); - if new_selection != selected { - actions.push(FrontendAction::SettingsChannelProvider(new_selection)); - } - // 推的是编辑后的值:推编辑前的拷贝会让宿主把旧值写回字段,每敲一个字 - // 就被回灌一次(渠道名、下划线搜索框都踩过这个坑)。 - let response = layout::text_input( + }); + }, + ); + if format == LlmRequestFormat::Responses { + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.providers.responsesThinkingHint")) + .size(11.5) + .color(theme::INK_4), + ); + } + if editor.provider_type == "ark" { + let mut changed = false; + toggle_row( ui, - &mut vm.channel_form_name, - egui::Id::new("openless-settings-channel-name"), - tr_l10n(lang, "settings.channels.name_placeholder"), - 200.0, - false, + tr_l10n(lang, "settings.providers.serviceTierLabel"), + tr_l10n(lang, "settings.providers.serviceTierHint"), + draft.values[4] == "fast", + || changed = true, ); - if response.changed() { - actions.push(FrontendAction::SettingsChannelName( - vm.channel_form_name.clone(), + if changed { + editor_mark_dirty(ui); + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::ServiceTier, + if draft.values[4] == "fast" { + String::new() + } else { + "fast".into() + }, )); } - 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)); + } + if format == LlmRequestFormat::Messages { + let budget = draft.values[1] == "budget"; + modal_form_row( + ui, + tr_l10n(lang, "settings.providers.messagesThinkingLabel"), + |ui| { + egui::ComboBox::from_id_salt("provider-messages-thinking") + .selected_text(tr_l10n( + lang, + if budget { + "settings.providers.thinkingBudget" + } else { + "settings.providers.thinkingAdaptive" + }, + )) + .show_ui(ui, |ui| { + for (value, key) in [ + ("adaptive", "settings.providers.thinkingAdaptive"), + ("budget", "settings.providers.thinkingBudget"), + ] { + if ui + .selectable_label(budget == (value == "budget"), tr_l10n(lang, key)) + .clicked() + { + editor_mark_dirty(ui); + actions.push(FrontendAction::SettingsProviderField( + SettingsProviderField::MessagesThinking, + value.into(), + )); + ui.close(); + } + } + }); + }, + ); + for (index, field, key, placeholder) in [ + ( + 2, + SettingsProviderField::MaxTokens, + "settings.providers.maxTokensLabel", + "8192", + ), + ( + 3, + SettingsProviderField::ThinkingBudget, + "settings.providers.thinkingBudgetLabel", + "1024", + ), + ] { + if index == 3 && !budget { + continue; + } + modal_form_row(ui, tr_l10n(lang, key), |ui| { + let mut value = draft.values[index].clone(); + if ui + .add(egui::TextEdit::singleline(&mut value).hint_text(placeholder)) + .changed() + { + editor_mark_dirty(ui); + actions.push(FrontendAction::SettingsProviderField(field, value)); + } + }); } - }); - ui.label( - egui::RichText::new(tr_l10n(lang, "settings.channels.name_hint")) - .size(11.0) - .color(theme::INK_4), - ); + ui.label( + egui::RichText::new(tr_l10n(lang, "settings.providers.messagesThinkingHint")) + .size(11.5) + .color(theme::INK_4), + ); + } + if let Err(error) = draft.validate() { + let key = match error.message.as_str() { + "llmRequestFormatInvalid" => "settings.providers.llmRequestFormatInvalid", + "llmThinkingModeInvalid" => "settings.providers.llmThinkingModeInvalid", + "llmTokenLimitInvalid" => "settings.providers.llmTokenLimitInvalid", + "llmThinkingBudgetInvalid" => "settings.providers.llmThinkingBudgetInvalid", + "llmServiceTierInvalid" => "settings.providers.llmServiceTierInvalid", + "llmProtocolHeaderConflict" => "settings.providers.llmProtocolHeaderConflict", + _ => "common.operationFailed", + }; + ui.label( + egui::RichText::new(tr_l10n(lang, key)) + .size(12.0) + .color(theme::ERR), + ); + } +} + +/// Tauri `.ol-channel-form-row`:`grid-template-columns: 140px minmax(0, 1fr)`, +/// 标签与控件同一行、标签顶部对齐(label `padding-top: 9px`)。 +fn modal_form_row( + ui: &mut egui::Ui, + label: &str, + control: impl FnOnce(&mut egui::Ui) -> R, +) -> R { + /// Tauri 的标签列宽。 + const LABEL_WIDTH: f32 = 140.0; + /// Tauri `gap: 8px 20px`。 + const COLUMN_GAP: f32 = 20.0; + /// Tauri 渠道弹窗里的控件高 38,标签用 `padding-top: 9px` 与它对齐;标签格就取 + /// 同一行高,不要用 `available_height()`——在纵向布局里那可能是整屏高度,会把 + /// 这一行撑开、后面的控件被挤出可见区。 + const ROW_HEIGHT: f32 = 38.0; + ui.horizontal(|ui| { + let (label_rect, _) = + ui.allocate_exact_size(egui::vec2(LABEL_WIDTH, ROW_HEIGHT), egui::Sense::hover()); + ui.painter().text( + egui::pos2(label_rect.left(), label_rect.top() + 9.0), + egui::Align2::LEFT_TOP, + label, + egui::FontId::proportional(13.0), + theme::INK_2, + ); + ui.add_space(COLUMN_GAP); + control(ui) + }) + .inner } -/// A row whose value is a button that opens a link / performs an action. fn link_row( ui: &mut egui::Ui, label: &str, @@ -3160,26 +5163,8 @@ fn link_row( // ── 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. +/// 实验性功能卡片:标题旁带一枚徽章,悬停给说明(Tauri 的 +/// `ExperimentalSectionTitle`)。多模态管线与 Beta 开关都用它。 fn experimental_card( ui: &mut egui::Ui, title: &str, @@ -3223,10 +5208,33 @@ fn experimental_card( ui.add_space(16.0); } +/// 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. +pub(super) 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 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)); +pub(super) fn card_group(ui: &mut egui::Ui, title: &str, contents: impl FnOnce(&mut egui::Ui)) { + let id = egui::Id::unique(("openless-settings-group", title)); let mut open = ui.data(|data| data.get_temp::(id).unwrap_or(true)); egui::Frame::new() .fill(theme::SURFACE) @@ -3302,10 +5310,11 @@ fn card_header( /// blue underline, required services carry a red/yellow status dot). fn service_tabs( ui: &mut egui::Ui, - items: &[(&str, Option)], + items: &[(&str, Option, bool)], active: usize, + lang: Lang, ) -> Option { - let height = 38.0; + let height = 62.0; let (rect, _) = ui.allocate_exact_size( egui::vec2(ui.available_width(), height), egui::Sense::hover(), @@ -3317,37 +5326,66 @@ fn service_tabs( ], egui::Stroke::new(1.0, theme::LINE), ); + let width = + (rect.width() - 4.0 * items.len().saturating_sub(1) as f32) / items.len().max(1) as f32; 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 }; + for (index, (label, dot, inactive)) in items.iter().enumerate() { 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(), + ui.scope_id().with(("openless-service-tab", index)), + if *inactive { + egui::Sense::hover() + } else { + egui::Sense::click() + }, ); let selected = index == active; - let mut text_x = tab.left() + 14.0; + let color = if *inactive { + theme::INK_4 + } else if selected { + theme::BLUE + } else if response.hovered() { + theme::INK + } else { + theme::INK_3 + }; + let dot_width = if dot.is_some() { 12.0 } else { 0.0 }; + let galley = ui.painter().layout( + (*label).to_owned(), + egui::FontId::proportional(13.0), + color, + (width - 8.0 - dot_width).max(1.0), + ); + let left = tab.center().x - (galley.size().x + dot_width) / 2.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; + .circle_filled(egui::pos2(left + 3.5, tab.top() + 19.0), 3.5, *color); } - 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 - }, + ui.painter().galley( + egui::pos2(left + dot_width, tab.top() + 10.0), + galley, + color, ); + if *inactive { + let label = tr_l10n(lang, "modal.service_views.inactive"); + let size = egui::vec2( + (layout::text_width(ui, label, 10.0) + 8.0).min(width - 4.0), + 17.0, + ); + let badge = + egui::Rect::from_center_size(egui::pos2(tab.center().x, tab.bottom() - 15.0), size); + ui.painter().rect_filled(badge, 4, theme::WARN_SOFT); + ui.painter().text( + badge.center(), + egui::Align2::CENTER_CENTER, + label, + egui::FontId::proportional(10.0), + theme::WARN, + ); + response.clone().on_hover_text(label); + } if selected { ui.painter().line_segment( [ @@ -3357,10 +5395,10 @@ fn service_tabs( egui::Stroke::new(2.0, theme::BLUE), ); } - if response.clicked() { + if !*inactive && response.clicked() { clicked = Some(index); } - x += width + 6.0; + x += width + 4.0; } clicked } @@ -3394,49 +5432,19 @@ fn help_dot(ui: &mut egui::Ui, hint: &str) -> egui::Response { 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, + egui::FontId::proportional(10.0), + theme::INK_4, ); - response.clicked() + response.on_hover_text(hint) } -fn toggle_row(ui: &mut egui::Ui, label: &str, desc: &str, value: bool, on_toggle: impl FnOnce()) { +pub(super) 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() { @@ -3609,12 +5617,35 @@ fn remote_mode_row(ui: &mut egui::Ui, lang: Lang, selected: usize, on_select: im 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()); + // Half-size Siri stage, matching Tauri's 230×90 preview. Keep its + // aspect ratio instead of squeezing the ribbons into a pill. + let size = if style == 0 { + egui::vec2(230.0, 90.0) + } else { + egui::vec2(164.0, 42.0) + }; + let (rect, _) = ui.allocate_exact_size(size, 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)); + let dt = ui.input(|input| input.stable_dt); + let clock = super::siri_wgpu::tick( + ui.ctx(), + "settings-siri-preview", + super::siri_wgpu::SiriDrive { + level: 0.18, + ..Default::default() + }, + dt, + ); + let _ = super::siri_wgpu::paint( + ui, + rect, + super::siri_wgpu::SiriEffect::wave(clock.time, clock.level), + ); + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(16)); } 1 => { let pill = egui::Rect::from_center_size(rect.center(), egui::vec2(152.0, 34.0)); @@ -3664,7 +5695,7 @@ fn text_row(ui: &mut egui::Ui, label: &str, desc: &str, value: &str) { }); } -fn text_edit_row( +pub(super) fn text_edit_row( ui: &mut egui::Ui, label: &str, desc: &str, @@ -3676,44 +5707,57 @@ fn text_edit_row( let response = layout::text_input( ui, value, - egui::Id::new(("openless-settings-text", label)), + egui::Id::unique(("openless-settings-text", label)), hint, ui.available_width().min(300.0), false, ); + if response.gained_focus() { + response.scroll_to_me(Some(egui::Align::Center)); + } if response.changed() { on_change(value); } }); } -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( +/// 多行文本行(Tauri 的 textarea 设置项:线索词、系统提示词)。 +fn text_area_row( ui: &mut egui::Ui, label: &str, desc: &str, - state: super::view_model::PermissionState, - lang: Lang, + value: &mut String, + hint: &str, + rows: usize, + on_change: impl FnOnce(&str), ) { - 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); + row_desc(ui, label, desc, |ui| { + let width = ui.available_width().clamp(220.0, 520.0); + let response = ui.add( + egui::TextEdit::multiline(value) + .id(egui::Id::unique(("openless-settings-textarea", label))) + .desired_rows(rows) + .desired_width(width) + .hint_text(egui::RichText::new(hint).size(12.0).color(theme::INK_4)) + .font(egui::TextStyle::Body), + ); + if response.gained_focus() { + response.scroll_to_me(Some(egui::Align::Center)); + } + if response.changed() { + on_change(value); + } + }); +} + +fn status_row(ui: &mut egui::Ui, label: &str, desc: &str, status: &str, color: egui::Color32) { + row_desc(ui, label, desc, |ui| { + ui.add( + egui::Label::new(egui::RichText::new(status).size(12.0).color(color)) + .wrap() + .halign(egui::Align::Min), + ); + }); } /// A row whose right-hand control is an action button. @@ -3741,11 +5785,16 @@ fn action_row( }); } -fn row(ui: &mut egui::Ui, label: &str, control: impl FnOnce(&mut egui::Ui)) { +pub(super) 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)) { +pub(super) 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, // 而不是各自贴右边缘 —— 贴右会让不同宽度的控件彼此错开。 @@ -3767,10 +5816,23 @@ fn row_desc(ui: &mut egui::Ui, label: &str, desc: &str, control: impl FnOnce(&mu // 「都被向左对齐了」)。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), + // Reserve both the help glyph and egui's inter-item gap; + // otherwise described rows grow the label column by 4px. + let help_width = if desc.is_empty() { + 0.0 + } else { + 16.0 + ui.spacing().item_spacing.x + }; + let text_width = (label_width - help_width).max(1.0); + ui.add_sized( + egui::vec2(text_width, 46.0), + egui::Label::new( + egui::RichText::new(label) + .font(theme::medium_font(14.0)) + .color(theme::INK), + ) + .wrap() + .halign(egui::Align::Min), ); } if !desc.is_empty() { @@ -3810,6 +5872,109 @@ pub(crate) fn test_render_shortcuts( #[cfg(test)] mod tests { + /// 点「验证」后按钮要立刻变成「正在验证…」并禁用,直到结果回包或超时。 + /// Tauri 用 `testingIds` 做同一件事(组件本地状态)。 + #[test] + fn a_verify_click_marks_the_channel_as_testing_until_the_result_arrives() { + let ctx = eframe::egui::Context::default(); + let channel = super::super::view_model::SettingsChannel { + id: "channel-1".into(), + name: "DeepSeek".into(), + model: "deepseek-v4-flash".into(), + provider: "DeepSeek".into(), + provider_type: "deepseek".into(), + is_active: true, + enabled: true, + last_ok: None, + last_error: None, + last_latency_ms: None, + last_check_age_seconds: None, + }; + let mut states = Vec::new(); + let mut output = ctx.run_ui(eframe::egui::RawInput::default(), |ui| { + states.push(super::channel_testing(ui, &channel)); + super::mark_channel_testing(ui, &channel); + states.push(super::channel_testing(ui, &channel)); + }); + output.textures_delta.clear(); + assert_eq!( + states, + vec![false, true], + "the button flips to the checking state right away" + ); + + // 快照里出现新结果(或超时)就不再自称「正在验证」。 + let answered = super::super::view_model::SettingsChannel { + last_check_age_seconds: Some(3), + last_ok: Some(true), + ..channel.clone() + }; + let mut cleared = Vec::new(); + let mut output = ctx.run_ui(eframe::egui::RawInput::default(), |ui| { + cleared.push(super::channel_testing(ui, &answered)); + }); + output.textures_delta.clear(); + assert_eq!( + cleared, + vec![false], + "a fresh result restores the normal button" + ); + } + + #[test] + fn siri_preview_advances_and_requests_animation_frames() { + let ctx = eframe::egui::Context::default(); + let mut previous = 0.0; + for frame in 0..4 { + let mut output = ctx.run_ui( + eframe::egui::RawInput { + screen_rect: Some(eframe::egui::Rect::from_min_size( + eframe::egui::Pos2::ZERO, + eframe::egui::vec2(800.0, 600.0), + )), + time: Some(frame as f64 / 60.0), + ..Default::default() + }, + |ui| super::capsule_style_preview(ui, 0), + ); + let clock = ctx + .data(|data| { + data.get_temp::(eframe::egui::Id::unique(( + "openless-siri-clock", + "settings-siri-preview", + ))) + }) + .expect("preview animation clock"); + assert!(clock.time > previous); + previous = clock.time; + assert!( + output.viewport_output[&eframe::egui::ViewportId::ROOT].repaint_delay + <= std::time::Duration::from_millis(16) + ); + output.textures_delta.clear(); + } + } + + #[test] + fn siri_preview_uses_half_of_the_full_stage() { + let ctx = eframe::egui::Context::default(); + let mut output = ctx.run_ui( + eframe::egui::RawInput { + screen_rect: Some(eframe::egui::Rect::from_min_size( + eframe::egui::Pos2::ZERO, + eframe::egui::vec2(800.0, 600.0), + )), + ..Default::default() + }, + |ui| super::capsule_style_preview(ui, 0), + ); + assert!(output.shapes.iter().any(|shape| matches!( + &shape.shape, eframe::egui::Shape::Callback(callback) + if callback.rect.size() == eframe::egui::vec2(230.0, 90.0) + ))); + output.textures_delta.clear(); + } + use super::*; #[test] @@ -3855,9 +6020,7 @@ mod tests { events, ..Default::default() }, - |ui| { - privacy(ui, &mut vm, &mut actions); - }, + |ui| privacy(ui, &mut vm, &mut actions), ); if let Some(expected) = expected { assert_eq!(vm.settings.polish_skip_max_chars, expected); @@ -3869,18 +6032,168 @@ mod tests { assert!(actions.is_empty()); } ctx.memory_mut(|memory| { - memory.request_focus(egui::Id::new(("openless-settings-text", label))) + memory.request_focus(egui::Id::unique(("openless-settings-text", label))) }); } } #[test] - fn quick_note_shortcut_value_and_menu_align_left_with_arrow_at_row_end() { + fn linux_privacy_describes_session_services_without_claiming_authorization() { let ctx = egui::Context::default(); let mut vm = FrontendViewModel { - shortcut_menu: Some(ShortcutField::QuickNote), + lang: Lang::En, + application_data_directory: "/tmp/openless-user-data".into(), + linux_access: openless_linux_egui::linux_access::LinuxAccessSnapshot { + keyring: openless_linux_egui::linux_access::KeyringState::Ready, + input: openless_linux_egui::linux_access::CheckState::Ready, + network: openless_linux_egui::linux_access::CheckState::Ready, + network_latency_ms: Some(12), + ..Default::default() + }, + permissions: super::super::view_model::SettingsPermissions { + microphone: super::super::view_model::PermissionState::Granted, + hotkey: super::super::view_model::PermissionState::Granted, + ..Default::default() + }, ..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(1000.0, 1800.0), + )), + ..Default::default() + }, + |ui| privacy(ui, &mut vm, &mut actions), + ); + let mut text = String::new(); + for clipped in &output.shapes { + collect_shape_text(&clipped.shape, &mut text); + } + let text = text.replace('\n', ""); + for expected in [ + tr_l10n(Lang::En, "settings.linux_privacy.keyring_title"), + tr_l10n(Lang::En, "settings.linux_privacy.keyring_ready"), + tr_l10n(Lang::En, "settings.linux_privacy.mic_detected"), + tr_l10n(Lang::En, "settings.linux_privacy.hotkey_ready"), + "org.freedesktop.secrets", + "/tmp/openless-user-data", + ] { + assert!(text.contains(expected), "missing {expected:?}: {text}"); + } + assert!(!text.contains(tr_l10n(Lang::En, "settings.permissions.granted"))); + assert!(!text.contains(tr_l10n(Lang::En, "settings.permissions.acc_label"))); + assert!( + actions.is_empty(), + "rendering must not access or mutate credentials" + ); + } + + #[test] + fn unavailable_or_unchecked_keyrings_are_never_painted_as_ready() { + use openless_linux_egui::linux_access::{KeyringState, LinuxAccessSnapshot}; + for (state, checking, expected) in [ + ( + KeyringState::Unknown, + false, + "settings.linux_privacy.not_checked", + ), + ( + KeyringState::Locked, + false, + "settings.linux_privacy.keyring_locked", + ), + ( + KeyringState::MissingService, + false, + "settings.linux_privacy.keyring_missing_service", + ), + ( + KeyringState::MissingCollection, + false, + "settings.linux_privacy.keyring_missing_collection", + ), + ( + KeyringState::RestartRequired, + false, + "settings.linux_privacy.keyring_restart", + ), + ( + KeyringState::TemporaryVault, + false, + "settings.linux_privacy.keyring_temporary", + ), + (KeyringState::Ready, true, "settings.permissions.checking"), + ] { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel { + lang: Lang::En, + linux_access: LinuxAccessSnapshot { + keyring: state, + checking, + ..Default::default() + }, + ..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(1000.0, 1800.0), + )), + ..Default::default() + }, + |ui| privacy_access(ui, &mut vm, &mut actions), + ); + let mut text = String::new(); + for clipped in &output.shapes { + collect_shape_text(&clipped.shape, &mut text); + } + let text = text.replace('\n', ""); + assert!( + text.contains(tr_l10n(Lang::En, expected)), + "{state:?}: {text}" + ); + assert!( + !text.contains(tr_l10n(Lang::En, "settings.linux_privacy.keyring_ready")), + "{state:?}: {text}" + ); + assert!(actions.is_empty()); + } + } + + #[test] + fn privacy_paragraphs_keep_wrapped_lines_left_aligned() { + let ctx = egui::Context::default(); + let output = crate::ui::frontend::run_pass(&ctx, egui::RawInput::default(), |ui| { + ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { + privacy_note(ui, "short\na substantially longer second line"); + }); + }); + let text = output + .shapes + .iter() + .find_map(|shape| match &shape.shape { + egui::Shape::Text(text) => Some(text), + _ => None, + }) + .expect("privacy paragraph must be painted"); + assert_eq!(text.galley.job.halign, egui::Align::Min); + assert!(text.galley.rows.len() >= 2); + assert!(text.galley.rows.iter().all(|row| row.pos.x.abs() < 0.5)); + } + + /// 速记页的快捷键卡片现在复用设置页的内联录制器:键帽在右、↻/✕ 有描边, + /// 不再有 chevron 菜单。 + #[test] + fn quick_note_shortcut_uses_the_inline_recorder() { + let ctx = egui::Context::default(); + let mut vm = FrontendViewModel::default(); let row = ShortcutRow::new( ShortcutField::QuickNote, "", @@ -3889,7 +6202,7 @@ mod tests { String::new(), ); let mut actions = Vec::new(); - let _ = crate::ui::frontend::run_pass( + let output = crate::ui::frontend::run_pass( &ctx, egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( @@ -3900,29 +6213,47 @@ mod tests { }, |ui| { ui.set_width(480.0); - shortcut_control(ui, &mut vm, &mut actions, &row); - shortcut_menu(ui, &mut vm, &mut actions, &row); + shortcut_recorder_row(ui, &mut vm, &mut actions, &row, true); }, ); - 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); + // 收集画出来的文字(`painted_text_of` 在 linux_app 的测试模块里,不可见)。 + let painted = { + 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 text = String::new(); + for clipped in &output.shapes { + collect(&clipped.shape, &mut text); + } + text + }; + // 三个键帽都画出来了,顺序是 Ctrl → Shift → S。 + // 注意 `S` 也是 `Shift` 的首字母,所以按"行"比较而不是按字符查找。 + let order: Vec<&str> = painted.lines().map(str::trim).collect(); + let ctrl = order + .iter() + .position(|line| *line == "Ctrl") + .expect("Ctrl chip"); + let shift = order + .iter() + .position(|line| *line == "Shift") + .expect("Shift chip"); + let key = order.iter().rposition(|line| *line == "S").expect("S chip"); assert!( - (record.left() - line.left()).abs() < 1.0, - "record button must be below value, left-aligned: {record:?}, {line:?}" + ctrl < shift && shift < key, + "keycaps must read left to right: {painted}" ); - assert!(record.top() >= line.bottom()); } #[test] @@ -4024,8 +6355,13 @@ mod tests { for auth in shapes { let editor = SettingsProviderEditor { channel_id: "channel".to_string(), + is_asr: false, + is_draft: false, provider: "volcengine".to_string(), provider_type: "volcengine".to_string(), + endpoint_presets: Vec::new(), + api_key_optional: false, + default_endpoint: String::new(), name: "main".to_string(), endpoint: "https://example.invalid".to_string(), model: "model".to_string(), @@ -4040,12 +6376,13 @@ mod tests { default_model: String::new(), has_models_url: false, custom_model: false, + protocol: Default::default(), 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); + provider_editor_body(ui, &editor, &[], None, Lang::ZhCn, false, &mut actions); }); assert!( actions.is_empty(), @@ -4061,14 +6398,19 @@ mod tests { 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(), + is_asr: false, + is_draft: false, + provider: "ark".to_string(), + provider_type: "ark".to_string(), + endpoint_presets: Vec::new(), + api_key_optional: false, + default_endpoint: String::new(), 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, + auth_mode: "api_key".to_string(), + auth: SettingsProviderAuth::ApiKey, primary_secret: String::new(), secondary_secret: String::new(), models: vec!["doubao-lite".to_string()], @@ -4077,6 +6419,7 @@ mod tests { default_model: "doubao-lite".to_string(), has_models_url, custom_model, + protocol: Default::default(), busy: false, } }; @@ -4115,6 +6458,196 @@ mod tests { ); } + /// Tauri 的自动保存:字段改动后防抖落盘;关闭前先把未落盘的改动冲掉。 + #[test] + fn editor_auto_save_debounces_and_flushes_before_closing() { + let ctx = egui::Context::default(); + let mut actions = Vec::new(); + let raw = |time: f64| egui::RawInput { + time: Some(time), + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(700.0, 680.0), + )), + ..Default::default() + }; + + // 第一帧:标脏,未到防抖截止 → 不保存。 + crate::ui::frontend::run_pass(&ctx, raw(10.0), |ui| { + editor_mark_dirty(ui); + assert!( + !editor_auto_save(ui, &mut actions), + "the debounce window must not save yet" + ); + }); + assert!( + actions.is_empty(), + "nothing may be saved during the debounce" + ); + + // 时间推进到截止之后 → 推一次保存,并且只推一次。 + crate::ui::frontend::run_pass(&ctx, raw(11.0), |ui| { + assert!( + editor_auto_save(ui, &mut actions), + "the settled edit must be saved" + ); + assert!( + !editor_auto_save(ui, &mut actions), + "an already saved edit must not save twice" + ); + }); + assert_eq!(actions.len(), 1); + assert!(matches!(actions[0], FrontendAction::SettingsProviderSave)); + + // 关闭时:脏则先保存再关;干净则只关。 + actions.clear(); + crate::ui::frontend::run_pass(&ctx, raw(12.0), |ui| { + editor_mark_dirty(ui); + }); + crate::ui::frontend::run_pass(&ctx, raw(12.1), |ui| { + editor_close(ui, &mut actions); + }); + assert_eq!( + actions.len(), + 2, + "closing must flush the pending edit first" + ); + assert!(matches!(actions[0], FrontendAction::SettingsProviderSave)); + assert!(matches!(actions[1], FrontendAction::SettingsProviderClose)); + + actions.clear(); + crate::ui::frontend::run_pass(&ctx, raw(13.0), |ui| { + editor_close(ui, &mut actions); + }); + assert_eq!(actions.len(), 1, "a clean editor closes without saving"); + assert!(matches!(actions[0], FrontendAction::SettingsProviderClose)); + } + + /// 渠道编辑器在桌面端是**右栏覆盖**(Tauri 的 embedded 形态):整栏画灰底、 + /// 头部是返回按钮而不是关闭按钮,正文是白底圆角表单卡,底部有删除与完成。 + #[test] + fn the_channel_editor_covers_the_content_column_with_tauri_chrome() { + use super::super::view_model::{SettingsChannel, SettingsChannelProvider}; + + let ctx = egui::Context::default(); + let vm = crate::ui::frontend::FrontendViewModel { + lang: Lang::ZhCn, + provider_editor: Some(SettingsProviderEditor { + channel_id: "channel".to_string(), + is_asr: false, + is_draft: false, + provider: "DeepSeek".to_string(), + provider_type: "deepseek".to_string(), + endpoint_presets: Vec::new(), + api_key_optional: false, + default_endpoint: String::new(), + name: "DeepSeek".to_string(), + endpoint: "https://api.deepseek.com/v1".to_string(), + model: "deepseek-v4-flash".to_string(), + resource_id: String::new(), + auth_mode: "api_key".to_string(), + auth: SettingsProviderAuth::ApiKey, + primary_secret: String::new(), + secondary_secret: String::new(), + models: Vec::new(), + models_loading: false, + static_models: Vec::new(), + default_model: String::new(), + has_models_url: true, + custom_model: false, + protocol: Default::default(), + busy: false, + }), + channel_providers: vec![SettingsChannelProvider { + provider_type: "deepseek".to_string(), + label: "DeepSeek".to_string(), + }], + channels: vec![SettingsChannel { + id: "channel".to_string(), + name: "DeepSeek".to_string(), + provider: "DeepSeek".to_string(), + provider_type: "deepseek".to_string(), + model: "deepseek-v4-flash".to_string(), + is_active: true, + enabled: true, + last_ok: None, + last_error: None, + last_latency_ms: None, + last_check_age_seconds: None, + }], + ..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(700.0, 680.0), + )), + ..Default::default() + }, + |ui| channel_editor_panel(ui, &vm, &mut actions), + ); + let mut text = String::new(); + for clipped in &output.shapes { + collect_shape_text(&clipped.shape, &mut text); + } + let painted = |needle: &str| text.contains(needle); + for key in [ + "settings.channels.edit_title", + "settings.channels.llm_title", + "settings.channels.auto_save_hint", + "settings.channels.connection_title", + "settings.channels.provider_label", + "settings.channels.name_label", + "settings.channels.name_hint", + "settings.channels.delete", + "settings.channels.done", + ] { + let expected = tr_l10n(Lang::ZhCn, key); + assert!( + painted(expected), + "{key} must be painted inside the channel editor, got {text:?}" + ); + } + // 右栏覆盖不该出现设置页自己的标题(编辑器整栏盖住内容区)。 + let section_title = tr_l10n(Lang::ZhCn, "modal.service_views.llm"); + assert!( + !painted(&format!("{section_title}服务")), + "the settings section body must not show through the editor" + ); + + // 草稿模式(Tauri `isDraft`):标题换成「添加渠道」。 + let mut draft_vm = vm; + if let Some(editor) = draft_vm.provider_editor.as_mut() { + editor.is_draft = true; + } + let output = crate::ui::frontend::run_pass( + &ctx, + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(700.0, 680.0), + )), + ..Default::default() + }, + |ui| channel_editor_panel(ui, &draft_vm, &mut actions), + ); + let mut draft_text = String::new(); + for clipped in &output.shapes { + collect_shape_text(&clipped.shape, &mut draft_text); + } + assert!( + draft_text.contains(tr_l10n(Lang::ZhCn, "settings.channels.create_title")), + "a draft editor is titled as a new channel, got {draft_text:?}" + ); + assert!( + !draft_text.contains(tr_l10n(Lang::ZhCn, "settings.channels.edit_title")), + "a draft editor must not say it is editing an existing channel" + ); + } + fn painted_provider_editor(editor: &SettingsProviderEditor) -> String { let ctx = egui::Context::default(); let mut actions = Vec::new(); @@ -4127,7 +6660,7 @@ mod tests { )), ..Default::default() }, - |ui| provider_editor_panel(ui, editor, Lang::ZhCn, &mut actions), + |ui| provider_editor_body(ui, editor, &[], None, Lang::ZhCn, false, &mut actions), ); let mut text = String::new(); for clipped in &output.shapes { 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 deleted file mode 100644 index cea96914e..000000000 --- a/openless-all/app/linux-egui/src/ui/frontend/siri_gl.rs +++ /dev/null @@ -1,361 +0,0 @@ -//! 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; - // Rounding at t == 1 can make sin(PI) slightly negative. - let envelope = (std::f32::consts::PI * t).sin().max(0.0).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 primitives = ctx.tessellate(output.shapes.clone(), output.pixels_per_point); - for primitive in primitives { - if let egui::epaint::Primitive::Mesh(mesh) = primitive.primitive { - assert!(mesh.vertices.iter().all(|vertex| vertex.pos.is_finite())); - } - } - 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/siri_wgpu.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs new file mode 100644 index 000000000..5ecb17af8 --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu.rs @@ -0,0 +1,966 @@ +//! Vulkan/WGPU Siri visuals. +//! +//! Siri is rendered as an egui WGPU paint callback. There are deliberately no +//! legacy graphics paths: the normal eframe renderer and the native +//! Wayland layer-shell renderer both use the same WGSL pipeline. + +use std::borrow::Cow; +use std::sync::OnceLock; + +use eframe::egui; +use eframe::egui_wgpu; + +/// Which WGSL effect drives the capsule centre. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SiriMode { + /// Spectral voice wave while recording. + Wave, + /// Six fluid points while thinking. + Orb, + /// Rounded-rectangle perimeter used by the composer ring. + Ring, + /// One distance-field morph for the entire dictation lifecycle. + Capsule, +} + +#[derive(Clone, Copy, Debug)] +pub struct SiriEffect { + pub mode: SiriMode, + pub time: f32, + pub level: f32, + pub resolved: f32, + pub gather: f32, + pub radius: f32, + pub thickness: f32, + pub tint: [f32; 3], + /// 整体不透明度(预乘 alpha):wave→orb 交叉淡出用,`1.0` = 原样。 + pub opacity: f32, +} + +impl SiriEffect { + pub fn wave(time: f32, level: f32) -> Self { + Self { + mode: SiriMode::Wave, + time, + level, + resolved: 1.0, + gather: 0.0, + radius: 0.0, + thickness: 2.0, + tint: [1.0; 3], + opacity: 1.0, + } + } + + pub fn orb(time: f32, gather: f32) -> Self { + Self { + mode: SiriMode::Orb, + time, + level: 0.0, + resolved: 0.0, + gather, + radius: 0.0, + thickness: 2.0, + tint: [1.0; 3], + opacity: 1.0, + } + } + + pub fn capsule(time: f32, level: f32, resolved: f32, gather: f32) -> Self { + Self { + mode: SiriMode::Capsule, + level, + resolved: resolved.clamp(0.0, 1.0), + ..Self::orb(time, gather.clamp(0.0, 1.0)) + } + } + + pub fn ring(time: f32, radius: f32, thickness: f32) -> Self { + Self { + mode: SiriMode::Ring, + time, + level: 0.0, + resolved: 1.0, + gather: 0.0, + radius, + thickness, + tint: [1.0; 3], + opacity: 1.0, + } + } + + pub fn with_tint(mut self, tint: [f32; 3]) -> Self { + self.tint = tint; + self + } + + /// 预乘 alpha 的整体淡出(wave→orb 交叉淡出;Tauri `opacity .6s ease-out .55s`)。 + pub fn with_opacity(mut self, opacity: f32) -> Self { + self.opacity = opacity.clamp(0.0, 1.0); + self + } +} + +/// Audio drive for the persistent egui animation clock. +#[derive(Clone, Copy, Debug)] +pub struct SiriDrive { + pub level: f32, + pub resolved: f32, + pub speed: f32, + pub warming: bool, + /// 预备态预期时长(ms),驱动光条的「预测式展开」(Tauri `warmupMs`)。 + pub warmup_ms: f32, + /// 思考圆点的收尾:`true` 时六点干脆合回中央一颗圆(Tauri `merging`)。 + pub merging: bool, +} + +impl Default for SiriDrive { + fn default() -> Self { + Self { + level: 0.0, + resolved: 1.0, + speed: 1.0, + warming: false, + warmup_ms: DEFAULT_WARMUP_MS, + merging: false, + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct SiriClock { + pub time: f32, + pub level: f32, + pub resolved: f32, + pub speed: f32, + /// 预备态展开进度(0 = 收拢/加载中,1 = 展开到位)。对齐 Tauri `warmProgress`。 + pub warm_progress: f32, + /// 思考圆点的「聚拢度」:1 = 六点全在圆心(出场那一拍),0 = 散开成环。 + pub gather: f32, + /// 该时钟存活了多久(真实秒,不乘 speed):驱动 `gather` 的 hold 计时。 + pub elapsed: f32, +} + +/// 预备态光条的「收拢度」:wave 停在 0.2 —— 光条明显还没展开(「加载中」)。 +/// 与 Tauri `SiriGL.tsx` 的 `WARMING_RESOLVED` 同值。 +pub const WARMING_RESOLVED: f32 = 0.2; +/// 思考圆点出场时「全聚圆心」保持的时长,之后才缓缓散开成环(Tauri `GATHER_HOLD_S`)。 +const GATHER_HOLD_SECONDS: f32 = 0.3; +/// 预备态预期时长的默认值(Tauri `WARMUP_MS_DEFAULT`)。 +pub const DEFAULT_WARMUP_MS: f32 = 150.0; +/// 学习出来的预热时长上下限(Tauri `readWarmupMs` 的 clamp)。 +pub const MIN_WARMUP_MS: f32 = 60.0; +pub const MAX_WARMUP_MS: f32 = 600.0; + +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) +} + +pub fn tick(ctx: &egui::Context, id: &str, drive: SiriDrive, dt: f32) -> SiriClock { + let key = egui::Id::unique(("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: if drive.warming { + WARMING_RESOLVED + } else { + drive.resolved + }, + speed: drive.speed, + warm_progress: if drive.warming { 0.0 } else { 1.0 }, + // 圆点出场那一拍:六点全聚在圆心。 + gather: 1.0, + elapsed: 0.0, + }) + }); + clock.speed += (drive.speed - clock.speed) * (1.0 - (-dt * 2.5).exp()); + clock.time += dt * clock.speed; + clock.elapsed += dt; + + // 预测式展开(Tauri 用户方案):入场不再死等就绪信号,而是按历史平均加载时长 + // `warmup_ms` 从按下就平滑推进;未就绪 cap 在 0.9(留一截给就绪收尾),一旦就绪 + // 快速补到 1 —— 就绪早 = 剩得多,看上去「迅速展开完成」。 + let warmup_sec = (drive.warmup_ms.clamp(MIN_WARMUP_MS, MAX_WARMUP_MS) / 1000.0).max(0.06); + if drive.warming { + clock.warm_progress = (clock.warm_progress + dt / warmup_sec).min(0.9); + } else { + clock.warm_progress += (1.0 - clock.warm_progress) * (1.0 - (-dt * 9.0).exp()); + } + + // resolved:思考态(0)走 Tauri 原版的「从容汇聚」;入场/录音由 warmProgress + // 从收拢态展开到满。 + let thinking = drive.resolved < 0.5; + let resolved_target = if thinking { + drive.resolved + } else { + WARMING_RESOLVED + (1.0 - WARMING_RESOLVED) * clock.warm_progress + }; + let resolved_k = if thinking { 2.2 } else { 9.0 }; + clock.resolved += (resolved_target - clock.resolved) * (1.0 - (-dt * resolved_k).exp()); + + let level_target = if drive.warming { + 0.12 + 0.06 * (clock.time * 3.0).sin() + } else if thinking { + 0.14 + 0.07 * (clock.time * 2.2).sin() + } else { + visual_voice(drive.level) + }; + let attack = if level_target > clock.level { + 14.0 + } else { + 5.0 + }; + clock.level += (level_target - clock.level) * (1.0 - (-dt * attack).exp()); + + // 圆点环的生命周期(Tauri 用户拍板):出场全聚圆心接住 wave 收缩成的光点 → + // 稳住一拍后缓缓「从中心散开」成环转动 → `merging`(终态)时干脆地合回中央一颗圆。 + if drive.merging { + clock.gather += (1.0 - clock.gather) * (1.0 - (-dt * 4.0).exp()); + } else if clock.elapsed > GATHER_HOLD_SECONDS { + clock.gather += (0.0 - clock.gather) * (1.0 - (-dt * 1.6).exp()); + } + + ctx.data_mut(|data| data.insert_temp(key, clock)); + clock +} + +/// 丢掉某个时钟,让入场动画(wave 展开 / 圆点聚拢)从头走一遍。 +/// +/// Tauri 里胶囊组件每次会话重新挂载,动画状态天然是新的;Linux 这边两个窗口进程都 +/// 常驻,所以由调用方在新会话开始时显式复位。 +pub fn reset(ctx: &egui::Context, id: &str) { + let key = egui::Id::unique(("openless-siri-clock", id)); + ctx.data_mut(|data| { + data.remove::(key); + }); +} + +const SIRI_WGSL: &str = r#" +struct Uniforms { + time: f32, + level: f32, + resolved: f32, + gather: f32, + mode: f32, + radius: f32, + thickness: f32, + _pad: f32, + tint: vec4, + size: vec2, + _pad2: vec2, +}; +@group(0) @binding(0) var u: Uniforms; + +struct VertexOut { + @builtin(position) position: vec4, + @location(0) uv: vec2, +}; + +@vertex +fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut { + var points = array, 3>( + vec2(-1.0, -1.0), + vec2( 3.0, -1.0), + vec2(-1.0, 3.0) + ); + var out: VertexOut; + let point = points[index]; + out.position = vec4(point, 0.0, 1.0); + // Interpolated local coordinates, independent of the callback's viewport + // origin, DPI, clipping and offscreen backdrop scale. + out.uv = vec2((point.x + 1.0) * 0.5, (1.0 - point.y) * 0.5); + return out; +} + +fn spectral4(index: i32) -> vec3 { + let x = f32(index); + return clamp(vec3(abs(x - 3.0) - 1.0, 2.0 - abs(x - 2.0), 2.0 - abs(x - 4.0)), vec3(0.0), vec3(1.0)); +} + +fn hue2rgb(h0: f32) -> vec3 { + let h = h0 - floor(h0); + return vec3( + clamp(abs(h * 6.0 - 3.0) - 1.0, 0.0, 1.0), + clamp(2.0 - abs(h * 6.0 - 2.0), 0.0, 1.0), + clamp(2.0 - abs(h * 6.0 - 4.0), 0.0, 1.0) + ); +} + +fn wave(frag: vec2) -> vec4 { + let pi = 3.14159265359; + let aspect = u.size.x / max(u.size.y, 1.0); + var p = (frag / u.size) * 2.0 - vec2(1.0); + let y_screen = p.y; + p.y = -p.y; + p.x *= aspect; + p /= 0.6; + let res = clamp(u.resolved, 0.0, 1.0); + let capsule = u.mode > 2.5; + var field: FluidField; + if capsule { field = fluid_field(frag); } + let point_distance = max(field.distance, vec3(0.0)) / 0.6; + var point_material = vec3(0.0); + let level = clamp(u.level, 0.0, 1.0); + let low = clamp(0.45 + 0.45 * sin(u.time * 0.8) * sin(u.time * 0.37 + 1.0), 0.0, 1.0) * level; + let mid = clamp(0.40 + 0.40 * sin(u.time * 1.7 + 2.0) * sin(u.time * 0.53), 0.0, 1.0) * level; + let high = clamp(0.30 + 0.30 * sin(u.time * 2.9 + 4.0) * sin(u.time * 0.71 + 2.0), 0.0, 1.0) * level; + var pw = p; + pw.x *= mix(5.0, 1.0, res); + let x_norm = pw.x / max(aspect, 1.0); + let envelope = pow(max(cos(pi * 0.5 * min(abs(0.9 * x_norm), 1.0)), 0.0), 2.0); + let a1 = 0.32 * mix(0.14, 1.0, level) + 0.01 * low * 6.0; + let a2 = a1 + mid * 0.05 + high * 0.06; + let drift = (u.time % (20.0 * pi)) * 2.4; + let aberr = (2.6 + mid * 0.8 + high * 0.5) * res; + let soft = 0.01 * res * max(0.0, 2.5 + mid * 0.4); + let unres = max(length(p) - mix(0.14, 0.14, res), 0.0); + let y_main = a1 * envelope * res * sin(pw.x * 1.1 + drift); + // Keep the original SiriGL spectral normalization and filled ribbons. + // Summing the four hues without dividing by their weights biases yellow. + let thickness = mix(0.1, 0.03, res); + let intensity = mix(0.1, 0.01 * (2.0 + low * 1.5), res); + let band_amount = 3.0 * intensity; + var numerator = vec3(0.0); + var denominator = vec3(0.0); + for (var s: i32 = 0; s < 4; s = s + 1) { + let hue = mix(vec3(1.0), spectral4(s), res); + let ab = mix(-aberr, aberr, f32(s) / 3.0); + let y_line = a2 * envelope * res * sin(pw.x + drift + ab); + var distance = vec3(mix(unres, abs(p.y - y_line), res)); + var band_distance = vec3(max(0.0, max(p.y - max(y_main, y_line), min(y_main, y_line) - p.y))); + if capsule { + // Morph geometry before shading. Both materials see this same + // field; no separately rendered wave/orb images are cross-faded. + distance = mix(point_distance, vec3(abs(p.y - y_line)), res); + band_distance = mix(point_distance, band_distance, res); + point_material += fluid_material(field, distance * 0.6) * 0.25; + } + let lorentz = mix(vec3(1.0) / (vec3(1.0) + pow(0.02 * distance, vec3(2.0))), vec3(1.0), res); + let line = vec3(intensity) / (sqrt(distance * distance + vec3(soft * soft)) + vec3(thickness)); + let band = vec3(band_amount) / (band_distance + vec3(0.08)); + numerator += hue * lorentz * (line + band); + denominator += hue; + } + var main_distance = vec3(mix(unres, abs(p.y - y_main), res)); + if capsule { main_distance = mix(point_distance, vec3(abs(p.y - y_main)), res); } + let main_lorentz = mix(vec3(1.0) / (vec3(1.0) + pow(0.02 * main_distance, vec3(2.0))), vec3(1.0), res); + let boost = (1.0 - res) * (3.0 * low + 1.2); + var col = numerator / denominator; + col += 0.5 * intensity * (main_lorentz + vec3(boost)) / (sqrt(main_distance * main_distance + vec3(soft * soft)) + vec3(thickness)); + col = pow(max(col, vec3(0.0)), vec3(1.5)); + let edge_t = clamp((abs(y_screen) - 1.0) / -0.4, 0.0, 1.0); + let edge = edge_t * edge_t * (3.0 - 2.0 * edge_t); + let gaussian = exp(-pow(x_norm * 1.7, 2.0)); + col *= edge * gaussian * mix(0.55, 1.0, res); + col = clamp(col, vec3(0.0), vec3(1.0)); + if capsule { col = mix(point_material, col, res); } + let alpha = clamp(max(col.r, max(col.g, col.b)), 0.0, 1.0); + return vec4(col, alpha); +} + +// Direct WGSL translation of SiriGL.tsx's siriFluidDotsCore. The six +// signed-distance fields are smooth-unioned, not six additive bloom layers. +struct FluidField { + distance: vec3, + color: vec3, + brightness: f32, +}; + +fn fluid_hash(n: f32) -> f32 { return fract(sin(n * 127.1 + 311.7) * 43758.5453); } +fn fluid_settle(t: f32) -> f32 { + if t <= 0.0 { return 0.0; } + return 1.0 - exp(-3.2 * t) * cos(4.6 * t); +} +fn fluid_smin(a: f32, b: f32) -> f32 { + let h = max(0.08 - abs(a - b), 0.0) / 0.08; + return min(a, b) - h * h * 0.08 * 0.25; +} +fn fluid_radius(i: f32, seed: f32, t: f32) -> f32 { + return 0.036 + 0.010 * sin(t * 1.3 + seed * 6.28318530718) + 0.005 * sin(t * 2.4 + i * 1.3); +} +fn fluid_distance(p: vec2, pos: vec2, r: f32, i: f32, damp: f32) -> f32 { + let d = p - pos; + let sq = 0.075 * (0.5 + 0.5 * sin(u.time * 0.9 + i * 2.0)) * damp; + let ca = cos(u.time * 0.35 + i); + let sa = sin(u.time * 0.35 + i); + let rotated = vec2(ca * d.x + sa * d.y, -sa * d.x + ca * d.y); + return length(rotated * vec2(1.0 + sq, 1.0 - sq)) - r; +} +fn fluid_field(frag: vec2) -> FluidField { + var p = (2.0 * frag - u.size) / min(u.size.x, u.size.y); + p.y = -p.y; + p /= 1.0 + 0.03 * sin(u.time); + let gather = clamp(u.gather, 0.0, 1.0); + let cycle = floor(u.time / 6.0); + let te = fract(u.time / 6.0) * 6.0; + var total = vec3(1e5); + var color = vec3(0.0); + var weight = 1e-6; + for (var i: i32 = 0; i < 6; i = i + 1) { + let fi = f32(i); + let seed = fluid_hash(fi); + let angle = fi / 6.0 * 6.28318530718 + u.time * 0.35; + let dir = vec2(cos(angle), sin(angle)); + let radius = 0.17 + 0.010 * sin(u.time) + 0.007 * sin(u.time * 1.3 + seed * 6.28318530718); + let pair = fi % 3.0; + let low = (cycle + pair) % 2.0; + let mover = select(step(0.5, low), step(low, 0.5), fi < 2.5); + let go = pair * 0.33; + let back = 3.0 * 0.33 + pair * 0.33; + let travel = (fluid_settle(te - go) - fluid_settle(te - back)) * mover; + let recoil = (fluid_settle(te - go - 0.11) - fluid_settle(te - back - 0.11)) * (1.0 - mover); + let self_r = mix(fluid_radius(fi, seed, u.time), 0.036, gather); + let partner = (fi + 3.0) % 6.0; + let partner_r = fluid_radius(partner, fluid_hash(partner), u.time); + let deep = -(radius + 0.035) - 0.12 * partner_r; + let radial = mix(mix(radius, deep, travel) + 0.035 * recoil, 0.008, gather); + let pos = radial * dir; + let sd = vec3( + fluid_distance(p, pos, self_r, fi, 1.0 - gather), + fluid_distance(p - 0.0025 * dir, pos, self_r, fi, 1.0 - gather), + fluid_distance(p - 0.005 * dir, pos, self_r, fi, 1.0 - gather) + ); + total = vec3(fluid_smin(total.r, sd.r), fluid_smin(total.g, sd.g), fluid_smin(total.b, sd.b)); + let hue = fract(fi / 6.0 + u.time * 0.06) * 0.667; + let dot_color = mix(vec3(1.0), hue2rgb(hue), 0.01); + let w = exp(-sd.g * 0.5); + color += w * dot_color; + weight += w; + } + let brightness = mix(1.0, 0.85, gather) * (1.0 + 0.30 * gather) * (1.0 + 0.05 * sin(u.time + 1.0)); + return FluidField(total, color / weight, brightness); +} +fn fluid_material(field: FluidField, distance: vec3) -> vec3 { + let sd = max(distance, vec3(0.0)) + vec3(1e-4); + let core = clamp(vec3(0.0025) / pow(sd, vec3(1.35)), vec3(0.0), vec3(1.0)); + let edge = vec3(1.0) - smoothstep(vec3(0.02), vec3(0.56), sd); + let col = core * edge * field.brightness * field.color; + return min(pow(col, vec3(1.0 / 1.2)), vec3(1.0)); +} +fn orb(frag: vec2) -> vec4 { + let field = fluid_field(frag); + let col = fluid_material(field, field.distance); + return vec4(col, max(col.r, max(col.g, col.b))); +} + +fn round_box(p: vec2, half_size: vec2, radius: f32) -> f32 { + let q = abs(p) - half_size + vec2(radius); + return length(max(q, vec2(0.0))) + min(max(q.x, q.y), 0.0) - radius; +} + +fn ring(frag: vec2) -> vec4 { + let p = frag - u.size * 0.5; + let radius = clamp(u.radius, 0.0, min(u.size.x, u.size.y) * 0.5); + let distance = round_box(p, u.size * 0.5, radius); + let edge = 1.0 - smoothstep(0.0, max(u.thickness, 1.0), abs(distance)); + let alpha = edge * 0.9; + return vec4(u.tint.rgb * alpha, alpha); +} + +@fragment +fn fs_main(in: VertexOut) -> @location(0) vec4 { + let local = in.uv * u.size; + return fade(mode_color(local)); +} + +// 预乘 alpha 的整体淡出:`u.tint.a` 是 opacity(RGB 与 alpha 一起缩, +// 才能让 PREMULTIPLIED_ALPHA_BLENDING 下的淡出走对)。 +fn fade(color: vec4) -> vec4 { + let opacity = clamp(u.tint.a, 0.0, 1.0); + return vec4(color.rgb * opacity, color.a * opacity); +} + +fn mode_color(local: vec2) -> vec4 { + if u.mode < 0.5 { return wave(local); } + if u.mode < 1.5 { return orb(local); } + if u.mode < 2.5 { return ring(local); } + if u.resolved <= 0.0 { return orb(local); } + return wave(local); +} + +// Match egui's framebuffer handling: an sRGB target will encode RGB again, +// so decode our gamma-space Siri colors first. Alpha is always linear. +@fragment +fn fs_main_linear(in: VertexOut) -> @location(0) vec4 { + let local = in.uv * u.size; + let color = mode_color(local); + let lower = color.rgb / 12.92; + let higher = pow((color.rgb + vec3(0.055)) / 1.055, vec3(2.4)); + let linear = vec4(select(higher, lower, color.rgb < vec3(0.04045)), color.a); + return fade(linear); +} +"#; + +#[derive(Clone, Copy)] +struct SiriUniforms { + effect: SiriEffect, + size: [f32; 2], +} + +struct SiriGpu { + pipeline: egui_wgpu::wgpu::RenderPipeline, + bind_layout: egui_wgpu::wgpu::BindGroupLayout, +} + +fn uniform_bytes(uniforms: SiriUniforms) -> [u8; 64] { + let mode = match uniforms.effect.mode { + SiriMode::Wave => 0.0, + SiriMode::Orb => 1.0, + SiriMode::Ring => 2.0, + SiriMode::Capsule => 3.0, + }; + let values = [ + uniforms.effect.time, + uniforms.effect.level, + uniforms.effect.resolved, + uniforms.effect.gather, + mode, + uniforms.effect.radius, + uniforms.effect.thickness, + 0.0, + uniforms.effect.tint[0], + uniforms.effect.tint[1], + uniforms.effect.tint[2], + uniforms.effect.opacity.clamp(0.0, 1.0), + uniforms.size[0], + uniforms.size[1], + ]; + // WGSL's uniform layout rounds the trailing `vec2` padding to 16-byte + // alignment: 14 values (56 bytes) are followed by `_pad2: vec2`. + // wgpu validates the complete binding size when this callback is drawn. + let mut bytes = [0_u8; 64]; + for (index, value) in values.into_iter().enumerate() { + bytes[index * 4..index * 4 + 4].copy_from_slice(&value.to_ne_bytes()); + } + bytes +} + +fn create_gpu( + device: &egui_wgpu::wgpu::Device, + format: egui_wgpu::wgpu::TextureFormat, + msaa_samples: u32, +) -> SiriGpu { + use egui_wgpu::wgpu; + let bind_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("openless-siri-bind-layout"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: wgpu::BufferSize::new(64), + }, + count: None, + }], + }); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("openless-siri-wgsl"), + source: wgpu::ShaderSource::Wgsl(Cow::Borrowed(SIRI_WGSL)), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("openless-siri-pipeline-layout"), + bind_group_layouts: &[Some(&bind_layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("openless-siri-wgpu"), + 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 { + count: msaa_samples.max(1), + ..Default::default() + }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(if format.is_srgb() { + "fs_main_linear" + } else { + "fs_main" + }), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }); + SiriGpu { + pipeline, + bind_layout, + } +} + +/// Install the Siri pipeline into an eframe renderer before the first frame. +pub fn install_wgpu(state: &eframe::egui_wgpu::RenderState, msaa_samples: u32) { + let mut renderer = state.renderer.write(); + install_renderer( + &mut renderer, + &state.device, + state.target_format, + msaa_samples, + ); +} + +/// Same installation hook for the standalone Wayland layer-shell renderer. +pub fn install_renderer( + renderer: &mut egui_wgpu::Renderer, + device: &egui_wgpu::wgpu::Device, + format: egui_wgpu::wgpu::TextureFormat, + msaa_samples: u32, +) { + if renderer.callback_resources.get::().is_none() { + renderer + .callback_resources + .insert(create_gpu(device, format, msaa_samples)); + } +} + +struct SiriCallback { + uniforms: SiriUniforms, + // egui prepares every callback before painting any of them. A shared + // queue-written uniform would make every draw use the last effect's data. + binding: OnceLock, +} + +impl egui_wgpu::CallbackTrait for SiriCallback { + fn prepare( + &self, + device: &egui_wgpu::wgpu::Device, + _queue: &egui_wgpu::wgpu::Queue, + _screen: &egui_wgpu::ScreenDescriptor, + _encoder: &mut egui_wgpu::wgpu::CommandEncoder, + resources: &mut egui_wgpu::CallbackResources, + ) -> Vec { + if let Some(gpu) = resources.get::() { + self.binding.get_or_init(|| { + use egui_wgpu::wgpu::{self, util::DeviceExt as _}; + let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("openless-siri-uniform"), + contents: &uniform_bytes(self.uniforms), + usage: wgpu::BufferUsages::UNIFORM, + }); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("openless-siri-bind-group"), + layout: &gpu.bind_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: uniform.as_entire_binding(), + }], + }) + }); + } + Vec::new() + } + + fn paint( + &self, + _info: egui::PaintCallbackInfo, + render_pass: &mut egui_wgpu::wgpu::RenderPass<'static>, + resources: &egui_wgpu::CallbackResources, + ) { + let Some(gpu) = resources.get::() else { + return; + }; + let Some(binding) = self.binding.get() else { + return; + }; + render_pass.set_pipeline(&gpu.pipeline); + render_pass.set_bind_group(0, binding, &[]); + render_pass.draw(0..3, 0..1); + } +} + +/// Queue one Vulkan/WGPU paint callback. Returning `true` means the caller must +/// not draw an egui fallback over it. +pub fn paint(ui: &egui::Ui, rect: egui::Rect, effect: SiriEffect) -> bool { + if !rect.is_positive() || !rect.is_finite() { + return false; + } + let scale = ui.ctx().pixels_per_point(); + let callback = egui_wgpu::Callback::new_paint_callback( + rect, + SiriCallback { + binding: OnceLock::new(), + uniforms: SiriUniforms { + effect, + size: [rect.width() * scale, rect.height() * scale], + }, + }, + ); + ui.painter().add(egui::Shape::Callback(callback)); + true +} + +#[cfg(test)] +#[path = "siri_wgpu_gpu_tests.rs"] +mod gpu_tests; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn paint_queues_wgpu_callbacks_for_every_mode() { + let ctx = egui::Context::default(); + let mut output = ctx.run_ui( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::vec2(320.0, 240.0), + )), + ..Default::default() + }, + |ui| { + for effect in [ + SiriEffect::wave(0.5, 0.3), + SiriEffect::orb(0.5, 1.0), + SiriEffect::ring(0.5, 12.0, 2.0), + ] { + assert!(paint(ui, ui.max_rect(), effect)); + } + }, + ); + let callbacks = output + .shapes + .iter() + .filter(|clipped| matches!(clipped.shape, egui::Shape::Callback(_))) + .count(); + assert_eq!(callbacks, 3); + output.textures_delta.clear(); + } + + #[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); + assert_eq!(start.level, 0.0); + 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); + assert!(clock.level > 0.0); + assert!(clock.level <= visual_voice(0.5) + f32::EPSILON); + 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); + } + + /// 预备态:光条从收拢态起步,按 `warmup_ms` 预测式展开,就绪后快速补满。 + #[test] + fn the_wave_expands_on_the_learned_warmup_schedule() { + let ctx = egui::Context::default(); + let predict = |elapsed_ms: f32| { + let ctx = egui::Context::default(); + let mut clock = tick( + &ctx, + "warm", + SiriDrive { + warming: true, + warmup_ms: 200.0, + ..Default::default() + }, + 0.0, + ); + let frames = (elapsed_ms / 1000.0 / (1.0 / 60.0)) as usize; + for _ in 0..frames { + clock = tick( + &ctx, + "warm", + SiriDrive { + warming: true, + warmup_ms: 200.0, + ..Default::default() + }, + 1.0 / 60.0, + ); + } + clock.resolved + }; + // 挂载那一帧:收拢(未成形),而不是直接展开。 + let mount = tick( + &ctx, + "mount", + SiriDrive { + warming: true, + ..Default::default() + }, + 0.0, + ); + assert!((mount.resolved - WARMING_RESOLVED).abs() < 1e-6); + assert_eq!(mount.warm_progress, 0.0); + // 200ms 的预期时长:越等越展开,但未就绪时封在 0.9 的 cap 之下。 + let early = predict(0.0); + let halfway = predict(100.0); + let capped = predict(400.0); + assert!( + early < halfway && halfway < capped, + "{early} {halfway} {capped}" + ); + assert!( + capped < 1.0, + "warming must stay short of fully open: {capped}" + ); + // 就绪:快速补到满。 + let mut clock = tick( + &ctx, + "ready", + SiriDrive { + warming: true, + warmup_ms: 200.0, + ..Default::default() + }, + 0.0, + ); + for _ in 0..60 { + clock = tick( + &ctx, + "ready", + SiriDrive { + warming: true, + warmup_ms: 200.0, + ..Default::default() + }, + 1.0 / 60.0, + ); + } + for _ in 0..60 { + clock = tick(&ctx, "ready", SiriDrive::default(), 1.0 / 60.0); + } + assert!( + clock.resolved > 0.99, + "a ready microphone must open the ribbon: {}", + clock.resolved + ); + } + + /// 思考圆点:出场全聚圆心 → 稳住 0.3s → 散开成环;终态 `merging` 时合回一颗圆。 + #[test] + fn the_orb_gathers_then_spreads_and_merges_at_the_end() { + let ctx = egui::Context::default(); + let orb = |merging: bool| SiriDrive { + level: 0.0, + resolved: 0.0, + speed: 1.5, + warming: false, + warmup_ms: DEFAULT_WARMUP_MS, + merging, + }; + let mount = tick(&ctx, "orb", orb(false), 0.0); + assert_eq!(mount.gather, 1.0, "the six dots start merged in the centre"); + let mut clock = mount; + // 0.2s(还在 hold 里):仍是全聚。 + for _ in 0..12 { + clock = tick(&ctx, "orb", orb(false), 1.0 / 60.0); + } + assert!(clock.elapsed < GATHER_HOLD_SECONDS); + assert!(clock.gather > 0.99, "hold: {}", clock.gather); + // 又过了 1.5s:应该已经散开成环。 + for _ in 0..90 { + clock = tick(&ctx, "orb", orb(false), 1.0 / 60.0); + } + assert!(clock.gather < 0.2, "spread into a ring: {}", clock.gather); + // 终态:合回中央一颗圆。 + for _ in 0..60 { + clock = tick(&ctx, "orb", orb(true), 1.0 / 60.0); + } + assert!( + clock.gather > 0.9, + "merging into one circle: {}", + clock.gather + ); + } + + /// 新会话复位:时钟丢掉之后,下一帧回到「刚出场」的状态。 + #[test] + fn resetting_the_clock_replays_the_entry_animation() { + let ctx = egui::Context::default(); + for _ in 0..30 { + let _ = tick(&ctx, "session", SiriDrive::default(), 1.0 / 60.0); + } + reset(&ctx, "session"); + let fresh = tick( + &ctx, + "session", + SiriDrive { + warming: true, + ..Default::default() + }, + 0.0, + ); + assert_eq!(fresh.time, 0.0); + assert_eq!(fresh.elapsed, 0.0); + assert_eq!(fresh.warm_progress, 0.0); + assert_eq!(fresh.gather, 1.0); + assert!((fresh.resolved - WARMING_RESOLVED).abs() < 1e-6); + } + + /// 整体不透明度(预乘 alpha)写进 tint 的第四个分量。 + #[test] + fn opacity_lands_in_the_tint_alpha_channel() { + let opaque = uniform_bytes(SiriUniforms { + effect: SiriEffect::wave(1.0, 0.5), + size: [64.0, 64.0], + }); + let faded = uniform_bytes(SiriUniforms { + effect: SiriEffect::wave(1.0, 0.5).with_opacity(0.25), + size: [64.0, 64.0], + }); + let read = |bytes: &[u8; 64]| f32::from_ne_bytes(bytes[44..48].try_into().unwrap()); + assert_eq!(read(&opaque), 1.0); + assert_eq!(read(&faded), 0.25); + } + + #[test] + fn uniform_buffer_matches_wgsl_alignment() { + let bytes = uniform_bytes(SiriUniforms { + effect: SiriEffect::wave(1.35, 0.52), + size: [320.0, 80.0], + }); + assert_eq!(bytes.len(), 64); + } + + #[test] + fn visual_voice_gates_and_eases() { + assert_eq!(visual_voice(0.0), 0.0); + assert_eq!(visual_voice(0.012), 0.0); + assert_eq!(visual_voice(0.34), 1.0); + let mid = visual_voice(0.18); + assert!(mid > 0.4 && mid < 1.0); + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs new file mode 100644 index 000000000..8fe35e6da --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/siri_wgpu_gpu_tests.rs @@ -0,0 +1,366 @@ +//! Real Vulkan draw/readback regression: non-origin viewports, per-callback +//! uniforms, MSAA, DPI, clipping and transparent pixels (not just shape counts). +use super::*; +use egui_wgpu::wgpu; + +const WIDTH: u32 = 512; +const HEIGHT: u32 = 256; +const EFFECT_SIZE: [f32; 2] = [128.0, 96.0]; + +fn draw( + device: &wgpu::Device, + queue: &wgpu::Queue, + samples: u32, + scale: f32, + effects: &[(SiriEffect, [f32; 2])], + clipped: bool, +) -> Vec { + draw_format( + device, + queue, + samples, + scale, + &effects + .iter() + .map(|(effect, origin)| (*effect, *origin, EFFECT_SIZE)) + .collect::>(), + clipped, + wgpu::TextureFormat::Rgba8Unorm, + ) +} + +fn draw_format( + device: &wgpu::Device, + queue: &wgpu::Queue, + samples: u32, + scale: f32, + effects: &[(SiriEffect, [f32; 2], [f32; 2])], + clipped: bool, + format: wgpu::TextureFormat, +) -> Vec { + let mut renderer = egui_wgpu::Renderer::new( + device, + format, + egui_wgpu::RendererOptions { + msaa_samples: samples, + ..Default::default() + }, + ); + install_renderer(&mut renderer, device, format, samples); + let primitives: Vec<_> = effects + .iter() + .map(|(effect, origin, effect_size)| { + let rect = egui::Rect::from_min_size( + egui::pos2(origin[0] / scale, origin[1] / scale), + egui::vec2(effect_size[0] / scale, effect_size[1] / scale), + ); + let clip_rect = if clipped { + rect.shrink(16.0 / scale) + } else { + rect + }; + egui::ClippedPrimitive { + clip_rect, + primitive: egui::epaint::Primitive::Callback( + egui_wgpu::Callback::new_paint_callback( + rect, + SiriCallback { + uniforms: SiriUniforms { + effect: *effect, + size: *effect_size, + }, + binding: OnceLock::new(), + }, + ), + ), + } + }) + .collect(); + let texture = |sample_count| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some("siri-regression-target"), + size: wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count, + dimension: wgpu::TextureDimension::D2, + format, + usage: if sample_count == 1 { + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC + } else { + wgpu::TextureUsages::RENDER_ATTACHMENT + }, + view_formats: &[], + }) + }; + let target = texture(1); + let view = target.create_view(&Default::default()); + let msaa = (samples > 1).then(|| texture(samples).create_view(&Default::default())); + let screen = egui_wgpu::ScreenDescriptor { + size_in_pixels: [WIDTH, HEIGHT], + pixels_per_point: scale, + }; + let mut encoder = device.create_command_encoder(&Default::default()); + let callbacks = renderer.update_buffers(device, queue, &mut encoder, &primitives, &screen); + { + let mut pass = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("siri-regression-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: msaa.as_ref().unwrap_or(&view), + resolve_target: msaa.as_ref().map(|_| &view), + 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, + }) + .forget_lifetime(); + renderer.render(&mut pass, &primitives, &screen); + } + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("siri-regression-readback"), + size: (WIDTH * HEIGHT * 4) as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(WIDTH * 4), + rows_per_image: Some(HEIGHT), + }, + }, + wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + ); + queue.submit( + callbacks + .into_iter() + .chain(std::iter::once(encoder.finish())), + ); + let slice = buffer.slice(..); + slice.map_async(wgpu::MapMode::Read, |result| { + result.expect("map Siri pixels") + }); + device + .poll(wgpu::PollType::wait_indefinitely()) + .expect("GPU draw completes"); + let pixels = slice.get_mapped_range().expect("mapped pixels").to_vec(); + buffer.unmap(); + pixels +} + +#[test] +fn vulkan_effects_are_visible_local_independent_and_transparent() { + 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() + .unwrap(); + let adapter = match runtime.block_on(instance.request_adapter(&Default::default())) { + Ok(adapter) => adapter, + Err(error) => { + assert!( + std::env::var_os("OPENLESS_REQUIRE_GPU_TESTS").is_none(), + "Vulkan required: {error}" + ); + eprintln!("Skipping Siri Vulkan readback: {error}"); + return; + } + }; + eprintln!("Siri GPU regression adapter: {:?}", adapter.get_info()); + let (device, queue) = runtime + .block_on(adapter.request_device(&Default::default())) + .unwrap(); + // Reference values evaluated independently from SiriGL.tsx's wave formula + // at pixel centers in a 128×96 canvas, t=1.35, level=0.52. These catch lost + // band fill, missing per-channel normalization and framebuffer gamma drift. + for format in [ + wgpu::TextureFormat::Rgba8Unorm, + wgpu::TextureFormat::Rgba8UnormSrgb, + ] { + let pixels = draw_format( + &device, + &queue, + 1, + 1.0, + &[(SiriEffect::wave(1.35, 0.52), [0.0, 0.0], EFFECT_SIZE)], + false, + format, + ); + for (x, y, rgba) in [ + (32, 48, [58u8, 57, 73, 73]), + (48, 40, [69, 59, 44, 69]), + (56, 48, [206, 255, 255, 255]), + (64, 48, [255, 255, 255, 255]), + (72, 56, [52, 79, 57, 79]), + (80, 40, [52, 33, 39, 52]), + (96, 48, [66, 67, 63, 67]), + (64, 20, [12, 11, 10, 12]), + ] { + let offset = ((y * WIDTH + x) * 4) as usize; + for channel in 0..4 { + assert!( + pixels[offset + channel].abs_diff(rgba[channel]) <= 2, + "SiriGL color mismatch at {x},{y} on {format:?}: {:?} != {rgba:?}", + &pixels[offset..offset + 4] + ); + } + } + } + + // The 46-point composer must keep its ring on the outer edge at every DPI. + for samples in [1, 4] { + for scale in [1.0, 1.5, 2.0] { + let size = [220.0 * scale, 46.0 * scale]; + let origin = [32.0, 128.0]; + let pixels = draw_format( + &device, + &queue, + samples, + scale, + &[(SiriEffect::ring(0.5, 12.0, 2.0), origin, size)], + false, + wgpu::TextureFormat::Rgba8Unorm, + ); + let width = size[0] as u32; + let height = size[1] as u32; + let alpha = |x, y| { + let x = x + origin[0] as u32; + let y = y + origin[1] as u32; + pixels[((y * WIDTH + x) * 4 + 3) as usize] + }; + for (x, y) in [ + (width / 2, 0), + (width / 2, height - 1), + (0, height / 2), + (width - 1, height / 2), + ] { + assert!( + alpha(x, y) > 128, + "composer ring missing at outer edge {x},{y}, MSAA={samples}, DPI={scale}" + ); + } + assert_eq!( + alpha(width / 2, height / 2), + 0, + "composer centre must remain transparent, MSAA={samples}, DPI={scale}" + ); + assert_eq!( + alpha(0, 0), + 0, + "rounded composer corner must be transparent" + ); + } + } + // One distance field must meet both endpoints without a handoff pop. + // Check the near-endpoint too, not merely an exact-zero special case. + for (resolved, reference) in [ + (1.0, SiriEffect::wave(1.35, 0.52)), + (1.0 - 1e-6, SiriEffect::wave(1.35, 0.52)), + (0.0, SiriEffect::orb(1.35, 1.0)), + (1e-6, SiriEffect::orb(1.35, 1.0)), + ] { + let morph = SiriEffect::capsule(1.35, 0.52, resolved, 1.0); + let actual = draw(&device, &queue, 1, 1.0, &[(morph, [0.0, 0.0])], false); + let expected = draw(&device, &queue, 1, 1.0, &[(reference, [0.0, 0.0])], false); + assert!( + actual + .iter() + .zip(&expected) + .all(|(a, b)| a.abs_diff(*b) <= 2), + "capsule endpoint discontinuity at resolved={resolved}" + ); + } + + let effects = [ + (SiriEffect::wave(1.35, 0.52), [32.0, 128.0]), + (SiriEffect::orb(0.7, 0.5), [192.0, 128.0]), + ( + SiriEffect::ring(0.5, 12.0, 2.0).with_tint([0.2, 0.6, 1.0]), + [352.0, 128.0], + ), + ]; + for samples in [1, 4] { + for scale in [1.0, 1.5, 2.0] { + let combined = draw(&device, &queue, samples, scale, &effects, false); + let clipped = draw(&device, &queue, samples, scale, &effects, true); + for (effect, origin) in effects { + let reference = draw( + &device, + &queue, + samples, + scale, + &[(effect, [0.0, 0.0])], + false, + ); + let mut visible = 0; + for y in 0..96 { + for x in 0..128 { + let from = ((y * WIDTH + x) * 4) as usize; + let to = + (((y + origin[1] as u32) * WIDTH + x + origin[0] as u32) * 4) as usize; + let pixel = &combined[to..to + 4]; + for channel in 0..4 { + assert!(pixel[channel].abs_diff(reference[from + channel]) <= 2, + "{:?}: viewport/shared-uniform mismatch at {x},{y}, MSAA={samples}, DPI={scale}", effect.mode); + let expected = if (16..112).contains(&x) && (16..80).contains(&y) { + pixel[channel] + } else { + 0 + }; + assert!( + clipped[to + channel].abs_diff(expected) <= 2, + "clipping moved effect" + ); + } + assert!( + pixel[..3].iter().all(|c| *c <= pixel[3]), + "RGB must be premultiplied" + ); + if pixel[..3].iter().any(|c| *c > 16) && pixel[3] > 16 { + visible += 1; + } + } + } + assert!( + visible > 50, + "{:?} must draw visible pixels, got {visible}", + effect.mode + ); + assert_eq!(reference[3], 0, "effect corner must remain transparent"); + } + assert!( + combined[..(WIDTH * 100 * 4) as usize] + .iter() + .all(|v| *v == 0), + "outside callbacks must remain transparent" + ); + } + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/style.rs b/openless-all/app/linux-egui/src/ui/frontend/style.rs index 9e9ec99d3..671e5eb39 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/style.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/style.rs @@ -161,7 +161,7 @@ fn list_header( ); let raw_response = ui.interact( raw_rect, - ui.id().with("style-raw-tab"), + ui.scope_id().with("style-raw-tab"), egui::Sense::click(), ); painter.rect_filled( @@ -266,6 +266,14 @@ fn pack_grid( }; let card_width = ((grid_width - GAP * (columns - 1) as f32) / columns as f32).max(1.0); + super::paging::load_more_footer( + ui, + lang, + super::paging::Collection::StylePacks, + vm, + false, + actions, + ); let pack_indices: Vec = vm .style_packs .iter() @@ -433,7 +441,7 @@ fn style_pack_card( }; let response = ui.interact( rect, - ui.id().with(("style-pack-card", index)), + ui.scope_id().with(("style-pack-card", index)), egui::Sense::click(), ); let painter = ui.painter().with_clip_rect(rect); @@ -465,7 +473,7 @@ fn style_pack_card( ); let icon_response = ui.interact( icon_rect, - ui.id().with(("style-icon", index)), + ui.scope_id().with(("style-icon", index)), egui::Sense::click(), ); // `.ol-style-icon-button`: 40×40, 1px `--ol-line-strong`, radius 11, and a @@ -523,7 +531,7 @@ fn style_pack_card( 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 key = egui::Id::unique("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); @@ -553,7 +561,7 @@ fn style_pack_card( if ui .interact( reset_rect, - ui.id().with(("style-icon-reset", index)), + ui.scope_id().with(("style-icon-reset", index)), egui::Sense::click(), ) .on_hover_text(tr_l10n(lang, "style.pack.resetIcon")) @@ -708,7 +716,7 @@ fn new_pack_tile( ) { let response = ui.interact( rect, - ui.id().with("style-new-pack-tile"), + ui.scope_id().with("style-new-pack-tile"), egui::Sense::click(), ); let painter = ui.painter().with_clip_rect(rect); @@ -794,7 +802,7 @@ fn notice(ui: &mut egui::Ui, width: f32, vm: &mut FrontendViewModel) { ); let response = ui.interact( dismiss, - ui.id().with("style-notice-dismiss"), + ui.scope_id().with("style-notice-dismiss"), egui::Sense::click(), ); if response.hovered() { @@ -853,14 +861,14 @@ fn editor_overlay( ); // 卡片实际落点写进 memory,供测试查询(Area 覆盖整个 body,面积已不等于卡片). ctx.data_mut(|data| { - data.insert_temp(egui::Id::new("openless-style-editor-card-rect"), card_rect) + data.insert_temp(egui::Id::unique("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")) + egui::Area::new(egui::Id::unique("openless-style-editor-modal")) .order(egui::Order::Foreground) .fixed_pos(body.min) .constrain(false) @@ -957,7 +965,7 @@ fn drawer_header( if ui .interact( close, - ui.id().with("style-editor-close"), + ui.scope_id().with("style-editor-close"), egui::Sense::click(), ) .on_hover_text(tr_l10n(lang, "style.pack.closeEditor")) @@ -1083,14 +1091,14 @@ fn drawer_body( }); #[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::unique("style-editor-scroll-rect"), scroll.inner_rect); data.insert_temp( - egui::Id::new("style-editor-scroll-content-height"), + egui::Id::unique("style-editor-scroll-content-height"), scroll.content_size.y, ); data.insert_temp( - egui::Id::new("style-editor-scroll-offset"), - scroll.state.offset.y, + egui::Id::unique("style-editor-scroll-offset"), + scroll.state.clamped_offset().y, ); }); #[cfg(not(test))] @@ -1457,7 +1465,7 @@ fn pills_row( fn editor_input(text: &mut String) -> egui::TextEdit<'_> { egui::TextEdit::singleline(text) .font(egui::FontId::proportional(12.5)) - .vertical_align(egui::Align::Center) + .align(egui::Align2::CENTER_CENTER) .margin(egui::Margin::symmetric(11, 9)) .frame( egui::Frame::new() @@ -1497,15 +1505,15 @@ fn editor_textarea(ui: &mut egui::Ui, text: &mut String, height: f32, id: &'stat #[cfg(test)] ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new(("style-editor-textarea-measure", id)), + egui::Id::unique(("style-editor-textarea-measure", id)), ( scroll.content_size.y, scroll.inner_rect.height(), - scroll.state.offset.y, + scroll.state.clamped_offset().y, ), ); data.insert_temp( - egui::Id::new(("style-editor-textarea-rect", id)), + egui::Id::unique(("style-editor-textarea-rect", id)), scroll.inner_rect, ) }); diff --git a/openless-all/app/linux-egui/src/ui/frontend/transcript_motion.rs b/openless-all/app/linux-egui/src/ui/frontend/transcript_motion.rs new file mode 100644 index 000000000..16f86717e --- /dev/null +++ b/openless-all/app/linux-egui/src/ui/frontend/transcript_motion.rs @@ -0,0 +1,259 @@ +use eframe::egui; +use unicode_segmentation::UnicodeSegmentation; + +#[derive(Clone)] +struct Glyph { + text: String, + width: f32, + born: f64, + from: f32, + target: f32, + moved: f64, +} + +impl Glyph { + fn x(&self, now: f64) -> f32 { + self.from + (self.target - self.from) * spring(now - self.moved, 260.0, 30.0, 1.0) + } +} + +#[derive(Clone, Default)] +struct TranscriptMotion { + glyphs: Vec, + width_from: f32, + width: f32, + right_from: f32, + right: f32, + changed: f64, +} + +fn spring(elapsed: f64, stiffness: f32, damping: f32, mass: f32) -> f32 { + if elapsed <= 0.0 { + return 0.0; + } + if elapsed >= 1.0 { + return 1.0; + } + let t = elapsed as f32; + let decay = damping / (2.0 * mass); + let discriminant = stiffness / mass - decay * decay; + let response = if discriminant.abs() < f32::EPSILON { + 1.0 + decay * t + } else if discriminant > 0.0 { + let frequency = discriminant.sqrt(); + (frequency * t).cos() + decay / frequency * (frequency * t).sin() + } else { + let frequency = (-discriminant).sqrt(); + (frequency * t).cosh() + decay / frequency * (frequency * t).sinh() + }; + 1.0 - (-decay * t).exp() * response +} + +impl TranscriptMotion { + fn width_at(&self, now: f64) -> f32 { + self.width_from + + (self.width - self.width_from) * spring(now - self.changed - 0.012, 330.0, 32.0, 0.9) + } + + fn right_at(&self, now: f64) -> f32 { + self.right_from + + (self.right - self.right_from) * spring(now - self.changed - 0.152, 180.0, 27.0, 1.0) + } + + fn update(&mut self, next: Vec<(String, f32)>, now: f64, max_width: f32) { + if self.glyphs.len() == next.len() + && self + .glyphs + .iter() + .zip(&next) + .all(|(old, (text, width))| old.text == *text && old.width == *width) + { + return; + } + let mut prefix = 0; + while prefix < self.glyphs.len().min(next.len()) + && self.glyphs[prefix].text == next[prefix].0 + { + prefix += 1; + } + let mut suffix = 0; + while suffix < self.glyphs.len() - prefix + && suffix < next.len() - prefix + && self.glyphs[self.glyphs.len() - 1 - suffix].text == next[next.len() - 1 - suffix].0 + { + suffix += 1; + } + let total = next.iter().map(|(_, width)| width + 1.4).sum::() - 1.4; + let width = (total + 36.0).clamp(72.0, max_width.max(72.0)); + let birth_advance = next[prefix..next.len() - suffix] + .iter() + .map(|(_, width)| width + 1.4) + .sum::() + .clamp(14.0, 28.0); + let mut x = -total; + let mut glyphs = Vec::with_capacity(next.len()); + for (index, (text, glyph_width)) in next.into_iter().enumerate() { + let old = if index < prefix { + self.glyphs.get(index) + } else { + None + }; + glyphs.push(Glyph { + text, + width: glyph_width, + born: old.map_or(now + index.saturating_sub(prefix) as f64 * 0.008, |old| { + old.born + }), + from: old.map_or(x + birth_advance, |old| old.x(now)), + target: x, + moved: now + (x.abs() as f64 / 2350.0).min(0.096) + 0.026, + }); + x += glyph_width + 1.4; + } + // Keep suffix glyph identities and birth times across recognition corrections. + if suffix > 0 { + let next_len = glyphs.len(); + for offset in 0..suffix { + let old = &self.glyphs[self.glyphs.len() - suffix + offset]; + let next = &mut glyphs[next_len - suffix + offset]; + next.born = old.born; + next.from = old.x(now); + } + } + if self.glyphs.is_empty() { + self.width_from = width; + self.right_from = width / 2.0; + } else { + self.width_from = self.width_at(now); + self.right_from = self.right_at(now); + } + self.width = width; + self.right = width / 2.0; + self.changed = now; + self.glyphs = glyphs; + } +} + +pub(super) struct RailStyle { + pub stage: egui::Rect, + pub bottom: f32, + pub height: f32, + pub font: egui::FontId, + pub ink: egui::Color32, + pub background: egui::Color32, + pub border: egui::Color32, +} + +pub(super) fn clear(ui: &egui::Ui) { + ui.data_mut(|data| { + data.remove_temp::(egui::Id::unique("capsule-transcript-motion")) + }); +} + +pub(super) fn paint(ui: &egui::Ui, text: &str, style: RailStyle) { + let id = egui::Id::unique("capsule-transcript-motion"); + let now = ui.input(|input| input.time); + let mut motion = ui + .data(|data| data.get_temp::(id)) + .unwrap_or_default(); + // Grapheme units match Intl.Segmenter: do not split combining marks or emoji. + // Keep only the 64-unit visible tail and the three-unit overflow buffer. + let mut units = text.graphemes(true).rev().take(67).collect::>(); + units.reverse(); + let galleys = units + .iter() + .map(|unit| { + ui.painter() + .layout_no_wrap((*unit).to_owned(), style.font.clone(), style.ink) + }) + .collect::>(); + motion.update( + units + .iter() + .zip(&galleys) + .map(|(unit, galley)| ((*unit).to_owned(), galley.size().x)) + .collect(), + now, + style.stage.width().min(440.0), + ); + let reduce_motion = ui.style().animation_time == 0.0; + let width = if reduce_motion { + motion.width + } else { + motion.width_at(now) + }; + let right = style.stage.center().x + + if reduce_motion { + motion.right + } else { + motion.right_at(now) + }; + let rail = egui::Rect::from_min_size( + egui::pos2(right - width, style.bottom - style.height), + egui::vec2(width, style.height), + ); + ui.painter().rect_filled( + rail, + egui::CornerRadius::same((style.height / 2.0) as u8), + style.background, + ); + ui.painter().rect_stroke( + rail, + egui::CornerRadius::same((style.height / 2.0) as u8), + egui::Stroke::new(1.0, style.border), + egui::StrokeKind::Inside, + ); + let clip = rail.shrink2(egui::vec2(18.0, 0.0)); + let painter = ui.painter().with_clip_rect(clip); + for (glyph, galley) in motion.glyphs.iter().zip(galleys) { + let age = now - glyph.born; + let alpha = if reduce_motion { + 1.0 + } else { + (age / 0.180).clamp(0.0, 1.0) as f32 + }; + let y = if reduce_motion { + 0.0 + } else { + 9.0 * (1.0 - spring(age, 460.0, 23.0, 0.8)) + }; + let x = if reduce_motion { + glyph.target + } else { + glyph.x(now) + }; + painter.galley_with_override_text_color( + egui::pos2( + clip.right() + x, + rail.center().y - galley.size().y / 2.0 + y, + ), + galley, + style.ink.gamma_multiply(alpha), + ); + } + // Only a genuinely clipped leading edge fades; short text stays opaque. + let content_width = motion + .glyphs + .iter() + .map(|glyph| glyph.width + 1.4) + .sum::() + - 1.4; + if content_width > clip.width() { + for offset in 0..18 { + let fade = (1.0 - offset as f32 / 18.0).powi(2); + painter.rect_filled( + egui::Rect::from_min_size( + egui::pos2(clip.left() + offset as f32, clip.top()), + egui::vec2(1.0, clip.height()), + ), + 0, + style.background.gamma_multiply(fade), + ); + } + } + if !reduce_motion && now - motion.changed < 1.0 { + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(16)); + } + ui.data_mut(|data| data.insert_temp(id, motion)); +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/translation.rs b/openless-all/app/linux-egui/src/ui/frontend/translation.rs index c0b7acdb9..01a262530 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/translation.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/translation.rs @@ -114,11 +114,11 @@ fn toolbar(ui: &mut egui::Ui, width: f32, vm: &mut FrontendViewModel) { ui.put( input, egui::TextEdit::singleline(&mut vm.translation_query) - .id(egui::Id::new("openless-translation-search")) + .id(egui::Id::unique("openless-translation-search")) .hint_text(tr_l10n(lang, "translation.search_languages")) .text_color(theme::INK) .frame(egui::Frame::NONE) - .vertical_align(egui::Align::Center), + .align(egui::Align2::CENTER_CENTER), ); ui.painter().text( egui::pos2(row.right(), row.center().y), @@ -220,7 +220,7 @@ fn language_row( ) -> bool { let response = ui.interact( rect, - ui.id().with(("openless-language-row", native)), + ui.scope_id().with(("openless-language-row", native)), egui::Sense::click(), ); let painter = ui.painter().with_clip_rect(rect); @@ -473,7 +473,7 @@ fn paint_style_badge(ui: &egui::Ui, rect: egui::Rect, name: &str) { ); #[cfg(test)] ui.ctx() - .data_mut(|data| data.insert_temp(egui::Id::new("translation-style-badge"), rect)); + .data_mut(|data| data.insert_temp(egui::Id::unique("translation-style-badge"), rect)); } fn usage_columns(width: f32) -> usize { 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 64722bc4b..54da3696e 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 @@ -21,6 +21,27 @@ pub enum Page { // ── FrontendAction ────────────────────────────────────────────────────────── +/// Apply an adjacent swap only while its observed identities still match. +pub fn move_channel(ids: &mut [String], channel_id: &str, neighbor_id: &str, delta: isize) -> bool { + if !matches!(delta, -1 | 1) { + return false; + } + let Some(index) = ids.iter().position(|id| id == channel_id) else { + return false; + }; + let Some(target) = index + .checked_add_signed(delta) + .filter(|target| *target < ids.len()) + else { + return false; + }; + if ids[target] != neighbor_id { + return false; + } + ids.swap(index, target); + true +} + /// 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. @@ -68,6 +89,12 @@ pub enum FrontendAction { MarketplaceToggleLike(usize), MarketplaceCloseMine, /// Select a history entry (index into `history_entries`). + /// 拉取某个集合的下一页;宿主按同样的顺序切片回发(见 [`super::paging`])。 + /// `offset` 用窗口当前的已载入条数,因为宿主只知道完整集合、不知道窗口手上有多少。 + LoadMore { + collection: super::paging::Collection, + offset: usize, + }, HistorySelect(usize), /// Re-read the history list from Core. HistoryRefresh, @@ -82,14 +109,14 @@ pub enum FrontendAction { /// Dismiss the pending confirmation dialog. HistoryCancelConfirm, /// Export a history entry's recording to a file. - HistoryExport(usize), + HistoryExport(String), /// Re-run ASR on a history entry's recording. - HistoryRetranscribe(usize), + HistoryRetranscribe(String), HistoryRepolishOpen(usize), HistoryRepolishClose, - HistoryRepolish(usize, Option), + HistoryRepolish(String, Option), /// Open a history entry's recording in the system player. - HistoryPlay(usize), + HistoryPlay(String), /// Pause or resume the current in-app recording without dropping its cursor. HistoryPauseToggle, /// Seek the current recording to a millisecond timestamp. @@ -185,23 +212,22 @@ pub enum FrontendAction { /// Delete a channel (index into `settings.channels`). SettingsChannelDelete(usize), /// Open/close the "add channel" form. - SettingsChannelFormOpen(bool), + /// 「添加渠道」:宿主先建一张空渠道,再打开同一个编辑器(草稿模式)。 + SettingsChannelDraft, /// 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, + channel_id: String, + neighbor_id: String, delta: isize, }, /// Switch a channel to another provider type (Core's `set_channel_provider_type`). SettingsChannelProviderType { - index: usize, + channel_id: String, provider_type: String, }, /// Make a channel the active provider (Core's `set_active_provider`). @@ -210,6 +236,11 @@ pub enum FrontendAction { SettingsProviderField(SettingsProviderField, String), /// Save the editor: rename + endpoint/model + credentials through Core. SettingsProviderSave, + SettingsOmni(openless_linux_egui::omni_settings::OmniAction), + /// Encrypted cloud sync card (enable/unlock/restore/delete/custom server). + SettingsCloudSync(openless_linux_egui::cloud_sync_ui::CloudSyncAction), + /// Appearance → typography (font family, scale, reading width). + SettingsTypography(openless_linux_egui::typography::TypographyField), /// Drop the channel's stored secrets (Core's `remove_credential`). SettingsProviderClearSecrets, /// Ask Core for the provider's model list (Core's `provider.list_models`). @@ -224,13 +255,14 @@ pub enum FrontendAction { /// Close the provider editor. SettingsProviderClose, /// 快捷键行的交互:展开/收起编辑菜单(`None` 收起全部)。 - ShortcutMenu(Option), /// 进入录制态(`None` = 取消录制)。 ShortcutRecording(Option), /// 录入完成:主键 + 修饰键写回该绑定。 ShortcutCaptured(ShortcutField, String, Vec), /// 停用该绑定(核心录音快捷键不可停用,见 `ShortcutField::Dictation`)。 ShortcutDisable(ShortcutField), + /// 恢复该快捷键的出厂默认绑定(Tauri 录制器里的 ↻)。 + ShortcutReset(ShortcutField), /// 风格直达:开关草稿行 / 选择风格包 / 移除整行。 StyleHotkeyDraft(bool), StyleHotkeyDraftPack(usize), @@ -243,6 +275,8 @@ pub enum FrontendAction { OverviewPeriod(usize), /// Overview: metric toggle (0 = count, 1 = chars, 2 = duration). OverviewMetric(usize), + /// Dismiss a non-fatal runtime warning overlay. + DismissRuntimeWarning, /// Window close requested. WindowClose, /// Window maximize/minimize toggle. @@ -292,9 +326,9 @@ pub struct MarketplaceMinePack { // ── Settings types ────────────────────────────────────────────────────────── -/// Host permission state, mirroring the Tauri permission rows. Linux has no -/// OS permission prompts, so most of these stay `Unsupported` — but the value -/// now comes from the host snapshot instead of a hardcoded label. +/// Legacy wire names for host capabilities, not OS authorization grants. +/// On Linux, `Granted` means a microphone was enumerated or the hotkey listener +/// was initialized; neither proves that every capture/input operation will work. #[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug, Default, PartialEq, Eq)] pub enum PermissionState { #[default] @@ -351,6 +385,8 @@ pub enum ShortcutField { /// One credential channel shown in the AI-services settings tab. #[derive(Clone, serde::Serialize, serde::Deserialize, Debug, Default)] pub struct SettingsChannel { + /// 渠道 id。宿主用它定位编辑目标(Tauri 的 `Channel.id` 同义)。 + pub id: String, pub name: String, /// Model / endpoint summary shown under the channel name. pub model: String, @@ -361,8 +397,14 @@ pub struct SettingsChannel { /// 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, + /// 最近一次验证是否通过;`None` = 从未验证(Tauri 的 `lastTest`)。 + pub last_ok: Option, + /// 失败时的错误文本,Core 原样回显,不在这里二次解释。 + pub last_error: Option, + /// 通过时的往返耗时(毫秒)。 + pub last_latency_ms: Option, + /// 距最近一次验证过了多少秒。宿主用 epoch 秒算好,窗口只负责本地化。 + pub last_check_age_seconds: Option, } /// A field of the provider editor. Secret fields are write-only: opening an @@ -376,6 +418,13 @@ pub enum SettingsProviderField { AuthMode, PrimarySecret, SecondarySecret, + RequestFormat, + MessagesThinking, + MaxTokens, + ThinkingBudget, + ServiceTier, + Temperature, + ExtraHeaders, } /// Which inputs the editor renders. Core's `AuthRequirement` decides this; @@ -391,7 +440,9 @@ pub enum SettingsProviderAuth { Volcengine, /// Xfyun: AppID + API Key. Xfyun, - /// Another Core-defined shape (Tencent Cloud and friends). + /// Tencent Cloud: public AppID and two write-only secret fields. + TencentCloud, + /// Another Core-defined credential shape. Other, /// Endpoint + Model + API Key. ApiKey, @@ -402,9 +453,19 @@ pub enum SettingsProviderAuth { #[derive(Clone, serde::Serialize, serde::Deserialize, Debug)] pub struct SettingsProviderEditor { pub channel_id: String, + /// 渠道种类:弹窗副标题在「文字处理渠道 / 语音识别渠道」之间切换。 + pub is_asr: bool, + /// 草稿(刚从「添加渠道」建出来):标题是「添加渠道」,且未交互就关闭时要回收。 + pub is_draft: bool, /// Localized provider label (read-only). pub provider: String, pub provider_type: String, + #[serde(default)] + pub endpoint_presets: Vec, + #[serde(default)] + pub api_key_optional: bool, + #[serde(default)] + pub default_endpoint: String, pub name: String, pub endpoint: String, pub model: String, @@ -429,6 +490,8 @@ pub struct SettingsProviderEditor { pub has_models_url: bool, /// 模型字段是否处于「自定义模型…」手输模式(Tauri `customModelMode`)。 pub custom_model: bool, + #[serde(default)] + pub protocol: openless_linux_egui::provider_protocol::ProviderProtocolDraft, pub busy: bool, } @@ -467,6 +530,8 @@ pub enum SettingsField { AudioCue, /// 录音胶囊是否显示(Tauri `showCapsule`;Linux 上同样生效)。 ShowCapsule, + CapsuleTranscript, + LlmThinking, MuteWhileRecording, /// 轻量本机麦克风增益与软限幅。 AudioEnhancement, @@ -476,6 +541,10 @@ pub enum SettingsField { TestingFeatures, LessComputer, Multimodal, + /// 选区助手:语音编辑(口述指令改选区)开关(Tauri `selectionVoiceEnabled`)。 + SelectionVoiceEnabled, + /// 选区助手:自动判断「提问 vs 编辑」(Tauri `selectionVoiceIntentMode`)。 + SelectionVoiceAutoIntent, } #[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug)] @@ -485,13 +554,19 @@ pub enum SettingsComboField { Microphone, /// 胶囊样式:0 = siri(流光),1 = classic(经典药丸),2 = typeless(深色胶囊)。 CapsuleStyle, + CapsuleTranscriptFontSize, RecordingMode, SilenceSeconds, RemoteDefaultMode, /// 选区润色交付方式:0 = 直接替换,1 = 预览确认。 SelectionPolishDelivery, + /// 识别管线模式:0 = 传统(ASR + LLM 两段),1 = 多模态(单模型一步)。 + /// 与 Tauri 的 `pipelineMode` 同义:切换只改偏好,不动另一套凭据。 + PipelineMode, /// Less Computer 的 Agent 后端(0 = Claude Code, 1 = OpenCode, 2 = Codex, 3 = dsh)。 CodingAgentProvider, + /// 选区助手:编辑方案格式(0 = XML,1 = JSON)。 + SelectionVoicePlanFormat, /// Less Computer 权限模式(0 = 放行, 1 = 只读/计划, 2 = 默认, 3 = 完全放行)。 CodingAgentPermission, } @@ -511,13 +586,20 @@ pub enum SettingsTextField { CodingAgentModel, CodingAgentWorkdir, CodingAgentExe, + /// 选区助手:额外问句线索(每行一个)。 + SelectionVoiceKeywords, + /// 选区助手:编辑方案系统提示词(留空 = 用风格包 / 内置默认)。 + SelectionVoiceSystemPrompt, } #[derive(Clone, serde::Serialize, serde::Deserialize, Copy, Debug)] pub enum SettingsActionField { RetrySave, + RefreshLinuxAccess, /// 试听录音提示音(Tauri `audioCuePreview` 按钮)。 PreviewAudioCue, + /// 恢复默认的编辑方案系统提示词(Tauri `editSystemPromptReset`)。 + ResetSelectionVoicePrompt, ExportDiagnostics, CopyCertFingerprint, OpenGitHub, @@ -685,6 +767,9 @@ pub struct FrontendViewModel { pub tools_open: bool, pub settings_open: bool, + /// Localized non-fatal runtime warning (for example, unavailable fcitx5). + pub runtime_warning: Option, + /// 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")] @@ -704,6 +789,11 @@ pub struct FrontendViewModel { pub history_query: String, pub history_selected: usize, pub history_entries: Vec, + /// 列表里的历史总条数(概览页的 `history_total` 是「累计听写」统计口径, + /// 两者不是一回事)。历史按页下发(见 [`super::paging`]),`history_entries` + /// 只是已载入的前缀;`history_list_total > history_entries.len()` 时窗口显示 + /// 「已载入 X / 共 Y」并可继续加载。 + pub history_list_total: usize, pub quick_note_recording: bool, /// Whether the dismissible shortcut card on the Quick Note page is hidden. pub quick_note_shortcut_hidden: bool, @@ -720,7 +810,11 @@ pub struct FrontendViewModel { // Vocab pub vocab_entries: Vec, + /// 词库总条数(`vocab_entries` 只是已载入的前缀)。 + pub vocab_total: usize, pub vocab_rules: Vec, + /// 纠错规则总条数(`vocab_rules` 只是已载入的前缀)。 + pub correction_rule_total: usize, /// 纠正规则页的「只看自动收集」筛选(Tauri 的 `onlyLearnedRules`)。 pub vocab_rules_only_learned: bool, /// 0 = all, 1 = auto-collected, 2 = manual. @@ -739,6 +833,8 @@ pub struct FrontendViewModel { // Style pub style_packs: Vec, + /// 风格包总条数(`style_packs` 只是已载入的前缀;图标 data URL 很占体积)。 + pub style_pack_total: usize, pub style_selected: usize, pub style_selection_workflow: bool, pub style_editor_open: bool, @@ -774,6 +870,8 @@ pub struct FrontendViewModel { pub marketplace_query: String, pub marketplace_sort: MarketplaceSort, pub marketplace_packs: Vec, + /// 市场列表总条数(`marketplace_packs` 只是已载入的前缀)。 + pub marketplace_total: usize, pub marketplace_selected: Option, pub marketplace_detail_prompt: Option, pub marketplace_mine_open: bool, @@ -821,7 +919,6 @@ pub struct FrontendViewModel { /// host did start the listener) keep the section visible. pub hotkeys_supported: bool, /// 展开编辑菜单的快捷键行(`None` = 都收起)。 - pub shortcut_menu: Option, /// 正在录入按键的快捷键行(`None` = 未在录入)。 pub shortcut_recording: Option, /// 「+ 添加风格快捷键」草稿行是否打开。 @@ -830,6 +927,11 @@ pub struct FrontendViewModel { pub style_hotkey_draft_pack: usize, /// Real host permission snapshot for 隐私与数据(不再写死「已授权」)。 pub permissions: SettingsPermissions, + /// Actual backend data directory; empty until the backend is available. + #[serde(default)] + pub application_data_directory: String, + #[serde(default)] + pub linux_access: openless_linux_egui::linux_access::LinuxAccessSnapshot, /// 远程输入服务正在监听 → 显示配对码 / 访问网址 / 证书指纹。 pub remote_running: bool, /// 服务报出的地址已过期:继续展示旧网址会诱导用户在错误的地址上配对, @@ -843,9 +945,6 @@ pub struct FrontendViewModel { /// 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. @@ -890,6 +989,14 @@ pub struct FrontendViewModel { pub selection_polish_hotkey: String, /// Pipeline mode is 多模态 → the task strip hides the legacy LLM/ASR views. pub pipeline_multimodal: bool, + #[serde(default)] + pub omni: openless_linux_egui::omni_settings::OmniSettings, + #[serde(default)] + pub cloud_sync: openless_linux_egui::cloud_sync_ui::CloudSyncUi, + /// Reading typography mirrors the saved UI state so the combos show the + /// live choice without re-reading the file each frame. + #[serde(default)] + pub typography: openless_linux_egui::typography::Typography, } impl Default for FrontendViewModel { @@ -901,6 +1008,7 @@ impl Default for FrontendViewModel { style_open: true, tools_open: true, settings_open: false, + runtime_warning: None, overview_loading: true, overview_error: None, overview: None, @@ -909,6 +1017,7 @@ impl Default for FrontendViewModel { history_query: String::new(), history_selected: 0, history_entries: Vec::new(), + history_list_total: 0, quick_note_recording: false, quick_note_shortcut_hidden: false, history_loading: true, @@ -920,7 +1029,9 @@ impl Default for FrontendViewModel { history_repolish_result: None, history_repolish_error: None, vocab_entries: Vec::new(), + vocab_total: 0, vocab_rules: Vec::new(), + correction_rule_total: 0, vocab_rules_only_learned: false, vocab_filter: 0, vocab_query: String::new(), @@ -935,6 +1046,7 @@ impl Default for FrontendViewModel { vocab_error: None, vocab_unsupported: true, style_packs: Vec::new(), + style_pack_total: 0, style_selected: 0, style_selection_workflow: false, style_editor_open: false, @@ -964,6 +1076,7 @@ impl Default for FrontendViewModel { marketplace_query: String::new(), marketplace_sort: MarketplaceSort::Popular, marketplace_packs: Vec::new(), + marketplace_total: 0, marketplace_selected: None, marketplace_detail_prompt: None, marketplace_mine_open: false, @@ -992,9 +1105,6 @@ impl Default for FrontendViewModel { 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, @@ -1013,6 +1123,9 @@ impl Default for FrontendViewModel { status: String::new(), selection_polish_hotkey: String::new(), pipeline_multimodal: false, + omni: Default::default(), + cloud_sync: Default::default(), + typography: Default::default(), remote_running: false, remote_urls_stale: false, remote_pin: String::new(), @@ -1022,11 +1135,12 @@ impl Default for FrontendViewModel { 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(), + application_data_directory: String::new(), + linux_access: Default::default(), dictation_hotkey: String::new(), qa_hotkey: String::new(), quick_note_hotkey: String::new(), @@ -1066,6 +1180,10 @@ pub struct SettingsFields { /// 录音胶囊开关与样式(Tauri `capsuleLabel` / `capsuleStyleLabel`)。 pub show_capsule: bool, pub capsule_style: usize, + pub capsule_transcript: bool, + pub capsule_transcript_font_size: usize, + #[serde(default)] + pub llm_thinking_enabled: bool, pub record_audio_for_debug: bool, pub history_max_entries: String, /// 风格直达快捷键(可录制/停用/移除)。 @@ -1078,6 +1196,16 @@ pub struct SettingsFields { pub audio_recording_max_entries: String, /// 选区润色交付方式:0 = 直接替换,1 = 预览确认。 pub selection_polish_delivery: usize, + /// 选区助手:语音编辑开关。 + pub selection_voice_enabled: bool, + /// 选区助手:自动判断意图。 + pub selection_voice_auto_intent: bool, + /// 选区助手:额外问句线索,一行一条。 + pub selection_voice_keywords: String, + /// 选区助手:编辑方案格式(0 = XML,1 = JSON)。 + pub selection_voice_plan_format: usize, + /// 选区助手:编辑方案系统提示词。 + pub selection_voice_system_prompt: String, pub activity_heatmap: bool, pub system_proxy: bool, pub testing_features: bool, @@ -1119,6 +1247,9 @@ impl Default for SettingsFields { audio_cue: false, show_capsule: true, capsule_style: 0, + capsule_transcript: true, + capsule_transcript_font_size: 14, + llm_thinking_enabled: false, record_audio_for_debug: false, history_max_entries: String::new(), style_pack_hotkeys: Vec::new(), @@ -1126,6 +1257,11 @@ impl Default for SettingsFields { polish_skip_max_chars: String::new(), audio_recording_max_entries: String::new(), selection_polish_delivery: 0, + selection_voice_enabled: false, + selection_voice_auto_intent: false, + selection_voice_keywords: String::new(), + selection_voice_plan_format: 0, + selection_voice_system_prompt: String::new(), activity_heatmap: true, system_proxy: false, testing_features: false, @@ -1159,3 +1295,38 @@ mod lang_tag { Lang::parse(&tag).ok_or_else(|| serde::de::Error::custom(format!("unknown lang tag {tag}"))) } } + +#[cfg(test)] +mod action_identity_tests { + use super::*; + #[test] + fn a_drag_tracks_the_channel_through_multiple_neighbors() { + let mut ids = vec!["A".into(), "B".into(), "C".into()]; + assert!(move_channel(&mut ids, "A", "B", 1)); + assert!(!move_channel(&mut ids, "A", "B", 1)); + assert!(move_channel(&mut ids, "A", "C", 1)); + for _ in 0..10 { + assert!(!move_channel(&mut ids, "A", "C", 1)); + } + assert_eq!(ids, ["B", "C", "A"]); + assert!(move_channel(&mut ids, "A", "C", -1)); + assert_eq!(ids, ["B", "A", "C"]); + assert!(!move_channel(&mut ids, "missing", "A", 1)); + assert!(!move_channel(&mut ids, "B", "A", -1)); + assert!(!move_channel(&mut ids, "A", "C", 2)); + } + #[test] + fn history_actions_keep_the_record_id_on_the_wire() { + for action in [ + FrontendAction::HistoryPlay("entry-220".into()), + FrontendAction::HistoryExport("entry-220".into()), + FrontendAction::HistoryRetranscribe("entry-220".into()), + FrontendAction::HistoryRepolish("entry-220".into(), Some(2)), + ] { + let encoded = serde_json::to_value(&action).unwrap(); + let decoded: FrontendAction = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), encoded); + assert!(encoded.to_string().contains("entry-220")); + } + } +} diff --git a/openless-all/app/linux-egui/src/ui/frontend/vocab.rs b/openless-all/app/linux-egui/src/ui/frontend/vocab.rs index 40e91c43c..e810289c0 100644 --- a/openless-all/app/linux-egui/src/ui/frontend/vocab.rs +++ b/openless-all/app/linux-egui/src/ui/frontend/vocab.rs @@ -27,7 +27,7 @@ const NEW_WORD_FOCUS: &str = "openless-vocab-new-word-focus"; /// 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)) + data.get_temp::(egui::Id::unique(NEW_WORD_OPEN)) .unwrap_or(false) }) } @@ -43,24 +43,24 @@ const SEARCH_WIDTH: f32 = 210.0; fn search_open(ctx: &egui::Context) -> bool { ctx.data(|data| { - data.get_temp::(egui::Id::new(SEARCH_ID)) + data.get_temp::(egui::Id::unique(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)); + ctx.data_mut(|data| data.insert_temp(egui::Id::unique(SEARCH_ID), open)); } fn selection(ctx: &egui::Context) -> BTreeSet { ctx.data(|data| { - data.get_temp::>(egui::Id::new(SELECTION_ID)) + data.get_temp::>(egui::Id::unique(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)); + ctx.data_mut(|data| data.insert_temp(egui::Id::unique(SELECTION_ID), selection)); } // ── Entry point ───────────────────────────────────────────────────────────── @@ -97,8 +97,8 @@ pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec(egui::Id::new(PRESETS_OPEN)) + data.get_temp::(egui::Id::unique(PRESETS_OPEN)) .unwrap_or(false) }); - let panel_id = egui::Id::new(("openless-vocab-bottom-height", presets_expanded)); + let panel_id = egui::Id::unique(("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(); @@ -188,6 +188,14 @@ pub fn page(ui: &mut egui::Ui, vm: &mut FrontendViewModel, actions: &mut Vec(egui::Id::new(NEW_WORD_OPEN)) + data.get_temp::(egui::Id::unique(NEW_WORD_OPEN)) .unwrap_or(false) }) { @@ -338,13 +346,13 @@ fn tool_row( #[cfg(test)] ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new("openless-vocab-tabs-test-rects"), + egui::Id::unique("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"), + ui.scope_id().with("vocab-active-pill-x"), tab_rects[active].left(), 0.18, ); @@ -361,7 +369,7 @@ fn tool_row( let active = active == index; let response = ui.interact( rect, - ui.id().with(("openless-vocab-tab", index)), + ui.scope_id().with(("openless-vocab-tab", index)), egui::Sense::click(), ); let painter = ui.painter().with_clip_rect(rect); @@ -405,7 +413,7 @@ fn tool_row( ); let checkbox = ui.interact( checkbox_rect, - ui.id().with("openless-vocab-select-all"), + ui.scope_id().with("openless-vocab-select-all"), egui::Sense::click(), ); draw_checkbox(ui, checkbox_rect, all_selected, partial); @@ -430,7 +438,7 @@ fn tool_row( ); let label_response = ui.interact( label_rect, - ui.id().with("openless-vocab-select-all-label"), + ui.scope_id().with("openless-vocab-select-all-label"), egui::Sense::click(), ); if checkbox.clicked() || label_response.clicked() { @@ -470,7 +478,7 @@ fn tool_row( let response = ui.put( inner, egui::TextEdit::singleline(&mut vm.vocab_query) - .id(egui::Id::new(SEARCH_INPUT_ID)) + .id(egui::Id::unique(SEARCH_INPUT_ID)) .hint_text(tr_l10n(lang, "vocab.search_placeholder")) .text_color(theme::INK) .frame(egui::Frame::NONE), @@ -481,7 +489,7 @@ fn tool_row( } let search_response = ui.interact( circle, - ui.id().with("openless-vocab-search-toggle"), + ui.scope_id().with("openless-vocab-search-toggle"), egui::Sense::click(), ); ui.painter().rect_filled( @@ -507,7 +515,7 @@ fn tool_row( } else { set_search_open(ui.ctx(), !open); if !open { - ui.memory_mut(|memory| memory.request_focus(egui::Id::new(SEARCH_ID))); + ui.memory_mut(|memory| memory.request_focus(egui::Id::unique(SEARCH_ID))); } } } @@ -734,7 +742,7 @@ fn quick_add( let response = ui.add_sized( [content_width, 20.0], egui::TextEdit::singleline(&mut vm.vocab_input) - .id(egui::Id::new(INPUT_ID)) + .id(egui::Id::unique(INPUT_ID)) .desired_width(content_width) .hint_text(tr_l10n(lang, "vocab.placeholder")) .frame(egui::Frame::NONE), @@ -780,7 +788,7 @@ fn presets( ) { let lang = vm.lang; let open = ui.ctx().data(|data| { - data.get_temp::(egui::Id::new(PRESETS_OPEN)) + data.get_temp::(egui::Id::unique(PRESETS_OPEN)) .unwrap_or(false) }); card(ui, width, |ui| { @@ -822,7 +830,7 @@ fn presets( } if response.clicked() { ui.ctx() - .data_mut(|data| data.insert_temp(egui::Id::new(PRESETS_OPEN), !open)); + .data_mut(|data| data.insert_temp(egui::Id::unique(PRESETS_OPEN), !open)); } if !open { return; @@ -1009,7 +1017,7 @@ fn new_word_overlay( }) { close = true; } - egui::Area::new(egui::Id::new("openless-vocab-new-word-area")) + egui::Area::new(egui::Id::unique("openless-vocab-new-word-area")) .order(egui::Order::Foreground) .fixed_pos(region.min) .constrain(false) @@ -1073,7 +1081,7 @@ fn new_word_overlay( 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) + data.insert_temp(egui::Id::unique("openless-new-word-close-rect"), rect) }); if response.clicked() { close = true; @@ -1099,7 +1107,7 @@ fn new_word_overlay( if ui .ctx() .data_mut(|data| { - data.remove_temp::(egui::Id::new(NEW_WORD_FOCUS)) + data.remove_temp::(egui::Id::unique(NEW_WORD_FOCUS)) }) .unwrap_or(false) { @@ -1248,7 +1256,7 @@ fn new_word_overlay( #[cfg(test)] ui.ctx().data_mut(|data| { data.insert_temp( - egui::Id::new("openless-new-word-card-rect"), + egui::Id::unique("openless-new-word-card-rect"), card.response.rect, ) }); @@ -1256,7 +1264,7 @@ fn new_word_overlay( let _ = card; }); if close { - ctx.data_mut(|data| data.insert_temp(egui::Id::new(NEW_WORD_OPEN), false)); + ctx.data_mut(|data| data.insert_temp(egui::Id::unique(NEW_WORD_OPEN), false)); } } @@ -1290,7 +1298,7 @@ fn primary_button( ) -> egui::Response { let response = ui.interact( rect, - ui.id().with(("openless-vocab-primary", label)), + ui.scope_id().with(("openless-vocab-primary", label)), egui::Sense::click(), ); let painter = ui.painter().with_clip_rect(rect); @@ -1349,7 +1357,7 @@ mod tests { 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)); + ctx.data_mut(|data| data.insert_temp(egui::Id::unique(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 { @@ -1363,11 +1371,11 @@ mod tests { 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")) + data.get_temp(egui::Id::unique("openless-vocab-bottom-test-rect")) .unwrap() }); let bottom: f32 = ctx.data(|data| { - data.get_temp(egui::Id::new("openless-vocab-viewport-test-bottom")) + data.get_temp(egui::Id::unique("openless-vocab-viewport-test-bottom")) .unwrap() }); assert!( @@ -1401,7 +1409,7 @@ mod tests { |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")) + data.get_temp(egui::Id::unique("openless-vocab-tabs-test-rects")) .unwrap() }); assert_eq!(outer.height(), 39.0); @@ -1448,11 +1456,11 @@ mod tests { |_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")) + data.get_temp(egui::Id::unique("openless-new-word-card-rect")) .unwrap() }); let close: egui::Rect = ctx.data(|data| { - data.get_temp(egui::Id::new("openless-new-word-close-rect")) + data.get_temp(egui::Id::unique("openless-new-word-close-rect")) .unwrap() }); assert!(card.contains_rect(close), "close={close:?}, card={card:?}"); diff --git a/openless-all/app/linux-egui/src/ui/theme.rs b/openless-all/app/linux-egui/src/ui/theme.rs index f8813b059..1350634e7 100644 --- a/openless-all/app/linux-egui/src/ui/theme.rs +++ b/openless-all/app/linux-egui/src/ui/theme.rs @@ -63,6 +63,14 @@ pub const CAPSULE_BADGE_BG: egui::Color32 = egui::Color32::from_rgb(250, 250, 25 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); +/// 蓝色主按钮的悬停色(Tauri 的 `.ol-btn-blue:hover` 略深)。 +pub const BLUE_HOVER: egui::Color32 = egui::Color32::from_rgb(29, 78, 216); +/// Tauri `.ol-channel-delete-button`:`rgba(220,38,38,0.05)` 底 + `0.3` 红描边。 +pub const DANGER_FILL: egui::Color32 = egui::Color32::from_rgba_premultiplied(11, 2, 2, 13); +/// 危险按钮悬停底色(比默认略深一档)。 +pub const DANGER_FILL_HOVER: egui::Color32 = egui::Color32::from_rgba_premultiplied(17, 3, 3, 26); +/// Tauri `.ol-channel-delete-button` 的 `rgba(220,38,38,0.3)` 描边。 +pub const DANGER_BORDER: egui::Color32 = egui::Color32::from_rgba_premultiplied(66, 11, 11, 77); /// Font key of the registered Medium face (see [`medium_font`]). const MEDIUM_FACE: &str = "openless-medium"; @@ -112,8 +120,10 @@ fn fontconfig_match(query: &str) -> Option<(PathBuf, u32)> { /// 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) { +pub fn install(ctx: &egui::Context, chosen: openless_linux_egui::typography::UiFont) { enable_antialiasing(ctx); + // 切换字体后可能没有独立 Medium 面,不能沿用上一轮注册标记。 + MEDIUM_AVAILABLE.store(false, std::sync::atomic::Ordering::Relaxed); // (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) { @@ -123,8 +133,16 @@ pub fn install(ctx: &egui::Context) { // 返回的是 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)); + // 外观页选中的字族(Tauri 的 `--ol-font-ui`):拉丁面按用户选择解析, + // CJK 面仍走下面的 `:lang=zh-cn` 兜底,缺字形时自动回退。 + if chosen == openless_linux_egui::typography::UiFont::System { + if let Some((path, index)) = fontconfig_match("sans-serif:lang=en") { + candidates.push(("openless-ui-latin".to_owned(), path, index, true, false)); + } + } else if let Some((path, index)) = + fontconfig_match(&format!("{}:lang=en", chosen.fontconfig_query())) + { + candidates.push(("openless-ui-chosen".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)); @@ -176,7 +194,8 @@ pub fn install(ctx: &egui::Context) { fonts.font_data.insert( name.clone(), egui::FontData { - font: bytes.into(), + // egui 0.37:`font` 由 `Cow<[u8]>` 改成 `Arc>`。 + font: std::sync::Arc::new(bytes), index, tweak: Default::default(), } @@ -207,15 +226,15 @@ pub fn install(ctx: &egui::Context) { // 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"); + let regular_face = fontconfig_match(chosen.fontconfig_query()); + let medium_face = fontconfig_match(&format!("{}:weight=medium", chosen.fontconfig_query())); 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(), + font: std::sync::Arc::new(bytes), index, tweak: Default::default(), } @@ -232,6 +251,12 @@ pub fn install(ctx: &egui::Context) { fonts .families .insert(egui::FontFamily::Name(MEDIUM_FAMILY.into()), chain); + // 界面正文默认也用 Medium 面:Regular 的 Noto Sans CJK 在 11–13px + // 时笔画又细又尖(用户反馈"不圆润"),Tauri 那边是 font-weight 500。 + // egui 没有字重轴,只能换面;标题仍靠字号/颜色区分层级。 + if let Some(chain) = fonts.families.get_mut(&egui::FontFamily::Proportional) { + chain.insert(0, MEDIUM_FACE.to_owned()); + } MEDIUM_AVAILABLE.store(true, std::sync::atomic::Ordering::Relaxed); } } @@ -250,7 +275,7 @@ pub fn install(ctx: &egui::Context) { } /// Feather shape edges in physical pixels on both the WGPU windows (which also -/// use 4x MSAA) and the single-sample EGL layer surface. +/// use 4x MSAA) and the single-sample Vulkan layer surface. pub fn enable_antialiasing(ctx: &egui::Context) { ctx.tessellation_options_mut(|options| { options.feathering = true; diff --git a/openless-all/app/linux-egui/src/ui_state.rs b/openless-all/app/linux-egui/src/ui_state.rs index 52cd0c41a..6f5632b45 100644 --- a/openless-all/app/linux-egui/src/ui_state.rs +++ b/openless-all/app/linux-egui/src/ui_state.rs @@ -125,6 +125,76 @@ fn write_state_value(key: &str, value: serde_json::Value) -> Result<(), UiStateE }) } +/// Reading typography (font family / scale / content width). Missing or +/// unreadable fields fall back to the Tauri defaults instead of failing. +pub fn load_typography() -> crate::typography::Typography { + use crate::typography::{ContentWidth, FontScale, Typography, UiFont}; + let document = read_state_object(); + let read = |key: &str| document.get(key).and_then(serde_json::Value::as_str); + Typography { + font: read("font_family") + .and_then(UiFont::from_tag) + .unwrap_or_default(), + scale: read("font_scale") + .and_then(FontScale::from_tag) + .unwrap_or_default(), + width: read("content_width") + .and_then(ContentWidth::from_tag) + .unwrap_or_default(), + } +} + +/// Persist the whole typography document. Every field is written so a partial +/// write can never leave the three controls disagreeing with each other. +pub fn save_typography(typography: crate::typography::Typography) -> Result<(), UiStateError> { + write_state_values(&[ + ( + "font_family", + serde_json::Value::String(typography.font.tag().to_string()), + ), + ( + "font_scale", + serde_json::Value::String(typography.scale.tag().to_string()), + ), + ( + "content_width", + serde_json::Value::String(typography.width.tag().to_string()), + ), + ]) +} + +/// Merge several keys in one atomic write (one temp file + one rename). +fn write_state_values(entries: &[(&str, serde_json::Value)]) -> Result<(), UiStateError> { + let Some(dir) = ui_state_dir() else { + return Err(io_error( + "resolve ui-state directory", + std::io::Error::new(std::io::ErrorKind::NotFound, "HOME is unavailable"), + )); + }; + let Some(path) = ui_state_path() else { + return Err(io_error( + "resolve ui-state path", + std::io::Error::new(std::io::ErrorKind::NotFound, "HOME is unavailable"), + )); + }; + std::fs::create_dir_all(&dir).map_err(|error| io_error("create ui-state directory", error))?; + let mut document = read_state_object(); + for (key, value) in entries { + document.insert((*key).to_string(), value.clone()); + } + 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(|_| ()) + .map_err(|error| match error { + crate::desktop::DesktopError::InvalidInput(message) => UiStateError::Json(message), + crate::desktop::DesktopError::Io { operation, source } => { + UiStateError::Io { operation, source } + } + other => UiStateError::Json(other.to_string()), + }) +} + /// 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 { @@ -141,6 +211,47 @@ pub fn save_quick_note_shortcut_hidden(hidden: bool) -> Result<(), UiStateError> ) } +/// 胶囊「预备 → 就绪」的平均耗时(ms):Tauri 存在 localStorage +/// (`ol-capsule-warmup-ms`),Linux 这边落在同一份 UI 状态文档里(`capsule_warmup_ms`)。 +/// 缺失 / 越界一律回落到默认值,学习值也不可能把动画带飞。 +pub fn load_capsule_warmup_ms() -> f32 { + let stored = read_state_object() + .get("capsule_warmup_ms") + .and_then(serde_json::Value::as_f64); + match stored { + Some(value) if value.is_finite() => (value as f32).clamp( + crate::siri_wgpu::MIN_WARMUP_MS, + crate::siri_wgpu::MAX_WARMUP_MS, + ), + _ => crate::siri_wgpu::DEFAULT_WARMUP_MS, + } +} + +/// 用一次实测的「预备 → 就绪」耗时更新学习值,并把新值落盘、返回给调用方。 +/// +/// 异常样本直接丢弃(返回 `None`):<20ms 多半不是真入场,>3s 多半是首次设备授权或 +/// 系统卡顿,都不代表常态。公式与 Tauri `Capsule.tsx` 完全一致: +/// `clamp(prev * 0.7 + observed * 0.3, 60, 600)`。 +pub fn learn_capsule_warmup_ms(observed_ms: f32) -> Option { + if !observed_ms.is_finite() || !(20.0..=3000.0).contains(&observed_ms) { + return None; + } + let previous = load_capsule_warmup_ms(); + let next = (previous * 0.7 + observed_ms * 0.3).clamp( + crate::siri_wgpu::MIN_WARMUP_MS, + crate::siri_wgpu::MAX_WARMUP_MS, + ); + let _ = save_capsule_warmup_ms(next); + Some(next) +} + +pub fn save_capsule_warmup_ms(warmup_ms: f32) -> Result<(), UiStateError> { + write_state_value( + "capsule_warmup_ms", + serde_json::Value::from(f64::from(warmup_ms)), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -223,4 +334,42 @@ mod tests { assert_eq!(load_locale_pref(), LocalePref::Lang(Lang::Ja)); }); } + + #[test] + fn capsule_warmup_defaults_and_learns_a_bounded_average() { + with_tmp_state(|_dir| { + // 缺省 = Tauri 的 150ms;损坏 / 越界值也回落默认。 + assert_eq!( + load_capsule_warmup_ms(), + crate::siri_wgpu::DEFAULT_WARMUP_MS + ); + save_capsule_warmup_ms(9_999.0).unwrap(); + assert_eq!(load_capsule_warmup_ms(), crate::siri_wgpu::MAX_WARMUP_MS); + save_capsule_warmup_ms(1.0).unwrap(); + assert_eq!(load_capsule_warmup_ms(), crate::siri_wgpu::MIN_WARMUP_MS); + }); + } + + #[test] + fn capsule_warmup_learning_moves_towards_the_observation() { + with_tmp_state(|_dir| { + // 异常样本(太快 / 太慢 / 非数)一律丢弃。 + assert_eq!(learn_capsule_warmup_ms(5.0), None); + assert_eq!(learn_capsule_warmup_ms(9_000.0), None); + assert_eq!(learn_capsule_warmup_ms(f32::NAN), None); + // 正常样本:EMA 向观测值靠(150*0.7 + 500*0.3 = 255)。 + let learned = learn_capsule_warmup_ms(500.0).expect("sample is plausible"); + assert!((learned - 255.0).abs() < 0.01, "{learned}"); + assert!((load_capsule_warmup_ms() - 255.0).abs() < 0.01); + // 连续更快就绪会把估计拉下来(但不会越出下限)。 + let mut value = learned; + for _ in 0..40 { + value = learn_capsule_warmup_ms(25.0).expect("sample is plausible"); + } + assert!( + (value - crate::siri_wgpu::MIN_WARMUP_MS).abs() < 0.01, + "{value}" + ); + }); + } } diff --git a/openless-all/app/linux-egui/src/wgpu_device.rs b/openless-all/app/linux-egui/src/wgpu_device.rs new file mode 100644 index 000000000..8906d303b --- /dev/null +++ b/openless-all/app/linux-egui/src/wgpu_device.rs @@ -0,0 +1,63 @@ +//! 所有 wgpu 设备共用的 `DeviceDescriptor`。 +//! +//! wgpu 28 起 `MemoryHints` 的默认值是 `Performance`:wgpu-hal 的 Vulkan 子分配器 +//! 会按 device 128–256 MB / host 64–128 MB 的内存块向驱动申请 +//! (`wgpu-hal/src/lib.rs` 的 `AllocationSizes::from_memory_hints`)。对 egui 这种 +//! 「一堆小 buffer + 小纹理」的负载,那块显存纯属预占: +//! +//! | hints | 单个渲染进程建 device 后的显存(GTX 1660 SUPER / NVIDIA 615.71.09) | +//! |---|---| +//! | `Performance`(wgpu 默认) | 195 MiB | +//! | `MemoryUsage`(这里) | 15 MiB | +//! +//! 帧率不受影响(实测胶囊 29.8 fps 上限、设置页 72.7 fps 两档都不变)。宿主、主窗、 +//! 每个弹窗都是独立进程,各自建一个设备,所以这里统一按最低占用配置;egui 也要求 +//! 2D 纹理上限能覆盖 4K 表面,这一点与 egui-wgpu 的默认保持一致。 + +use wgpu::{DeviceDescriptor, Limits, MemoryHints}; + +/// 与 egui-wgpu 默认的 `max_texture_dimension_2d` 一致:足够容纳 4K+ 表面。 +const MAX_TEXTURE_DIMENSION_2D: u32 = 8192; + +/// eframe 窗口与原生 layer-shell 胶囊共用的一份设备描述。 +pub fn device_descriptor() -> DeviceDescriptor<'static> { + DeviceDescriptor { + label: Some("openless-wgpu-device"), + required_limits: Limits { + max_texture_dimension_2d: MAX_TEXTURE_DIMENSION_2D, + ..Limits::default() + }, + memory_hints: MemoryHints::MemoryUsage, + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn device_descriptor_prefers_memory_usage_over_performance() { + assert!( + matches!(device_descriptor().memory_hints, MemoryHints::MemoryUsage), + "Performance hints reserve a 128 MiB device block per process" + ); + } + + #[test] + fn device_descriptor_still_covers_4k_surfaces() { + assert_eq!( + device_descriptor().required_limits.max_texture_dimension_2d, + MAX_TEXTURE_DIMENSION_2D + ); + // 3840 宽是 egui 在 4K 表面下的硬需求;常量断言放在 const 块里, + // 编译期就能拦住把上限调小的改动。 + const { assert!(MAX_TEXTURE_DIMENSION_2D >= 3840) }; + } + + #[test] + fn device_descriptor_asks_for_no_optional_features() { + // 只改显存策略:不能顺手把 wgpu 的实验特性打开。 + assert!(device_descriptor().required_features.is_empty()); + } +} diff --git a/openless-all/app/linux-egui/tests/localization_contract.rs b/openless-all/app/linux-egui/tests/localization_contract.rs index f2008922f..d2e084203 100644 --- a/openless-all/app/linux-egui/tests/localization_contract.rs +++ b/openless-all/app/linux-egui/tests/localization_contract.rs @@ -13,6 +13,81 @@ use std::collections::BTreeSet; use std::path::{Path, PathBuf}; +use openless_linux_egui::{fmt_l10n, tr_l10n, Lang, LANGS}; + +#[test] +fn cloud_sync_counts_are_formatted_in_every_language() { + for lang in LANGS { + let review = fmt_l10n(lang, "cloudSyncE2ee.deviceReview", &[&2]); + assert!(!review.contains("{}"), "{lang:?}: {review}"); + assert_eq!(review.matches('2').count(), 1, "{lang:?}: {review}"); + + let retention = fmt_l10n(lang, "cloudSyncE2ee.backupRetention", &[&30]); + assert!(!retention.contains("{}"), "{lang:?}: {retention}"); + assert_eq!(retention.matches("30").count(), 1, "{lang:?}: {retention}"); + + let progress = fmt_l10n(lang, "cloudSyncE2ee.conflictProgress", &[&1, &3]); + let expected = match lang { + Lang::ZhCn => "已处理 1 / 3 项冲突", + Lang::ZhTw => "已處理 1 / 3 項衝突", + Lang::En => "1 of 3 conflicts resolved", + Lang::Ja => "競合を 1 / 3 件解決済み", + Lang::Ko => "충돌 1 / 3개 해결", + }; + assert_eq!(progress, expected, "{lang:?}"); + assert!(!progress.contains("{}"), "{lang:?}: {progress}"); + } +} + +#[test] +fn ui_refinement_additions_have_japanese_korean_and_traditional_chinese_translations() { + const KEYS: [&str; 22] = [ + "uiRefinement.family", + "uiRefinement.fontHint", + "uiRefinement.fontSize", + "uiRefinement.storageError", + "uiRefinement.typography", + "uiRefinement.width", + "uiRefinement.back", + "uiRefinement.noResults", + "uiRefinement.shortcutHint", + "uiRefinement.shortcutsSearch", + "uiRefinement.unassigned", + "uiRefinement.all", + "uiRefinement.assigned", + "uiRefinement.category", + "uiRefinement.recording", + "uiRefinement.status", + "uiRefinement.styles", + "uiRefinement.system", + "uiRefinement.comfortable", + "uiRefinement.narrow", + "uiRefinement.native", + "uiRefinement.wide", + ]; + for key in KEYS { + let english = tr_l10n(Lang::En, key); + for lang in [Lang::Ja, Lang::Ko, Lang::ZhTw] { + let translated = tr_l10n(lang, key); + assert!(!translated.trim().is_empty(), "{lang:?}: {key}"); + assert_ne!(translated, key, "{lang:?}: missing {key}"); + if matches!(lang, Lang::Ja | Lang::Ko) { + assert_ne!(translated, english, "{lang:?}: English fallback for {key}"); + } + } + } + assert_eq!(tr_l10n(Lang::ZhTw, "uiRefinement.family"), "字型系列"); + assert_eq!(tr_l10n(Lang::ZhTw, "uiRefinement.width"), "閱讀寬度"); + assert_eq!( + tr_l10n(Lang::ZhTw, "uiRefinement.fontHint"), + "僅使用已安裝的字型,找不到時改用系統字型。不下載、不掃描,也不發出字型網路請求。" + ); + assert_eq!( + tr_l10n(Lang::ZhTw, "uiRefinement.shortcutHint"), + "點選按鍵即可重新錄製。Esc 取消;儲存前會檢查快速鍵衝突。" + ); +} + fn strip_comments(source: &str) -> Vec { let bytes = source.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); @@ -97,6 +172,89 @@ fn crate_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).to_path_buf() } +/// 源码里出现的每个 catalog 键都必须在目录里真实存在。 +/// +/// 这个测试盯的是「动态拼键」造成的漏翻译:`cloud_sync.rs` 曾经用 +/// `format!("cloudSyncE2ee.errors.{code}")` 拼键,`cloud_sync_ui.rs` 里还有一批 +/// `states.* / step* / syncNow` 常量从未进过目录,结果界面上直接画出了 +/// `cloudSyncE2ee.errors.service_unavailable`、`cloudSyncE2ee.states.disabled`。 +/// +/// 只看**字符串字面量**(不看注释与标识符),并跳过带 `{` 的格式模板: +/// 模板拼出来的键无法在编译期校验,只能靠 `error_key()` 那种字面量表。 +#[test] +fn every_literal_catalog_key_exists_in_the_catalog() { + fn string_literals(source: &str) -> Vec { + let cleaned = strip_comments(source); + let mut out = Vec::new(); + let mut i = 0; + while i < cleaned.len() { + if cleaned[i] != b'"' { + i += 1; + continue; + } + let mut inner = Vec::new(); + let mut j = i + 1; + while j < cleaned.len() { + match cleaned[j] { + b'\\' if j + 1 < cleaned.len() => { + inner.push(cleaned[j + 1]); + j += 2; + } + b'"' => break, + byte => { + inner.push(byte); + j += 1; + } + } + } + out.push(String::from_utf8_lossy(&inner).into_owned()); + i = j + 1; + } + out + } + + let root = crate_root().join("src"); + let catalog = std::fs::read_to_string(root.join("i18n.rs")).unwrap(); + let mut known: BTreeSet = BTreeSet::new(); + let mut rest = catalog.as_str(); + while let Some(index) = rest.find("key: \"") { + let after = &rest[index + "key: \"".len()..]; + let Some(end) = after.find('"') else { break }; + known.insert(after[..end].to_string()); + rest = &after[end..]; + } + assert!(!known.is_empty(), "the catalog must declare keys"); + + const NAMESPACES: [&str; 2] = ["cloudSyncE2ee.", "settings."]; + let mut missing: BTreeSet = BTreeSet::new(); + for file in source_files_under(&root) { + if file.strip_prefix(&root).unwrap().starts_with("i18n.rs") { + continue; + } + let source = std::fs::read_to_string(&file).unwrap(); + for literal in string_literals(&source) { + let Some(namespace) = NAMESPACES.iter().find(|ns| literal.starts_with(**ns)) else { + continue; + }; + let key = literal.as_str(); + let looks_like_key = key.len() > namespace.len() + && !key.ends_with('.') + && !key.contains('{') + && !key.contains('/') + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_'); + if looks_like_key && !known.contains(key) { + missing.insert(key.to_string()); + } + } + } + assert!( + missing.is_empty(), + "these catalog keys are referenced from src/ but missing from src/i18n.rs: {missing:?}" + ); +} + #[test] fn shell_module_is_fully_localized_with_no_raw_simplified_chinese() { let shell = std::fs::read_to_string(crate_root().join("src/ui/shell.rs")).unwrap(); diff --git a/openless-all/app/linux-egui/tests/zh_user_visible_baseline.txt b/openless-all/app/linux-egui/tests/zh_user_visible_baseline.txt index 00dce3fce..a9b59e425 100644 --- a/openless-all/app/linux-egui/tests/zh_user_visible_baseline.txt +++ b/openless-all/app/linux-egui/tests/zh_user_visible_baseline.txt @@ -263,3 +263,9 @@ session-二 (无文字) (语音问题) + 添加 + +# ── 测试夹具(快捷键筛选回归测试里用来定位行/按钮的文案) ────────────────── +分类 +翻译修饰键 +点『全部』必须恢复完整列表: {back:?} +风格分类不该包含翻译行: {filtered:?} diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 5fdd0acae..6ff8a1249 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -29,17 +29,17 @@ "tailwindcss": "^4.3.2" }, "devDependencies": { - "@playwright/test": "1.63.0", + "@playwright/test": "1.64.1", "@tauri-apps/cli": "^2.1.0", "@types/dompurify": "^3.0.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "js-yaml": "4.3.0", - "prettier": "3.9.6", - "prettier-plugin-sh": "0.19.0", + "prettier": "3.9.10", + "prettier-plugin-sh": "0.20.2", "shadcn": "^4.13.0", - "tsx": "4.23.1", + "tsx": "4.23.15", "tw-animate-css": "^1.4.0", "typescript": "^5.6.3", "vite": "^6.4.3" @@ -102,14 +102,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -312,27 +312,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.7" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -391,13 +391,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -407,13 +407,13 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -423,9 +423,9 @@ } }, "node_modules/@babel/plugin-transform-typescript": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.7.tgz", - "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.29.9.tgz", + "integrity": "sha512-FFwIwzU+7SCOuxxV4YtJql6T9981ZVTm+FHO5GhVsRqCTdy0WwrEZh3l42ARpXruJUDGOptdduHW6Zr7pNPLNg==", "dev": true, "license": "MIT", "dependencies": { @@ -463,13 +463,10 @@ } }, "node_modules/@babel/runtime": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", - "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-8.0.7.tgz", + "integrity": "sha512-k0hagDDq/6w4v+0Sp4wFvIz/yswH5O7Va6aRTnD7N62Tnborp4X+YB+H3XUcWVSR4aE47WPEXiIIZchBs0GmYg==", + "license": "MIT" }, "node_modules/@babel/template": { "version": "7.29.7", @@ -487,18 +484,18 @@ } }, "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", + "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", + "@babel/parser": "^7.29.9", "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -506,9 +503,9 @@ } }, "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { @@ -520,16 +517,16 @@ } }, "node_modules/@base-ui/react": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.6.0.tgz", - "integrity": "sha512-/jzjTWJYXhRFO45Bev9lc3cHbmjzCMpUqbMZ2AgKy/z25mY9B6shGSNcXcjQar9n5doM0KYW1W8fcFv2jZBuMw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@base-ui/react/-/react-1.9.0.tgz", + "integrity": "sha512-S8CSYosASBKtpL8SWUfS78rFElfLijiCgA7oHOK22jCJ95FULsYyKwBNFOgudt0RA+/7yNc7F81bOvQnRgbBiQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.29.2", - "@base-ui/utils": "0.3.1", - "@floating-ui/react-dom": "^2.1.8", - "@floating-ui/utils": "^0.2.11", - "use-sync-external-store": "^1.6.0" + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@base-ui/utils": "0.5.0", + "@floating-ui/react-dom": "^2.1.9", + "@floating-ui/utils": "^0.2.12", + "use-sync-external-store": "^1.7.0" }, "engines": { "node": ">=14.0.0" @@ -558,15 +555,15 @@ } }, "node_modules/@base-ui/utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.3.1.tgz", - "integrity": "sha512-gFFiltORVmW/N6IILTGxizP3PBpVpysqML1ALY5Vk0mH+7faVkCknOU31goYHN5Aoek2dkjxva1XOD2Ce9WuIg==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@base-ui/utils/-/utils-0.5.0.tgz", + "integrity": "sha512-dYGGcEttjkYiMoa5nHm8vLaJ2UcuTWxo3BFAwOYXeRg/q7UVVvA6cacEIXEs3FgBfK1CGtW1ptElqs7wsx+t9A==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.29.2", - "@floating-ui/utils": "^0.2.11", - "reselect": "^5.2.0", - "use-sync-external-store": "^1.6.0" + "@babel/runtime": "^7.29.7 || ^8.0.0", + "@floating-ui/utils": "^0.2.12", + "reselect": "^5.3.0", + "use-sync-external-store": "^1.7.0" }, "peerDependencies": { "@types/react": "^17 || ^18 || ^19", @@ -775,9 +772,9 @@ "license": "BSD-3-Clause" }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -791,9 +788,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -807,9 +804,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -823,9 +820,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -839,9 +836,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -855,9 +852,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -871,9 +868,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -887,9 +884,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -903,9 +900,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -919,9 +916,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -935,9 +932,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -951,9 +948,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -967,9 +964,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -983,9 +980,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -999,9 +996,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -1015,9 +1012,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -1031,9 +1028,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -1047,9 +1044,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -1063,9 +1060,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -1079,9 +1076,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -1095,9 +1092,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -1111,9 +1108,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -1127,9 +1124,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -1143,9 +1140,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -1159,9 +1156,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -1175,9 +1172,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -1191,31 +1188,31 @@ } }, "node_modules/@floating-ui/core": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", - "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.11" + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", - "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.5", - "@floating-ui/utils": "^0.2.11" + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", - "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz", + "integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.6" + "@floating-ui/dom": "^1.8.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -1223,22 +1220,22 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", - "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.5.tgz", + "integrity": "sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==", "dev": true, "license": "MIT", "engines": { - "node": ">=18.14.1" + "node": ">=20" }, "peerDependencies": { - "hono": "^4" + "hono": "^4 || ^5.0.0-0" } }, "node_modules/@jridgewell/gen-mapping": { @@ -1271,9 +1268,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1287,13 +1284,13 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.29.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", - "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.32.1.tgz", + "integrity": "sha512-2DdE+SJDtzLEEWzY1ZjY7Q+VcPhcV1KisD3zI4u0XZyktsjHum1mwbMI+JaulUBi2OZk+KJAi2uPXzxichPkdw==", "dev": true, "license": "MIT", "dependencies": { - "@hono/node-server": "^1.19.9", + "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", @@ -1327,6 +1324,25 @@ } } }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1366,13 +1382,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", - "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.63.0" + "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -1464,9 +1480,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", - "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.10.tgz", + "integrity": "sha512-oJ9ZfVIZjSjts0ZIYA48+5T0vurE5fQfX/Liad2U52Y6EN99x/OCdjGGuKMDP6jSk2i2lY6jwwA3X0drEc4PBg==", "cpu": [ "arm" ], @@ -1477,9 +1493,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", - "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.10.tgz", + "integrity": "sha512-LylBINgYYwgHNz8OMCy5+Y0w/LEgRYE0PcAIZfEuTuLNMf0S40GSLX6SFwqZsBLplS7RIHHy4zOAbl8Y5cQEvg==", "cpu": [ "arm64" ], @@ -1490,9 +1506,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", - "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.10.tgz", + "integrity": "sha512-vbuT3VT1GBI2fAOoUJUrsHVoOg9nbrFXTM7e3DSnb9PYf4n6jZCN8v4Vo8O6x4u5B67+/c803b8BtCZe2nIeqA==", "cpu": [ "arm64" ], @@ -1503,9 +1519,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", - "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.10.tgz", + "integrity": "sha512-qYUp/qYvM3T4cF+VWalPl+przBUESILxi+qZ1m1MVRHpg06vS+jHaxXubjCaRpXjLJPDFdXzOZ0FABDghHOhNQ==", "cpu": [ "x64" ], @@ -1516,9 +1532,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", - "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.10.tgz", + "integrity": "sha512-v9NRQG+JztbP4DP/0yuhSyDcEJk047lYbqeNnqIgbXITcVDjGDS7OpkW5nadSY3tJ7MY8W1qIMMqP4Q3Q8BDjQ==", "cpu": [ "arm64" ], @@ -1529,9 +1545,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", - "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.10.tgz", + "integrity": "sha512-CHc9LBHdxhn6/NXfb9IegXCWoz5VlvcDOx9QrzZ9q+XYhFlfzwymSWJjOlZcEMMtC1zgMV7qxDe6U18AI1SG0A==", "cpu": [ "x64" ], @@ -1542,12 +1558,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", - "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.10.tgz", + "integrity": "sha512-ble2WR7kWfzZFJUd3ODMGCJn1XDszptfRC0QvWc0h9/EA6MxnrQ1mvbjPZifEdgHyfHhDrwWeKeJONDITcrHSg==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1555,12 +1574,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", - "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.10.tgz", + "integrity": "sha512-DMCYegihxBMKvLpODdk4OsvIgxtHTi+Oddf1Pm3d6wMVNjcBiG2pqyXjMPy55HgG1gCdEFr9VfhB1CmvuKn1lw==", "cpu": [ "arm" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1568,12 +1590,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", - "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.10.tgz", + "integrity": "sha512-HQ6HuVH9TmhvPWIas7MA/KeAROgdwqH3REpUU6hZq47NW7m1b1QCrHgM+M3+N/B8DfMt88x1/z79ppRc9RflCQ==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1581,12 +1606,15 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", - "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.10.tgz", + "integrity": "sha512-avBhxba1DdctX0kxKkN3JWdTSg72QsxSn6FdfhZ9MkL3TTYDOXqxY1GqbC7VNttlgviK7tCe2No5btfRkFgeIg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1594,12 +1622,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", - "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.10.tgz", + "integrity": "sha512-UtQMex7fiXq0W/GqcWRpy6XKqUbXgQeGKB+Bd5rJngmHzpI+3kjwpWFLAhKg02onFsIMSh+Zx0zp9JIMQ7/05A==", "cpu": [ "loong64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1607,12 +1638,15 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", - "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.10.tgz", + "integrity": "sha512-0Aum2pUsCSuOopi6oka+nR1DTj/nfvc1xjL979lvh6kXUjNa30TCX7sKFjyMsrX7bK3bNVs0FsJwQcE2HCWX1Q==", "cpu": [ "loong64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1620,12 +1654,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", - "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.10.tgz", + "integrity": "sha512-4dgsouKal3qfMFORgtNigLPt7K3HXiO6jTr7+nFJv1GWu1OkZx0kvQIXbK4sjrIGnaLBk0s80eQCQ9hPIelLrw==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1633,12 +1670,15 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", - "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.10.tgz", + "integrity": "sha512-d7NE89try3jisYzBD7J9M5iwfgcrXYC4dwHEjQ3HIWBftvwch1U/cy3iZ26SthtUOyClKiu7iqYiFjQEwiOlqQ==", "cpu": [ "ppc64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1646,12 +1686,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", - "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.10.tgz", + "integrity": "sha512-dqB5ChSBp91JWXxpCKAS5ZVAE38esKQvdfyqANm0lF5JPiQrXnYwgG3oVBQmuNRcQ1WHkA3j5qYLsrl2CtfhCA==", "cpu": [ "riscv64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1659,12 +1702,15 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", - "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.10.tgz", + "integrity": "sha512-7GMf3vbQQslorWLmfsbi5fdt9RVnglk205XpOMFvW6UY5jE5oJc6JLieQJPHtsYlZ2EO0PtC06nY9Hz/IG8guw==", "cpu": [ "riscv64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1672,12 +1718,15 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", - "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.10.tgz", + "integrity": "sha512-tuKbkB23DKc5LG+irPSUnZZztRHqC3CNmyxeWloueBteHB+i+1HkRCMzAE41lseLDlarybeLhqfibFitKOxKMg==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1685,12 +1734,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", - "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.10.tgz", + "integrity": "sha512-ntHtQOMw7oIshHRF1swV3vyBw+beQt9PeZ04O3D32EeDZUMoHMJuZKp4/fDllH/+xogeK0GUCONVfjjKusqKLg==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1698,12 +1750,15 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", - "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.10.tgz", + "integrity": "sha512-Dlh8q8r145aYgh+N3gNIG1dz35Fz/FCxe5hs/43orDsffjP8S1P4Xz5I4FpSJsDS4LpQw1QmswZkQlUbkCldjQ==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1711,9 +1766,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", - "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.10.tgz", + "integrity": "sha512-VBJhHWz/55u45CWFv9OmfSRDgJDVENESgFxSAn13gIKJpTuFJbP4/7ymMZhDfeKFx1zGn1XBsLnNB7Ryy71qww==", "cpu": [ "x64" ], @@ -1724,9 +1779,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", - "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.10.tgz", + "integrity": "sha512-Vjcazg+EEzUfIz+LcaxS6YtbQHo+sv0sCD+5VbwhY504bCKNW4OOreVgKq61pHT2esRQ/+AXYVPStDwxSvAOdw==", "cpu": [ "arm64" ], @@ -1737,9 +1792,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", - "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.10.tgz", + "integrity": "sha512-jJAaP4El+HWxYkM0jeJghASaFgfnluY0FRHhyHXxU6STs/nBhKMIxIMDrJEFg2ftKz6orX9WAo4diVan5NugNA==", "cpu": [ "arm64" ], @@ -1750,9 +1805,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", - "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.10.tgz", + "integrity": "sha512-bXXdRUfRx9PaHCToKt8R/7wjDV6Tbk/hmSHK0uQBKmD3rj4jkuYndct8uGX77ZuZsePrVkQbWUgPR2jJhCTMpQ==", "cpu": [ "ia32" ], @@ -1763,9 +1818,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", - "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.10.tgz", + "integrity": "sha512-57Idx+VkEOQeG6Ys6sZOBFT7FfbeyxGv9A3k1m+Lf/z2SwqT40rNWbAINzjPHSMoCCBvY7Fnb9RGZWQin/HHAw==", "cpu": [ "x64" ], @@ -1776,9 +1831,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", - "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.10.tgz", + "integrity": "sha512-vrvuasIpm/xf+BpZbzgirku0WnrQZMAc+H2Ai26jzqBxYEmQQE4DdJb7h8erkJMfRRipx0ma2Cty8jJwUm9ocQ==", "cpu": [ "x64" ], @@ -1813,6 +1868,41 @@ } } }, + "node_modules/@shadcn/registry": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@shadcn/registry/-/registry-0.2.0.tgz", + "integrity": "sha512-m3TeqU/+av2kgOLfESHkWzsmhuQuY4NXZd5Y1Rd4lzy4IX3JJ5+k2mYfkq6OT8oMuk2W4GQPAkO7+CC7KlpSbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/plugin-transform-typescript": "^7.28.0", + "@babel/preset-typescript": "^7.27.1", + "@dotenvx/dotenvx": "^1.48.4", + "cn": "^0.2.4", + "code-block-writer": "^13.0.3", + "deepmerge": "^4.3.1", + "execa": "^9.6.0", + "fast-glob": "^3.3.3", + "fs-extra": "^11.3.1", + "fuzzysort": "^3.1.0", + "kleur": "^4.1.5", + "ora": "^8.2.0", + "postcss": "^8.5.23", + "prompts": "^2.4.2", + "recast": "^0.23.11", + "socks": "^2.8.8", + "stringify-object": "^5.0.0", + "tsconfig-paths": "^4.2.0", + "type-fest": "^4.41.0", + "undici": "^7.29.1", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/@sindresorhus/merge-streams": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sindresorhus/merge-streams/-/merge-streams-4.0.0.tgz", @@ -1827,47 +1917,47 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", - "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "5.21.6", + "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.3.2" + "tailwindcss": "4.3.3" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", - "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-arm64": "4.3.2", - "@tailwindcss/oxide-darwin-x64": "4.3.2", - "@tailwindcss/oxide-freebsd-x64": "4.3.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", - "@tailwindcss/oxide-linux-x64-musl": "4.3.2", - "@tailwindcss/oxide-wasm32-wasi": "4.3.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", - "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", "cpu": [ "arm64" ], @@ -1881,9 +1971,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", - "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", "cpu": [ "arm64" ], @@ -1897,9 +1987,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", - "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", "cpu": [ "x64" ], @@ -1913,9 +2003,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", - "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", "cpu": [ "x64" ], @@ -1929,9 +2019,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", - "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", "cpu": [ "arm" ], @@ -1945,12 +2035,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", - "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1961,12 +2054,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", - "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -1977,12 +2073,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", - "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -1993,12 +2092,15 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", - "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2009,9 +2111,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", - "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -2038,9 +2140,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", - "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", "cpu": [ "arm64" ], @@ -2054,9 +2156,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", - "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", "cpu": [ "x64" ], @@ -2070,23 +2172,23 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", - "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.3.2", - "@tailwindcss/oxide": "4.3.2", - "tailwindcss": "4.3.2" + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "node_modules/@tauri-apps/api": { - "version": "2.11.0", - "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz", - "integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==", + "version": "2.12.4", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.12.4.tgz", + "integrity": "sha512-pVmfis+4zT8H0Qhp3xPZx9Ju7kpzvA6rr/PSJPyuwfvucTQ8sVt61NYU9KMkK703UFlYadFmjKA4HhybOtN0gg==", "license": "Apache-2.0 OR MIT", "funding": { "type": "opencollective", @@ -2094,9 +2196,9 @@ } }, "node_modules/@tauri-apps/cli": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", - "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.12.3.tgz", + "integrity": "sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg==", "dev": true, "license": "Apache-2.0 OR MIT", "bin": { @@ -2110,23 +2212,23 @@ "url": "https://opencollective.com/tauri" }, "optionalDependencies": { - "@tauri-apps/cli-darwin-arm64": "2.10.1", - "@tauri-apps/cli-darwin-x64": "2.10.1", - "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", - "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", - "@tauri-apps/cli-linux-arm64-musl": "2.10.1", - "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-gnu": "2.10.1", - "@tauri-apps/cli-linux-x64-musl": "2.10.1", - "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", - "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", - "@tauri-apps/cli-win32-x64-msvc": "2.10.1" + "@tauri-apps/cli-darwin-arm64": "2.12.3", + "@tauri-apps/cli-darwin-x64": "2.12.3", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.12.3", + "@tauri-apps/cli-linux-arm64-gnu": "2.12.3", + "@tauri-apps/cli-linux-arm64-musl": "2.12.3", + "@tauri-apps/cli-linux-riscv64-gnu": "2.12.3", + "@tauri-apps/cli-linux-x64-gnu": "2.12.3", + "@tauri-apps/cli-linux-x64-musl": "2.12.3", + "@tauri-apps/cli-win32-arm64-msvc": "2.12.3", + "@tauri-apps/cli-win32-ia32-msvc": "2.12.3", + "@tauri-apps/cli-win32-x64-msvc": "2.12.3" } }, "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", - "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.12.3.tgz", + "integrity": "sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA==", "cpu": [ "arm64" ], @@ -2141,9 +2243,9 @@ } }, "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", - "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.12.3.tgz", + "integrity": "sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g==", "cpu": [ "x64" ], @@ -2158,9 +2260,9 @@ } }, "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", - "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.12.3.tgz", + "integrity": "sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ==", "cpu": [ "arm" ], @@ -2175,13 +2277,16 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", - "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.12.3.tgz", + "integrity": "sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -2192,13 +2297,16 @@ } }, "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", - "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.12.3.tgz", + "integrity": "sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -2209,13 +2317,16 @@ } }, "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", - "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.12.3.tgz", + "integrity": "sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q==", "cpu": [ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -2226,13 +2337,16 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", - "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.12.3.tgz", + "integrity": "sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -2243,13 +2357,16 @@ } }, "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", - "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.12.3.tgz", + "integrity": "sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "Apache-2.0 OR MIT", "optional": true, "os": [ @@ -2260,9 +2377,9 @@ } }, "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", - "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.12.3.tgz", + "integrity": "sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ==", "cpu": [ "arm64" ], @@ -2277,9 +2394,9 @@ } }, "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", - "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.12.3.tgz", + "integrity": "sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q==", "cpu": [ "ia32" ], @@ -2294,9 +2411,9 @@ } }, "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", - "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", + "version": "2.12.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.12.3.tgz", + "integrity": "sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ==", "cpu": [ "x64" ], @@ -2311,30 +2428,30 @@ } }, "node_modules/@tauri-apps/plugin-dialog": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.2.tgz", - "integrity": "sha512-pX0IGm1I3I6wc+zeKYcq1GSqogK6okCNX5fOdaNU5ab1AjGS6l1E5wFNjEb7meg7ZFSp0JUs+0jQGQNyOvLrsg==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.8.3.tgz", + "integrity": "sha512-Hd/yBfk2R5v2MFrz/0VArSXeh8xKLLXGunqSiTtcHKn1zNZwaKujuA0VSwUvNfwC4hDoF1PveS+gBDNe3nHspw==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.11.0" + "@tauri-apps/api": "^2.12.0" } }, "node_modules/@tauri-apps/plugin-shell": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", - "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.5.0.tgz", + "integrity": "sha512-FZ6RtcikCxJ35do1kfqi94Fknmc7DAK4mJxDRfNBInX24TQMsMLddM5i4XLqE8VvJ+Lg6EriiDcLs7JIfuFkWw==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@tauri-apps/api": "^2.12.0" } }, "node_modules/@tauri-apps/plugin-updater": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz", - "integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.14.0.tgz", + "integrity": "sha512-gdylJ9tger4hkyOn24QZd6MNOKVo2Eh53iw5th2Qj1vKTR/+u4PIwJWuiis5hohYvobn6iOvlnALfrqIPSzh7Q==", "license": "MIT OR Apache-2.0", "dependencies": { - "@tauri-apps/api": "^2.10.1" + "@tauri-apps/api": "^2.12.0" } }, "node_modules/@ts-morph/common": { @@ -2405,9 +2522,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/prop-types": { @@ -2418,9 +2535,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "18.3.28", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", - "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -2550,9 +2667,9 @@ "license": "Python-2.0" }, "node_modules/ast-types": { - "version": "0.16.1", - "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", - "integrity": "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==", + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.3.tgz", + "integrity": "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA==", "dev": true, "license": "MIT", "dependencies": { @@ -2583,9 +2700,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.40", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", - "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "version": "2.11.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", + "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2621,9 +2738,9 @@ } }, "node_modules/body-parser/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "dev": true, "license": "MIT", "engines": { @@ -2635,16 +2752,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -2661,9 +2778,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", "dev": true, "funding": [ { @@ -2681,11 +2798,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" @@ -2695,9 +2812,9 @@ } }, "node_modules/bundle-name": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", - "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.1.tgz", + "integrity": "sha512-DdH81/zPLVS11EUgWq3tEu/xn+EzljlMYooDNdzWEnFha3R3NBMpMV1UqYIpjYHV/SgpFKMIX1Oh7o06SjM/oA==", "dev": true, "license": "MIT", "dependencies": { @@ -2751,20 +2868,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", "dev": true, "funding": [ { @@ -2845,6 +2952,19 @@ "node": ">=6" } }, + "node_modules/cn": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/cn/-/cn-0.2.6.tgz", + "integrity": "sha512-+i4L0zUGgRcEnhsxueVrP7iBGxBx5iD0WOTYg1MwFEu2ZyCmH5Ov2V1cul2Ht5UchRQQgftCf4be/RxspuW6QQ==", + "dev": true, + "license": "MIT", + "bin": { + "cn": "bin/cn.mjs" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/code-block-writer": { "version": "13.0.3", "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", @@ -2926,9 +3046,9 @@ } }, "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", + "integrity": "sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==", "dev": true, "license": "MIT", "engines": { @@ -2994,33 +3114,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cosmiconfig": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz", - "integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.1", - "import-fresh": "^3.3.0", - "js-yaml": "^4.1.0", - "parse-json": "^5.2.0" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/d-fischer" - }, - "peerDependencies": { - "typescript": ">=4.9.5" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3139,9 +3232,9 @@ } }, "node_modules/default-browser": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", - "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", "dev": true, "license": "MIT", "dependencies": { @@ -3211,9 +3304,9 @@ } }, "node_modules/dompurify": { - "version": "3.4.11", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", - "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.17.tgz", + "integrity": "sha512-B/pSlYVHQ58kqISD/yDjuJ1yfzI34m1gF4lAod2bwSRhiN4K5Cl3H8Af7hqB2PmTL911tdEudnnXn5FnhRoz0w==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -3271,9 +3364,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.380", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", - "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", + "version": "1.5.459", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.459.tgz", + "integrity": "sha512-4SC9HHSFjO/liAhifpdOL5hG9b4BP7/5G3y3UPZVO8trlAXr1CaUEEJL0gWK3kOcYcZrOA5rkwG45Gra/lTIgg==", "dev": true, "license": "ISC" }, @@ -3295,9 +3388,9 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.21.6", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", - "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -3331,16 +3424,6 @@ "node": ">=6" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-arrayish": "^0.2.1" - } - }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3375,9 +3458,10 @@ } }, "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -3387,32 +3471,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/escalade": { @@ -3470,9 +3554,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", - "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "dev": true, "license": "MIT", "engines": { @@ -3507,16 +3591,16 @@ } }, "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.3.0.tgz", + "integrity": "sha512-qCqy1xSoaugxOD4IVHb1ZWdkvCF2W3aE2okKouWj8VwJ+ztskSiXoBnCSJqfKT+cVPJhuJqe9YLbY425+Zh00g==", "dev": true, "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", + "body-parser": "^2.3.0", + "content-disposition": "^2.0.1", + "content-type": "^2.0.0", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", @@ -3532,14 +3616,14 @@ "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", + "proxy-addr": "^2.0.8", + "qs": "^6.16.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", - "type-is": "^2.0.1", + "type-is": "^2.1.0", "vary": "^1.1.2" }, "engines": { @@ -3551,12 +3635,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.5.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", - "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", + "version": "8.7.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.1.tgz", + "integrity": "sha512-2FXbqkygil3mZiXzEtM0IjE/UBmBNot0J1GEVrJT4JbrvwOSoEtqtO+wHbiMQELWe1eNvHoYRg2dkjOid+6JkA==", "dev": true, "license": "MIT", "dependencies": { + "debug": "^4.4.3", "ip-address": "^10.2.0" }, "engines": { @@ -3569,15 +3654,29 @@ "express": ">= 4.11" } }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "node_modules/express/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "dev": true, - "license": "MIT" - }, - "node_modules/fast-glob": { - "version": "3.3.3", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, @@ -3594,9 +3693,9 @@ } }, "node_modules/fast-uri": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", - "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "dev": true, "funding": [ { @@ -3611,9 +3710,9 @@ "license": "BSD-3-Clause" }, "node_modules/fastq": { - "version": "1.20.1", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", - "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", "dev": true, "license": "ISC", "dependencies": { @@ -3712,12 +3811,12 @@ } }, "node_modules/framer-motion": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.39.0.tgz", - "integrity": "sha512-+vnLfzrv0MzjLzNl+nvNvR7jdg3q4cxxjz/YvzfifHl0TREtL00cs1RoMTxs+1PzLiEqZGV6gYsBY0oEAYZ24w==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz", + "integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==", "license": "MIT", "dependencies": { - "motion-dom": "^12.39.0", + "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, @@ -3749,9 +3848,9 @@ } }, "node_modules/fs-extra": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.6.tgz", - "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.1.tgz", + "integrity": "sha512-KYAb4c9BJQI6QqGKthV68OHe0badztdXJWKo0WtBA9IuCFPTKvE5ZdUBglP833aMjhaSPNO4A5j/EkzZtGlKjA==", "dev": true, "license": "MIT", "dependencies": { @@ -3805,9 +3904,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.7.0.tgz", + "integrity": "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q==", "dev": true, "license": "MIT", "engines": { @@ -3945,9 +4044,9 @@ } }, "node_modules/hono": { - "version": "4.12.27", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", - "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", "dev": true, "license": "MIT", "engines": { @@ -3955,12 +4054,12 @@ } }, "node_modules/html-parse-stringify": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", - "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", "license": "MIT", - "dependencies": { - "void-elements": "3.1.0" + "funding": { + "url": "https://locize.com" } }, "node_modules/http-errors": { @@ -3995,9 +4094,9 @@ } }, "node_modules/i18next": { - "version": "26.0.8", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz", - "integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==", + "version": "26.5.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.5.2.tgz", + "integrity": "sha512-6fIXt//2MZNok3tULyjnypxe3vbsy3pV1bVg8p2x3yKrzZvp7z3T+wXx/88LQsATRdhyJeAUAvUxEtxsHdJ29w==", "funding": [ { "type": "individual", @@ -4014,7 +4113,7 @@ ], "license": "MIT", "peerDependencies": { - "typescript": "^5 || ^6" + "typescript": "^5 || ^6 || ^7" }, "peerDependenciesMeta": { "typescript": { @@ -4049,23 +4148,6 @@ "node": ">= 4" } }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4074,9 +4156,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", "dev": true, "license": "MIT", "engines": { @@ -4093,13 +4175,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arrayish": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", - "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, - "license": "MIT" - }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -4299,9 +4374,9 @@ } }, "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "dev": true, "license": "MIT", "funding": { @@ -4350,13 +4425,6 @@ "node": ">=6" } }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, - "license": "MIT" - }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -4543,6 +4611,9 @@ "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4563,6 +4634,9 @@ "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4583,6 +4657,9 @@ "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4603,6 +4680,9 @@ "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4656,13 +4736,6 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, "node_modules/locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", @@ -4730,12 +4803,18 @@ } }, "node_modules/lucide-react": { - "version": "1.23.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", - "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "version": "1.59.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.59.0.tgz", + "integrity": "sha512-5qgMyyCPU6OvnKpLS2t693+ZtiqA8oaSweWoUows0BLFKFf6V75m4+CptpmohZHpD5IuOVIBXHBBiv2vpLJmtw==", "license": "ISC", "peerDependencies": { + "@types/react": "*", "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } } }, "node_modules/magic-string": { @@ -4770,13 +4849,17 @@ } }, "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/merge-descriptors": { @@ -4887,13 +4970,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.5" + "brace-expansion": "^5.0.8" }, "engines": { "node": "18 || 20 || >=22" @@ -4913,9 +4996,9 @@ } }, "node_modules/motion-dom": { - "version": "12.39.0", - "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.39.0.tgz", - "integrity": "sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==", + "version": "12.43.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz", + "integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==", "license": "MIT", "dependencies": { "motion-utils": "^12.39.0" @@ -4935,9 +5018,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -4953,19 +5036,40 @@ } }, "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", "dev": true, "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", "dev": true, "license": "MIT", "engines": { @@ -5085,18 +5189,18 @@ } }, "node_modules/open": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/open/-/open-11.0.0.tgz", - "integrity": "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==", + "version": "11.0.4", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.4.tgz", + "integrity": "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg==", "dev": true, "license": "MIT", "dependencies": { - "default-browser": "^5.4.0", + "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", - "powershell-utils": "^0.1.0", - "wsl-utils": "^0.3.0" + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" }, "engines": { "node": ">=20" @@ -5130,9 +5234,9 @@ } }, "node_modules/ora/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", "engines": { @@ -5197,38 +5301,6 @@ "node": ">=6" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/parse-json": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", - "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.0.0", - "error-ex": "^1.3.1", - "json-parse-even-better-errors": "^2.3.0", - "lines-and-columns": "^1.1.6" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/parse-ms": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz", @@ -5297,9 +5369,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "license": "MIT", "engines": { "node": ">=12" @@ -5332,13 +5404,13 @@ } }, "node_modules/playwright": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", - "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.63.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" @@ -5348,9 +5420,9 @@ } }, "node_modules/playwright-core": { - "version": "1.63.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", - "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -5361,9 +5433,9 @@ } }, "node_modules/postcss": { - "version": "8.5.12", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz", - "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "funding": [ { "type": "opencollective", @@ -5380,18 +5452,18 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" } }, "node_modules/postcss-selector-parser": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", - "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz", + "integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==", "dev": true, "license": "MIT", "dependencies": { @@ -5403,9 +5475,9 @@ } }, "node_modules/powershell-utils": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", - "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", "dev": true, "license": "MIT", "engines": { @@ -5416,9 +5488,9 @@ } }, "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "version": "3.9.10", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", + "integrity": "sha512-skGHTH1Yb9QvaTC4hWnRTzEYtCpApF0UMBPz+t/0ZPzP8ihefo/btKd+QA7pUE0fEwA1SbS/NmhyEcstdXOIdA==", "dev": true, "license": "MIT", "bin": { @@ -5432,14 +5504,14 @@ } }, "node_modules/prettier-plugin-sh": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.19.0.tgz", - "integrity": "sha512-39VXFZH/cOGtcuu8aeSvqp/hhwomOR4QroZUj+jBz2cNb3os9s0sqFZSNlYts6jdtLLDU7D2YT3Z1+abtb7adQ==", + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/prettier-plugin-sh/-/prettier-plugin-sh-0.20.2.tgz", + "integrity": "sha512-4QVyY7Vav0f5i6AVa80SFwUJXaWsAv9t0yUZIivwI0ucxTei6Jwhso/khWqaKDj4QGdwjP2py1nxQrDUeZ0jyQ==", "dev": true, "license": "MIT", "dependencies": { "@reteps/dockerfmt": "^0.5.4", - "sh-syntax": "^0.6.0" + "sh-syntax": "^0.7.0" }, "engines": { "node": ">=16.0.0" @@ -5452,9 +5524,9 @@ } }, "node_modules/pretty-ms": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz", - "integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.1.tgz", + "integrity": "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA==", "dev": true, "license": "MIT", "dependencies": { @@ -5492,9 +5564,9 @@ } }, "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5503,12 +5575,16 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.1.tgz", + "integrity": "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5599,19 +5675,19 @@ } }, "node_modules/react-i18next": { - "version": "17.0.6", - "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz", - "integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.1.0.tgz", + "integrity": "sha512-4VouOnTDyvhvqXx6WZ+s3/cgSiaLutILdYkO3P1Ms+4qcKR7At+JP4Vi7IeHkVLyVV+DmN1l23Pn73UcwSGGPA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.29.2", - "html-parse-stringify": "^3.0.1", + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { - "i18next": ">= 26.0.1", + "i18next": ">= 26.2.0", "react": ">= 16.8.0", - "typescript": "^5 || ^6" + "typescript": "^5 || ^6 || ^7" }, "peerDependenciesMeta": { "react-dom": { @@ -5625,6 +5701,15 @@ } } }, + "node_modules/react-i18next/node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5636,9 +5721,9 @@ } }, "node_modules/recast": { - "version": "0.23.12", - "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.12.tgz", - "integrity": "sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==", + "version": "0.23.21", + "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.21.tgz", + "integrity": "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw==", "dev": true, "license": "MIT", "dependencies": { @@ -5663,21 +5748,11 @@ } }, "node_modules/reselect": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.3.0.tgz", + "integrity": "sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg==", "license": "MIT" }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -5723,12 +5798,12 @@ } }, "node_modules/rollup": { - "version": "4.60.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", - "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "version": "4.64.10", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.10.tgz", + "integrity": "sha512-8N52YH1B5JzBP+B1rkcBERG7rfRTGAM0RmUpyd76yyRfTljuG452GCNbVm6MPzs1+gXBha6Rw5/oHRFohOpfDg==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.8" + "@types/estree": "1.0.9" }, "bin": { "rollup": "dist/bin/rollup" @@ -5738,31 +5813,32 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.2", - "@rollup/rollup-android-arm64": "4.60.2", - "@rollup/rollup-darwin-arm64": "4.60.2", - "@rollup/rollup-darwin-x64": "4.60.2", - "@rollup/rollup-freebsd-arm64": "4.60.2", - "@rollup/rollup-freebsd-x64": "4.60.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", - "@rollup/rollup-linux-arm-musleabihf": "4.60.2", - "@rollup/rollup-linux-arm64-gnu": "4.60.2", - "@rollup/rollup-linux-arm64-musl": "4.60.2", - "@rollup/rollup-linux-loong64-gnu": "4.60.2", - "@rollup/rollup-linux-loong64-musl": "4.60.2", - "@rollup/rollup-linux-ppc64-gnu": "4.60.2", - "@rollup/rollup-linux-ppc64-musl": "4.60.2", - "@rollup/rollup-linux-riscv64-gnu": "4.60.2", - "@rollup/rollup-linux-riscv64-musl": "4.60.2", - "@rollup/rollup-linux-s390x-gnu": "4.60.2", - "@rollup/rollup-linux-x64-gnu": "4.60.2", - "@rollup/rollup-linux-x64-musl": "4.60.2", - "@rollup/rollup-openbsd-x64": "4.60.2", - "@rollup/rollup-openharmony-arm64": "4.60.2", - "@rollup/rollup-win32-arm64-msvc": "4.60.2", - "@rollup/rollup-win32-ia32-msvc": "4.60.2", - "@rollup/rollup-win32-x64-gnu": "4.60.2", - "@rollup/rollup-win32-x64-msvc": "4.60.2", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.10", + "@rollup/rollup-android-arm64": "4.64.10", + "@rollup/rollup-darwin-arm64": "4.64.10", + "@rollup/rollup-darwin-x64": "4.64.10", + "@rollup/rollup-freebsd-arm64": "4.64.10", + "@rollup/rollup-freebsd-x64": "4.64.10", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.10", + "@rollup/rollup-linux-arm-musleabihf": "4.64.10", + "@rollup/rollup-linux-arm64-gnu": "4.64.10", + "@rollup/rollup-linux-arm64-musl": "4.64.10", + "@rollup/rollup-linux-loong64-gnu": "4.64.10", + "@rollup/rollup-linux-loong64-musl": "4.64.10", + "@rollup/rollup-linux-ppc64-gnu": "4.64.10", + "@rollup/rollup-linux-ppc64-musl": "4.64.10", + "@rollup/rollup-linux-riscv64-gnu": "4.64.10", + "@rollup/rollup-linux-riscv64-musl": "4.64.10", + "@rollup/rollup-linux-s390x-gnu": "4.64.10", + "@rollup/rollup-linux-x64-gnu": "4.64.10", + "@rollup/rollup-linux-x64-musl": "4.64.10", + "@rollup/rollup-openbsd-x64": "4.64.10", + "@rollup/rollup-openharmony-arm64": "4.64.10", + "@rollup/rollup-win32-arm64-msvc": "4.64.10", + "@rollup/rollup-win32-ia32-msvc": "4.64.10", + "@rollup/rollup-win32-x64-gnu": "4.64.10", + "@rollup/rollup-win32-x64-msvc": "4.64.10", "fsevents": "~2.3.2" } }, @@ -5901,9 +5977,9 @@ "license": "ISC" }, "node_modules/sh-syntax": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.6.0.tgz", - "integrity": "sha512-52VK6z/cdZHv7UURjIcwfBUQZrAhIEEe0bY4lrkfypjnFIKsDZdD3Uaz/dBiw/sF8BeX0Mssv140s8EnrsJ9dQ==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/sh-syntax/-/sh-syntax-0.7.0.tgz", + "integrity": "sha512-sgMyAxpHiGgAaNg+gD3ad41vha24CzIjjghD4mdiON1lUafZPLJNYknyjcXmU4geilPstd69zCKkpccY0YvajA==", "dev": true, "license": "MIT", "engines": { @@ -5914,9 +5990,9 @@ } }, "node_modules/shadcn": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.13.0.tgz", - "integrity": "sha512-5fuJ4jI/GcPeA/iTL4cJivCZuYQGXz/N3bIzyd+Gd/FM6xUCy2MxGG+LaDQuw2cjNy9zGPSFPTEmI048UwPTZA==", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/shadcn/-/shadcn-4.22.1.tgz", + "integrity": "sha512-LbB4Lro+b0qSVki/tgL5edtKylEnXe1ppxMJC12d7NHPgaT+eSgJGMQULwP72V7X9tbiUq7xfx3VDAfB7jdGoA==", "dev": true, "license": "MIT", "dependencies": { @@ -5925,11 +6001,12 @@ "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", - "@modelcontextprotocol/sdk": "^1.26.0", + "@modelcontextprotocol/sdk": "^1.32.0", + "@shadcn/registry": "0.2.0", "@types/validate-npm-package-name": "^4.0.2", - "browserslist": "^4.26.2", + "browserslist": "^4.29.3", + "cn": "^0.2.4", "commander": "^14.0.0", - "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", @@ -5938,20 +6015,19 @@ "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "kleur": "^4.1.5", - "open": "^11.0.0", + "open": "^11.0.4", "ora": "^8.2.0", - "postcss": "^8.5.6", - "postcss-selector-parser": "^7.1.0", + "postcss": "^8.5.23", + "postcss-selector-parser": "^7.1.6", "prompts": "^2.4.2", "recast": "^0.23.11", + "socks": "^2.8.8", "stringify-object": "^5.0.0", - "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", - "undici": "^7.27.2", + "undici": "^7.29.1", "validate-npm-package-name": "^7.0.1", - "zod": "^3.24.1", - "zod-to-json-schema": "^3.24.6" + "zod": "^4.2.0" }, "bin": { "shadcn": "dist/index.js" @@ -6079,6 +6155,32 @@ "dev": true, "license": "MIT" }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.10", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.10.tgz", + "integrity": "sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -6090,9 +6192,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -6140,9 +6242,9 @@ } }, "node_modules/string-width/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", "dev": true, "license": "MIT", "engines": { @@ -6236,9 +6338,9 @@ } }, "node_modules/systeminformation": { - "version": "5.31.13", - "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.31.13.tgz", - "integrity": "sha512-iUJXJoKzm4vtLSeT3nwe2s9QjoJAxHg7wYJ0KaQ54Xy2u9jsTq0ULWQQ0+T72FXjX2XnGqubazNx9lUfng7ELw==", + "version": "5.33.15", + "resolved": "https://registry.npmjs.org/systeminformation/-/systeminformation-5.33.15.tgz", + "integrity": "sha512-s5RloL0F7xhN0ZjfHtLGnbf7EW74EI0N8NFL+1xR7Oxz9QA33obNZsl7lyfnJOhN04E/kGFPoJPlvY7TZ5fzmw==", "dev": true, "license": "MIT", "os": [ @@ -6255,7 +6357,7 @@ "systeminformation": "lib/cli.js" }, "engines": { - "node": ">=8.0.0" + "node": ">=10.0.0" }, "funding": { "type": "Buy me a coffee", @@ -6263,9 +6365,9 @@ } }, "node_modules/tailwind-merge": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", - "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.7.0.tgz", + "integrity": "sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==", "license": "MIT", "funding": { "type": "github", @@ -6273,9 +6375,9 @@ } }, "node_modules/tailwindcss": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", - "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", "license": "MIT" }, "node_modules/tapable": { @@ -6370,9 +6472,9 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.1.tgz", - "integrity": "sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==", + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "devOptional": true, "license": "MIT", "dependencies": { @@ -6388,712 +6490,715 @@ "fsevents": "~2.3.3" } }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" } }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=18" + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, "engines": { - "node": ">=18" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "android" - ], "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, "engines": { - "node": ">=18" + "node": ">=14.17" } }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], + "node_modules/undici": { + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], "engines": { - "node": ">=18" + "node": ">=20.18.1" } }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], + "node_modules/unicorn-magic": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", + "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], "engines": { "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=18" + "node": ">= 10.0.0" } }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">= 0.8" } }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } ], "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" } }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/validate-npm-package-name": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", + "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=18" + "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], "engines": { - "node": ">=18" + "node": ">= 0.8" } }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], + "node_modules/vite": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.4.tgz", + "integrity": "sha512-xGjU/SyjeNyb0E4KxXBvYz/1C+3JXuujvf2hXNOtnlcE+58o5ajd9N2/n2VhkiJvpCP2qQasnUpx5PXNeI+p1Q==", "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">=18" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } } }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "cpu": [ "ppc64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "aix" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", "cpu": [ - "riscv64" + "arm" ], "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", "cpu": [ - "s390x" + "arm64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ - "linux" + "android" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ - "netbsd" + "darwin" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ - "netbsd" + "darwin" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", "cpu": [ "arm64" ], "license": "MIT", "optional": true, "os": [ - "openbsd" + "freebsd" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", "cpu": [ "x64" ], "license": "MIT", "optional": true, "os": [ - "openbsd" + "freebsd" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", "cpu": [ - "arm64" + "arm" ], "license": "MIT", "optional": true, "os": [ - "openharmony" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", "cpu": [ - "x64" + "arm64" ], "license": "MIT", "optional": true, "os": [ - "sunos" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", "cpu": [ - "arm64" + "ia32" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", "cpu": [ - "ia32" + "loong64" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", "cpu": [ - "x64" + "mips64el" ], "license": "MIT", "optional": true, "os": [ - "win32" + "linux" ], "engines": { "node": ">=18" } }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "devOptional": true, - "hasInstallScript": true, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" } }, - "node_modules/tw-animate-css": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", - "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", - "dev": true, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Wombosvideo" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" } }, - "node_modules/type-is": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", - "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", - "dev": true, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], "license": "MIT", - "dependencies": { - "content-type": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=18" } }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", - "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", - "dev": true, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "linux" + ], "engines": { "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" } }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=14.17" + "node": ">=18" } }, - "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", - "dev": true, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], "engines": { - "node": ">=20.18.1" + "node": ">=18" } }, - "node_modules/unicorn-magic": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/unicorn-magic/-/unicorn-magic-0.3.0.tgz", - "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", - "dev": true, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], "engines": { - "node": ">= 10.0.0" + "node": ">=18" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], "engines": { - "node": ">= 0.8" + "node": ">=18" } }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" ], "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], "license": "MIT", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, - "license": "MIT" - }, - "node_modules/validate-npm-package-name": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", - "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", - "dev": true, - "license": "ISC", + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": "^20.17.0 || >=22.9.0" + "node": ">=18" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], "license": "MIT", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8" + "node": ">=18" } }, - "node_modules/vite": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", - "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "hasInstallScript": true, "license": "MIT", - "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, "bin": { - "vite": "bin/vite.js" + "esbuild": "bin/esbuild" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" + "node": ">=18" }, "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/void-elements": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", - "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" } }, "node_modules/which": { @@ -7120,14 +7225,14 @@ "license": "ISC" }, "node_modules/wsl-utils": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz", - "integrity": "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.1.tgz", + "integrity": "sha512-c16PKLIRxBYpR3jSWsdS7SFp0COm5SOQdiFjY818dYFHJbMGuLBszpF07LBWmOS1uzrHa9VGdynsEgoeKy0oOQ==", "dev": true, "license": "MIT", "dependencies": { "is-wsl": "^3.1.0", - "powershell-utils": "^0.1.0" + "powershell-utils": "^0.2.0" }, "engines": { "node": ">=20" @@ -7144,9 +7249,9 @@ "license": "ISC" }, "node_modules/yocto-spinner": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.0.tgz", - "integrity": "sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz", + "integrity": "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==", "dev": true, "license": "MIT", "dependencies": { @@ -7160,9 +7265,9 @@ } }, "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.2.0.tgz", + "integrity": "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg==", "dev": true, "license": "MIT", "engines": { @@ -7173,9 +7278,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "dev": true, "license": "MIT", "funding": { diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 7f8980840..905b54e71 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -58,17 +58,17 @@ "tailwindcss": "^4.3.2" }, "devDependencies": { - "@playwright/test": "1.63.0", + "@playwright/test": "1.64.1", "@tauri-apps/cli": "^2.1.0", "@types/dompurify": "^3.0.5", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "@vitejs/plugin-react": "^4.3.3", "js-yaml": "4.3.0", - "prettier": "3.9.6", - "prettier-plugin-sh": "0.19.0", + "prettier": "3.9.10", + "prettier-plugin-sh": "0.20.2", "shadcn": "^4.13.0", - "tsx": "4.23.1", + "tsx": "4.23.15", "tw-animate-css": "^1.4.0", "typescript": "^5.6.3", "vite": "^6.4.3" diff --git a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs index 41051e9dd..33fffe94f 100644 --- a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs +++ b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs @@ -117,9 +117,14 @@ try { // --- workflow contract (#1103) --- const workflow = readFileSync(workflowPath, 'utf8'); const parsed = load(workflow); -assert.deepEqual(Object.keys(parsed.on).sort(), ['push', 'workflow_dispatch']); -assert.deepEqual(parsed.on.push.branches, ['**']); +// Tags build the release APK; a weekly schedule keeps the Gradle/Rust cache +// warm now that a per-branch push would cost 13-18 minutes and duplicate what +// `android-check` already covers. +assert.deepEqual(Object.keys(parsed.on).sort(), ['push', 'schedule', 'workflow_dispatch']); +assert.equal(parsed.on.push.branches, undefined); assert.deepEqual(parsed.on.push.tags, ['v*-tauri']); +assert.equal(parsed.on.schedule.length, 1); +assert.match(parsed.on.schedule[0].cron, /^\d+ \d+ \* \* [0-6]$/); assert.equal(typeof parsed.concurrency.group, 'string'); assert.equal(parsed.jobs['build-android-apk'].steps[0].with.submodules, false); assert.equal(parsed.on.workflow_dispatch.inputs.signed_debug.type, 'boolean'); diff --git a/openless-all/app/scripts/build-harmony-backend.sh b/openless-all/app/scripts/build-harmony-backend.sh index 90ee36593..f2d6e0a8f 100755 --- a/openless-all/app/scripts/build-harmony-backend.sh +++ b/openless-all/app/scripts/build-harmony-backend.sh @@ -9,4 +9,15 @@ export CARGO_TARGET_DIR="$CACHE_ROOT/harmony/rust-target" export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_OHOS_LINKER="$NATIVE/llvm/bin/aarch64-unknown-linux-ohos-clang" export CC_aarch64_unknown_linux_ohos="$CARGO_TARGET_AARCH64_UNKNOWN_LINUX_OHOS_LINKER" export AR_aarch64_unknown_linux_ohos="$NATIVE/llvm/bin/llvm-ar" + +# aws-lc-sys configures its CMake tree once and then caches the detected +# architecture: switching to a different toolchain path reuses rules that were +# generated for the previous one, and the build dies assembling x86_64 sources +# for aarch64. Rebuild only that crate when the toolchain moves. +marker="$CARGO_TARGET_DIR/ohos-toolchain-path" +if [ "$(cat "$marker" 2>/dev/null)" != "$TOOLCHAIN" ]; then + rm -rf "$CARGO_TARGET_DIR"/aarch64-unknown-linux-ohos/release/build/aws-lc-sys-* + mkdir -p "$CARGO_TARGET_DIR" + printf '%s\n' "$TOOLCHAIN" > "$marker" +fi cargo build --manifest-path "$APP_ROOT/Cargo.toml" -p openless-harmony-host --target aarch64-unknown-linux-ohos --release diff --git a/openless-all/app/scripts/build-harmony-frontend.sh b/openless-all/app/scripts/build-harmony-frontend.sh index de0fc9fca..be9a056de 100755 --- a/openless-all/app/scripts/build-harmony-frontend.sh +++ b/openless-all/app/scripts/build-harmony-frontend.sh @@ -91,8 +91,19 @@ shutil.copy2(target.parent / 'rust-target/aarch64-unknown-linux-ohos/release/lib PY cd "$BUILD_PROJECT" -ohpm install --all --cache "$CACHE_ROOT/harmony/ohpm" -hvigorw --mode module -p product=default -p module=entry@default -p buildMode=debug assembleHap --no-daemon +# ohpm keeps its cache under $OHPM_HOME (default ~/.ohpm). The CLI's `--cache` +# flag only exists in newer DevEco builds, so set the home instead of guessing +# which flag this toolchain has. +export OHPM_HOME="${OHPM_HOME:-$CACHE_ROOT/harmony/ohpm}" +ohpm install --all +# `HVIGOR_JS` lets a runner without DevEco drive the standalone hvigor CLI; the +# DevEco install keeps using its own `hvigorw` wrapper. +if [ -n "${HVIGOR_JS:-}" ]; then + HVIGOR=(node "$HVIGOR_JS") +else + HVIGOR=(hvigorw) +fi +"${HVIGOR[@]}" --mode module -p product=default -p module=entry@default -p buildMode=debug assembleHap --no-daemon printf '\nHAP output: %s/entry/build/default/outputs/default/\n' "$BUILD_PROJECT" if [[ -f "$SIGNING_DIR/debug.cer" && -f "$SIGNING_DIR/debug.p7b" ]]; then diff --git a/openless-all/app/scripts/ci-cache-sharing.test.mjs b/openless-all/app/scripts/ci-cache-sharing.test.mjs new file mode 100644 index 000000000..d4e847b00 --- /dev/null +++ b/openless-all/app/scripts/ci-cache-sharing.test.mjs @@ -0,0 +1,63 @@ +// Contract test: release builds must restore the cache the baseline branch +// maintains, instead of compiling every dependency from a cold tree. +// +// `swatinem/rust-cache` builds its key as +// ----- +// with `restore-keys` falling back to everything before the hashes. The job name +// only enters the key when `shared-key` is absent, so two different jobs share a +// cache exactly when they declare the same `shared-key`. +// +// Runs without `npm ci` (the Linux contract job has no node_modules), so the +// workflows are read as text. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const read = (name) => readFileSync(join(root, '.github/workflows', name), 'utf8'); + +const ci = read('ci.yml'); +const release = read('release-tauri.yml'); +const androidApk = read('android-apk.yml'); +const linux = read('check-linux-egui.yml'); +const linuxRelease = read('release-linux-egui.yml'); + +const sharedKeys = (yaml) => + [...yaml.matchAll(/shared-key:\s*([^\s#]+)/g)].map((match) => match[1].trim()); + +// 1. The baseline job must own a stable, job-name-independent key per platform. +for (const key of ['tauri-windows', 'tauri-macos']) { + assert.ok( + sharedKeys(ci).includes(key), + `ci.yml must declare shared-key ${key} so the release workflow can reuse it`, + ); + assert.ok( + sharedKeys(release).includes(key), + `release-tauri.yml must restore shared-key ${key}`, + ); +} + +// 2. The release workflow must not keep a private key: that is what made tag +// builds cold (the baseline branch never wrote it). +assert.doesNotMatch(release, /key:\s*macos-release/); + +// 3. Baseline writes stay gated to the default branch, releases only read it. +const saveIfs = [...ci.matchAll(/save-if:\s*\$\{\{\s*github\.ref == 'refs\/heads\/beta'[^}]*\}\}/g)]; +assert.ok(saveIfs.length >= 3, 'every shared cache in ci.yml must be beta-only for writes'); + +// 4. The APK build keeps its per-ABI key (it builds a different target triple +// than `android-check`), but must not define a private ad-hoc `key`. +assert.doesNotMatch(androidApk, /^\s*key:\s/m); +assert.ok(sharedKeys(androidApk).some((key) => key.startsWith('android-'))); + +// 5. Linux already shares one job between CI and release: the release entry point +// must reuse the same reusable workflow rather than a second build path. +assert.match(linuxRelease, /uses: \.\/\.github\/workflows\/check-linux-egui\.yml/); +assert.match(linux, /cargo build --locked --release -p openless-linux-egui/); + +// 6. Release entry points pin the tag, so the artifacts cannot come from a branch. +assert.match(linuxRelease, /release_tag: \$\{\{ inputs\.tag \}\}/); +assert.match(linuxRelease, /checkout_ref: \$\{\{ inputs\.tag \}\}/); + +console.log('ci-cache-sharing contract test passed'); diff --git a/openless-all/app/scripts/ci-cache-usage.sh b/openless-all/app/scripts/ci-cache-usage.sh index 602adf8fe..ac5aabe90 100755 --- a/openless-all/app/scripts/ci-cache-usage.sh +++ b/openless-all/app/scripts/ci-cache-usage.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash # # Report GitHub Actions cache usage for this repository and, with --prune, -# delete the scopes that can never be restored again. +# delete the entries that can never be restored again or that are superseded +# by a newer sibling. # -# scripts/ci-cache-usage.sh [--prune] [--limit-gb N] [--repo OWNER/NAME] +# scripts/ci-cache-usage.sh [--prune] [--keep-family N] [--dry-run] [--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 @@ -11,6 +12,18 @@ # 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. # +# What --prune deletes: +# 1. cache scopes of closed or merged pull requests (never restorable), and +# 2. with --keep-family N, all but the N newest entries of the same +# (branch, key family) pair. A key family is the key with its trailing +# content hashes removed, e.g. +# v0-rust-cross-platform-Windows_NT-x64-3619e479-a77d3f83 +# -> v0-rust-cross-platform-Windows_NT-x64 +# Old siblings stay useful as `restore-keys` fallbacks, so the default +# keeps them; the scheduled job passes 2. +# It never touches tag scopes: a tag run can only restore the default branch's +# caches, and release caches are written per tag on purpose. +# # 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 @@ -18,10 +31,17 @@ set -euo pipefail repo=${REPO:-Open-Less/openless} limit_gb=${LIMIT_GB:-8} prune=0 +keep_family=0 +dry_run=0 while [ $# -gt 0 ]; do case "$1" in --prune) prune=1 ;; + --dry-run) dry_run=1 ;; + --keep-family) + keep_family=${2:?--keep-family needs a number} + shift + ;; --limit-gb) limit_gb=${2:?--limit-gb needs a number} shift @@ -31,23 +51,40 @@ while [ $# -gt 0 ]; do shift ;; *) - echo "usage: $0 [--prune] [--limit-gb N] [--repo OWNER/NAME]" >&2 + echo "usage: $0 [--prune] [--keep-family N] [--dry-run] [--limit-gb N] [--repo OWNER/NAME]" >&2 exit 2 ;; esac shift done +case "$keep_family" in + '' | *[!0-9]*) + echo "--keep-family needs a non-negative integer" >&2 + exit 2 + ;; +esac + command -v gh > /dev/null 2>&1 || { echo "gh is required" >&2 exit 1 } +# Percent-encode a value for a query string. Keys contain only [A-Za-z0-9._-], +# but refs contain slashes, so encode unconditionally. +urlencode() { + if command -v python3 > /dev/null 2>&1; then + python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=""))' "$1" + else + jq -rn --arg v "$1" '$v|@uri' + fi +} + 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" + --jq '.actions_caches[] | [.ref, .key, .size_in_bytes, .created_at] | @tsv' > "$caches" count=$(wc -l < "$caches" | tr -d ' ') total_gb=$(awk -F'\t' '{ sum += $3 } END { printf "%.2f", sum / 1e9 }' "$caches") @@ -58,8 +95,21 @@ awk -F'\t' '{ size[$1] += $3 } END { for (ref in size) printf " %7.2f GB %s\n" | sort -rn \ | head -10 +delete_cache() { + # GitHub changed this endpoint: DELETE requires `key`, and `ref` only + # narrows it. Without both, the call answers HTTP 422. + local key=$1 ref=$2 + if [ "$dry_run" = 1 ]; then + echo " would prune $ref $key" + return 0 + fi + gh api -X DELETE \ + "repos/$repo/actions/caches?key=$(urlencode "$key")&ref=$(urlencode "$ref")" > /dev/null +} + if [ "$prune" = 1 ]; then pruned=0 + # 1. Closed or merged pull requests can never restore their scope again. 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%%/*} @@ -72,12 +122,43 @@ if [ "$prune" = 1 ]; 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)) + for key in $(awk -F'\t' -v want="$ref" '$1 == want { print $2 }' "$caches"); do + echo " prune $ref (pull request $state)" + delete_cache "$key" "$ref" + pruned=$((pruned + 1)) + done done - echo "pruned $pruned scope(s)" + + # 2. Superseded siblings inside one branch scope. + if [ "$keep_family" -gt 0 ]; then + families=$(mktemp) + awk -F'\t' ' + function family(key) { + while (key ~ /-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]+$/) { + sub(/-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]+$/, "", key) + } + return key + } + $1 ~ /^refs\/heads\// { print $1 "\t" family($2) "\t" $4 "\t" $2 } + ' "$caches" | sort -t "$(printf '\t')" -k1,1 -k2,2 -k3,3 > "$families" + # Two passes: first count the siblings, then emit the oldest ones beyond the + # keep window. `restore-keys` only needs the previous generation, so the two + # newest entries of a family are enough. + while IFS=$(printf '\t') read -r ref key; do + echo " prune $ref ${key} (superseded, keeping ${keep_family})" + delete_cache "$key" "$ref" + pruned=$((pruned + 1)) + done < <(awk -F'\t' -v keep="$keep_family" ' + FNR == NR { total[$1 "\t" $2]++; next } + { + group = $1 "\t" $2 + seen[group]++ + if (seen[group] <= total[group] - keep) print $1 "\t" $4 + } + ' "$families" "$families") + rm -f "$families" + fi + echo "pruned $pruned cache(s)" fi if awk -v used="$total_gb" -v limit="$limit_gb" 'BEGIN { exit (used + 0 > limit + 0) ? 0 : 1 }'; then diff --git a/openless-all/app/scripts/ci-cache-usage.test.mjs b/openless-all/app/scripts/ci-cache-usage.test.mjs index 00be156bd..283a26185 100644 --- a/openless-all/app/scripts/ci-cache-usage.test.mjs +++ b/openless-all/app/scripts/ci-cache-usage.test.mjs @@ -6,7 +6,15 @@ // 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 { + chmodSync, + existsSync, + 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'; @@ -22,33 +30,63 @@ const fixtures = [ ref: 'refs/heads/beta', key: 'v0-rust-cross-platform-Windows_NT-x64-3619e479-a77d3f83', size: 1_622_000_000, + created: '2026-10-19T02:00:00Z', }, { ref: 'refs/pull/101/merge', key: 'v0-rust-cross-platform-Windows_NT-x64-3619e479-aaaaaaaa', size: 1_600_000_000, + created: '2026-10-02T02:00:00Z', }, { ref: 'refs/pull/102/merge', key: 'v0-rust-build-linux-egui-Linux-x64-32c982f2-6e438e89', size: 1_338_000_000, + created: '2026-10-03T02:00:00Z', }, { ref: 'refs/pull/103/head', key: 'v0-rust-macos-msrv-v1-macos-msrv-Darwin-arm64-832c8724-bbbb', size: 715_000_000, + created: '2026-10-04T02:00:00Z', }, { ref: 'refs/tags/v2.0.0-Beta.3-tauri', key: 'v0-rust-macos-release-v1-build-Darwin-x64-ab76de4a-c48940d0', size: 759_000_000, + created: '2026-10-05T02:00:00Z', + }, + // Three generations of one key family inside the beta scope: the two newest are + // still useful as `restore-keys` fallbacks, the oldest one is not. + { + ref: 'refs/heads/beta', + key: 'v0-rust-android-check-Linux-x64-f2847a1e-aa0001', + size: 100_000_000, + created: '2026-09-01T02:00:00Z', + }, + { + ref: 'refs/heads/beta', + key: 'v0-rust-android-check-Linux-x64-f2847a1e-aa0002', + size: 100_000_000, + created: '2026-09-15T02:00:00Z', + }, + { + ref: 'refs/heads/beta', + key: 'v0-rust-android-check-Linux-x64-f2847a1e-aa0003', + size: 100_000_000, + created: '2026-10-01T02:00:00Z', }, ]; writeFileSync( join(scratch, 'caches.json'), JSON.stringify({ total_count: fixtures.length, - actions_caches: fixtures.map(({ ref, key, size }) => ({ ref, key, size_in_bytes: size })), + actions_caches: fixtures.map(({ ref, key, size, created }) => ({ + ref, + key, + size_in_bytes: size, + created_at: created, + })), }), ); @@ -72,7 +110,7 @@ case "$args" in 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']}") + print(f"{entry['ref']}\\t{entry['key']}\\t{entry['size_in_bytes']}\\t{entry['created_at']}") PY ;; *repos/*/pulls/*) @@ -125,41 +163,68 @@ 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 reports total', /6\.33 GB used/.test(report.stdout), true); check('over budget mentions the limit', /exceeds the 5 GB budget/.test(report.stdout), true); + check('report deletes nothing', existsSync(deleteLog), false); - // Prune: closed and merged pull requests go, open ones stay. + // Prune closed and merged pull requests. The endpoint requires both key and + // ref since GitHub's 2026-10 API change; refs are percent-encoded because + // they contain slashes. 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 without --keep-family deleted two scopes', deleted.length, 2); check( 'prune deleted the closed pull request', - deleted.some((line) => line.includes('ref=refs/pull/102/merge')), + deleted.some((line) => line.includes('ref=refs%2Fpull%2F102%2Fmerge')), true, ); check( 'prune deleted the merged pull request', - deleted.some((line) => line.includes('ref=refs/pull/103/head')), + deleted.some((line) => line.includes('ref=refs%2Fpull%2F103%2Fhead')), + true, + ); + check( + 'prune sends the cache key as well', + deleted.every((line) => /[?&]key=[^&]+/.test(line)), true, ); check( 'prune kept the open pull request', - deleted.some((line) => line.includes('ref=refs/pull/101/merge')), + deleted.some((line) => line.includes('refs%2Fpull%2F101') || line.includes('refs/pull/101')), false, ); check( - 'prune kept the branch scope', - deleted.some((line) => line.includes('refs/heads/beta')), + 'prune kept the branch scope beyond the pull requests', + deleted.some((line) => line.includes('key=v0-rust-cross-platform-Windows_NT-x64-3619e479-a77d3f83')), false, ); check( 'prune kept the tag scope', - deleted.some((line) => line.includes('refs/tags/')), + deleted.some((line) => line.includes('refs%2Ftags') || line.includes('refs/tags')), false, ); check('prune announces the kept scope', /keep\s+refs\/pull\/101\/merge/.test(prune.stdout), true); + // Family pruning: keep the two newest entries of one (branch, family) pair. + rmSync(deleteLog, { force: true }); + const family = run(['--prune', '--keep-family', '2', '--repo', 'example/openless', '--limit-gb', '99']); + check('family prune exits 0 inside budget', family.code, 0); + const familyDeleted = readFileSync(deleteLog, 'utf8').trim().split('\n').filter(Boolean); + // Two closed/merged pull request scopes go as well; exactly one branch + // sibling of the android family is beyond the keep window. + check('family prune deleted the two dead pull request scopes plus one sibling', familyDeleted.length, 3); + check( + 'family prune deleted the 2026-09-01 generation', + familyDeleted.some((line) => line.includes('aa0001')), + true, + ); + check( + 'family prune kept a newer sibling', + familyDeleted.some((line) => line.includes('aa0002') || line.includes('aa0003')), + false, + ); + // Inside the budget: exit 0. const healthy = run(['--repo', 'example/openless', '--limit-gb', '10']); check('inside budget exits 0', healthy.code, 0); @@ -168,6 +233,8 @@ try { // Usage errors keep the exit code distinct from the budget signal. const badFlag = run(['--nonsense']); check('unknown flag exits 2', badFlag.code, 2); + const badKeep = run(['--prune', '--keep-family', 'two']); + check('non-numeric --keep-family exits 2', badKeep.code, 2); } finally { rmSync(scratch, { recursive: true, force: true }); } diff --git a/openless-all/app/scripts/ci-changed-areas.sh b/openless-all/app/scripts/ci-changed-areas.sh index b4deb928f..928083d56 100755 --- a/openless-all/app/scripts/ci-changed-areas.sh +++ b/openless-all/app/scripts/ci-changed-areas.sh @@ -9,6 +9,8 @@ # 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. +# harmony The HarmonyOS host: ArkTS contracts, the ArkWeb bundle and the HAP +# release path. # # 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 @@ -17,7 +19,7 @@ # cannot reach it, never of a failure to read the change set. set -uo pipefail -area=${1:?usage: ci-changed-areas.sh [base-sha]} +area=${1:?usage: ci-changed-areas.sh [base-sha]} base=${2:-} run() { @@ -26,7 +28,7 @@ run() { } case "$area" in - tauri | msrv | linux) ;; + tauri | msrv | linux | harmony) ;; *) echo "unknown area: $area" >&2 run @@ -49,7 +51,7 @@ while IFS= read -r file; do case "$file" in openless-all/app/linux-egui/Cargo.toml) run ;; openless-all/app/linux-egui/*) ;; - openless-all/scripts/linux-fcitx5-plugin/*) ;; + openless-all/app/scripts/linux-fcitx5-plugin/*) ;; docs/* | *.md | LICENSE* | NOTICE*) ;; *) run ;; esac @@ -69,6 +71,16 @@ while IFS= read -r file; do *) run ;; esac ;; + harmony) + # The HarmonyOS job builds the shared frontend bundle and the Rust host, so + # only prose and the Linux-only trees are provably out of reach. + case "$file" in + docs/* | *.md | LICENSE* | NOTICE*) ;; + openless-all/app/linux-egui/*) ;; + openless-all/app/scripts/linux-fcitx5-plugin/*) ;; + *) run ;; + esac + ;; esac done <<< "$files" diff --git a/openless-all/app/scripts/ci-changed-areas.test.mjs b/openless-all/app/scripts/ci-changed-areas.test.mjs index 0c35e26f8..8bd7489b2 100644 --- a/openless-all/app/scripts/ci-changed-areas.test.mjs +++ b/openless-all/app/scripts/ci-changed-areas.test.mjs @@ -60,6 +60,7 @@ try { check('docs only: tauri', decide('tauri'), 'false'); check('docs only: msrv', decide('msrv'), 'false'); check('docs only: linux', decide('linux'), 'false'); + check('docs only: harmony', decide('harmony'), 'false'); reset(); // The Linux host is a separate crate: only its own Rust sources stay in. @@ -67,6 +68,14 @@ try { check('linux host source: tauri', decide('tauri'), 'false'); check('linux host source: msrv', decide('msrv'), 'true'); check('linux host source: linux', decide('linux'), 'true'); + check('linux host source: harmony', decide('harmony'), 'false'); + reset(); + + // HarmonyOS work touches the shared bundle and the Rust host, never the Linux + // package chain or the Tauri desktop build. + commit({ 'openless-all/app/harmony/entry/src/main/ets/pages/Index.ets': 'export {};\n' }, 'arkts'); + check('arkts source: harmony', decide('harmony'), 'true'); + check('arkts source: tauri', decide('tauri'), 'true'); reset(); // ...but its manifest moves the shared workspace dependency graph. @@ -84,6 +93,7 @@ try { check('frontend only: tauri', decide('tauri'), 'true'); check('frontend only: msrv', decide('msrv'), 'false'); check('frontend only: linux', decide('linux'), 'true'); + check('frontend only: harmony', decide('harmony'), 'true'); reset(); // Native code keeps every check on. @@ -103,15 +113,18 @@ try { check('workflow change: tauri', decide('tauri'), 'true'); check('workflow change: msrv', decide('msrv'), 'true'); check('workflow change: linux', decide('linux'), 'true'); + check('workflow change: harmony', decide('harmony'), '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('no base revision: harmony', decide('harmony', null), 'true'); check('empty diff: tauri', decide('tauri'), 'true'); check('empty diff: msrv', decide('msrv'), 'true'); check('empty diff: linux', decide('linux'), 'true'); + check('empty diff: harmony', decide('harmony'), 'true'); check('unknown revision: tauri', decide('tauri', 'deadbeefdeadbeef'), 'true'); check('unknown area', decide('nonsense'), 'true'); } finally { @@ -135,7 +148,7 @@ const reusable = readFileSync( ), 'utf8', ); -const areas = ['tauri', 'msrv', 'linux']; +const areas = ['tauri', 'msrv', 'linux', 'harmony']; check( 'workflow publishes every area', areas.every((area) => workflow.includes(area + ': ${{ steps.detect.outputs.' + area + ' }}')), @@ -143,7 +156,7 @@ check( ); check( 'workflow computes every area in the detect step', - /for area in tauri msrv linux; do/.test(workflow), + /for area in tauri msrv linux harmony; do/.test(workflow), true, ); check( @@ -156,11 +169,28 @@ check( workflow.includes("if: needs.changes.outputs.msrv != 'false'"), true, ); +// A macOS runner is only spent on MSRV while the declared MSRV is behind the +// stable toolchain; otherwise `cross-platform` already compiles that code. +check( + 'msrv job is skipped when MSRV equals stable', + workflow.includes("needs.changes.outputs.msrv_needed == 'true'"), + true, +); +check( + 'changes job publishes the msrv comparison', + workflow.includes('msrv_needed: ${{ steps.msrv.outputs.needed }}'), + true, +); check( 'android gate is fail-open', /inputs\.platform != 'macos'\) && needs\.changes\.outputs\.tauri != 'false'/.test(workflow), true, ); +check( + 'harmony gate is fail-open', + workflow.includes("needs.changes.outputs.harmony != 'false'"), + true, +); check( 'linux scope defaults to full', workflow.includes("needs.changes.outputs.linux == 'false' && 'none' || 'full'"), diff --git a/openless-all/app/scripts/harmony-release-contract.test.mjs b/openless-all/app/scripts/harmony-release-contract.test.mjs new file mode 100644 index 000000000..c77bb73d9 --- /dev/null +++ b/openless-all/app/scripts/harmony-release-contract.test.mjs @@ -0,0 +1,96 @@ +// Contract test for the HarmonyOS release path. +// +// The HAP cannot be built on a hosted runner (the DevEco SDK is account-gated), +// so the risks this test guards are structural: the entry point must pin the tag, +// run on the SDK-bearing runner by default, keep every credential in Secrets, and +// hand the signed HAP to both the GitHub Release and AppGallery Connect. +// Text-based so it also runs in the Linux contract job (no node_modules). +import assert from 'node:assert/strict'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const workflow = readFileSync(join(appRoot, '../../.github/workflows/release-harmony.yml'), 'utf8'); +const packaging = readFileSync(join(appRoot, 'scripts/package-harmony-hap.sh'), 'utf8'); +const signing = readFileSync(join(appRoot, 'scripts/sign-harmony-hap.py'), 'utf8'); +const upload = readFileSync(join(appRoot, 'scripts/upload-appgallery.mjs'), 'utf8'); +const releasing = readFileSync(join(appRoot, '../../RELEASING.md'), 'utf8'); + +// Dispatch-only, with the tag as a required input: the HAP can never come from a +// branch or ride along with an unrelated push. +assert.match(workflow, /^on:\n workflow_dispatch:/m); +assert.doesNotMatch(workflow, /^ (push|pull_request):/m); +assert.match(workflow, /inputs:[\s\S]*?tag:[\s\S]*?required: true/); + +// The tag is what gets built. +assert.match(workflow, /ref: \$\{\{ inputs\.tag \}\}/); +assert.match(workflow, /TAG_VERSION=\$\{TAG_VERSION%-tauri\}/); +assert.match(workflow, /does not match package\.json version/); + +// Default runner is a hosted one and the toolchain comes from the public mirror +// that `prepare-harmony-sdk.sh` pins by checksum - no secrets, no self-hosted box. +assert.match(workflow, /default: '\["ubuntu-latest"\]'/); +assert.match(workflow, /prepare-harmony-sdk\.sh/); +const prepare = readFileSync(join(appRoot, 'scripts/prepare-harmony-sdk.sh'), 'utf8'); +assert.match(prepare, /DEFAULT_URL=https:\/\//); +assert.match(prepare, /DEFAULT_SHA256=[0-9a-f]{64}/); +assert.match(prepare, /checksum mismatch/); +// No DevEco install: the standalone hvigor CLI must be usable. +assert.match(packaging, /@ohos\/hvigor\/bin\/hvigor\.js/); + +// Distribution policy: the repository only ever carries the unsigned package, +// AppGallery gets the signed one, and the test channel signs with its own +// material. +assert.match(workflow, /channel:[\s\S]{0,400}default: release/); +assert.match(workflow, /HARMONY_KEYSTORE_BASE64[\s\S]{0,400}prefix\.p12/); +assert.match(workflow, /OPENLESS_HARMONY_CHANNEL: \$\{\{ inputs\.channel \}\}/); +assert.match(workflow, /--channel "\$\{\{ inputs\.channel \}\}"/); +const attachStart = workflow.indexOf('gh release upload'); +const releaseAttach = workflow.slice(attachStart, workflow.indexOf('attached HarmonyOS assets', attachStart)); +assert.ok(!releaseAttach.includes('-signed.hap'), 'the Release must not carry the signed HAP'); +assert.match(releaseAttach, /OpenLess_harmony_arm64\.hap/); +assert.match(packaging, /OpenLess_harmony_arm64-signed\.hap/); +assert.match(packaging, /OPENLESS_HARMONY_CHANNEL/); +assert.match(readFileSync(join(appRoot, 'scripts/sign-harmony-hap.py'), 'utf8'), /--prefix/); +assert.match(readFileSync(join(appRoot, 'scripts/verify-harmony-hap.sh'), 'utf8'), /--allow-unsigned/); + +// Credentials arrive as Secrets/Variables, never as literals. +for (const secret of [ + 'HARMONY_KEYSTORE_BASE64', + 'HARMONY_CERT_BASE64', + 'HARMONY_PROFILE_BASE64', + 'HARMONY_KEYSTORE_PASSWORD', + 'HARMONY_KEY_ALIAS', + 'APPGALLERY_CLIENT_ID', + 'APPGALLERY_CLIENT_SECRET', + 'APPGALLERY_APP_ID', +]) { + assert.ok(workflow.includes(secret), `release-harmony.yml must consume ${secret}`); + assert.ok(releasing.includes(secret), `RELEASING.md must document ${secret}`); +} +assert.doesNotMatch(workflow, /password\s*:\s*['"][^'"]+['"]/i, 'no literal passwords in the workflow'); + +// Packaging and signing are separate, testable scripts. +assert.ok(existsSync(join(appRoot, 'scripts/package-harmony-hap.sh'))); +assert.match(packaging, /sign-harmony-hap\.py/); +assert.match(packaging, /--prefix "\$SIGN_PREFIX"/); +assert.match(packaging, /sha256sum \.\/\*\.hap > SHA256SUMS/); +assert.match(packaging, /OpenLess_harmony_arm64\.hap/); +assert.match(packaging, /OpenLess_harmony\.hap/); +assert.match(signing, /HARMONY_KEYSTORE_PASSWORD/); +assert.match(signing, /\[redacted\]/); + +// AppGallery upload: documented endpoint chain, dry-run, and a skip path. +assert.match(upload, /api\/oauth2\/v1\/token/); +assert.match(upload, /api\/publish\/v2\/upload-url\/for-obs/); +assert.match(upload, /api\/publish\/v3\/app-package-info/); +assert.match(upload, /--dry-run/); +assert.match(upload, /--skip-if-unconfigured/); +assert.match(workflow, /upload-appgallery\.mjs/); + +// Both destinations are wired. +assert.match(workflow, /gh release upload/); +assert.match(workflow, /OpenLess_harmony_arm64\.hap/); + +console.log('harmony-release contract test passed'); diff --git a/openless-all/app/scripts/linux-egui-manual-install.sh b/openless-all/app/scripts/linux-egui-manual-install.sh deleted file mode 100755 index 56b92ea19..000000000 --- a/openless-all/app/scripts/linux-egui-manual-install.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/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 index d948ec7a2..f3084ce25 100644 --- a/openless-all/app/scripts/linux-egui-release-contract.test.mjs +++ b/openless-all/app/scripts/linux-egui-release-contract.test.mjs @@ -24,6 +24,9 @@ const [release, ci, check, pack, verify, gate, manifest, tauri, revisionFile, bu // 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/); +// The release entry point needs write access to attach assets; the CI caller is +// read-only and must not be able to publish. +assert.match(release, /permissions:\n contents: write/); assert.deepEqual( [...ci.matchAll(/^ (linux-[\w-]+):/gm)].map((match) => match[1]), ['linux-egui-package'], @@ -35,7 +38,7 @@ assert.match( /^on:\n workflow_call:/m, 'the reusable check must not run as a duplicate standalone workflow', ); -assert.match(check, /contents: read/); +assert.match(check, /contents: write/); 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']) { @@ -46,7 +49,9 @@ assert.match( /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/); +// `cargo test --all-targets` already compiles every target, so a following +// `cargo check --all-targets` was a second full compile for no extra coverage. +assert.doesNotMatch(check, /cargo check --locked -p openless-linux-egui/); 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/); @@ -64,7 +69,14 @@ assert.doesNotMatch(ci + check, /libgtk-3|webkit2gtk|libwebkit/); // Shared Tauri tags and PRs must never publish unaccepted Linux packages. assert.match(release, /^on:\n workflow_dispatch:/m); assert.doesNotMatch(release, /^ (push|pull_request):/m); -assert.doesNotMatch(release, /contents: write|action-gh-release|gh release/); +// Linux now ships inside the same Release as the desktop and Android assets, so +// the entry point attaches the verified packages itself. Publishing stays opt-in +// per dispatch and requires the tag, which is what keeps an unaccepted package +// out of a Tauri release. +assert.match(check, /gh release upload/); +assert.match(check, /inputs\.publish/); +assert.match(check, /inputs\.release_tag != ''/); +assert.match(check, /SHA256SUMS-linux/); assert.match(check, /github\.ref_type == 'tag'/); assert.match(check, /does not match package\.json version/); const revision = revisionFile.trim(); @@ -81,10 +93,16 @@ 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'); +assert.match(release, /release_tag: \$\{\{ inputs\.tag \}\}/); +assert.match(release, /checkout_ref: \$\{\{ inputs\.tag \}\}/); // Keep deb/rpm only, including dynamically-loaded desktop libraries and the // fcitx5 addon. No AppImage, minisign, embedded downloader or Qwen ASR runtime. +// +// Rendering is Vulkan-only: the GL/EGL paths were removed (`refactor: remove +// Linux egui OpenGL rendering`), so the packages depend on the Vulkan loader +// and nothing dlopens libEGL / libwayland-egl any more. If libegl1 or +// libwayland-egl1 comes back, a GL renderer was reintroduced. assert.match(pack, /dpkg-deb --build/); assert.match(pack, /rpmbuild --define/); assert.match(pack, /x86_64-linux-gnu\/fcitx5\/libopenless\.so/); @@ -101,13 +119,20 @@ 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']) { +for (const dep of ['fcitx5', 'libpipewire-0.3-0', 'libvulkan1', 'liblzma5', 'libwayland-client0']) { assert.ok(debDeps.includes(dep), `deb must require ${dep}`); } +for (const removed of ['libegl1', 'libgl1', 'libwayland-egl1']) { + assert.ok(!debDeps.includes(removed), `deb must not require ${removed} after the Vulkan migration`); +} const rpmDeps = pack.match(/^Requires: ([^\n]+)/m)?.[1].split(/,\s*/) ?? []; -for (const dep of ['fcitx5', 'pipewire-libs', 'libglvnd-egl', 'libwayland-egl.so.1()(64bit)']) { +for (const dep of ['fcitx5', 'pipewire-libs', 'vulkan-loader', 'libwayland-client']) { assert.ok(rpmDeps.includes(dep), `rpm must require ${dep}`); } +for (const removed of ['libglvnd-egl', 'libwayland-egl.so.1()(64bit)']) { + assert.ok(!rpmDeps.includes(removed), `rpm must not require ${removed} after the Vulkan migration`); +} +assert.match(pack, /^Recommends: mesa-vulkan-drivers$/m, 'packages must recommend a Vulkan driver'); for (const [file, size] of [ ['32x32.png', '32x32'], ['64x64.png', '64x64'], diff --git a/openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt b/openless-all/app/scripts/linux-fcitx5-plugin/CMakeLists.txt similarity index 100% rename from openless-all/scripts/linux-fcitx5-plugin/CMakeLists.txt rename to openless-all/app/scripts/linux-fcitx5-plugin/CMakeLists.txt diff --git a/openless-all/scripts/linux-fcitx5-plugin/build.sh b/openless-all/app/scripts/linux-fcitx5-plugin/build.sh similarity index 100% rename from openless-all/scripts/linux-fcitx5-plugin/build.sh rename to openless-all/app/scripts/linux-fcitx5-plugin/build.sh diff --git a/openless-all/scripts/linux-fcitx5-plugin/hotkey_match.h b/openless-all/app/scripts/linux-fcitx5-plugin/hotkey_match.h similarity index 96% rename from openless-all/scripts/linux-fcitx5-plugin/hotkey_match.h rename to openless-all/app/scripts/linux-fcitx5-plugin/hotkey_match.h index d445cfe03..598daaf7e 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/hotkey_match.h +++ b/openless-all/app/scripts/linux-fcitx5-plugin/hotkey_match.h @@ -17,8 +17,6 @@ #include -#include - namespace openless_hotkeys { constexpr uint32_t kShiftBit = 0x01; @@ -30,6 +28,11 @@ constexpr uint32_t kShiftBit = 0x01; /// along on every key event and must not decide whether a hotkey fires. constexpr uint32_t kModifierMask = 0x01u | 0x04u | 0x08u | 0x40u; +/// Read fcitx::KeyState::Repeat from the original, unnormalized event states. +inline bool shouldNotifyAction(bool isPress, uint32_t originalStates) { + return !isPress || (originalStates & (1u << 31)) == 0; +} + /// X11 modifier keysyms (Shift_L … Hyper_R) plus CapsLock/ShiftLock. inline bool isModifierSym(uint32_t sym) { return sym >= 0xffe1 && sym <= 0xffee; } diff --git a/openless-all/scripts/linux-fcitx5-plugin/hotkey_match_contract.cpp b/openless-all/app/scripts/linux-fcitx5-plugin/hotkey_match_contract.cpp similarity index 79% rename from openless-all/scripts/linux-fcitx5-plugin/hotkey_match_contract.cpp rename to openless-all/app/scripts/linux-fcitx5-plugin/hotkey_match_contract.cpp index 1f2c30d03..24a90e6d4 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/hotkey_match_contract.cpp +++ b/openless-all/app/scripts/linux-fcitx5-plugin/hotkey_match_contract.cpp @@ -9,6 +9,7 @@ using openless_hotkeys::matches; using openless_hotkeys::shouldConsume; +using openless_hotkeys::shouldNotifyAction; using openless_hotkeys::symMatches; static constexpr uint32_t kCtrl = 0x04; @@ -18,6 +19,7 @@ static constexpr uint32_t kCapsLock = 0x02; static constexpr uint32_t kNumLock = 0x10; static constexpr uint32_t kMod3 = 0x20; static constexpr uint32_t kMod5 = 0x80; +static constexpr uint32_t kRepeat = 1u << 31; static void expect(bool actual, const char *what) { if (!actual) { @@ -114,6 +116,40 @@ int main() { expect(openless_hotkeys::statesMatch(kCtrl | kShift, kCtrl) == false, "statesMatch still distinguishes real modifiers"); + // 8. Repeat is inspected before normalizing away raw event state bits. + expect(shouldNotifyAction(true, kCtrl | kShift), + "an ordinary press is notified"); + expect(!shouldNotifyAction(true, kRepeat | kCtrl | kShift | kCapsLock), + "a repeated press is suppressed even with modifiers and CapsLock"); + expect(shouldNotifyAction(false, kRepeat | kCtrl | kShift | kCapsLock), + "a release is always notified even if its raw states include Repeat"); + + struct Event { + bool isPress; + uint32_t states; + }; + const Event gesture[] = { + {true, kCtrl | kShift}, + {true, kCtrl | kShift | kRepeat}, + {true, kCtrl | kShift | kRepeat | kCapsLock}, + {false, kCtrl | kShift}, + }; + unsigned presses = 0; + unsigned releases = 0; + for (const auto &event : gesture) { + if (shouldNotifyAction(event.isPress, event.states)) { + event.isPress ? ++presses : ++releases; + } + } + expect(presses == 1 && releases == 1, + "a held gesture sends exactly one press and one release"); + expect(shouldNotifyAction(true, kCtrl | kShift), + "a new press after release starts the next gesture"); + expect(shouldNotifyAction(true, kCtrl | kShift) && + !shouldNotifyAction(true, kCtrl | kShift | kRepeat) && + shouldNotifyAction(true, kCtrl | kShift), + "a new non-repeat press still fires when the prior release was lost"); + std::printf("hotkey_match contract passed\n"); return 0; } diff --git a/openless-all/scripts/linux-fcitx5-plugin/input_target_contract.cpp b/openless-all/app/scripts/linux-fcitx5-plugin/input_target_contract.cpp similarity index 100% rename from openless-all/scripts/linux-fcitx5-plugin/input_target_contract.cpp rename to openless-all/app/scripts/linux-fcitx5-plugin/input_target_contract.cpp diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.conf.in b/openless-all/app/scripts/linux-fcitx5-plugin/openless.conf.in similarity index 100% rename from openless-all/scripts/linux-fcitx5-plugin/openless.conf.in rename to openless-all/app/scripts/linux-fcitx5-plugin/openless.conf.in diff --git a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp b/openless-all/app/scripts/linux-fcitx5-plugin/openless.cpp similarity index 99% rename from openless-all/scripts/linux-fcitx5-plugin/openless.cpp rename to openless-all/app/scripts/linux-fcitx5-plugin/openless.cpp index 35d795818..e67e649c5 100644 --- a/openless-all/scripts/linux-fcitx5-plugin/openless.cpp +++ b/openless-all/app/scripts/linux-fcitx5-plugin/openless.cpp @@ -264,12 +264,18 @@ class OpenLess final : public AddonInstance, FCITX_LOGC(openless, Debug) << "QA shortcut sym=0x" << std::hex << sym << std::dec << " states=0x" << std::hex << states; - qaShortcutEvent(qaRawSym_, qaRawStates_, isPress); + if (openless_hotkeys::shouldNotifyAction(isPress, + static_cast(keyEvent.origKey().states()))) { + qaShortcutEvent(qaRawSym_, qaRawStates_, isPress); + } consume(qaRawSym_); return; } if (quickNoteRawSym_ != 0 && hit(quickNoteRawSym_, quickNoteRawStates_)) { - quickNoteEvent(quickNoteRawSym_, quickNoteRawStates_, isPress); + if (openless_hotkeys::shouldNotifyAction(isPress, + static_cast(keyEvent.origKey().states()))) { + quickNoteEvent(quickNoteRawSym_, quickNoteRawStates_, isPress); + } consume(quickNoteRawSym_); return; } diff --git a/openless-all/scripts/linux-fcitx5-plugin/primary_selection.cpp b/openless-all/app/scripts/linux-fcitx5-plugin/primary_selection.cpp similarity index 100% rename from openless-all/scripts/linux-fcitx5-plugin/primary_selection.cpp rename to openless-all/app/scripts/linux-fcitx5-plugin/primary_selection.cpp diff --git a/openless-all/scripts/linux-fcitx5-plugin/primary_selection.h b/openless-all/app/scripts/linux-fcitx5-plugin/primary_selection.h similarity index 100% rename from openless-all/scripts/linux-fcitx5-plugin/primary_selection.h rename to openless-all/app/scripts/linux-fcitx5-plugin/primary_selection.h diff --git a/openless-all/scripts/linux-fcitx5-plugin/protocols/README.md b/openless-all/app/scripts/linux-fcitx5-plugin/protocols/README.md similarity index 100% rename from openless-all/scripts/linux-fcitx5-plugin/protocols/README.md rename to openless-all/app/scripts/linux-fcitx5-plugin/protocols/README.md diff --git a/openless-all/scripts/linux-fcitx5-plugin/protocols/ext-data-control-v1.xml b/openless-all/app/scripts/linux-fcitx5-plugin/protocols/ext-data-control-v1.xml similarity index 100% rename from openless-all/scripts/linux-fcitx5-plugin/protocols/ext-data-control-v1.xml rename to openless-all/app/scripts/linux-fcitx5-plugin/protocols/ext-data-control-v1.xml diff --git a/openless-all/app/scripts/pack-harmony-toolchain.sh b/openless-all/app/scripts/pack-harmony-toolchain.sh new file mode 100755 index 000000000..1b0cfcf3e --- /dev/null +++ b/openless-all/app/scripts/pack-harmony-toolchain.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# +# Pack a HarmonyOS toolchain for CI. +# +# scripts/pack-harmony-toolchain.sh [output.tar.zst] +# +# The HarmonyOS SDK that hvigor accepts cannot be downloaded without a Huawei +# account, so CI restores a tarball instead (see scripts/prepare-harmony-sdk.sh). +# This script produces that tarball from a local install of the HarmonyOS +# command-line tools. +# +# It packs the SDK plus `hvigorw`, `hvigor/`, `ohpm/` and the bundled node, and +# skips only the emulator. hvigor refuses to build when any of the expected +# components (ets, js, native, previewer, toolchains) is missing, so those all +# have to travel with the tarball. +# +# The result is printed with its size and SHA-256; those two values are what the +# release workflow needs (`HARMONY_SDK_URL`, `HARMONY_SDK_SHA256`). +set -euo pipefail + +toolchain=${OPENLESS_HARMONY_TOOLCHAIN:-$HOME/.local/share/harmony/current} +# `current` is a symlink into a versioned directory; tar would archive the link +# itself, so pack the real target. +[ -d "$toolchain" ] && toolchain=$(readlink -f "$toolchain") +[ -d "$toolchain/sdk" ] || { echo "no toolchain at $toolchain" >&2; exit 1; } +[ -x "$toolchain/bin/hvigorw" ] || { echo "$toolchain/bin/hvigorw is missing" >&2; exit 1; } + +output=${1:-$PWD/harmony-toolchain.tar.zst} +mkdir -p "$(dirname "$output")" + +# Pack an explicit member list: excluding `previewer` by name would also drop the +# hvigor plugin's own `tasks/hook/previewer` module, which every build imports. +name=$(basename "$toolchain") +members=() +for part in sdk bin hvigor ohpm tool lib LICENSE.txt NOTICE.txt version.txt; do + [ -e "$toolchain/$part" ] && members+=("$name/$part") +done + +echo "packing $toolchain (${#members[@]} top-level entries)" +tar_args=(-cf - -C "$(dirname "$toolchain")" + --exclude="$name/emulator" + --exclude='node_modules/.cache') +if command -v zstd > /dev/null 2>&1; then + tar "${tar_args[@]}" "${members[@]}" | zstd -10 -T0 --quiet -f -o "$output" +else + echo "zstd not found, falling back to gzip (slower and larger)" >&2 + output=${output%.zst}.gz + tar "${tar_args[@]}" "${members[@]}" | gzip -6 -c > "$output" +fi + +bytes=$(stat -c %s "$output") +printf 'size: %s bytes (%.1f MiB)\n' "$bytes" "$(awk -v b="$bytes" 'BEGIN {print b/1048576}')" +if [ "$bytes" -gt 2147483648 ]; then + echo "warning: larger than the 2 GiB GitHub Release asset limit; host it on object storage or split the archive" >&2 +fi +echo "sha256: $(sha256sum "$output" | awk '{print $1}')" +echo "$output" diff --git a/openless-all/app/scripts/package-harmony-hap.sh b/openless-all/app/scripts/package-harmony-hap.sh new file mode 100755 index 000000000..5977d365c --- /dev/null +++ b/openless-all/app/scripts/package-harmony-hap.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# Build, sign and stage the HarmonyOS HAP for a release. +# +# scripts/package-harmony-hap.sh +# +# Requires a Huawei DevEco / HarmonyOS SDK (`OPENLESS_HARMONY_TOOLCHAIN`, or +# ~/.local/share/harmony/current) plus release signing material +# (`OPENLESS_HARMONY_SIGNING_DIR`, see scripts/sign-harmony-hap.py). Both are +# available on a self-hosted runner or a developer machine, never on a hosted +# GitHub runner: the SDK download is account-gated. +# +# Output: target/harmony-packages/ +# OpenLess_harmony_arm64.hap versioned artifact +# OpenLess_harmony.hap stable alias used by the website links +# SHA256SUMS +set -euo pipefail + +APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TOOLCHAIN="${OPENLESS_HARMONY_TOOLCHAIN:-$HOME/.local/share/harmony/current}" +# Channels, matching how the artifacts are distributed: +# release production build for HUAWEI AppGallery (signed with release.*) +# test beta build for the AppGallery test channel (signed with test.*) +# none no store signing; the debug material is used when it is present +# The GitHub Release only ever carries the *unsigned* HAP. +CHANNEL="${OPENLESS_HARMONY_CHANNEL:-release}" +case "$CHANNEL" in + release | test) SIGN_PREFIX="$CHANNEL" ;; + none) SIGN_PREFIX="" ;; + *) echo 'OPENLESS_HARMONY_CHANNEL must be release, test or none' >&2; exit 1 ;; +esac +SIGNING_DIR="${OPENLESS_HARMONY_SIGNING_DIR:-$HOME/.local/share/harmony/signing/release}" +CACHE_ROOT="${OPENLESS_CACHE_ROOT:-$APP_ROOT/target/harmony-cache}" +OUTPUT="${OPENLESS_HARMONY_OUTPUT:-$APP_ROOT/target/harmony-packages}" + +# The command-line tools ship their own `hvigorw` and node; put them on PATH up +# front so the version stamp, `ohpm` and `hvigorw` are all found without relying +# on the caller's environment. +if [ -x "$TOOLCHAIN/bin/hvigorw" ]; then + PATH="$TOOLCHAIN/bin:$TOOLCHAIN/tool/node/bin:$PATH" + export PATH +fi + +[ -d "$TOOLCHAIN/sdk" ] || { + echo "missing HarmonyOS SDK at $TOOLCHAIN (set OPENLESS_HARMONY_TOOLCHAIN)" >&2 + exit 1 +} +if [ -n "$SIGN_PREFIX" ] && [ ! -d "$SIGNING_DIR" ]; then + echo "missing signing directory $SIGNING_DIR (set OPENLESS_HARMONY_SIGNING_DIR)" >&2 + exit 1 +fi + +APP_JSON="$APP_ROOT/harmony/AppScope/app.json5" +VERSION="${OPENLESS_HARMONY_VERSION:-$(node -p "require('$APP_ROOT/package.json').version" | sed 's/[-+].*//')}" +VERSION_CODE="${OPENLESS_HARMONY_VERSION_CODE:-$(< "$APP_ROOT/linux-egui/package-revision")}" +[ -n "$VERSION" ] || { echo "cannot resolve the HarmonyOS versionName" >&2; exit 1; } +case "$VERSION_CODE" in + '' | *[!0-9]*) + echo "versionCode must be a positive integer, got '$VERSION_CODE'" >&2 + exit 1 + ;; +esac + +# AppGallery rejects a versionCode that does not increase, and the manifest in +# the repository is a development placeholder (0.1.0 / 1). Patch the checkout for +# the duration of the build and restore it afterwards, including on failure. +BACKUP="$(mktemp)" +cp "$APP_JSON" "$BACKUP" +restore_app_json() { cp "$BACKUP" "$APP_JSON"; rm -f "$BACKUP"; } +trap restore_app_json EXIT +python3 - "$APP_JSON" "$VERSION" "$VERSION_CODE" <<'PY' +import re, sys +path, version, code = sys.argv[1], sys.argv[2], sys.argv[3] +text = open(path).read() +text = re.sub(r'"versionCode":\s*\d+', '"versionCode": ' + code, text) +text = re.sub(r'"versionName":\s*"[^"]*"', '"versionName": "' + version + '"', text) +open(path, 'w').write(text) +PY + +# build-harmony-frontend.sh builds the Rust cdylib, the ArkWeb bundle, runs +# hvigor and (when debug material is present) signs. We sign again below with the +# release material so the artifact matches what AppGallery accepts. +# On a hosted runner there is no DevEco install, so `hvigorw` does not exist: +# scripts/prepare-harmony-sdk.sh installs hvigor next to the SDK instead. +HVIGOR_BIN="" +if [ -f "$TOOLCHAIN/standalone-hvigor/node_modules/@ohos/hvigor/bin/hvigor.js" ]; then + HVIGOR_BIN="$TOOLCHAIN/standalone-hvigor/node_modules/@ohos/hvigor/bin/hvigor.js" +fi +if [ -z "$HVIGOR_BIN" ] && ! command -v hvigorw > /dev/null 2>&1; then + echo "no hvigor available: install the SDK with scripts/prepare-harmony-sdk.sh or use a machine with DevEco Studio" >&2 + exit 1 +fi + +HVIGOR_JS="$HVIGOR_BIN" \ +OPENLESS_CACHE_ROOT="$CACHE_ROOT" \ +OPENLESS_HARMONY_TOOLCHAIN="$TOOLCHAIN" \ +OPENLESS_HARMONY_SIGNING_DIR="$SIGNING_DIR" \ + bash "$APP_ROOT/scripts/build-harmony-frontend.sh" + +BUILD_PROJECT="$CACHE_ROOT/harmony/openless-project" +OUT_DIR="$BUILD_PROJECT/entry/build/default/outputs/default" +UNSIGNED=$(ls "$OUT_DIR"/*-unsigned.hap 2>/dev/null | head -1) +[ -n "$UNSIGNED" ] || { echo "hvigor produced no unsigned HAP in $OUT_DIR" >&2; exit 1; } + +SIGNED="" +if [ -n "$SIGN_PREFIX" ]; then + python3 "$APP_ROOT/scripts/sign-harmony-hap.py" \ + "$TOOLCHAIN" "$SIGNING_DIR" "$OUT_DIR" --prefix "$SIGN_PREFIX" + SIGNED="$OUT_DIR/entry-default-signed.hap" + [ -f "$SIGNED" ] || { echo "expected a signed HAP at $SIGNED" >&2; exit 1; } +else + echo "no store signing: packaging the unsigned HAP only" +fi + +rm -rf "$OUTPUT" +mkdir -p "$OUTPUT" +# The repository and the GitHub Release distribute the unsigned package, so a +# download never carries someone else's signing identity. +cp "$UNSIGNED" "$OUTPUT/OpenLess_harmony_arm64.hap" +cp "$UNSIGNED" "$OUTPUT/OpenLess_harmony.hap" +if [ -n "$SIGNED" ]; then + # Store-only artifact: the workflow uploads it to AppGallery and keeps it out + # of the Release. + cp "$SIGNED" "$OUTPUT/OpenLess_harmony_arm64-signed.hap" +fi +( cd "$OUTPUT" && sha256sum ./*.hap > SHA256SUMS ) +echo "HAP packages ready in $OUTPUT" +ls -l "$OUTPUT" diff --git a/openless-all/app/scripts/package-linux-egui.sh b/openless-all/app/scripts/package-linux-egui.sh index 78cb2842a..3b700c8c2 100644 --- a/openless-all/app/scripts/package-linux-egui.sh +++ b/openless-all/app/scripts/package-linux-egui.sh @@ -16,7 +16,7 @@ fi ARCH=${OPENLESS_LINUX_ARCH:-x86_64} TARGET_DIR=${CARGO_TARGET_DIR:-"$APP_ROOT/target"} BINARY="$TARGET_DIR/release/openless-linux-egui" -PLUGIN_ROOT="$APP_ROOT/../scripts/linux-fcitx5-plugin/build" +PLUGIN_ROOT="$APP_ROOT/scripts/linux-fcitx5-plugin/build" PACKAGING="$APP_ROOT/linux-egui/packaging" OUTPUT="$TARGET_DIR/linux-egui-packages" # 图标:与 Tauri 侧共用同一套画(`icon.png` 与 `public/AppIcon.png` 的 md5 相同, @@ -114,7 +114,7 @@ Priority: optional Architecture: amd64 Maintainer: OpenLess Contributors Description: OpenLess Linux egui host -Depends: fcitx5, fcitx5-module-dbus, libasound2, libbz2-1.0, libc6, libdbus-1-3, libegl1, libfcitx5config6, libfcitx5core7, libfcitx5utils2, libffi8, libgcc-s1, liblzma5, libpipewire-0.3-0, libpulse0, libstdc++6, libsystemd0, libuuid1, libvulkan1, libwayland-client0, libwayland-egl1, libx11-6, libx11-xcb1, libxcb1, libxcursor1, libxi6, libxkbcommon0, libxkbcommon-x11-0 +Depends: fcitx5, fcitx5-module-dbus, libasound2, libbz2-1.0, libc6, libdbus-1-3, libfcitx5config6, libfcitx5core7, libfcitx5utils2, libffi8, libgcc-s1, liblzma5, libpipewire-0.3-0, libpulse0, libstdc++6, libsystemd0, libuuid1, libvulkan1, libwayland-client0, libx11-6, libx11-xcb1, libxcb1, libxcursor1, libxi6, libxkbcommon0, libxkbcommon-x11-0 Recommends: mesa-vulkan-drivers Homepage: https://github.com/Open-Less/openless EOF @@ -154,7 +154,7 @@ License: AGPL-3.0-only URL: https://github.com/Open-Less/openless BuildArch: x86_64 Source0: openless-$RPM_VERSION.tar.gz -Requires: fcitx5, dbus-libs, alsa-lib, pipewire-libs, pulseaudio-libs, libX11, libxcb, libwayland-client, libxkbcommon, libglvnd-egl, vulkan-loader, libXi.so.6()(64bit), libXcursor.so.1()(64bit), libX11-xcb.so.1()(64bit), libxkbcommon-x11.so.0()(64bit), libwayland-egl.so.1()(64bit) +Requires: fcitx5, dbus-libs, alsa-lib, pipewire-libs, pulseaudio-libs, libX11, libxcb, libwayland-client, libxkbcommon, vulkan-loader, libXi.so.6()(64bit), libXcursor.so.1()(64bit), libX11-xcb.so.1()(64bit), libxkbcommon-x11.so.0()(64bit) Recommends: mesa-vulkan-drivers %description OpenLess Linux egui host. @@ -191,4 +191,9 @@ rpmbuild --define "_topdir $RPM_TOP" --define "_dbpath $RPM_TOP/rpmdb" \ mv "$RPM_TOP/RPMS/x86_64/openless-$RPM_VERSION-1.x86_64.rpm" \ "$OUTPUT/OpenLess-Linux-egui-${VERSION}-${ARCH}.rpm" +# The checksums are part of the delivery, not a step for the caller to remember: +# a stale file here makes `verify-linux-egui-packages.sh` fail on freshly built +# packages. +( cd "$OUTPUT" && sha256sum ./*.deb ./*.rpm > SHA256SUMS ) + find "$OUTPUT" -maxdepth 1 -type f -printf '%f\n' | sort diff --git a/openless-all/app/scripts/prepare-harmony-sdk.sh b/openless-all/app/scripts/prepare-harmony-sdk.sh new file mode 100755 index 000000000..1814e5a94 --- /dev/null +++ b/openless-all/app/scripts/prepare-harmony-sdk.sh @@ -0,0 +1,164 @@ +#!/usr/bin/env bash +# +# Prepare a HarmonyOS SDK on a hosted runner. +# +# scripts/prepare-harmony-sdk.sh +# +# Why this exists: `hvigor` refuses the public OpenHarmony SDK. The two trees are +# not interchangeable - verified against `@ohos/hos-sdkmanager-common` (the loader +# hvigor 6 uses for `runtimeOS: HarmonyOS`): +# +# * component metadata is `sdk-pkg.json` with the payload under a `data` key +# (the OpenHarmony SDK ships `oh-uni-package.json` with the payload inline); +# * component directories are named `-`, e.g. +# `ets-HarmonyOS-NEXT2` (`HOS_CONFIG.sdkSufNameMapper`), and each component +# carries `hosSdkVersion`/`stage` that map to an API level through +# `HOS_CONFIG.osVersionMapper`; +# * `HosLocalComponentLoader` accepts a component only when its location matches +# `/` (or sits in a directory called `default`). +# +# So the SDK has to be produced by DevEco / the HarmonyOS SDK Manager, which is +# account-gated. What other HarmonyOS projects do - and what this script +# implements - is to keep one verified tarball of that tree where CI may read it, +# pin its SHA-256, and restore it from cache on every run. +# +# A public mirror of the official command-line tools is the default source, which +# is exactly what other HarmonyOS projects do (sherpa-onnx's `harmony-os` workflow +# downloads the same archive and caches it by name). Override the URL/checksum when +# a newer tools release is needed; the checksum is what makes the download +# trustworthy either way. +# +# Environment: +# HARMONY_SDK_URL https URL of the tools archive (default below) +# HARMONY_SDK_SHA256 expected SHA-256 (default below) +# OPENLESS_HARMONY_TOOLCHAIN install root (default: $HOME/.local/share/harmony/current) +# HARMONY_HVIGOR_VERSION @ohos/hvigor version to install (default: 6.26.8) +# HARMONY_NPM_REGISTRY Huawei npm registry (default: https://repo.harmonyos.com/npm/) +set -euo pipefail + +# commandline-tools-linux-x64-6.0.1.251.zip, 2,108,510,902 bytes. +DEFAULT_URL=https://huggingface.co/csukuangfj/harmonyos-commandline-tools/resolve/main/commandline-tools-linux-x64-6.0.1.251.zip +DEFAULT_SHA256=e971348eabe959b41b1d07fae037b3cc53ab2c0a8306f0895357dd45f98ad421 +url=${HARMONY_SDK_URL:-$DEFAULT_URL} +sha=${HARMONY_SDK_SHA256:-$DEFAULT_SHA256} +root=${OPENLESS_HARMONY_TOOLCHAIN:-$HOME/.local/share/harmony/current} +hvigor_version=${HARMONY_HVIGOR_VERSION:-6.26.8} +registry=${HARMONY_NPM_REGISTRY:-https://repo.harmonyos.com/npm/} +sdk_root=$root/sdk + +die() { + echo "error: $*" >&2 + exit 1 +} + +# Fail with the loader's own rule instead of letting hvigor report +# "Cannot find the corresponding SDK version under the specified SDK path". +validate_sdk_tree() { + local dir=$1 + [ -d "$dir" ] || die "SDK root $dir does not exist" + local packages + packages=$(find "$dir" -maxdepth 3 -name sdk-pkg.json | head -50) + if [ -z "$packages" ]; then + die "no sdk-pkg.json under $dir: this looks like an OpenHarmony SDK (oh-uni-package.json), which hvigor rejects for runtimeOS HarmonyOS" + fi + if ! xargs -r grep -l '"apiVersion"' <<< "$packages" > /dev/null; then + die "sdk-pkg.json under $dir carries no apiVersion; the tarball is not a usable HarmonyOS SDK" + fi + # Two shapes exist: one sdk-pkg.json in `default/` describing the whole SDK + # (what the official command-line tools ship), or one per component directory. + if [ ! -d "$dir/default/openharmony/ets" ] \ + && [ -z "$(find "$dir" -maxdepth 3 -type d -name 'ets*' | head -1)" ]; then + die "SDK has no ArkTS component under $dir" + fi + if [ ! -d "$dir/default/openharmony/toolchains" ] \ + && [ -z "$(find "$dir" -maxdepth 3 -type d -name 'toolchains*' | head -1)" ]; then + die "SDK has no toolchains component under $dir" + fi + local api + api=$(xargs -r grep -h '"apiVersion"' <<< "$packages" | head -1 | sed 's/[^0-9]//g') + echo "SDK ready at $dir (API ${api:-unknown})" +} + +# A cache restore can be partial (SDK without `bin/hvigorw`, or the other way +# round), so require both halves before skipping the download. +if [ -n "$(find "$sdk_root" -maxdepth 3 -name sdk-pkg.json 2> /dev/null | head -1)" ] \ + && [ -x "$root/bin/hvigorw" ]; then + echo "HarmonyOS toolchain already present at $root" +else + [ -n "$url" ] || die "HARMONY_SDK_URL is required to install the SDK" + [ -n "$sha" ] || die "HARMONY_SDK_SHA256 is required to install the SDK" + + archive=$(mktemp) + # Keep the original name so the extractor can tell zip from tar by extension. + record=$archive.${url##*.} + echo "downloading $url" + curl --fail --location --retry 3 --output "$record" "$url" + actual=$(sha256sum "$record" | awk '{print $1}') + if [ "$actual" != "$sha" ]; then + rm -f "$record" + die "SDK archive checksum mismatch: got $actual, expected $sha" + fi + + staging=$(mktemp -d) + case "$record" in + *.zip) + command -v unzip > /dev/null 2>&1 || die "unzip is required to extract the tools archive" + unzip -q "$record" -d "$staging" + ;; + *) + tar -xf "$archive" -C "$staging" + ;; + esac + rm -f "$record" + + # Accept three tarball shapes: a bare SDK tree, a whole command-line-tools + # install (`sdk/` + `bin/` + `hvigor/` + `tool/`), or either wrapped in one + # top-level directory. + source_root=$staging + if [ ! -d "$staging/sdk" ] && [ -z "$(find "$staging" -maxdepth 3 -name sdk-pkg.json | head -1)" ]; then + inner=$(find "$staging" -maxdepth 3 -type d -name sdk | head -1) + [ -n "$inner" ] || die "SDK archive contains neither sdk/ nor sdk-pkg.json" + source_root=$(dirname "$inner") + fi + + mkdir -p "$root" + if [ -d "$source_root/sdk" ]; then + # Whole toolchain: keep hvigorw and the bundled node, they are what + # `package-harmony-hap.sh` drives. + for part in sdk bin hvigor ohpm tool lib; do + [ -d "$source_root/$part" ] || continue + rm -rf "${root:?}/$part" + cp -a "$source_root/$part" "$root/$part" + done + echo "HarmonyOS command-line tools installed at $root" + else + rm -rf "$sdk_root" + cp -a "$source_root" "$sdk_root" + echo "SDK installed at $sdk_root" + fi + rm -rf "$staging" +fi + +validate_sdk_tree "$sdk_root" + +# hvigor comes from two places: the HarmonyOS command-line tools ship it +# (`$root/bin/hvigorw`), and a bare SDK tarball needs it from the Huawei npm +# registry. Never install into the toolchain's own `hvigor/` directory: a second +# `@ohos/hvigor` copy there shadows the bundled one, so `hvigorfile.ts` loads a +# different `hvigor` singleton and every build dies with +# "The root node is not yet available for build". +standalone=$root/standalone-hvigor +if [ -x "$root/bin/hvigorw" ]; then + echo "hvigor ships with the toolchain at $root/bin/hvigorw" +elif [ -f "$standalone/node_modules/@ohos/hvigor/bin/hvigor.js" ]; then + echo "hvigor already installed at $standalone" +else + mkdir -p "$standalone" + ( + cd "$standalone" + npm init -y > /dev/null + npm install --no-audit --no-fund --registry="$registry" \ + "@ohos/hvigor@${hvigor_version}" "@ohos/hvigor-ohos-plugin@${hvigor_version}" + ) + echo "hvigor installed at $standalone" +fi diff --git a/openless-all/app/scripts/sherpa-onnx-android.mjs b/openless-all/app/scripts/sherpa-onnx-android.mjs index 2ee65ef72..95f3a9403 100644 --- a/openless-all/app/scripts/sherpa-onnx-android.mjs +++ b/openless-all/app/scripts/sherpa-onnx-android.mjs @@ -20,12 +20,12 @@ import process from 'node:process'; import { fileURLToPath } from 'node:url'; // Must match the sherpa-onnx crate version in src-tauri/Cargo.toml. -export const SHERPA_ONNX_VERSION = '1.13.2'; +export const SHERPA_ONNX_VERSION = '1.13.8'; export const SHERPA_ONNX_ANDROID_ARCHIVE = { name: `sherpa-onnx-v${SHERPA_ONNX_VERSION}-android.tar.bz2`, url: `https://github.com/k2-fsa/sherpa-onnx/releases/download/v${SHERPA_ONNX_VERSION}/sherpa-onnx-v${SHERPA_ONNX_VERSION}-android.tar.bz2`, - sizeBytes: 52_236_308, - sha256: 'fc4d17941152941a883b0cfabfc9acac118682324e9f97df6c1ae1360bc7bc8e', + sizeBytes: 46_093_321, + sha256: '2ff63469a71cb6009aa2e3ed5f4a670f8abdcbe4bb9ffd23776afc792a6b4f44', }; /** The only ABI local ASR is built for; the others stay cloud-only. */ export const SHERPA_ONNX_ANDROID_ABI = 'arm64-v8a'; diff --git a/openless-all/app/scripts/sign-harmony-hap.py b/openless-all/app/scripts/sign-harmony-hap.py index e7e3d8994..7b056b1ec 100644 --- a/openless-all/app/scripts/sign-harmony-hap.py +++ b/openless-all/app/scripts/sign-harmony-hap.py @@ -1,21 +1,87 @@ -"""Sign with local developer material without storing passwords in the project.""" +"""Sign a HarmonyOS HAP with material that never enters the repository. + +Usage: + sign-harmony-hap.py TOOLCHAIN SIGNING_DIR OUTPUT_DIR [--release] [--prefix NAME] + +Signing material layout inside SIGNING_DIR: + + debug.cer / debug.p7b / debug.p12 / keystore-password # local debug builds + release.cer / release.p7b / release.p12 / keystore-password # AppGallery production + test.cer / test.p7b / test.p12 / keystore-password # AppGallery test channel + +`--prefix` selects the material explicitly, which is how a test-channel build is +signed with the same script. + +The passwords may also come from the environment (`HARMONY_KEYSTORE_PASSWORD`, +`HARMONY_KEY_PASSWORD`), which is what CI uses: the files are written from +base64 secrets at job time and never committed or cached. +""" from pathlib import Path +import os import subprocess import sys -toolchain, signing, output = map(Path, sys.argv[1:]) -password = (signing / 'keystore-password').read_text().strip() +release = '--release' in sys.argv[1:] +prefix_override = '' +args = [] +rest = sys.argv[1:] +while rest: + argument = rest.pop(0) + if argument == '--prefix': + if not rest: + sys.exit('--prefix needs a value') + prefix_override = rest.pop(0) + elif argument.startswith('--'): + continue + else: + args.append(argument) +if len(args) != 3: + sys.exit(__doc__) +toolchain, signing, output = map(Path, args) + +prefix = prefix_override or ('release' if release else 'debug') +key_alias = os.environ.get('HARMONY_KEY_ALIAS') or f'harmony-{prefix}' + +password = os.environ.get('HARMONY_KEYSTORE_PASSWORD', '').strip() +if not password: + password_file = signing / 'keystore-password' + if not password_file.is_file(): + sys.exit(f'missing signing password: set HARMONY_KEYSTORE_PASSWORD or {password_file}') + password = password_file.read_text().strip() +key_password = os.environ.get('HARMONY_KEY_PASSWORD', '').strip() or password + +required = [signing / f'{prefix}.cer', signing / f'{prefix}.p7b', signing / f'{prefix}.p12'] +missing = [str(path) for path in required if not path.is_file()] +if missing: + sys.exit(f'missing HarmonyOS signing material: {", ".join(missing)}') + jar = toolchain / 'sdk/default/openharmony/toolchains/lib/hap-sign-tool.jar' -unsigned = output / 'entry-default-unsigned.hap' +if not jar.is_file(): + sys.exit(f'missing hap-sign-tool.jar under {toolchain}') + +candidates = sorted(output.glob('*-unsigned.hap')) +if len(candidates) != 1: + sys.exit(f'expected exactly one unsigned HAP in {output}, found {len(candidates)}') +unsigned = candidates[0] +# Keep the name the build scripts already reference; `--release` only swaps the +# signing material, not the artifact name. signed = output / 'entry-default-signed.hap' +if signed.exists(): + signed.unlink() + command = ['java', '-jar', str(jar), 'sign-app', '-mode', 'localSign', - '-keyAlias', 'harmony-debug', '-appCertFile', str(signing / 'debug.cer'), - '-profileFile', str(signing / 'debug.p7b'), '-inFile', str(unsigned), - '-signAlg', 'SHA256withECDSA', '-keystoreFile', str(signing / 'debug.p12'), - '-keystorePwd', password, '-keyPwd', password, '-outFile', str(signed), + '-keyAlias', key_alias, + '-appCertFile', str(signing / f'{prefix}.cer'), + '-profileFile', str(signing / f'{prefix}.p7b'), + '-inFile', str(unsigned), + '-signAlg', 'SHA256withECDSA', + '-keystoreFile', str(signing / f'{prefix}.p12'), + '-keystorePwd', password, '-keyPwd', key_password, + '-outFile', str(signed), '-compatibleVersion', '12', '-signCode', '1'] result = subprocess.run(command, capture_output=True, text=True) -print((result.stdout + result.stderr).replace(password, '[redacted]')) +# Never echo the passwords back, even on failure. +print((result.stdout + result.stderr).replace(password, '[redacted]').replace(key_password, '[redacted]')) if result.returncode: sys.exit(result.returncode) print('Signed HAP:', signed) diff --git a/openless-all/app/scripts/sync-egui-lucide-icons.mjs b/openless-all/app/scripts/sync-egui-lucide-icons.mjs index 5991659ee..6602c969b 100644 --- a/openless-all/app/scripts/sync-egui-lucide-icons.mjs +++ b/openless-all/app/scripts/sync-egui-lucide-icons.mjs @@ -19,9 +19,10 @@ export const icons = { Download: 'Download', Upload: 'Upload', Plus: 'Plus', Play: 'Play', Stop: 'Square', Close: 'X', Check: 'Check', Send: 'ArrowUp', Pin: 'Pin', Chat: 'MessageSquare', More: 'Ellipsis', ChevronRight: 'ChevronRight', + ChevronLeft: 'ChevronLeft', Feather: 'Feather', Layout: 'PanelLeft', Doc: 'FileText', Pencil: 'Pencil', Cloud: 'Cloud', Shield: 'ShieldCheck', Info: 'Info', Help: 'CircleHelp', - External: 'ExternalLink', Monitor: 'Monitor', + External: 'ExternalLink', Monitor: 'Monitor', Grip: 'GripVertical', }; const version = JSON.parse(readFileSync(resolve(root, 'node_modules/lucide-react/package.json'), 'utf8')).version; const check = process.argv.includes('--check'); diff --git a/openless-all/app/scripts/upload-appgallery.mjs b/openless-all/app/scripts/upload-appgallery.mjs new file mode 100644 index 000000000..65bcc883a --- /dev/null +++ b/openless-all/app/scripts/upload-appgallery.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// Upload a HarmonyOS HAP to HUAWEI AppGallery Connect (Publishing API v2/v3). +// +// node scripts/upload-appgallery.mjs --file path/to/hap [--channel release|test] +// [--release-type N] [--release-phase N] [--dry-run] [--skip-if-unconfigured] +// +// Environment (GitHub Secrets in CI, never the repository): +// APPGALLERY_CLIENT_ID AGC API client id +// APPGALLERY_CLIENT_SECRET AGC API client secret +// APPGALLERY_APP_ID AGC app id of the HarmonyOS app +// +// Flow (matches the documented Publishing API sequence): +// 1. POST /api/oauth2/v1/token -> access_token +// 2. GET /api/publish/v2/upload-url/for-obs -> objectId + upload url/headers +// 3. PUT -> file bytes +// 4. PUT /api/publish/v3/app-package-info -> bind the object to the app +// +// The app is *not* submitted for review: step 4 only replaces the candidate +// package, so a release still needs the human submission that AGC requires. +import { createHash } from 'node:crypto'; +import { readFileSync, statSync } from 'node:fs'; +import { basename } from 'node:path'; + +const ORIGIN = 'https://connect-api.cloud.huawei.com'; +const args = process.argv.slice(2); +const value = (flag, fallback = '') => { + const index = args.indexOf(flag); + return index >= 0 && args[index + 1] ? args[index + 1] : fallback; +}; +const file = value('--file'); +const dryRun = args.includes('--dry-run'); +const skipIfUnconfigured = args.includes('--skip-if-unconfigured'); +// The GitHub Release ships the unsigned package, so only this store upload ever +// sees a signed HAP. `--channel test` is a label for the log plus the default +// release type used by the AppGallery test channel. +const channel = value('--channel', 'release'); +const releaseType = Number(value('--release-type', channel === 'test' ? '1' : '1')); +const releasePhase = Number(value('--release-phase', '0')); + +const clientId = process.env.APPGALLERY_CLIENT_ID ?? ''; +const clientSecret = process.env.APPGALLERY_CLIENT_SECRET ?? ''; +const appId = process.env.APPGALLERY_APP_ID ?? ''; + +const missing = []; +if (!clientId) missing.push('APPGALLERY_CLIENT_ID'); +if (!clientSecret) missing.push('APPGALLERY_CLIENT_SECRET'); +if (!appId) missing.push('APPGALLERY_APP_ID'); +if (!file) missing.push('--file'); + +if (missing.length) { + const message = `AppGallery upload needs ${missing.join(', ')}`; + if (skipIfUnconfigured) { + console.log(`[appgallery] skipped: ${message}`); + process.exit(0); + } + console.error(`[appgallery] ${message}`); + process.exit(2); +} + +const size = statSync(file).size; +const fileName = basename(file); +const digest = createHash('sha256').update(readFileSync(file)).digest('hex'); +console.log(`[appgallery] ${fileName} ${size} bytes sha256=${digest} channel=${channel}`); + +if (dryRun) { + console.log(`[appgallery] dry run: would upload to app ${appId} (releaseType=${releaseType}, releasePhase=${releasePhase})`); + process.exit(0); +} + +const request = async (path, init = {}) => { + const response = await fetch(`${ORIGIN}${path}`, init); + const text = await response.text(); + let body = null; + try { + body = text ? JSON.parse(text) : null; + } catch { + body = null; + } + return { status: response.status, body, text }; +}; + +const token = await request('/api/oauth2/v1/token', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'cache-control': 'no-cache' }, + body: JSON.stringify({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecret }), +}); +const accessToken = token.body?.access_token; +if (!accessToken) { + console.error(`[appgallery] token request failed: HTTP ${token.status} ${token.text.slice(0, 400)}`); + process.exit(1); +} + +const authHeaders = { Authorization: `Bearer ${accessToken}`, client_id: clientId }; + +const uploadUrl = await request( + `/api/publish/v2/upload-url/for-obs?appId=${encodeURIComponent(appId)}&fileName=${encodeURIComponent(fileName)}&contentLength=${size}`, + { headers: { ...authHeaders, 'Content-Type': 'application/json' } }, +); +const urlInfo = uploadUrl.body?.urlInfo; +if (uploadUrl.body?.ret?.code !== 0 || !urlInfo?.url || !urlInfo?.objectId) { + console.error(`[appgallery] upload-url failed: HTTP ${uploadUrl.status} ${uploadUrl.text.slice(0, 400)}`); + process.exit(1); +} + +const put = await fetch(urlInfo.url, { + method: 'PUT', + headers: { ...(urlInfo.headers ?? {}), 'Content-Length': String(size) }, + body: readFileSync(file), +}); +if (!put.ok) { + console.error(`[appgallery] object upload failed: HTTP ${put.status} ${(await put.text()).slice(0, 400)}`); + process.exit(1); +} + +const apply = await request( + `/api/publish/v3/app-package-info?appId=${encodeURIComponent(appId)}&releaseType=${releaseType}&releasePhase=${releasePhase}`, + { + method: 'PUT', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ fileName, objectId: urlInfo.objectId }), + }, +); +if (apply.body?.ret?.code !== 0) { + console.error(`[appgallery] app-package-info failed: HTTP ${apply.status} ${apply.text.slice(0, 400)}`); + process.exit(1); +} + +console.log( + `[appgallery] uploaded ${fileName} to app ${appId} (${channel}); submit it for review in AppGallery Connect to publish.`, +); diff --git a/openless-all/scripts/vendor-dsh-events.sh b/openless-all/app/scripts/vendor-dsh-events.sh similarity index 100% rename from openless-all/scripts/vendor-dsh-events.sh rename to openless-all/app/scripts/vendor-dsh-events.sh diff --git a/openless-all/app/scripts/verify-harmony-hap.sh b/openless-all/app/scripts/verify-harmony-hap.sh new file mode 100755 index 000000000..a93466685 --- /dev/null +++ b/openless-all/app/scripts/verify-harmony-hap.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# +# Verify a packaged HarmonyOS HAP without a device. +# +# scripts/verify-harmony-hap.sh [toolchain] [versionName] [versionCode] [--allow-unsigned] +# +# Checks the things that silently go wrong in a HAP build: a missing ArkWeb +# bundle, a stale version stamp, a missing native library, and - when the Huawei +# signing tool is available - an invalid signature. +set -euo pipefail + +hap=${1:?usage: verify-harmony-hap.sh [toolchain] [versionName] [versionCode]} +toolchain=${2:-${OPENLESS_HARMONY_TOOLCHAIN:-$HOME/.local/share/harmony/current}} +version_name=${3:-} +version_code=${4:-} + +[ -f "$hap" ] || { echo "missing HAP: $hap" >&2; exit 1; } + +entries=$(unzip -Z1 "$hap") +require_entry() { + grep -qx "$1" <<< "$entries" || { echo "HAP is missing $1" >&2; exit 1; } +} +require_entry module.json +require_entry 'libs/arm64-v8a/libopenless_harmony.so' +require_entry 'resources/rawfile/web/index.html' +# The ArkWeb bundle is hashed on every build: a fixed name would hide a stale copy. +grep -q '^resources/rawfile/web/assets/index-.*\.js$' <<< "$entries" \ + || { echo "HAP has no hashed ArkWeb entry bundle" >&2; exit 1; } + +python3 - "$hap" "$version_name" "$version_code" <<'PY' +import json +import subprocess +import sys + +hap, want_name, want_code = sys.argv[1:4] +module = json.loads(subprocess.run(['unzip', '-p', hap, 'module.json'], + capture_output=True, check=True).stdout) +app = module['app'] +if want_name and str(app.get('versionName')) != want_name: + sys.exit(f"versionName is {app.get('versionName')}, expected {want_name}") +if want_code and str(app.get('versionCode')) != want_code: + sys.exit(f"versionCode is {app.get('versionCode')}, expected {want_code}") +module_name = module['module']['name'] +print(f"HAP: {module_name} versionName={app.get('versionName')} versionCode={app.get('versionCode')}") +PY + +jar=$toolchain/sdk/default/openharmony/toolchains/lib/hap-sign-tool.jar +if [ "${5:-}" = '--allow-unsigned' ]; then + # The GitHub Release carries the unsigned package on purpose. + echo "signature check skipped (unsigned artifact)" +elif [ -f "$jar" ] && command -v java > /dev/null 2>&1; then + # verify-app exits non-zero on a tampered or unsigned package; keep its output + # terse because a valid run prints several informational lines. + if out=$(java -jar "$jar" verify-app -inFile "$hap" -outCertChain "$hap.cer" \ + -outProfile "$hap.p7b" 2>&1); then + echo "signature verified" + else + echo "$out" >&2 + echo "HAP signature verification failed" >&2 + exit 1 + fi + rm -f "$hap.cer" "$hap.p7b" +else + echo "skipping signature verification: hap-sign-tool.jar or java not available" +fi + +echo "verified $hap" diff --git a/openless-all/app/scripts/verify-linux-egui-packages.sh b/openless-all/app/scripts/verify-linux-egui-packages.sh index f77fb5112..88d29e4c7 100644 --- a/openless-all/app/scripts/verify-linux-egui-packages.sh +++ b/openless-all/app/scripts/verify-linux-egui-packages.sh @@ -13,7 +13,17 @@ test "${#rpms[@]}" -eq 1 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 +# `SHA256SUMS` plus the two packages. `SHA256SUMS-linux` is an optional copy the +# release workflow adds for the GitHub Release, where the desktop and Android +# assets already own the name `SHA256SUMS`. +expected_files=3 +[ -f "$OUTPUT/SHA256SUMS-linux" ] && expected_files=4 +actual_files=$(find "$OUTPUT" -maxdepth 1 \( -type f -o -type l \) | wc -l) +if [ "$actual_files" -ne "$expected_files" ]; then + echo "expected $expected_files files in $OUTPUT, found $actual_files:" >&2 + find "$OUTPUT" -maxdepth 1 -type f -printf ' %f\n' >&2 + exit 1 +fi ( cd "$OUTPUT" sha256sum --check --strict SHA256SUMS @@ -71,11 +81,13 @@ appstreamcli validate --no-net "$WORK/rpm/usr/share/metainfo/top.openless.OpenLe 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]}") +# Query our unsigned local artifact without touching the host's system RPM keyring. +mkdir -p "$WORK/rpmdb" +RPM_FILES=$(rpm --dbpath "$WORK/rpmdb" -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]}") +RPM_DEPENDS=$(rpm --dbpath "$WORK/rpmdb" -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-ime-lifecycle-contract.test.mjs b/openless-all/app/scripts/windows-ime-lifecycle-contract.test.mjs index 487a5973c..a1cfd8bbb 100644 --- a/openless-all/app/scripts/windows-ime-lifecycle-contract.test.mjs +++ b/openless-all/app/scripts/windows-ime-lifecycle-contract.test.mjs @@ -10,6 +10,16 @@ const editSession = readFileSync(join(imeRoot, 'edit_session.cpp'), 'utf8'); const displayAttribute = readFileSync(join(imeRoot, 'display_attribute.cpp'), 'utf8'); const registry = readFileSync(join(imeRoot, 'registry.cpp'), 'utf8'); const protocol = readFileSync(join(appRoot, 'src-tauri', 'src', 'windows_ime_protocol.rs'), 'utf8'); + +// The TSF DLL is a standalone vcxproj that the Tauri build never touches, so the +// only thing that can catch a compile error before a maintainer opens Visual +// Studio is an explicit CI step. +const ciWorkflow = readFileSync(join(appRoot, '../../.github/workflows/ci.yml'), 'utf8'); +assert.match( + ciWorkflow, + /msbuild OpenLessIme\.sln \/p:Configuration=Release \/p:Platform=x64/, + 'CI must compile the Windows TSF IME', +); const ipc = readFileSync(join(appRoot, 'src-tauri', 'src', 'windows_ime_ipc.rs'), 'utf8'); const tauriLib = readFileSync(join(appRoot, 'src-tauri', 'src', 'lib.rs'), 'utf8'); diff --git a/openless-all/app/scripts/windows-msi-release-contract.test.mjs b/openless-all/app/scripts/windows-msi-release-contract.test.mjs index f79455215..d3fec672e 100644 --- a/openless-all/app/scripts/windows-msi-release-contract.test.mjs +++ b/openless-all/app/scripts/windows-msi-release-contract.test.mjs @@ -8,8 +8,11 @@ import yaml from 'js-yaml'; const workflow = yaml.load( readFileSync(new URL('../../../.github/workflows/release-tauri.yml', import.meta.url), 'utf8'), ); -assert.deepEqual(workflow.on.push.branches, ['**']); +// Releases are tag-only plus a weekly smoke build: a per-branch push wrote a +// 2 GB+ cache entry every time and evicted the beta scope. +assert.equal(workflow.on.push.branches, undefined); assert.deepEqual(workflow.on.push.tags, ['v*-tauri']); +assert.equal(workflow.on.schedule.length, 1); assert.ok(workflow.on.workflow_dispatch.inputs.platform.options.includes('windows')); assert.equal(workflow.on.workflow_dispatch.inputs.release_tag.default, ''); const job = workflow.jobs.build; diff --git a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs index a8cfeb286..680886384 100644 --- a/openless-all/app/scripts/workflow-concurrency-contract.test.mjs +++ b/openless-all/app/scripts/workflow-concurrency-contract.test.mjs @@ -44,15 +44,20 @@ assert.ok( workflows.ci.includes("branches: ['**']"), 'CI must run on every branch push so branch changes receive the quality gate', ); +// Release workflows are tag-only: a per-branch push rebuilt a 1-2 GB cache entry +// on every commit and evicted the beta scope, while `ci.yml` already builds the +// same trees for the quality gate. A weekly scheduled run keeps them warm. for (const [name, source] of [ ['Android release', workflows.android], ['Tauri release', workflows.tauriRelease], ]) { - assert.match( + assert.doesNotMatch( source, - /branches:\n\s+- '\*\*'/, - `${name} workflow must build branch artifacts for provenance-bound testing`, + /push:[\s\S]{0,200}branches:/, + `${name} workflow must not rebuild on every branch push`, ); + assert.match(source, /schedule:/, `${name} workflow needs a warm-up schedule`); + assert.match(source, /tags:[\s\S]{0,40}v\*-tauri/, `${name} workflow must still ship tags`); } console.log('workflow-concurrency-contract.test.mjs passed'); diff --git a/openless-all/app/shared/remote_tls/tls_identity.rs b/openless-all/app/shared/remote_tls/tls_identity.rs index d8b9f8358..9b3d8bf98 100644 --- a/openless-all/app/shared/remote_tls/tls_identity.rs +++ b/openless-all/app/shared/remote_tls/tls_identity.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use rcgen::{ BasicConstraints, CertificateParams, DistinguishedName, DnType, ExtendedKeyUsagePurpose, IsCa, - KeyPair, KeyUsagePurpose, + Issuer, KeyPair, KeyUsagePurpose, }; use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}; use serde::{Deserialize, Serialize}; @@ -46,9 +46,13 @@ fn matching_key(cert: &[u8], key: &[u8]) -> Result<(), String> { .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 parse_cert(cert: &[u8]) -> Result, String> { + let (remaining, parsed) = x509_parser::parse_x509_certificate(cert) + .map_err(|error| format!("invalid remote TLS certificate: {error}"))?; + if !remaining.is_empty() { + return Err("remote TLS certificate has trailing bytes".into()); + } + Ok(parsed) } fn read_identity(path: &Path) -> Result, String> { @@ -127,16 +131,9 @@ fn valid_leaf(identity: &StoredIdentity, sans: &[String], now: OffsetDateTime) - 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)) + if params.is_ca() + || params.validity().not_before.to_datetime() > now + || params.validity().not_after.to_datetime() < now + Duration::days(30) || matching_key(&identity.leaf_cert, &identity.leaf_key).is_err() { return false; @@ -158,26 +155,26 @@ fn valid_leaf(identity: &StoredIdentity, sans: &[String], now: OffsetDateTime) - .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() + !sans.is_empty() + && sans.iter().all(|name| { + let Ok(name) = rustls::pki_types::ServerName::try_from(name.clone()) 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 { @@ -207,20 +204,23 @@ fn load_at(directory: &Path, sans: &[String], now: OffsetDateTime) -> Result now - || ca_params.not_after <= now + Duration::days(30) + if !ca_params.is_ca() + || !ca_params + .key_usage() + .map_err(|error| error.to_string())? + .is_some_and(|usage| usage.value.key_cert_sign()) + || ca_params.validity().not_before.to_datetime() > now + || ca_params.validity().not_after.to_datetime() <= 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 ca_expiry = ca_params.validity().not_after.to_datetime(); + let ca = + Issuer::from_ca_cert_der(&CertificateDer::from(identity.ca_cert.as_slice()), &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())?; @@ -234,7 +234,7 @@ fn load_at(directory: &Path, sans: &[String], now: OffsetDateTime) -> Result Result String { use sha2::{Digest, Sha256}; - format!("{:x}", Sha256::digest(cert)) + Sha256::digest(cert) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() } /// The profile contains a public CA only. IDs include its fingerprint so two @@ -332,10 +335,8 @@ mod tests { // 两张证书的名称完全相同,描述文件的名称和标识也可以照抄。 assert_eq!( - parse_cert(&original.trust_cert).unwrap().distinguished_name, - parse_cert(&replacement.trust_cert) - .unwrap() - .distinguished_name + parse_cert(&original.trust_cert).unwrap().subject(), + parse_cert(&replacement.trust_cert).unwrap().subject() ); let encode = |bytes: &[u8]| base64::engine::general_purpose::STANDARD.encode(bytes); let substituted = mobileconfig(&original.trust_cert).replace( @@ -436,15 +437,26 @@ mod tests { 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)); + assert_eq!( + ca.basic_constraints() + .unwrap() + .unwrap() + .value + .path_len_constraint, + Some(0) + ); + assert!(!leaf.is_ca()); + assert!( + leaf.extended_key_usage() + .unwrap() + .unwrap() + .value + .server_auth + ); + assert!( + leaf.validity().not_after.to_datetime() - leaf.validity().not_before.to_datetime() + <= Duration::days(366) + ); } #[test] @@ -477,7 +489,14 @@ mod tests { 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)); + assert!( + parse_cert(&after.leaf_cert) + .unwrap() + .validity() + .not_after + .to_datetime() + > now + Duration::days(700) + ); } #[test] diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index e92eb6af3..18d1747d6 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -10,12 +10,12 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aead" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.2.2", + "inout 0.2.5", ] [[package]] @@ -25,30 +25,41 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", +] + [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] [[package]] name = "alloc-no-stdlib" -version = "2.0.4" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +checksum = "c2fb6cfd47bf496ff64095c20eaba0c201404ee38714d4142fcfa1dc334fcc7a" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0b5c1865780388bfa186411ab5f247819487fc4864c6e9c3106611fa347586e1" dependencies = [ "alloc-no-stdlib", ] @@ -60,7 +71,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.13.0", + "bitflags 2.13.2", "cfg-if", "libc", ] @@ -77,9 +88,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -136,48 +147,48 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" dependencies = [ "derive_arbitrary", ] [[package]] name = "arboard" -version = "3.6.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" dependencies = [ "clipboard-win", "image", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "x11rb", ] [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.1", "password-hash", "zeroize", ] @@ -206,8 +217,8 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 2.0.119", + "synstructure 0.13.2", ] [[package]] @@ -218,7 +229,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -321,13 +332,13 @@ dependencies = [ [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -356,13 +367,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -396,13 +407,16 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "auto-launch" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f012b8cc0c850f34117ec8252a44418f2e34a2cf501de89e29b241ae5f79471" +checksum = "17918dba7ecf78b9a14507ec9f984e7977d13fad87dc86d61038980a45d128cf" dependencies = [ - "dirs 4.0.0", - "thiserror 1.0.69", - "winreg 0.10.1", + "dirs 6.0.0", + "os_info", + "smappservice-rs", + "thiserror 2.0.21", + "windows-registry", + "windows-result 0.4.1", ] [[package]] @@ -411,6 +425,29 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "axum" version = "0.7.9" @@ -436,10 +473,10 @@ dependencies = [ "rustversion", "serde", "serde_urlencoded", - "sha1", + "sha1 0.10.7", "sync_wrapper", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tower", "tower-layer", "tower-service", @@ -477,6 +514,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -489,7 +532,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cexpr", "clang-sys", "itertools", @@ -498,7 +541,7 @@ dependencies = [ "regex", "rustc-hash", "shlex 1.3.0", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -524,20 +567,20 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -555,13 +598,23 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + [[package]] name = "block2" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -570,14 +623,14 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] name = "blocking" -version = "1.6.2" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ "async-channel", "async-task", @@ -588,9 +641,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.3" +version = "9.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" +checksum = "f8b851b75c23ca7873623d612fe49bd1989aeb03d08fb9432187eb253d3d4c6b" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -599,9 +652,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.1" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" +checksum = "941cd9bd4ddab83cb46fa5a2d428f1c857b24ac78cb876cf7beb710840934bd7" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -624,9 +677,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytecheck" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" dependencies = [ "bytecheck_derive", "ptr_meta", @@ -636,20 +689,20 @@ dependencies = [ [[package]] name = "bytecheck_derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -665,9 +718,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] @@ -691,6 +744,15 @@ dependencies = [ "bzip2-sys", ] +[[package]] +name = "bzip2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" +dependencies = [ + "libbz2-rs-sys", +] + [[package]] name = "bzip2-sys" version = "0.1.13+1.0.8" @@ -707,7 +769,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cairo-sys-rs", "glib", "libc", @@ -728,9 +790,9 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591" dependencies = [ "serde_core", ] @@ -755,24 +817,25 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] name = "cargo_toml" -version = "0.22.3" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +checksum = "82f4b26e751e711a5302649417f2da046dce6391b2ea30a4820f37462314f0b9" dependencies = [ + "semver", "serde", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", ] [[package]] name = "cc" -version = "1.2.63" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -797,13 +860,13 @@ dependencies = [ [[package]] name = "cfb" -version = "0.7.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" dependencies = [ - "byteorder", "fnv", "uuid", + "web-time", ] [[package]] @@ -818,49 +881,38 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chacha20" -version = "0.9.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cipher 0.5.2", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] [[package]] name = "chacha20poly1305" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ "aead", - "chacha20 0.9.1", - "cipher", + "chacha20", + "cipher 0.5.2", "poly1305", - "zeroize", ] [[package]] @@ -883,16 +935,26 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", - "inout", - "zeroize", + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.5", ] [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -908,13 +970,28 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cocoa" version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block", "cocoa-foundation", "core-foundation 0.10.1", @@ -930,7 +1007,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block", "core-foundation 0.10.1", "core-graphics-types", @@ -945,9 +1022,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", @@ -962,17 +1039,32 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "constant_time_eq" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] + [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "time", "version_check", @@ -1010,7 +1102,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -1023,7 +1115,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", @@ -1036,11 +1128,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.10.1", "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "coreaudio-rs" version = "0.11.3" @@ -1084,6 +1182,12 @@ dependencies = [ "windows 0.54.0", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1095,9 +1199,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -1119,27 +1223,27 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -1154,15 +1258,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", - "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core 0.10.1", +] + [[package]] name = "cssparser" -version = "0.36.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" dependencies = [ "cssparser-macros", "dtoa-short", @@ -1173,29 +1287,28 @@ dependencies = [ [[package]] name = "cssparser-macros" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a" dependencies = [ "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "ctor" -version = "0.8.0" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" -dependencies = [ - "ctor-proc-macro", - "dtor", -] +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" [[package]] -name = "ctor-proc-macro" -version = "0.0.7" +name = "ctutils" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +dependencies = [ + "cmov", +] [[package]] name = "darling" @@ -1209,12 +1322,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -1228,20 +1341,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -1252,18 +1365,18 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -1274,15 +1387,15 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "dbus" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" dependencies = [ "libc", "libdbus-sys", @@ -1295,6 +1408,37 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2" +[[package]] +name = "defmt" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "defmt-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -1311,23 +1455,22 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" dependencies = [ - "powerfmt", "serde_core", ] [[package]] name = "derive_arbitrary" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -1348,7 +1491,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1358,7 +1501,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1379,7 +1522,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1388,18 +1531,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", "subtle", ] [[package]] -name = "dirs" -version = "4.0.0" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "dirs-sys 0.3.7", + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", + "zeroize", ] [[package]] @@ -1408,18 +1555,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys 0.5.0", + "dirs-sys", ] [[package]] -name = "dirs-sys" -version = "0.3.7" +name = "dirs" +version = "7.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", + "dirs-sys", ] [[package]] @@ -1430,7 +1575,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] @@ -1440,21 +1585,21 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -1477,18 +1622,18 @@ checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "dom_query" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +checksum = "fac5fca71e65e94cc718a6e2af65d6e0f9c6027751c2aa562fbb5087fda639bc" dependencies = [ "bit-set", "cssparser", - "foldhash 0.2.0", + "foldhash", "html5ever", "precomputed-hash", "selectors", @@ -1519,21 +1664,6 @@ dependencies = [ "dtoa", ] -[[package]] -name = "dtor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -1548,22 +1678,22 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.16.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" [[package]] name = "embed-resource" -version = "3.0.9" +version = "3.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31a88c8d26de40ed18fe748c547845aa39de1db3afd958f8cb91579f3644bcb" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", "vswhom", - "winreg 0.55.0", + "winreg 0.56.0", ] [[package]] @@ -1574,11 +1704,16 @@ checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1598,7 +1733,7 @@ dependencies = [ "foreign-types-shared 0.3.1", "libc", "log", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "windows 0.58.0", @@ -1624,14 +1759,14 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "env_filter" -version = "1.0.1" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -1639,9 +1774,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.10" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -1652,9 +1787,9 @@ dependencies = [ [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "erased-serde" @@ -1679,17 +1814,16 @@ dependencies = [ [[package]] name = "error-code" -version = "3.3.2" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1706,9 +1840,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fax" @@ -1740,7 +1874,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -1776,18 +1910,19 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -1796,12 +1931,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foldhash" version = "0.2.0" @@ -1829,13 +1958,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -1871,15 +2000,21 @@ dependencies = [ "reqwest 0.12.28", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-stream", "tokio-util", - "ureq 3.3.0", + "ureq 3.4.2", "urlencoding", - "zip 2.4.2", + "zip 2.6.1", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fst" version = "0.4.7" @@ -1888,24 +2023,24 @@ checksum = "7ab85b9b05e3978cc9a9cf8fea7f01b494e1a09ed3037e16ba39edc7a29eb61a" [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1914,9 +2049,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -1933,32 +2068,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-io", @@ -2118,17 +2253,15 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", "wasm-bindgen", ] @@ -2140,7 +2273,7 @@ checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2181,7 +2314,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "futures-channel", "futures-core", "futures-executor", @@ -2209,7 +2342,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2224,9 +2357,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "global-hotkey" @@ -2235,11 +2368,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fbb3a4e56c901ee66c190fdb3fa08344e6d09593cc6c61f8eb9add7144b271" dependencies = [ "crossbeam-channel", - "keyboard-types", - "objc2 0.6.4", + "keyboard-types 0.7.0", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "once_cell", - "thiserror 2.0.18", + "thiserror 2.0.21", "windows-sys 0.59.0", "x11-dl", ] @@ -2304,14 +2437,14 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "h2" -version = "0.4.14" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -2319,7 +2452,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.14.0", + "indexmap 2.14.2", "slab", "tokio", "tokio-util", @@ -2343,15 +2476,6 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash 0.1.5", -] - [[package]] name = "hashbrown" version = "0.17.1" @@ -2372,9 +2496,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -2388,14 +2512,23 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] name = "html5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" dependencies = [ "log", "markup5ever", @@ -2403,9 +2536,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -2413,9 +2546,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -2423,9 +2556,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -2447,14 +2580,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "hyper" -version = "1.10.1" +name = "hybrid-array" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", "futures-core", "h2", "http", @@ -2470,9 +2612,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -2481,7 +2623,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] @@ -2502,16 +2644,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -2561,9 +2704,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2575,9 +2718,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -2588,9 +2731,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2602,16 +2745,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2622,15 +2766,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2641,12 +2785,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2701,9 +2839,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2713,9 +2851,9 @@ dependencies = [ [[package]] name = "infer" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" dependencies = [ "cfb", ] @@ -2729,11 +2867,20 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is-docker" @@ -2800,26 +2947,56 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.28" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" dependencies = [ + "defmt", + "jiff-core", "jiff-static", + "jiff-tzdb-platform", "log", "portable-atomic", "portable-atomic-util", "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", ] [[package]] name = "jiff-static" -version = "0.2.28" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ + "jiff-core", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", ] [[package]] @@ -2850,7 +3027,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.21", "walkdir", "windows-link 0.2.1", ] @@ -2865,7 +3042,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2893,24 +3070,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.100" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -2919,21 +3096,21 @@ dependencies = [ [[package]] name = "json-patch" -version = "3.0.1" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +checksum = "7421438de105a0827e44fadd05377727847d717c80ce29a229f85fd04c427b72" dependencies = [ "jsonptr", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.21", ] [[package]] name = "jsonptr" -version = "0.6.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +checksum = "a5a3cc660ba5d72bce0b3bb295bf20847ccbb40fd423f3f05b61273672e561fe" dependencies = [ "serde", "serde_json", @@ -2945,11 +3122,21 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "serde", "unicode-segmentation", ] +[[package]] +name = "keyboard-types" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d" +dependencies = [ + "bitflags 2.13.2", + "serde", +] + [[package]] name = "keyring" version = "3.6.3" @@ -2966,15 +3153,9 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "leb128fmt" -version = "0.1.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "libappindicator" @@ -3000,11 +3181,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" + [[package]] name = "libc" -version = "0.2.186" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libdbus-sys" @@ -3037,9 +3224,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] @@ -3052,9 +3239,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "local-ip-address" @@ -3078,15 +3265,15 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "lzma-rs" @@ -3098,6 +3285,15 @@ dependencies = [ "crc", ] +[[package]] +name = "lzma-rust2" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb" +dependencies = [ + "sha2 0.11.1", +] + [[package]] name = "lzma-sys" version = "0.1.20" @@ -3129,9 +3325,9 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.38.0" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" dependencies = [ "log", "tendril", @@ -3151,14 +3347,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -3206,6 +3412,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" +[[package]] +name = "minisign-verify" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871285dc19d8d0ebe0eef3d0e99a205f2a71363b122632cbbfa4a6c370a960ce" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -3216,11 +3428,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -3239,25 +3461,31 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.2" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" +checksum = "cca139e57da4383727f189e43e66e84fa372347027fc996f9f1a007ec0a37996" dependencies = [ "crossbeam-channel", "dpi", "gtk", - "keyboard-types", - "objc2 0.6.4", + "keyboard-types 0.8.3", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.21", "windows-sys 0.61.2", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "munge" version = "0.4.7" @@ -3275,7 +3503,7 @@ checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3301,7 +3529,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys 0.5.0+25.2.9519653", @@ -3315,7 +3543,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys 0.6.0+11769913", @@ -3354,7 +3582,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "byteorder", "derive_builder", "getset", @@ -3374,7 +3602,7 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3383,6 +3611,18 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -3395,9 +3635,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -3417,14 +3657,14 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -3457,14 +3697,14 @@ dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "num_threads" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4" dependencies = [ "libc", ] @@ -3500,9 +3740,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" dependencies = [ "objc-sys", "objc2-encode", @@ -3510,9 +3750,9 @@ dependencies = [ [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", "objc2-exception-helper", @@ -3524,10 +3764,10 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2 0.5.1", "libc", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-core-data 0.2.2", "objc2-core-image 0.2.2", "objc2-foundation 0.2.2", @@ -3540,12 +3780,13 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] @@ -3554,8 +3795,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", + "bitflags 2.13.2", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3565,9 +3806,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3577,7 +3818,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3587,9 +3828,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", ] [[package]] @@ -3598,9 +3839,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "dispatch2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] @@ -3612,7 +3853,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal", ] @@ -3623,7 +3864,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3633,7 +3874,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3643,8 +3884,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", + "bitflags 2.13.2", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", ] @@ -3670,10 +3911,10 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2 0.5.1", "libc", - "objc2 0.5.2", + "objc2 0.5.3", ] [[package]] @@ -3682,10 +3923,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2 0.6.2", "libc", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3695,8 +3936,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", + "bitflags 2.13.2", + "objc2 0.6.5", "objc2-core-foundation", ] @@ -3706,9 +3947,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", ] @@ -3718,8 +3959,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", + "bitflags 2.13.2", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", ] @@ -3730,9 +3971,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2 0.5.1", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-foundation 0.2.2", "objc2-metal", ] @@ -3743,10 +3984,34 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.0", - "objc2 0.6.4", + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-service-management" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b213642d6959cc6023ceb1217aa595eaaf09b8094ce95127c103cab611fe65e8" +dependencies = [ + "block2 0.6.2", + "objc2 0.6.5", "objc2-core-foundation", "objc2-foundation 0.3.2", + "objc2-security", ] [[package]] @@ -3755,9 +4020,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-cloud-kit", "objc2-core-data 0.3.2", "objc2-core-foundation", @@ -3776,7 +4041,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", ] @@ -3786,9 +4051,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2 0.6.2", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3847,22 +4112,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "open" -version = "5.3.5" +version = "5.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" dependencies = [ "dunce", "is-wsl", "libc", - "pathdiff", ] [[package]] @@ -3889,7 +4147,7 @@ dependencies = [ "futures-util", "getrandom 0.3.4", "global-hotkey", - "hmac", + "hmac 0.12.1", "hyper", "hyper-util", "jni 0.21.1", @@ -3897,10 +4155,10 @@ dependencies = [ "libc", "local-ip-address", "log", - "md-5", - "minisign-verify", + "md-5 0.10.6", + "minisign-verify 0.2.5", "ndk-context", - "objc2 0.5.2", + "objc2 0.5.3", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", "once_cell", @@ -3915,12 +4173,12 @@ dependencies = [ "semver", "serde", "serde_json", - "sha1", - "sha2", + "sha1 0.10.7", + "sha2 0.10.9", "sherpa-onnx", "simplelog", "subtle", - "tao", + "tao 0.35.3", "tar", "tauri", "tauri-build", @@ -3940,7 +4198,7 @@ dependencies = [ "time", "tokio", "tokio-rustls", - "tokio-tungstenite", + "tokio-tungstenite 0.24.0", "tower", "url", "uuid", @@ -3950,7 +4208,7 @@ dependencies = [ "winreg 0.52.0", "x509-parser", "zeroize", - "zip 2.4.2", + "zip 2.6.1", ] [[package]] @@ -3959,42 +4217,43 @@ version = "0.1.0" dependencies = [ "anyhow", "argon2", - "base64 0.22.1", - "bzip2 0.4.4", + "base64 0.23.1", + "bzip2 0.6.1", "chacha20poly1305", "chrono", "ferrous-opencc", "futures-util", - "getrandom 0.3.4", - "hmac", + "getrandom 0.4.3", + "hex", + "hmac 0.13.0", "log", - "md-5", + "md-5 0.11.0", "once_cell", "parking_lot", "regex", - "reqwest 0.12.28", + "reqwest 0.13.5", "serde", "serde_json", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.1", "tar", - "thiserror 1.0.69", + "thiserror 2.0.21", "tokio", - "tokio-tungstenite", + "tokio-tungstenite 0.30.0", "unicode-normalization", "url", "uuid", "zeroize", - "zip 2.4.2", + "zip 9.0.5", ] [[package]] name = "openssl" -version = "0.10.80" +version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cfg-if", "foreign-types 0.3.2", "libc", @@ -4010,7 +4269,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4021,9 +4280,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.116" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -4047,6 +4306,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_info" +version = "3.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cf20a545b305cf1da722b236b5155c9bb35f1d5ceb28c048bd96ca842f41b5b" +dependencies = [ + "android_system_properties", + "log", + "nix", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "windows-sys 0.61.2", +] + [[package]] name = "os_pipe" version = "1.2.3" @@ -4063,12 +4337,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-foundation 0.3.2", "objc2-osa-kit", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -4127,29 +4401,32 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom 0.4.3", + "phc", ] [[package]] -name = "pathdiff" -version = "0.2.3" +name = "pbkdf2" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] [[package]] name = "pbkdf2" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest", - "hmac", + "digest 0.11.3", + "hmac 0.13.0", ] [[package]] @@ -4168,6 +4445,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.13.1" @@ -4209,7 +4497,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -4240,18 +4528,18 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "plist" -version = "1.10.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" dependencies = [ - "base64 0.22.1", - "indexmap 2.14.0", + "base64 0.23.1", + "indexmap 2.14.2", "quick-xml", "serde", "time", @@ -4267,7 +4555,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4276,11 +4564,11 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -4299,44 +4587,49 @@ dependencies = [ [[package]] name = "poly1305" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", + "cpufeatures 0.3.1", "universal-hash", ] [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" dependencies = [ "portable-atomic", ] [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppmd-rust" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "494b4379484fc92217cc7d9e3a532375b1d01a61889ec8333025dc3b0377af56" [[package]] name = "ppv-lite86" @@ -4353,16 +4646,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4389,7 +4672,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", + "toml_edit 0.25.17+spec-1.1.0", ] [[package]] @@ -4418,38 +4701,38 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "ptr_meta" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" dependencies = [ "ptr_meta_derive", ] [[package]] name = "ptr_meta_derive" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-error" @@ -4459,18 +4742,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.41.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -4480,7 +4763,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -4488,21 +4771,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ + "aws-lc-rs", "bytes", - "getrandom 0.4.2", + "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -4510,23 +4794,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -4545,18 +4829,18 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rancor" -version = "0.1.1" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" dependencies = [ "ptr_meta", ] [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha", @@ -4565,12 +4849,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "chacha20 0.10.1", - "getrandom 0.4.2", + "chacha20", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4634,56 +4918,44 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", -] - -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", + "bitflags 2.13.2", ] [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -4693,9 +4965,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -4710,9 +4982,9 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rend" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" dependencies = [ "bytecheck", ] @@ -4762,16 +5034,16 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-core", "futures-util", @@ -4783,13 +5055,16 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", + "serde_urlencoded", "sync_wrapper", "tokio", "tokio-rustls", @@ -4817,7 +5092,7 @@ dependencies = [ "gtk-sys", "js-sys", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -4844,14 +5119,14 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.17" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "815cc8a37159a463064825246cadb07961e25cd9885908606f6d08a98d8f8874" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" dependencies = [ "bytecheck", "bytes", "hashbrown 0.17.1", - "indexmap 2.14.0", + "indexmap 2.14.2", "munge", "ptr_meta", "rancor", @@ -4863,20 +5138,20 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.17" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ed1a78a1b19d184b0daa629dd9a024573173ec7d485b287cb369fb3607cc1c" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -4898,11 +5173,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -4911,10 +5186,11 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", @@ -4926,9 +5202,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4938,9 +5214,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -4948,9 +5224,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -4969,16 +5245,17 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4986,9 +5263,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -5007,9 +5284,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" dependencies = [ "windows-sys 0.61.2", ] @@ -5043,9 +5320,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "dyn-clone", "ref-cast", @@ -5062,7 +5339,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5077,7 +5354,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -5090,7 +5367,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5109,11 +5386,11 @@ dependencies = [ [[package]] name = "selectors" -version = "0.36.1" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +checksum = "8adfa1c298912827b8a28b223b3b874357397ae706e6190acd9bf28cee99114d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "cssparser", "derive_more", "log", @@ -5138,9 +5415,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -5160,22 +5437,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -5186,14 +5463,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -5204,13 +5481,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -5224,9 +5501,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -5245,18 +5522,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.3", "serde_core", "serde_json", "serde_with_macros", @@ -5265,14 +5543,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -5294,7 +5572,7 @@ checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5308,42 +5586,63 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] -name = "sha2" -version = "0.10.9" +name = "sha1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] name = "shared_child" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +checksum = "607549934f6cc26b89cfecfdc46fa90f1e5d1536a68349b0c3a4f9d1c0d37959" dependencies = [ "libc", - "sigchld", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "sherpa-onnx" -version = "1.13.2" +version = "1.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f70620e4fa58e4cb1acf4e0a9c2cbc7496ea8284f80e55be23d443b92e563e49" +checksum = "efaa98d5f08380f3d47026e00f60fed182f6edaf4c0af290d88ac0e08d6e3b90" dependencies = [ "serde", "serde_json", @@ -5352,13 +5651,14 @@ dependencies = [ [[package]] name = "sherpa-onnx-sys" -version = "1.13.2" +version = "1.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f3fe4987367b162336027b5d1ffca6dcd627bee6a324e46f80e82dfcb4365b" +checksum = "14bbeabb73f73f1c1f4a278af0ee9ae90a19f9727482027047fb9422fa14f8fe" dependencies = [ "bzip2 0.4.4", "tar", "ureq 2.12.1", + "zip 2.6.1", ] [[package]] @@ -5373,27 +5673,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "sigchld" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" -dependencies = [ - "libc", - "os_pipe", - "signal-hook", -] - -[[package]] -name = "signal-hook" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" -dependencies = [ - "libc", - "signal-hook-registry", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -5406,15 +5685,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -5439,9 +5718,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -5451,15 +5730,27 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "smappservice-rs" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52703b97a53101cf5d4580e0737aaa634ce1fdcfc123c16b2a9658e358013488" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-service-management", + "thiserror 2.0.21", +] [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -5474,7 +5765,7 @@ dependencies = [ "bytemuck", "js-sys", "ndk 0.9.0", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-core-foundation", "objc2-core-graphics", "objc2-foundation 0.3.2", @@ -5519,6 +5810,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_vcruntime" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7589a1859b09232522bb9edf076ca7fcf14ded96685d3fa629db73023ffbb7" + [[package]] name = "string_cache" version = "0.9.0" @@ -5557,9 +5854,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "swift-rs" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +checksum = "cefd87076fd385308ee4aff597256c902f1fbb4d031de1558efc12f8ffefadd9" dependencies = [ "base64 0.21.7", "serde", @@ -5578,9 +5875,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -5604,7 +5912,18 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", ] [[package]] @@ -5613,7 +5932,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5647,7 +5966,7 @@ version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics 0.25.0", @@ -5664,7 +5983,7 @@ dependencies = [ "log", "ndk 0.9.0", "ndk-sys 0.6.0+11769913", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit", @@ -5681,15 +6000,55 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "tao" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37f381f4e048e6cdf038b5705f8cf14ad108279d46eb968140a7b291aba9400" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni 0.21.1", + "libc", + "log", + "ndk 0.9.0", + "ndk-context", + "ndk-sys 0.6.0+11769913", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "objc2-ui-kit", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-version", + "x11-dl", +] + [[package]] name = "tao-macros" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5711,14 +6070,14 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.2" +version = "2.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" +checksum = "f316751027fc2644221d1a448564d519efb07067fda584879d9cda966391d212" dependencies = [ "anyhow", "bytes", "cookie", - "dirs 6.0.0", + "dirs 7.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -5731,7 +6090,7 @@ dependencies = [ "log", "mime", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "objc2-ui-kit", @@ -5739,7 +6098,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.4", + "reqwest 0.13.5", "serde", "serde_json", "serde_repr", @@ -5750,25 +6109,25 @@ dependencies = [ "tauri-runtime", "tauri-runtime-wry", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tray-icon", "url", "webkit2gtk", "webview2-com", - "window-vibrancy 0.6.0", - "windows 0.61.3", + "window-vibrancy 0.8.1", + "windows 0.62.2", ] [[package]] name = "tauri-build" -version = "2.6.2" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" +checksum = "641dd5fd297c99ab92717b0428cca5b9c6f960b2ac96ca131b3eebc9d70b8ddc" dependencies = [ "anyhow", "cargo_toml", - "dirs 6.0.0", + "dirs 7.0.0", "glob", "heck 0.5.0", "json-patch", @@ -5776,6 +6135,8 @@ dependencies = [ "semver", "serde", "serde_json", + "static_vcruntime", + "strsim", "tauri-utils", "tauri-winres", "walkdir", @@ -5783,25 +6144,25 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.2" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" +checksum = "a6585c5b536c00f039d12ce70a9755172d5423bbcfd3969863baa70314c3e02f" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "brotli", "ico", "json-patch", "plist", - "png 0.17.16", + "png 0.18.1", "proc-macro2", "quote", "semver", "serde", "serde_json", - "sha2", - "syn 2.0.117", + "sha2 0.10.9", + "syn 2.0.119", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "url", "uuid", @@ -5810,14 +6171,14 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.2" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" +checksum = "e2e83a0cfa1bae376e8fbbdc0b1692707aed50cd6072ce85cae4789af5d6c19f" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", "tauri-codegen", "tauri-utils", ] @@ -5827,7 +6188,7 @@ name = "tauri-nspanel" version = "2.0.1" source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#18ffb9a201fbf6fedfaa382fd4b92315ea30ab1a" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "block", "cocoa", "core-foundation 0.10.1", @@ -5840,9 +6201,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.2" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" +checksum = "67fb21a77e8f94e8beb3325e583102488659365af0a3948b7ea244c5b779c291" dependencies = [ "anyhow", "glob", @@ -5856,23 +6217,23 @@ dependencies = [ [[package]] name = "tauri-plugin-autostart" -version = "2.5.1" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459383cebc193cdd03d1ba4acc40f2c408a7abce419d64bdcd2d745bc2886f70" +checksum = "bce56ceb0a99b8ab5eebc977ba9ffc62391b862648cb9ab76fc8bc7231b8f7e2" dependencies = [ "auto-launch", "serde", "serde_json", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] name = "tauri-plugin-dialog" -version = "2.7.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2d3c1dbe38037e7f590cdf2492594d5ceebe031e7bc7e827509b22a999d2940" +checksum = "b7461aaacf1901915ea7b9f5cd74f8224a351ed96af0a62c07fce934003cb34b" dependencies = [ "log", "raw-window-handle", @@ -5882,15 +6243,15 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-plugin-fs", - "thiserror 2.0.18", + "thiserror 2.0.21", "url", ] [[package]] name = "tauri-plugin-fs" -version = "2.5.1" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b" dependencies = [ "anyhow", "dunce", @@ -5905,18 +6266,19 @@ dependencies = [ "tauri", "tauri-plugin", "tauri-utils", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", "url", ] [[package]] name = "tauri-plugin-shell" -version = "2.3.5" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +checksum = "8567d42b2c7264355daa1df415ec77f52aa42e885924c86a969be2e920e05521" dependencies = [ "encoding_rs", + "libc", "log", "open", "os_pipe", @@ -5927,42 +6289,45 @@ dependencies = [ "shared_child", "tauri", "tauri-plugin", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", + "windows-sys 0.60.2", ] [[package]] name = "tauri-plugin-single-instance" -version = "2.4.2" +version = "2.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +checksum = "561cd99484561bc39c954768e7b41a66cebbe1ac3d9d081c34361f740c489e2c" dependencies = [ "serde", "serde_json", "tauri", - "thiserror 2.0.18", + "thiserror 2.0.21", + "tokio", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "zbus", ] [[package]] name = "tauri-plugin-updater" -version = "2.10.1" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +checksum = "7259989d4fe11b2bf51f7d671642249b1837c4ae2da1430dc713ae38f98ceeec" dependencies = [ "base64 0.22.1", - "dirs 6.0.0", + "dirs 7.0.0", "flate2", "futures-util", "http", "infer", + "libc", "log", - "minisign-verify", + "minisign-verify 0.3.0", "osakit", "percent-encoding", - "reqwest 0.13.4", + "reqwest 0.13.5", "rustls", "semver", "serde", @@ -5971,70 +6336,70 @@ dependencies = [ "tauri", "tauri-plugin", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tokio", "url", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "zip 4.6.1", ] [[package]] name = "tauri-runtime" -version = "2.11.2" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" +checksum = "7c55766d99cea69055904d1735b93c2049302b2e09effd462120deb5581d78e9" dependencies = [ "cookie", "dpi", "gtk", "http", "jni 0.21.1", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-ui-kit", "objc2-web-kit", "raw-window-handle", "serde", "serde_json", "tauri-utils", - "thiserror 2.0.18", + "thiserror 2.0.21", "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows 0.62.2", ] [[package]] name = "tauri-runtime-wry" -version = "2.11.2" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" +checksum = "8d61892587aea2a85245bcaec98c6fe46268874c0a0deae5535fb71ca7b93eae" dependencies = [ "gtk", "http", "jni 0.21.1", "log", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "once_cell", "percent-encoding", "raw-window-handle", "softbuffer", - "tao", + "tao 0.37.1", "tauri-runtime", "tauri-utils", "url", "webkit2gtk", "webview2-com", - "windows 0.61.3", + "windows 0.62.2", "wry", ] [[package]] name = "tauri-utils" -version = "2.9.2" +version = "2.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" +checksum = "362ab21cd4cc713f2a02b1a9b5688198b1541cfb6907c4281e9190e0f0ef4aa3" dependencies = [ "anyhow", "brotli", @@ -6060,8 +6425,8 @@ dependencies = [ "serde_json", "serde_with", "swift-rs", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.21", + "toml 1.1.8+spec-1.1.0", "url", "urlpattern", "uuid", @@ -6076,7 +6441,7 @@ checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" dependencies = [ "dunce", "embed-resource", - "toml 1.1.2+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", ] [[package]] @@ -6086,7 +6451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -6094,12 +6459,11 @@ dependencies = [ [[package]] name = "tendril" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ "new_debug_unreachable", - "utf-8", ] [[package]] @@ -6122,11 +6486,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.21", ] [[package]] @@ -6137,18 +6501,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -6167,12 +6531,12 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", + "js-sys", "libc", "num-conv", "num_threads", @@ -6184,15 +6548,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -6200,9 +6564,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -6210,24 +6574,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -6242,13 +6597,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -6263,9 +6618,9 @@ dependencies = [ [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -6273,9 +6628,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -6294,19 +6649,36 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite", + "tungstenite 0.24.0", + "webpki-roots 0.26.11", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite 0.30.0", "webpki-roots 0.26.11", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] @@ -6325,32 +6697,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -6364,18 +6721,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] @@ -6386,7 +6734,7 @@ version = "0.19.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 0.6.3", "winnow 0.5.40", ] @@ -6397,7 +6745,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.3", @@ -6406,30 +6754,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ - "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", + "indexmap 2.14.2", + "toml_datetime 1.1.2+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -6452,7 +6800,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -6495,7 +6843,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6509,15 +6857,15 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.23.1" +version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15edbb0d80583e85ee8df283410038e17314df5cba30da2087a54a85216c0773" +checksum = "b2b9c52859a94554803ccd4a24b98f74148ebc73b90676d783f3490b1bff9d72" dependencies = [ "crossbeam-channel", - "dirs 6.0.0", + "dirs 7.0.0", "libappindicator", "muda", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-core-graphics", @@ -6525,7 +6873,7 @@ dependencies = [ "once_cell", "png 0.18.1", "serde", - "thiserror 2.0.18", + "thiserror 2.0.21", "windows-sys 0.61.2", ] @@ -6547,14 +6895,38 @@ dependencies = [ "http", "httparse", "log", - "rand 0.8.6", + "rand 0.8.8", "rustls", "rustls-pki-types", - "sha1", + "sha1 0.10.7", "thiserror 1.0.69", "utf-8", ] +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.3", + "rustls", + "rustls-pki-types", + "sha1 0.11.0", + "thiserror 2.0.21", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -6578,64 +6950,23 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "unic-char-property" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" -dependencies = [ - "unic-char-range", -] - -[[package]] -name = "unic-char-range" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" - -[[package]] -name = "unic-common" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" - -[[package]] -name = "unic-ucd-ident" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" -dependencies = [ - "unic-char-property", - "unic-char-range", - "unic-ucd-version", -] - -[[package]] -name = "unic-ucd-version" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" -dependencies = [ - "unic-common", -] - [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] @@ -6646,20 +6977,14 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -6686,11 +7011,11 @@ dependencies = [ [[package]] name = "ureq" -version = "3.3.0" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "flate2", "log", "percent-encoding", @@ -6698,16 +7023,16 @@ dependencies = [ "rustls-pki-types", "ureq-proto", "utf8-zero", - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "841583fc36089d7d59419568ae157f43a5a03a2e5a81ba87c77d1596ef46e922" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "http", "httparse", "log", @@ -6734,13 +7059,13 @@ checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" [[package]] name = "urlpattern" -version = "0.3.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +checksum = "df16f50ef4cc145211879a3867ba757076b25dfee812040dcb0658bd9ae7904b" dependencies = [ + "icu_properties", "regex", "serde", - "unic-ucd-ident", "url", ] @@ -6770,11 +7095,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.3" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -6830,9 +7155,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -6845,27 +7170,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -6876,19 +7192,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.73" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6896,48 +7213,26 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.123" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - [[package]] name = "wasm-streams" version = "0.4.2" @@ -6964,23 +7259,11 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.13.0", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.100" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -6998,9 +7281,9 @@ dependencies = [ [[package]] name = "web_atoms" -version = "0.2.4" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" dependencies = [ "phf", "phf_codegen", @@ -7054,9 +7337,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -7067,30 +7350,28 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.7", + "webpki-roots 1.0.9", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] [[package]] name = "webview2-com" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +checksum = "3f89fca7a704cee10dcb3654c1dbb8941d1783132f1917358af75bec37a7d7e6" dependencies = [ "webview2-com-macros", "webview2-com-sys", - "windows 0.61.3", - "windows-core 0.61.2", - "windows-implement 0.60.2", - "windows-interface 0.59.3", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -7101,18 +7382,18 @@ checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "webview2-com-sys" -version = "0.38.2" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +checksum = "b3a07132775117d6065853d9d1178157b8c90e228de47129d6bce2c7edebedfb" dependencies = [ - "thiserror 2.0.18", - "windows 0.61.3", - "windows-core 0.61.2", + "thiserror 2.0.21", + "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -7154,31 +7435,32 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "window-vibrancy" -version = "0.6.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", "raw-window-handle", - "windows-sys 0.59.0", + "windows-sys 0.60.2", "windows-version", ] [[package]] name = "window-vibrancy" -version = "0.7.1" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010797bd7c40396fbc59d3105089fed0885fe267a0ef4a0a4646df54e28647f6" +checksum = "111e51caca442cafd9bab396628ac4d814880eaea4e63dfd76a12e9f342b5580" dependencies = [ - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "raw-window-handle", - "windows-sys 0.60.2", + "windows-sys 0.61.2", "windows-version", ] @@ -7208,11 +7490,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "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]] @@ -7224,6 +7518,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.54.0" @@ -7281,7 +7584,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +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", ] [[package]] @@ -7292,7 +7606,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7303,7 +7617,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7314,7 +7628,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7325,7 +7639,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -7350,6 +7664,16 @@ dependencies = [ "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", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -7551,6 +7875,15 @@ 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", +] + [[package]] name = "windows-version" version = "0.1.7" @@ -7751,28 +8084,13 @@ dependencies = [ [[package]] name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - -[[package]] -name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - [[package]] name = "winreg" version = "0.52.0" @@ -7785,21 +8103,12 @@ dependencies = [ [[package]] name = "winreg" -version = "0.55.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" dependencies = [ "cfg-if", - "windows-sys 0.59.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", + "windows-sys 0.61.2", ] [[package]] @@ -7808,102 +8117,23 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.13.0", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wry" -version = "0.55.1" +version = "0.57.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +checksum = "a819957a01b3119af85e638a38d242af76dbc87d130dca67bfd0441072e21ff0" dependencies = [ "base64 0.22.1", "block2 0.6.2", "cookie", "crossbeam-channel", - "dirs 6.0.0", + "dirs 7.0.0", "dom_query", "dpi", "dunce", @@ -7914,7 +8144,7 @@ dependencies = [ "jni 0.21.1", "libc", "ndk 0.9.0", - "objc2 0.6.4", + "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -7923,16 +8153,16 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle", - "sha2", + "sha2 0.10.9", "soup3", "tao-macros", - "thiserror 2.0.18", + "thiserror 2.0.21", "url", "webkit2gtk", "webkit2gtk-sys", "webview2-com", - "windows 0.61.3", - "windows-core 0.61.2", + "windows 0.62.2", + "windows-core 0.62.2", "windows-version", "x11-dl", ] @@ -8051,21 +8281,21 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 3.0.9", + "synstructure 0.14.0", ] [[package]] name = "zbus" -version = "5.16.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" dependencies = [ "async-broadcast", "async-executor", @@ -8090,7 +8320,7 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.3", + "winnow 1.0.4", "zbus_macros", "zbus_names", "zvariant", @@ -8098,14 +8328,14 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.16.0" +version = "5.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -8113,33 +8343,42 @@ dependencies = [ [[package]] name = "zbus_names" -version = "4.3.2" +version = "4.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" dependencies = [ "serde", - "winnow 1.0.3", + "winnow 1.0.4", "zvariant", ] +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -8153,41 +8392,41 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 3.0.9", + "synstructure 0.14.0", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -8196,9 +8435,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -8207,43 +8446,41 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "zip" -version = "2.4.2" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "1dcb24d0152526ae49b9b96c1dcf71850ca1e0b882e4e28ed898a93c41334744" dependencies = [ - "aes", + "aes 0.8.4", "arbitrary", "bzip2 0.5.2", - "constant_time_eq", + "constant_time_eq 0.3.1", "crc32fast", "crossbeam-utils", "deflate64", - "displaydoc", "flate2", "getrandom 0.3.4", - "hmac", - "indexmap 2.14.0", + "hmac 0.12.1", + "indexmap 2.14.2", "lzma-rs", "memchr", - "pbkdf2", - "sha1", - "thiserror 2.0.18", + "pbkdf2 0.12.2", + "sha1 0.10.7", "time", "xz2", "zeroize", "zopfli", - "zstd", + "zstd 0.13.3", ] [[package]] @@ -8254,21 +8491,55 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", - "indexmap 2.14.0", + "indexmap 2.14.2", "memchr", ] +[[package]] +name = "zip" +version = "9.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" +dependencies = [ + "aes 0.9.3", + "bzip2 0.6.1", + "constant_time_eq 0.6.1", + "crc32fast", + "deflate64", + "flate2", + "getrandom 0.4.3", + "hmac 0.13.0", + "indexmap 2.14.2", + "lzma-rust2", + "memchr", + "nix", + "pbkdf2 0.13.0", + "ppmd-rust", + "sha1 0.11.0", + "time", + "typed-path", + "zeroize", + "zopfli", + "zstd 0.14.3", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -8282,23 +8553,41 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ - "zstd-safe", + "zstd-safe 7.3.0", +] + +[[package]] +name = "zstd" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" +dependencies = [ + "zstd-safe 8.2.0", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", ] [[package]] name = "zstd-safe" -version = "7.2.4" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", @@ -8306,9 +8595,9 @@ dependencies = [ [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-jpeg" @@ -8321,40 +8610,41 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.12.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.3", + "winnow 1.0.4", + "zcheapstr", "zvariant_derive", "zvariant_utils", ] [[package]] name = "zvariant_derive" -version = "5.12.0" +version = "5.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", "zvariant_utils", ] [[package]] name = "zvariant_utils" -version = "3.4.0" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.117", - "winnow 1.0.3", + "syn 3.0.9", + "winnow 1.0.4", ] diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 36b7f677d..efe7b8475 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -5,16 +5,16 @@ license = "AGPL-3.0-only" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] edition = "2021" -rust-version = "1.88" +rust-version = "1.99" [lib] name = "openless_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] -# Keep on the same train as `tauri = "=2.11.2"`. Floating `version = "2"` resolves +# Keep on the same train as `tauri = "=2.12.4"`. Floating `version = "2"` resolves # to tauri-build 2.7 when CI regenerates the lockfile. -tauri-build = { version = "=2.6.2", features = [] } +tauri-build = { version = "=2.7.3", features = [] } # Windows manifest anchor and Apple compiler runtime discovery (not legacy ASR). cc = "1.1" @@ -23,19 +23,19 @@ zeroize = "1" # Framework-independent business core. Tauri remains an adapter and owns the # window/plugin/IPC concerns; Linux must depend on the same crate directly. openless-core = { path = "../crates/openless-core" } -# Pin the whole 2.11.2 train. tauri 2.11.2 allows `tauri-runtime ^2.11`, so an +# Pin the whole 2.12.x train. tauri 2.11.2 allows `tauri-runtime ^2.11`, so an # unlocked resolve picks 2.12.0 and fails to compile (Monitor / UnexpectedMenuKind). # Direct pins keep fresh lockfile resolution on the compatible train. # macos-private-api must live in [dependencies] so tauri_build can match tauri.conf.json # macOSPrivateApi; tray-icon stays desktop-only in the target table below. -tauri = { version = "=2.11.2", features = ["macos-private-api"] } -tauri-runtime = "=2.11.2" -tauri-runtime-wry = "=2.11.2" -tauri-macros = "=2.6.2" -tauri-utils = "=2.9.2" +tauri = { version = "=2.12.4", features = ["macos-private-api"] } +tauri-runtime = "=2.12.2" +tauri-runtime-wry = "=2.12.2" +tauri-macros = "=2.7.3" +tauri-utils = "=2.10.2" # 锁 >=2.3.5 修复 GHSA-c9pr-q8gx-3mgp:tauri-plugin-shell `open` 端点作用域校验绕过(CRITICAL,issue #668)。 -tauri-plugin-shell = "2.3.5" -tauri-plugin-dialog = "2.7.2" +tauri-plugin-shell = "2.5.0" +tauri-plugin-dialog = "2.8.3" serde = { version = "1", features = ["derive"] } serde_json = "1" semver = "1" @@ -79,15 +79,15 @@ raw-window-handle = "0.6" cpal = "0.15" [target.'cfg(any(target_os = "android", target_os = "ios"))'.dependencies] -tauri-plugin-fs = "2.5.1" +tauri-plugin-fs = "2.7.1" # Desktop-only plugins, hotkey/insertion helpers, and tray-icon (not built for mobile). [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] reqwest = { version = "0.12", default-features = false, features = ["native-tls"] } -tauri = { version = "=2.11.2", features = ["macos-private-api", "tray-icon"] } -tauri-plugin-updater = "=2.10.1" -tauri-plugin-single-instance = "2" -tauri-plugin-autostart = "2" +tauri = { version = "=2.12.4", features = ["macos-private-api", "tray-icon"] } +tauri-plugin-updater = "=2.14.0" +tauri-plugin-single-instance = "2.5.4" +tauri-plugin-autostart = "2.7.0" global-hotkey = "0.6" enigo = "0.3" arboard = "3" @@ -127,10 +127,10 @@ tao = "0.35" # verified official libraries (scripts/sherpa-onnx-android.mjs) and ships them # in jniLibs. Other Android ABIs stay cloud-only. [target.'cfg(all(target_os = "android", target_arch = "aarch64"))'.dependencies] -sherpa-onnx = { version = "=1.13.2", default-features = false, features = ["shared"] } +sherpa-onnx = { version = "=1.13.8", default-features = false, features = ["shared"] } [target.'cfg(target_os = "macos")'.dependencies] -sherpa-onnx = { version = "=1.13.2", default-features = false, features = ["static"] } +sherpa-onnx = { version = "=1.13.8", default-features = false, features = ["static"] } security-framework = "=3.7.0" block2 = "0.5" core-foundation = "0.10" diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.lock b/openless-all/app/src-tauri/backend-tests/Cargo.lock index 4f3dabcf3..2a3260d62 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.lock +++ b/openless-all/app/src-tauri/backend-tests/Cargo.lock @@ -2,31 +2,25 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - [[package]] name = "aead" -version = "0.5.2" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" dependencies = [ - "crypto-common", - "generic-array", + "crypto-common 0.2.2", + "inout", ] [[package]] name = "aes" -version = "0.8.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cfg-if", "cipher", - "cpufeatures 0.2.17", + "cpubits", + "cpufeatures 0.3.1", ] [[package]] @@ -40,9 +34,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -53,24 +47,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "argon2" -version = "0.5.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", "blake2", - "cpufeatures 0.2.17", + "cpufeatures 0.3.1", "password-hash", "zeroize", ] @@ -100,7 +85,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -126,12 +111,41 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "aws-lc-rs" +version = "1.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -140,17 +154,17 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -162,6 +176,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + [[package]] name = "block2" version = "0.5.1" @@ -197,15 +221,9 @@ checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" @@ -214,38 +232,18 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bzip2" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" -dependencies = [ - "bzip2-sys", - "libc", -] - -[[package]] -name = "bzip2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" -dependencies = [ - "bzip2-sys", -] - -[[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" dependencies = [ - "cc", - "pkg-config", + "libbz2-rs-sys", ] [[package]] name = "cc" -version = "1.4.4" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" dependencies = [ "find-msvc-tools", "jobserver", @@ -255,9 +253,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" @@ -265,17 +263,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - [[package]] name = "chacha20" version = "0.10.2" @@ -283,21 +270,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", + "cipher", "cpufeatures 0.3.1", - "rand_core 0.10.1", + "rand_core", ] [[package]] name = "chacha20poly1305" -version = "0.10.1" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" dependencies = [ "aead", - "chacha20 0.9.1", + "chacha20", "cipher", "poly1305", - "zeroize", ] [[package]] @@ -313,20 +300,54 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common", + "block-buffer 0.12.1", + "crypto-common 0.2.2", "inout", - "zeroize", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "9eb6feb175df65d0a3ccd3e73484e940d0ac664e8034f0519fd49a93db3071d9" +dependencies = [ + "rustversion", +] [[package]] name = "core-foundation" @@ -338,12 +359,28 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -363,44 +400,42 @@ dependencies = [ ] [[package]] -name = "crc" -version = "3.4.0" +name = "crc32fast" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ - "crc-catalog", + "cfg-if", ] [[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" - -[[package]] -name = "crc32fast" -version = "1.5.1" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ - "cfg-if", + "generic-array", + "typenum", ] [[package]] -name = "crossbeam-utils" -version = "0.8.22" +name = "crypto-common" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.3", + "hybrid-array", + "rand_core", +] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "ctutils" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", + "cmov", ] [[package]] @@ -431,30 +466,31 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] -name = "derive_arbitrary" -version = "1.4.2" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "block-buffer 0.10.4", + "crypto-common 0.1.7", ] [[package]] name = "digest" -version = "0.10.7" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer", - "crypto-common", - "subtle", + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", + "zeroize", ] [[package]] @@ -465,14 +501,20 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "equivalent" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -505,7 +547,7 @@ dependencies = [ "serde", "serde_json", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] @@ -531,18 +573,17 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.11" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ - "crc32fast", - "miniz_oxide", + "zlib-rs", ] [[package]] @@ -554,6 +595,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fst" version = "0.4.7" @@ -589,7 +636,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] @@ -643,20 +690,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 5.3.0", - "wasip2", - "wasm-bindgen", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -666,8 +699,8 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", + "r-efi", + "rand_core", "wasm-bindgen", ] @@ -677,13 +710,19 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "hmac" -version = "0.12.1" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest", + "digest 0.11.3", ] [[package]] @@ -725,15 +764,23 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", "http", "http-body", @@ -747,9 +794,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -758,21 +805,21 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots 1.0.9", ] [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -916,9 +963,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown", @@ -926,18 +973,18 @@ dependencies = [ [[package]] name = "inout" -version = "0.1.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] name = "ipnet" -version = "2.12.1" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "itoa" @@ -945,6 +992,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.21", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "jobserver" version = "0.1.35" @@ -957,9 +1053,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -968,15 +1064,21 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libbz2-rs-sys" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.189" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "linux-raw-sys" @@ -1007,39 +1109,27 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "lzma-rs" -version = "0.3.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" -dependencies = [ - "byteorder", - "crc", -] +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] -name = "lzma-sys" -version = "0.1.20" +name = "lzma-rust2" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +checksum = "7277c9742229dc25243236d578b6471a798c554512c6f6a04365ec2e87c2a3bb" dependencies = [ - "cc", - "libc", - "pkg-config", + "sha2 0.11.1", ] [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest", + "digest 0.11.3", ] [[package]] @@ -1070,21 +1160,11 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - [[package]] name = "mio" -version = "1.2.2" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -1111,6 +1191,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "7.1.3" @@ -1195,18 +1287,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - [[package]] name = "openless-backend-tests" version = "0.1.0" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "block2", "futures-util", "libc", @@ -1218,9 +1304,9 @@ dependencies = [ "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tempfile", - "thiserror 2.0.20", + "thiserror 2.0.21", "time", "tokio", "uuid", @@ -1234,13 +1320,14 @@ version = "0.1.0" dependencies = [ "anyhow", "argon2", - "base64", - "bzip2 0.4.4", + "base64 0.23.1", + "bzip2", "chacha20poly1305", "chrono", "ferrous-opencc", "futures-util", - "getrandom 0.3.4", + "getrandom 0.4.3", + "hex", "hmac", "log", "md-5", @@ -1251,9 +1338,9 @@ dependencies = [ "serde", "serde_json", "sha1", - "sha2", + "sha2 0.11.1", "tar", - "thiserror 1.0.69", + "thiserror 2.0.21", "tokio", "tokio-tungstenite", "unicode-normalization", @@ -1263,6 +1350,12 @@ dependencies = [ "zip", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1288,22 +1381,21 @@ dependencies = [ [[package]] name = "password-hash" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b" dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", + "getrandom 0.4.3", + "phc", ] [[package]] name = "pbkdf2" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest", + "digest 0.11.3", "hmac", ] @@ -1313,7 +1405,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -1323,6 +1415,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phc" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892" +dependencies = [ + "base64ct", + "ctutils", + "getrandom 0.4.3", +] + [[package]] name = "phf" version = "0.13.1" @@ -1390,12 +1493,11 @@ checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "poly1305" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", + "cpufeatures 0.3.1", "universal-hash", ] @@ -1410,18 +1512,15 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] -name = "ppv-lite86" -version = "0.2.21" +name = "ppmd-rust" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] +checksum = "494b4379484fc92217cc7d9e3a532375b1d01a61889ec8333025dc3b0377af56" [[package]] name = "proc-macro2" @@ -1449,14 +1548,14 @@ checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -1466,7 +1565,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -1474,21 +1573,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.17" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04759210543be93709136e28212294a659ef5001836ff4eab4d663e4529bba83" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand", "rand_pcg", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -1496,9 +1596,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -1517,12 +1617,6 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -1540,43 +1634,13 @@ dependencies = [ [[package]] name = "rand" -version = "0.8.8" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ - "libc", - "rand_chacha", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20 0.10.2", + "chacha20", "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -1591,7 +1655,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.10.1", + "rand_core", ] [[package]] @@ -1657,11 +1721,11 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.28" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-core", "futures-util", @@ -1679,6 +1743,7 @@ dependencies = [ "quinn", "rustls", "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "serde_urlencoded", @@ -1694,7 +1759,6 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", - "webpki-roots 1.0.9", ] [[package]] @@ -1738,7 +1802,7 @@ checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] @@ -1747,6 +1811,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rusticata-macros" version = "4.1.0" @@ -1758,9 +1831,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -1771,10 +1844,11 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.43" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -1783,6 +1857,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -1793,12 +1879,40 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + [[package]] name = "rustls-webpki" version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -1816,12 +1930,59 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.229" @@ -1849,14 +2010,14 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", @@ -1879,13 +2040,13 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.7" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.2.17", - "digest", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1896,7 +2057,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1911,6 +2083,16 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -1919,9 +2101,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -1931,9 +2113,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "socket2" @@ -1970,9 +2152,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.4" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -1999,6 +2181,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + [[package]] name = "system-configuration" version = "0.7.0" @@ -2006,7 +2199,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys", ] @@ -2055,11 +2248,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -2075,13 +2268,13 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] @@ -2091,6 +2284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", + "js-sys", "num-conv", "powerfmt", "serde_core", @@ -2126,24 +2320,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -2162,14 +2347,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -2177,9 +2362,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.24.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" dependencies = [ "futures-util", "log", @@ -2193,9 +2378,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -2276,24 +2461,28 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.24.0" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" dependencies = [ - "byteorder", "bytes", "data-encoding", "http", "httparse", "log", - "rand 0.8.8", + "rand", "rustls", "rustls-pki-types", "sha1", - "thiserror 1.0.69", - "utf-8", + "thiserror 2.0.21", ] +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.20.1" @@ -2302,33 +2491,33 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" -version = "0.1.25" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" dependencies = [ "tinyvec", ] [[package]] name = "universal-hash" -version = "0.5.1" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" dependencies = [ - "crypto-common", - "subtle", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2350,12 +2539,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2364,9 +2547,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.26.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -2380,11 +2563,21 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -2395,20 +2588,11 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -2419,19 +2603,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.77" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2439,31 +2624,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -2474,9 +2659,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.104" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -2492,6 +2677,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -2510,6 +2704,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows" version = "0.58.0" @@ -2726,12 +2929,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.4" @@ -2766,15 +2963,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xz2" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" -dependencies = [ - "lzma-sys", -] - [[package]] name = "yasna" version = "0.5.2" @@ -2797,34 +2985,14 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", + "synstructure 0.14.0", ] [[package]] @@ -2838,21 +3006,21 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", - "synstructure", + "syn 3.0.9", + "synstructure 0.14.0", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" dependencies = [ "zeroize_derive", ] @@ -2898,39 +3066,43 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.4", + "syn 3.0.9", ] [[package]] name = "zip" -version = "2.4.2" +version = "9.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "6e04976da629d3b04204ba8105b396a193af8ad2583b2c79bfb3ab8af580a11e" dependencies = [ "aes", - "arbitrary", - "bzip2 0.5.2", + "bzip2", "constant_time_eq", "crc32fast", - "crossbeam-utils", "deflate64", - "displaydoc", "flate2", - "getrandom 0.3.4", + "getrandom 0.4.3", "hmac", "indexmap", - "lzma-rs", + "lzma-rust2", "memchr", + "nix", "pbkdf2", + "ppmd-rust", "sha1", - "thiserror 2.0.20", "time", - "xz2", + "typed-path", "zeroize", "zopfli", "zstd", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" @@ -2939,9 +3111,9 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zopfli" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" dependencies = [ "bumpalo", "crc32fast", @@ -2951,27 +3123,27 @@ dependencies = [ [[package]] name = "zstd" -version = "0.13.3" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +checksum = "db3ae9bf90af6e40197bef767eeefc46ad6397420fe873830bd85e5fbbfb3c70" dependencies = [ "zstd-safe", ] [[package]] name = "zstd-safe" -version = "7.2.4" +version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "ded520f16b29e1a9b771c722221b2b0a0398c2316d9bb3c42b458a5414d7434b" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.2+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "481ada9c543b2323cbb7bbdbb5249177fbf0188ed959ed2c15e4b22421c84ed3" dependencies = [ "cc", "pkg-config", diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.toml b/openless-all/app/src-tauri/backend-tests/Cargo.toml index c76417c95..6078b3e91 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.toml +++ b/openless-all/app/src-tauri/backend-tests/Cargo.toml @@ -2,7 +2,7 @@ name = "openless-backend-tests" version = "0.1.0" edition = "2021" -rust-version = "1.88" +rust-version = "1.99" publish = false [dependencies] diff --git a/openless-all/app/src-tauri/src/asr/local/foundry_native.rs b/openless-all/app/src-tauri/src/asr/local/foundry_native.rs index 17534fca1..e1073b57e 100644 --- a/openless-all/app/src-tauri/src/asr/local/foundry_native.rs +++ b/openless-all/app/src-tauri/src/asr/local/foundry_native.rs @@ -255,7 +255,7 @@ mod imp { } async fn download_package_with_fallbacks( - client: &reqwest::Client, + client: &crate::net::HttpClient, package: &NativePackage, source: RuntimeSource, out_dir: &Path, @@ -288,7 +288,7 @@ mod imp { } async fn download_package_from_feed( - client: &reqwest::Client, + client: &crate::net::HttpClient, package: &NativePackage, feed_index: &str, out_dir: &Path, @@ -554,7 +554,7 @@ if ($readyForFoundryX64) { exit 0 } else { exit 1 } } async fn resolve_package_base_address( - client: &reqwest::Client, + client: &crate::net::HttpClient, feed_index: &str, ) -> Result { let value: Value = client diff --git a/openless-all/app/src-tauri/vendor/qwen3-asr-rs b/openless-all/app/src-tauri/vendor/qwen3-asr-rs new file mode 160000 index 000000000..c72c7534b --- /dev/null +++ b/openless-all/app/src-tauri/vendor/qwen3-asr-rs @@ -0,0 +1 @@ +Subproject commit c72c7534b0ad439bc45211d86c2278a8207652e3 diff --git a/openless-all/app/src/components/chat/AgentBuddy.tsx b/openless-all/app/src/components/chat/AgentBuddy.tsx deleted file mode 100644 index 4f87549b2..000000000 --- a/openless-all/app/src/components/chat/AgentBuddy.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useId, type CSSProperties } from 'react'; - -export type BuddyColor = 'coral' | 'amber' | 'violet' | 'mint'; -const COLORS: Record = { - coral: ['#f5b59b', '#dd8167'], - amber: ['#f8d99a', '#d6af65'], - violet: ['#c9b7ea', '#a18bc8'], - mint: ['#b9d5af', '#81b095'], -}; -const SHAPES: Record = { - coral: 'M19 11c6-6 21-6 26 1l7 9c9 11 4 28-9 31l-6 1-8 6-2-6C9 51 4 40 8 28l3-9z', - amber: - 'M16 9c-3-5-8-2-7 3l2 13C2 36 8 51 23 53l10 5 3-6c15 0 23-12 19-25l-5-13c-1-9-9-11-10-2-8-4-17-5-24-3z', - violet: - 'M32 6 52 16c6 3 7 9 5 15l-4 14c-2 8-11 11-19 10l-9 4-2-7C9 49 4 40 7 28l4-11C14 10 23 5 32 6z', - mint: 'M31 11c1-9 8-9 10-5-5-1-7 2-7 6 17 0 24 10 23 24-1 12-12 19-26 18l-9 5-1-7C9 49 5 41 7 28c2-11 12-17 24-17z', -}; - -/** Four original OpenLess companions; silhouettes are not third-party brand logos. */ -export function AgentBuddy({ - color = 'mint', - working = false, - size = 42, -}: { - color?: BuddyColor; - working?: boolean; - size?: number; -}) { - const id = useId(); - const [light, base] = COLORS[color]; - return ( - - ); -} diff --git a/openless-all/app/src/components/chat/ui/bubble.tsx b/openless-all/app/src/components/chat/ui/bubble.tsx deleted file mode 100644 index 56cfdad39..000000000 --- a/openless-all/app/src/components/chat/ui/bubble.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import * as React from 'react'; -import { mergeProps } from '@base-ui/react/merge-props'; -import { useRender } from '@base-ui/react/use-render'; -import { cva, type VariantProps } from 'class-variance-authority'; - -import { cn } from '../lib/utils'; - -function BubbleGroup({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -const bubbleVariants = cva( - 'group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full', - { - variants: { - variant: { - default: - '*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80', - secondary: - '*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]', - muted: - '*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]', - tinted: - '*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]', - outline: - '*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30', - ghost: - 'border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50', - destructive: - '*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30', - }, - }, - defaultVariants: { - variant: 'default', - }, - }, -); - -function Bubble({ - variant = 'default', - align = 'start', - className, - ...props -}: React.ComponentProps<'div'> & - VariantProps & { - align?: 'start' | 'end'; - }) { - return ( -
- ); -} - -function BubbleContent({ className, render, ...props }: useRender.ComponentProps<'div'>) { - return useRender({ - defaultTagName: 'div', - props: mergeProps<'div'>( - { - className: cn( - 'w-fit max-w-full min-w-0 overflow-hidden rounded-3xl border border-transparent px-3 py-2.5 text-sm leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-3 [button,a]:focus-visible:ring-ring/30', - className, - ), - }, - props, - ), - render, - state: { - slot: 'bubble-content', - }, - }); -} - -const bubbleReactionsVariants = cva( - 'absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-full bg-muted px-1.5 py-0.5 text-sm ring-3 ring-card has-[button]:p-0', - { - variants: { - side: { - top: 'top-0 -translate-y-3/4', - bottom: 'bottom-0 translate-y-3/4', - }, - align: { - start: 'left-3', - end: 'right-3', - }, - }, - defaultVariants: { - side: 'bottom', - align: 'end', - }, - }, -); - -function BubbleReactions({ - side = 'bottom', - align = 'end', - className, - ...props -}: React.ComponentProps<'div'> & { - align?: 'start' | 'end'; - side?: 'top' | 'bottom'; -}) { - return ( -
- ); -} - -export { BubbleGroup, Bubble, BubbleContent, BubbleReactions }; diff --git a/openless-all/app/src/components/chat/ui/card.tsx b/openless-all/app/src/components/chat/ui/card.tsx deleted file mode 100644 index 1ed494dad..000000000 --- a/openless-all/app/src/components/chat/ui/card.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import * as React from 'react'; - -import { cn } from '../lib/utils'; - -function Card({ - className, - size = 'default', - ...props -}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) { - return ( -
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(4)] dark:ring-foreground/10 *:[img:first-child]:rounded-t-[min(var(--radius-4xl),24px)] *:[img:last-child]:rounded-b-[min(var(--radius-4xl),24px)]', - className, - )} - {...props} - /> - ); -} - -function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -function CardAction({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -function CardContent({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; diff --git a/openless-all/app/src/components/chat/ui/empty.tsx b/openless-all/app/src/components/chat/ui/empty.tsx deleted file mode 100644 index c22acc3ed..000000000 --- a/openless-all/app/src/components/chat/ui/empty.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import * as React from 'react'; -import { cva, type VariantProps } from 'class-variance-authority'; - -import { cn } from '../lib/utils'; - -function Empty({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -function EmptyHeader({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -const emptyMediaVariants = cva( - 'mb-2 flex shrink-0 items-center justify-center [&_svg]:pointer-events-none [&_svg]:shrink-0', - { - variants: { - variant: { - default: 'bg-transparent', - icon: "flex size-10 shrink-0 items-center justify-center rounded-xl bg-muted text-foreground [&_svg:not([class*='size-'])]:size-5", - }, - }, - defaultVariants: { - variant: 'default', - }, - }, -); - -function EmptyMedia({ - className, - variant = 'default', - ...props -}: React.ComponentProps<'div'> & VariantProps) { - return ( -
- ); -} - -function EmptyTitle({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -function EmptyDescription({ className, ...props }: React.ComponentProps<'p'>) { - return ( -
a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary', - className, - )} - {...props} - /> - ); -} - -function EmptyContent({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
- ); -} - -export { Empty, EmptyHeader, EmptyTitle, EmptyDescription, EmptyContent, EmptyMedia }; diff --git a/openless-all/app/src/components/chat/ui/input-group.tsx b/openless-all/app/src/components/chat/ui/input-group.tsx deleted file mode 100644 index 4937af138..000000000 --- a/openless-all/app/src/components/chat/ui/input-group.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import * as React from 'react'; -import { cva, type VariantProps } from 'class-variance-authority'; - -import { cn } from '../lib/utils'; -import { Button } from './button'; -import { Input } from './input'; -import { Textarea } from './textarea'; - -function InputGroup({ className, ...props }: React.ComponentProps<'div'>) { - return ( -
[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5', - className, - )} - {...props} - /> - ); -} - -const inputGroupAddonVariants = cva( - "flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium text-muted-foreground select-none group-data-[disabled=true]/input-group:opacity-50 **:data-[slot=kbd]:rounded-2xl **:data-[slot=kbd]:bg-muted-foreground/10 **:data-[slot=kbd]:px-1.5 [&>svg:not([class*='size-'])]:size-4", - { - variants: { - align: { - 'inline-start': 'order-first pl-2 has-[>button]:ml-[-0.3rem] has-[>kbd]:ml-[-0.15rem]', - 'inline-end': 'order-last pr-2 has-[>button]:mr-[-0.3rem] has-[>kbd]:mr-[-0.15rem]', - 'block-start': - 'order-first w-full justify-start px-2.5 pt-2 group-has-[>input]/input-group:pt-2 [.border-b]:pb-2', - 'block-end': - 'order-last w-full justify-start px-2.5 pb-2 group-has-[>input]/input-group:pb-2 [.border-t]:pt-2', - }, - }, - defaultVariants: { - align: 'inline-start', - }, - }, -); - -function InputGroupAddon({ - className, - align = 'inline-start', - ...props -}: React.ComponentProps<'div'> & VariantProps) { - return ( -
{ - if ((e.target as HTMLElement).closest('button')) { - return; - } - e.currentTarget.parentElement?.querySelector('input')?.focus(); - }} - {...props} - /> - ); -} - -const inputGroupButtonVariants = cva('flex items-center gap-2 rounded-2xl text-sm shadow-none', { - variants: { - size: { - xs: "h-6 gap-1 rounded-xl px-1.5 [&>svg:not([class*='size-'])]:size-3.5", - sm: '', - 'icon-xs': 'size-6 rounded-xl p-0 has-[>svg]:p-0', - 'icon-sm': 'size-8 p-0 has-[>svg]:p-0', - }, - }, - defaultVariants: { - size: 'xs', - }, -}); - -function InputGroupButton({ - className, - type = 'button', - variant = 'ghost', - size = 'xs', - ...props -}: Omit, 'size' | 'type'> & - VariantProps & { - type?: 'button' | 'submit' | 'reset'; - }) { - return ( -