diff --git a/.github/actions/cache-macos-mlx/action.yml b/.github/actions/cache-macos-mlx/action.yml new file mode 100644 index 000000000..3fcc8fc52 --- /dev/null +++ b/.github/actions/cache-macos-mlx/action.yml @@ -0,0 +1,45 @@ +name: Cache macOS MLX native build +description: Preserve vendored MLX CMake output before rust-cache removes in-tree path dependencies. +inputs: + profile: + description: Cargo output profile directory (debug or release) + required: true +runs: + using: composite + steps: + - name: Fingerprint MLX native toolchain + id: native + shell: bash + working-directory: openless-all/app + env: + OPENLESS_MLX_PROFILE: ${{ inputs.profile }} + run: | + set -euo pipefail + case "$OPENLESS_MLX_PROFILE" in + debug|release) ;; + *) echo "::error::Unsupported MLX cache profile"; exit 1 ;; + esac + fingerprint=$({ + rustc -vV + xcrun clang --version + # Downloadable Metal toolchains can change independently of Clang. + # Their InstalledDir may contain a per-boot mount identifier. + xcrun --sdk macosx metal --version | sed '/^InstalledDir:/d' + xcrun --sdk macosx --show-sdk-version + cmake --version + git -C src-tauri/vendor/qwen3-asr-rs rev-parse HEAD + git -C src-tauri/vendor/qwen3-asr-rs/mlx-c rev-parse HEAD + for name in CARGO_PROFILE_DEV_DEBUG CARGO_PROFILE_TEST_DEBUG CARGO_PROFILE_RELEASE_CODEGEN_UNITS CARGO_PROFILE_RELEASE_STRIP RUSTFLAGS CARGO_ENCODED_RUSTFLAGS CC CXX CFLAGS CXXFLAGS SDKROOT DEVELOPER_DIR MACOSX_DEPLOYMENT_TARGET CMAKE_GENERATOR CMAKE_TOOLCHAIN_FILE; do + printf '%s=%s\n' "$name" "${!name-}" + done + } | shasum -a 256 | awk '{print $1}') + echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT" + + # Call this action AFTER rust-cache: post actions run in reverse order, + # so MLX is saved before rust-cache prunes src-tauri/vendor path packages. + # Cache only CMake output, not Cargo fingerprints: Cargo still reruns the + # build script and CMake validates its native inputs on every clean checkout. + - uses: actions/cache@v4 + with: + path: openless-all/app/src-tauri/target/${{ inputs.profile }}/build/qwen3-asr-rs-*/out + key: macos-mlx-v1-${{ runner.arch }}-${{ inputs.profile }}-${{ steps.native.outputs.fingerprint }}-${{ hashFiles('openless-all/app/src-tauri/Cargo.toml', 'openless-all/app/src-tauri/Cargo.lock', 'openless-all/app/src-tauri/tauri*.json', '.cargo/config.toml', '.github/actions/cache-macos-mlx/action.yml') }} diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 007564c86..014e4c3f5 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -5,13 +5,27 @@ name: Android APK (debug) # - workflow_dispatch → signed release APK when ANDROID_KEYSTORE_* secrets exist # (overlay install + user data preserved); otherwise unsigned debug APK (annotated, non-blocking) # -# Scope: full overlay/accessibility APK for ADB testing and tag releases. +# #1103 build-time changes: +# - Do not wipe Cargo/Gradle/target before cache post-save (removed Free disk step). +# - Bump rust-cache prefix-key so old half-cold caches are not reused. +# - Dispatch defaults to aarch64 only; tag builds all ABIs in a parallel matrix. +# - Optional dispatch fast_profile (disable LTO / raise codegen-units); tag never uses it. +# - Tauri still runs plugin-init cargo + android-studio-script per ABI (first ABI twice). on: push: tags: - 'v*-tauri' workflow_dispatch: + inputs: + abis: + description: 'ABIs to build (comma-separated: aarch64,armv7,i686,x86_64) or all' + required: false + default: 'aarch64' + fast_profile: + description: 'Dispatch-only fast release profile (no LTO, codegen-units=16). Ignored on tags.' + type: boolean + default: false # 同一 tag 重复推送只跑最新一次;workflow_dispatch 用 run_id 隔离避免互相取消。 concurrency: @@ -19,25 +33,27 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'push' }} jobs: - build-android-apk: + plan: + name: Plan Android ABI matrix permissions: - contents: write + contents: read runs-on: ubuntu-latest - env: - CI: true - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - OPENLESS_RELEASE_CHANNEL: ${{ (endsWith(github.ref_name, '-beta-tauri') || contains(github.ref_name, '-Beta.')) && 'beta' || 'stable' }} + outputs: + is_tag_release: ${{ steps.mode.outputs.is_tag_release }} + mode: ${{ steps.mode.outputs.mode }} + label: ${{ steps.mode.outputs.label }} + signing: ${{ steps.mode.outputs.signing }} + signing_label: ${{ steps.mode.outputs.signing_label }} + matrix: ${{ steps.abis.outputs.matrix }} + abi_list: ${{ steps.abis.outputs.abi_list }} + gradle_abi_list: ${{ steps.abis.outputs.gradle_abi_list }} + fast_profile: ${{ steps.abis.outputs.fast_profile }} + release_channel: ${{ steps.mode.outputs.release_channel }} steps: - uses: actions/checkout@v4 with: - # Android 不使用本地 Qwen3/Whisper C 子模块。 submodules: false - - name: Disable macOS-only Qwen3 MLX dependency - run: node openless-all/app/scripts/ci-disable-macos-qwen3.mjs - - - name: Detect build mode id: mode shell: bash @@ -54,6 +70,12 @@ jobs: fi echo "is_tag_release=$is_tag" >> "$GITHUB_OUTPUT" + channel=stable + if [[ "${{ github.ref_name }}" == *-beta-tauri ]] || [[ "${{ github.ref_name }}" == *-Beta.* ]]; then + channel=beta + fi + echo "release_channel=$channel" >> "$GITHUB_OUTPUT" + has_keystore=true for name in ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do if [ -z "${!name:-}" ]; then @@ -80,8 +102,58 @@ jobs: echo "::notice title=Unsigned debug APK::ANDROID_KEYSTORE_* secrets not configured. Building debug APK without release signing. Overlay install and user data preservation require the release keystore; uninstall before installing if replacing a signed build." fi + - name: Resolve ABI matrix + id: abis + shell: bash + env: + INPUT_ABIS: ${{ github.event.inputs.abis }} + INPUT_FAST_PROFILE: ${{ github.event.inputs.fast_profile }} + run: | + set -euo pipefail + if [ "${{ steps.mode.outputs.is_tag_release }}" = "true" ]; then + raw=all + fast=false + else + raw="${INPUT_ABIS:-aarch64}" + fast="${INPUT_FAST_PROFILE:-false}" + fi + matrix="$(node openless-all/app/scripts/android-abi-matrix.mjs parse "$raw")" + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + abi_list="$(node -e 'const m=JSON.parse(process.argv[1]); process.stdout.write(m.map(x=>x.abi).join(","))' "$matrix")" + gradle_list="$(node -e 'const m=JSON.parse(process.argv[1]); process.stdout.write(m.map(x=>x.gradle_abi).join(","))' "$matrix")" + echo "abi_list=$abi_list" >> "$GITHUB_OUTPUT" + echo "gradle_abi_list=$gradle_list" >> "$GITHUB_OUTPUT" + echo "fast_profile=$fast" >> "$GITHUB_OUTPUT" + echo "Resolved ABIs: $abi_list (fast_profile=$fast)" + + build-android-apk: + name: Build Android (${{ matrix.abi }}) + needs: plan + permissions: + contents: write + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: ${{ fromJson(needs.plan.outputs.matrix) }} + env: + CI: true + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + OPENLESS_RELEASE_CHANNEL: ${{ needs.plan.outputs.release_channel }} + OPENLESS_ANDROID_TARGETS: ${{ matrix.abi }} + CARGO_TERM_COLOR: always + steps: + - uses: actions/checkout@v4 + with: + # Android 不使用本地 Qwen3/Whisper C 子模块。 + submodules: false + + - name: Disable macOS-only Qwen3 MLX dependency + run: node openless-all/app/scripts/ci-disable-macos-qwen3.mjs + - name: Check updater signing availability (tag release) - if: steps.mode.outputs.is_tag_release == 'true' + if: needs.plan.outputs.is_tag_release == 'true' shell: bash env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -92,7 +164,7 @@ jobs: fi - name: Check Android keystore secrets (tag release) - if: steps.mode.outputs.is_tag_release == 'true' + if: needs.plan.outputs.is_tag_release == 'true' shell: bash env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} @@ -112,6 +184,16 @@ jobs: exit 1 fi + - name: Apply dispatch fast profile + if: needs.plan.outputs.fast_profile == 'true' && needs.plan.outputs.is_tag_release != 'true' + shell: bash + run: | + set -euo pipefail + # Override [profile.release] for this job only; tag builds never take this path. + echo "CARGO_PROFILE_RELEASE_LTO=false" >> "$GITHUB_ENV" + echo "CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16" >> "$GITHUB_ENV" + echo "::notice title=Fast profile::Dispatch fast_profile enabled (LTO off, codegen-units=16)" + - name: Setup Java 17 (Zulu) uses: actions/setup-java@v4 with: @@ -152,26 +234,28 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: - targets: >- - aarch64-linux-android, - armv7-linux-androideabi, - i686-linux-android, - x86_64-linux-android + targets: ${{ matrix.rust_target }} - name: Cache Cargo + id: rust-cache uses: swatinem/rust-cache@v2 with: + # #1103: bump prefix so previously truncated / half-cold caches are not reused. + prefix-key: v1-rust-android-1103 + shared-key: android-${{ matrix.abi }} workspaces: openless-all/app/src-tauri -> target + cache-on-failure: true - uses: gradle/actions/setup-gradle@v4 + with: + # Keep Gradle caches for post-job save; do not wipe ~/.gradle before post. + cache-read-only: false - name: Install npm deps working-directory: openless-all/app run: npm ci - - name: Build frontend - working-directory: openless-all/app - run: npm run build + # Frontend is built by tauri beforeBuildCommand; skip a duplicate standalone build (#1103). - name: Initialize Android project working-directory: openless-all/app @@ -202,7 +286,7 @@ jobs: run: node scripts/merge-android-updater-manifest.mjs - name: Configure Android release signing - if: steps.mode.outputs.mode == 'release' + if: needs.plan.outputs.mode == 'release' working-directory: openless-all/app env: ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }} @@ -227,127 +311,167 @@ jobs: exit 1 - name: Build Android debug APK - if: steps.mode.outputs.mode == 'debug' + if: needs.plan.outputs.mode == 'debug' working-directory: openless-all/app run: npm run tauri:android:build:debug - name: Build Android release APK - if: steps.mode.outputs.mode == 'release' + if: needs.plan.outputs.mode == 'release' working-directory: openless-all/app + env: + # Resolve signing credentials at execution time; compiled Gradle scripts + # are cached and must never contain secret literals. + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} + ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }} + ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: npm run tauri:android:build:release - - name: Free disk before artifact upload + - name: Record cache / duplicate-compile notes + if: always() shell: bash - working-directory: openless-all/app run: | set -euo pipefail - rm -rf src-tauri/target - rm -rf ~/.cargo/registry ~/.cargo/git ~/.gradle/caches - df -h - + { + echo "### Cache / compile notes (\`${{ matrix.abi }}\`)" + echo + echo "- Rust cache hit: \`${{ steps.rust-cache.outputs.cache-hit }}\`" + echo "- Tauri runs plugin-init cargo for the first ABI, then \`android-studio-script\` per ABI (first ABI may compile twice)." + echo "- Do not wipe \`target\` / Cargo registry / Gradle caches before action post-save (#1103)." + } >> "$GITHUB_STEP_SUMMARY" - name: Collect split APKs id: apk shell: bash working-directory: openless-all/app env: - OPENLESS_APK_MODE: ${{ steps.mode.outputs.mode }} - OPENLESS_APK_LABEL: ${{ steps.mode.outputs.label }} + OPENLESS_APK_MODE: ${{ needs.plan.outputs.mode }} + OPENLESS_APK_LABEL: ${{ needs.plan.outputs.label }} + OPENLESS_EXPECTED_GRADLE_ABIS: ${{ matrix.gradle_abi }} + run: node scripts/collect-android-split-apks.mjs + + - name: Upload Android APK artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ needs.plan.outputs.mode == 'release' && format('openless-android-release-{0}', matrix.gradle_abi) || format('openless-android-debug-{0}', matrix.gradle_abi) }} + path: ${{ steps.apk.outputs.apk_path }} + if-no-files-found: error + + - name: Write build summary + if: always() + run: | + cat >> "$GITHUB_STEP_SUMMARY" << EOF + ### Android APK build (\`${{ matrix.abi }}\`) + + | Field | Value | + |-------|-------| + | Gradle mode | \`${{ needs.plan.outputs.mode }}\` | + | Signing | ${{ needs.plan.outputs.signing_label }} | + | Artifact label | \`${{ needs.plan.outputs.label }}\` | + | Fast profile | \`${{ needs.plan.outputs.fast_profile }}\` | + | Rust cache hit | \`${{ steps.rust-cache.outputs.cache-hit }}\` | + + EOF + if [ "${{ needs.plan.outputs.signing }}" = "debug-fallback" ]; then + cat >> "$GITHUB_STEP_SUMMARY" << 'EOF' + > **Unsigned debug APK.** Debug builds use a CI-only signature. Use `adb install -r` only when replacing the same debug build. To upgrade from a signed release without losing user data, configure `ANDROID_KEYSTORE_*` repo secrets and re-run this workflow. + EOF + elif [ "${{ needs.plan.outputs.signing }}" = "dispatch-release" ]; then + cat >> "$GITHUB_STEP_SUMMARY" << 'EOF' + > **Signed release APK** (manual dispatch). Same keystore as tag releases — supports overlay install and preserves user data when upgrading from an existing signed install. + EOF + fi + + publish-android-release: + name: Publish Android release assets + if: needs.plan.outputs.is_tag_release == 'true' + needs: [plan, build-android-apk] + permissions: + contents: write + runs-on: ubuntu-latest + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + OPENLESS_RELEASE_CHANNEL: ${{ needs.plan.outputs.release_channel }} + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: npm + cache-dependency-path: openless-all/app/package-lock.json + + - name: Install npm deps + working-directory: openless-all/app + run: npm ci + + - name: Download all ABI artifacts + uses: actions/download-artifact@v4 + with: + pattern: openless-android-release-* + path: ${{ runner.temp }}/android-release-apks + merge-multiple: true + + - name: Verify full ABI set and stage files + id: stage + shell: bash + working-directory: openless-all/app + env: + OPENLESS_STAGE_DIR: ${{ runner.temp }}/android-release-apks run: | set -euo pipefail - python - <<'PY' - import json - import os - import shutil - import sys - import zipfile - from pathlib import Path - - expected = { - "arm64-v8a": "arm64_v8a", - "armeabi-v7a": "armeabi_v7a", - "x86": "x86", - "x86_64": "x86_64", - } - arch_map = { - "arm64-v8a": "aarch64", - "armeabi-v7a": "armv7", - "x86": "i686", - "x86_64": "x86_64", - } - root = Path("src-tauri/gen/android") - mode = os.environ["OPENLESS_APK_MODE"] - label = os.environ["OPENLESS_APK_LABEL"] - version = json.loads(Path("package.json").read_text(encoding="utf-8"))["version"] - out_dir = Path(os.environ["RUNNER_TEMP"]) / f"openless-android-{mode}-split" - out_dir.mkdir(parents=True, exist_ok=True) - found = {} - candidates = [ - apk for apk in sorted(root.rglob("*.apk")) - if "outputs" in apk.parts - ] - if not candidates: - print("::error::No APK found under src-tauri/gen/android/**/outputs/") - for apk in sorted(root.rglob("*.apk")): - print(apk) - sys.exit(1) - for apk in candidates: - with zipfile.ZipFile(apk) as archive: - abis = sorted({ - name.split("/")[1] - for name in archive.namelist() - if name.startswith("lib/") and len(name.split("/")) >= 3 - }) - if len(abis) != 1: - print(f"::error::{apk} contains ABI directories {abis}; expected exactly one ABI per APK") - sys.exit(1) - abi = abis[0] - if abi not in expected: - print(f"::error::{apk} contains unexpected ABI {abi}") - sys.exit(1) - if abi in found: - print(f"::error::Duplicate APKs for ABI {abi}: {found[abi]} and {apk}") - sys.exit(1) - if mode == "release": - dest = out_dir / f"OpenLess_{version}_{abi}.apk" - else: - dest = out_dir / f"OpenLess-android-debug-{abi}-{label}.apk" - shutil.copy2(apk, dest) - found[abi] = dest - print(f"Collected {abi}: {apk} -> {dest}") - missing = sorted(set(expected) - set(found)) - if missing: - print(f"::error::Missing split APKs for ABI(s): {', '.join(missing)}") - sys.exit(1) - with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: - for abi, key in expected.items(): - output.write(f"{key}_path={found[abi]}\n") - output.write(f"{key}_arch={arch_map[abi]}\n") - output.write(f"out_dir={out_dir}\n") - output.write("release_files<> "$GITHUB_OUTPUT" + { + echo "release_files<> "$GITHUB_OUTPUT" - name: Sign release APKs (minisign) - if: steps.mode.outputs.is_tag_release == 'true' working-directory: openless-all/app env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: | set -euo pipefail + out_dir="${{ steps.stage.outputs.out_dir }}" + version="$(node -p "require('./package.json').version")" node scripts/sign-android-apks.mjs \ - "${{ steps.apk.outputs.arm64_v8a_path }}" \ - "${{ steps.apk.outputs.armeabi_v7a_path }}" \ - "${{ steps.apk.outputs.x86_path }}" \ - "${{ steps.apk.outputs.x86_64_path }}" + "$out_dir/OpenLess_${version}_arm64-v8a.apk" \ + "$out_dir/OpenLess_${version}_armeabi-v7a.apk" \ + "$out_dir/OpenLess_${version}_x86.apk" \ + "$out_dir/OpenLess_${version}_x86_64.apk" - name: Write Android updater manifests - if: steps.mode.outputs.is_tag_release == 'true' working-directory: openless-all/app env: - OPENLESS_UPDATE_APK_DIR: ${{ steps.apk.outputs.out_dir }} + OPENLESS_UPDATE_APK_DIR: ${{ steps.stage.outputs.out_dir }} OPENLESS_UPDATE_REPO: Open-Less/openless OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }} @@ -360,14 +484,13 @@ jobs: done - name: Append release files (manifests + signatures) - if: steps.mode.outputs.is_tag_release == 'true' id: release_assets shell: bash run: | set -euo pipefail - out_dir="${{ steps.apk.outputs.out_dir }}" + out_dir="${{ steps.stage.outputs.out_dir }}" { - echo "${{ steps.apk.outputs.release_files }}" + echo "${{ steps.stage.outputs.release_files }}" for f in "$out_dir"/*.apk.sig "$out_dir"/latest-android-*.json; do [ -e "$f" ] && echo "$f" done @@ -376,36 +499,7 @@ jobs: cat "$RUNNER_TEMP/android-release-files.txt" >> "$GITHUB_OUTPUT" echo "EOF" >> "$GITHUB_OUTPUT" - - name: Upload Android APK artifact (arm64-v8a) - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-arm64-v8a' || 'openless-android-debug-arm64-v8a' }} - path: ${{ steps.apk.outputs.arm64_v8a_path }} - if-no-files-found: error - - - name: Upload Android APK artifact (armeabi-v7a) - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-armeabi-v7a' || 'openless-android-debug-armeabi-v7a' }} - path: ${{ steps.apk.outputs.armeabi_v7a_path }} - if-no-files-found: error - - - name: Upload Android APK artifact (x86) - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-x86' || 'openless-android-debug-x86' }} - path: ${{ steps.apk.outputs.x86_path }} - if-no-files-found: error - - - name: Upload Android APK artifact (x86_64) - uses: actions/upload-artifact@v4 - with: - name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-x86_64' || 'openless-android-debug-x86_64' }} - path: ${{ steps.apk.outputs.x86_64_path }} - if-no-files-found: error - - name: Prepare Android release body - if: steps.mode.outputs.is_tag_release == 'true' shell: bash run: | cat > "$RUNNER_TEMP/android-release-body.md" << 'EOF' @@ -423,7 +517,6 @@ jobs: EOF - name: Attach Android assets to GitHub Release - if: steps.mode.outputs.is_tag_release == 'true' uses: softprops/action-gh-release@v2 with: tag_name: ${{ github.ref_name }} @@ -435,26 +528,3 @@ jobs: append_body: true body_path: ${{ runner.temp }}/android-release-body.md files: ${{ steps.release_assets.outputs.files }} - - - name: Write build summary - if: always() && steps.mode.outputs.signing != '' - run: | - cat >> "$GITHUB_STEP_SUMMARY" << EOF - ### Android APK build - - | Field | Value | - |-------|-------| - | Gradle mode | \`${{ steps.mode.outputs.mode }}\` | - | Signing | ${{ steps.mode.outputs.signing_label }} | - | Artifact label | \`${{ steps.mode.outputs.label }}\` | - - EOF - if [ "${{ steps.mode.outputs.signing }}" = "debug-fallback" ]; then - cat >> "$GITHUB_STEP_SUMMARY" << 'EOF' - > **Unsigned debug APK.** Debug builds use a CI-only signature. Use `adb install -r` only when replacing the same debug build. To upgrade from a signed release without losing user data, configure `ANDROID_KEYSTORE_*` repo secrets and re-run this workflow. - EOF - elif [ "${{ steps.mode.outputs.signing }}" = "dispatch-release" ]; then - cat >> "$GITHUB_STEP_SUMMARY" << 'EOF' - > **Signed release APK** (manual dispatch). Same keystore as tag releases — supports overlay install and preserves user data when upgrading from an existing signed install. - EOF - fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b6515e726..2c2f191b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,12 @@ on: pull_request: branches: [main, beta] workflow_dispatch: + inputs: + platform: + description: Platforms to check (macos runs only macOS gates) + type: choice + options: [all, macos] + default: all # 同一 PR 快速重复推送时取消旧运行;workflow_dispatch 用 run_id 隔离。 concurrency: @@ -20,6 +26,7 @@ concurrency: jobs: android-check: + if: github.event_name != 'workflow_dispatch' || inputs.platform != 'macos' name: Android cargo check runs-on: ubuntu-latest defaults: @@ -166,6 +173,7 @@ jobs: --no-daemon linux-core-contract: + if: github.event_name != 'workflow_dispatch' || inputs.platform != 'macos' name: Linux core tests runs-on: ubuntu-22.04 defaults: @@ -194,13 +202,7 @@ jobs: # 一个平台挂掉不阻塞其他平台拿到验证结果。 fail-fast: false matrix: - include: - - os: macos-latest - label: macOS - preflight: false - - os: windows-latest - label: Windows - preflight: true + include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"os":"macos-latest","label":"macOS","preflight":false}]' || '[{"os":"macos-latest","label":"macOS","preflight":false},{"os":"windows-latest","label":"Windows","preflight":true}]') }} runs-on: ${{ matrix.os }} env: # 新增 shared Core 后,macOS 首次编译同时构建 MLX C++ 依赖和完整 @@ -226,15 +228,36 @@ jobs: cache: npm cache-dependency-path: openless-all/app/package-lock.json - # 现有测试继续用 stable;额外安装声明的 MSRV,供下方兼容性门禁显式调用。 + # macOS MSRV 在独立 job 并行检查;Windows 保留原有检查顺序。 - uses: dtolnay/rust-toolchain@1.88.0 + if: runner.os == 'Windows' - uses: dtolnay/rust-toolchain@stable + - name: Configure macOS check profile + if: runner.os == 'macOS' + run: echo "CARGO_PROFILE_DEV_DEBUG=0" >> "$GITHUB_ENV" + - uses: swatinem/rust-cache@v2 + if: runner.os == 'Windows' with: workspaces: 'openless-all/app/src-tauri -> target' + - name: Cache macOS stable dependencies + if: runner.os == 'macOS' + uses: swatinem/rust-cache@v2 + with: + key: macos-stable-v1 + workspaces: | + openless-all/app -> target + openless-all/app/src-tauri -> target + + - name: Cache macOS MLX native build + if: runner.os == 'macOS' && runner.arch == 'ARM64' + uses: ./.github/actions/cache-macos-mlx + with: + profile: debug + - name: Prepare Windows Sherpa static libraries if: runner.os == 'Windows' shell: pwsh @@ -284,11 +307,14 @@ jobs: run: cargo test --locked -p openless-core hardening_actually_narrows_the_writable_roots -- --ignored --nocapture --test-threads=1 - name: Check Tauri backend (cargo check) + if: runner.os == 'Windows' run: cargo check --locked --manifest-path src-tauri/Cargo.toml + # test 编译并运行 lib/bin,覆盖原 cargo check 的生产 binary 路径。 + # 避免先 metadata-only check、再为同一依赖图做一次 codegen。 - name: Run Rust backend unit tests if: runner.os != 'Windows' - run: cargo test --locked --manifest-path src-tauri/Cargo.toml --lib + run: cargo test --locked --manifest-path src-tauri/Cargo.toml --lib --bins --timings - name: Compile Rust backend unit tests (Windows) # Windows runner 能链接 lib test binary,但干净镜像缺少可选 native runtime @@ -304,9 +330,11 @@ jobs: run: cargo test --locked --manifest-path src-tauri/backend-tests/Cargo.toml - name: Check Tauri backend with Rust 1.88 MSRV + if: runner.os == 'Windows' run: cargo +1.88.0 check --locked --manifest-path src-tauri/Cargo.toml - name: Compile backend tests with Rust 1.88 MSRV + if: runner.os == 'Windows' run: cargo +1.88.0 test --locked --manifest-path src-tauri/backend-tests/Cargo.toml --no-run - name: Verify version sync across all 5 files @@ -338,3 +366,42 @@ jobs: exit 1 fi echo "[ok] 全部 5 处版本号一致:$PKG" + + macos-msrv: + name: macOS Rust 1.88 MSRV + runs-on: macos-latest + env: + CARGO_BUILD_JOBS: 2 + CARGO_PROFILE_DEV_DEBUG: 0 + CARGO_PROFILE_TEST_DEBUG: 0 + CMAKE_BUILD_PARALLEL_LEVEL: 2 + defaults: + run: + working-directory: openless-all/app + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - 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 + - uses: swatinem/rust-cache@v2 + with: + key: macos-msrv-v1 + workspaces: | + openless-all/app/src-tauri -> target + openless-all/app/src-tauri/backend-tests -> target + - name: Cache macOS MLX native build + if: runner.arch == 'ARM64' + uses: ./.github/actions/cache-macos-mlx + with: + profile: debug + - 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 diff --git a/.github/workflows/release-tauri.yml b/.github/workflows/release-tauri.yml index 4f2f3a627..68b66be60 100644 --- a/.github/workflows/release-tauri.yml +++ b/.github/workflows/release-tauri.yml @@ -21,6 +21,12 @@ on: tags: - 'v*-tauri' workflow_dispatch: + inputs: + platform: + description: Desktop platforms to build (manual builds do not publish a release) + type: choice + options: [all, macos] + default: all # 同一 tag 重复推送只跑最新一次;workflow_dispatch 用 run_id 隔离避免互相取消。 concurrency: @@ -34,19 +40,7 @@ jobs: strategy: fail-fast: false matrix: - include: - - platform: macos-latest - rust-target: aarch64-apple-darwin - updater-target: darwin - updater-arch: aarch64 - - platform: macos-15-intel - rust-target: x86_64-apple-darwin - updater-target: darwin - updater-arch: x86_64 - - platform: windows-latest - rust-target: x86_64-pc-windows-msvc - updater-target: windows - updater-arch: x86_64 + include: ${{ fromJSON(github.event_name == 'workflow_dispatch' && inputs.platform == 'macos' && '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"}]' || '[{"platform":"macos-latest","rust-target":"aarch64-apple-darwin","updater-target":"darwin","updater-arch":"aarch64"},{"platform":"macos-15-intel","rust-target":"x86_64-apple-darwin","updater-target":"darwin","updater-arch":"x86_64"},{"platform":"windows-latest","rust-target":"x86_64-pc-windows-msvc","updater-target":"windows","updater-arch":"x86_64"}]') }} runs-on: ${{ matrix.platform }} env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} @@ -79,11 +73,31 @@ jobs: with: targets: ${{ matrix.rust-target }} + # 在恢复缓存前设置实际编译环境,让 cache key 包含 macOS profile。 + - name: Configure macOS build environment + if: startsWith(matrix.platform, 'macos') + working-directory: openless-all/app + run: bash scripts/macos-build-env.sh + - name: Cache Cargo + if: matrix.platform == 'windows-latest' uses: swatinem/rust-cache@v2 with: workspaces: 'openless-all/app/src-tauri -> target' + - name: Cache macOS release dependencies + if: startsWith(matrix.platform, 'macos') + uses: swatinem/rust-cache@v2 + with: + key: macos-release-v1 + workspaces: 'openless-all/app/src-tauri -> target' + + - name: Cache macOS MLX native build + if: matrix.updater-arch == 'aarch64' + uses: ./.github/actions/cache-macos-mlx + with: + profile: release + - name: Prepare Windows Sherpa static libraries if: matrix.platform == 'windows-latest' working-directory: 'openless-all/app' @@ -192,6 +206,14 @@ jobs: TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} run: bash scripts/build-mac.sh + - name: Upload macOS Cargo timings + if: always() && startsWith(matrix.platform, 'macos') + uses: actions/upload-artifact@v4 + with: + name: macos-cargo-timings-${{ matrix.updater-arch }} + path: openless-all/app/src-tauri/target/cargo-timings/*.html + if-no-files-found: ignore + # ── Windows:先 build OpenLessIme.dll(x64+x86),再跑 tauri bundle。 # openless-ime.wxs 用 $(env.OPENLESS_IME_DLL_X64) / _X86 拿绝对路径, # 跨 candle/light cwd 都能 resolve(Tauri wix bundler cwd 不固定)。 @@ -427,6 +449,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: openless-macos-${{ matrix.updater-arch }} + compression-level: 0 path: | openless-all/app/src-tauri/target/release/bundle/dmg/*.dmg if-no-files-found: error @@ -436,6 +459,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: openless-macos-${{ matrix.updater-arch }}-updater + compression-level: 0 path: | openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz openless-all/app/src-tauri/target/release/bundle/macos/*.app.tar.gz.sig diff --git a/README.md b/README.md index 2666efcda..74f5f506b 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ Every item below is one more layer sedimented into a default — a capability yo - **Toggle and push-to-talk** recording modes, plus a **MediaPlayPause trigger** so wired-earbud inline controls can start and stop recording. `Esc` cancels at any phase, including polish and insertion. - **Cloud ASR**: Volcengine streaming ASR (bigasr), Tencent Cloud Hunyuan realtime ASR (Hy-ASR), iFlytek realtime ASR (RTASR), Alibaba Cloud Bailian (classic realtime / Qwen3 realtime / Fun-ASR-Flash file transcription), StepFun StepAudio (batch + realtime), Zhipu GLM-ASR, Xiaomi MiMo ASR, OrcaRouter audio-input Gemini, ElevenLabs Scribe, OpenAI-compatible batch transcription (OpenAI Whisper / Groq / SiliconFlow SenseVoice / OpenRouter / ZenMux), and Apple Speech (macOS). - **Local ASR**: bundled Qwen3-ASR (0.6B / 1.7B) via vendored `Open-Less/qwen-asr` (macOS); Windows Foundry Local Whisper and sherpa-onnx (experimental) variants. -- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, Tencent Cloud TokenHub, CometAPI, OpenRouter, OrcaRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, StepFun, and OpenCode Zen — plus any OpenAI-compatible endpoint you bring. +- **Polish providers**: Ark (Volcengine), DeepSeek, OpenAI, Google Gemini, Codex OAuth, SiliconFlow, Atlas Cloud, Xiaomi MiMo, Tencent Cloud TokenHub, CometAPI, OpenRouter, Requesty, OrcaRouter, Alibaba Cloud Coding Plan, CodingPlanX, MiniMax, StepFun, and OpenCode Zen — plus any OpenAI-compatible endpoint you bring. - **Four output modes**: raw, light polish, structured (**AI-prompt mode**), and formal. Plus a **translation hotkey** that converts speech directly into the configured target language ([#43](../../issues/43)). - **Selection-ask QA panel** — a separate hotkey opens a floating panel that runs voice Q&A against the highlighted text in any app ([#118](../../issues/118)). - **Main window**: Overview / History / Vocab / Style / Marketplace / Settings. Persistent tray icon, plus a mini status capsule that floats on screen and follows the display you are typing on (multi-monitor). diff --git a/README.zh.md b/README.zh.md index c39339184..2e06ad3e4 100644 --- a/README.zh.md +++ b/README.zh.md @@ -212,7 +212,7 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 - **切换式与按住说话(push-to-talk)** 两种录音模式,外加 **MediaPlayPause 触发**,让有线耳机的线控也能开始 / 停止录音。`Esc` 可在任意阶段取消,包括润色与插入。 - **云端 ASR**:Volcengine 流式 ASR(bigasr)、腾讯云混元实时 ASR(Hy-ASR)、讯飞实时语音转写(RTASR)、阿里云百炼(经典实时 / Qwen3 实时 / Fun-ASR-Flash 录音文件)、阶跃星辰 StepAudio(批式 + 实时)、智谱 GLM-ASR、小米 MiMo ASR、OrcaRouter 音频输入 Gemini、ElevenLabs Scribe、OpenAI 兼容批量转写(OpenAI Whisper / Groq / 硅基流动 SenseVoice / OpenRouter / ZenMux),以及 Apple Speech(macOS)。 - **本地 ASR**:通过 vendored 的 `Open-Less/qwen-asr` 内置 Qwen3-ASR(0.6B / 1.7B)(macOS);Windows 上的 Foundry Local Whisper 与 sherpa-onnx(实验性)变体。 -- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、腾讯云 TokenHub、CometAPI、OpenRouter、OrcaRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 +- **润色提供方**:Ark(火山方舟)、DeepSeek、OpenAI、Google Gemini、Codex OAuth、硅基流动、Atlas Cloud、小米 MiMo、腾讯云 TokenHub、CometAPI、OpenRouter、Requesty、OrcaRouter、阿里云 Coding Plan、CodingPlanX、MiniMax、StepFun、OpenCode Zen,以及你自带的任意 OpenAI 兼容端点。 - **四种输出模式**:原文、轻度润色、结构化(**AI 提示词模式**)、正式。另有一个**翻译快捷键**,将语音直接转换为所配置的目标语言([#43](../../issues/43))。 - **选区问答面板**——一个独立快捷键打开浮动面板,针对任意应用中被高亮选中的文本进行语音问答([#118](../../issues/118))。 - **主窗口**:概览 / 历史 / 词典 / 风格 / 市场 / 设置。常驻托盘图标,以及一个浮于屏幕、并跟随你正在输入的显示器的迷你状态胶囊(多显示器)。 diff --git a/docs/android-build-time-research-1103.md b/docs/android-build-time-research-1103.md new file mode 100644 index 000000000..7ba32ae67 --- /dev/null +++ b/docs/android-build-time-research-1103.md @@ -0,0 +1,46 @@ +# Android APK 编译耗时调研(#1103) + +调研日期:2026-09-25。基线:上游 beta `d9113e0b03c0b5998079d5f43dcb33fb8bba50e7`。原始调研与后续实现记录;Beta 3 整合时采用 ABI 并行与缓存回写方案。 + +## 实测证据 + +- [tag run 35989822568](https://github.com/Open-Less/openless/actions/runs/35989822568):job 32 分 32 秒;Build Android release APK 30 分 31 秒。 +- Cargo 完成记录为 6m01s、2m14s、5m11s、5m27s、5m37s。前两轮都为 aarch64,第二轮重新编译应用及部分 Tauri crates。重复编译事实已确认,失效原因尚未确认。 +- [同 SHA 的 beta dispatch 35985550453](https://github.com/Open-Less/openless/actions/runs/35985550453)也有五轮编译;arm64 第二轮 2m15s。 +- tag Gradle 日志明确 cache-read-only=true;仓库默认分支为 beta,beta dispatch 为 false。不能用 post 为零秒单独证明清盘阻止保存。 +- beta dispatch 在清盘后仍保存约 171 MB Rust cache,随后 tag 命中同一缓存。这不证明 target 编译产物被保留:workflow 明确提前删除 target、Cargo registry/git 和 Gradle caches。 +- 独立前端构建约 15 秒;Tauri beforeBuildCommand 随后再次执行 npm run build。 + +## 建议顺序 + +1. 修复清盘与缓存生命周期,并更换缓存版本键,避免继续命中已有残缺缓存;验证连续两次运行的实际缓存内容及编译耗时。post action 在普通 steps 之后执行,仅把清盘移动到普通 steps 末尾无效。 +2. 日常 dispatch 可选 ABI,默认 arm64;正式 tag 保留全 ABI。进一步使用 ABI matrix 降低全量墙钟,分别衡量总 runner 分钟和排队时间;发布资产集中汇总,校验 ABI 完整性。 +3. 记录 Cargo timings/fingerprint 和两轮 arm64 调用参数、环境、生成文件差异,定位重复编译。不要直接绕过 Tauri/Gradle native 构建。 +4. 当前 release 使用 opt-level=3、thin LTO、codegen-units=1。试验仅 dispatch 的快速 profile:关闭 LTO、提高 codegen-units,同时保持签名;单独比较 APK 大小、运行表现和耗时。正式 tag 优化配置暂不改变。 +5. ci-disable-macos-qwen3.mjs 每次删除平台依赖后 cargo generate-lockfile。应研究保留已锁定版本的确定性处理,避免无关依赖升级及缓存键变化;stable Rust 也应考虑固定版本并有计划升级。 +6. Cargo timings 若表明依赖或主 crate 占比高,再审计 Android 不使用的依赖/features、缩小重编译边界。现有 openless-core 可作为评估起点,不能未经测量就大改架构。 +7. 次要优化:删除重复前端构建,验证 Gradle task-output cache,更强的 x64 runner、预置工具链环境,以及 artifact 压缩参数。现有四份上传合计约 14 秒,优先级低。 + +## 限制与官方资料 + +- [Cargo profiles](https://doc.rust-lang.org/cargo/reference/profiles.html):LTO/codegen-units 是构建速度与产物表现之间的取舍,不能承诺未经实测的收益。 +- [rust-cache](https://github.com/Swatinem/rust-cache):默认排除 workspace crates、清理 incremental;缓存命中不等于应用无需重编译。 +- [sccache Rust](https://github.com/mozilla/sccache/blob/main/docs/Rust.md):不能缓存涉及系统链接的 cdylib 等输出,因此不是 Tauri 主库的万能缓存;可单独评估依赖缓存收益。 +- [Gradle Actions v4](https://github.com/gradle/actions/blob/v4/docs/setup-gradle.md)与[GitHub cache scope](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching):默认分支写缓存与 ref 可见性应一并设计。 +- [Gradle build cache](https://docs.gradle.org/current/userguide/build_cache.html):任务输出缓存和依赖缓存不同;仅缓存输入输出定义完整的任务。 +- [Android NDK host](https://developer.android.com/ndk/guides/other_build_systems):Linux 官方 NDK 使用 x86_64 host 工具链,不能因为目标 APK 是 arm64 就直接换 Linux arm64 runner。 +- [Tauri CLI](https://v2.tauri.app/reference/cli/):Android build 会执行 beforeBuildCommand。 + +下述基准是优化前的测量;实际加速效果须以整合后上游仓库的完整运行统计为准。基准应覆盖冷缓存、相同依赖下的源码改动、依赖变更,以及正式全 ABI 构建;同时检查签名、APK ABI 和 updater manifest 完整性。 + +## 实现备注(#1103 跟进) + +重复 aarch64 根因已定位:Tauri CLI `android build` 在 `apk::build` 之前会对**第一个** target 调用 `first_target.build(...)`(注释为 initialize plugins),随后 Gradle 再对每个 ABI 执行 `tauri android android-studio-script`。因此首个 ABI 必然两次 cargo;第二轮约 2m 是因为 `write_options` / `inject_resources` 发生在首次编译之后,指纹变脏。不要绕过 Gradle/native 路径;用单 ABI dispatch + 全 ABI matrix 并行降低墙钟。 + + +## Beta 3 发布整合 + +- 正式 tag 仍使用原来的 release profile,四 ABI 并行构建后统一签名与生成 Beta updater manifest;快速 profile 只允许手动验证运行。 +- Gradle 缓存回写前保留编译缓存,但生成的 Kotlin DSL 只读取环境变量,不写入签名密码或 alias。实际 release build 步骤注入凭据;keystore 权限保持 0600。 +- APK 收集器使用 runner 既有 Python 3 标准库校验 ZIP 和 CRC,并检查完整 ABI 目录集合;损坏、未知 ABI、多 ABI、重复或缺失产物均有回归测试。 +- 未合入 #1106 中额外的 Linux egui 测试修补;本轮没有 Linux 代码或发布工作流变更。 diff --git a/docs/architecture.md b/docs/architecture.md index 55c6947b8..e1ba031c1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,7 +71,9 @@ Tauri 在 `src-tauri/src/coordinator.rs` 构造 Core,`core_adapters.rs` 组装 Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺寸与点击范围在保存偏好时同步。胶囊按显示器工作区底部定位,避开未自动隐藏的 Dock/任务栏;可见期间重新检查工作区。带正文的浮窗使用不透明底色,聊天面板另叠加细噪点纹理,圆角外部仍保留透明区域。 -思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入在可读取 AX 光标的控件上等待原控件光标到达本批文字末尾,再完成写入和恢复输入源;仅读选区范围,不读正文。不可读、提交型 Return 或等待超过 10 秒时回到按键发送完成语义,本次会话停止继续探测该控件。目标应用的实际输入表现仍需设备验收。 +思考动画覆盖转写、润色和原生文字写入,输入完成后才收尾。macOS 流式键盘输入由会话内串行 worker 维护原控件和累计 UTF-16 终点;每批发送后不再等待 AX 长确认,finish/cancel 在已接收写入之后等待最终屏障,再恢复输入源。AX 仅读选区范围,不读正文;采用 250 ms 无进展预算和 10 秒总预算。不可读、提交型 Return 或预算耗尽时明确降级到按键已发送语义,不重新粘贴已发送文字。目标应用的实际输入表现仍需设备验收。 + +Less Computer 面板将听写与直接语音提交分开:麦克风把转写填入草稿供编辑,语音模式和快捷键可直接提交给 Agent。Core 的 `voice_state` 事件携带会话 ID、模式、实时转写及收尾结果;波形使用实际音量采样。停止和取消均绑定指定会话,延迟请求不能结束下一段录音;开麦与文字发送互斥。工具过程默认折叠,运行状态只在真实活动步骤显示动效,右侧工作台汇总当前轮次;历史和多会话仍标为暂不可用。登录弹窗打开时,听写结果只更新草稿,不抢走弹窗或授权浏览器的焦点。 选区直接润色在捕获文字和原输入目标后显示处理中提示,重复快捷键的 Busy 返回不覆盖该提示。已有语音选区入口在松开快捷键后继续显示思考动画,直到处理/替换完成,或交给确认和预览面板。录音提示音的 Web Audio context 在恢复超时或音频时钟冻结时丢弃并最多重试一次,重试沿用原请求的取消和迟到边界。 @@ -86,7 +88,8 @@ Siri、Classic、Typeless 三种胶囊共用 Core 的 `CapsuleStyle`,窗口尺 | 服务凭据 | Core `CredentialStore` 合同,Tauri keyring/Android Keystore 或 Linux `credentials.rs` 适配 | | 云端 ASR / LLM | Core provider 目录、选择与传输模块;平台本地引擎位于 `src-tauri/src/asr/local/` 或 Linux Host | | 风格包市场 | Core `marketplace.rs` 管理 HTTP、GitHub device flow 与本地安装;地址由 `MarketplaceConfig` 注入,内置默认值在该模块 | -| 私有云同步 | Core `cloud_sync.rs` 复用同一 GitHub 登录与官方服务地址,按版本同步词典、纠错、风格包和允许的个人偏好;`cloud_sync_transaction.rs` 在本地恢复失败时回滚文件,凭据和设备配置保持本机所有。合同及边界见 [官方云同步](cloud-sync.md) | +| 加密云同步 | Core `cloud_sync_e2ee` 复用 GitHub 登录并交换独立同步会话,通过 `/v1/...` 保存客户端加密快照;protocol/documents/store 分别负责加密协议、登记与合并、仓库及系统凭据的受控恢复。默认关闭,凭据、本地基线和回滚日志不交给 UI。详情与验证边界见 [加密云同步客户端](encrypted-cloud-sync.md) | +| 旧手动同步 | `cloud_sync.rs` 和 `/me/sync` 保留有限明文快照合同;旧入口不上传新加密文档中的服务密钥。已注册加密恢复 gate 的仓库拒绝旧多文件恢复,防止绕过受控恢复;未注册的旧 Host 保留原合同。见 [旧同步合同](cloud-sync.md) | | 风格图标 | React `src/lib/stylePackIcon.ts` 清理上传的 SVG 并转成 PNG;`set_style_pack_icon` / `read_style_pack_icon` 经 Core `style_pack_store.rs` 保存资源、校验读取范围并返回图片 data URL。图标沿用 ZIP 的 64 KiB 限制,与风格包一起导出 | | 局域网手机输入 | Core `remote_input_service.rs` 定义共享业务,Tauri `remote_server/` 提供本机网络入口和网页资源 | diff --git a/docs/cloud-sync.md b/docs/cloud-sync.md index a1b852eb5..9d400cf0c 100644 --- a/docs/cloud-sync.md +++ b/docs/cloud-sync.md @@ -1,6 +1,6 @@ # 官方云同步 -> 本文描述已实现的旧手动快照协议。新的全量加密云同步需求与接口在工作区 `5-cloud-sync/docs/`,尚未实现;不得向下述旧 `/me/sync` 上传服务 API 密钥或历史全量明文。项目入口由工作区文档总索引维护。 +> 本文描述旧手动快照协议。Beta 3 候选版的新客户端实现及验收边界见 [加密云同步客户端](encrypted-cloud-sync.md),协议合同在工作区 `5-cloud-sync/docs/`。不得向下述旧 `/me/sync` 上传服务 API 密钥或历史全量明文;已注册加密恢复 gate 的仓库拒绝旧多文件恢复,防止绕过加密恢复事务。 状态:手动快照同步,HTTP 合同版本 1。客户端需要官方服务同时提供 `/me/sync`;旧版服务返回 404/405/501 时显示暂不支持,不能将其当成空备份。生产同步地址为 `https://apic.openless.top:9443`(与市场后端同一服务器、独立端口),服务端连接细节见 [云同步服务端交接](cloud-sync-server-handoff.md)。 diff --git a/docs/encrypted-cloud-sync.md b/docs/encrypted-cloud-sync.md new file mode 100644 index 000000000..1caa901eb --- /dev/null +++ b/docs/encrypted-cloud-sync.md @@ -0,0 +1,44 @@ +# 加密云同步客户端 + +实现版本:`2.0.0-Beta.3+build.20260925`。协议版本为 `1`,密码学 profile 为 `argon2id-xchacha20poly1305-v1`。本页描述客户端实现和验证边界;正式发布状态以对应 GitHub Release 为准。 + +## 地址与协议边界 + +新客户端使用独立入口 `https://apic.openless.top:9443/v1/...`。启动同步前读取 `/v1/capabilities`,核对协议、体积上限及现有 OpenLess GitHub OAuth client ID,再使用原生层的 GitHub 登录令牌交换短期同步会话。数字 GitHub ID 是账号隔离依据。新请求不回退到旧 `/me/sync`;旧四个 `cloud_sync_*` 命令不改接新协议。旧 standalone 仓库仍保留有限明文快照语义;已注册加密同步写入门的仓库暂不支持旧 `cloud_sync_restore`,明确返回 Unsupported,避免绕过加密恢复事务。 + +2026-09-26 的不带凭据检查中,正式 origin 的 `/v1/capabilities` 返回了协议 `1`、匹配的 OAuth client ID 与加密 profile,备份保留上限为 7 天。这仅证明能力接口当时可用,不代表真实账号授权、私有数据往返或生产验收已经完成。 + +## 原生边界 + +- `crates/openless-core/src/cloud_sync_e2ee_protocol/`:严格 wire DTO、规范编码、Argon2id、XChaCha20-Poly1305、带身份与版本的 AAD、HTTPS/CAS/幂等收据。 +- `crates/openless-core/src/cloud_sync_e2ee_documents/`:逐字段登记、11 类逻辑文档、渠道和凭据一起合并、删除标记、脱敏冲突与加密恢复日志。 +- `crates/openless-core/src/cloud_sync_e2ee_store/`:受控仓库、持久化代次、写隔离、完整恢复与读回验证。 +- `crates/openless-core/src/cloud_sync_e2ee/`:默认关闭的状态机、密码解锁、创建、同步、改密、删除、原生凭据库中的记住密钥与加密本地基线。 +- `src-tauri/src/commands/cloud_sync_e2ee.rs`:仅主窗口的本地 IPC;`src/lib/ipc/cloud-sync-e2ee.ts` 为对应 typed wrapper。浏览器预览返回 unavailable。 +- `src/lib/encryptedSyncUiBridge.ts`:主窗口中实际语言/字号偏好的受控镜像,以及恢复成功后的界面更新。 + +上传只发送密文。密码和派生密钥不进入服务器请求;系统凭据库只导出登记过的服务账号,不复制 keyring/Keystore 文件。普通服务 API Key、接口设置、文本历史和风格内容纳入加密范围;OAuth 登录态、同步令牌、同步密码/派生密钥、设备私钥、远程输入 PIN、系统权限、录音、模型权重、缓存和程序不进入快照。 + +旧版 Omni 初始化可能将默认 `custom` 槽错误保存为空标识。原生读取时仅在目标不存在或规范后全部字段相同的情况下,将这条旧槽完整映射回 `custom`,保留其他已选供应商;不同配置发生碰撞时继续拒绝同步并保留两份数据。读取只规范内存副本,持久化仍走既有写入门,不通过删除凭据或放宽同步文档校验绕过错误。 + +## 行为 + +首次开启需明确确认完整范围;已有云端快照必须验证 AEAD 并展示恢复预览,不能用新设备默认值直接覆盖。密码按 NFC 规范化,创建/改密执行本地强度策略。记住密钥由系统安全存储实现,并绑定服务 origin、数字账号、vault、key 和设备。 + +共同基线、未确认操作及恢复资料在本机加密。网络丢失响应时保留原始请求体和操作 ID,先核对收据,必要时仅重送同一请求。不能将 404 收据当作“未提交”。上传中发生的新本地代次仍待同步。删除状态会停用自动上传,不能由旧设备自动重建。 + +自动同步仅在已开启、已登录且已解锁时运行:一次启动触发,持久化变更后 750 ms 防抖、最长 5 秒;没有周期轮询或无限重试。关闭与删除是独立操作。跨设备路径和平台设置以设备档案保留,恢复不授予系统权限或自动执行 Agent。 + +完成有效服务配置后,本机首次引导只申请一次持久化提示。凭据读取失败、配置正在保存、录音/输入/恢复繁忙时不申请;提示不访问同步网络、不启用上传。欢迎流程可直接打开“从云端恢复”,无需先填写服务密钥。窗口隐藏、失焦或任务开始后会丢弃迟到的提示结果。 + +短暂的本机写入竞争最多触发 4 次有界延后重试;网络失败、结果未知、版本冲突不进入该重试。后续手动任务、锁定或停用会淘汰旧重试链。同步连接遵循当前系统代理偏好,策略变化后重建连接,回环测试始终直连。 + +系统自启动 `launchAtLogin` 不进入远端文档或设备档案;保留接收设备本机值,实际 OS 状态仍由现有 UI 自启动插件管理,恢复不宣称替用户重建自启动授权。 + +恢复必须绑定 `RestoreRuntimeEffects` 的真实 Host 适配器。所有仓库写入后,在同一加密 journal、独占写入门和运行态屏障内等待绝对目标设置生效;副作用失败按 before 镜像回滚。只保存在本地 journal 的 Agent 开关、provider 镜像和录音标记恢复后,再应用最终旧设置;若回滚副作用也失败,保留 recovery_required,不能提前放行录音或 Agent。未绑定适配器明确失败,不使用空实现代替平台确认。 + +## 验证边界 + +协议测试使用公开互操作向量;服务测试使用 loopback 模拟 OAuth/同步端和内存凭据库,不触碰真实账号或用户钥匙串。恢复层须单独覆盖文件/凭据故障、进程中断、账号/版本隔离及排除项。通过协议测试不能替代真实平台钥匙串、两设备恢复与正式 OAuth 的人工验收。 + +发布遵循 [RELEASING.md](../RELEASING.md) 的对应提交 CI、签名和资产检查流程。真实 GitHub 授权及两台设备的生产数据往返仍须单独验收,不能由本地模拟服务器的通过结果代替。 diff --git a/docs/index.md b/docs/index.md index 073a576c9..fe3f406d5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -17,12 +17,16 @@ ## 接口契约 +- [加密云同步客户端](encrypted-cloud-sync.md):Beta 3 新协议、受控数据范围、系统凭据库、恢复与验证边界。 + - [官方云同步](cloud-sync.md):可同步字段、GitHub 身份边界、版本冲突、本地恢复与验证。 - [云同步服务端交接](cloud-sync-server-handoff.md):同步服务地址与端口(apic.openless.top:9443)、客户端请求行为、状态码映射与服务端验收清单。 - [Linux egui 后端契约](linux-egui-backend-contract.md):`contract/backend-2.0.json`、启动快照、事件面与公开签名。 ## 平台与运营 +- [macOS CI 与打包耗时](macos-build-performance.md):基线日志、Rust 编译优化、缓存边界与仅 macOS 验证入口。 +- [Android APK 编译耗时调研与实现](android-build-time-research-1103.md):ABI 并行、缓存回写和发布验证。 - [Android APK / 悬浮窗计划](android-mobile-apk-overlay-plan.md)(实施中) - [火山引擎 ASR 配置](volcengine-setup.md) - [讯飞(iflytek)ASR 配置](xfyun-asr.md) @@ -39,6 +43,8 @@ ## 本地集成记录 +- [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/local-builds/2026-09-25-beta3.md b/docs/local-builds/2026-09-25-beta3.md new file mode 100644 index 000000000..9c6608ffd --- /dev/null +++ b/docs/local-builds/2026-09-25-beta3.md @@ -0,0 +1,32 @@ +# 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/macos-build-performance.md b/docs/macos-build-performance.md new file mode 100644 index 000000000..e9578cbf9 --- /dev/null +++ b/docs/macos-build-performance.md @@ -0,0 +1,55 @@ +# macOS CI 与打包耗时 + +状态:实现说明;更新:2026-09-25。范围仅包含 macOS 检查和桌面打包。Windows、Android、Linux 的编译参数与发布行为由原有工作流维护。 + +## 调研依据 + +对照 `beta` 的 `d9113e0b`、[CI 35984434219](https://github.com/Open-Less/openless/actions/runs/35984434219) 与 [桌面构建 35989822601](https://github.com/Open-Less/openless/actions/runs/35989822601) 的完整日志: + +| 基线步骤 | 时间 | 日志证据 | +| --- | --- | --- | +| macOS 检查 job | 24 分 59 秒 | stable 检查、测试、MSRV 串行执行 | +| stable `cargo check` | 7 分 54 秒 | 含 MLX,本轮 dev profile 带 debug info | +| stable Tauri 库测试编译 | 7 分 27 秒 | 再次编译 qwen3、Core、Host,test profile 不带 debug info | +| Rust 1.88 Host 检查 | 5 分 03 秒 | 第三次编译 qwen3 与 Host | +| Rust 1.88 独立 backend-tests 编译 | 58.51 秒 | 独立 target 未配置缓存 | +| Apple Silicon release Rust 编译 | 14 分 16 秒 | 依赖缓存命中约 731 MB,仍重新编译 qwen3、Core、Host | +| Intel release Rust 编译 | 13 分 57 秒 | 依赖缓存命中约 700 MB,仍重新编译 Core、Host | + +ARM/Intel 的整个打包步骤分别约 15 分 17 秒、15 分 08 秒。主要等待发生在 Rust,而不是 npm 安装、DMG 或 artifact 上传。时间是该次运行的观测值,不是不同 runner、缓存和提交之间的性能保证。 + +源码中的相关因素: + +- `ci.yml` 的 macOS job 先 metadata-only check,再生成测试机器码;dev/test 的 debug 配置也不同。MSRV 与 stable 共用 job 和缓存,Core workspace、独立 backend-tests 的 target 没有全部纳入缓存。 +- Tauri release profile 使用 `opt-level=3`、thin LTO 和 `codegen-units=1`。最后一个设置限制单个大 crate 的 LLVM 并行能力;命中第三方依赖缓存仍无法避免 Core/Host 的代码生成成本。 +- `build-mac.sh` 曾将所有 `qwen3-asr-rs-*` 目录当成重复输出。Cargo 实际分别保存 build-script 可执行文件和 `OUT_DIR`;只留一个目录会破坏下一轮缓存。 +- `rust-cache` 默认只保留依赖产物,不应把命中缓存等同于整个应用无须重编译。参见 [rust-cache 缓存行为](https://github.com/Swatinem/rust-cache#cache-details)。没有添加 sccache:该工具不能缓存调用系统 linker 的 bin/cdylib/proc-macro,参见 [官方限制](https://github.com/mozilla/sccache/blob/main/docs/Rust.md)。 + +## 当前流程 + +`ci.yml` 的 macOS stable job 用 `cargo test --lib --bins` 编译并运行库及二进制目标,覆盖库的生产构建与测试构建。MSRV 单独并行执行,继续检查完整 Tauri Host 并编译独立 backend-tests。两个 job 均保留 `--locked`、MLX 子模块与两路编译并发限制;stable 保留全部前端/合同测试和 Codex sandbox 实测。 + +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` 也加载它。 + +MLX 清理只比较含非空 metallib 的输出目录,保留 build-script 可执行文件。构建前删除本次架构的旧 app、DMG 和 updater,保留 Cargo 编译缓存;Tauri 非零退出直接失败。因此热构建复用旧时间戳二进制时仍能正确打包,失败时也不会接受旧安装包。现有用途声明、签名、公证和 MLX 包内容校验继续执行。 + +## 仅 macOS 的验证入口 + +从待验证分支手动触发已有工作流: + +```sh +gh workflow run ci.yml --repo Open-Less/openless --ref -f platform=macos +gh workflow run release-tauri.yml --repo Open-Less/openless --ref -f platform=macos +``` + +前者只运行 macOS stable/MSRV,后者并行生成 Apple Silicon 与 Intel 的桌面包。使用分支 ref,不创建 tag 或 GitHub Release。省略 input 的既有手动运行以及 tag 发布仍使用原有全部平台矩阵。 + +桌面工作流上传两个架构的 Cargo HTML timings;失败时若已生成计时文件也会上传。DMG/updater 本身已压缩,artifact 使用 `compression-level: 0`。 + +本地在 `openless-all/app` 运行 `npm test`、`cargo test --locked --manifest-path src-tauri/Cargo.toml --lib --bins` 与 `INSTALL=0 bash scripts/build-mac.sh`。`macos-build-cache.test.mjs` 实际执行隔离的 shell 构建流程,验证缓存目录保留、重复热打包、旧产物清理以及失败传播。 + +性能验收应记录一次新缓存运行及相同提交的再次运行,分别报告 Rust 编译、完整 job 和产物大小。不同机器的本地构建时间不与 GitHub runner 直接比较;构建通过不等于真实设备 ASR 性能已经验证。 diff --git a/docs/structure.md b/docs/structure.md index 8d1ac96d1..26569f11d 100644 --- a/docs/structure.md +++ b/docs/structure.md @@ -41,7 +41,8 @@ | 共享业务入口 | `crates/openless-core/src/api.rs` | `events.rs`、`ports.rs`、`domains.rs`、`config.rs` | | 听写和服务 | Core `dictation_engine.rs`、`provider_*`、`asr/`、`polish.rs` | Host 的录音、插入和本地模型适配 | | 历史、词库、纠错、风格包 | Core `history.rs`、`vocabulary.rs`、`correction.rs`、`style_pack_store.rs` | Tauri `persistence/` 与对应 command | -| 官方云同步 | Core `cloud_sync.rs`、`cloud_sync_types.rs`、`cloud_sync_validation.rs`、`cloud_sync_transaction.rs` | Tauri `commands/cloud_sync.rs`;GitHub 身份、有限字段与版本冲突见 [云同步合同](cloud-sync.md) | +| 加密云同步 | Core `cloud_sync_e2ee/`、`cloud_sync_e2ee_protocol/`、`cloud_sync_e2ee_documents/`、`cloud_sync_e2ee_store/` | Tauri `commands/cloud_sync_e2ee.rs`、系统凭据适配及 `src/lib/encryptedSyncUiBridge.ts`;见 [客户端边界](encrypted-cloud-sync.md) | +| 旧手动同步 | Core `cloud_sync.rs`、`cloud_sync_types.rs`、`cloud_sync_validation.rs`、`cloud_sync_transaction.rs` | Tauri `commands/cloud_sync.rs`;仅保留旧有限字段协议,见 [旧同步合同](cloud-sync.md) | | Tauri 组装与系统能力 | `src-tauri/src/coordinator.rs`、`core_adapters.rs`、`tauri_coordinator_host.rs` | 窗口、热键、权限、平台输入与生命周期 | | Linux 原生接入 | `linux-egui/src/main.rs`、`lib.rs`、`backend.rs` | `audio/credentials/fcitx5/hotkeys/settings` 等 Host 模块;见 [交接](linux-egui-handoff/README.md) | | Android 集成 | `android/`、`src-tauri/src/android/` | `@android` 别名与 `merge-android-*.mjs` 生成链 | diff --git a/openless-all/app/Cargo.lock b/openless-all/app/Cargo.lock index c9456d7bb..d0091d982 100644 --- a/openless-all/app/Cargo.lock +++ b/openless-all/app/Cargo.lock @@ -114,6 +114,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -238,6 +248,19 @@ dependencies = [ "x11rb", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", + "zeroize", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -559,6 +582,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -586,6 +615,15 @@ version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -824,6 +862,17 @@ 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" @@ -835,6 +884,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -854,6 +916,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1047,6 +1110,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -3243,16 +3307,25 @@ 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", + "chacha20poly1305", "chrono", "ferrous-opencc", "futures-util", + "getrandom 0.3.4", "hmac", "log", "md-5", @@ -3268,8 +3341,10 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-tungstenite", + "unicode-normalization", "url", "uuid", + "zeroize", "zip", ] @@ -3370,6 +3445,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -3531,6 +3617,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -3764,7 +3861,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -4895,6 +4992,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -4907,6 +5013,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/openless-all/app/assets/remote-input/app.js b/openless-all/app/assets/remote-input/app.js index d3ed74b01..a07ac65a8 100644 --- a/openless-all/app/assets/remote-input/app.js +++ b/openless-all/app/assets/remote-input/app.js @@ -1537,8 +1537,10 @@ setStatus(L.preparingMic, 'work'); clearResult(); // 清掉上一次的识别结果,避免新录音时还显示旧文字 + var gen = audioGen; withTimeout(ensureAudio(), MIC_PREP_TIMEOUT_MS, 'TIMEOUT') .then(function () { + if (gen !== audioGen) return; if (!recording) { // 期间已被取消/松手 teardownAudioCapture(); @@ -1549,6 +1551,7 @@ setStatus(L.preparingBackend, 'work'); }) .catch(function (err) { + if (gen !== audioGen) return; recording = false; resetRemoteStreamState(); // 超时多半是 audioCtx 卡死(resume 永不 settle),彻底重建,否则下次重试会继续卡在 @@ -1639,6 +1642,7 @@ // 确保 AudioContext / getUserMedia / 采集节点就绪并开始推流。 // 必须在用户手势调用栈内(startRecording 由手势触发)。 function ensureAudio() { + var gen = audioGen; // 不支持 getUserMedia if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { return Promise.reject(new Error('UNSUPPORTED:浏览器不支持录音,请升级或换浏览器')); @@ -1665,11 +1669,10 @@ return resumeP .then(function () { + if (gen !== audioGen) return null; // 2) 麦克风流(已存在则复用) if (mediaStream) return mediaStream; - // 捕获当前代际:迟到 resolve 时若代际已变(超时重置/断线释放),停掉轨道并放弃, - // 避免泄漏麦克风或覆盖重试成功的新流。 - var gen = audioGen; + // 使用准备开始时的代际;停止/取消后迟到的流必须释放,不能覆盖下一次录音。 return navigator.mediaDevices .getUserMedia({ audio: { @@ -1701,7 +1704,7 @@ .then(function (stream) { // 3) 建立采集图(若已建好则跳过)。audioCtx 可能在准备超时后被 resetAudioContext // 置空(本次 getUserMedia 迟到 resolve),此时直接放弃,避免对 null ctx 建图报错。 - if (sourceNode || !audioCtx || !stream) return; + if (gen !== audioGen || sourceNode || !audioCtx || !stream) return; sourceNode = audioCtx.createMediaStreamSource(stream); return buildCaptureGraph(); }); @@ -1709,12 +1712,14 @@ // 建立 AudioWorklet(优先)或 ScriptProcessor(兜底) function buildCaptureGraph() { + var gen = audioGen; var inSr = audioCtx.sampleRate || 48000; // 优先 AudioWorklet if (audioCtx.audioWorklet && typeof AudioWorkletNode !== 'undefined') { return loadWorklet() .then(function () { + if (gen !== audioGen) return; workletNode = new AudioWorkletNode(audioCtx, 'ol-pcm-worklet', { numberOfInputs: 1, numberOfOutputs: 0, @@ -1723,12 +1728,13 @@ }); workletNode.port.onmessage = function (e) { // e.data 是已转换好的 Int16 LE ArrayBuffer - sendAudio(e.data); + if (gen === audioGen) sendAudio(e.data); }; sourceNode.connect(workletNode); usingWorklet = true; }) .catch(function () { + if (gen !== audioGen) return; // worklet 加载失败 → 回退 ScriptProcessor usingWorklet = false; buildScriptProcessor(inSr); @@ -2035,6 +2041,7 @@ // ============================================================ // 仅停止"采集/推流"(断开节点),保留 audioCtx & mediaStream 以便快速重启。 function teardownAudioCapture() { + audioGen++; // 停止/取消也作废在途的 resume、麦克风、worklet 和准备超时回调。 releaseWakeLock(); if (wakeLockHint) wakeLockHint.textContent = L.wakeLockHint; try { @@ -2072,7 +2079,6 @@ // 彻底释放(断线时):停止麦克风轨道并关闭 ctx。 function teardownAudio() { - audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 teardownAudioCapture(); if (mediaStream) { try { @@ -2093,7 +2099,6 @@ // 与 teardownAudio 的区别:这里 close 并置空 audioCtx —— 超时根因往往是 ctx 自身坏掉 // (resume 永不 settle),保留它只会让下次继续卡。 function resetAudioContext() { - audioGen++; // 代际推进:作废所有在途的 getUserMedia 迟到回调 teardownAudioCapture(); if (mediaStream) { try { diff --git a/openless-all/app/contract/backend-2.0.json b/openless-all/app/contract/backend-2.0.json index f8a0a8325..25507cdfd 100644 --- a/openless-all/app/contract/backend-2.0.json +++ b/openless-all/app/contract/backend-2.0.json @@ -50,6 +50,9 @@ "history_changed", "vocabulary_changed", "style_packs_changed", + "cloud_sync_state_changed", + "cloud_sync_conflict_detected", + "cloud_sync_restore_completed", "download_progress", "permission_changed", "hotkey_status_changed", @@ -82,6 +85,9 @@ "history_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "history_changed", "payload": {"revision": 1}}}, "vocabulary_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "vocabulary_changed", "payload": {"revision": 1}}}, "style_packs_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "style_packs_changed", "payload": {"revision": 1}}}, + "cloud_sync_state_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "cloud_sync_state_changed", "payload": {"sequence": "1", "accountId": null, "vaultId": null, "taskId": null, "status": {"sequence": "1", "enabled": false, "authState": "signed_out", "keyState": "locked", "syncState": "disabled", "account": null, "vaultId": null, "keyId": null, "localGeneration": "0", "lastSyncedLocalGeneration": null, "remoteRevision": null, "lastSuccessfulSyncAt": null, "pendingOperationId": null, "lastError": null, "recoveryRequired": false, "hasCloudSnapshot": null, "taskId": null, "serviceOrigin": "https://apic.openless.top:9443", "consentVersion": null, "backupRetentionDays": null}}}}, + "cloud_sync_conflict_detected": {"sequence": 1, "sessionId": null, "kind": {"type": "cloud_sync_conflict_detected", "payload": {"sequence": "1", "accountId": "118526", "vaultId": "11111111-1111-4111-8111-111111111111", "taskId": null, "preview": {"previewId": "22222222-2222-4222-8222-222222222222", "unconfirmedOperationId": null, "observedRevision": "1", "localGeneration": "1", "counts": {}, "deviceSettingsToReview": [], "conflicts": []}}}}, + "cloud_sync_restore_completed": {"sequence": 1, "sessionId": null, "kind": {"type": "cloud_sync_restore_completed", "payload": {"sequence": "1", "accountId": "118526", "vaultId": "11111111-1111-4111-8111-111111111111", "taskId": null, "localGeneration": "1", "uiPreferences": {"locale": "zh-CN", "fontScale": "medium"}}}}, "download_progress": {"sequence": 1, "sessionId": null, "kind": {"type": "download_progress", "payload": {"resourceId": "model", "completedBytes": 1, "totalBytes": null}}}, "permission_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "permission_changed", "payload": {"microphone": "unknown", "accessibility": "unknown"}}}, "hotkey_status_changed": {"sequence": 1, "sessionId": null, "kind": {"type": "hotkey_status_changed", "payload": {"adapter": "unavailable", "state": "starting", "message": null, "lastError": null}}}, @@ -101,7 +107,11 @@ }, "lessComputerVoice": { "phases": ["starting", "recording", "transcribing", "idle"], - "sample": {"seq": 3, "kind": "voice_state", "sessionId": "00000000-0000-4000-8000-000000000000", "phase": "recording", "level": 0.5, "elapsedMs": 120} + "modes": ["submit", "dictate"], + "outcomes": ["submitted", "committed", "empty", "failed", "cancelled"], + "sample": {"seq": 3, "kind": "voice_state", "sessionId": "00000000-0000-4000-8000-000000000000", "phase": "recording", "level": 0.5, "elapsedMs": 120, "mode": "dictate", "transcript": "打开"}, + "idleSample": {"seq": 4, "kind": "voice_state", "sessionId": "00000000-0000-4000-8000-000000000000", "phase": "idle", "level": 0.0, "elapsedMs": 120, "mode": "dictate", "transcript": "打开设置", "outcome": "committed"}, + "legacySample": {"seq": 2, "kind": "voice_state", "sessionId": "00000000-0000-4000-8000-000000000000", "phase": "starting", "level": 0.0, "elapsedMs": 0} }, "androidJni": { "fields": ["contractVersion", "ok", "payload", "error"], diff --git a/openless-all/app/crates/openless-core/Cargo.toml b/openless-all/app/crates/openless-core/Cargo.toml index a95160f05..e58f79a95 100644 --- a/openless-all/app/crates/openless-core/Cargo.toml +++ b/openless-all/app/crates/openless-core/Cargo.toml @@ -9,10 +9,13 @@ publish = false [dependencies] anyhow = "1" +argon2 = { version = "0.5", features = ["zeroize"] } base64 = "0.22" bzip2 = "0.4" +chacha20poly1305 = "0.10" chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } futures-util = "0.3" +getrandom = "0.3" ferrous-opencc = "0.4" hmac = "0.12" log = "0.4" @@ -35,4 +38,6 @@ tokio = { version = "1", features = ["sync", "time", "rt", "macros", "rt-multi-t tokio-tungstenite = { version = "0.24", 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" diff --git a/openless-all/app/crates/openless-core/src/activity.rs b/openless-all/app/crates/openless-core/src/activity.rs index 206abc362..474dbdd87 100644 --- a/openless-all/app/crates/openless-core/src/activity.rs +++ b/openless-all/app/crates/openless-core/src/activity.rs @@ -1,14 +1,12 @@ -//! Text-free daily activity aggregates shared by both desktop hosts. +//! Text-free daily activity aggregates with stable per-device contributions. +use crate::errors::{BackendError, BackendErrorCode}; +use crate::persistence::{atomic_write, persistence_error}; +use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Mutex; -use serde::{Deserialize, Serialize}; - -use crate::errors::{BackendError, BackendErrorCode}; -use crate::persistence::{atomic_write, persistence_error}; - const ACTIVITY_RETENTION_DAYS: usize = 731; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -20,7 +18,6 @@ pub struct DayStats { #[serde(default)] pub duration_ms: u64, } - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct ActivityDay { @@ -30,118 +27,291 @@ pub struct ActivityDay { pub duration_ms: u64, } +#[derive(Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DayBucket { + #[serde(flatten)] + stats: DayStats, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + source_contributions: BTreeMap, +} #[derive(Deserialize)] #[serde(untagged)] enum StoredDay { CountOnly(u32), - Full(DayStats), + Full(DayBucket), } -impl From for DayStats { - fn from(stored: StoredDay) -> Self { - match stored { - StoredDay::CountOnly(count) => Self { - count, - ..Self::default() - }, - StoredDay::Full(stats) => stats, +fn totals(sources: &BTreeMap) -> DayStats { + sources + .values() + .fold(DayStats::default(), |mut total, source| { + total.count = total.count.saturating_add(source.count); + total.chars = total.chars.saturating_add(source.chars); + total.duration_ms = total.duration_ms.saturating_add(source.duration_ms); + total + }) +} +fn read_days(path: &Path) -> Result, BackendError> { + let raw: BTreeMap = match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes) + .map_err(|_| persistence_error("decode activity aggregates"))?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), + Err(_) => return Err(persistence_error("read activity aggregates")), + }; + let mut stored = BTreeMap::new(); + for (date, value) in raw { + let day: StoredDay = serde_json::from_value(value.clone()) + .map_err(|_| persistence_error("decode activity day"))?; + if let StoredDay::Full(bucket) = &day { + crate::persistence::ensure_lossless_value( + &value, + &serde_json::to_value(bucket) + .map_err(|_| persistence_error("encode activity day"))?, + &[], + )?; } + stored.insert(date, day); } + Ok(stored + .into_iter() + .map(|(date, value)| { + let mut bucket = match value { + StoredDay::CountOnly(count) => DayBucket { + stats: DayStats { + count, + ..Default::default() + }, + ..Default::default() + }, + StoredDay::Full(bucket) => bucket, + }; + if !bucket.source_contributions.is_empty() { + bucket.stats = totals(&bucket.source_contributions); + } + (date, bucket) + }) + .collect()) } +struct ActivityState { + days: BTreeMap, + local_source: Option, +} pub struct ActivityStore { path: Option, - cache: Mutex>, + cache: Mutex, } - impl ActivityStore { pub fn at_data_dir(data_dir: impl AsRef) -> Result { Self::at_path(data_dir.as_ref().join("activity.json")) } - pub fn at_path(path: PathBuf) -> Result { - let stored = match std::fs::read(&path) { - Ok(bytes) => serde_json::from_slice::>(&bytes) - .map_err(|_| persistence_error("decode activity aggregates"))?, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), - Err(_) => return Err(persistence_error("read activity aggregates")), - }; + let days = read_days(&path)?; Ok(Self { path: Some(path), - cache: Mutex::new( - stored - .into_iter() - .map(|(date, day)| (date, day.into())) - .collect(), - ), + cache: Mutex::new(ActivityState { + days, + local_source: None, + }), }) } - - /// In-memory degradation for a non-critical aggregate store. pub fn in_memory() -> Self { Self { path: None, - cache: Mutex::new(BTreeMap::new()), + cache: Mutex::new(ActivityState { + days: BTreeMap::new(), + local_source: None, + }), } } - pub fn bump(&self, date: &str, chars: u64, duration_ms: u64) -> Result<(), BackendError> { - if !valid_date(date) { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "activity date must use YYYY-MM-DD", - )); + /// Memory-only Host identity attachment; it never attributes imported totals to this device. + pub(crate) fn bind_sync_device(&self, device: &str) -> Result<(), BackendError> { + if device.is_empty() { + return Err(persistence_error("missing activity source device")); } - let mut cache = self.lock_cache()?; - let entry = cache.entry(date.to_string()).or_default(); - entry.count = entry.count.saturating_add(1); - entry.chars = entry.chars.saturating_add(chars); - entry.duration_ms = entry.duration_ms.saturating_add(duration_ms); - while cache.len() > ACTIVITY_RETENTION_DAYS { - let Some(oldest) = cache.keys().next().cloned() else { - break; - }; - cache.remove(&oldest); - } - if let Some(path) = &self.path { - let bytes = serde_json::to_vec_pretty(&*cache) - .map_err(|_| persistence_error("encode activity aggregates"))?; - atomic_write(path, &bytes)?; + let mut state = self.lock_cache()?; + if state + .local_source + .as_deref() + .is_some_and(|old| old != device) + { + return Err(persistence_error("activity source identity changed")); } + state.local_source = Some(device.into()); Ok(()) } + pub fn bump(&self, date: &str, chars: u64, duration_ms: u64) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + if !valid_date(date) { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "activity date must use YYYY-MM-DD", + )); + } + let mut state = self.lock_cache()?; + let mut days = match &self.path { + Some(path) => read_days(path)?, + None => state.days.clone(), + }; + let bucket = days.entry(date.to_string()).or_default(); + if let Some(device) = &state.local_source { + if bucket.source_contributions.is_empty() { + bucket + .source_contributions + .insert(device.clone(), bucket.stats); + } + let own = bucket + .source_contributions + .entry(device.clone()) + .or_default(); + own.count = own.count.saturating_add(1); + own.chars = own.chars.saturating_add(chars); + own.duration_ms = own.duration_ms.saturating_add(duration_ms); + bucket.stats = totals(&bucket.source_contributions); + } else { + if !bucket.source_contributions.is_empty() { + return Err(persistence_error( + "activity source must be bound before mutation", + )); + } + bucket.stats.count = bucket.stats.count.saturating_add(1); + bucket.stats.chars = bucket.stats.chars.saturating_add(chars); + bucket.stats.duration_ms = bucket.stats.duration_ms.saturating_add(duration_ms); + } + while days.len() > ACTIVITY_RETENTION_DAYS { + let Some(oldest) = days.keys().next().cloned() else { + break; + }; + days.remove(&oldest); + } + if let Some(path) = &self.path { + let bytes = serde_json::to_vec_pretty(&days) + .map_err(|_| persistence_error("encode activity aggregates"))?; + atomic_write(path, &bytes)?; + } + state.days = days; + Ok(()) + }, + ) + } + pub fn snapshot(&self) -> Result, BackendError> { Ok(self .lock_cache()? + .days .iter() - .map(|(date, stats)| ActivityDay { + .map(|(date, bucket)| ActivityDay { date: date.clone(), - count: stats.count, - chars: stats.chars, - duration_ms: stats.duration_ms, + count: bucket.stats.count, + chars: bucket.stats.chars, + duration_ms: bucket.stats.duration_ms, }) .collect()) } - fn lock_cache( + pub(crate) fn sync_records( + &self, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result, BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("activity persistence unavailable"))?; + crate::cloud_sync_e2ee_store::gate::require_exclusive(path, permit)?; + self.sync_records_readonly() + } + + pub(crate) fn sync_records_readonly( + &self, + ) -> Result, BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("activity persistence unavailable"))?; + let mut state = self.lock_cache()?; + state.days = read_days(path)?; + let device = state + .local_source + .as_deref() + .ok_or_else(|| persistence_error("activity source unavailable"))?; + let mut output = Vec::new(); + for (date, bucket) in &state.days { + let sources = if bucket.source_contributions.is_empty() { + BTreeMap::from([(device.to_string(), bucket.stats)]) + } else { + bucket.source_contributions.clone() + }; + for (source_device_id, stats) in sources { + output.push(crate::cloud_sync_e2ee_documents::ActivityRecord { + source_device_id, + date: date.clone(), + count: stats.count.into(), + chars: stats.chars, + duration_ms: stats.duration_ms, + }); + } + } + Ok(output) + } + + pub(crate) fn sync_replace_records( &self, - ) -> Result>, BackendError> { + records: &[crate::cloud_sync_e2ee_documents::ActivityRecord], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("activity persistence unavailable"))?; + crate::cloud_sync_e2ee_store::gate::require_exclusive(path, permit)?; + let mut days: BTreeMap = BTreeMap::new(); + for record in records { + if !valid_date(&record.date) || record.source_device_id.is_empty() { + return Err(persistence_error("invalid activity contribution")); + } + let stats = DayStats { + count: u32::try_from(record.count) + .map_err(|_| persistence_error("activity count is not representable"))?, + chars: record.chars, + duration_ms: record.duration_ms, + }; + if days + .entry(record.date.clone()) + .or_default() + .source_contributions + .insert(record.source_device_id.clone(), stats) + .is_some() + { + return Err(persistence_error("duplicate activity source")); + } + } + for bucket in days.values_mut() { + bucket.stats = totals(&bucket.source_contributions); + } + // Restoration is complete, independent of the normal rolling retention policy. + let bytes = serde_json::to_vec_pretty(&days) + .map_err(|_| persistence_error("encode restored activity"))?; + let mut state = self.lock_cache()?; + crate::persistence::atomic_write_for_sync(path, &bytes, permit)?; + state.days = days; + Ok(()) + } + + fn lock_cache(&self) -> Result, BackendError> { self.cache.lock().map_err(|_| { BackendError::new(BackendErrorCode::Internal, "activity store lock poisoned") }) } } - fn valid_date(date: &str) -> bool { - let bytes = date.as_bytes(); - bytes.len() == 10 - && bytes[4] == b'-' - && bytes[7] == b'-' - && bytes - .iter() - .enumerate() - .all(|(index, byte)| index == 4 || index == 7 || byte.is_ascii_digit()) + chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d") + .is_ok_and(|value| value.format("%Y-%m-%d").to_string() == date) } #[cfg(test)] diff --git a/openless-all/app/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 0207a0ed5..72e274dd3 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -132,6 +132,36 @@ pub struct LessComputerVoiceSession { request: crate::domains::LessComputerRunRequest, partials: Arc, archive_successful_recording: bool, + mode: crate::events::LessComputerVoiceMode, + feedback: Arc, +} + +/// Start options for a Core-owned Less Computer voice capture. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct LessComputerVoiceOptions { + pub mode: crate::events::LessComputerVoiceMode, + /// Hotkey captures surface start failures in the conversation stream. A + /// panel request receives the error from its command and shows it inline. + pub publish_start_error: bool, +} + +impl Default for LessComputerVoiceOptions { + fn default() -> Self { + Self { + mode: crate::events::LessComputerVoiceMode::Submit, + publish_start_error: true, + } + } +} + +/// How a finished Less Computer capture was delivered. +#[derive(Debug, Clone, PartialEq)] +pub enum LessComputerVoiceFinish { + /// The transcript became an Agent turn (hotkey / voice-mode semantics). + Submitted(crate::domains::LessComputerRunResult), + /// Dictation only: the transcript was published for the host composer. + /// An empty transcript means nothing was recognized. + Dictated { transcript: String }, } pub struct VoiceTranscriptionSession { @@ -611,6 +641,9 @@ struct VoiceTranscriptSink { publisher: crate::events::BackendEventPublisher, session_id: SessionId, transcript: Mutex, + /// Less Computer mirrors the accumulated transcript into its voice + /// projection; other voice surfaces only publish `TranscriptDelta`. + feedback: Option>, } struct VoiceCaptureControl { @@ -625,23 +658,72 @@ struct VoiceCaptureControl { struct LessVoiceFeedback { publisher: BackendEventPublisher, session_id: SessionId, - state: Mutex<(crate::events::LessComputerVoicePhase, u64)>, + mode: crate::events::LessComputerVoiceMode, + state: Mutex, +} + +struct LessVoiceFeedbackState { + phase: crate::events::LessComputerVoicePhase, + elapsed_ms: u64, + level: f32, + transcript: String, + outcome: Option, +} + +impl LessVoiceFeedbackState { + fn new(phase: crate::events::LessComputerVoicePhase) -> Self { + Self { + phase, + elapsed_ms: 0, + level: 0.0, + transcript: String::new(), + outcome: None, + } + } } impl LessVoiceFeedback { + fn new( + publisher: BackendEventPublisher, + session_id: SessionId, + mode: crate::events::LessComputerVoiceMode, + phase: crate::events::LessComputerVoicePhase, + ) -> Self { + Self { + publisher, + session_id, + mode, + state: Mutex::new(LessVoiceFeedbackState::new(phase)), + } + } + fn phase(&self, phase: crate::events::LessComputerVoicePhase) { let mut state = self.state.lock().expect("voice feedback lock poisoned"); - if state.0 == crate::events::LessComputerVoicePhase::Idle { + if state.phase == crate::events::LessComputerVoicePhase::Idle { return; } - state.0 = phase; - self.emit(phase, 0.0, state.1); + state.phase = phase; + state.level = 0.0; + // Only a delivered transcript survives into the terminal snapshot; + // cancelled or failed captures must not leave partial text behind. + if phase == crate::events::LessComputerVoicePhase::Idle + && !matches!( + state.outcome, + Some( + crate::events::LessComputerVoiceOutcome::Committed + | crate::events::LessComputerVoiceOutcome::Submitted + ) + ) + { + state.transcript.clear(); + } + self.emit(&state); } fn level(&self, elapsed_ms: u64, level: f32) { let mut state = self.state.lock().expect("voice feedback lock poisoned"); if !matches!( - state.0, + state.phase, crate::events::LessComputerVoicePhase::Starting | crate::events::LessComputerVoicePhase::Recording ) { @@ -649,21 +731,49 @@ impl LessVoiceFeedback { } // AudioRecorder reports levels only after consuming a non-empty PCM // frame. A native start receipt alone cannot make capture look ready. - state.0 = crate::events::LessComputerVoicePhase::Recording; - state.1 = elapsed_ms; - self.emit(state.0, level.clamp(0.0, 1.0), elapsed_ms); + state.phase = crate::events::LessComputerVoicePhase::Recording; + state.elapsed_ms = elapsed_ms; + state.level = level.clamp(0.0, 1.0); + self.emit(&state); } - fn emit(&self, phase: crate::events::LessComputerVoicePhase, level: f32, elapsed_ms: u64) { + /// Mirror the accumulated live transcript while the capture is still open. + fn transcript(&self, text: &str) { + let mut state = self.state.lock().expect("voice feedback lock poisoned"); + if state.phase == crate::events::LessComputerVoicePhase::Idle || state.transcript == text { + return; + } + state.transcript = text.to_string(); + self.emit(&state); + } + + /// Record how the capture ends. The terminal `idle` snapshot emitted when + /// the feedback guard drops carries it; the first recorded outcome wins. + fn settle(&self, outcome: crate::events::LessComputerVoiceOutcome, transcript: Option) { + let mut state = self.state.lock().expect("voice feedback lock poisoned"); + if state.phase == crate::events::LessComputerVoicePhase::Idle || state.outcome.is_some() { + return; + } + state.outcome = Some(outcome); + if let Some(transcript) = transcript { + state.transcript = transcript; + } + } + + fn emit(&self, state: &LessVoiceFeedbackState) { + let idle = state.phase == crate::events::LessComputerVoicePhase::Idle; self.publisher.publish( Some(self.session_id), BackendEventKind::LessComputerEvent(crate::events::LessComputerEvent { seq: None, kind: crate::events::LessComputerEventKind::VoiceState { session_id: self.session_id, - phase, - level, - elapsed_ms, + phase: state.phase, + level: state.level, + elapsed_ms: state.elapsed_ms, + mode: self.mode, + transcript: state.transcript.clone(), + outcome: if idle { state.outcome } else { None }, }, }), ); @@ -853,15 +963,17 @@ struct VoiceControlGuard { impl Drop for VoiceControlGuard { fn drop(&mut self) { + // Release the hold first: observers of the terminal idle snapshot may + // immediately start another capture or text turn. self.control - .feedback + .resources .lock() - .expect("voice feedback lock poisoned") + .expect("voice resource lock poisoned") .take(); self.control - .resources + .feedback .lock() - .expect("voice resource lock poisoned") + .expect("voice feedback lock poisoned") .take(); let mut controls = self.controls.lock().expect("voice control lock poisoned"); if controls @@ -880,10 +992,15 @@ impl TextStreamSink for VoiceTranscriptSink { offset: chunk.offset, is_final: false, }; - self.transcript + let mut transcript = self + .transcript .lock() - .expect("voice transcript lock poisoned") - .apply(&delta)?; + .expect("voice transcript lock poisoned"); + transcript.apply(&delta)?; + if let Some(feedback) = &self.feedback { + feedback.transcript(transcript.text()); + } + drop(transcript); self.publisher.publish( Some(self.session_id), BackendEventKind::TranscriptDelta(delta), @@ -958,6 +1075,8 @@ impl LessComputerVoiceSession { { return Box::pin(async { Ok(()) }); } + self.feedback + .settle(crate::events::LessComputerVoiceOutcome::Cancelled, None); let control = Arc::clone(&self.control); let controls = Arc::clone(&self.controls); let less_computer = Arc::clone(&self.less_computer); @@ -978,9 +1097,17 @@ impl LessComputerVoiceSession { ) } + pub fn mode(&self) -> crate::events::LessComputerVoiceMode { + self.mode + } + + /// Stop capture and deliver the transcript according to the session mode: + /// `Submit` starts an Agent turn, `Dictate` only publishes the text. pub fn finish( self, - ) -> futures_util::future::BoxFuture<'static, Result> { + ) -> futures_util::future::BoxFuture<'static, Result> + { + use crate::events::{LessComputerVoiceMode, LessComputerVoiceOutcome}; if self .control .closed @@ -1003,6 +1130,8 @@ impl LessComputerVoiceSession { let request = self.request; let partials = Arc::clone(&self.partials); let session_id = self.session_id; + let mode = self.mode; + let feedback = Arc::clone(&self.feedback); let resources = control .resources .lock() @@ -1029,6 +1158,7 @@ impl LessComputerVoiceSession { controls, }; if less_computer.capture_cancelled(session_id) { + feedback.settle(LessComputerVoiceOutcome::Cancelled, None); if let Some(recording) = recording { let _ = recording.stop().await; } @@ -1042,21 +1172,32 @@ impl LessComputerVoiceSession { if let Some(recording) = recording { if let Err(error) = recording.stop().await { let _ = transcription.cancel().await; - return Err( - fail_less_voice_capture(&less_computer, session_id, error).await - ); + return Err(fail_less_voice_finish( + &less_computer, + session_id, + &feedback, + mode, + error, + ) + .await); } } let transcript = match transcription.finish().await { Ok(output) => output.text, Err(error) => { let _ = transcription.cancel().await; - return Err( - fail_less_voice_capture(&less_computer, session_id, error).await - ); + return Err(fail_less_voice_finish( + &less_computer, + session_id, + &feedback, + mode, + error, + ) + .await); } }; if less_computer.capture_cancelled(session_id) { + feedback.settle(LessComputerVoiceOutcome::Cancelled, None); let _ = transcription.cancel().await; let _ = less_computer.abort_capture(session_id); return Err(BackendError::new( @@ -1066,9 +1207,21 @@ impl LessComputerVoiceSession { } let transcript = transcript.trim().to_string(); if transcript.is_empty() { - return Err(fail_less_voice_capture( + if mode == LessComputerVoiceMode::Dictate { + // Silence is not an error for dictation; the composer shows a hint. + feedback.settle(LessComputerVoiceOutcome::Empty, Some(String::new())); + let _ = less_computer.abort_capture(session_id); + drop(resources); + drop(_guard); + return Ok(LessComputerVoiceFinish::Dictated { + transcript: String::new(), + }); + } + return Err(fail_less_voice_finish( &less_computer, session_id, + &feedback, + mode, BackendError::new( BackendErrorCode::Provider, "transcription provider returned an empty transcript", @@ -1089,9 +1242,32 @@ impl LessComputerVoiceSession { } } if less_computer.capture_cancelled(session_id) { + feedback.settle(LessComputerVoiceOutcome::Cancelled, None); return Err(VoiceCaptureLifecycle::cancelled_error()); } - partials.publish_final(transcript.clone())?; + if let Err(error) = partials.publish_final(transcript.clone()) { + feedback.settle(LessComputerVoiceOutcome::Failed, None); + if mode == LessComputerVoiceMode::Dictate { + let _ = less_computer.abort_capture(session_id); + } + return Err(error); + } + if mode == LessComputerVoiceMode::Dictate { + // Release the capture before the terminal snapshot so a text + // submit triggered by the composer never observes it as busy. + let _ = less_computer.abort_capture(session_id); + drop(resources); + feedback.settle( + LessComputerVoiceOutcome::Committed, + Some(transcript.clone()), + ); + drop(_guard); + return Ok(LessComputerVoiceFinish::Dictated { transcript }); + } + feedback.settle( + LessComputerVoiceOutcome::Submitted, + Some(transcript.clone()), + ); drop(_guard); // Keep the hold through capture -> run promotion. If cancellation // won immediately before submit, acquire() must reject this old id @@ -1100,7 +1276,7 @@ impl LessComputerVoiceSession { let mut request = request; request.transcript = transcript; match less_computer.submit(request).await { - Ok(result) => Ok(result), + Ok(result) => Ok(LessComputerVoiceFinish::Submitted(result)), Err(error) => { let _ = less_computer.abort_capture(session_id); Err(error) @@ -1111,6 +1287,48 @@ impl LessComputerVoiceSession { } } +/// Terminal failure while finishing a capture. Agent-bound captures keep the +/// existing conversation error; dictation never became a turn, so it only +/// releases the capture and reports through its idle snapshot. +async fn fail_less_voice_finish( + less_computer: &Arc, + session_id: SessionId, + feedback: &LessVoiceFeedback, + mode: crate::events::LessComputerVoiceMode, + error: BackendError, +) -> BackendError { + use crate::events::LessComputerVoiceOutcome; + if mode == crate::events::LessComputerVoiceMode::Dictate { + let cancelled = error.code == BackendErrorCode::Cancelled + || less_computer.capture_cancelled(session_id); + feedback.settle( + if cancelled { + LessComputerVoiceOutcome::Cancelled + } else { + LessComputerVoiceOutcome::Failed + }, + None, + ); + let _ = less_computer.abort_capture(session_id); + return if cancelled { + VoiceCaptureLifecycle::cancelled_error() + } else { + BackendError::new(error.code, crate::less_computer::VOICE_CAPTURE_FAILED) + .retryable(error.retryable) + }; + } + let public = fail_less_voice_capture(less_computer, session_id, error).await; + feedback.settle( + if public.code == BackendErrorCode::Cancelled { + LessComputerVoiceOutcome::Cancelled + } else { + LessComputerVoiceOutcome::Failed + }, + None, + ); + public +} + impl VoiceTranscriptionSession { pub fn session_id(&self) -> SessionId { self.session_id @@ -1360,7 +1578,10 @@ impl BackendRepositories { .unwrap_or_else(|_| StylePackStore::in_memory()), ); let mut preference_snapshot = preferences.get(); - if sync_style_pack_preferences(&mut preference_snapshot, &style_packs.list()?) { + if !crate::cloud_sync_e2ee_store::gate::recovery_pending_for_path( + &data_dir.join("preferences.json"), + ) && sync_style_pack_preferences(&mut preference_snapshot, &style_packs.list()?) + { preferences.set(preference_snapshot)?; } Ok(Self { @@ -1857,6 +2078,7 @@ impl EngineProgressSink for BackendEngineProgress { pub struct OpenLessBackend { config: BackendConfig, + startup_error: Option, deps: BackendDependencies, clock: Arc, events: Arc, @@ -1877,13 +2099,19 @@ pub struct OpenLessBackend { preferences_revision: Arc, settings_write_gate: Arc>, cloud_sync: Option, + encrypted_sync: Option, + encrypted_sync_store: Option>, pending_corrections: Arc>>, edit_observation_generation: Arc, text_insertions: Arc>>, voice_sessions: Arc, + runtime_start_work: Arc, less_computer_voice_controls: Arc>>>, } +#[path = "cloud_sync_e2ee/api.rs"] +mod encrypted_sync_api; + struct HistoryProviderAttribution { asr_provider: Option, asr_model: Option, @@ -2026,6 +2254,75 @@ impl HistoryProviderAttribution { } impl OpenLessBackend { + /// Construct an explicitly unavailable Core so the native shell can show + /// its startup error instead of panicking before any window is visible. + /// This path never opens the user's files or a credential store. + pub fn blocked_startup(mut config: BackendConfig, error: BackendError) -> Self { + if config.data_dir.as_os_str().is_empty() { + config.data_dir = std::path::PathBuf::from("openless-startup-blocked"); + } + let repositories = BackendRepositories { + preferences: Arc::new(PreferencesStore::in_memory()), + history: Arc::new(HistoryStore::at_path(std::path::PathBuf::new())), + activity: Arc::new(ActivityStore::in_memory()), + vocabulary: Arc::new(DictionaryStore::at_path(std::path::PathBuf::new())), + correction_rules: Arc::new(CorrectionRuleStore::at_path(std::path::PathBuf::new())), + style_packs: Arc::new(StylePackStore::in_memory()), + }; + // All fallible external dependencies are absent and the only required + // config field was normalized above. A failure here is a code invariant. + let mut backend = + Self::new_with_repositories(config, BackendDependencies::unsupported(), repositories) + .expect("memory-only blocked backend is constructible"); + let denied = error.clone(); + let guard: crate::domains::RuntimeRestoreGuard = Arc::new(move || Err(denied.clone())); + backend + .voice_sessions + .bind_restore_guard(Arc::clone(&guard)) + .expect("fresh voice guard"); + backend + .runtime_start_work + .bind(Arc::clone(&guard), Arc::clone(&backend.deps.task_spawner)) + .expect("fresh startup guard"); + backend + .deps + .services + .selection_voice + .bind_runtime_restore_guard(guard, Arc::clone(&backend.deps.task_spawner)) + .expect("fresh selection guard"); + backend.startup_error = Some(error); + backend + } + + pub fn startup_error(&self) -> Option { + self.startup_error.clone() + } + + pub fn bind_restore_runtime_effects( + &self, + effects: Arc, + ) -> Result<(), BackendError> { + if let Some(store) = &self.encrypted_sync_store { + store.bind_runtime_effects(effects).map_err(|_| { + BackendError::new( + BackendErrorCode::InvalidState, + "restore runtime effects binding failed", + ) + })?; + } + Ok(()) + } + + /// Called after a complete journal target has reached its Host effects. + /// This resets ephemeral interpreters only; it never writes repositories. + pub fn reset_restored_runtime_preferences(&self) { + self.hotkey + .lock() + .expect("hotkey interpreter lock poisoned") + .reset(); + self.disarm_edit_observation(); + } + pub fn new(config: BackendConfig, deps: BackendDependencies) -> Result { if config.data_dir.as_os_str().is_empty() { return Err(BackendError::new( @@ -2079,6 +2376,7 @@ impl OpenLessBackend { let vocabulary_revision = Arc::new(AtomicU64::new(0)); let settings_write_gate = Arc::new(Mutex::new(())); let voice_sessions = Arc::clone(&deps.services.voice_sessions); + let runtime_start_work = Arc::new(crate::voice_session::RuntimeActivityGate::default()); deps.services.selection_voice = Arc::new(crate::selection_voice_service::SelectionVoiceService::new( BackendEventPublisher::new(Arc::clone(&events)), @@ -2181,7 +2479,11 @@ impl OpenLessBackend { Arc::clone(&deps.credential_store), )); } - let cloud_sync = if let Some(marketplace_config) = deps.marketplace_config.take() { + let mut encrypted_sync = None; + let mut encrypted_sync_store = None; + let cloud_sync = if let Some(mut marketplace_config) = deps.marketplace_config.take() { + let github_client_id = marketplace_config.github_client_id.clone(); + let sync_config = marketplace_config.encrypted_sync_config.take(); let marketplace = Arc::new(crate::marketplace::MarketplaceService::new( marketplace_config, Arc::clone(&deps.credential_store), @@ -2191,6 +2493,19 @@ impl OpenLessBackend { Arc::clone(&style_pack_revision), )?); deps.services.marketplace = marketplace.clone(); + if let Some(sync_config) = sync_config { + let (service, store) = crate::cloud_sync_e2ee::build( + sync_config, + &config.data_dir, + repositories.clone(), + Arc::clone(&deps.credential_store), + Arc::clone(&marketplace), + github_client_id, + BackendEventPublisher::new(Arc::clone(&events)), + )?; + encrypted_sync = Some(service); + encrypted_sync_store = Some(store); + } Some(crate::cloud_sync::CloudSyncService::new( marketplace, repositories.clone(), @@ -2269,8 +2584,69 @@ impl OpenLessBackend { deps.services .remote_input .bind_event_publisher(BackendEventPublisher::new(Arc::clone(&events))); + if let Some(store) = &encrypted_sync_store { + let weak_store = Arc::downgrade(store); + let guard: crate::domains::RuntimeRestoreGuard = Arc::new(move || { + let store = weak_store.upgrade().ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidState, + "runtime restore source is unavailable", + ) + })?; + store.ensure_runtime_available().map_err(|_| { + BackendError::new( + BackendErrorCode::Busy, + "encrypted sync restore must finish before starting runtime work", + ) + }) + }); + voice_sessions.bind_restore_guard(Arc::clone(&guard))?; + runtime_start_work.bind(Arc::clone(&guard), Arc::clone(&deps.task_spawner))?; + deps.services + .qa + .bind_runtime_restore_guard(Arc::clone(&guard), Arc::clone(&deps.task_spawner))?; + deps.services + .selection + .bind_runtime_restore_guard(Arc::clone(&guard), Arc::clone(&deps.task_spawner))?; + deps.services + .selection_voice + .bind_runtime_restore_guard(guard, Arc::clone(&deps.task_spawner))?; + let voice = Arc::downgrade(&voice_sessions); + let starts = Arc::downgrade(&runtime_start_work); + let qa = Arc::downgrade(&deps.services.qa); + let selection = Arc::downgrade(&deps.services.selection); + let selection_voice = Arc::downgrade(&deps.services.selection_voice); + // Each probe releases its lock before the next one. Starts check + // Store's already-raised flag while holding the matching lock. + // Both directions are weak, so neither binding owns the backend. + store + .bind_runtime_idle_probe(Arc::new(move || { + voice + .upgrade() + .is_some_and(|gate| gate.runtime_restore_idle()) + && starts + .upgrade() + .is_some_and(|gate| gate.runtime_restore_idle()) + && qa + .upgrade() + .is_some_and(|service| service.runtime_restore_idle()) + && selection + .upgrade() + .is_some_and(|service| service.runtime_restore_idle()) + && selection_voice + .upgrade() + .is_some_and(|service| service.runtime_restore_idle()) + })) + .map_err(|_| { + BackendError::new( + BackendErrorCode::InvalidState, + "failed to bind encrypted sync runtime barrier", + ) + })?; + } Ok(Self { config, + startup_error: None, deps, clock, events, @@ -2300,14 +2676,35 @@ impl OpenLessBackend { preferences_revision, settings_write_gate, cloud_sync, + encrypted_sync, + encrypted_sync_store, pending_corrections: Arc::new(Mutex::new(Vec::new())), edit_observation_generation: Arc::new(AtomicU64::new(0)), text_insertions: Arc::new(Mutex::new(HashMap::new())), voice_sessions, + runtime_start_work, less_computer_voice_controls: Arc::new(Mutex::new(HashMap::new())), }) } + /// Hosts can inspect settings during recovery, but must defer runtime + /// startup effects until this succeeds. Task admission itself is also + /// guarded under Core's runtime locks; this check is not a session lease. + pub fn ensure_runtime_ready(&self) -> Result<(), BackendError> { + if let Some(error) = &self.startup_error { + return Err(error.clone()); + } + if let Some(store) = &self.encrypted_sync_store { + store.ensure_runtime_available().map_err(|_| { + BackendError::new( + BackendErrorCode::Busy, + "encrypted sync restore must finish before starting runtime work", + ) + })?; + } + Ok(()) + } + pub fn repositories(&self) -> BackendRepositories { BackendRepositories { preferences: Arc::clone(&self.preferences), @@ -2377,6 +2774,7 @@ impl OpenLessBackend { /// passed to [`Self::submit_less_computer_with_session`], or released with /// [`Self::abort_less_computer_capture`]. pub fn begin_less_computer_capture(&self, session_id: SessionId) -> Result<(), BackendError> { + let _runtime = self.runtime_start_work.acquire()?; if !self.get_preferences().coding_agent_enabled { return Err(BackendError::new( BackendErrorCode::PermissionDenied, @@ -2420,6 +2818,23 @@ impl OpenLessBackend { session_id: SessionId, recording_control: Arc, ) -> Result { + self.start_less_computer_voice_with( + session_id, + recording_control, + LessComputerVoiceOptions::default(), + ) + .await + } + + /// Same as [`Self::start_less_computer_voice`], with an explicit delivery + /// mode and start-error surface (panel requests report errors inline). + pub async fn start_less_computer_voice_with( + &self, + session_id: SessionId, + recording_control: Arc, + options: LessComputerVoiceOptions, + ) -> Result { + let _runtime = self.runtime_start_work.acquire()?; let preferences = self.get_preferences(); if !preferences.coding_agent_enabled { return Err(BackendError::new( @@ -2436,11 +2851,12 @@ impl OpenLessBackend { } self.deps.services.less_computer.begin_capture(session_id)?; let resources = self.voice_sessions.hold_resources(session_id)?; - let feedback = Arc::new(LessVoiceFeedback { - publisher: self.event_publisher(), + let feedback = Arc::new(LessVoiceFeedback::new( + self.event_publisher(), session_id, - state: Mutex::new((crate::events::LessComputerVoicePhase::Starting, 0)), - }); + options.mode, + crate::events::LessComputerVoicePhase::Starting, + )); feedback.phase(crate::events::LessComputerVoicePhase::Starting); let feedback_guard = LessVoiceFeedbackGuard(Arc::clone(&feedback)); let result = async { @@ -2510,6 +2926,7 @@ impl OpenLessBackend { publisher: self.event_publisher(), session_id, transcript: Mutex::new(crate::types::TranscriptAccumulator::default()), + feedback: Some(Arc::clone(&feedback)), }); let started_at = std::time::Instant::now(); let recording_progress = Arc::new(LessComputerRecordingProgress { @@ -2626,11 +3043,13 @@ impl OpenLessBackend { request, partials, archive_successful_recording: context.recording.archive_successful_recording, + mode: options.mode, + feedback: Arc::clone(&feedback), }) } .await; if let Err(error) = &result { - if error.code != BackendErrorCode::Cancelled { + if options.publish_start_error && error.code != BackendErrorCode::Cancelled { self.event_publisher().publish( Some(session_id), BackendEventKind::LessComputerEvent(crate::events::LessComputerEvent { @@ -2691,6 +3110,7 @@ impl OpenLessBackend { publisher: self.event_publisher(), session_id, transcript: Mutex::new(crate::types::TranscriptAccumulator::default()), + feedback: None, }); let capture = own_voice_start( &self.deps.task_spawner, @@ -2894,6 +3314,7 @@ impl OpenLessBackend { session_id: SessionId, transcript: String, ) -> Result { + let _runtime = self.runtime_start_work.acquire()?; let preferences = self.get_preferences(); if !preferences.coding_agent_enabled { return Err(BackendError::new( @@ -3086,13 +3507,37 @@ impl OpenLessBackend { } pub async fn start(&self) -> Result { + if let Some(error) = &self.startup_error { + return Err(error.clone()); + } + // Native status warms only through the already-bound sync gate. A + // pending journal therefore uses the Host's read-only loader before + // startup recovery/auto-sync tries to capture credentials. + let before = self.get_preferences(); + let warmed = self.deps.credential_store.status(before).await; + if let Some(sync) = &self.encrypted_sync { + // Keep Settings available even after a denied/failed warm read. + // The service records its own retryable recovery/storage error; + // runtime admission remains fenced until recovery is complete. + let _ = sync.start(Arc::clone(&self.deps.task_spawner)).await; + } let preferences = self.get_preferences(); - let credentials = match self.deps.credential_store.status(preferences.clone()).await { + let status = match warmed { + // Recovery can replace both preferences and credentials. Recompute + // from the new in-memory target after it has settled. + Ok(_) if self.encrypted_sync.is_some() => { + self.deps.credential_store.status(preferences.clone()).await + } + Ok(credentials) => Ok(credentials), + Err(error) => Err(error), + }; + let credentials = match status { Ok(credentials) => credentials, - Err(error) if error.code == BackendErrorCode::Persistence => { - // Vault unreadable (e.g. Android Keystore temporarily unavailable) - // must not fail the 2.0 handshake. Dictation still gates on read(). - log::warn!("[core] startup credential status unavailable: {error}"); + Err(error) => { + log::warn!( + "[core] startup credential status unavailable: {:?}", + error.code + ); CredentialsStatus { pipeline_mode: crate::shared_types::effective_pipeline_mode( preferences.multimodal_pipeline_enabled, @@ -3101,7 +3546,6 @@ impl OpenLessBackend { ..CredentialsStatus::default() } } - Err(error) => return Err(error), }; let mut state = self.state.write().expect("backend state lock poisoned"); state.credentials = credentials; @@ -3136,6 +3580,9 @@ impl OpenLessBackend { } pub async fn shutdown(&self) -> Result<(), BackendError> { + if let Some(sync) = &self.encrypted_sync { + sync.shutdown().await; + } let (active_session, preserve_quick_note) = { let mut state = self.state.write().expect("backend state lock poisoned"); if !state.running { @@ -3399,10 +3846,12 @@ impl OpenLessBackend { }; let preferences = self.get_preferences(); let mode = preferences.hotkey.mode; - let modifier_only = crate::hotkey_interpreter::modifier_arbitration_required( - crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey), - mode, - ); + let modifier_only = + crate::shortcut_types::is_modifier_chord_binding(&preferences.dictation_hotkey) + || crate::hotkey_interpreter::modifier_arbitration_required( + crate::shortcut_types::legacy_modifier_trigger(&preferences.dictation_hotkey), + mode, + ); let (intent, reservation) = { let mut hotkey = self .hotkey @@ -3951,6 +4400,7 @@ impl OpenLessBackend { options: crate::SettingsUpdateOptions, runtime: &R, ) -> Result { + self.ensure_runtime_ready()?; let _write_guard = self .settings_write_gate .lock() @@ -7225,7 +7675,9 @@ mod tests { BackendErrorCode::InvalidArgument ); session.feed_pcm(&[1, 0, 2, 0]).unwrap(); - let result = session.finish().await.unwrap(); + let LessComputerVoiceFinish::Submitted(result) = session.finish().await.unwrap() else { + panic!("hotkey-mode capture must submit an Agent turn"); + }; assert_eq!(result.session_id, session_id); assert_eq!(*transcription.pcm.lock().unwrap(), vec![1, 0, 2, 0]); @@ -7248,87 +7700,421 @@ mod tests { assert_eq!(transcription.starts.load(Ordering::Acquire), 1); } - #[tokio::test] - async fn qa_voice_capture_owns_recorder_and_transcription_lifecycle() { - let data_dir = TestDataDir::new("qa-voice-capture"); - let recorder = Arc::new( - crate::testing::FixtureAudioRecorder::new(vec![vec![1, 0, 2, 0]], vec![]) - .with_archived_recording(true), - ); - let transcription = Arc::new(crate::testing::FixtureTranscriptionEngine::successful( - "voice question", - 120, - )); - let engine = crate::PipelineDictationEngine::new( - recorder.clone(), - transcription.clone(), - Arc::new(crate::testing::FixtureTextPolisher::successful("unused")), - ); - let backend = OpenLessBackend::new( - BackendConfig { - data_dir: data_dir.path().to_path_buf(), - ..BackendConfig::default() - }, - BackendDependencies { - dictation_engine: Arc::new(engine), - ..BackendDependencies::unsupported() - }, - ) - .unwrap(); - - let first_id = SessionId::new(); - backend - .voice_sessions - .acquire(first_id, crate::voice_session::VoiceSessionKind::Qa) - .unwrap(); - let capture = backend - .start_qa_voice_capture( - first_id, - DictationStartOptions::default(), - Arc::new(VoiceRecordingProgress), - ) - .await - .unwrap(); - let result = capture.finish().await.unwrap(); - backend.voice_sessions.release(first_id); - - assert_eq!(result.transcript.as_deref(), Some("voice question")); - assert!(result.audio_wav.is_none()); - assert_eq!(result.duration_ms, 120); - assert_eq!(recorder.stop_count(), 1); - assert_eq!(transcription.pcm(), vec![1, 0, 2, 0]); + struct DictationTranscription { + text: String, + partials: Mutex>>, + fail_start: std::sync::atomic::AtomicBool, + } - let mut preferences = backend.get_preferences(); - preferences.multimodal_pipeline_enabled = true; - preferences.pipeline_mode = crate::shared_types::PipelineMode::Multimodal; - backend.set_preferences(preferences).unwrap(); - let second_id = SessionId::new(); - backend - .voice_sessions - .acquire(second_id, crate::voice_session::VoiceSessionKind::Qa) - .unwrap(); - let capture = backend - .start_qa_voice_capture( - second_id, - DictationStartOptions::default(), - Arc::new(VoiceRecordingProgress), - ) - .await - .unwrap(); - let result = capture.finish().await.unwrap(); - assert!(result.transcript.is_none()); - assert!(result.audio_wav.is_some_and(|wav| wav.starts_with(b"RIFF"))); - assert_eq!(recorder.stop_count(), 2); + impl crate::ports::AudioConsumer for DictationTranscription { + fn consume_pcm_chunk(&self, _pcm: &[u8]) {} } - #[tokio::test] - async fn qa_and_selection_voice_capture_can_cancel_during_transcription_finish() { - struct PendingTranscription { - entered: Arc, - gate: Arc, - cancellations: std::sync::atomic::AtomicUsize, - } - impl crate::ports::AudioConsumer for PendingTranscription { + impl crate::ports::TranscriptionSession for DictationTranscription { + fn finish(&self) -> BoxFuture<'static, Result> { + let text = self.text.clone(); + boxed(async move { + Ok(crate::TranscriptOutput { + text, + duration_ms: 100, + }) + }) + } + + fn cancel(&self) -> BoxFuture<'static, Result<(), BackendError>> { + boxed(async { Ok(()) }) + } + } + + struct DictationOnlyEngine(Arc); + + impl DictationEngine for DictationOnlyEngine { + fn start( + &self, + _session_id: SessionId, + _context: Arc, + _progress: Arc, + ) -> BoxFuture<'static, Result<(), BackendError>> { + boxed(async { Ok(()) }) + } + + fn start_transcription( + &self, + _session_id: SessionId, + _context: Arc, + partials: Arc, + ) -> BoxFuture<'static, Result, BackendError>> { + *self.0.partials.lock().unwrap() = Some(partials); + let session: Arc = self.0.clone(); + boxed(async move { Ok(session) }) + } + + fn start_voice_capture( + &self, + _session_id: SessionId, + _context: Arc, + _partials: Arc, + _progress: Arc, + _cancel: crate::CancellationToken, + ) -> BoxFuture<'static, Result> { + let error = if self.0.fail_start.load(Ordering::Acquire) { + BackendError::new( + BackendErrorCode::PermissionDenied, + "microphone permission denied", + ) + } else { + BackendError::new(BackendErrorCode::Unsupported, "use the transcription path") + }; + boxed(async move { Err(error) }) + } + + fn finish( + &self, + _session_id: SessionId, + _progress: Arc, + ) -> BoxFuture<'static, Result> { + boxed(async { unreachable!("dictation-only engine does not run dictation") }) + } + + fn cancel(&self, _session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + boxed(async { Ok(()) }) + } + } + + fn dictation_backend( + name: &str, + text: &str, + ) -> ( + TestDataDir, + OpenLessBackend, + Arc, + Arc, + ) { + let data_dir = TestDataDir::new(name); + let transcription = Arc::new(DictationTranscription { + text: text.into(), + partials: Mutex::new(None), + fail_start: std::sync::atomic::AtomicBool::new(false), + }); + let runtime = Arc::new(LessComputerCaptureRuntime::default()); + let dependencies = BackendDependencies { + host_actions: Arc::new(crate::testing::RecordingHostActions::default()), + dictation_engine: Arc::new(DictationOnlyEngine(Arc::clone(&transcription))), + ..BackendDependencies::unsupported() + }; + dependencies.services.less_computer.bind_runner(Arc::new( + crate::coding_agent::CodingAgentRunner::new(runtime.clone()), + )); + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().to_path_buf(), + ..BackendConfig::default() + }, + dependencies, + ) + .unwrap(); + let mut preferences = backend.get_preferences(); + preferences.coding_agent_enabled = true; + backend.set_preferences(preferences).unwrap(); + (data_dir, backend, transcription, runtime) + } + + const DICTATE: LessComputerVoiceOptions = LessComputerVoiceOptions { + mode: crate::events::LessComputerVoiceMode::Dictate, + publish_start_error: false, + }; + + fn less_computer_event_kinds( + events: &mut crate::events::EventSubscription, + ) -> Vec { + std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event.kind { + BackendEventKind::LessComputerEvent(event) => Some(event.kind), + _ => None, + }) + .collect() + } + + fn last_voice_state( + kinds: &[crate::events::LessComputerEventKind], + ) -> &crate::events::LessComputerEventKind { + kinds + .iter() + .rev() + .find(|kind| { + matches!( + kind, + crate::events::LessComputerEventKind::VoiceState { .. } + ) + }) + .expect("voice state was published") + } + + #[tokio::test] + async fn less_computer_dictation_streams_partials_and_commits_without_submitting() { + use crate::events::{ + LessComputerEventKind, LessComputerVoiceMode, LessComputerVoiceOutcome, + LessComputerVoicePhase, + }; + let (_data_dir, backend, transcription, runtime) = + dictation_backend("less-computer-dictation", " 打开设置 "); + let mut events = backend.subscribe(); + let session_id = SessionId::new(); + let session = backend + .start_less_computer_voice_with( + session_id, + Arc::new(FakeRecordingControl::default()), + DICTATE, + ) + .await + .unwrap(); + assert_eq!(session.mode(), LessComputerVoiceMode::Dictate); + let partials = transcription.partials.lock().unwrap().clone().unwrap(); + partials + .publish(crate::ports::TextStreamChunk { + text: "打开".into(), + offset: 0, + }) + .unwrap(); + session.feed_pcm(&[1, 0]).unwrap(); + + assert_eq!( + session.finish().await.unwrap(), + LessComputerVoiceFinish::Dictated { + transcript: "打开设置".into() + } + ); + assert!(runtime.request.lock().unwrap().is_none()); + assert_eq!(backend.less_computer_active_session(), None); + + let kinds = less_computer_event_kinds(&mut events); + assert!(kinds.iter().any(|kind| matches!( + kind, + LessComputerEventKind::VoiceState { + phase: LessComputerVoicePhase::Starting, + mode: LessComputerVoiceMode::Dictate, + transcript, + outcome: None, + .. + } if transcript == "打开" + ))); + assert!(!kinds.iter().any(|kind| matches!( + kind, + LessComputerEventKind::User { .. } + | LessComputerEventKind::Started + | LessComputerEventKind::Error { .. } + ))); + let committed = LessComputerEventKind::VoiceState { + session_id, + phase: LessComputerVoicePhase::Idle, + level: 0.0, + elapsed_ms: 0, + mode: LessComputerVoiceMode::Dictate, + transcript: "打开设置".into(), + outcome: Some(LessComputerVoiceOutcome::Committed), + }; + assert_eq!(last_voice_state(&kinds), &committed); + assert_eq!( + backend + .event_publisher() + .latest_less_computer_voice_state() + .unwrap() + .kind, + committed + ); + + // The composer can send the edited text immediately after dictation. + backend + .submit_less_computer("打开设置并截图".into()) + .await + .unwrap(); + assert!(runtime.request.lock().unwrap().is_some()); + } + + #[tokio::test] + async fn less_computer_dictation_reports_silence_and_cancel_without_chat_errors() { + use crate::events::{LessComputerEventKind, LessComputerVoiceOutcome}; + let (_data_dir, backend, transcription, runtime) = + dictation_backend("less-computer-dictation-empty", " "); + let mut events = backend.subscribe(); + let silent = backend + .start_less_computer_voice_with( + SessionId::new(), + Arc::new(FakeRecordingControl::default()), + DICTATE, + ) + .await + .unwrap(); + assert_eq!( + silent.finish().await.unwrap(), + LessComputerVoiceFinish::Dictated { + transcript: String::new() + } + ); + let kinds = less_computer_event_kinds(&mut events); + assert!(!kinds + .iter() + .any(|kind| matches!(kind, LessComputerEventKind::Error { .. }))); + assert!(matches!( + last_voice_state(&kinds), + LessComputerEventKind::VoiceState { + outcome: Some(LessComputerVoiceOutcome::Empty), + .. + } + )); + assert_eq!(backend.less_computer_active_session(), None); + + let abandoned = backend + .start_less_computer_voice_with( + SessionId::new(), + Arc::new(FakeRecordingControl::default()), + DICTATE, + ) + .await + .unwrap(); + transcription + .partials + .lock() + .unwrap() + .clone() + .unwrap() + .publish(crate::ports::TextStreamChunk { + text: "draft".into(), + offset: 0, + }) + .unwrap(); + abandoned.cancel().await.unwrap(); + let kinds = less_computer_event_kinds(&mut events); + assert!(matches!( + last_voice_state(&kinds), + LessComputerEventKind::VoiceState { + outcome: Some(LessComputerVoiceOutcome::Cancelled), + transcript, + .. + } if transcript.is_empty() + )); + assert!(runtime.request.lock().unwrap().is_none()); + assert_eq!(backend.less_computer_active_session(), None); + } + + #[tokio::test] + async fn less_computer_panel_start_errors_are_returned_not_published() { + let (_data_dir, backend, transcription, _runtime) = + dictation_backend("less-computer-dictation-start-error", "unused"); + transcription.fail_start.store(true, Ordering::Release); + let has_chat_error = |kinds: Vec| { + kinds + .iter() + .any(|kind| matches!(kind, crate::events::LessComputerEventKind::Error { .. })) + }; + + let mut events = backend.subscribe(); + let error = match backend + .start_less_computer_voice_with( + SessionId::new(), + Arc::new(FakeRecordingControl::default()), + DICTATE, + ) + .await + { + Err(error) => error, + Ok(_) => panic!("the engine refused to start"), + }; + assert_eq!(error.code, BackendErrorCode::PermissionDenied); + assert!(!has_chat_error(less_computer_event_kinds(&mut events))); + assert_eq!(backend.less_computer_active_session(), None); + + assert!(backend + .start_less_computer_voice(SessionId::new(), Arc::new(FakeRecordingControl::default())) + .await + .is_err()); + assert!( + has_chat_error(less_computer_event_kinds(&mut events)), + "hotkey captures keep reporting start failures in the conversation" + ); + } + + #[tokio::test] + async fn qa_voice_capture_owns_recorder_and_transcription_lifecycle() { + let data_dir = TestDataDir::new("qa-voice-capture"); + let recorder = Arc::new( + crate::testing::FixtureAudioRecorder::new(vec![vec![1, 0, 2, 0]], vec![]) + .with_archived_recording(true), + ); + let transcription = Arc::new(crate::testing::FixtureTranscriptionEngine::successful( + "voice question", + 120, + )); + let engine = crate::PipelineDictationEngine::new( + recorder.clone(), + transcription.clone(), + Arc::new(crate::testing::FixtureTextPolisher::successful("unused")), + ); + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().to_path_buf(), + ..BackendConfig::default() + }, + BackendDependencies { + dictation_engine: Arc::new(engine), + ..BackendDependencies::unsupported() + }, + ) + .unwrap(); + + let first_id = SessionId::new(); + backend + .voice_sessions + .acquire(first_id, crate::voice_session::VoiceSessionKind::Qa) + .unwrap(); + let capture = backend + .start_qa_voice_capture( + first_id, + DictationStartOptions::default(), + Arc::new(VoiceRecordingProgress), + ) + .await + .unwrap(); + let result = capture.finish().await.unwrap(); + backend.voice_sessions.release(first_id); + + assert_eq!(result.transcript.as_deref(), Some("voice question")); + assert!(result.audio_wav.is_none()); + assert_eq!(result.duration_ms, 120); + assert_eq!(recorder.stop_count(), 1); + assert_eq!(transcription.pcm(), vec![1, 0, 2, 0]); + + let mut preferences = backend.get_preferences(); + preferences.multimodal_pipeline_enabled = true; + preferences.pipeline_mode = crate::shared_types::PipelineMode::Multimodal; + backend.set_preferences(preferences).unwrap(); + let second_id = SessionId::new(); + backend + .voice_sessions + .acquire(second_id, crate::voice_session::VoiceSessionKind::Qa) + .unwrap(); + let capture = backend + .start_qa_voice_capture( + second_id, + DictationStartOptions::default(), + Arc::new(VoiceRecordingProgress), + ) + .await + .unwrap(); + let result = capture.finish().await.unwrap(); + assert!(result.transcript.is_none()); + assert!(result.audio_wav.is_some_and(|wav| wav.starts_with(b"RIFF"))); + assert_eq!(recorder.stop_count(), 2); + } + + #[tokio::test] + async fn qa_and_selection_voice_capture_can_cancel_during_transcription_finish() { + struct PendingTranscription { + entered: Arc, + gate: Arc, + cancellations: std::sync::atomic::AtomicUsize, + } + impl crate::ports::AudioConsumer for PendingTranscription { fn consume_pcm_chunk(&self, _pcm: &[u8]) {} } impl TranscriptionSession for PendingTranscription { @@ -7412,6 +8198,7 @@ mod tests { publisher: backend.event_publisher(), session_id: SessionId::new(), transcript: Mutex::new(crate::types::TranscriptAccumulator::default()), + feedback: None, }), lifecycle: Arc::new(VoiceCaptureLifecycle::default()), task_spawner: Arc::new(TokioTaskSpawner), @@ -7529,11 +8316,12 @@ mod tests { let started_at = std::time::Instant::now(); let progress = LessComputerRecordingProgress { session_id: stop_session, - feedback: Arc::new(LessVoiceFeedback { - publisher: backend.event_publisher(), - session_id: stop_session, - state: Mutex::new((crate::events::LessComputerVoicePhase::Recording, 0)), - }), + feedback: Arc::new(LessVoiceFeedback::new( + backend.event_publisher(), + stop_session, + crate::events::LessComputerVoiceMode::Submit, + crate::events::LessComputerVoicePhase::Recording, + )), less_computer: Arc::clone(&backend.services().less_computer), control: Arc::clone(&control) as Arc, task_spawner: Arc::new(TokioTaskSpawner), @@ -7576,11 +8364,12 @@ mod tests { .unwrap(); let fault_progress = LessComputerRecordingProgress { session_id: fault_session, - feedback: Arc::new(LessVoiceFeedback { - publisher: backend.event_publisher(), - session_id: fault_session, - state: Mutex::new((crate::events::LessComputerVoicePhase::Recording, 0)), - }), + feedback: Arc::new(LessVoiceFeedback::new( + backend.event_publisher(), + fault_session, + crate::events::LessComputerVoiceMode::Submit, + crate::events::LessComputerVoicePhase::Recording, + )), less_computer: Arc::clone(&backend.services().less_computer), control: Arc::clone(&control) as Arc, task_spawner: Arc::new(TokioTaskSpawner), @@ -9447,7 +10236,19 @@ mod tests { } } backend.cancel_dictation(Some(first)).await.unwrap(); - let second = backend.start_dictation().await.unwrap(); + let second = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + match backend.start_dictation().await { + Ok(id) => break id, + Err(error) if error.code == BackendErrorCode::Busy => { + tokio::task::yield_now().await; + } + Err(error) => panic!("unexpected start failure: {error}"), + } + } + }) + .await + .expect("successor dictation should become available after cancel"); release_guard.release(); settings.join().unwrap().unwrap(); stopping.await.unwrap().unwrap(); @@ -12735,6 +13536,64 @@ mod tests { assert!(!saw_recording_after_cancel); } + #[tokio::test] + async fn modifier_chord_companion_does_not_start_dictation_engine() { + for mode in [ + crate::shared_types::HotkeyMode::Hold, + crate::shared_types::HotkeyMode::Auto, + crate::shared_types::HotkeyMode::Toggle, + ] { + let data_dir = TestDataDir::new("modifier-chord-companion"); + let engine = crate::testing::FixtureDictationEngine::successful("raw", "polished"); + let backend = backend_with_dictation_engine( + data_dir.path().to_path_buf(), + Arc::new(engine.clone()), + ); + backend.start().await.unwrap(); + let mut preferences = backend.get_preferences(); + preferences.hotkey.mode = mode; + preferences.dictation_hotkey = crate::shared_types::ShortcutBinding { + primary: "ModifierChord".into(), + modifiers: vec!["ctrl-left".into(), "cmd-left".into()], + }; + backend.set_preferences(preferences).unwrap(); + + let at = std::time::Instant::now(); + let mut press = std::pin::pin!(backend + .dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Pressed { press_id: 1, at })); + // Poll the real public API until it waits. A companion key arrives + // before the grace expires, so even native startup must stay idle. + assert!(futures_util::poll!(press.as_mut()).is_pending()); + assert_eq!(backend.snapshot().dictation.phase, DictationPhase::Idle); + assert!(engine.actions().is_empty()); + assert_eq!( + backend + .dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Combined { + press_id: 1, + at: at + std::time::Duration::from_millis(1), + }) + .await + .unwrap(), + CliDispatchOutcome::Noop + ); + assert_eq!(press.await.unwrap(), CliDispatchOutcome::Noop); + assert!(engine.actions().is_empty()); + + // A later chord without a companion remains a working trigger. + assert!(matches!( + backend + .dispatch_dictation_hotkey_edge(DictationHotkeyEdge::Pressed { + press_id: 2, + at: at + std::time::Duration::from_secs(1), + }) + .await + .unwrap(), + CliDispatchOutcome::DictationStarted(_) + )); + backend.cancel_dictation(None).await.unwrap(); + } + } + #[tokio::test] async fn hotkey_combined_edge_cancels_the_same_generation_during_start_await() { let entered = Arc::new(tokio::sync::Notify::new()); @@ -12957,4 +13816,340 @@ mod tests { backend.shutdown().await.unwrap(); let _ = std::fs::remove_dir_all(data_dir); } + fn runtime_restore_backend() -> TestBackend { + runtime_restore_backend_with_polisher(Arc::new( + crate::testing::FixtureTextPolisher::successful("fixture"), + )) + } + + fn runtime_restore_backend_with_polisher( + polisher: Arc, + ) -> TestBackend { + let data_dir = TestDataDir::new("runtime-restore"); + let mut deps = BackendDependencies::unsupported(); + deps.dictation_engine = Arc::new(FakeEngine); + deps.credential_store = Arc::new(crate::credentials::InMemoryCredentialStore::default()); + deps.selection_runtime = Some(Arc::new( + crate::testing::FixtureSelectionRuntime::successful( + crate::domains::SelectionCapture { + text: "fixture".into(), + source_app: None, + }, + InsertOutcome::Inserted, + ), + )); + deps.selection_polisher = Some(polisher); + deps.marketplace_config = Some( + crate::marketplace::MarketplaceConfig::new("https://market.example") + .unwrap() + .with_encrypted_sync(crate::cloud_sync_e2ee::EncryptedSyncConfig { + service_origin: "https://sync.example".into(), + app_version: "runtime-test".into(), + }), + ); + let backend = OpenLessBackend::new( + BackendConfig { + data_dir: data_dir.path().into(), + ..BackendConfig::default() + }, + deps, + ) + .unwrap(); + // This fixture exercises local admission only; do not start the network sync worker. + backend.state.write().unwrap().running = true; + TestBackend { + backend, + _data_dir: data_dir, + } + } + + #[tokio::test] + async fn runtime_restore_actual_store_recovery_rejects_active_voice_before_repository_mutation() + { + use crate::cloud_sync_e2ee_documents::DocumentError; + let backend = runtime_restore_backend(); + backend.ensure_runtime_ready().unwrap(); + let mut preferences = backend.get_preferences(); + preferences.coding_agent_enabled = true; + backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + let owner = SessionId::new(); + backend.begin_less_computer_capture(owner).unwrap(); + let gate = crate::cloud_sync_e2ee_store::gate::open_for_data_dir(backend._data_dir.path()) + .unwrap(); + drop(gate.begin_mutation().unwrap()); // model an interrupted save that requires reconciliation + let before = std::fs::read( + backend + ._data_dir + .path() + .join("encrypted-sync/generation.json"), + ) + .unwrap(); + let store = backend.encrypted_sync_store.as_ref().unwrap(); + assert_eq!( + store.recover_registered().await.unwrap_err(), + DocumentError::RuntimeBusy + ); + assert_eq!( + std::fs::read( + backend + ._data_dir + .path() + .join("encrypted-sync/generation.json") + ) + .unwrap(), + before + ); + assert_eq!( + backend.ensure_runtime_ready().unwrap_err().code, + BackendErrorCode::Busy + ); + assert!( + backend.get_preferences().coding_agent_enabled, + "settings remain readable for recovery UI" + ); + backend.abort_less_computer_capture(owner).unwrap(); + } + + #[tokio::test] + async fn runtime_restore_constructor_guard_blocks_all_configured_runtime_starts_on_recovery_latch( + ) { + let backend = runtime_restore_backend(); + let gate = crate::cloud_sync_e2ee_store::gate::open_for_data_dir(backend._data_dir.path()) + .unwrap(); + drop(gate.begin_mutation().unwrap()); + assert_eq!( + backend.start_dictation().await.unwrap_err().code, + BackendErrorCode::Busy + ); + assert_eq!( + backend + .begin_less_computer_capture(SessionId::new()) + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + assert_eq!( + backend + .submit_less_computer("fixture".into()) + .await + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + assert_eq!( + backend + .services() + .selection + .begin_polish(crate::domains::SelectionPolishRequest { + selected_text: Some("fixture".into()), + mode: crate::PolishMode::Raw, + instruction: None, + }) + .await + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + assert_eq!( + backend + .services() + .selection_voice + .begin(crate::domains::SelectionCapture { + text: "fixture".into(), + source_app: None + }) + .await + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + assert_eq!(backend.snapshot().dictation.phase, DictationPhase::Idle); + } + + #[tokio::test] + async fn runtime_restore_cancelled_apply_ticket_stays_busy_until_native_completion() { + let (backend, _) = backend(); + let service = &backend.services().selection_voice; + let session = service + .begin(crate::domains::SelectionCapture { + text: "fixture".into(), + source_app: None, + }) + .await + .unwrap(); + service.mark_processing(session).await.unwrap(); + service + .set_preview(crate::domains::SelectionVoicePreviewUpdate { + session_id: session, + owner_session_id: None, + text: "replacement".into(), + summary: None, + }) + .await + .unwrap(); + let ticket = service + .begin_preview_apply(None, "replacement".into()) + .unwrap(); + service.cancel(Some(session)).await.unwrap(); + assert!( + !service.runtime_restore_idle(), + "cancel does not complete a native apply ticket" + ); + assert_eq!( + service + .finish_preview_apply( + ticket.ticket_id, + crate::domains::SelectionVoiceApplyOutcome::Failed + ) + .await + .unwrap_err() + .code, + BackendErrorCode::Cancelled + ); + assert!(service.runtime_restore_idle()); + } + + #[test] + fn runtime_restore_bindings_do_not_keep_store_or_services_alive() { + let backend = runtime_restore_backend(); + let store = Arc::downgrade(backend.encrypted_sync_store.as_ref().unwrap()); + let voice = Arc::downgrade(&backend.voice_sessions); + let qa = Arc::downgrade(&backend.services().qa); + let selection_voice = Arc::downgrade(&backend.services().selection_voice); + drop(backend); + assert!(store.upgrade().is_none()); + assert!(voice.upgrade().is_none()); + assert!(qa.upgrade().is_none()); + assert!(selection_voice.upgrade().is_none()); + } + struct RestoreCancelPolisher { + entered: Arc, + release: Arc, + drained: Arc, + } + impl crate::ports::TextPolisher for RestoreCancelPolisher { + fn polish( + &self, + _: SessionId, + _: Arc, + _: String, + _: Arc, + ) -> BoxFuture<'static, Result> { + Box::pin(async { Ok(crate::ports::PolishOutput::text("fixture")) }) + } + fn cancel(&self, _: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + let entered = self.entered.clone(); + let release = self.release.clone(); + let drained = self.drained.clone(); + Box::pin(async move { + entered.notify_one(); + release.notified().await; + drained.notify_one(); + Ok(()) + }) + } + } + + #[tokio::test] + async fn runtime_restore_selection_cancel_retains_lease_after_waiter_drop() { + let polisher = Arc::new(RestoreCancelPolisher { + entered: Arc::new(tokio::sync::Notify::new()), + release: Arc::new(tokio::sync::Notify::new()), + drained: Arc::new(tokio::sync::Notify::new()), + }); + let backend = runtime_restore_backend_with_polisher(polisher.clone()); + let mut preferences = backend.get_preferences(); + preferences.selection_polish_output_mode = + crate::shared_types::SelectionPolishOutputMode::PreviewConfirm; + backend + .update_settings( + preferences, + crate::SettingsUpdateOptions::STRICT, + &crate::NoopSettingsRuntime, + ) + .unwrap(); + let selection = backend.services().selection.clone(); + selection + .begin_polish(crate::domains::SelectionPolishRequest { + selected_text: Some("fixture".into()), + mode: crate::PolishMode::Raw, + instruction: None, + }) + .await + .unwrap(); + let cancel = tokio::spawn(selection.cancel(None)); + polisher.entered.notified().await; + assert_eq!( + selection.snapshot().await.unwrap().phase, + crate::domains::SelectionPhase::Cancelled + ); + assert!(!selection.runtime_restore_idle()); + cancel.abort(); + assert!(cancel.await.unwrap_err().is_cancelled()); + assert!(!selection.runtime_restore_idle()); + polisher.release.notify_one(); + polisher.drained.notified().await; + for _ in 0..4 { + tokio::task::yield_now().await; + } + assert!(selection.runtime_restore_idle()); + } + #[tokio::test] + async fn restore_host_blocked_startup_is_visible_and_never_opens_or_mutates_real_stores() { + struct ForbiddenRuntime; + impl crate::SettingsRuntime for ForbiddenRuntime { + fn prepare( + &self, + _: &crate::SettingsEffectPlan, + ) -> Result { + panic!("blocked startup must not run Host effects") + } + } + let data_dir = TestDataDir::new("blocked-startup"); + let error = BackendError::new(BackendErrorCode::Persistence, "local storage access denied"); + let backend = OpenLessBackend::blocked_startup( + BackendConfig { + data_dir: data_dir.path().into(), + ..BackendConfig::default() + }, + error, + ); + assert!(!data_dir.path().exists()); + assert_eq!( + backend.start().await.unwrap_err().code, + BackendErrorCode::Persistence + ); + assert!(!backend.snapshot().running); + assert_eq!( + backend.ensure_runtime_ready().unwrap_err().code, + BackendErrorCode::Persistence + ); + let mut target = backend.get_preferences(); + target.launch_at_login = true; + assert_eq!( + backend + .update_settings( + target, + crate::SettingsUpdateOptions::STRICT, + &ForbiddenRuntime + ) + .unwrap_err() + .code, + BackendErrorCode::Persistence + ); + assert_eq!( + backend + .begin_less_computer_capture(SessionId::new()) + .unwrap_err() + .code, + BackendErrorCode::Persistence + ); + assert!(!data_dir.path().exists()); + } } 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 dec6ac09b..5a52a88ad 100644 --- a/openless-all/app/crates/openless-core/src/cloud_providers.rs +++ b/openless-all/app/crates/openless-core/src/cloud_providers.rs @@ -78,6 +78,7 @@ pub const SHARED_CLOUD_LLM_PROVIDER_TYPES: &[&str] = &[ "mimo", "cometapi", "openrouterFree", + "requesty", "orcarouter", "alibabaCoding", "codingPlanX", diff --git a/openless-all/app/crates/openless-core/src/cloud_sync.rs b/openless-all/app/crates/openless-core/src/cloud_sync.rs index 7b1865671..4a7a6c8f8 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync.rs @@ -296,6 +296,19 @@ impl CloudSyncService { } fn restore_local(&self, payload: &CloudSyncPayload) -> Result<(), BackendError> { + // The legacy four-command protocol has no encrypted crash journal or Host-effect + // convergence. It must never bypass an installed E2EE repository write barrier. + if crate::cloud_sync_e2ee_store::gate::gate_for_path( + self.repositories.preferences.persistence_path(), + ) + .map_err(|error| BackendError::new(BackendErrorCode::InvalidState, error.to_string()))? + .is_some() + { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "当前仓库暂不支持旧版云端恢复,请使用加密云同步的确认恢复流程。", + )); + } validate_payload(payload).map_err(|_| invalid_remote())?; let mut next_packs = validated_native_packs(payload)?; let dictionary: Vec = payload @@ -627,3 +640,94 @@ fn validate_base_revision(revision: u64) -> Result<(), BackendError> { Ok(()) } } + +#[cfg(test)] +mod encrypted_gate_tests { + use super::*; + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("legacy-sync-gate-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + fn service(repositories: BackendRepositories) -> CloudSyncService { + let marketplace = MarketplaceService::new( + crate::MarketplaceConfig::new("https://market.example.test").unwrap(), + Arc::new(crate::InMemoryCredentialStore::default()), + repositories.preferences.clone(), + repositories.style_packs.clone(), + crate::events::BackendEventPublisher::new(Arc::new(crate::events::EventBus::new(8))), + Arc::new(std::sync::atomic::AtomicU64::new(0)), + ) + .unwrap(); + CloudSyncService::new( + Arc::new(marketplace), + repositories, + Arc::new(Mutex::new(())), + ) + } + #[test] + fn registered_encrypted_repositories_reject_legacy_restore_even_when_gate_is_idle() { + for hold_exclusive in [false, true] { + let temp = Temp::new(); + let gate = crate::cloud_sync_e2ee_store::gate::open_for_data_dir(&temp.0).unwrap(); + let repositories = BackendRepositories::open(&temp.0).unwrap(); + let sync = service(repositories.clone()); + let mut payload = sync.capture(CloudSyncUiPreferences::default()).unwrap(); + payload.dictionary.push(SyncDictionaryEntry { + id: "legacy-extra".into(), + phrase: "must not persist".into(), + note: None, + enabled: true, + hits: 0, + created_at: "2026-09-26T00:00:00Z".into(), + }); + let generation = gate.generation().unwrap(); + let preferences = std::fs::read(temp.0.join("preferences.json")).ok(); + let styles = std::fs::read(temp.0.join("style-packs.json")).unwrap(); + let _permit = hold_exclusive.then(|| gate.try_exclusive().unwrap()); + assert_eq!( + sync.restore_local(&payload).unwrap_err().code, + BackendErrorCode::Unsupported + ); + assert!(repositories.vocabulary.list().unwrap().is_empty()); + assert_eq!( + std::fs::read(temp.0.join("preferences.json")).ok(), + preferences + ); + assert_eq!( + std::fs::read(temp.0.join("style-packs.json")).unwrap(), + styles + ); + assert_eq!(gate.generation().unwrap(), generation); + } + } + #[test] + fn standalone_legacy_repositories_without_an_encrypted_gate_keep_their_existing_restore() { + let temp = Temp::new(); + let repositories = BackendRepositories::open(&temp.0).unwrap(); + let sync = service(repositories.clone()); + let mut payload = sync.capture(CloudSyncUiPreferences::default()).unwrap(); + payload.dictionary.push(SyncDictionaryEntry { + id: "legacy-extra".into(), + phrase: "standalone restore".into(), + note: None, + enabled: true, + hits: 0, + created_at: "2026-09-26T00:00:00Z".into(), + }); + sync.restore_local(&payload).unwrap(); + assert_eq!( + repositories.vocabulary.list().unwrap()[0].phrase, + "standalone restore" + ); + } +} 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 new file mode 100644 index 000000000..16d826ab2 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs @@ -0,0 +1,248 @@ +use std::{path::Path, sync::Arc}; + +use futures_util::future::BoxFuture; +use sha2::{Digest, Sha256}; + +use crate::cloud_sync_e2ee_documents::{ + CryptoJournalProtector, DocumentError, DocumentResult, ExportedDocuments, JournalProtector, + RestoreContext, SecretJson, SyncScope, ValidatedSyncDocuments, +}; +use crate::cloud_sync_e2ee_protocol::types::{DocumentSet, Revision, SourceDevice}; +use crate::cloud_sync_e2ee_store::{ + extensions::{DeviceExtensionKey, ProtectedExtensionStore}, + gate::SyncChange, + CoreSyncStore, +}; + +use super::{document_error, local::LocalStorage, service::SyncServiceData, SyncResult}; + +pub(crate) fn build( + config: super::EncryptedSyncConfig, + data_dir: &Path, + repositories: crate::BackendRepositories, + credentials: Arc, + marketplace: Arc, + github_client_id: String, + events: crate::events::BackendEventPublisher, +) -> SyncResult<(super::EncryptedSyncService, Arc)> { + let origin = url::Url::parse(&config.service_origin) + .map_err(|_| super::error("unsupported_protocol"))?; + if origin.scheme() != "https" + || !origin.username().is_empty() + || origin.password().is_some() + || origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + || data_dir.as_os_str().is_empty() + { + return Err(super::error("unsupported_protocol")); + } + let origin = origin.origin().ascii_serialization(); + let root = data_dir.join("encrypted-sync"); + std::fs::create_dir_all(&root).map_err(|_| super::error("local_storage_unavailable"))?; + let gate = + crate::cloud_sync_e2ee_store::gate::open_for_data_dir(data_dir).map_err(document_error)?; + let device_id = load_device_id(&root)?; + let device = SourceDevice { + id: device_id.clone(), + os: std::env::consts::OS.into(), + arch: std::env::consts::ARCH.into(), + app_version: config.app_version, + }; + let local = LocalStorage::new( + root.join("protected"), + origin.clone(), + device_id, + credentials.clone(), + ); + let store = Arc::new( + CoreSyncStore::new( + repositories, + credentials, + data_dir.into(), + device, + gate, + Arc::new(local.clone()), + ) + .map_err(document_error)?, + ); + let service = super::EncryptedSyncService::new( + super::SyncServiceConfig { + origin, + github_client_id, + }, + marketplace, + local, + store.clone(), + events, + ); + Ok((service, store)) +} + +pub(crate) fn load_device_id(root: &Path) -> SyncResult { + let device_path = root.join("device-id"); + let device_id = match std::fs::read_to_string(&device_path) { + Ok(value) => value, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + // Never invent another keyring/AAD binding for existing ciphertext. + for entry in + std::fs::read_dir(root).map_err(|_| super::error("local_storage_unavailable"))? + { + let entry = entry.map_err(|_| super::error("local_storage_unavailable"))?; + let path = entry.path(); + if path.is_dir() + && std::fs::read_dir(&path) + .map_err(|_| super::error("local_storage_unavailable"))? + .next() + .is_some() + { + return Err(super::error("recovery_required")); + } + } + let id = uuid::Uuid::new_v4().to_string(); + if super::local::durable_create(&device_path, id.as_bytes())? { + id + } else { + std::fs::read_to_string(&device_path) + .map_err(|_| super::error("local_storage_unavailable"))? + } + } + Err(_) => return Err(super::error("local_storage_unavailable")), + }; + crate::cloud_sync_e2ee_protocol::types::UuidV4::parse(&device_id) + .map_err(|_| super::error("recovery_required"))?; + Ok(device_id) +} + +impl ProtectedExtensionStore for LocalStorage { + fn read_scope(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + self.read(&scope_bucket(&scope)?, "extensions") + .await + .map_err(|_| DocumentError::JournalUnavailable) + }) + } + + fn write_scope( + &self, + scope: SyncScope, + value: SecretJson, + ) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + self.write(&scope_bucket(&scope)?, "extensions", value) + .await + .map_err(|_| DocumentError::JournalUnavailable) + }) + } + + fn read_device( + &self, + key: DeviceExtensionKey, + ) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + if key == DeviceExtensionKey::UiPreferences { + self.read_ui() + .await + .map(|value| value.map(|envelope| envelope.value)) + .map_err(|_| DocumentError::JournalUnavailable) + } else { + self.read("device", key.storage_name()) + .await + .map_err(|_| DocumentError::JournalUnavailable) + } + }) + } + + fn write_device( + &self, + key: DeviceExtensionKey, + value: SecretJson, + ) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + if key == DeviceExtensionKey::UiPreferences { + let envelope = super::local::UiEnvelope { + schema_version: 1, + revision: uuid::Uuid::new_v4().to_string(), + value, + }; + self.write("device", key.storage_name(), envelope) + .await + .map_err(|_| DocumentError::JournalUnavailable) + } else { + self.write("device", key.storage_name(), value) + .await + .map_err(|_| DocumentError::JournalUnavailable) + } + }) + } + + fn read_ui_revision(&self) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + self.read_ui() + .await + .map(|value| value.map(|envelope| envelope.revision)) + .map_err(|_| DocumentError::JournalUnavailable) + }) + } + + fn journal_protector(&self) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + let key = self.key().await.map_err(|_| DocumentError::Locked)?; + Ok(Arc::new(CryptoJournalProtector::new(key)) as Arc) + }) + } +} + +fn scope_bucket(scope: &SyncScope) -> DocumentResult { + // Changing the cloud encryption password keeps the same locally protected + // baseline/tombstone bucket; account or vault changes never share it. + let bytes = serde_json::to_vec(&[ + &scope.service_origin, + &scope.owner_github_id, + &scope.vault_id, + &scope.device_id, + ]) + .map_err(|_| DocumentError::InvalidDocument)?; + Ok(format!("scope:{:x}", Sha256::digest(bytes))) +} + +impl SyncServiceData for CoreSyncStore { + fn export(&self, scope: SyncScope) -> BoxFuture<'_, SyncResult> { + Box::pin(async move { self.export_scope(scope).await.map_err(document_error) }) + } + fn restore( + &self, + desired: ValidatedSyncDocuments, + context: RestoreContext, + ) -> BoxFuture<'_, SyncResult> { + Box::pin(async move { + self.restore_scope(desired, context) + .await + .map_err(document_error) + }) + } + fn recover(&self) -> BoxFuture<'_, SyncResult<()>> { + Box::pin(async move { self.recover_registered().await.map_err(document_error) }) + } + fn baseline( + &self, + scope: SyncScope, + documents: DocumentSet, + revision: Revision, + ) -> BoxFuture<'_, SyncResult<()>> { + Box::pin(async move { + self.record_baseline(scope, documents, revision) + .await + .map_err(document_error) + }) + } + fn generation(&self) -> SyncResult { + CoreSyncStore::generation(self).map_err(document_error) + } + fn device(&self) -> SourceDevice { + CoreSyncStore::device(self) + } + fn changes(&self) -> tokio::sync::watch::Receiver { + CoreSyncStore::changes(self) + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs new file mode 100644 index 000000000..b01b5b66a --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/api.rs @@ -0,0 +1,231 @@ +use super::OpenLessBackend; +use crate::{cloud_sync_e2ee::*, BackendError}; + +impl OpenLessBackend { + pub async fn sign_out_account(&self) -> Result<(), BackendError> { + if let Some(service) = &self.encrypted_sync { + service.sign_out().await.map(|_| ()) + } else { + self.services().marketplace.logout().await + } + } + fn encrypted_sync_service( + &self, + ) -> Result<&crate::cloud_sync_e2ee::EncryptedSyncService, BackendError> { + self.encrypted_sync + .as_ref() + .ok_or_else(|| crate::cloud_sync_e2ee::error("service_unavailable")) + } + + pub fn cloud_sync_e2ee_status(&self) -> Result { + Ok(self.encrypted_sync_service()?.status()) + } + + pub async fn cloud_sync_e2ee_claim_setup_prompt(&self) -> Result { + let Some(service) = self.encrypted_sync.as_ref() else { + return Ok(false); + }; + let Some(store) = self.encrypted_sync_store.as_ref() else { + return Ok(false); + }; + let store = std::sync::Arc::clone(store); + let settings_gate = std::sync::Arc::clone(&self.settings_write_gate); + service + .claim_setup_prompt( + std::sync::Arc::clone(&self.deps.credential_store), + move || { + let _settings = match settings_gate.try_lock() { + Ok(guard) => guard, + Err(std::sync::TryLockError::WouldBlock) => return Ok(None), + Err(std::sync::TryLockError::Poisoned(_)) => { + return Err(crate::cloud_sync_e2ee::error("recovery_required")) + } + }; + store + .setup_prompt_state() + .map_err(crate::cloud_sync_e2ee::document_error) + }, + ) + .await + } + + pub async fn cloud_sync_e2ee_prepare_enable( + &self, + consent_version: String, + ) -> Result { + self.encrypted_sync_service()? + .prepare_enable(consent_version) + .await + } + + pub async fn cloud_sync_e2ee_create( + &self, + password: String, + password_confirmation: String, + remember_key: bool, + consent_version: String, + observed_revision: String, + ) -> Result { + self.encrypted_sync_service()? + .create( + password, + password_confirmation, + remember_key, + consent_version, + observed_revision, + ) + .await + } + + pub async fn cloud_sync_e2ee_unlock( + &self, + password: String, + remember_key: bool, + ) -> Result { + self.encrypted_sync_service()? + .unlock(password, remember_key) + .await + } + + pub async fn cloud_sync_e2ee_lock(&self) -> Result { + self.encrypted_sync_service()?.lock().await + } + + pub async fn cloud_sync_e2ee_set_enabled( + &self, + enabled: bool, + ) -> Result { + self.encrypted_sync_service()?.set_enabled(enabled).await + } + + pub async fn cloud_sync_e2ee_sync_now(&self) -> Result { + self.encrypted_sync_service()?.sync_now().await + } + + pub fn cloud_sync_e2ee_cancel( + &self, + task_id: String, + ) -> Result { + self.encrypted_sync_service()?.cancel(&task_id) + } + + pub async fn cloud_sync_e2ee_preview_restore( + &self, + observed_revision: String, + ) -> Result { + self.encrypted_sync_service()? + .preview_restore(observed_revision) + .await + } + + pub async fn cloud_sync_e2ee_apply_restore( + &self, + preview_id: String, + mode: RestoreMode, + conflict_choices: Vec, + ) -> Result { + self.encrypted_sync_service()? + .apply_restore(preview_id, mode, conflict_choices) + .await + } + + pub async fn cloud_sync_e2ee_change_password( + &self, + current_password: String, + new_password: String, + confirmation: String, + remember_key: bool, + ) -> Result { + self.encrypted_sync_service()? + .change_password(current_password, new_password, confirmation, remember_key) + .await + } + + pub async fn cloud_sync_e2ee_delete_remote( + &self, + expected_vault_id: String, + observed_revision: String, + confirmed: bool, + ) -> Result { + self.encrypted_sync_service()? + .delete_remote(expected_vault_id, observed_revision, confirmed) + .await + } + + pub async fn cloud_sync_e2ee_sign_out(&self) -> Result { + self.encrypted_sync_service()?.sign_out().await + } + + pub async fn cloud_sync_e2ee_begin_sign_in(&self) -> Result { + self.encrypted_sync_service()?.begin_sign_in().await + } + + pub async fn cloud_sync_e2ee_poll_sign_in( + &self, + authorization_session_id: String, + ) -> Result { + self.encrypted_sync_service()? + .poll_sign_in(authorization_session_id) + .await + } + + pub async fn cloud_sync_e2ee_cancel_sign_in( + &self, + authorization_session_id: String, + ) -> Result<(), BackendError> { + self.encrypted_sync_service()? + .cancel_sign_in(authorization_session_id) + .await + } + + pub async fn cloud_sync_e2ee_set_ui_preferences( + &self, + locale: String, + font_scale: String, + ) -> Result<(), BackendError> { + let store = self + .encrypted_sync_store + .as_ref() + .ok_or_else(|| crate::cloud_sync_e2ee::error("service_unavailable"))?; + store + .set_ui_preferences(crate::cloud_sync_e2ee_documents::ui_preferences( + &locale, + &font_scale, + )) + .await + .map_err(crate::cloud_sync_e2ee::document_error) + } + + pub async fn cloud_sync_e2ee_get_ui_preferences( + &self, + ) -> Result, BackendError> { + self.encrypted_sync_service()?.ui_preferences().await + } + + pub async fn cloud_sync_e2ee_get_ui_preferences_snapshot( + &self, + ) -> Result { + self.encrypted_sync_service()? + .ui_preferences_snapshot() + .await + } + + pub async fn cloud_sync_e2ee_set_ui_preferences_checked( + &self, + locale: String, + font_scale: String, + expected_revision: Option, + ) -> Result<(), BackendError> { + let store = self + .encrypted_sync_store + .as_ref() + .ok_or_else(|| crate::cloud_sync_e2ee::error("service_unavailable"))?; + store + .set_ui_preferences_checked( + crate::cloud_sync_e2ee_documents::ui_preferences(&locale, &font_scale), + expected_revision, + ) + .await + .map_err(crate::cloud_sync_e2ee::document_error) + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/dto.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/dto.rs new file mode 100644 index 000000000..30ea132ca --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/dto.rs @@ -0,0 +1,222 @@ +//! Public, value-free sync status. Secret document types never cross IPC. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +pub const CONSENT_VERSION: &str = "encrypted-full-snapshot-v1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthState { + SignedOut, + SignedIn, + Expired, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum KeyState { + Locked, + Unlocked, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncState { + Disabled, + SignInRequired, + UnlockRequired, + Ready, + Pending, + Syncing, + Conflict, + Failed, + OutcomeUnknown, + RecoveryRequired, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncAccount { + pub github_id: String, + pub login: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedSyncSignIn { + pub authorization_session_id: String, + pub user_code: String, + pub verification_uri: String, + pub expires_at: String, + pub interval_seconds: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "status", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum EncryptedSyncSignInResult { + Pending { slow_down: bool }, + SignedIn { account: SyncAccount }, + Denied, + Expired, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncFailure { + pub code: String, + pub retry_after_seconds: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedSyncStatus { + pub sequence: String, + pub enabled: bool, + pub auth_state: AuthState, + pub key_state: KeyState, + pub sync_state: SyncState, + pub account: Option, + pub vault_id: Option, + pub key_id: Option, + pub local_generation: String, + pub last_synced_local_generation: Option, + pub remote_revision: Option, + pub last_successful_sync_at: Option, + pub pending_operation_id: Option, + pub last_error: Option, + pub recovery_required: bool, + pub has_cloud_snapshot: Option, + pub task_id: Option, + pub service_origin: String, + pub consent_version: Option, + pub backup_retention_days: Option, +} + +impl EncryptedSyncStatus { + pub(crate) fn initial(origin: String) -> Self { + Self { + sequence: "0".into(), + enabled: false, + auth_state: AuthState::SignedOut, + key_state: KeyState::Locked, + sync_state: SyncState::Disabled, + account: None, + vault_id: None, + key_id: None, + local_generation: "0".into(), + last_synced_local_generation: None, + remote_revision: None, + last_successful_sync_at: None, + pending_operation_id: None, + last_error: None, + recovery_required: false, + has_cloud_snapshot: None, + task_id: None, + service_origin: origin, + consent_version: None, + backup_retention_days: None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EnableStep { + Create, + Unlock, + RestoreReview, + Ready, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnablePreparation { + pub next_step: EnableStep, + pub status: EncryptedSyncStatus, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RestoreMode { + Replace, + Merge, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SyncConflictChoice { + pub id: String, + pub side: ConflictSide, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConflictSide { + Local, + Cloud, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncConflictItem { + pub id: String, + pub kind: String, + /// A fixed reason code, never a value, path, history excerpt, or API key. + pub reason: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RestorePreview { + pub preview_id: String, + pub unconfirmed_operation_id: Option, + pub observed_revision: String, + pub local_generation: String, + pub counts: BTreeMap, + pub device_settings_to_review: Vec, + pub conflicts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedSyncEvent { + pub sequence: String, + pub account_id: Option, + pub vault_id: Option, + pub task_id: Option, + pub status: EncryptedSyncStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedSyncConflictEvent { + pub sequence: String, + pub account_id: String, + pub vault_id: String, + pub task_id: Option, + pub preview: RestorePreview, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedSyncRestoreEvent { + pub sequence: String, + pub account_id: String, + pub vault_id: String, + pub task_id: Option, + pub local_generation: String, + pub ui_preferences: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedUiPreferencesSnapshot { + pub preferences: Option, + pub revision: Option, +} 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 new file mode 100644 index 000000000..d9e63b5ca --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/local.rs @@ -0,0 +1,491 @@ +//! Encrypted local baselines and unresolved operations. The wrapping key lives +//! only in the host's system vault, never beside these files. + +use std::{ + fs, + io::Write, + path::{Path, PathBuf}, + sync::Arc, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tokio::sync::OnceCell; +use zeroize::Zeroizing; + +use crate::cloud_sync_e2ee_protocol::crypto::{open_local, seal_local, DerivedKey}; +use crate::credentials::SyncSecretAccount; +use crate::{CredentialStore, SecretValue}; + +use super::{error, SyncResult}; + +#[derive(Clone)] +pub(crate) struct LocalStorage { + root: PathBuf, + origin: String, + device_id: String, + credentials: Arc, + cipher: Arc>>, + io: Arc>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ClientSettings { + pub version: u32, + pub enabled: bool, + pub consent_version: Option, + pub owner_id: Option, + pub remember_key: bool, + #[serde(default)] + pub key_preference_epoch: u64, + pub last_success: Option, + pub last_generation: Option, + pub vault_id: Option, + pub key_id: Option, + pub prompted: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct UiEnvelope { + pub schema_version: u32, + pub revision: String, + pub value: crate::cloud_sync_e2ee_documents::SecretJson, +} + +impl Default for ClientSettings { + fn default() -> Self { + Self { + version: 1, + enabled: false, + consent_version: None, + owner_id: None, + remember_key: false, + key_preference_epoch: 0, + last_success: None, + last_generation: None, + vault_id: None, + key_id: None, + prompted: false, + } + } +} + +impl LocalStorage { + pub(crate) fn new( + root: PathBuf, + origin: String, + device_id: String, + credentials: Arc, + ) -> Self { + Self { + root, + origin, + device_id, + credentials, + cipher: Arc::new(OnceCell::new()), + io: Arc::new(tokio::sync::Mutex::new(())), + } + } + + pub(crate) fn initialized(&self) -> SyncResult { + self.path("device", "client") + .try_exists() + .map_err(|_| error("local_storage_unavailable")) + } + + pub(crate) async fn read_ui(&self) -> SyncResult> { + let value: Option = + self.read("device", "sync-ui-preferences") + .await + .inspect_err(|error| { + log_local_failure("ui_mirror_read", error); + })?; + if let Some(value) = &value { + if value.schema_version != 1 + || crate::cloud_sync_e2ee_protocol::types::UuidV4::parse(&value.revision).is_err() + { + return Err(error("recovery_required")); + } + } + Ok(value) + } + + pub(crate) fn binding( + &self, + owner: &str, + vault: &str, + key: &str, + ) -> 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")) + } + + pub(crate) async fn key(&self) -> SyncResult> { + self.cipher + .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) + )) + .map_err(|_| error("local_storage_unavailable"))?; + let value = self + .credentials + .read_sync_secret(account.clone()) + .await + .map_err(|_| { + log::warn!("[e2ee-local] stage=wrapping_key_read code=secure_storage_denied"); + error("secure_storage_denied") + })?; + let bytes = match value { + Some(value) => decode_key(value.expose_secret())?, + None => { + // Losing the system-vault key never silently resets encrypted recovery data. + let root = self.root.clone(); + let has_state = tokio::task::spawn_blocking(move || { + if !root.exists() { + return Ok(false); + } + let files = fs::read_dir(root) + .map_err(|_| error("local_storage_unavailable"))?; + for file in files { + let file = file.map_err(|_| error("local_storage_unavailable"))?; + if file + .path() + .extension() + .is_some_and(|extension| extension == "enc") + { + return Ok(true); + } + } + Ok(false) + }) + .await + .map_err(|_| error("local_storage_unavailable"))??; + if has_state { + return Err(error("recovery_required")); + } + let mut bytes = Zeroizing::new([0_u8; 32]); + getrandom::fill(&mut *bytes) + .map_err(|_| error("secure_random_unavailable"))?; + self.credentials + .write_sync_secret( + account.clone(), + SecretValue::new(URL_SAFE_NO_PAD.encode(&bytes[..])), + ) + .await + .map_err(|_| { + log::warn!("[e2ee-local] stage=wrapping_key_write code=secure_storage_denied"); + error("secure_storage_denied") + })?; + let verified = self + .credentials + .read_sync_secret(account) + .await + .map_err(|_| error("secure_storage_denied"))? + .ok_or_else(|| error("secure_storage_denied"))?; + if decode_key(verified.expose_secret())?.as_ref() != bytes.as_ref() { + return Err(error("secure_storage_denied")); + } + bytes + } + }; + Ok(Arc::new(DerivedKey::from_secret_bytes(bytes))) + }) + .await + .cloned() + } + + fn aad(&self, owner: &str, name: &str) -> SyncResult> { + serde_json::to_vec(&[ + "openless.local-sync.v1", + &self.origin, + &self.device_id, + owner, + name, + ]) + .map_err(|_| error("local_storage_unavailable")) + } + + 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()))) + } + + pub(crate) async fn read( + &self, + owner: &str, + name: &str, + ) -> SyncResult> { + let io = self.io.clone().lock_owned().await; + let path = self.path(owner, name); + if !path + .try_exists() + .map_err(|_| error("local_storage_unavailable"))? + { + return Ok(None); + } + let key = self.key().await?; + let aad = self.aad(owner, name)?; + tokio::task::spawn_blocking(move || { + let _io = io; + let file = fs::File::open(path).map_err(|_| error("local_storage_unavailable"))?; + if file + .metadata() + .map_err(|_| error("local_storage_unavailable"))? + .len() + > 41 * 1024 * 1024 + { + return Err(error("recovery_required")); + } + use std::io::Read; + let mut bytes = Vec::new(); + file.take(41 * 1024 * 1024 + 1) + .read_to_end(&mut bytes) + .map_err(|_| error("local_storage_unavailable"))?; + let plain = open_local(&key, &aad, &bytes).map_err(|_| error("recovery_required"))?; + serde_json::from_slice(&plain) + .map(Some) + .map_err(|_| error("recovery_required")) + }) + .await + .map_err(|_| error("local_storage_unavailable"))? + } + + pub(crate) async fn write( + &self, + owner: &str, + name: &str, + value: T, + ) -> SyncResult<()> { + let key = self.key().await?; + let io = self.io.clone().lock_owned().await; + let aad = self.aad(owner, name)?; + let path = self.path(owner, name); + tokio::task::spawn_blocking(move || { + let _io = io; + let plain = Zeroizing::new( + serde_json::to_vec(&value).map_err(|_| error("local_storage_unavailable"))?, + ); + let sealed = + seal_local(&key, &aad, &plain).map_err(|_| error("local_storage_unavailable"))?; + durable_replace(&path, &sealed) + }) + .await + .map_err(|_| error("local_storage_unavailable"))? + } + + pub(crate) async fn remove(&self, owner: &str, name: &str) -> SyncResult<()> { + let io = self.io.clone().lock_owned().await; + let path = self.path(owner, name); + tokio::task::spawn_blocking(move || { + let _io = io; + match fs::remove_file(&path) { + Ok(()) => sync_directory( + path.parent() + .ok_or_else(|| error("local_storage_unavailable"))?, + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(error("local_storage_unavailable")), + } + }) + .await + .map_err(|_| error("local_storage_unavailable"))? + } + + pub(crate) async fn remember( + &self, + owner: &str, + vault: &str, + key_id: &str, + key: &DerivedKey, + remember: bool, + ) -> SyncResult<()> { + let account = self.binding(owner, vault, key_id)?; + if remember { + let bytes = key.copy_secret_bytes(); + self.credentials + .write_sync_secret( + account, + SecretValue::new(URL_SAFE_NO_PAD.encode(&bytes[..])), + ) + .await + .map_err(|_| error("secure_storage_denied")) + } else { + self.credentials + .remove_sync_secret(account) + .await + .map_err(|_| error("secure_storage_denied")) + } + } + + pub(crate) async fn remembered( + &self, + owner: &str, + vault: &str, + key_id: &str, + ) -> SyncResult> { + self.credentials + .read_sync_secret(self.binding(owner, vault, key_id)?) + .await + .map_err(|_| error("secure_storage_denied"))? + .map(|value| decode_key(value.expose_secret()).map(DerivedKey::from_secret_bytes)) + .transpose() + } + + pub(crate) async fn forget(&self, owner: &str, vault: &str, key_id: &str) -> SyncResult<()> { + self.credentials + .remove_sync_secret(self.binding(owner, vault, key_id)?) + .await + .map_err(|_| error("secure_storage_denied")) + } + + /// A non-secret, durable refusal to auto-unlock survives even when encrypted + /// recovery metadata or the operating-system vault cannot currently be read. + pub(crate) async fn set_lockout(&self, locked: bool) -> SyncResult<()> { + let io = self.io.clone().lock_owned().await; + let path = self.root.join("unlock-disabled"); + tokio::task::spawn_blocking(move || { + let _io = io; + if locked { + durable_replace(&path, b"locked-v1\n") + } else { + match fs::remove_file(&path) { + Ok(()) => sync_directory( + path.parent() + .ok_or_else(|| error("local_storage_unavailable"))?, + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(error("local_storage_unavailable")), + } + } + }) + .await + .map_err(|_| error("local_storage_unavailable"))? + } + + pub(crate) async fn locked_out(&self) -> SyncResult { + let io = self.io.clone().lock_owned().await; + let path = self.root.join("unlock-disabled"); + tokio::task::spawn_blocking(move || { + let _io = io; + path.try_exists() + .map_err(|_| error("local_storage_unavailable")) + }) + .await + .map_err(|_| error("local_storage_unavailable"))? + } +} + +fn decode_key(value: &str) -> SyncResult> { + let bytes = Zeroizing::new( + URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| error("secure_storage_denied"))?, + ); + if URL_SAFE_NO_PAD.encode(&*bytes) != value { + return Err(error("secure_storage_denied")); + } + let bytes = + <[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| error("secure_storage_denied"))?; + Ok(Zeroizing::new(bytes)) +} + +pub(crate) fn durable_replace(path: &Path, bytes: &[u8]) -> SyncResult<()> { + let parent = path + .parent() + .ok_or_else(|| error("local_storage_unavailable"))?; + fs::create_dir_all(parent).map_err(|_| error("local_storage_unavailable"))?; + let temporary = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|_| error("local_storage_unavailable"))?; + file.write_all(bytes) + .map_err(|_| error("local_storage_unavailable"))?; + file.sync_all() + .map_err(|_| error("local_storage_unavailable"))?; + fs::rename(&temporary, path).map_err(|_| error("local_storage_unavailable"))?; + sync_directory(parent) + })(); + if result.is_err() { + let _ = fs::remove_file(temporary); + } + result +} + +fn sync_directory(path: &Path) -> SyncResult<()> { + #[cfg(unix)] + { + fs::File::open(path) + .and_then(|f| f.sync_all()) + .map_err(|_| error("local_storage_unavailable"))?; + } + #[cfg(not(unix))] + { + let _ = path; + } + Ok(()) +} + +/// Publish a fully written identity without replacing an existing install's ID. +pub(crate) fn durable_create(path: &Path, bytes: &[u8]) -> SyncResult { + let parent = path + .parent() + .ok_or_else(|| error("local_storage_unavailable"))?; + fs::create_dir_all(parent).map_err(|_| error("local_storage_unavailable"))?; + let temporary = parent.join(format!(".identity-{}.tmp", uuid::Uuid::new_v4())); + let result = (|| { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|_| error("local_storage_unavailable"))?; + file.write_all(bytes) + .and_then(|()| file.sync_all()) + .map_err(|_| error("local_storage_unavailable"))?; + match fs::hard_link(&temporary, path) { + Ok(()) => { + sync_directory(parent)?; + Ok(true) + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(false), + Err(_) => Err(error("local_storage_unavailable")), + } + })(); + let _ = fs::remove_file(&temporary); + result +} + +fn log_local_failure(stage: &'static str, failure: &crate::BackendError) { + let code = match failure + .details + .as_ref() + .and_then(|details| details.get("reason")) + .and_then(serde_json::Value::as_str) + { + Some("secure_storage_denied") => "secure_storage_denied", + Some("recovery_required") => "recovery_required", + Some("secure_random_unavailable") => "secure_random_unavailable", + _ => "local_storage_unavailable", + }; + log::warn!("[e2ee-local] stage={stage} code={code}"); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs new file mode 100644 index 000000000..da39dce42 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs @@ -0,0 +1,102 @@ +//! Encrypted sync orchestration. All private data stays in Core; the UI receives +//! only status, category counts and opaque conflict identifiers. + +mod adapter; +mod dto; +mod local; +mod service; +#[cfg(test)] +mod tests; + +pub(crate) use adapter::build; +pub use dto::*; +pub(crate) use service::{EncryptedSyncService, SyncServiceConfig}; + +pub const DEFAULT_SYNC_SERVICE_ORIGIN: &str = "https://apic.openless.top:9443"; + +#[derive(Debug, Clone)] +pub struct EncryptedSyncConfig { + pub service_origin: String, + pub app_version: String, +} + +use crate::cloud_sync_e2ee_protocol as protocol; +use crate::{BackendError, BackendErrorCode}; + +pub(crate) type SyncResult = Result; + +pub(crate) fn error(reason: &'static str) -> BackendError { + let code = match reason { + "busy" | "stale_preview" | "revision_conflict" => BackendErrorCode::Busy, + "cancelled" | "account_changed" => BackendErrorCode::Cancelled, + "sign_in_required" | "unlock_required" | "secure_storage_denied" => { + BackendErrorCode::PermissionDenied + } + "service_unavailable" | "unsupported_protocol" => BackendErrorCode::Unsupported, + "outcome_unknown" => BackendErrorCode::OutcomeUnknown, + "recovery_required" | "local_storage_unavailable" => BackendErrorCode::Persistence, + "transport_failed" | "rate_limited" => BackendErrorCode::Provider, + _ => BackendErrorCode::InvalidState, + }; + let mut error = BackendError::new(code, reason); + error.details = Some(serde_json::json!({"reason": reason})); + error +} + +pub(crate) fn protocol_error(value: protocol::Error) -> BackendError { + use protocol::Error as E; + let reason = match &value { + E::WeakPassword => "weak_password", + E::PasswordConfirmationMismatch => "password_confirmation_mismatch", + E::InvalidPasswordOrCiphertext => "invalid_password_or_ciphertext", + E::Transport => "transport_failed", + E::PayloadTooLarge => "payload_too_large", + E::AccountMismatch => "account_changed", + E::ContextMismatch => "revision_conflict", + E::UnsupportedProtocol | E::UnsupportedDocumentVersion => "unsupported_protocol", + E::RandomUnavailable => "secure_random_unavailable", + E::Api { + status: 401 | 403, .. + } => "sign_in_required", + E::Api { + status: 409 | 412, .. + } => "revision_conflict", + E::Api { + status: 404 | 405 | 501, + .. + } => "service_unavailable", + E::Api { status: 429, .. } => "rate_limited", + E::Api { status: 413, .. } => "payload_too_large", + E::Api { .. } => "service_failed", + _ => "invalid_response", + }; + let mut result = error(reason); + if let E::Api { + retry_after_seconds: Some(seconds), + .. + } = value + { + result.details = Some(serde_json::json!({"reason": reason, "retryAfterSeconds": seconds})); + } + result +} + +pub(crate) fn document_error( + value: crate::cloud_sync_e2ee_documents::DocumentError, +) -> BackendError { + // DocumentError's Display is a closed set of value-free protocol codes. + let reason = value.to_string(); + let code = if matches!( + value, + crate::cloud_sync_e2ee_documents::DocumentError::RuntimeBusy + | crate::cloud_sync_e2ee_documents::DocumentError::SourceChanged + | crate::cloud_sync_e2ee_documents::DocumentError::StalePreview + ) { + BackendErrorCode::Busy + } else { + BackendErrorCode::InvalidState + }; + let mut result = BackendError::new(code, reason.clone()); + result.details = Some(serde_json::json!({"reason": reason})); + result +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs new file mode 100644 index 000000000..640ac19cf --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/service.rs @@ -0,0 +1,2122 @@ +#[path = "setup_prompt.rs"] +mod setup_prompt; + +use std::{ + collections::BTreeMap, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, + time::{Duration, Instant}, +}; + +use futures_util::future::BoxFuture; +use serde::{Deserialize, Serialize}; + +use crate::cloud_sync_e2ee_documents::{ + self as documents, ExportedDocuments, RestoreContext, SyncScope, ValidatedSyncDocuments, +}; +use crate::cloud_sync_e2ee_protocol::{ + crypto::{self, DerivedKey, EncryptContext, NormalizedPassword}, + transport::{Metadata, MetadataResult, OperationStatus, ProxyPolicy, SyncSession, Transport}, + types::*, +}; +use crate::events::{BackendEventKind, BackendEventPublisher}; +use crate::marketplace::MarketplaceService; +use crate::{BackendError, SecretValue}; + +use super::{ + document_error, + dto::*, + error, + local::{ClientSettings, LocalStorage}, + protocol_error, SyncResult, +}; + +/// The repository adapter performs the protected, exclusive restore transaction +/// and verifies actual read-back before returning. It never runs UI callbacks. +pub(crate) trait SyncServiceData: Send + Sync { + fn export(&self, scope: SyncScope) -> BoxFuture<'_, SyncResult>; + fn restore( + &self, + desired: ValidatedSyncDocuments, + context: RestoreContext, + ) -> BoxFuture<'_, SyncResult>; + fn recover(&self) -> BoxFuture<'_, SyncResult<()>>; + fn baseline( + &self, + scope: SyncScope, + documents: DocumentSet, + revision: Revision, + ) -> BoxFuture<'_, SyncResult<()>>; + fn generation(&self) -> SyncResult; + fn device(&self) -> SourceDevice; + fn changes( + &self, + ) -> tokio::sync::watch::Receiver; +} + +pub(crate) struct SyncServiceConfig { + pub origin: String, + pub github_client_id: String, +} + +struct Connection { + transport: Transport, + session: SyncSession, + github_token: SecretValue, + expires_at: Instant, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Baseline { + revision: Revision, + vault_id: UuidV4, + documents: DocumentSet, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Pending { + record: String, + operation_id: String, + vault_id: String, + key_id: Option, + local_generation: Revision, + deleted: bool, + remember_key: bool, + key_preference_epoch: u64, + base_revision: Revision, + old_key_id: Option, +} + +struct PreviewState { + public: RestorePreview, + remote: ValidatedSyncDocuments, + local: ExportedDocuments, + baseline: Option, + vault_id: String, + key_id: String, +} + +struct Runtime { + initialized: bool, + settings: ClientSettings, + connection: Option, + metadata: Option, + snapshot: Option, + key: Option>, + baseline: Option, + preview: Option, + retry_at: Option, +} + +struct Shared { + config: SyncServiceConfig, + marketplace: Arc, + data: Arc, + local: LocalStorage, + events: BackendEventPublisher, + runtime: tokio::sync::Mutex, + status: Mutex, + cancelled: Arc, + auto_started: AtomicBool, + shutdown: AtomicBool, + auto_suspended: AtomicBool, + sequence: AtomicU64, + wake: tokio::sync::Notify, +} + +#[derive(Clone)] +pub(crate) struct EncryptedSyncService(Arc); + +impl EncryptedSyncService { + pub(crate) fn new( + config: SyncServiceConfig, + marketplace: Arc, + local: LocalStorage, + data: Arc, + events: BackendEventPublisher, + ) -> Self { + let status = EncryptedSyncStatus::initial(config.origin.clone()); + Self(Arc::new(Shared { + config, + marketplace, + data, + local, + events, + runtime: tokio::sync::Mutex::new(Runtime { + initialized: false, + settings: ClientSettings::default(), + connection: None, + metadata: None, + snapshot: None, + key: None, + baseline: None, + preview: None, + retry_at: None, + }), + status: Mutex::new(status), + cancelled: Arc::new(AtomicBool::new(false)), + auto_started: AtomicBool::new(false), + shutdown: AtomicBool::new(false), + auto_suspended: AtomicBool::new(false), + sequence: AtomicU64::new(0), + wake: tokio::sync::Notify::new(), + })) + } + + pub(crate) fn status(&self) -> EncryptedSyncStatus { + let mut value = self + .0 + .status + .lock() + .unwrap_or_else(|e| e.into_inner()) + .clone(); + value.sequence = self.0.sequence.load(Ordering::Acquire).to_string(); + match self.0.data.generation() { + Ok(generation) => value.local_generation = generation.as_str().into(), + Err(_) => { + value.recovery_required = true; + value.sync_state = SyncState::RecoveryRequired; + } + } + value + } + + fn update(&self, update: impl FnOnce(&mut EncryptedSyncStatus)) { + let status = { + let mut status = self.0.status.lock().unwrap_or_else(|e| e.into_inner()); + update(&mut status); + status.sequence = self + .0 + .sequence + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1) + .to_string(); + status.clone() + }; + self.0.events.publish( + None, + BackendEventKind::CloudSyncStateChanged(EncryptedSyncEvent { + sequence: status.sequence.clone(), + account_id: status.account.as_ref().map(|v| v.github_id.clone()), + vault_id: status.vault_id.clone(), + task_id: status.task_id.clone(), + status, + }), + ); + } + + fn begin(&self) { + self.update(|s| { + self.0.cancelled.store(false, Ordering::Release); + s.task_id = Some(uuid::Uuid::new_v4().to_string()); + s.sync_state = SyncState::Syncing; + s.last_error = None; + }); + } + + fn check_cancelled(&self) -> SyncResult<()> { + if self.0.cancelled.load(Ordering::Acquire) || self.0.shutdown.load(Ordering::Acquire) { + Err(error("cancelled")) + } else { + Ok(()) + } + } + + async fn current_account(&self, runtime: &Runtime) -> SyncResult<()> { + self.check_cancelled()?; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + if self + .0 + .marketplace + .read_access_token() + .await + .map_err(|_| error("sign_in_required"))? + != connection.github_token + { + return Err(error("account_changed")); + } + self.check_cancelled() + } + + fn finish(&self, runtime: &mut Runtime, result: &SyncResult) { + let failure = result.as_ref().err().map(|e| { + let code = e + .details + .as_ref() + .and_then(|v| v.get("reason")) + .and_then(|v| v.as_str()) + .unwrap_or("sync_failed") + .to_owned(); + let retry_after_seconds = e + .details + .as_ref() + .and_then(|v| v.get("retryAfterSeconds")) + .and_then(|v| v.as_u64()) + .and_then(|v| u32::try_from(v).ok()); + SyncFailure { + code, + retry_after_seconds, + } + }); + if let Some(seconds) = failure.as_ref().and_then(|v| v.retry_after_seconds) { + runtime.retry_at = Some(Instant::now() + Duration::from_secs(u64::from(seconds))); + } + self.update(|s| { + if let Ok(generation) = self.0.data.generation() { + s.local_generation = generation.as_str().into(); + } + s.task_id = None; + s.enabled = runtime.settings.enabled; + s.key_state = if runtime.key.is_some() { + KeyState::Unlocked + } else { + KeyState::Locked + }; + s.consent_version = runtime.settings.consent_version.clone(); + s.last_successful_sync_at = runtime.settings.last_success.clone(); + s.last_synced_local_generation = runtime.settings.last_generation.clone(); + if let Some(failure) = failure { + s.sync_state = match failure.code.as_str() { + "sign_in_required" | "account_changed" => { + s.auth_state = AuthState::Expired; + SyncState::SignInRequired + } + "unlock_required" | "invalid_password_or_ciphertext" => { + SyncState::UnlockRequired + } + "outcome_unknown" => SyncState::OutcomeUnknown, + "recovery_required" => { + s.recovery_required = true; + SyncState::RecoveryRequired + } + "conflict" | "restore_review_required" => SyncState::Conflict, + "cancelled" => { + if s.enabled { + SyncState::Pending + } else { + SyncState::Disabled + } + } + _ => SyncState::Failed, + }; + s.last_error = Some(failure); + } else { + s.last_error = None; + s.sync_state = if runtime.preview.is_some() { + SyncState::Conflict + } else if !s.enabled { + SyncState::Disabled + } else if s.auth_state != AuthState::SignedIn { + SyncState::SignInRequired + } else if runtime.key.is_none() { + SyncState::UnlockRequired + } else if s.pending_operation_id.is_some() { + SyncState::OutcomeUnknown + } else if s.last_synced_local_generation.as_deref() + != Some(s.local_generation.as_str()) + { + SyncState::Pending + } else { + SyncState::Ready + }; + } + }); + } + + async fn initialize(&self, runtime: &mut Runtime) -> SyncResult<()> { + // Recovery precedes all ordinary mutations and backend-ready reporting. + // It also handles a dropped IPC future without requiring a process restart. + self.0.data.recover().await?; + if runtime.initialized { + return Ok(()); + } + if self.0.local.initialized()? { + runtime.settings = self + .0 + .local + .read("device", "client") + .await? + .ok_or_else(|| error("recovery_required"))?; + if runtime.settings.version != 1 { + return Err(error("recovery_required")); + } + } + if self.0.local.locked_out().await? { + runtime.settings.remember_key = false; + runtime.key = None; + runtime.preview = None; + } + runtime.initialized = true; + Ok(()) + } + + async fn save_settings(&self, runtime: &Runtime) -> SyncResult<()> { + self.0 + .local + .write("device", "client", runtime.settings.clone()) + .await + } + + async fn connect(&self, runtime: &mut Runtime) -> SyncResult<()> { + self.initialize(runtime).await?; + self.check_cancelled()?; + if runtime.retry_at.is_some_and(|at| at > Instant::now()) { + return Err(error("rate_limited")); + } + // Capture once after recovery has applied the current preferences. Both + // cache validation and client construction use this exact policy. + let proxy_policy = + ProxyPolicy::for_origin(&self.0.config.origin, crate::net::use_system_proxy()); + self.connect_with_proxy_policy(runtime, proxy_policy).await + } + + async fn connect_with_proxy_policy( + &self, + runtime: &mut Runtime, + proxy_policy: ProxyPolicy, + ) -> SyncResult<()> { + let valid = if let Some(connection) = &runtime.connection { + connection.expires_at > Instant::now() + Duration::from_secs(30) + && connection.transport.proxy_policy() == proxy_policy + && self.0.marketplace.read_access_token().await.ok().as_ref() + == Some(&connection.github_token) + } else { + false + }; + if valid { + return Ok(()); + } + #[cfg(not(test))] + let transport = Transport::new( + &self.0.config.origin, + &self.0.config.github_client_id, + proxy_policy, + ) + .await + .map_err(protocol_error)?; + #[cfg(test)] + let transport = if self.0.config.origin.starts_with("http://127.0.0.1:") { + Transport::for_test_with_proxy_policy( + &self.0.config.origin, + &self.0.config.github_client_id, + proxy_policy, + ) + .await + .map_err(protocol_error)? + } else { + Transport::new( + &self.0.config.origin, + &self.0.config.github_client_id, + proxy_policy, + ) + .await + .map_err(protocol_error)? + }; + let (token, account) = self + .0 + .marketplace + .sync_identity() + .await + .map_err(|_| error("sign_in_required"))?; + self.check_cancelled()?; + let session = transport + .exchange(token.expose_secret(), &account.github_id) + .await + .map_err(protocol_error)?; + if session.account().github_id != account.github_id + || transport.service_origin().trim_end_matches('/') + != self.0.config.origin.trim_end_matches('/') + { + return Err(error("account_changed")); + } + let backup_retention_days = transport.capabilities().max_backup_retention_days; + if self + .0 + .marketplace + .read_access_token() + .await + .map_err(|_| error("sign_in_required"))? + != token + { + return Err(error("account_changed")); + } + let owner = account.github_id.as_str().to_string(); + if runtime.settings.owner_id.as_deref() != Some(&owner) { + if runtime.settings.owner_id.is_some() { + runtime.settings.enabled = false; + runtime.settings.remember_key = false; + self.save_settings(runtime).await?; + } + self.forget_unlock_material(runtime).await?; + runtime.settings.enabled = false; + runtime.settings.consent_version = None; + runtime.settings.remember_key = false; + runtime.settings.last_success = None; + runtime.settings.last_generation = None; + runtime.settings.vault_id = None; + runtime.settings.key_id = None; + runtime.key = None; + runtime.baseline = None; + runtime.preview = None; + runtime.metadata = None; + runtime.snapshot = None; + } + runtime.settings.owner_id = Some(owner.clone()); + let expires_at = Instant::now() + Duration::from_secs(u64::from(session.expires_in())); + runtime.connection = Some(Connection { + transport, + session, + github_token: token, + expires_at, + }); + runtime.baseline = self.0.local.read(&owner, "baseline").await?; + let pending: Option = self.0.local.read(&owner, "pending").await?; + self.update(|s| { + s.account = Some(SyncAccount { + github_id: owner, + login: account.login, + }); + s.auth_state = AuthState::SignedIn; + s.backup_retention_days = Some(backup_retention_days); + s.pending_operation_id = pending.map(|p| p.operation_id); + s.has_cloud_snapshot = None; + }); + Ok(()) + } + + #[cfg(test)] + pub(super) async fn connect_with_proxy_setting_for_test( + &self, + use_system_proxy: bool, + ) -> SyncResult<()> { + let mut runtime = self.0.runtime.lock().await; + self.initialize(&mut runtime).await?; + self.check_cancelled()?; + let policy = ProxyPolicy::for_origin(&self.0.config.origin, use_system_proxy); + self.connect_with_proxy_policy(&mut runtime, policy).await + } + + async fn refresh_metadata(&self, runtime: &mut Runtime) -> SyncResult<()> { + self.current_account(runtime).await?; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + let metadata = match connection + .transport + .metadata(&connection.session, None) + .await + .map_err(protocol_error)? + { + MetadataResult::Modified(value) => *value, + MetadataResult::NotModified => return Err(error("invalid_response")), + }; + self.current_account(runtime).await?; + let value = metadata.value(); + let owner = self.owner(runtime)?.to_owned(); + let seen: Option = self.0.local.read(&owner, "last-seen-revision").await?; + if seen.is_some_and(|seen| value.revision < seen) + || runtime + .baseline + .as_ref() + .is_some_and(|base| value.revision < base.revision) + { + return Err(error("revision_rollback")); + } + self.0 + .local + .write(&owner, "last-seen-revision", value.revision) + .await?; + let key_binding_changed = runtime.key.is_some() + && (runtime.settings.vault_id.as_deref() + != value.vault_id.as_ref().map(UuidV4::as_str) + || runtime.settings.key_id.as_deref() != value.key_id.as_ref().map(UuidV4::as_str)); + if value.state != VaultState::Active + || key_binding_changed + || runtime.snapshot.as_ref().is_some_and(|s| { + Some(&s.key_id) != value.key_id.as_ref() + || Some(&s.vault_id) != value.vault_id.as_ref() + }) + { + runtime.key = None; + runtime.snapshot = None; + runtime.preview = None; + } + if value.state == VaultState::Deleted { + runtime.settings.enabled = false; + } + self.update(|s| { + s.remote_revision = Some(value.revision.as_str().into()); + s.vault_id = value.vault_id.as_ref().map(|v| v.as_str().into()); + s.key_id = value.key_id.as_ref().map(|v| v.as_str().into()); + s.has_cloud_snapshot = Some(value.state == VaultState::Active); + }); + runtime.metadata = Some(metadata); + Ok(()) + } + + fn metadata<'a>(&self, runtime: &'a Runtime) -> SyncResult<&'a Metadata> { + runtime + .metadata + .as_ref() + .ok_or_else(|| error("metadata_required")) + } + fn owner<'a>(&self, runtime: &'a Runtime) -> SyncResult<&'a str> { + runtime + .settings + .owner_id + .as_deref() + .ok_or_else(|| error("sign_in_required")) + } + fn unlocked(&self, runtime: &Runtime) -> SyncResult> { + runtime.key.clone().ok_or_else(|| error("unlock_required")) + } + + async fn download(&self, runtime: &mut Runtime) -> SyncResult { + self.current_account(runtime).await?; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + let snapshot = connection + .transport + .snapshot(&connection.session, self.metadata(runtime)?) + .await + .map_err(protocol_error)?; + self.current_account(runtime).await?; + Ok(snapshot) + } + + async fn decrypt( + &self, + runtime: &Runtime, + snapshot: SnapshotUpload, + key: Arc, + ) -> SyncResult { + let metadata = self.metadata(runtime)?.value().clone(); + let owner = GithubId::parse(self.owner(runtime)?).map_err(protocol_error)?; + tokio::task::spawn_blocking(move || { + let set = crypto::decrypt_snapshot(&snapshot, &metadata, &owner, &key) + .map_err(protocol_error)?; + documents::validate_sync_documents(set, metadata.revision).map_err(document_error) + }) + .await + .map_err(|_| error("crypto_worker_failed"))? + } + + async fn remember( + &self, + runtime: &mut Runtime, + snapshot: &SnapshotUpload, + key: Arc, + remember: bool, + ) -> SyncResult<()> { + self.0 + .local + .remember( + self.owner(runtime)?, + snapshot.vault_id.as_str(), + snapshot.key_id.as_str(), + &key, + remember, + ) + .await?; + runtime.key = Some(key); + runtime.settings.remember_key = remember; + runtime.settings.key_preference_epoch = runtime + .settings + .key_preference_epoch + .checked_add(1) + .ok_or_else(|| error("recovery_required"))?; + runtime.settings.vault_id = Some(snapshot.vault_id.as_str().into()); + runtime.settings.key_id = Some(snapshot.key_id.as_str().into()); + self.save_settings(runtime).await?; + self.0.local.set_lockout(false).await + } + + async fn try_remembered(&self, runtime: &mut Runtime) -> SyncResult<()> { + if runtime.key.is_some() + || !runtime.settings.remember_key + || self.0.local.locked_out().await? + { + return Ok(()); + } + let metadata = self.metadata(runtime)?.value(); + if metadata.state != VaultState::Active { + return Ok(()); + } + let vault = metadata + .vault_id + .as_ref() + .ok_or_else(|| error("invalid_response"))? + .as_str(); + let key_id = metadata + .key_id + .as_ref() + .ok_or_else(|| error("invalid_response"))? + .as_str(); + if runtime.settings.vault_id.as_deref() != Some(vault) + || runtime.settings.key_id.as_deref() != Some(key_id) + { + return Ok(()); + } + if let Some(key) = self + .0 + .local + .remembered(self.owner(runtime)?, vault, key_id) + .await? + { + let snapshot = self.download(runtime).await?; + let key = Arc::new(key); + // Merely retrieving/deriving a key cannot set keyState=unlocked. + self.decrypt(runtime, snapshot.clone(), key.clone()).await?; + self.current_account(runtime).await?; + runtime.snapshot = Some(snapshot); + runtime.key = Some(key); + } + Ok(()) + } + + pub(crate) async fn prepare_enable( + &self, + consent_version: String, + ) -> SyncResult { + if consent_version != CONSENT_VERSION { + return Err(error("consent_required")); + } + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = async { + self.connect(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + runtime.settings.consent_version = Some(consent_version); + runtime.settings.prompted = true; + self.save_settings(&runtime).await?; + self.try_remembered(&mut runtime).await?; + let step = if self.metadata(&runtime)?.value().state != VaultState::Active { + EnableStep::Create + } else if runtime.key.is_none() { + EnableStep::Unlock + } else if runtime.baseline.as_ref().is_some_and(|b| { + Some(&b.vault_id) + == self + .metadata(&runtime) + .ok() + .and_then(|m| m.value().vault_id.as_ref()) + }) { + EnableStep::Ready + } else { + EnableStep::RestoreReview + }; + Ok(step) + } + .await; + self.finish(&mut runtime, &result); + result.map(|next_step| EnablePreparation { + next_step, + status: self.status(), + }) + } + + pub(crate) async fn unlock( + &self, + password: String, + remember_key: bool, + ) -> SyncResult { + let password = SecretInput::new(password); + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.0.auto_suspended.store(false, Ordering::Release); + self.begin(); + let result = async { + self.connect(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + if runtime.settings.consent_version.as_deref() != Some(CONSENT_VERSION) { + return Err(error("consent_required")); + } + let snapshot = self.download(&mut runtime).await?; + let encoded = snapshot.clone(); + let metadata = self.metadata(&runtime)?.value().clone(); + let owner = GithubId::parse(self.owner(&runtime)?).map_err(protocol_error)?; + let (key, documents) = tokio::task::spawn_blocking(move || { + let (key, set) = + crypto::decrypt_snapshot_with_password(&encoded, &metadata, &owner, password) + .map_err(protocol_error)?; + let documents = documents::validate_sync_documents(set, metadata.revision) + .map_err(document_error)?; + Ok::<_, BackendError>((Arc::new(key), documents)) + }) + .await + .map_err(|_| error("crypto_worker_failed"))??; + self.current_account(&runtime).await?; + if let (Some(vault), Some(key_id)) = + (&runtime.settings.vault_id, &runtime.settings.key_id) + { + if vault != snapshot.vault_id.as_str() || key_id != snapshot.key_id.as_str() { + self.0 + .local + .forget(self.owner(&runtime)?, vault, key_id) + .await?; + } + } + self.remember(&mut runtime, &snapshot, key, remember_key) + .await?; + runtime.snapshot = Some(snapshot); + if !runtime.baseline.as_ref().is_some_and(|b| { + Some(&b.vault_id) + == self + .metadata(&runtime) + .ok() + .and_then(|m| m.value().vault_id.as_ref()) + }) { + self.make_preview(&mut runtime, documents).await?; + } + Ok(()) + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn create( + &self, + password: String, + confirmation: String, + remember_key: bool, + consent_version: String, + observed_revision: String, + ) -> SyncResult { + let password = SecretInput::new(password); + let confirmation = SecretInput::new(confirmation); + if consent_version != CONSENT_VERSION { + return Err(error("consent_required")); + } + let observed = Revision::parse(&observed_revision).map_err(protocol_error)?; + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.0.auto_suspended.store(false, Ordering::Release); + self.begin(); + let result = async { + self.connect(&mut runtime).await?; + self.reconcile_for_review(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + let metadata = self.metadata(&runtime)?.value(); + if metadata.state == VaultState::Active || metadata.revision != observed { + return Err(error("revision_conflict")); + } + if runtime.settings.consent_version.as_deref() != Some(CONSENT_VERSION) { + return Err(error("consent_required")); + } + self.retire_reviewed_pending(&mut runtime, None).await?; + let context = EncryptContext { + owner_github_id: GithubId::parse(self.owner(&runtime)?).map_err(protocol_error)?, + vault_id: UuidV4::random().map_err(protocol_error)?, + key_id: UuidV4::random().map_err(protocol_error)?, + base_revision: observed, + operation_id: UuidV4::random().map_err(protocol_error)?, + kind: UploadKind::Create, + kdf: Kdf::fixed(Salt::random().map_err(protocol_error)?), + }; + let captured = self + .0 + .data + .export(self.scope(&runtime, context.vault_id.as_str(), context.key_id.as_str())?) + .await?; + let documents = captured.documents.documents().clone(); + let (snapshot, key) = tokio::task::spawn_blocking(move || { + let password = NormalizedPassword::confirmed_new(password, confirmation) + .map_err(protocol_error)?; + let key = + Arc::new(crypto::derive_key(&password, &context.kdf).map_err(protocol_error)?); + let snapshot = + crypto::encrypt_snapshot(&documents, &context, &key).map_err(protocol_error)?; + Ok::<_, BackendError>((snapshot, key)) + }) + .await + .map_err(|_| error("crypto_worker_failed"))??; + self.current_account(&runtime).await?; + self.remember(&mut runtime, &snapshot, key, remember_key) + .await?; + self.upload(&mut runtime, snapshot, captured, None).await?; + self.check_cancelled()?; + runtime.settings.enabled = true; + self.save_settings(&runtime).await?; + Ok(()) + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + async fn upload( + &self, + runtime: &mut Runtime, + snapshot: SnapshotUpload, + captured: ExportedDocuments, + old_key_id: Option, + ) -> SyncResult<()> { + self.current_account(runtime).await?; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + let operation = connection + .transport + .prepare_upload( + &connection.session, + self.metadata(runtime)?, + &snapshot, + runtime.snapshot.as_ref(), + ) + .map_err(protocol_error)?; + let pending = Pending { + record: String::from_utf8(operation.to_pending_record().map_err(protocol_error)?) + .map_err(|_| error("invalid_response"))?, + operation_id: snapshot.operation_id.as_str().into(), + vault_id: snapshot.vault_id.as_str().into(), + key_id: Some(snapshot.key_id.as_str().into()), + local_generation: captured.generation, + deleted: false, + remember_key: runtime.settings.remember_key, + key_preference_epoch: runtime.settings.key_preference_epoch, + base_revision: snapshot.base_revision, + old_key_id, + }; + let owner = self.owner(runtime)?.to_string(); + self.0 + .local + .write( + &owner, + &format!("proposal:{}", pending.operation_id), + captured.documents.documents().clone(), + ) + .await?; + self.0 + .local + .write(&owner, "pending", pending.clone()) + .await?; + self.update(|s| s.pending_operation_id = Some(pending.operation_id.clone())); + self.current_account(runtime).await?; + // Once submitted, preserve the exact operation until a validated receipt. + let receipt = connection + .transport + .submit(&connection.session, &operation) + .await; + match receipt { + Ok(receipt) => { + self.accept_receipt(runtime, &pending, &receipt.receipt) + .await?; + runtime.snapshot = Some(snapshot); + if receipt.replayed { + // A replay proves that operation committed, not that its + // revision is still the server's current head. + self.update(|s| s.has_cloud_snapshot = None); + } + Ok(()) + } + Err(e) => { + if definite_rejection(&e) { + self.discard_rejected(runtime, &pending).await?; + if snapshot.kind != UploadKind::Snapshot { + self.0 + .local + .forget(&owner, snapshot.vault_id.as_str(), snapshot.key_id.as_str()) + .await?; + } + if snapshot.kind == UploadKind::Create { + runtime.key = None; + runtime.settings.remember_key = false; + runtime.settings.key_id = None; + runtime.settings.vault_id = None; + self.save_settings(runtime).await?; + } + return Err(protocol_error(e)); + } + Err(error("outcome_unknown")) + } + } + } + + async fn reconcile(&self, runtime: &mut Runtime) -> SyncResult<()> { + let owner = self.owner(runtime)?.to_owned(); + let Some(pending): Option = self.0.local.read(&owner, "pending").await? else { + return Ok(()); + }; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + let operation = connection + .transport + .restore_pending(&connection.session, pending.record.as_bytes()) + .map_err(protocol_error)?; + if operation.operation_id().as_str() != pending.operation_id { + return Err(error("recovery_required")); + } + self.current_account(runtime).await?; + let receipt = match connection + .transport + .operation(&connection.session, &operation) + .await + .map_err(protocol_error)? + { + OperationStatus::Committed(receipt) => receipt, + OperationStatus::Pending(pending) => { + let mut result = error("outcome_unknown"); + result.details = Some( + serde_json::json!({"reason":"outcome_unknown","retryAfterSeconds":pending.retry_after_seconds}), + ); + return Err(result); + } + OperationStatus::NotFound => { + // 404 does not prove failure. Only an identical body/op ID can be retried. + self.current_account(runtime).await?; + match connection + .transport + .submit(&connection.session, &operation) + .await + { + Ok(receipt) => receipt.receipt, + Err(e) if definite_rejection(&e) => { + // This is an older unknown operation: rejection of a + // retry cannot prove that the original never committed. + let _ = self.refresh_metadata(runtime).await; + return Err(error("outcome_unknown")); + } + Err(_) => return Err(error("outcome_unknown")), + } + } + }; + self.accept_receipt(runtime, &pending, &receipt).await + } + + async fn accept_receipt( + &self, + runtime: &mut Runtime, + pending: &Pending, + receipt: &OperationReceipt, + ) -> SyncResult<()> { + let owner = self.owner(runtime)?.to_string(); + if receipt.operation_id.as_str() != pending.operation_id + || receipt.vault_id.as_str() != pending.vault_id + { + return Err(error("invalid_response")); + } + if pending.deleted { + runtime.settings.enabled = false; + runtime.key = None; + runtime.preview = None; + runtime.baseline = None; + self.0.local.remove(&owner, "baseline").await?; + if let Some(key_id) = &runtime.settings.key_id { + self.0 + .local + .forget(&owner, &pending.vault_id, key_id) + .await?; + } + runtime.settings.remember_key = false; + runtime.settings.vault_id = None; + runtime.settings.key_id = None; + } else { + if runtime.settings.vault_id.as_deref() != Some(pending.vault_id.as_str()) + || runtime.settings.key_id.as_ref() != pending.key_id.as_ref() + { + // Receipt reconciliation can complete a password rotation while + // memory still holds the old key from the failed upload turn. + runtime.key = None; + runtime.snapshot = None; + runtime.preview = None; + } + let documents: DocumentSet = self + .0 + .local + .read(&owner, &format!("proposal:{}", pending.operation_id)) + .await? + .ok_or_else(|| error("recovery_required"))?; + documents::validate_sync_documents(documents.clone(), receipt.committed_revision) + .map_err(document_error)?; + let baseline = Baseline { + revision: receipt.committed_revision, + vault_id: receipt.vault_id.clone(), + documents, + }; + self.0 + .local + .write(&owner, "baseline", baseline.clone()) + .await?; + self.0 + .data + .baseline( + self.scope( + runtime, + &pending.vault_id, + pending + .key_id + .as_deref() + .ok_or_else(|| error("recovery_required"))?, + )?, + baseline.documents.clone(), + baseline.revision, + ) + .await?; + runtime.baseline = Some(baseline); + runtime.settings.vault_id = Some(pending.vault_id.clone()); + runtime.settings.key_id = pending.key_id.clone(); + if runtime.settings.key_preference_epoch == pending.key_preference_epoch + && !self.0.local.locked_out().await? + { + runtime.settings.remember_key = pending.remember_key; + } + if let Some(old) = &pending.old_key_id { + if Some(old) != pending.key_id.as_ref() { + self.0.local.forget(&owner, &pending.vault_id, old).await?; + } + } + } + runtime.settings.last_generation = Some(pending.local_generation.as_str().into()); + runtime.settings.last_success = Some(receipt.committed_at.clone()); + let seen: Option = self.0.local.read(&owner, "last-seen-revision").await?; + self.0 + .local + .write( + &owner, + "last-seen-revision", + seen.map_or(receipt.committed_revision, |seen| { + seen.max(receipt.committed_revision) + }), + ) + .await?; + self.save_settings(runtime).await?; + // Deleting the pending record is last; a crash at any earlier step replays safely. + self.0.local.remove(&owner, "pending").await?; + // This is an unreferenced encrypted scratch copy after pending removal. + // Its cleanup failure cannot undo a confirmed commit or restore old keys. + let _ = self + .0 + .local + .remove(&owner, &format!("proposal:{}", pending.operation_id)) + .await; + self.update(|s| { + s.pending_operation_id = None; + s.remote_revision = Some(receipt.committed_revision.as_str().into()); + s.has_cloud_snapshot = Some(!pending.deleted); + s.vault_id = (!pending.deleted).then(|| pending.vault_id.clone()); + s.key_id = if pending.deleted { + None + } else { + pending.key_id.clone() + }; + }); + Ok(()) + } + + pub(crate) fn cancel(&self, task_id: &str) -> SyncResult { + { + // The identity check and flag update share begin/finish's status + // lock. A delayed cancel can never poison the next task's flag. + let status = self.0.status.lock().unwrap_or_else(|e| e.into_inner()); + if status.task_id.as_deref() != Some(task_id) { + return Err(error("stale_task")); + } + self.0.cancelled.store(true, Ordering::Release); + } + Ok(self.status()) + } + + pub(crate) async fn lock(&self) -> SyncResult { + self.0.auto_suspended.store(true, Ordering::Release); + self.0.cancelled.store(true, Ordering::Release); + let mut runtime = self.0.runtime.lock().await; + runtime.key = None; + runtime.preview = None; + runtime.settings.remember_key = false; + let result = async { + self.0.local.set_lockout(true).await?; + self.initialize(&mut runtime).await?; + runtime.settings.remember_key = false; + runtime.settings.key_preference_epoch = runtime + .settings + .key_preference_epoch + .checked_add(1) + .ok_or_else(|| error("recovery_required"))?; + // Persist the refusal to auto-unlock before asking the OS to delete + // a key; a denied deletion must not unlock again on restart. + self.save_settings(&runtime).await?; + self.forget_unlock_material(&runtime).await + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn set_enabled(&self, enabled: bool) -> SyncResult { + if !enabled { + self.0.auto_suspended.store(true, Ordering::Release); + self.0.cancelled.store(true, Ordering::Release); + } else { + self.0.auto_suspended.store(false, Ordering::Release); + } + let mut runtime = self.0.runtime.lock().await; + self.initialize(&mut runtime).await?; + let result = async { + if enabled { + if runtime.settings.consent_version.as_deref() != Some(CONSENT_VERSION) { + return Err(error("consent_required")); + } + if runtime.preview.is_some() || runtime.baseline.is_none() { + return Err(error("restore_review_required")); + } + self.unlocked(&runtime)?; + self.0.cancelled.store(false, Ordering::Release); + self.current_account(&runtime).await?; + } + runtime.settings.enabled = enabled; + self.save_settings(&runtime).await?; + if enabled { + self.0.wake.notify_one(); + } + Ok(()) + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + fn scope(&self, runtime: &Runtime, vault: &str, key: &str) -> SyncResult { + Ok(SyncScope { + service_origin: self.0.config.origin.clone(), + owner_github_id: self.owner(runtime)?.into(), + vault_id: vault.into(), + key_id: key.into(), + device_id: self.0.data.device().id.clone(), + }) + } + + fn active_scope(&self, runtime: &Runtime) -> SyncResult { + let meta = self.metadata(runtime)?.value(); + self.scope( + runtime, + meta.vault_id + .as_ref() + .ok_or_else(|| error("cloud_deleted"))? + .as_str(), + meta.key_id + .as_ref() + .ok_or_else(|| error("cloud_deleted"))? + .as_str(), + ) + } + + fn baseline_documents(&self, runtime: &Runtime) -> SyncResult> { + runtime + .baseline + .as_ref() + .filter(|b| { + Some(&b.vault_id) + == runtime + .metadata + .as_ref() + .and_then(|m| m.value().vault_id.as_ref()) + }) + .map(|b| { + documents::validate_sync_documents(b.documents.clone(), b.revision) + .map_err(document_error) + }) + .transpose() + } + + async fn make_preview( + &self, + runtime: &mut Runtime, + remote: ValidatedSyncDocuments, + ) -> SyncResult { + let scope = self.active_scope(runtime)?; + let local = self.0.data.export(scope.clone()).await?; + let baseline = self.baseline_documents(runtime)?; + let merged = documents::diff_sync_documents(baseline.as_ref(), &local.documents, &remote) + .map_err(document_error)?; + let conflicts = merged + .conflicts() + .iter() + .map(|conflict| SyncConflictItem { + id: conflict.conflict_id.clone(), + kind: wire_name(&conflict.kind), + reason: wire_name(&conflict.reason), + }) + .collect(); + let mut counts = BTreeMap::new(); + for doc in &remote.documents().documents { + *counts.entry(wire_name(&doc.kind)).or_insert(0) += 1; + } + let public = RestorePreview { + preview_id: uuid::Uuid::new_v4().to_string(), + unconfirmed_operation_id: self + .reviewable_pending(runtime) + .await? + .map(|p| p.operation_id), + observed_revision: remote.observed_revision().as_str().into(), + local_generation: local.generation.as_str().into(), + counts, + device_settings_to_review: remote + .documents() + .documents + .iter() + .filter(|d| d.kind == DocumentKind::DeviceProfile) + .map(|_| "device_profile".to_string()) + .take(1) + .collect(), + conflicts, + }; + runtime.preview = Some(PreviewState { + public: public.clone(), + remote, + local, + baseline, + vault_id: scope.vault_id.clone(), + key_id: scope.key_id.clone(), + }); + self.0.events.publish( + None, + BackendEventKind::CloudSyncConflictDetected(EncryptedSyncConflictEvent { + sequence: self + .0 + .sequence + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1) + .to_string(), + account_id: scope.owner_github_id, + vault_id: scope.vault_id, + task_id: self.status().task_id, + preview: public.clone(), + }), + ); + Ok(public) + } + + pub(crate) async fn preview_restore( + &self, + observed_revision: String, + ) -> SyncResult { + let observed = Revision::parse(&observed_revision).map_err(protocol_error)?; + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = async { + self.connect(&mut runtime).await?; + self.reconcile_for_review(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + if self.metadata(&runtime)?.value().revision != observed { + return Err(error("stale_preview")); + } + let key = self.unlocked(&runtime)?; + let snapshot = self.download(&mut runtime).await?; + let remote = self.decrypt(&runtime, snapshot.clone(), key).await?; + runtime.snapshot = Some(snapshot); + self.current_account(&runtime).await?; + self.make_preview(&mut runtime, remote).await + } + .await; + self.finish(&mut runtime, &result); + result + } + + pub(crate) async fn apply_restore( + &self, + preview_id: String, + mode: RestoreMode, + choices: Vec, + ) -> SyncResult { + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = async { + self.current_account(&runtime).await?; + self.unlocked(&runtime)?; + self.refresh_metadata(&mut runtime).await?; + let preview = runtime + .preview + .as_ref() + .ok_or_else(|| error("stale_preview"))?; + let scope = self.active_scope(&runtime)?; + if preview.public.preview_id != preview_id + || self.metadata(&runtime)?.value().revision.as_str() + != preview.public.observed_revision + || self.0.data.generation()?.as_str() != preview.public.local_generation + || scope.vault_id != preview.vault_id + || scope.key_id != preview.key_id + { + return Err(error("stale_preview")); + } + let desired = match mode { + RestoreMode::Replace => preview.remote.clone(), + RestoreMode::Merge => { + let choices: Vec = choices + .into_iter() + .map(|c| documents::ConflictChoice { + conflict_id: c.id, + side: match c.side { + ConflictSide::Local => documents::ConflictSide::Local, + ConflictSide::Cloud => documents::ConflictSide::Remote, + }, + }) + .collect(); + documents::diff_sync_documents( + preview.baseline.as_ref(), + &preview.local.documents, + &preview.remote, + ) + .map_err(document_error)? + .resolve(&choices) + .map_err(document_error)? + } + }; + let remote = preview.remote.clone(); + let context = RestoreContext { + scope: scope.clone(), + operation_id: uuid::Uuid::new_v4().to_string(), + observed_revision: remote.observed_revision(), + local_generation: preview.local.generation, + target_device: self.0.data.device(), + }; + let reviewed_operation = preview.public.unconfirmed_operation_id.clone(); + self.current_account(&runtime).await?; + self.retire_reviewed_pending(&mut runtime, reviewed_operation.as_deref()) + .await?; + // After journal prepare, cancellation must finish commit/rollback. + let applied = self.0.data.restore(desired, context).await?; + let baseline = Baseline { + revision: remote.observed_revision(), + vault_id: UuidV4::parse(&scope.vault_id).map_err(protocol_error)?, + documents: remote.documents().clone(), + }; + self.0 + .local + .write(&scope.owner_github_id, "baseline", baseline.clone()) + .await?; + self.0 + .data + .baseline(scope.clone(), baseline.documents.clone(), baseline.revision) + .await?; + runtime.baseline = Some(baseline); + runtime.preview = None; + runtime.settings.last_generation = + if same_documents(applied.documents.documents(), remote.documents()) { + Some(applied.generation.as_str().into()) + } else { + None + }; + if !self.0.cancelled.load(Ordering::Acquire) { + runtime.settings.enabled = true; + } + self.save_settings(&runtime).await?; + let ui_preferences = applied + .documents + .documents() + .documents + .iter() + .filter(|d| d.kind == DocumentKind::UiPreferences) + .filter_map(|d| d.value.as_str().map(|v| (d.id.clone(), v.to_owned()))) + .collect(); + self.0.events.publish( + None, + BackendEventKind::CloudSyncRestoreCompleted(EncryptedSyncRestoreEvent { + sequence: self + .0 + .sequence + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1) + .to_string(), + account_id: scope.owner_github_id, + vault_id: scope.vault_id, + task_id: self.status().task_id, + local_generation: applied.generation.as_str().into(), + ui_preferences, + }), + ); + self.check_cancelled()?; + // A local conflict choice can differ from the current cloud head. + // Use the same CAS/merge path to upload it, never claim it synced early. + self.run_once(&mut runtime).await + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn sync_now(&self) -> SyncResult { + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = self.run_once(&mut runtime).await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + async fn run_once(&self, runtime: &mut Runtime) -> SyncResult<()> { + self.connect(runtime).await?; + self.reconcile(runtime).await?; + if !runtime.settings.enabled { + return Ok(()); + } + if runtime.settings.consent_version.as_deref() != Some(CONSENT_VERSION) { + return Err(error("consent_required")); + } + self.refresh_metadata(runtime).await?; + if self.metadata(runtime)?.value().state != VaultState::Active { + self.save_settings(runtime).await?; + return Err(error("cloud_deleted")); + } + self.try_remembered(runtime).await?; + let key = self.unlocked(runtime)?; + let snapshot = self.download(runtime).await?; + let remote = self.decrypt(runtime, snapshot.clone(), key.clone()).await?; + runtime.snapshot = Some(snapshot.clone()); + let baseline = self.baseline_documents(runtime)?; + if baseline.is_none() { + self.make_preview(runtime, remote).await?; + return Err(error("restore_review_required")); + } + let scope = self.active_scope(runtime)?; + let mut captured = self.0.data.export(scope.clone()).await?; + let merge = documents::diff_sync_documents(baseline.as_ref(), &captured.documents, &remote) + .map_err(document_error)?; + if !merge.conflicts().is_empty() { + self.make_preview(runtime, remote).await?; + return Err(error("conflict")); + } + let desired = merge.resolve(&[]).map_err(document_error)?; + self.current_account(runtime).await?; + if !same_documents(captured.documents.documents(), desired.documents()) { + let context = RestoreContext { + scope: scope.clone(), + operation_id: uuid::Uuid::new_v4().to_string(), + observed_revision: remote.observed_revision(), + local_generation: captured.generation, + target_device: self.0.data.device(), + }; + captured = self.0.data.restore(desired, context).await?; + self.emit_restored(&scope, &captured); + } + if same_documents(captured.documents.documents(), remote.documents()) { + let baseline = Baseline { + revision: remote.observed_revision(), + vault_id: snapshot.vault_id.clone(), + documents: remote.documents().clone(), + }; + self.0 + .local + .write(&scope.owner_github_id, "baseline", baseline.clone()) + .await?; + self.0 + .data + .baseline(scope, baseline.documents.clone(), baseline.revision) + .await?; + runtime.baseline = Some(baseline); + runtime.settings.last_generation = Some(captured.generation.as_str().into()); + runtime.settings.last_success = Some(chrono::Utc::now().to_rfc3339()); + self.save_settings(runtime).await?; + return Ok(()); + } + self.current_account(runtime).await?; + let documents = captured.documents.documents().clone(); + let context = EncryptContext { + owner_github_id: snapshot.owner_github_id.clone(), + vault_id: snapshot.vault_id.clone(), + key_id: snapshot.key_id.clone(), + base_revision: self.metadata(runtime)?.value().revision, + operation_id: UuidV4::random().map_err(protocol_error)?, + kind: UploadKind::Snapshot, + kdf: snapshot.kdf.clone(), + }; + let encrypted = tokio::task::spawn_blocking(move || { + crypto::encrypt_snapshot(&documents, &context, &key).map_err(protocol_error) + }) + .await + .map_err(|_| error("crypto_worker_failed"))??; + self.upload(runtime, encrypted, captured, None).await + } + + pub(crate) async fn change_password( + &self, + current_password: String, + new_password: String, + confirmation: String, + remember_key: bool, + ) -> SyncResult { + let current_password = SecretInput::new(current_password); + let new_password = SecretInput::new(new_password); + let confirmation = SecretInput::new(confirmation); + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = async { + self.run_once(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + let previous = self.download(&mut runtime).await?; + let metadata = self.metadata(&runtime)?.value().clone(); + if !runtime.baseline.as_ref().is_some_and(|baseline| { + baseline.revision == metadata.revision && baseline.vault_id == previous.vault_id + }) { + // Another device advanced the head after run_once merged it. + // Never encrypt the older local image against the newer CAS token. + return Err(error("revision_conflict")); + } + let scope = self.active_scope(&runtime)?; + let captured = self.0.data.export(scope).await?; + let prior = previous.clone(); + let content = captured.documents.documents().clone(); + let (snapshot, key) = tokio::task::spawn_blocking(move || { + let (_, _) = crypto::decrypt_snapshot_with_password( + &prior, + &metadata, + &prior.owner_github_id, + current_password, + ) + .map_err(protocol_error)?; + let password = NormalizedPassword::confirmed_new(new_password, confirmation) + .map_err(protocol_error)?; + let context = EncryptContext { + owner_github_id: prior.owner_github_id.clone(), + vault_id: prior.vault_id.clone(), + key_id: UuidV4::random().map_err(protocol_error)?, + base_revision: metadata.revision, + operation_id: UuidV4::random().map_err(protocol_error)?, + kind: UploadKind::PasswordChange, + kdf: Kdf::fixed(Salt::random().map_err(protocol_error)?), + }; + let key = + Arc::new(crypto::derive_key(&password, &context.kdf).map_err(protocol_error)?); + let snapshot = + crypto::encrypt_snapshot(&content, &context, &key).map_err(protocol_error)?; + Ok::<_, BackendError>((snapshot, key)) + }) + .await + .map_err(|_| error("crypto_worker_failed"))??; + self.current_account(&runtime).await?; + let old_key = runtime.key.clone(); + let old_settings = runtime.settings.clone(); + // Both scoped key entries survive an unknown result; delete the old + // remembered key only after the password-change receipt is verified. + self.remember(&mut runtime, &snapshot, key, remember_key) + .await?; + runtime.snapshot = Some(previous.clone()); + let result = self + .upload( + &mut runtime, + snapshot, + captured, + Some(previous.key_id.as_str().into()), + ) + .await; + if result.is_err() { + let epoch = runtime.settings.key_preference_epoch; + let remember = runtime.settings.remember_key; + runtime.key = old_key; + runtime.settings = old_settings; + runtime.settings.key_preference_epoch = epoch; + runtime.settings.remember_key = remember; + self.save_settings(&runtime).await?; + } + result + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn delete_remote( + &self, + expected_vault_id: String, + observed_revision: String, + confirmed: bool, + ) -> SyncResult { + if !confirmed { + return Err(error("delete_confirmation_required")); + } + let observed = Revision::parse(&observed_revision).map_err(protocol_error)?; + let expected = UuidV4::parse(&expected_vault_id).map_err(protocol_error)?; + let mut runtime = self.0.runtime.try_lock().map_err(|_| error("busy"))?; + self.begin(); + let result = async { + self.connect(&mut runtime).await?; + self.reconcile_for_review(&mut runtime).await?; + self.refresh_metadata(&mut runtime).await?; + let metadata = self.metadata(&runtime)?; + if metadata.value().revision != observed + || metadata.value().vault_id.as_ref() != Some(&expected) + || metadata.value().state != VaultState::Active + { + return Err(error("revision_conflict")); + } + self.retire_reviewed_pending(&mut runtime, None).await?; + let metadata = self.metadata(&runtime)?; + let connection = runtime + .connection + .as_ref() + .ok_or_else(|| error("sign_in_required"))?; + let operation = connection + .transport + .prepare_delete( + &connection.session, + metadata, + UuidV4::random().map_err(protocol_error)?, + ) + .map_err(protocol_error)?; + let pending = Pending { + record: String::from_utf8(operation.to_pending_record().map_err(protocol_error)?) + .map_err(|_| error("invalid_response"))?, + operation_id: operation.operation_id().as_str().into(), + vault_id: expected_vault_id, + key_id: None, + local_generation: self.0.data.generation()?, + deleted: true, + remember_key: false, + key_preference_epoch: runtime.settings.key_preference_epoch, + base_revision: observed, + old_key_id: None, + }; + self.0 + .local + .write(self.owner(&runtime)?, "pending", pending.clone()) + .await?; + self.update(|s| s.pending_operation_id = Some(pending.operation_id.clone())); + self.current_account(&runtime).await?; + let receipt = match connection + .transport + .submit(&connection.session, &operation) + .await + { + Ok(receipt) => receipt, + Err(e) if definite_rejection(&e) => { + self.discard_rejected(&runtime, &pending).await?; + return Err(protocol_error(e)); + } + Err(_) => return Err(error("outcome_unknown")), + }; + self.accept_receipt(&mut runtime, &pending, &receipt.receipt) + .await + } + .await; + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn sign_out(&self) -> SyncResult { + use crate::domains::MarketplaceApi; + self.0.auto_suspended.store(true, Ordering::Release); + self.0.cancelled.store(true, Ordering::Release); + // Preserve the account API's existing fail-closed guarantee immediately, + // even while an earlier sync task is draining its native I/O worker. + self.0.marketplace.invalidate_authentication(); + let mut runtime = self.0.runtime.lock().await; + runtime.key = None; + runtime.preview = None; + runtime.settings.enabled = false; + runtime.settings.remember_key = false; + let connection = runtime.connection.take(); + // OAuth logout is independent of encrypted journal/key cleanup. Always + // attempt it; denied sync-key deletion cannot keep GitHub authorized. + let logout_result = self + .0 + .marketplace + .logout() + .await + .map_err(|_| error("secure_storage_denied")); + let cleanup_result = async { + self.0.local.set_lockout(true).await?; + self.initialize(&mut runtime).await?; + runtime.settings.enabled = false; + runtime.settings.remember_key = false; + runtime.settings.key_preference_epoch = runtime + .settings + .key_preference_epoch + .checked_add(1) + .ok_or_else(|| error("recovery_required"))?; + self.save_settings(&runtime).await?; + self.forget_unlock_material(&runtime).await + } + .await; + if let Some(Connection { + transport, + session, + github_token, + .. + }) = connection + { + drop(github_token); + // Local sign-out remains possible offline; the discarded session + // also has a short server-enforced expiry. + let _ = tokio::time::timeout(Duration::from_secs(5), transport.revoke_session(session)) + .await; + } + runtime.settings.enabled = false; + runtime.settings.remember_key = false; + runtime.metadata = None; + runtime.snapshot = None; + runtime.baseline = None; + self.update(|s| { + s.account = None; + s.auth_state = AuthState::SignedOut; + s.has_cloud_snapshot = None; + s.vault_id = None; + s.key_id = None; + s.remote_revision = None; + s.pending_operation_id = None; + }); + let result = cleanup_result.and(logout_result); + self.finish(&mut runtime, &result); + result.map(|()| self.status()) + } + + pub(crate) async fn begin_sign_in(&self) -> SyncResult { + use crate::domains::MarketplaceApi; + let flow = self + .0 + .marketplace + .start_device_flow() + .await + .map_err(|_| error("sign_in_required"))?; + if flow.verification_uri != "https://github.com/login/device" { + return Err(error("invalid_response")); + } + let expires = chrono::Utc::now() + .checked_add_signed(chrono::Duration::seconds( + i64::try_from(flow.expires_in_secs).map_err(|_| error("invalid_response"))?, + )) + .ok_or_else(|| error("invalid_response"))?; + Ok(EncryptedSyncSignIn { + authorization_session_id: flow.flow_id, + user_code: flow.user_code, + verification_uri: flow.verification_uri, + expires_at: expires.to_rfc3339(), + interval_seconds: flow.interval_secs, + }) + } + + async fn forget_unlock_material(&self, runtime: &Runtime) -> SyncResult<()> { + let Some(owner) = &runtime.settings.owner_id else { + return Ok(()); + }; + if let (Some(vault), Some(key)) = (&runtime.settings.vault_id, &runtime.settings.key_id) { + self.0.local.forget(owner, vault, key).await?; + } + // An interrupted password rotation can have two remembered keys. Lock + // and sign-out clear both while retaining the encrypted replay record. + if let Some(pending) = self.0.local.read::(owner, "pending").await? { + for key in [pending.key_id.as_ref(), pending.old_key_id.as_ref()] + .into_iter() + .flatten() + { + self.0.local.forget(owner, &pending.vault_id, key).await?; + } + } + Ok(()) + } + + pub(crate) async fn poll_sign_in( + &self, + session: String, + ) -> SyncResult { + use crate::domains::MarketplaceApi; + use crate::domains::OAuthPollResult; + match self + .0 + .marketplace + .poll_device_flow(session) + .await + .map_err(|_| error("sign_in_required"))? + { + OAuthPollResult::Authorized { .. } => { + let (_, account) = self + .0 + .marketplace + .sync_identity() + .await + .map_err(|_| error("sign_in_required"))?; + Ok(EncryptedSyncSignInResult::SignedIn { + account: SyncAccount { + github_id: account.github_id.as_str().into(), + login: account.login, + }, + }) + } + OAuthPollResult::Pending => Ok(EncryptedSyncSignInResult::Pending { slow_down: false }), + OAuthPollResult::SlowDown => Ok(EncryptedSyncSignInResult::Pending { slow_down: true }), + OAuthPollResult::Error { message } + if message == "OAuth 设备码已过期,请重新发起登录" => + { + Ok(EncryptedSyncSignInResult::Expired) + } + OAuthPollResult::Error { message } + if message.contains("拒绝") || message.contains("取消") => + { + Ok(EncryptedSyncSignInResult::Denied) + } + OAuthPollResult::Error { .. } => Err(error("sign_in_required")), + } + } + + pub(crate) async fn cancel_sign_in(&self, session: String) -> SyncResult<()> { + use crate::domains::MarketplaceApi; + self.0.marketplace.cancel_device_flow(Some(session)).await + } + + pub(crate) async fn start(&self, spawner: Arc) -> SyncResult<()> { + if self.0.auto_started.swap(true, Ordering::AcqRel) { + return Ok(()); + } + { + let mut runtime = self.0.runtime.lock().await; + let result = self.initialize(&mut runtime).await; + self.finish(&mut runtime, &result); + if result.is_err() { + self.0.auto_started.store(false, Ordering::Release); + } + result?; + } + let service = self.clone(); + spawner.spawn(Box::pin(async move { + let mut changes = service.0.data.changes(); + // One startup trigger. Only a bounded local-busy retry may schedule + // another attempt without a user change; no network polling timer. + service.auto_sync(true).await; + loop { + let mut forced = tokio::select! { + changed = changes.changed() => { if changed.is_err() { break; } false }, + _ = service.0.wake.notified() => true, + }; + if service.0.shutdown.load(Ordering::Acquire) { break; } + let first = Instant::now(); + let mut deadline = tokio::time::Instant::now() + Duration::from_millis(750); + loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => break, + changed = changes.changed() => { + if changed.is_err() { return; } + let elapsed = first.elapsed(); + if elapsed >= Duration::from_secs(5) { break; } + deadline = tokio::time::Instant::now() + Duration::from_millis(750).min(Duration::from_secs(5) - elapsed); + }, + _ = service.0.wake.notified() => { if service.0.shutdown.load(Ordering::Acquire) { return; } forced = true; }, + } + } + let change = *changes.borrow_and_update(); + if forced || matches!(change.origin, crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User) { service.auto_sync(false).await; } + } + })); + Ok(()) + } + + async fn auto_sync(&self, startup: bool) { + // A LocalOnly writer can overlap the debounce/capture boundary without + // emitting another dirty generation when it finishes. Retain this + // trigger briefly instead of losing it after SourceChanged. This budget + // applies only to local contention, never transport/CAS/unknown results. + let mut delays = [250, 750, 1_500, 3_000].into_iter(); + let mut retry_sequence = None; + loop { + let mut runtime = self.0.runtime.lock().await; + if self.0.shutdown.load(Ordering::Acquire) + || self.0.auto_suspended.load(Ordering::Acquire) + || !runtime.settings.enabled + || runtime.preview.is_some() + { + return; + } + if retry_sequence.is_some_and(|sequence| { + self.0.cancelled.load(Ordering::Acquire) + || self.0.sequence.load(Ordering::Acquire) != sequence + }) { + return; + } + if runtime.key.is_none() && !(startup && runtime.settings.remember_key) { + return; + } + if runtime.retry_at.is_some_and(|at| at > Instant::now()) { + return; + } + self.begin(); + let result = self.run_once(&mut runtime).await; + let local_busy = result.as_ref().err().is_some_and(|failure| { + failure.code == crate::BackendErrorCode::Busy + && matches!( + failure.message.as_str(), + "sync_documents_source_changed" | "runtime_busy" + ) + }); + self.finish(&mut runtime, &result); + let completed_sequence = self.0.sequence.load(Ordering::Acquire); + // Pause/lock/manual work must be able to acquire the runtime during + // the delay. Every retry revalidates the current service state. + drop(runtime); + if !local_busy || self.0.cancelled.load(Ordering::Acquire) { + return; + } + let Some(delay) = delays.next() else { + return; + }; + // A later manual operation owns its own outcome. In particular an + // old local retry must never reconcile that operation's unknown PUT. + retry_sequence = Some(completed_sequence); + tokio::time::sleep(Duration::from_millis(delay)).await; + } + } + + pub(crate) async fn shutdown(&self) { + self.0.shutdown.store(true, Ordering::Release); + self.0.cancelled.store(true, Ordering::Release); + self.0.wake.notify_waiters(); + let mut runtime = self.0.runtime.lock().await; + runtime.key = None; + runtime.preview = None; + runtime.connection = None; + } + + async fn discard_rejected(&self, runtime: &Runtime, pending: &Pending) -> SyncResult<()> { + let owner = self.owner(runtime)?; + self.0.local.remove(owner, "pending").await?; + let _ = self + .0 + .local + .remove(owner, &format!("proposal:{}", pending.operation_id)) + .await; + self.update(|status| status.pending_operation_id = None); + Ok(()) + } + + async fn reconcile_for_review(&self, runtime: &mut Runtime) -> SyncResult<()> { + match self.reconcile(runtime).await { + Err(e) if e.message == "outcome_unknown" => Ok(()), + result => result, + } + } + + async fn reviewable_pending(&self, runtime: &Runtime) -> SyncResult> { + let pending: Option = self.0.local.read(self.owner(runtime)?, "pending").await?; + if let Some(pending) = &pending { + // Once a newer head is observed, the original If-Match can no + // longer commit. Until then, only exact replay is safe. + if self.metadata(runtime)?.value().revision <= pending.base_revision { + return Err(error("outcome_unknown")); + } + } + Ok(pending) + } + + async fn retire_reviewed_pending( + &self, + runtime: &mut Runtime, + expected_id: Option<&str>, + ) -> SyncResult<()> { + let pending = self.reviewable_pending(runtime).await?; + if expected_id.is_some() && pending.as_ref().map(|p| p.operation_id.as_str()) != expected_id + { + return Err(error("stale_preview")); + } + if let Some(pending) = pending { + self.current_account(runtime).await?; + let head = self.metadata(runtime)?.value(); + for key_id in [pending.key_id.as_ref(), pending.old_key_id.as_ref()] + .into_iter() + .flatten() + { + let active = head.vault_id.as_ref().map(UuidV4::as_str) + == Some(pending.vault_id.as_str()) + && head.key_id.as_ref().map(UuidV4::as_str) == Some(key_id.as_str()); + if !active { + self.0 + .local + .forget(self.owner(runtime)?, &pending.vault_id, key_id) + .await?; + } + } + // Explicit restore/create/delete supersedes the old CAS only after + // review. Preserve the uncertain operation as protected evidence; + // never mislabel it failed or committed without its receipt. + self.0 + .local + .write( + self.owner(runtime)?, + &format!("reviewed-uncertain:{}", pending.operation_id), + pending.clone(), + ) + .await?; + self.0.local.remove(self.owner(runtime)?, "pending").await?; + self.update(|status| status.pending_operation_id = None); + } + Ok(()) + } + + pub(crate) async fn ui_preferences(&self) -> SyncResult> { + Ok(self.ui_preferences_snapshot().await?.preferences) + } + + pub(crate) async fn ui_preferences_snapshot( + &self, + ) -> SyncResult { + let Some(envelope) = self.0.local.read_ui().await? else { + return Ok(EncryptedUiPreferencesSnapshot { + preferences: None, + revision: None, + }); + }; + let preferences = serde_json::from_value(serde_json::json!({ "locale": envelope.value.expose().get("locale"), "fontScale": envelope.value.expose().get("fontScale") })) + .map_err(|_| error("recovery_required"))?; + Ok(EncryptedUiPreferencesSnapshot { + preferences: Some(preferences), + revision: Some(envelope.revision), + }) + } + + fn emit_restored(&self, scope: &SyncScope, applied: &ExportedDocuments) { + let ui_preferences = applied + .documents + .documents() + .documents + .iter() + .filter(|d| d.kind == DocumentKind::UiPreferences) + .filter_map(|d| d.value.as_str().map(|v| (d.id.clone(), v.to_owned()))) + .collect(); + self.0.events.publish( + None, + BackendEventKind::CloudSyncRestoreCompleted(EncryptedSyncRestoreEvent { + sequence: self + .0 + .sequence + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1) + .to_string(), + account_id: scope.owner_github_id.clone(), + vault_id: scope.vault_id.clone(), + task_id: self.status().task_id, + local_generation: applied.generation.as_str().into(), + ui_preferences, + }), + ); + } +} + +fn wire_name(value: &impl Serialize) -> String { + serde_json::to_value(value) + .ok() + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_else(|| "unknown".into()) +} + +fn same_documents(left: &DocumentSet, right: &DocumentSet) -> bool { + // Device/time metadata describes an export, not a user edit. + left.documents == right.documents && left.tombstones == right.tombstones +} + +fn definite_rejection(error: &crate::cloud_sync_e2ee_protocol::Error) -> bool { + // Only for the first submit of a freshly generated operation ID. A retry of + // an older unknown request cannot use a CAS rejection as proof of failure. + matches!( + error, + crate::cloud_sync_e2ee_protocol::Error::Api { + status: 400..=499, + .. + } + ) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt.rs new file mode 100644 index 000000000..2e42db06b --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt.rs @@ -0,0 +1,61 @@ +//! One local claim after a usable pipeline is configured. This path never connects +//! to OAuth/sync servers, exports private documents, or enables synchronization. +use super::*; +use crate::{CredentialStore, CredentialsStatus, UserPreferences}; + +type Eligibility = Option<(UserPreferences, (Revision, u64))>; + +fn configured(preferences: &UserPreferences, status: &CredentialsStatus) -> bool { + match crate::shared_types::effective_pipeline_mode( + preferences.multimodal_pipeline_enabled, + preferences.pipeline_mode, + ) { + crate::shared_types::PipelineMode::Traditional => { + status.asr_configured && status.llm_configured + } + crate::shared_types::PipelineMode::Multimodal => status.omni_configured, + } +} + +impl EncryptedSyncService { + pub(crate) async fn claim_setup_prompt( + &self, + credentials: Arc, + eligibility: impl Fn() -> SyncResult + Send + Sync, + ) -> SyncResult { + let mut runtime = match self.0.runtime.try_lock() { + Ok(runtime) => runtime, + Err(_) => return Ok(false), + }; + if self.0.shutdown.load(Ordering::Acquire) { + return Ok(false); + } + let Some((preferences, stamp)) = eligibility()? else { + return Ok(false); + }; + self.initialize(&mut runtime).await?; + if runtime.settings.enabled || runtime.settings.prompted { + return Ok(false); + } + let status = credentials.status(preferences.clone()).await?; + if !configured(&preferences, &status) { + return Ok(false); + } + let Some((_, current)) = eligibility()? else { + return Ok(false); + }; + if current != stamp || self.0.shutdown.load(Ordering::Acquire) { + return Ok(false); + } + // Publish the in-memory claim only after durable encrypted persistence succeeds. + // Read errors and busy/changing inputs do not consume the installation's prompt. + let mut settings = runtime.settings.clone(); + settings.prompted = true; + self.0 + .local + .write("device", "client", settings.clone()) + .await?; + runtime.settings = settings; + Ok(true) + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs new file mode 100644 index 000000000..8e8cf3193 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/setup_prompt_tests.rs @@ -0,0 +1,280 @@ +use super::*; +use std::sync::atomic::AtomicUsize; + +struct ReadyCredentials { + status: CredentialsStatus, + deny: bool, + reads: Arc, + barrier: Option>, +} +impl CredentialStore for ReadyCredentials { + fn status( + &self, + _: UserPreferences, + ) -> BoxFuture<'static, Result> { + let status = self.status.clone(); + let deny = self.deny; + let reads = self.reads.clone(); + let barrier = self.barrier.clone(); + Box::pin(async move { + reads.fetch_add(1, Ordering::AcqRel); + if let Some(barrier) = barrier { + barrier.wait().await; + barrier.wait().await; + } + if deny { + Err(error("secure_storage_denied")) + } else { + Ok(status) + } + }) + } + fn read( + &self, + _: CredentialKey, + ) -> BoxFuture<'static, Result, BackendError>> { + Box::pin(async { panic!("claim must use Host readiness, not require a local-ASR API key") }) + } + fn write( + &self, + _: CredentialKey, + _: SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { panic!("claim must not mutate provider credentials") }) + } + fn remove(&self, _: CredentialKey) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { panic!("claim must not mutate provider credentials") }) + } +} +fn readiness(asr: bool, llm: bool, omni: bool) -> Arc { + Arc::new(ReadyCredentials { + status: CredentialsStatus { + asr_configured: asr, + llm_configured: llm, + omni_configured: omni, + ..Default::default() + }, + deny: false, + reads: Arc::new(AtomicUsize::new(0)), + barrier: None, + }) +} +fn eligible( + prefs: UserPreferences, +) -> impl Fn() -> SyncResult> + Send + Sync { + move || Ok(Some((prefs.clone(), (Revision::new(1), 0)))) +} +fn reopened_service(fixture: &Fixture, server: &Server) -> EncryptedSyncService { + let repos = crate::BackendRepositories::open(&fixture.root).unwrap(); + let config = crate::MarketplaceConfig::new(&server.origin).unwrap(); + let events = + crate::events::BackendEventPublisher::new(Arc::new(crate::events::EventBus::new(32))); + let marketplace = Arc::new( + crate::marketplace::MarketplaceService::new( + config, + fixture.vault.clone(), + repos.preferences, + repos.style_packs, + events.clone(), + Arc::new(AtomicU64::new(0)), + ) + .unwrap(), + ); + let local = LocalStorage::new( + fixture.root.join("protected"), + server.origin.clone(), + fixture.data.device().id.clone(), + fixture.vault.clone(), + ); + EncryptedSyncService::new( + SyncServiceConfig { + origin: server.origin.clone(), + github_client_id: CLIENT.into(), + }, + marketplace, + local, + fixture.data.clone(), + events, + ) +} + +#[tokio::test] +async fn setup_prompt_requires_the_selected_pipeline_and_accepts_ready_local_asr_without_a_key() { + let server = Server::start().await; + for (multi, enabled, asr, llm, omni, expected) in [ + (false, false, true, false, true, false), + (false, false, false, true, true, false), + (false, false, true, true, false, true), + (true, true, false, false, true, true), + (true, true, true, true, false, false), + (true, false, true, true, false, true), + ] { + let fixture = Fixture::new(&server).await; + let prefs = UserPreferences { + pipeline_mode: if multi { + crate::shared_types::PipelineMode::Multimodal + } else { + crate::shared_types::PipelineMode::Traditional + }, + multimodal_pipeline_enabled: enabled, + active_asr_provider: "local-qwen3".into(), + ..Default::default() + }; + assert_eq!( + fixture + .service + .claim_setup_prompt(readiness(asr, llm, omni), eligible(prefs)) + .await + .unwrap(), + expected + ); + } + assert_eq!(server.state.lock().unwrap().requests, 0); +} + +#[tokio::test] +async fn setup_prompt_claim_is_once_per_installation_and_survives_restart_without_http() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + let ready = readiness(true, true, false); + assert!(fixture + .service + .claim_setup_prompt(ready.clone(), eligible(UserPreferences::default())) + .await + .unwrap()); + assert!(!fixture + .service + .claim_setup_prompt(ready.clone(), eligible(UserPreferences::default())) + .await + .unwrap()); + let reopened = reopened_service(&fixture, &server); + assert!(!reopened + .claim_setup_prompt(ready, eligible(UserPreferences::default())) + .await + .unwrap()); + assert_eq!(server.state.lock().unwrap().requests, 0); + assert!(server.state.lock().unwrap().puts.is_empty()); +} + +#[tokio::test] +async fn setup_prompt_read_error_busy_or_stale_capture_never_consumes_the_flag() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + let denied = Arc::new(ReadyCredentials { + deny: true, + status: CredentialsStatus::default(), + reads: Arc::new(AtomicUsize::new(0)), + barrier: None, + }); + assert!(fixture + .service + .claim_setup_prompt(denied, eligible(UserPreferences::default())) + .await + .is_err()); + assert!(!fixture + .service + .claim_setup_prompt(readiness(true, true, false), || Ok(None)) + .await + .unwrap()); + let calls = AtomicUsize::new(0); + assert!(!fixture + .service + .claim_setup_prompt(readiness(true, true, false), || Ok(Some(( + UserPreferences::default(), + ( + Revision::new(1), + calls.fetch_add(1, Ordering::AcqRel) as u64 + ) + )))) + .await + .unwrap()); + assert!(fixture.vault.secrets.lock().unwrap().is_empty()); + assert!(fixture + .service + .claim_setup_prompt( + readiness(true, true, false), + eligible(UserPreferences::default()) + ) + .await + .unwrap()); + assert_eq!(server.state.lock().unwrap().requests, 0); +} + +#[tokio::test] +async fn setup_prompt_durable_write_failure_and_enabled_state_do_not_claim() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.vault.deny.store(true, Ordering::Release); + assert!(fixture + .service + .claim_setup_prompt( + readiness(true, true, false), + eligible(UserPreferences::default()) + ) + .await + .is_err()); + fixture.vault.deny.store(false, Ordering::Release); + assert!(fixture + .service + .claim_setup_prompt( + readiness(true, true, false), + eligible(UserPreferences::default()) + ) + .await + .unwrap()); + let other = Fixture::new(&server).await; + let local = LocalStorage::new( + other.root.join("protected"), + server.origin.clone(), + other.data.device().id.clone(), + other.vault.clone(), + ); + let settings = local::ClientSettings { + enabled: true, + ..Default::default() + }; + local.write("device", "client", settings).await.unwrap(); + let ready = readiness(true, true, false); + assert!(!other + .service + .claim_setup_prompt(ready.clone(), eligible(UserPreferences::default())) + .await + .unwrap()); + assert_eq!(ready.reads.load(Ordering::Acquire), 0); + assert_eq!(server.state.lock().unwrap().requests, 0); +} + +#[tokio::test] +async fn setup_prompt_service_busy_is_false_and_concurrent_claims_do_not_duplicate() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let ready = Arc::new(ReadyCredentials { + status: CredentialsStatus { + asr_configured: true, + llm_configured: true, + ..Default::default() + }, + deny: false, + reads: Arc::new(AtomicUsize::new(0)), + barrier: Some(barrier.clone()), + }); + let service = fixture.service.clone(); + let first = tokio::spawn(async move { + service + .claim_setup_prompt(ready, eligible(UserPreferences::default())) + .await + }); + barrier.wait().await; + assert!(!fixture + .service + .claim_setup_prompt( + readiness(true, true, false), + eligible(UserPreferences::default()) + ) + .await + .unwrap()); + barrier.wait().await; + assert!(first.await.unwrap().unwrap()); + assert_eq!(server.state.lock().unwrap().requests, 0); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs new file mode 100644 index 000000000..625087e1b --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs @@ -0,0 +1,1310 @@ +//! Service-level fault tests use a loopback fake OAuth/sync service and an +//! in-memory system vault. No user's data, account, or operating-system keychain. +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, Mutex, + }, +}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use futures_util::future::BoxFuture; +use serde_json::{json, Value}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, +}; + +use super::{ + local::LocalStorage, + service::{EncryptedSyncService, SyncServiceConfig, SyncServiceData}, + *, +}; +use crate::cloud_sync_e2ee_documents::{ + self as documents, ExportedDocuments, RestoreContext, SyncScope, ValidatedSyncDocuments, +}; +use crate::cloud_sync_e2ee_protocol::types::*; +use crate::cloud_sync_e2ee_store::gate::{ChangeOrigin, SyncChange}; +use crate::credentials::SyncSecretAccount; +use crate::{ + BackendError, CredentialKey, CredentialNamespace, CredentialStore, CredentialsStatus, + InMemoryCredentialStore, SecretValue, UserPreferences, +}; + +const PASSWORD: &str = "FixtureOpenLess!2684"; +const OWNER: &str = "118526"; +const CLIENT: &str = "Ov23liyv3nEucG7oMHNE"; + +#[derive(Default)] +struct Vault { + ordinary: InMemoryCredentialStore, + secrets: Mutex>, + deny: AtomicBool, + deny_remove: AtomicBool, +} +impl CredentialStore for Vault { + fn status( + &self, + prefs: UserPreferences, + ) -> BoxFuture<'static, Result> { + self.ordinary.status(prefs) + } + fn read( + &self, + key: CredentialKey, + ) -> BoxFuture<'static, Result, BackendError>> { + self.ordinary.read(key) + } + fn write( + &self, + key: CredentialKey, + value: SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + self.ordinary.write(key, value) + } + fn remove(&self, key: CredentialKey) -> BoxFuture<'static, Result<(), BackendError>> { + self.ordinary.remove(key) + } + fn read_sync_secret( + &self, + account: SyncSecretAccount, + ) -> BoxFuture<'static, Result, BackendError>> { + let result = if self.deny.load(Ordering::Acquire) { + Err(error("secure_storage_denied")) + } else { + Ok(self.secrets.lock().unwrap().get(account.as_str()).cloned()) + }; + Box::pin(async move { result }) + } + fn write_sync_secret( + &self, + account: SyncSecretAccount, + value: SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let result = if self.deny.load(Ordering::Acquire) { + Err(error("secure_storage_denied")) + } else { + self.secrets + .lock() + .unwrap() + .insert(account.as_str().into(), value); + Ok(()) + }; + Box::pin(async move { result }) + } + fn remove_sync_secret( + &self, + account: SyncSecretAccount, + ) -> BoxFuture<'static, Result<(), BackendError>> { + if self.deny_remove.load(Ordering::Acquire) { + return Box::pin(async { Err(error("secure_storage_denied")) }); + } + self.secrets.lock().unwrap().remove(account.as_str()); + Box::pin(async { Ok(()) }) + } +} + +struct Data { + documents: Mutex, + baseline: Mutex>, + generation: AtomicU64, + changes: tokio::sync::watch::Sender, + fail_restore: AtomicBool, + capture_gate: Mutex>>, + export_attempts: AtomicU64, +} +impl Data { + fn new() -> Arc { + let set = DocumentSet { + schema_version: 1, + exported_at: "2026-09-25T12:00:00Z".into(), + source_device: SourceDevice { + id: "11111111-1111-4111-8111-111111111111".into(), + os: "macos".into(), + arch: "aarch64".into(), + app_version: "2.0.0-Beta.3".into(), + }, + documents: vec![ + LogicalDocument { + id: "futureLayout".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value: json!("private-fixture-history"), + }, + LogicalDocument { + id: "llm:fixture-channel".into(), + kind: DocumentKind::Channels, + schema_version: 1, + value: json!({"id":"fixture-channel","namespace":"llm","providerType":"openai","name":"Fixture","enabled":true,"order":0,"active":true}), + }, + LogicalDocument { + id: "llm:fixture-channel".into(), + kind: DocumentKind::ProviderCredentials, + schema_version: 1, + value: json!({"channelId":"fixture-channel","namespace":"llm","accounts":{"ark.api_key":"sk-private-fixture-key"}}), + }, + ], + tombstones: vec![], + }; + documents::validate_sync_documents(set.clone(), Revision::new(0)).unwrap(); + let (changes, _) = tokio::sync::watch::channel(SyncChange { + generation: Revision::new(1), + origin: ChangeOrigin::User, + }); + Arc::new(Self { + documents: Mutex::new(set), + baseline: Mutex::new(None), + generation: AtomicU64::new(1), + changes, + fail_restore: AtomicBool::new(false), + capture_gate: Mutex::new(None), + export_attempts: AtomicU64::new(0), + }) + } + fn edit(&self, text: &str) { + self.documents + .lock() + .unwrap() + .documents + .iter_mut() + .find(|d| d.id == "futureLayout") + .unwrap() + .value = json!(text); + let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; + self.changes.send_replace(SyncChange { + generation: Revision::new(generation), + origin: ChangeOrigin::User, + }); + } +} +impl SyncServiceData for Data { + fn export(&self, _scope: SyncScope) -> BoxFuture<'_, SyncResult> { + Box::pin(async move { + self.export_attempts.fetch_add(1, Ordering::AcqRel); + if let Some(gate) = self.capture_gate.lock().unwrap().as_ref() { + gate.coherent_generation().map_err(document_error)?; + } + let set = self.documents.lock().unwrap().clone(); + let baseline = self.baseline.lock().unwrap(); + let documents = match baseline.as_ref() { + Some(base) => { + documents::record_missing_tombstones(set, base, "2026-09-25T12:01:00Z") + } + None => documents::validate_sync_documents(set, Revision::new(0)), + } + .map_err(document_error)?; + Ok(ExportedDocuments { + generation: Revision::new(self.generation.load(Ordering::Acquire)), + documents, + }) + }) + } + fn restore( + &self, + desired: ValidatedSyncDocuments, + context: RestoreContext, + ) -> BoxFuture<'_, SyncResult> { + Box::pin(async move { + if self.fail_restore.load(Ordering::Acquire) { + return Err(error("recovery_required")); + } + if self.generation.load(Ordering::Acquire) != context.local_generation.get() { + return Err(error("stale_preview")); + } + *self.documents.lock().unwrap() = desired.documents().clone(); + let generation = self.generation.fetch_add(1, Ordering::AcqRel) + 1; + self.changes.send_replace(SyncChange { + generation: Revision::new(generation), + origin: ChangeOrigin::Restore, + }); + Ok(ExportedDocuments { + generation: Revision::new(generation), + documents: desired, + }) + }) + } + fn recover(&self) -> BoxFuture<'_, SyncResult<()>> { + Box::pin(async move { + if self.fail_restore.load(Ordering::Acquire) { + Err(error("recovery_required")) + } else { + Ok(()) + } + }) + } + fn baseline( + &self, + _scope: SyncScope, + set: DocumentSet, + revision: Revision, + ) -> BoxFuture<'_, SyncResult<()>> { + Box::pin(async move { + *self.baseline.lock().unwrap() = + Some(documents::validate_sync_documents(set, revision).map_err(document_error)?); + Ok(()) + }) + } + fn generation(&self) -> SyncResult { + Ok(Revision::new(self.generation.load(Ordering::Acquire))) + } + fn device(&self) -> SourceDevice { + self.documents.lock().unwrap().source_device.clone() + } + fn changes(&self) -> tokio::sync::watch::Receiver { + self.changes.subscribe() + } +} + +#[derive(Default)] +struct Remote { + snapshot: Option, + revision: u64, + receipt: Option, + drop_response: bool, + puts: Vec>, + requests: usize, + owner: String, + delay_upload: bool, + reject_upload: bool, + hide_receipts: bool, + drop_metadata_response: bool, +} +struct Server { + origin: String, + state: Arc>, + upload_arrived: Arc, + allow_upload: Arc, + task: tokio::task::JoinHandle<()>, +} +impl Drop for Server { + fn drop(&mut self) { + self.task.abort(); + } +} +impl Server { + async fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let state = Arc::new(Mutex::new(Remote { + owner: OWNER.into(), + ..Remote::default() + })); + let shared = state.clone(); + let upload_arrived = Arc::new(tokio::sync::Notify::new()); + let allow_upload = Arc::new(tokio::sync::Notify::new()); + let arrived = upload_arrived.clone(); + let allow = allow_upload.clone(); + let task = tokio::spawn(async move { + loop { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let mut buffer = [0_u8; 8192]; + let header_end = loop { + let size = socket.read(&mut buffer).await.unwrap(); + if size == 0 { + return; + } + bytes.extend_from_slice(&buffer[..size]); + if let Some(offset) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + break offset + 4; + } + }; + let head = String::from_utf8(bytes[..header_end].to_vec()).unwrap(); + let mut first = head.lines().next().unwrap().split_whitespace(); + let method = first.next().unwrap(); + let path = first.next().unwrap(); + let headers: HashMap<_, _> = head + .lines() + .filter_map(|l| l.split_once(':')) + .map(|(k, v)| (k.to_ascii_lowercase(), v.trim().to_string())) + .collect(); + let length: usize = headers + .get("content-length") + .map_or(0, |v| v.parse().unwrap()); + while bytes.len() < header_end + length { + let size = socket.read(&mut buffer).await.unwrap(); + assert!(size > 0); + bytes.extend_from_slice(&buffer[..size]); + } + let body = &bytes[header_end..header_end + length]; + if method == "DELETE" && path == "/v1/auth/session" { + socket.write_all(b"HTTP/1.1 204 No Content\r\nCache-Control: no-store\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").await.unwrap(); + continue; + } + let drop_metadata = method == "GET" + && path == "/v1/me/vault" + && std::mem::take(&mut shared.lock().unwrap().drop_metadata_response); + if drop_metadata { + shared.lock().unwrap().requests += 1; + continue; + } + let hidden_receipt = + path.starts_with("/v1/me/operations/") && shared.lock().unwrap().hide_receipts; + if hidden_receipt { + let body = serde_json::to_vec(&json!({"error":{"code":"operation_not_found","message":"fixture receipt expired","requestId":"dddddddd-dddd-4ddd-8ddd-dddddddddddd"}})).unwrap(); + let reply = format!("HTTP/1.1 404 Not Found\r\nContent-Type: application/json\r\nCache-Control: no-store\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + socket.write_all(reply.as_bytes()).await.unwrap(); + socket.write_all(&body).await.unwrap(); + continue; + } + let rejected = if method == "PUT" { + let mut state = shared.lock().unwrap(); + std::mem::take(&mut state.reject_upload) + || headers.get("if-match") + != Some(&format!("\"fixture-{}\"", state.revision)) + } else { + false + }; + if rejected { + let body = serde_json::to_vec(&json!({"error":{"code":"revision_conflict","message":"fixture CAS lost","requestId":"dddddddd-dddd-4ddd-8ddd-dddddddddddd","currentRevision":"2"}})).unwrap(); + let reply = format!("HTTP/1.1 412 Precondition Failed\r\nContent-Type: application/json\r\nCache-Control: no-store\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()); + socket.write_all(reply.as_bytes()).await.unwrap(); + socket.write_all(&body).await.unwrap(); + continue; + } + let delayed = method == "PUT" && shared.lock().unwrap().delay_upload; + if delayed { + arrived.notify_one(); + allow.notified().await; + } + let (status, response, etag, disconnect) = { + let mut remote = shared.lock().unwrap(); + remote.requests += 1; + let owner = remote.owner.clone(); + let mut disconnect = false; + let response = match (method, path) { + ("GET", "/v1/capabilities") => { + json!({"protocolVersion":1,"cryptoProfile":"argon2id-xchacha20poly1305-v1","githubClientId":CLIENT,"maxHttpBodyBytes":25165824,"maxCiphertextBytes":16777232,"maxPlaintextJsonBytes":15728640,"idempotencyRetentionSeconds":604800,"maxBackupRetentionDays":30}) + } + ("GET", "/github/user") => { + json!({"id":owner.parse::().unwrap(),"login":"fixture-user"}) + } + ("POST", "/v1/auth/github") => { + json!({"protocolVersion":1,"accessToken":"a".repeat(43),"tokenType":"Bearer","expiresIn":900,"account":{"githubId":owner,"login":"fixture-user"}}) + } + ("GET", "/v1/me/vault") => match &remote.snapshot { + None => { + json!({"protocolVersion":1,"state":"empty","ownerGithubId":owner,"revision":"0","vaultId":null,"keyId":null,"updatedAt":null,"payloadSchemaVersion":null,"ciphertextBytes":0,"ciphertextSha256":null,"lastOperationId":null}) + } + Some(s) => { + json!({"protocolVersion":1,"state":"active","ownerGithubId":owner,"revision":remote.revision.to_string(),"vaultId":s["vaultId"],"keyId":s["keyId"],"updatedAt":"2026-09-25T12:00:00Z","payloadSchemaVersion":1,"ciphertextBytes":URL_SAFE_NO_PAD.decode(s["ciphertext"].as_str().unwrap()).unwrap().len(),"ciphertextSha256":s["ciphertextSha256"],"lastOperationId":s["operationId"]}) + } + }, + ("GET", "/v1/me/vault/snapshot") => remote.snapshot.clone().unwrap(), + ("PUT", "/v1/me/vault/snapshot") => { + let value: Value = serde_json::from_slice(body).unwrap(); + assert_eq!( + headers["if-match"], + format!("\"fixture-{}\"", remote.revision) + ); + assert_eq!(headers["idempotency-key"], value["operationId"]); + remote.revision += 1; + assert_eq!(value["revision"], remote.revision.to_string()); + remote.puts.push(body.to_vec()); + let receipt = json!({"operationId":value["operationId"],"status":"committed","kind":value["kind"],"committedRevision":value["revision"],"committedAt":"2026-09-25T12:00:00Z","vaultId":value["vaultId"],"ciphertextSha256":value["ciphertextSha256"]}); + remote.snapshot = Some(value); + remote.receipt = Some(receipt.clone()); + disconnect = std::mem::take(&mut remote.drop_response); + receipt + } + ("GET", path) if path.starts_with("/v1/me/operations/") => { + remote.receipt.clone().unwrap() + } + _ => panic!("unexpected fixture request: {method} {path}"), + }; + ( + "200 OK", + response, + format!("\"fixture-{}\"", remote.revision), + disconnect, + ) + }; + if disconnect { + continue; + } + let body = serde_json::to_vec(&response).unwrap(); + let reply = format!("HTTP/1.1 {status}\r\nContent-Type: application/json\r\nCache-Control: no-store\r\nIdempotency-Replayed: false\r\nContent-Length: {}\r\nETag: {etag}\r\nConnection: close\r\n\r\n", body.len()); + let _ = socket.write_all(reply.as_bytes()).await; + let _ = socket.write_all(&body).await; + } + }); + Self { + origin, + state, + upload_arrived, + allow_upload, + task, + } + } +} + +struct Fixture { + service: EncryptedSyncService, + data: Arc, + vault: Arc, + root: std::path::PathBuf, +} +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} +impl Fixture { + async fn new(server: &Server) -> Self { + let root = + std::env::temp_dir().join(format!("openless-e2ee-service-{}", uuid::Uuid::new_v4())); + let repos = crate::BackendRepositories::open(&root).unwrap(); + let vault = Arc::new(Vault::default()); + vault + .write( + CredentialKey::new(CredentialNamespace::Marketplace, None, "github.oauth_token") + .unwrap(), + SecretValue::new("fixture-github-token"), + ) + .await + .unwrap(); + let mut config = crate::MarketplaceConfig::new(&server.origin).unwrap(); + config.github_user_url = format!("{}/github/user", server.origin).parse().unwrap(); + let events = + crate::events::BackendEventPublisher::new(Arc::new(crate::events::EventBus::new(100))); + let marketplace = Arc::new( + crate::marketplace::MarketplaceService::new( + config, + vault.clone(), + repos.preferences, + repos.style_packs, + events.clone(), + Arc::new(AtomicU64::new(0)), + ) + .unwrap(), + ); + let data = Data::new(); + let local = LocalStorage::new( + root.join("protected"), + server.origin.clone(), + data.device().id.clone(), + vault.clone(), + ); + let service = EncryptedSyncService::new( + SyncServiceConfig { + origin: server.origin.clone(), + github_client_id: CLIENT.into(), + }, + marketplace, + local, + data.clone(), + events, + ); + Self { + service, + data, + vault, + root, + } + } + async fn prepare(&self) { + assert_eq!( + self.service + .prepare_enable(CONSENT_VERSION.into()) + .await + .unwrap() + .next_step, + EnableStep::Create + ); + } + async fn create(&self, remember: bool) -> SyncResult { + self.service + .create( + PASSWORD.into(), + PASSWORD.into(), + remember, + CONSENT_VERSION.into(), + "0".into(), + ) + .await + } +} + +#[tokio::test] +async fn disabled_start_and_changes_make_no_network_requests_or_key_entries() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture + .service + .start(Arc::new(crate::TokioTaskSpawner)) + .await + .unwrap(); + fixture.data.edit("changed while disabled"); + tokio::time::sleep(std::time::Duration::from_millis(850)).await; + assert_eq!(server.state.lock().unwrap().requests, 0); + assert!(fixture.vault.secrets.lock().unwrap().is_empty()); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn cached_connection_rebuilds_after_captured_proxy_policy_changes() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + // Inject only this connection's captured setting. No global proxy setting + // or environment variable changes, and every endpoint is loopback/direct. + fixture + .service + .connect_with_proxy_setting_for_test(false) + .await + .unwrap(); + let direct_requests = server.state.lock().unwrap().requests; + fixture + .service + .connect_with_proxy_setting_for_test(false) + .await + .unwrap(); + assert_eq!(server.state.lock().unwrap().requests, direct_requests); + fixture + .service + .connect_with_proxy_setting_for_test(true) + .await + .unwrap(); + let enabled_requests = server.state.lock().unwrap().requests; + assert!( + enabled_requests >= direct_requests + 2, + "new policy must recheck capabilities and exchange a new session" + ); + fixture + .service + .connect_with_proxy_setting_for_test(true) + .await + .unwrap(); + assert_eq!(server.state.lock().unwrap().requests, enabled_requests); + fixture + .service + .connect_with_proxy_setting_for_test(false) + .await + .unwrap(); + assert!(server.state.lock().unwrap().requests >= enabled_requests + 2); + assert!(server.state.lock().unwrap().puts.is_empty()); + fixture.service.shutdown().await; +} + +async fn wait_for_auto_sync(mut ready: impl FnMut() -> bool) { + tokio::time::timeout(std::time::Duration::from_secs(15), async { + while !ready() { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("automatic sync did not reach the expected state"); +} + +async fn start_ready_auto_sync(fixture: &Fixture) { + fixture.prepare().await; + fixture.create(true).await.unwrap(); + let before = fixture.data.export_attempts.load(Ordering::Acquire); + fixture + .service + .start(Arc::new(crate::TokioTaskSpawner)) + .await + .unwrap(); + wait_for_auto_sync(|| { + fixture.data.export_attempts.load(Ordering::Acquire) > before + && fixture.service.status().sync_state == SyncState::Ready + }) + .await; +} + +#[tokio::test] +async fn auto_sync_retries_capture_after_local_only_writer_without_another_user_event() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + let gate = crate::cloud_sync_e2ee_store::gate::SyncWriteGate::open( + fixture.root.join("capture-gate.json"), + ) + .unwrap(); + *fixture.data.capture_gate.lock().unwrap() = Some(gate.clone()); + let changes = gate.subscribe(); + let generation = gate.generation().unwrap(); + let local_only = gate.begin_mutation().unwrap(); + let before = fixture.data.export_attempts.load(Ordering::Acquire); + fixture.data.edit("user edit preceding a local-only write"); + wait_for_auto_sync(|| { + fixture.data.export_attempts.load(Ordering::Acquire) > before + && fixture + .service + .status() + .last_error + .as_ref() + .is_some_and(|failure| failure.code == "sync_documents_source_changed") + }) + .await; + local_only.commit(ChangeOrigin::LocalOnly).unwrap(); + assert_eq!(gate.generation().unwrap(), generation); + assert!( + !changes.has_changed().unwrap(), + "LocalOnly completion must not be mistaken for another user edit" + ); + wait_for_auto_sync(|| { + fixture + .service + .status() + .last_synced_local_generation + .as_deref() + == Some("2") + }) + .await; + assert_eq!(fixture.service.status().sync_state, SyncState::Ready); + assert_eq!(server.state.lock().unwrap().puts.len(), 2); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn auto_sync_capture_retry_is_bounded_while_local_writer_stays_busy() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + let gate = crate::cloud_sync_e2ee_store::gate::SyncWriteGate::open( + fixture.root.join("capture-gate.json"), + ) + .unwrap(); + *fixture.data.capture_gate.lock().unwrap() = Some(gate.clone()); + let local_only = gate.begin_mutation().unwrap(); + let before = fixture.data.export_attempts.load(Ordering::Acquire); + fixture + .data + .edit("pending while local writer remains active"); + wait_for_auto_sync(|| { + fixture.data.export_attempts.load(Ordering::Acquire) == before + 5 + && fixture.service.status().last_error.is_some() + }) + .await; + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + assert_eq!( + fixture.data.export_attempts.load(Ordering::Acquire), + before + 5 + ); + assert_eq!(server.state.lock().unwrap().puts.len(), 1); + local_only.commit(ChangeOrigin::LocalOnly).unwrap(); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn auto_sync_does_not_retry_an_unknown_upload_outcome() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + server.state.lock().unwrap().drop_response = true; + fixture.data.edit("upload with a lost acknowledgement"); + wait_for_auto_sync(|| fixture.service.status().sync_state == SyncState::OutcomeUnknown).await; + let requests = server.state.lock().unwrap().requests; + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + assert_eq!(server.state.lock().unwrap().requests, requests); + assert_eq!(server.state.lock().unwrap().puts.len(), 2); + assert!(fixture.service.status().pending_operation_id.is_some()); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn auto_sync_does_not_retry_network_failure() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + server.state.lock().unwrap().drop_metadata_response = true; + fixture + .data + .edit("pending after a metadata transport failure"); + wait_for_auto_sync(|| { + fixture + .service + .status() + .last_error + .as_ref() + .is_some_and(|failure| failure.code == "transport_failed") + }) + .await; + let requests = server.state.lock().unwrap().requests; + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + assert_eq!(server.state.lock().unwrap().requests, requests); + assert_eq!(server.state.lock().unwrap().puts.len(), 1); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn auto_sync_local_retry_releases_runtime_and_respects_pause() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + let gate = crate::cloud_sync_e2ee_store::gate::SyncWriteGate::open( + fixture.root.join("capture-gate.json"), + ) + .unwrap(); + *fixture.data.capture_gate.lock().unwrap() = Some(gate.clone()); + let local_only = gate.begin_mutation().unwrap(); + fixture.data.edit("pause before a delayed local retry"); + wait_for_auto_sync(|| { + fixture + .service + .status() + .last_error + .as_ref() + .is_some_and(|failure| failure.code == "sync_documents_source_changed") + }) + .await; + tokio::time::timeout( + std::time::Duration::from_millis(200), + fixture.service.set_enabled(false), + ) + .await + .expect("local retry must release the runtime lock") + .unwrap(); + let attempts = fixture.data.export_attempts.load(Ordering::Acquire); + local_only.commit(ChangeOrigin::LocalOnly).unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + assert_eq!( + fixture.data.export_attempts.load(Ordering::Acquire), + attempts + ); + assert_eq!(server.state.lock().unwrap().puts.len(), 1); + fixture.service.set_enabled(true).await.unwrap(); + wait_for_auto_sync(|| { + fixture + .service + .status() + .last_synced_local_generation + .as_deref() + == Some("2") + }) + .await; + assert_eq!(server.state.lock().unwrap().puts.len(), 2); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn auto_sync_local_retry_cannot_reconcile_a_later_manual_unknown_upload() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + start_ready_auto_sync(&fixture).await; + let gate = crate::cloud_sync_e2ee_store::gate::SyncWriteGate::open( + fixture.root.join("capture-gate.json"), + ) + .unwrap(); + *fixture.data.capture_gate.lock().unwrap() = Some(gate.clone()); + let local_only = gate.begin_mutation().unwrap(); + fixture + .data + .edit("manual upload takes over a local-busy automatic retry"); + wait_for_auto_sync(|| { + fixture + .service + .status() + .last_error + .as_ref() + .is_some_and(|failure| failure.code == "sync_documents_source_changed") + }) + .await; + local_only.commit(ChangeOrigin::LocalOnly).unwrap(); + server.state.lock().unwrap().drop_response = true; + assert_eq!( + fixture.service.sync_now().await.unwrap_err().message, + "outcome_unknown" + ); + let pending = fixture.service.status().pending_operation_id; + let requests = server.state.lock().unwrap().requests; + tokio::time::sleep(std::time::Duration::from_millis(1_000)).await; + assert_eq!( + server.state.lock().unwrap().requests, + requests, + "the obsolete retry must not query/replay the newer pending operation" + ); + assert_eq!(fixture.service.status().pending_operation_id, pending); + assert_eq!( + fixture.service.status().sync_state, + SyncState::OutcomeUnknown + ); + assert_eq!(server.state.lock().unwrap().puts.len(), 2); + fixture.service.shutdown().await; +} + +#[tokio::test] +async fn create_encrypts_real_data_and_wrong_password_never_changes_local_data() { + let server = Server::start().await; + let first = Fixture::new(&server).await; + first.prepare().await; + let status = first.create(false).await.unwrap(); + assert!(status.enabled); + assert_eq!(status.key_state, KeyState::Unlocked); + assert_eq!(status.remote_revision.as_deref(), Some("1")); + let raw = server.state.lock().unwrap().puts[0].clone(); + let request = std::str::from_utf8(&raw).unwrap(); + for private in [ + PASSWORD, + "sk-private-fixture-key", + "private-fixture-history", + "fixture-github-token", + ] { + assert!(!request.contains(private)); + } + let second = Fixture::new(&server).await; + assert_eq!( + second + .service + .prepare_enable(CONSENT_VERSION.into()) + .await + .unwrap() + .next_step, + EnableStep::Unlock + ); + let before = second.data.documents.lock().unwrap().clone(); + assert!(second + .service + .unlock("WrongPassword!9876".into(), true) + .await + .is_err()); + assert_eq!(*second.data.documents.lock().unwrap(), before); + assert_eq!(second.service.status().key_state, KeyState::Locked); + assert!(!second.service.status().enabled); + assert_eq!(server.state.lock().unwrap().revision, 1); + let status = second.service.unlock(PASSWORD.into(), false).await.unwrap(); + assert_eq!(status.key_state, KeyState::Unlocked); + assert!( + !status.enabled, + "a successful unlock must still require first restore review" + ); +} + +#[tokio::test] +async fn lost_upload_response_reconciles_one_commit_without_another_put() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + server.state.lock().unwrap().drop_response = true; + let failed = fixture.create(true).await.unwrap_err(); + assert_eq!(failed.message, "outcome_unknown"); + assert!(fixture.service.status().pending_operation_id.is_some()); + let status = fixture.service.sync_now().await.unwrap(); + assert!(status.pending_operation_id.is_none()); + assert!(status.last_successful_sync_at.is_some()); + assert_eq!(server.state.lock().unwrap().puts.len(), 1); + assert_eq!(server.state.lock().unwrap().revision, 1); +} + +#[tokio::test] +async fn changes_during_upload_remain_pending_after_its_receipt() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + server.state.lock().unwrap().delay_upload = true; + let service = fixture.service.clone(); + let upload = tokio::spawn(async move { + service + .create( + PASSWORD.into(), + PASSWORD.into(), + false, + CONSENT_VERSION.into(), + "0".into(), + ) + .await + }); + tokio::time::timeout( + std::time::Duration::from_secs(30), + server.upload_arrived.notified(), + ) + .await + .unwrap(); + fixture.data.edit("new text while upload in flight"); + server.allow_upload.notify_one(); + let status = upload.await.unwrap().unwrap(); + assert_eq!(status.last_synced_local_generation.as_deref(), Some("1")); + assert_eq!(status.local_generation, "2"); + assert_eq!(status.sync_state, SyncState::Pending); +} + +#[tokio::test] +async fn denied_system_key_storage_aborts_before_private_upload() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.vault.deny.store(true, Ordering::Release); + assert!(fixture + .service + .prepare_enable(CONSENT_VERSION.into()) + .await + .is_err()); + assert!(server.state.lock().unwrap().puts.is_empty()); + assert!(fixture.vault.secrets.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn review_rotation_lost_response_must_not_keep_old_key_after_receipt() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + server.state.lock().unwrap().drop_response = true; + let failure = fixture + .service + .change_password( + PASSWORD.into(), + "FreshOpenLess!8436".into(), + "FreshOpenLess!8436".into(), + true, + ) + .await + .unwrap_err(); + assert_eq!(failure.message, "outcome_unknown"); + let reconciled = fixture.service.sync_now().await; + assert!( + reconciled.is_ok(), + "receipt of password rotation must load its matching key: {:?}", + reconciled.as_ref().err() + ); +} + +#[tokio::test] +async fn review_lock_must_drop_memory_key_even_when_recovery_fails() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + fixture.data.fail_restore.store(true, Ordering::Release); + assert!(fixture.service.lock().await.is_err()); + assert_eq!( + fixture.service.status().key_state, + KeyState::Locked, + "lock must not preserve unlocked key on recovery error" + ); +} + +#[tokio::test] +async fn review_pending_receipt_must_not_undo_a_later_explicit_lock() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + fixture.data.edit("changed data"); + server.state.lock().unwrap().drop_response = true; + assert_eq!( + fixture.service.sync_now().await.unwrap_err().message, + "outcome_unknown" + ); + fixture.vault.deny_remove.store(true, Ordering::Release); + assert_eq!( + fixture.service.lock().await.unwrap_err().message, + "secure_storage_denied" + ); + assert_eq!(fixture.service.status().key_state, KeyState::Locked); + let _ = fixture.service.sync_now().await; + assert_eq!( + fixture.service.status().key_state, + KeyState::Locked, + "replaying a prior operation must preserve later explicit lock intent" + ); +} + +#[tokio::test] +async fn review_first_submit_explicit_cas_rejection_must_not_stick_as_unknown() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + fixture.data.edit("local edit racing another device"); + server.state.lock().unwrap().reject_upload = true; + let rejection = fixture.service.sync_now().await.unwrap_err(); + assert_eq!(rejection.message, "revision_conflict"); + assert!( + fixture.service.status().pending_operation_id.is_none(), + "a first submit definitively rejected by CAS must allow a fresh metadata/merge cycle" + ); +} + +#[tokio::test] +async fn cancelled_local_write_cannot_overwrite_a_later_lock_record() { + struct Paused { + entered: Arc, + release: Arc<(Mutex, std::sync::Condvar)>, + } + impl serde::Serialize for Paused { + fn serialize(&self, serializer: S) -> Result { + self.entered.notify_one(); + let (mutex, cv) = &*self.release; + let guard = mutex.lock().unwrap(); + drop(cv.wait_while(guard, |released| !*released).unwrap()); + json!({"enabled": true, "rememberKey": true}).serialize(serializer) + } + } + let root = std::env::temp_dir().join(format!( + "openless-e2ee-local-cancel-{}", + uuid::Uuid::new_v4() + )); + let store = LocalStorage::new( + root.clone(), + "https://sync.example".into(), + "fixture-device".into(), + Arc::new(Vault::default()), + ); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new((Mutex::new(false), std::sync::Condvar::new())); + let old_store = store.clone(); + let old = tokio::spawn({ + let entered = entered.clone(); + let release = release.clone(); + async move { + old_store + .write("device", "client", Paused { entered, release }) + .await + } + }); + entered.notified().await; + old.abort(); + let _ = old.await; + let current_store = store.clone(); + let mut current = tokio::spawn(async move { + current_store + .write( + "device", + "client", + json!({"enabled":false,"rememberKey":false}), + ) + .await + }); + let early = tokio::time::timeout(std::time::Duration::from_millis(30), &mut current).await; + { + *release.0.lock().unwrap() = true; + release.1.notify_all(); + } + let was_blocked = early.is_err(); + if was_blocked { + current.await.unwrap().unwrap(); + } + assert!( + was_blocked, + "new settings must queue behind the actual old blocking worker" + ); + let value: Value = store.read("device", "client").await.unwrap().unwrap(); + assert_eq!(value, json!({"enabled":false,"rememberKey":false})); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn device_identity_is_create_only_and_never_replaced_over_existing_ciphertext() { + let root = + std::env::temp_dir().join(format!("openless-e2ee-device-id-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let first = super::adapter::load_device_id(&root).unwrap(); + assert_eq!(first, super::adapter::load_device_id(&root).unwrap()); + assert!(!super::local::durable_create(&root.join("device-id"), b"replacement").unwrap()); + assert_eq!( + first, + std::fs::read_to_string(root.join("device-id")).unwrap() + ); + std::fs::create_dir_all(root.join("protected")).unwrap(); + std::fs::write(root.join("protected/state.enc"), b"existing ciphertext").unwrap(); + std::fs::remove_file(root.join("device-id")).unwrap(); + assert_eq!( + super::adapter::load_device_id(&root).unwrap_err().message, + "recovery_required" + ); + assert!(!root.join("device-id").exists()); + std::fs::remove_dir_all(root).unwrap(); +} + +#[tokio::test] +async fn expired_unknown_receipt_can_be_resolved_by_explicit_review_of_a_newer_head() { + let server = Server::start().await; + let first = Fixture::new(&server).await; + first.prepare().await; + first.create(true).await.unwrap(); + first.data.edit("first device change"); + server.state.lock().unwrap().drop_response = true; + assert_eq!( + first.service.sync_now().await.unwrap_err().message, + "outcome_unknown" + ); + let second = Fixture::new(&server).await; + second + .service + .prepare_enable(CONSENT_VERSION.into()) + .await + .unwrap(); + second.service.unlock(PASSWORD.into(), false).await.unwrap(); + let preview = second.service.preview_restore("2".into()).await.unwrap(); + second + .service + .apply_restore(preview.preview_id, RestoreMode::Replace, vec![]) + .await + .unwrap(); + second.data.edit("second device change"); + second.service.sync_now().await.unwrap(); + assert_eq!(server.state.lock().unwrap().revision, 3); + server.state.lock().unwrap().hide_receipts = true; + assert_eq!( + first.service.sync_now().await.unwrap_err().message, + "outcome_unknown" + ); + let preview = first.service.preview_restore("3".into()).await.unwrap(); + assert!(preview.unconfirmed_operation_id.is_some()); + assert!(!preview.conflicts.is_empty()); + let choices = preview + .conflicts + .iter() + .map(|item| SyncConflictChoice { + id: item.id.clone(), + side: ConflictSide::Cloud, + }) + .collect(); + let status = first + .service + .apply_restore(preview.preview_id, RestoreMode::Merge, choices) + .await + .unwrap(); + assert!(status.pending_operation_id.is_none()); + first.data.edit("a reviewed new operation"); + first.service.sync_now().await.unwrap(); + assert_eq!(server.state.lock().unwrap().revision, 4); +} + +#[tokio::test] +async fn round2_lock_must_remove_key_of_retired_unknown_password_rotation() { + let server = Server::start().await; + let first = Fixture::new(&server).await; + first.prepare().await; + first.create(true).await.unwrap(); + server.state.lock().unwrap().drop_response = true; + let second_password = "MiddleOpenLess!3827"; + assert_eq!( + first + .service + .change_password( + PASSWORD.into(), + second_password.into(), + second_password.into(), + true + ) + .await + .unwrap_err() + .message, + "outcome_unknown" + ); + let second = Fixture::new(&server).await; + second + .service + .prepare_enable(CONSENT_VERSION.into()) + .await + .unwrap(); + second + .service + .unlock(second_password.into(), false) + .await + .unwrap(); + let preview = second.service.preview_restore("2".into()).await.unwrap(); + second + .service + .apply_restore(preview.preview_id, RestoreMode::Replace, vec![]) + .await + .unwrap(); + let third_password = "ThirdOpenLess!4829"; + second + .service + .change_password( + second_password.into(), + third_password.into(), + third_password.into(), + true, + ) + .await + .unwrap(); + assert_eq!(server.state.lock().unwrap().revision, 3); + server.state.lock().unwrap().hide_receipts = true; + assert_eq!( + first.service.sync_now().await.unwrap_err().message, + "outcome_unknown" + ); + first + .service + .unlock(third_password.into(), true) + .await + .unwrap(); + let preview = first.service.preview_restore("3".into()).await.unwrap(); + assert!(preview.unconfirmed_operation_id.is_some()); + first.vault.deny_remove.store(true, Ordering::Release); + let denied = first + .service + .apply_restore(preview.preview_id.clone(), RestoreMode::Replace, vec![]) + .await + .unwrap_err(); + assert_eq!(denied.message, "secure_storage_denied"); + assert!( + first.service.status().pending_operation_id.is_some(), + "failed key deletion must preserve the pending cleanup index" + ); + first.vault.deny_remove.store(false, Ordering::Release); + first + .service + .apply_restore(preview.preview_id, RestoreMode::Replace, vec![]) + .await + .unwrap(); + first.service.lock().await.unwrap(); + let secrets = first.vault.secrets.lock().unwrap(); + assert!( + !secrets + .keys() + .any(|key| key.starts_with("cloud-sync.e2ee.key.")), + "explicit lock must forget even the retired uncertain password key" + ); +} + +#[tokio::test] +async fn round2_refuses_previously_seen_revision_rollback_before_local_changes() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + let old = server.state.lock().unwrap().snapshot.clone(); + fixture.data.edit("newest local value"); + fixture.service.sync_now().await.unwrap(); + let before = fixture.data.documents.lock().unwrap().clone(); + let puts = server.state.lock().unwrap().puts.len(); + { + let mut remote = server.state.lock().unwrap(); + remote.snapshot = old; + remote.revision = 1; + } + assert_eq!( + fixture.service.sync_now().await.unwrap_err().message, + "revision_rollback" + ); + assert_eq!( + fixture.service.status().remote_revision.as_deref(), + Some("2") + ); + assert_eq!(fixture.data.documents.lock().unwrap().clone(), before); + assert_eq!(server.state.lock().unwrap().puts.len(), puts); +} + +#[tokio::test] +async fn round2_sync_key_deletion_failure_must_not_prevent_account_sign_out() { + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + fixture.prepare().await; + fixture.create(true).await.unwrap(); + fixture.vault.deny_remove.store(true, Ordering::Release); + assert_eq!( + fixture.service.sign_out().await.unwrap_err().message, + "secure_storage_denied" + ); + let oauth = fixture + .vault + .read( + CredentialKey::new(CredentialNamespace::Marketplace, None, "github.oauth_token") + .unwrap(), + ) + .await + .unwrap(); + assert!( + oauth.is_none(), + "a sync key cleanup failure must still attempt ordinary account token removal" + ); + assert_eq!(fixture.service.status().auth_state, AuthState::SignedOut); +} + +#[path = "setup_prompt_tests.rs"] +mod setup_prompt_tests; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs new file mode 100644 index 000000000..39ca280b5 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/export.rs @@ -0,0 +1,385 @@ +//! Explicit source-to-document registration. This module never scans user directories. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{json, Map, Value}; + +use crate::cloud_sync_e2ee_protocol::types::{DocumentKind, DocumentSet, LogicalDocument}; +use crate::credentials::{CredentialKey, CredentialNamespace, CredentialStore}; + +use super::registry::{ + credential_accounts, excluded_extension_key, is_device_channel, preference_field, + PreferenceClass, +}; +use super::types::*; +use super::validate::validate_sync_documents; + +pub async fn export_sync_documents( + source: &dyn SyncDocumentSource, +) -> DocumentResult { + export_snapshot(source.capture().await?) +} + +/// Export an already coherent capture. The caller must not substitute empty stores on errors. +pub fn export_snapshot(mut snapshot: ExportSnapshot) -> DocumentResult { + let mut documents = Vec::new(); + let mut device_preferences = Map::new(); + let preferences = snapshot + .preferences + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)?; + for (key, value) in preferences { + match preference_field(key).map(|field| field.class) { + Some(PreferenceClass::Excluded) => {} + Some(PreferenceClass::DeviceProfile) => { + device_preferences.insert(key.clone(), value.clone()); + } + Some(PreferenceClass::Portable) => documents.push(document( + DocumentKind::Preferences, + key.clone(), + value.clone(), + )), + None => { + if excluded_extension_key(key) { + return Err(DocumentError::ExcludedField); + } + documents.push(document( + DocumentKind::Preferences, + key.clone(), + value.clone(), + )); + } + } + } + for (key, value) in snapshot + .ui_preferences + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)? + { + if excluded_extension_key(key) { + return Err(DocumentError::ExcludedField); + } + documents.push(document( + DocumentKind::UiPreferences, + key.clone(), + value.clone(), + )); + } + + let mut credentials = BTreeMap::new(); + for record in std::mem::take(&mut snapshot.provider_credentials) { + if credentials.insert(record.document_id(), record).is_some() { + return Err(DocumentError::DuplicateId); + } + } + let mut seen_channels = BTreeSet::new(); + let mut device_channels = Vec::new(); + let mut device_credentials = Vec::new(); + for channel in std::mem::take(&mut snapshot.channels) { + let id = channel.document_id(); + if !seen_channels.insert(id.clone()) { + return Err(DocumentError::DuplicateId); + } + let credential = credentials + .remove(&id) + .ok_or(DocumentError::InvalidReference)?; + if is_device_channel(channel.namespace, &channel.provider_type) { + device_channels.push(channel); + device_credentials.push(credential); + } else { + documents.push(document( + DocumentKind::Channels, + id.clone(), + to_value(&channel)?, + )); + documents.push(document( + DocumentKind::ProviderCredentials, + id, + to_value(&credential)?, + )); + } + } + if !credentials.is_empty() { + return Err(DocumentError::InvalidReference); + } + + for (sort_index, mut entry) in std::mem::take(&mut snapshot.dictionary) + .into_iter() + .enumerate() + { + entry + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("sortIndex".into(), json!(sort_index)); + let id = record_id(entry.expose())?; + documents.push(document(DocumentKind::Dictionary, id, entry.into_value())); + } + for preset in std::mem::take(&mut snapshot.vocabulary_presets) { + let prefix = match preset.origin { + PresetOrigin::Custom => "custom", + PresetOrigin::Override => "override", + PresetOrigin::BuiltinState => "builtin", + }; + documents.push(document( + DocumentKind::VocabularyPresets, + format!("{prefix}:{}", preset.id), + to_value(&preset)?, + )); + } + for (sort_index, mut entry) in std::mem::take(&mut snapshot.corrections) + .into_iter() + .enumerate() + { + entry + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("sortIndex".into(), json!(sort_index)); + let id = record_id(entry.expose())?; + documents.push(document(DocumentKind::Corrections, id, entry.into_value())); + } + for mut entry in std::mem::take(&mut snapshot.style_packs) { + let id = record_id(entry.pack.expose())?; + let pack = entry + .pack + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + pack.remove("iconPath"); + pack.remove("active"); + documents.push(document(DocumentKind::StylePacks, id, to_value(&entry)?)); + } + for (sort_index, mut entry) in std::mem::take(&mut snapshot.history) + .into_iter() + .enumerate() + { + entry + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("sortIndex".into(), json!(sort_index)); + let id = record_id(entry.expose())?; + // Media never travels. Availability is recomputed against this device's recordings. + let history = entry + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + history.insert("hasAudioRecording".into(), Value::Bool(false)); + documents.push(document(DocumentKind::History, id, entry.into_value())); + } + for entry in std::mem::take(&mut snapshot.activity) { + documents.push(document( + DocumentKind::Activity, + entry.document_id(), + to_value(&entry)?, + )); + } + let profile = DeviceProfileRecord { + device: snapshot.source_device.clone(), + preferences: SecretJson::new(Value::Object(device_preferences)), + channels: device_channels, + provider_credentials: device_credentials, + window_positions: std::mem::take(&mut snapshot.window_positions), + }; + documents.push(document( + DocumentKind::DeviceProfile, + snapshot.source_device.id.clone(), + to_value(&profile)?, + )); + + // Retained documents must be explicit source profiles or unknown preferences. Never let + // an archive silently replace live source fields or resurrect a deleted record. + for retained in std::mem::take(&mut snapshot.retained_documents) { + let permitted = retained.kind == DocumentKind::DeviceProfile + || retained.kind == DocumentKind::Preferences + && preference_field(&retained.id).is_none() + || retained.kind == DocumentKind::UiPreferences + && !matches!(retained.id.as_str(), "locale" | "fontScale"); + if !permitted { + return Err(DocumentError::InvalidDocument); + } + if let Some(existing) = documents + .iter() + .find(|doc| doc.kind == retained.kind && doc.id == retained.id) + { + if existing != &retained { + return Err(DocumentError::SourceChanged); + } + } else { + documents.push(retained); + } + } + let set = DocumentSet { + schema_version: DOCUMENT_SCHEMA_VERSION, + exported_at: snapshot.exported_at, + source_device: snapshot.source_device, + documents, + tombstones: snapshot.tombstones, + }; + Ok(ExportedDocuments { + generation: snapshot.generation, + documents: validate_sync_documents(set, snapshot.base_revision)?, + }) +} + +/// Reads only registered logical accounts. Each error aborts the entire export. +/// Omni IDs must enumerate every stored provider entry; None would read only the active one. +pub async fn export_provider_credentials_for_sync( + store: &dyn CredentialStore, + channels: &[ChannelRecord], +) -> DocumentResult> { + let mut records = Vec::with_capacity(channels.len()); + let mut seen = BTreeSet::new(); + for channel in channels { + if !seen.insert(channel.document_id()) { + return Err(DocumentError::DuplicateId); + } + let namespace = match channel.namespace { + SyncNamespace::Asr => CredentialNamespace::Asr, + SyncNamespace::Llm => CredentialNamespace::Llm, + SyncNamespace::Omni => CredentialNamespace::Omni, + }; + let mut accounts = BTreeMap::new(); + for account in credential_accounts(channel.namespace) { + let key = CredentialKey::new(namespace, Some(channel.id.clone()), *account) + .map_err(|_| DocumentError::InvalidDocument)?; + if let Some(secret) = store + .read(key) + .await + .map_err(|_| DocumentError::CaptureFailed)? + { + accounts.insert((*account).to_string(), secret.expose_secret().to_string()); + } + } + records.push(ProviderCredentialRecord { + channel_id: channel.id.clone(), + namespace: channel.namespace, + accounts, + }); + } + Ok(records) +} + +/// Converts a registered store's typed vector to secret rows, without dropping any row. +pub fn secret_rows(rows: &[T]) -> DocumentResult> { + rows.iter().map(SecretJson::from_serializable).collect() +} + +/// Convert all preset state, including enabled builtins, to independently mergeable IDs. +pub fn vocabulary_records( + custom: &[crate::types::VocabPreset], + overrides: &[crate::types::VocabPreset], + disabled_builtin_ids: &[String], + builtin_ids: &[String], +) -> DocumentResult> { + if disabled_builtin_ids + .iter() + .any(|id| !builtin_ids.contains(id)) + { + return Err(DocumentError::InvalidReference); + } + let mut result = Vec::new(); + for (origin, entries) in [ + (PresetOrigin::Custom, custom), + (PresetOrigin::Override, overrides), + ] { + for (order, entry) in entries.iter().enumerate() { + result.push(VocabularyPresetRecord { + id: entry.id.clone(), + origin, + name: Some(entry.name.clone()), + phrases: entry.phrases.clone(), + enabled: true, + order: u32::try_from(order).map_err(|_| DocumentError::PayloadTooLarge)?, + }); + } + } + for id in builtin_ids { + result.push(VocabularyPresetRecord { + id: id.clone(), + origin: PresetOrigin::BuiltinState, + name: None, + phrases: Vec::new(), + enabled: !disabled_builtin_ids.contains(id), + order: 0, + }); + } + Ok(result) +} + +/// Read icons through the repository's bounded ID-based resource reader, not iconPath. +pub fn export_style_packs( + store: &crate::style_pack_store::StylePackStore, +) -> DocumentResult> { + store + .list() + .map_err(|_| DocumentError::CaptureFailed)? + .iter() + .map(|pack| { + let icon = match store + .icon_data_url(&pack.id) + .map_err(|_| DocumentError::CaptureFailed)? + { + Some(data) => { + let (mime, encoded) = data + .strip_prefix("data:") + .and_then(|s| s.split_once(";base64,")) + .ok_or(DocumentError::InvalidDocument)?; + Some(IconAsset { + mime: mime.to_string(), + base64: encoded.to_string(), + }) + } + None if pack.icon_path.is_some() => return Err(DocumentError::CaptureFailed), + None => None, + }; + Ok(StylePackRecord { + pack: SecretJson::from_serializable(pack)?, + icon, + }) + }) + .collect() +} + +pub(crate) fn document(kind: DocumentKind, id: String, value: Value) -> LogicalDocument { + LogicalDocument { + id, + kind, + schema_version: DOCUMENT_SCHEMA_VERSION, + value, + } +} + +pub(crate) fn to_value(value: &impl serde::Serialize) -> DocumentResult { + serde_json::to_value(value).map_err(|_| DocumentError::InvalidDocument) +} + +pub(crate) fn record_id(value: &Value) -> DocumentResult { + value + .get("id") + .and_then(Value::as_str) + .map(str::to_string) + .ok_or(DocumentError::InvalidDocument) +} + +/// A stable per-source aggregate, not a sum of two already merged device totals. +pub fn activity_record( + source_device_id: &str, + day: &crate::activity::ActivityDay, +) -> ActivityRecord { + ActivityRecord { + source_device_id: source_device_id.into(), + date: day.date.clone(), + count: day.count.into(), + chars: day.chars, + duration_ms: day.duration_ms, + } +} + +/// Convenience for UI bridges; unrelated localStorage keys must never be passed here. +pub fn ui_preferences(locale: &str, font_scale: &str) -> SecretJson { + SecretJson::new(json!({"locale": locale, "fontScale": font_scale})) +} 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 new file mode 100644 index 000000000..bdc8ac062 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/merge.rs @@ -0,0 +1,416 @@ +//! Three-way logical merge. Timestamps never choose a winning value. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::cloud_sync_e2ee_protocol::types::{ + DocumentKind, DocumentSet, LogicalDocument, Revision, Tombstone, +}; + +use super::types::*; +use super::validate::{timestamp, validate_sync_documents}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConflictReason { + BothModified, + DeleteModify, + NoCommonBaseline, +} + +/// No local/cloud values, value hashes, excerpts, channel names, or secret lengths reach UI. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct RedactedConflict { + pub conflict_id: String, + pub kind: DocumentKind, + pub reason: ConflictReason, + pub is_credential_unit: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ConflictSide { + Local, + Remote, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ConflictChoice { + pub conflict_id: String, + pub side: ConflictSide, +} + +#[derive(Clone, PartialEq)] +enum Entry { + Live(LogicalDocument), + Deleted(Tombstone), +} + +type Unit = BTreeMap; +type UnitMap = BTreeMap; + +pub struct MergePreview { + source: DocumentSet, + revision: Revision, + accepted: UnitMap, + unresolved: BTreeMap, Option)>, + conflicts: Vec, + active_choices: BTreeMap>, + active_conflicts: BTreeMap, Option)>, +} + +impl fmt::Debug for MergePreview { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("MergePreview([REDACTED])") + } +} + +impl MergePreview { + pub fn conflicts(&self) -> &[RedactedConflict] { + &self.conflicts + } + + pub fn resolve(mut self, choices: &[ConflictChoice]) -> DocumentResult { + let mut selected = BTreeMap::new(); + for choice in choices { + if selected + .insert(choice.conflict_id.clone(), choice.side) + .is_some() + { + return Err(DocumentError::ConflictChoiceRequired); + } + } + if selected.keys().collect::>() + != self + .unresolved + .keys() + .chain(self.active_conflicts.keys()) + .collect::>() + { + return Err(DocumentError::ConflictChoiceRequired); + } + for (id, (key, local, remote)) in std::mem::take(&mut self.unresolved) { + let chosen = match selected[&id] { + ConflictSide::Local => local, + ConflictSide::Remote => remote, + }; + if let Some(unit) = chosen { + self.accepted.insert(key, unit); + } + } + for (id, (namespace, local, remote)) in std::mem::take(&mut self.active_conflicts) { + self.active_choices.insert( + namespace, + match selected[&id] { + ConflictSide::Local => local, + ConflictSide::Remote => remote, + }, + ); + } + self.source.documents.clear(); + self.source.tombstones.clear(); + for unit in self.accepted.values() { + for entry in unit.values() { + match entry { + Entry::Live(doc) => self.source.documents.push(doc.clone()), + Entry::Deleted(tombstone) => self.source.tombstones.push(tombstone.clone()), + } + } + } + for (namespace, choice) in &self.active_choices { + let mut found = choice.is_none(); + for doc in self + .source + .documents + .iter_mut() + .filter(|doc| doc.kind == DocumentKind::Channels) + { + let mut channel: ChannelRecord = serde_json::from_value(doc.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + if channel.namespace == *namespace { + channel.active = choice.as_deref() == Some(channel.id.as_str()); + if channel.active { + channel.enabled = true; + found = true; + } + doc.value = serde_json::to_value(channel) + .map_err(|_| DocumentError::InvalidDocument)?; + } + } + if !found { + return Err(DocumentError::ConflictChoiceRequired); + } + } + validate_sync_documents(self.source, self.revision) + } +} + +/// Different settings/record IDs merge independently; a channel and all its credentials do not. +pub fn diff_sync_documents( + baseline: Option<&ValidatedSyncDocuments>, + local: &ValidatedSyncDocuments, + remote: &ValidatedSyncDocuments, +) -> DocumentResult { + let base = baseline.map(|set| units(&set.set)); + let left = units(&local.set); + let right = units(&remote.set); + let keys: BTreeSet<_> = base + .iter() + .flat_map(|map| map.keys()) + .chain(left.keys()) + .chain(right.keys()) + .cloned() + .collect(); + let mut preview = MergePreview { + source: local.set.clone(), + revision: local.revision.max(remote.revision), + accepted: BTreeMap::new(), + unresolved: BTreeMap::new(), + conflicts: Vec::new(), + active_choices: BTreeMap::new(), + active_conflicts: BTreeMap::new(), + }; + for key in keys { + let ancestor = base.as_ref().and_then(|map| map.get(&key)); + let local_unit = left.get(&key); + let remote_unit = right.get(&key); + // v1 never garbage-collects tombstones. A missing baseline identity is not a delete. + if ancestor.is_some() && (local_unit.is_none() || remote_unit.is_none()) { + return Err(DocumentError::MissingTombstone); + } + let chosen = if equivalent(local_unit, remote_unit) { + Some(prefer_tombstone_revision(local_unit, remote_unit)) + } else if baseline.is_none() { + if local_unit.is_none() { + Some(remote_unit.cloned()) + } else if remote_unit.is_none() { + Some(local_unit.cloned()) + } else { + None + } + } else if is_deleted(ancestor) && (has_live(local_unit) || has_live(remote_unit)) { + // A stale device cannot revive a deletion already present in the common baseline. + None + } else if equivalent(local_unit, ancestor) { + Some(remote_unit.cloned()) + } else if equivalent(remote_unit, ancestor) { + Some(local_unit.cloned()) + } else { + None + }; + if let Some(chosen) = chosen { + if let Some(unit) = chosen { + preview.accepted.insert(key, unit); + } + continue; + } + let conflict_id = conflict_id(&key); + let reason = if is_deleted(local_unit) || is_deleted(remote_unit) || is_deleted(ancestor) { + ConflictReason::DeleteModify + } else if baseline.is_none() { + ConflictReason::NoCommonBaseline + } else { + ConflictReason::BothModified + }; + preview.conflicts.push(RedactedConflict { + conflict_id: conflict_id.clone(), + kind: key.kind, + reason, + is_credential_unit: key.kind == DocumentKind::Channels, + }); + preview.unresolved.insert( + conflict_id, + (key, local_unit.cloned(), remote_unit.cloned()), + ); + } + for namespace in [SyncNamespace::Asr, SyncNamespace::Llm, SyncNamespace::Omni] { + let local_active = active_channel(&local.set, namespace)?; + let remote_active = active_channel(&remote.set, namespace)?; + let base_active = baseline + .map(|set| active_channel(&set.set, namespace)) + .transpose()? + .flatten(); + let chosen = if local_active == remote_active { + Some(local_active.clone()) + } else if baseline.is_some() && local_active == base_active { + Some(remote_active.clone()) + } else if baseline.is_some() && remote_active == base_active { + Some(local_active.clone()) + } else if baseline.is_none() && local_active.is_none() { + Some(remote_active.clone()) + } else if baseline.is_none() && remote_active.is_none() { + Some(local_active.clone()) + } else { + None + }; + if let Some(chosen) = chosen { + preview.active_choices.insert(namespace, chosen); + } else { + let id = format!( + "active-selection-{}", + match namespace { + SyncNamespace::Asr => "asr", + SyncNamespace::Llm => "llm", + SyncNamespace::Omni => "omni", + } + ); + let conflict_id = conflict_id(&DocumentKey { + kind: DocumentKind::Channels, + id, + }); + preview.conflicts.push(RedactedConflict { + conflict_id: conflict_id.clone(), + kind: DocumentKind::Channels, + reason: if baseline.is_some() { + ConflictReason::BothModified + } else { + ConflictReason::NoCommonBaseline + }, + is_credential_unit: true, + }); + preview + .active_conflicts + .insert(conflict_id, (namespace, local_active, remote_active)); + } + } + Ok(preview) +} + +fn active_channel(set: &DocumentSet, namespace: SyncNamespace) -> DocumentResult> { + for doc in set + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Channels) + { + let channel: ChannelRecord = serde_json::from_value(doc.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + if channel.namespace == namespace && channel.active { + return Ok(Some(channel.id)); + } + } + Ok(None) +} + +fn units(set: &DocumentSet) -> UnitMap { + let mut result: UnitMap = BTreeMap::new(); + for doc in &set.documents { + let key = DocumentKey { + kind: doc.kind, + id: doc.id.clone(), + }; + result + .entry(unit_key(&key)) + .or_default() + .insert(key, Entry::Live(doc.clone())); + } + for tombstone in &set.tombstones { + let key = DocumentKey { + kind: tombstone.kind, + id: tombstone.id.clone(), + }; + result + .entry(unit_key(&key)) + .or_default() + .insert(key, Entry::Deleted(tombstone.clone())); + } + result +} + +fn unit_key(key: &DocumentKey) -> DocumentKey { + DocumentKey { + kind: if key.kind == DocumentKind::ProviderCredentials { + DocumentKind::Channels + } else { + key.kind + }, + id: key.id.clone(), + } +} + +fn equivalent(left: Option<&Unit>, right: Option<&Unit>) -> bool { + match (left, right) { + (None, None) => true, + (Some(left), Some(right)) if left.len() == right.len() => { + left.iter() + .all(|(key, value)| match (value, right.get(key)) { + (Entry::Deleted(_), Some(Entry::Deleted(_))) => true, + (_, Some(other)) => value == other, + _ => false, + }) + } + _ => false, + } +} + +fn prefer_tombstone_revision(left: Option<&Unit>, right: Option<&Unit>) -> Option { + let mut selected = left?.clone(); + if let Some(right) = right { + for (key, entry) in right { + if let (Entry::Deleted(candidate), Some(Entry::Deleted(current))) = + (entry, selected.get(key)) + { + if candidate.base_revision > current.base_revision { + selected.insert(key.clone(), entry.clone()); + } + } + } + } + Some(selected) +} + +fn is_deleted(unit: Option<&Unit>) -> bool { + unit.is_some_and(|unit| { + unit.values() + .any(|entry| matches!(entry, Entry::Deleted(_))) + }) +} +fn has_live(unit: Option<&Unit>) -> bool { + unit.is_some_and(|unit| unit.values().any(|entry| matches!(entry, Entry::Live(_)))) +} + +fn conflict_id(key: &DocumentKey) -> String { + let identity = serde_json::to_vec(key).expect("fixed conflict identity serializes"); + format!("{:x}", Sha256::digest(identity)) +} + +/// Materialize explicit local deletes relative to the last accepted baseline; keep all old marks. +/// Call after a coherent capture, never use a remote snapshot as the local deletion baseline. +pub fn record_missing_tombstones( + mut current: DocumentSet, + baseline: &ValidatedSyncDocuments, + deleted_at: &str, +) -> DocumentResult { + timestamp(deleted_at)?; + let mut live: BTreeSet<_> = current + .documents + .iter() + .map(|doc| (doc.kind, doc.id.clone())) + .collect(); + live.extend( + current + .tombstones + .iter() + .map(|doc| (doc.kind, doc.id.clone())), + ); + for old in &baseline.set.tombstones { + if live.insert((old.kind, old.id.clone())) { + current.tombstones.push(old.clone()); + } + } + for old in &baseline.set.documents { + if live.insert((old.kind, old.id.clone())) { + current.tombstones.push(Tombstone { + id: old.id.clone(), + kind: old.kind, + deleted_at: deleted_at.into(), + base_revision: baseline.revision, + }); + } + } + validate_sync_documents(current, baseline.revision) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs new file mode 100644 index 000000000..1a445493d --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs @@ -0,0 +1,29 @@ +//! Client-side logical documents for encrypted sync. No network or UI effects live here. + +mod export; +mod merge; +pub mod registry; +mod restore; +mod types; +mod validate; + +pub use export::{ + activity_record, export_provider_credentials_for_sync, export_snapshot, export_style_packs, + export_sync_documents, secret_rows, ui_preferences, vocabulary_records, +}; +pub use merge::{ + diff_sync_documents, record_missing_tombstones, ConflictChoice, ConflictReason, ConflictSide, + MergePreview, RedactedConflict, +}; +pub use restore::{ + apply_sync_restore, prepare_sync_restore, recover_sync_restore, CryptoJournalProtector, + JournalProtector, JournalStore, RecoveryOutcome, RestorePlan, RestoreReceipt, SealedJournal, +}; +pub use types::*; +pub(crate) use validate::validate_scope; +pub use validate::{validate_credential_set, validate_icon, validate_sync_documents}; + +#[cfg(test)] +mod restore_tests; +#[cfg(test)] +mod tests; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs new file mode 100644 index 000000000..5c821db5c --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/registry.rs @@ -0,0 +1,929 @@ +//! Explicit sync classification for every persisted UserPreferences field. +use super::types::{DocumentError, DocumentResult, SyncNamespace}; +use serde_json::Value; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PreferenceClass { + Portable, + DeviceProfile, + Excluded, +} +#[derive(Debug, Clone, Copy)] +pub enum PreferenceShape { + Boolean, + Unsigned8, + Unsigned16, + Unsigned32, + Number, + Text, + TextList, + OptionalText, + OptionalUnsigned32, + Object, + OptionalObject, + ObjectList, +} +#[derive(Debug, Clone, Copy)] +pub struct PreferenceField { + pub rust_name: &'static str, + pub key: &'static str, + pub class: PreferenceClass, + pub shape: PreferenceShape, + pub reason: &'static str, +} +pub const PREFERENCE_FIELDS: &[PreferenceField] = &[ + PreferenceField { + rust_name: "hotkey", + key: "hotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Object, + reason: "device_bound", + }, + PreferenceField { + rust_name: "dictation_hotkey", + key: "dictationHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Object, + reason: "device_bound", + }, + PreferenceField { + rust_name: "default_mode", + key: "defaultMode", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "enabled_modes", + key: "enabledModes", + class: PreferenceClass::Portable, + shape: PreferenceShape::TextList, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "active_style_pack_id", + key: "activeStylePackId", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "style_system_prompts", + key: "styleSystemPrompts", + class: PreferenceClass::Portable, + shape: PreferenceShape::Object, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "custom_style_prompts", + key: "customStylePrompts", + class: PreferenceClass::Portable, + shape: PreferenceShape::Object, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "launch_at_login", + key: "launchAtLogin", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_os_autostart", + }, + PreferenceField { + rust_name: "show_capsule", + key: "showCapsule", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "capsule_style", + key: "capsuleStyle", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "capsule_transcript_enabled", + key: "capsuleTranscriptEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "capsule_transcript_font_size", + key: "capsuleTranscriptFontSize", + class: PreferenceClass::Portable, + shape: PreferenceShape::Unsigned8, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "mute_during_recording", + key: "muteDuringRecording", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "stable_transcription_enabled", + key: "stableTranscriptionEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "audio_cue_on_record", + key: "audioCueOnRecord", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "silence_auto_stop_enabled", + key: "silenceAutoStopEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "silence_auto_stop_seconds", + key: "silenceAutoStopSeconds", + class: PreferenceClass::Portable, + shape: PreferenceShape::Number, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "microphone_device_name", + key: "microphoneDeviceName", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "active_asr_provider", + key: "activeAsrProvider", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "selection_in_channel_unit", + }, + PreferenceField { + rust_name: "active_llm_provider", + key: "activeLlmProvider", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "selection_in_channel_unit", + }, + PreferenceField { + rust_name: "pipeline_mode", + key: "pipelineMode", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "multimodal_pipeline_enabled", + key: "multimodalPipelineEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "active_omni_provider", + key: "activeOmniProvider", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "selection_in_channel_unit", + }, + PreferenceField { + rust_name: "llm_thinking_enabled", + key: "llmThinkingEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "use_system_proxy", + key: "useSystemProxy", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Boolean, + reason: "device_bound", + }, + PreferenceField { + rust_name: "restore_clipboard_after_paste", + key: "restoreClipboardAfterPaste", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Boolean, + reason: "device_bound", + }, + PreferenceField { + rust_name: "paste_shortcut", + key: "pasteShortcut", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "allow_non_tsf_insertion_fallback", + key: "allowNonTsfInsertionFallback", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Boolean, + reason: "device_bound", + }, + PreferenceField { + rust_name: "windows_insertion_mode", + key: "windowsInsertionMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "windows_sendinput_newline_mode", + key: "windowsSendInputNewlineMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "macos_newline_mode", + key: "macosNewlineMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "windows_sendinput_insertion_only", + key: "windowsSendInputInsertionOnly", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Boolean, + reason: "device_bound", + }, + PreferenceField { + rust_name: "windows_show_openless_in_keyboard_list", + key: "windowsShowOpenlessInKeyboardList", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Boolean, + reason: "device_bound", + }, + PreferenceField { + rust_name: "working_languages", + key: "workingLanguages", + class: PreferenceClass::Portable, + shape: PreferenceShape::TextList, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "translation_target_language", + key: "translationTargetLanguage", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "chinese_script_preference", + key: "chineseScriptPreference", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "output_language_preference", + key: "outputLanguagePreference", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "qa_hotkey", + key: "qaHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "quick_note_hotkey", + key: "quickNoteHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "selection_polish_hotkey", + key: "selectionPolishHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "selection_polish_style_pack_id", + key: "selectionPolishStylePackId", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_polish_output_mode", + key: "selectionPolishOutputMode", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_enabled", + key: "selectionVoiceEnabled", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_intent_mode", + key: "selectionVoiceIntentMode", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_manual_intent", + key: "selectionVoiceManualIntent", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_edit_keywords", + key: "selectionVoiceEditKeywords", + class: PreferenceClass::Portable, + shape: PreferenceShape::TextList, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_edit_plan_format", + key: "selectionVoiceEditPlanFormat", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "selection_voice_edit_system_prompt", + key: "selectionVoiceEditSystemPrompt", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "qa_save_history", + key: "qaSaveHistory", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "custom_combo_hotkey", + key: "customComboHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "translation_hotkey", + key: "translationHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Object, + reason: "device_bound", + }, + PreferenceField { + rust_name: "switch_style_hotkey", + key: "switchStyleHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "open_app_hotkey", + key: "openAppHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "style_pack_hotkeys", + key: "stylePackHotkeys", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::ObjectList, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_enabled", + key: "codingAgentEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, + PreferenceField { + rust_name: "coding_agent_provider", + key: "codingAgentProvider", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_model", + key: "codingAgentModel", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalText, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_permission_mode", + key: "codingAgentPermissionMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_workdir", + key: "codingAgentWorkdir", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalText, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_exe", + key: "codingAgentExe", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalText, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_voice_hotkey", + key: "codingAgentVoiceHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_panel_hotkey", + key: "codingAgentPanelHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "coding_agent_quick_hotkey", + key: "codingAgentQuickHotkey", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::OptionalObject, + reason: "device_bound", + }, + PreferenceField { + rust_name: "remote_input_enabled", + key: "remoteInputEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_network_consent", + }, + PreferenceField { + rust_name: "remote_input_port", + key: "remoteInputPort", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Unsigned16, + reason: "device_bound", + }, + PreferenceField { + rust_name: "remote_input_pin", + key: "remoteInputPin", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "local_consent_or_secret", + }, + PreferenceField { + rust_name: "remote_input_default_mode", + key: "remoteInputDefaultMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "local_asr_active_model", + key: "localAsrActiveModel", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "local_whisper_active_model", + key: "localWhisperActiveModel", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "local_asr_mirror", + key: "localAsrMirror", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "local_asr_keep_loaded_secs", + key: "localAsrKeepLoadedSecs", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Unsigned32, + reason: "device_bound", + }, + PreferenceField { + rust_name: "local_asr_models_base_dir", + key: "localAsrModelsBaseDir", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "foundry_local_asr_model", + key: "foundryLocalAsrModel", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "foundry_local_runtime_source", + key: "foundryLocalRuntimeSource", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "foundry_local_asr_language_hint", + key: "foundryLocalAsrLanguageHint", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "foundry_local_asr_keep_loaded_secs", + key: "foundryLocalAsrKeepLoadedSecs", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Unsigned32, + reason: "device_bound", + }, + PreferenceField { + rust_name: "sherpa_onnx_model", + key: "sherpaOnnxModel", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "sherpa_onnx_language_hint", + key: "sherpaOnnxLanguageHint", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "sherpa_onnx_keep_loaded_secs", + key: "sherpaOnnxKeepLoadedSecs", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Unsigned32, + reason: "device_bound", + }, + PreferenceField { + rust_name: "update_channel", + key: "updateChannel", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "update_channel_explicit", + key: "updateChannelExplicit", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "history_retention_days", + key: "historyRetentionDays", + class: PreferenceClass::Portable, + shape: PreferenceShape::Unsigned32, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "polish_context_window_minutes", + key: "polishContextWindowMinutes", + class: PreferenceClass::Portable, + shape: PreferenceShape::Unsigned32, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "start_minimized", + key: "startMinimized", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "theme_mode", + key: "themeMode", + class: PreferenceClass::Portable, + shape: PreferenceShape::Text, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "streaming_insert", + key: "streamingInsert", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "streaming_insert_default_migrated", + key: "streamingInsertDefaultMigrated", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, + PreferenceField { + rust_name: "streaming_insert_save_clipboard", + key: "streamingInsertSaveClipboard", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "cursor_context_enabled", + key: "cursorContextEnabled", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Boolean, + reason: "local_consent_or_secret", + }, + PreferenceField { + rust_name: "show_overview_activity_heatmap", + key: "showOverviewActivityHeatmap", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "stacked_row_layout", + key: "stackedRowLayout", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "conservative_layout", + key: "conservativeLayout", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "auto_update_check", + key: "autoUpdateCheck", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "history_max_entries", + key: "historyMaxEntries", + class: PreferenceClass::Portable, + shape: PreferenceShape::OptionalUnsigned32, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "record_audio_for_debug", + key: "recordAudioForDebug", + class: PreferenceClass::Portable, + shape: PreferenceShape::Boolean, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "audio_recording_max_entries", + key: "audioRecordingMaxEntries", + class: PreferenceClass::Portable, + shape: PreferenceShape::OptionalUnsigned32, + reason: "portable_setting", + }, + PreferenceField { + rust_name: "quick_note_export_directory", + key: "quickNoteExportDirectory", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "marketplace_base_url", + key: "marketplaceBaseUrl", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "marketplace_dev_login", + key: "marketplaceDevLogin", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "local_consent_or_secret", + }, + PreferenceField { + rust_name: "android_insert_strategy", + key: "androidInsertStrategy", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_trigger", + key: "androidOverlayTrigger", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_activation_mode", + key: "androidOverlayActivationMode", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_left_swipe_action", + key: "androidOverlayLeftSwipeAction", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_cancel_swipe_direction", + key: "androidOverlayCancelSwipeDirection", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Text, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_gesture_actions", + key: "androidOverlayGestureActions", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Object, + reason: "device_bound", + }, + PreferenceField { + rust_name: "android_overlay_size_dp", + key: "androidOverlaySizeDp", + class: PreferenceClass::DeviceProfile, + shape: PreferenceShape::Unsigned32, + reason: "device_bound", + }, + PreferenceField { + rust_name: "splash_seen_version", + key: "splashSeenVersion", + class: PreferenceClass::Excluded, + shape: PreferenceShape::Text, + reason: "local_consent_or_secret", + }, +]; + +pub fn preference_field(key: &str) -> Option<&'static PreferenceField> { + PREFERENCE_FIELDS.iter().find(|field| field.key == key) +} + +pub fn excluded_extension_key(key: &str) -> bool { + let normalized: String = key + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .flat_map(char::to_lowercase) + .collect(); + [ + "oauth", + "githubtoken", + "accesstoken", + "refreshtoken", + "sessioncookie", + "privatekey", + "remotepin", + "remoteinputpin", + "syncpassword", + "synckey", + "synctoken", + "syncenabled", + "syncconsent", + "syncqueue", + "syncbaseline", + "synclock", + "permissiongrant", + ] + .iter() + .any(|item| normalized.contains(item)) +} + +pub fn validate_preference_value(field: &PreferenceField, value: &Value) -> DocumentResult<()> { + use PreferenceShape::*; + let valid = match field.shape { + Boolean => value.is_boolean(), + Unsigned8 => value.as_u64().is_some_and(|n| n <= u8::MAX.into()), + Unsigned16 => value.as_u64().is_some_and(|n| n <= u16::MAX.into()), + Unsigned32 => value.as_u64().is_some_and(|n| n <= u32::MAX.into()), + Number => value.as_f64().is_some_and(f64::is_finite), + Text => value.is_string(), + TextList => value + .as_array() + .is_some_and(|items| items.iter().all(Value::is_string)), + OptionalText => value.is_null() || value.is_string(), + OptionalUnsigned32 => { + value.is_null() || value.as_u64().is_some_and(|n| n <= u32::MAX.into()) + } + Object => value.is_object(), + OptionalObject => value.is_null() || value.is_object(), + ObjectList => value + .as_array() + .is_some_and(|items| items.iter().all(Value::is_object)), + }; + if !valid { + return Err(DocumentError::InvalidDocument); + } + let choices: &[&str] = match field.key { + "defaultMode" => &["raw", "light", "structured", "formal"], + "capsuleStyle" => &["siri", "classic", "typeless"], + "pipelineMode" => &["traditional", "multimodal"], + "chineseScriptPreference" => &["auto", "simplified", "traditional"], + "outputLanguagePreference" => &["auto", "zhCn", "zhTw", "en", "ja", "ko"], + "selectionPolishOutputMode" => &["directReplace", "previewConfirm"], + "selectionVoiceIntentMode" => &["prompt", "auto", "manual", "heuristic"], + "selectionVoiceManualIntent" => &["question", "edit"], + "selectionVoiceEditPlanFormat" => &["xml", "json"], + "updateChannel" => &["stable", "beta"], + "themeMode" => &["system", "light", "dark"], + _ => &[], + }; + if !choices.is_empty() && !value.as_str().is_some_and(|s| choices.contains(&s)) { + return Err(DocumentError::InvalidDocument); + } + if field.key == "enabledModes" + && !value.as_array().is_some_and(|items| { + items + .iter() + .all(|v| matches!(v.as_str(), Some("raw" | "light" | "structured" | "formal"))) + }) + { + return Err(DocumentError::InvalidDocument); + } + Ok(()) +} + +pub const ASR_ACCOUNTS: &[&str] = &[ + "asr.api_key", + "asr.endpoint", + "asr.model", + "asr.vocabulary_id", + "asr.advanced_config", + "volcengine.app_key", + "volcengine.access_key", + "volcengine.resource_id", + "volcengine.service", + "volcengine.auth_mode", + "volcengine.api_key", + "xfyun.app_id", + "xfyun.api_key", + "tencent_cloud.app_id", + "tencent_cloud.secret_id", + "tencent_cloud.secret_key", +]; +pub const LLM_ACCOUNTS: &[&str] = &[ + "ark.api_key", + "ark.model_id", + "ark.endpoint", + "ark.extra_headers", + "ark.temperature", + "ark.request_format", + "ark.messages_thinking", + "ark.max_tokens", + "ark.thinking_budget", +]; +pub const OMNI_ACCOUNTS: &[&str] = &[ + "omni.api_key", + "omni.endpoint", + "omni.model", + "omni.extra_headers", + "omni.temperature", +]; +pub fn credential_accounts(namespace: SyncNamespace) -> &'static [&'static str] { + match namespace { + SyncNamespace::Asr => ASR_ACCOUNTS, + SyncNamespace::Llm => LLM_ACCOUNTS, + SyncNamespace::Omni => OMNI_ACCOUNTS, + } +} + +pub fn is_device_channel(namespace: SyncNamespace, provider: &str) -> bool { + namespace == SyncNamespace::Asr + && matches!( + provider, + "apple-speech" + | "local-whisper" + | "local-qwen3" + | "local-qwen3-mlx" + | "local-qwen3-c" + | "foundry-local-whisper" + | "sherpa-onnx-local" + ) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore.rs new file mode 100644 index 000000000..c44dc72ba --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore.rs @@ -0,0 +1,527 @@ +//! Crash-recoverable logical restore with authenticated, encrypted before/after images. + +use std::fmt; +use std::sync::Arc; + +use futures_util::future::BoxFuture; +use serde::{Deserialize, Serialize}; +use zeroize::Zeroizing; + +use crate::cloud_sync_e2ee_protocol::{ + crypto::{self, DerivedKey}, + types::{DocumentSet, Revision}, +}; + +use super::export::export_snapshot; +use super::types::*; +use super::validate::{uuid_v4, validate_scope, validate_sync_documents}; + +pub struct RestorePlan { + desired: ValidatedSyncDocuments, + context: RestoreContext, +} +impl fmt::Debug for RestorePlan { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("RestorePlan([REDACTED])") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct RestoreReceipt { + pub operation_id: String, + pub generation: Revision, + pub committed: bool, + pub journal_cleanup_pending: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecoveryOutcome { + NoPending, + Committed(RestoreReceipt), + RolledBack(RestoreReceipt), +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SealedJournal { + pub operation_id: String, + pub ciphertext: Vec, +} +impl fmt::Debug for SealedJournal { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SealedJournal([REDACTED])") + } +} + +pub trait JournalStore: Send + Sync { + fn load(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult>>; + /// Save atomically and durably before returning. Never accept a plaintext journal DTO here. + fn save(&self, scope: SyncScope, journal: SealedJournal) -> BoxFuture<'_, DocumentResult<()>>; + fn clear(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult<()>>; +} + +pub trait JournalProtector: Send + Sync { + fn seal( + &self, + scope: &SyncScope, + operation_id: &str, + plaintext: &[u8], + ) -> DocumentResult>; + fn open( + &self, + scope: &SyncScope, + operation_id: &str, + ciphertext: &[u8], + ) -> DocumentResult>>; +} + +/// The key is a separate OS-protected local key, not a password or ordinary file secret. +pub struct CryptoJournalProtector { + key: Arc, +} +impl CryptoJournalProtector { + pub(crate) fn new(key: Arc) -> Self { + Self { key } + } +} +impl fmt::Debug for CryptoJournalProtector { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("CryptoJournalProtector([REDACTED])") + } +} +impl JournalProtector for CryptoJournalProtector { + fn seal( + &self, + scope: &SyncScope, + operation_id: &str, + plaintext: &[u8], + ) -> DocumentResult> { + crypto::seal_local(&self.key, &journal_aad(scope, operation_id)?, plaintext) + .map_err(|_| DocumentError::Locked) + } + fn open( + &self, + scope: &SyncScope, + operation_id: &str, + ciphertext: &[u8], + ) -> DocumentResult>> { + crypto::open_local(&self.key, &journal_aad(scope, operation_id)?, ciphertext) + .map_err(|_| DocumentError::Locked) + } +} + +fn journal_aad(scope: &SyncScope, operation_id: &str) -> DocumentResult> { + validate_scope(scope)?; + uuid_v4(operation_id)?; + serde_json::to_vec(&("openless-sync-restore-journal-v1", scope, operation_id)) + .map_err(|_| DocumentError::InvalidDocument) +} + +#[derive(Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum Phase { + Prepared, + Committed, + RolledBack, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Journal { + schema_version: u32, + scope: SyncScope, + operation_id: String, + observed_revision: Revision, + phase: Phase, + before: DocumentSet, + after: DocumentSet, + #[serde(default)] + local_before: SecretJson, +} + +pub fn prepare_sync_restore( + desired: ValidatedSyncDocuments, + context: RestoreContext, +) -> DocumentResult { + validate_scope(&context.scope)?; + uuid_v4(&context.operation_id)?; + if desired.observed_revision() != context.observed_revision + || context.target_device.id != context.scope.device_id + { + return Err(DocumentError::StalePreview); + } + Ok(RestorePlan { desired, context }) +} + +/// No caller cancellation branch is allowed after the durable journal exists. +/// If the task/process nevertheless dies, recovery chooses the durable decision below. +pub async fn apply_sync_restore( + plan: RestorePlan, + backend: &dyn RestoreBackend, + storage: &dyn JournalStore, + protector: &dyn JournalProtector, +) -> DocumentResult { + if storage.load(plan.context.scope.clone()).await?.is_some() { + return Err(DocumentError::RecoveryRequired); + } + let mut lease = backend + .acquire( + plan.context.scope.clone(), + Some(plan.context.local_generation), + ) + .await?; + let before = export_snapshot(lease.capture().await?)?; + if before.generation != plan.context.local_generation { + return Err(DocumentError::StalePreview); + } + let mut after = plan.desired.set.clone(); + // Foreign device profiles are preserved, but importing them cannot erase the receiving + // device's own profile or make its paths/programs active. + let local_profile = before.documents.set.documents.iter().find(|doc| { + doc.kind == crate::cloud_sync_e2ee_protocol::types::DocumentKind::DeviceProfile + && doc.id == plan.context.target_device.id + }); + if let Some(profile) = local_profile { + if !after + .documents + .iter() + .any(|doc| doc.kind == profile.kind && doc.id == profile.id) + { + after.documents.push(profile.clone()); + } + } + materialize_receiver_fields( + &mut after, + before.documents.documents(), + &plan.context.target_device, + )?; + preserve_applicable_asr_selection( + &mut after, + before.documents.documents(), + &plan.context.target_device.id, + )?; + after.source_device = plan.context.target_device.clone(); + let after = validate_sync_documents(after, plan.context.observed_revision)?; + lease.preflight(&after).await?; + let mut journal = Journal { + schema_version: 1, + scope: plan.context.scope.clone(), + operation_id: plan.context.operation_id.clone(), + observed_revision: plan.context.observed_revision, + phase: Phase::Prepared, + before: before.documents.set.clone(), + after: after.set.clone(), + local_before: lease.capture_rollback_state().await?, + }; + lease.mark_recovery_pending(&journal.operation_id)?; + save_journal(&journal, storage, protector).await?; + lease.mark_journal_ready(&journal.operation_id)?; + if replace_and_verify(lease.as_mut(), after).await.is_err() { + let before = validate_sync_documents(journal.before.clone(), journal.observed_revision)?; + if replace_and_verify(lease.as_mut(), before).await.is_err() { + return Err(DocumentError::RecoveryRequired); + } + restore_local_rollback_state(lease.as_mut(), &journal.local_before) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + journal.phase = Phase::RolledBack; + save_journal(&journal, storage, protector) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + let _ = finish(&journal, lease.as_mut(), storage).await?; + return Err(DocumentError::RestoreRolledBack); + } + journal.phase = Phase::Committed; + // A failed/unknown commit-decision write cannot safely be reinterpreted as rollback. + save_journal(&journal, storage, protector) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + finish(&journal, lease.as_mut(), storage).await +} + +fn materialize_receiver_fields( + after: &mut DocumentSet, + before: &DocumentSet, + target: &crate::cloud_sync_e2ee_protocol::types::SourceDevice, +) -> DocumentResult<()> { + use crate::cloud_sync_e2ee_protocol::types::DocumentKind; + for old in &before.documents { + let required = matches!( + old.kind, + DocumentKind::Preferences | DocumentKind::UiPreferences + ) || old.kind == DocumentKind::VocabularyPresets + && old.id.starts_with("builtin:") + || old.kind == DocumentKind::StylePacks && old.value["pack"]["kind"] == "builtin"; + if !required + || after + .documents + .iter() + .any(|doc| doc.kind == old.kind && doc.id == old.id) + { + continue; + } + let deleted = after + .tombstones + .iter() + .any(|mark| mark.kind == old.kind && mark.id == old.id); + if deleted { + let native_required = old.kind == DocumentKind::Preferences + && super::registry::preference_field(&old.id).is_some() + || old.kind == DocumentKind::UiPreferences + && matches!(old.id.as_str(), "locale" | "fontScale") + || old.kind == DocumentKind::VocabularyPresets + || old.kind == DocumentKind::StylePacks; + if native_required { + return Err(DocumentError::Unsupported); + } + } else { + after.documents.push(old.clone()); + } + } + if let Some(profile) = after + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id == target.id) + { + let mut value: DeviceProfileRecord = serde_json::from_value(profile.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + if value.device.os != target.os || value.device.arch != target.arch { + return Err(DocumentError::Unsupported); + } + if let Some(previous) = before + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id == target.id) + { + let previous: DeviceProfileRecord = serde_json::from_value(previous.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + let preferences = value + .preferences + .expose_mut() + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + for (key, old) in previous + .preferences + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)? + { + preferences + .entry(key.clone()) + .or_insert_with(|| old.clone()); + } + } + value.device = target.clone(); + profile.value = serde_json::to_value(value).map_err(|_| DocumentError::InvalidDocument)?; + } + Ok(()) +} + +fn preserve_applicable_asr_selection( + after: &mut DocumentSet, + before: &DocumentSet, + target_device_id: &str, +) -> DocumentResult<()> { + use crate::cloud_sync_e2ee_protocol::types::DocumentKind; + let foreign_local_active = after + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id != target_device_id) + .try_fold(false, |found, doc| { + let profile: DeviceProfileRecord = serde_json::from_value(doc.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + Ok::<_, DocumentError>( + found + || profile + .channels + .iter() + .any(|channel| channel.namespace == SyncNamespace::Asr && channel.active), + ) + })?; + let mut active = std::collections::BTreeSet::new(); + for doc in after + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Channels) + { + let channel: ChannelRecord = serde_json::from_value(doc.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + if channel.active { + active.insert(channel.namespace); + } + } + if foreign_local_active && !active.contains(&SyncNamespace::Asr) { + if let Some(current) = before.documents.iter().find(|doc| { + doc.kind == DocumentKind::Channels + && doc.value["namespace"] == "asr" + && doc.value["active"] == true + && !after.tombstones.iter().any(|mark| { + mark.id == doc.id + && matches!( + mark.kind, + DocumentKind::Channels | DocumentKind::ProviderCredentials + ) + }) + }) { + if let Some(existing) = after + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::Channels && doc.id == current.id) + { + existing.value["active"] = serde_json::Value::Bool(true); + existing.value["enabled"] = serde_json::Value::Bool(true); + } else { + after.documents.push(current.clone()); + if let Some(credential) = before.documents.iter().find(|doc| { + doc.kind == DocumentKind::ProviderCredentials && doc.id == current.id + }) { + after.documents.push(credential.clone()); + } + } + active.insert(SyncNamespace::Asr); + } + } + if let Some(profile) = after + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id == target_device_id) + { + let mut value: DeviceProfileRecord = serde_json::from_value(profile.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + for channel in &mut value.channels { + if active.contains(&channel.namespace) { + channel.active = false; + } + } + profile.value = serde_json::to_value(value).map_err(|_| DocumentError::InvalidDocument)?; + } + Ok(()) +} + +pub async fn recover_sync_restore( + scope: SyncScope, + backend: &dyn RestoreBackend, + storage: &dyn JournalStore, + protector: &dyn JournalProtector, +) -> DocumentResult { + validate_scope(&scope)?; + let mut lease = backend.acquire(scope.clone(), None).await?; + let Some(sealed) = storage.load(scope.clone()).await? else { + lease.recover_without_journal()?; + return Ok(RecoveryOutcome::NoPending); + }; + uuid_v4(&sealed.operation_id)?; + if sealed.ciphertext.len() > MAX_JOURNAL_BYTES + 128 { + return Err(DocumentError::RecoveryRequired); + } + let plaintext = protector.open(&scope, &sealed.operation_id, &sealed.ciphertext)?; + if plaintext.len() > MAX_JOURNAL_BYTES { + return Err(DocumentError::RecoveryRequired); + } + let mut journal: Journal = + serde_json::from_slice(&plaintext).map_err(|_| DocumentError::RecoveryRequired)?; + if journal.schema_version != 1 + || journal.scope != scope + || journal.operation_id != sealed.operation_id + { + return Err(DocumentError::RecoveryRequired); + } + let before = validate_sync_documents(journal.before.clone(), journal.observed_revision) + .map_err(|_| DocumentError::RecoveryRequired)?; + let after = validate_sync_documents(journal.after.clone(), journal.observed_revision) + .map_err(|_| DocumentError::RecoveryRequired)?; + if let Some(mut receipt) = lease.completed_restore(&journal.operation_id)? { + // New user mutations may exist after this receipt. Never replay an obsolete image. + receipt.journal_cleanup_pending = storage.clear(scope).await.is_err(); + return Ok(if receipt.committed { + RecoveryOutcome::Committed(receipt) + } else { + RecoveryOutcome::RolledBack(receipt) + }); + } + lease.mark_recovery_pending(&journal.operation_id)?; + lease.mark_journal_ready(&journal.operation_id)?; + let committed = journal.phase == Phase::Committed; + replace_and_verify(lease.as_mut(), if committed { after } else { before }) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + if !committed { + restore_local_rollback_state(lease.as_mut(), &journal.local_before) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + journal.phase = Phase::RolledBack; + save_journal(&journal, storage, protector) + .await + .map_err(|_| DocumentError::RecoveryRequired)?; + } + let receipt = finish(&journal, lease.as_mut(), storage).await?; + Ok(if committed { + RecoveryOutcome::Committed(receipt) + } else { + RecoveryOutcome::RolledBack(receipt) + }) +} + +async fn save_journal( + journal: &Journal, + storage: &dyn JournalStore, + protector: &dyn JournalProtector, +) -> DocumentResult<()> { + let plaintext = + Zeroizing::new(serde_json::to_vec(journal).map_err(|_| DocumentError::InvalidDocument)?); + if plaintext.len() > MAX_JOURNAL_BYTES { + return Err(DocumentError::PayloadTooLarge); + } + let ciphertext = protector.seal(&journal.scope, &journal.operation_id, &plaintext)?; + if ciphertext.is_empty() || ciphertext.len() > MAX_JOURNAL_BYTES + 128 { + return Err(DocumentError::JournalUnavailable); + } + storage + .save( + journal.scope.clone(), + SealedJournal { + operation_id: journal.operation_id.clone(), + ciphertext, + }, + ) + .await +} + +async fn restore_local_rollback_state( + lease: &mut dyn RestoreLease, + expected: &SecretJson, +) -> DocumentResult<()> { + lease.restore_rollback_state(expected).await?; + if lease.capture_rollback_state().await? != *expected { + return Err(DocumentError::RecoveryRequired); + } + Ok(()) +} + +async fn replace_and_verify( + lease: &mut dyn RestoreLease, + expected: ValidatedSyncDocuments, +) -> DocumentResult<()> { + lease.replace(expected.clone()).await?; + lease.reload().await?; + let actual = export_snapshot(lease.capture().await?)?.documents; + // Source metadata and capture time differ; logical records and deletions must not. + if actual.set.documents != expected.set.documents + || actual.set.tombstones != expected.set.tombstones + { + return Err(DocumentError::RecoveryRequired); + } + Ok(()) +} + +async fn finish( + journal: &Journal, + lease: &mut dyn RestoreLease, + storage: &dyn JournalStore, +) -> DocumentResult { + let mut receipt = + lease.finish_restore(&journal.operation_id, journal.phase == Phase::Committed)?; + receipt.journal_cleanup_pending = storage.clear(journal.scope.clone()).await.is_err(); + Ok(receipt) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore_tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore_tests.rs new file mode 100644 index 000000000..660140918 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/restore_tests.rs @@ -0,0 +1,457 @@ +use std::collections::VecDeque; +use std::sync::{ + atomic::{AtomicBool, AtomicUsize, Ordering}, + Arc, Mutex, +}; + +use futures_util::future::BoxFuture; +use serde_json::{json, Value}; +use zeroize::Zeroizing; + +use super::*; +use crate::cloud_sync_e2ee_protocol::{ + crypto::DerivedKey, + types::{DocumentKind, Revision}, +}; + +struct State { + data: ValidatedSyncDocuments, + generation: Revision, + failures: VecDeque, + pending: bool, + journal_ready: bool, + receipt: Option, + writes: usize, +} + +#[derive(Clone)] +struct Backend(Arc>); +struct Lease(Backend); + +impl RestoreBackend for Backend { + fn acquire( + &self, + _scope: SyncScope, + expected: Option, + ) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + if expected.is_some_and(|g| g != self.0.lock().unwrap().generation) { + return Err(DocumentError::StalePreview); + } + Ok(Box::new(Lease(self.clone())) as Box) + }) + } +} + +fn captured(state: &State) -> ExportSnapshot { + let mut snapshot = super::tests::snapshot(); + snapshot.generation = state.generation; + snapshot.base_revision = state.data.observed_revision(); + snapshot.preferences = SecretJson::default(); + snapshot.ui_preferences = SecretJson::default(); + for doc in &state.data.documents().documents { + match doc.kind { + DocumentKind::Preferences => { + snapshot + .preferences + .expose_mut() + .as_object_mut() + .unwrap() + .insert(doc.id.clone(), doc.value.clone()); + } + DocumentKind::UiPreferences => { + snapshot + .ui_preferences + .expose_mut() + .as_object_mut() + .unwrap() + .insert(doc.id.clone(), doc.value.clone()); + } + DocumentKind::Channels => snapshot + .channels + .push(serde_json::from_value(doc.value.clone()).unwrap()), + DocumentKind::ProviderCredentials => snapshot + .provider_credentials + .push(serde_json::from_value(doc.value.clone()).unwrap()), + DocumentKind::Dictionary => { + snapshot.dictionary.push(SecretJson::new(doc.value.clone())) + } + DocumentKind::Corrections => snapshot + .corrections + .push(SecretJson::new(doc.value.clone())), + DocumentKind::VocabularyPresets => snapshot + .vocabulary_presets + .push(serde_json::from_value(doc.value.clone()).unwrap()), + DocumentKind::StylePacks => snapshot + .style_packs + .push(serde_json::from_value(doc.value.clone()).unwrap()), + DocumentKind::History => snapshot.history.push(SecretJson::new(doc.value.clone())), + DocumentKind::Activity => snapshot + .activity + .push(serde_json::from_value(doc.value.clone()).unwrap()), + DocumentKind::DeviceProfile if doc.id == snapshot.source_device.id => { + let profile: DeviceProfileRecord = + serde_json::from_value(doc.value.clone()).unwrap(); + for (key, value) in profile.preferences.expose().as_object().unwrap() { + snapshot + .preferences + .expose_mut() + .as_object_mut() + .unwrap() + .insert(key.clone(), value.clone()); + } + snapshot.channels.extend(profile.channels); + snapshot + .provider_credentials + .extend(profile.provider_credentials); + snapshot.window_positions = profile.window_positions; + } + DocumentKind::DeviceProfile => snapshot.retained_documents.push(doc.clone()), + } + } + snapshot.tombstones = state.data.documents().tombstones.clone(); + snapshot +} + +impl RestoreLease for Lease { + fn capture(&mut self) -> BoxFuture<'_, DocumentResult> { + Box::pin(async move { Ok(captured(&self.0 .0.lock().unwrap())) }) + } + fn replace(&mut self, desired: ValidatedSyncDocuments) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + let mut state = self.0 .0.lock().unwrap(); + state.writes += 1; + if state.failures.pop_front().unwrap_or(false) { + // A first file became visible; the following credential write failed. + let value = desired + .documents() + .documents + .iter() + .find(|doc| doc.id == "themeMode") + .unwrap() + .value + .clone(); + state + .data + .set + .documents + .iter_mut() + .find(|doc| doc.id == "themeMode") + .unwrap() + .value = value; + return Err(DocumentError::CaptureFailed); + } + state.data = desired; + Ok(()) + }) + } + fn reload(&mut self) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async { Ok(()) }) + } + fn mark_recovery_pending(&mut self, _operation: &str) -> DocumentResult<()> { + self.0 .0.lock().unwrap().pending = true; + Ok(()) + } + fn mark_journal_ready(&mut self, _operation: &str) -> DocumentResult<()> { + self.0 .0.lock().unwrap().journal_ready = true; + Ok(()) + } + fn recover_without_journal(&mut self) -> DocumentResult<()> { + let mut state = self.0 .0.lock().unwrap(); + if state.pending && state.journal_ready { + return Err(DocumentError::RecoveryRequired); + } + state.pending = false; + Ok(()) + } + fn completed_restore(&self, operation: &str) -> DocumentResult> { + Ok(self + .0 + .0 + .lock() + .unwrap() + .receipt + .as_ref() + .filter(|r| r.operation_id == operation) + .cloned()) + } + fn finish_restore( + &mut self, + operation: &str, + committed: bool, + ) -> DocumentResult { + let mut state = self.0 .0.lock().unwrap(); + if let Some(receipt) = &state.receipt { + return Ok(receipt.clone()); + } + if committed { + state.generation = state.generation.checked_next().unwrap(); + } + let receipt = RestoreReceipt { + operation_id: operation.into(), + generation: state.generation, + committed, + journal_cleanup_pending: false, + }; + state.pending = false; + state.receipt = Some(receipt.clone()); + Ok(receipt) + } +} + +#[derive(Default)] +struct Storage { + value: Mutex>, + saves: AtomicUsize, + fail_before: AtomicUsize, + fail_after: AtomicUsize, + fail_clear: AtomicBool, +} +impl JournalStore for Storage { + fn load(&self, _scope: SyncScope) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async { Ok(self.value.lock().unwrap().clone()) }) + } + fn save(&self, _scope: SyncScope, value: SealedJournal) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + let number = self.saves.fetch_add(1, Ordering::SeqCst) + 1; + if self.fail_before.load(Ordering::SeqCst) == number { + return Err(DocumentError::JournalUnavailable); + } + *self.value.lock().unwrap() = Some(value); + if self.fail_after.load(Ordering::SeqCst) == number { + return Err(DocumentError::JournalUnavailable); + } + Ok(()) + }) + } + fn clear(&self, _scope: SyncScope) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async { + if self.fail_clear.load(Ordering::SeqCst) { + Err(DocumentError::JournalUnavailable) + } else { + *self.value.lock().unwrap() = None; + Ok(()) + } + }) + } +} + +fn context() -> RestoreContext { + RestoreContext { + scope: SyncScope { + service_origin: "https://sync.example.test".into(), + owner_github_id: "42".into(), + vault_id: "12345678-1234-4234-8234-123456789abc".into(), + key_id: "22345678-1234-4234-8234-123456789abc".into(), + device_id: "device-a".into(), + }, + operation_id: "32345678-1234-4234-8234-123456789abc".into(), + observed_revision: Revision::new(3), + local_generation: Revision::new(7), + target_device: super::tests::source(), + } +} + +fn setup(failures: &[bool]) -> (Backend, RestorePlan, Storage, CryptoJournalProtector) { + let data = export_snapshot(super::tests::channel_snapshot()) + .unwrap() + .documents; + let mut desired = data.documents().clone(); + desired + .documents + .iter_mut() + .find(|doc| doc.id == "themeMode") + .unwrap() + .value = json!("dark"); + let desired = validate_sync_documents(desired, Revision::new(3)).unwrap(); + let plan = prepare_sync_restore(desired, context()).unwrap(); + let backend = Backend(Arc::new(Mutex::new(State { + data, + generation: Revision::new(7), + failures: failures.iter().copied().collect(), + pending: false, + journal_ready: false, + receipt: None, + writes: 0, + }))); + let protector = CryptoJournalProtector::new(Arc::new(DerivedKey::from_secret_bytes( + Zeroizing::new([37; 32]), + ))); + (backend, plan, Storage::default(), protector) +} + +fn theme(backend: &Backend) -> Value { + backend + .0 + .lock() + .unwrap() + .data + .documents() + .documents + .iter() + .find(|doc| doc.id == "themeMode") + .unwrap() + .value + .clone() +} + +#[tokio::test] +async fn restore_verifies_all_logical_data_and_advances_one_generation() { + let (backend, plan, storage, protector) = setup(&[]); + let receipt = apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap(); + assert!(receipt.committed); + assert_eq!(receipt.generation.get(), 8); + assert_eq!(theme(&backend), "dark"); + assert!(!backend.0.lock().unwrap().pending); + assert!(storage.value.lock().unwrap().is_none()); +} + +#[tokio::test] +async fn credential_failure_rolls_back_the_already_written_file_without_false_success() { + let (backend, plan, storage, protector) = setup(&[true, false]); + assert_eq!( + apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::RestoreRolledBack + ); + assert_eq!(theme(&backend), "system"); + assert_eq!(backend.0.lock().unwrap().generation.get(), 7); + assert!(storage.value.lock().unwrap().is_none()); +} + +#[tokio::test] +async fn rollback_failure_leaves_only_encrypted_material_and_recovery_resumes_it() { + let (backend, plan, storage, protector) = setup(&[true, true]); + assert_eq!( + apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::RecoveryRequired + ); + let bytes = storage + .value + .lock() + .unwrap() + .as_ref() + .unwrap() + .ciphertext + .clone(); + assert!(!bytes + .windows(b"fixture-secret-original".len()) + .any(|part| part == b"fixture-secret-original")); + assert!(backend.0.lock().unwrap().pending); + let result = recover_sync_restore(context().scope, &backend, &storage, &protector) + .await + .unwrap(); + assert!(matches!(result, RecoveryOutcome::RolledBack(_))); + assert_eq!(theme(&backend), "system"); +} + +#[tokio::test] +async fn unknown_commit_journal_write_is_resolved_by_its_durable_decision() { + for persisted in [false, true] { + let (backend, plan, storage, protector) = setup(&[]); + if persisted { + storage.fail_after.store(2, Ordering::SeqCst); + } else { + storage.fail_before.store(2, Ordering::SeqCst); + } + assert_eq!( + apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::RecoveryRequired + ); + let recovered = recover_sync_restore(context().scope, &backend, &storage, &protector) + .await + .unwrap(); + assert_eq!( + matches!(recovered, RecoveryOutcome::Committed(_)), + persisted + ); + assert_eq!(theme(&backend), if persisted { "dark" } else { "system" }); + } +} + +#[tokio::test] +async fn obsolete_committed_journal_never_replays_over_new_user_data() { + let (backend, plan, storage, protector) = setup(&[]); + storage.fail_clear.store(true, Ordering::SeqCst); + assert!( + apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap() + .journal_cleanup_pending + ); + backend + .0 + .lock() + .unwrap() + .data + .set + .documents + .iter_mut() + .find(|doc| doc.id == "themeMode") + .unwrap() + .value = json!("light"); + let writes = backend.0.lock().unwrap().writes; + storage.fail_clear.store(false, Ordering::SeqCst); + recover_sync_restore(context().scope, &backend, &storage, &protector) + .await + .unwrap(); + assert_eq!(theme(&backend), "light"); + assert_eq!(backend.0.lock().unwrap().writes, writes); +} + +#[tokio::test] +async fn wrong_scope_or_ciphertext_cannot_mutate_recovery_data() { + let (backend, plan, storage, protector) = setup(&[true, true]); + let _ = apply_sync_restore(plan, &backend, &storage, &protector).await; + let before = theme(&backend); + let writes = backend.0.lock().unwrap().writes; + let mut wrong = context().scope; + wrong.owner_github_id = "43".into(); + assert_eq!( + recover_sync_restore(wrong, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::Locked + ); + { + let mut stored = storage.value.lock().unwrap(); + let data = &mut stored.as_mut().unwrap().ciphertext; + let last = data.len() - 1; + data[last] ^= 1; + } + assert_eq!( + recover_sync_restore(context().scope, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::Locked + ); + assert_eq!(theme(&backend), before); + assert_eq!(backend.0.lock().unwrap().writes, writes); +} + +#[tokio::test] +async fn journal_prepare_failure_never_starts_a_data_write() { + let (backend, plan, storage, protector) = setup(&[]); + storage.fail_before.store(1, Ordering::SeqCst); + assert_eq!( + apply_sync_restore(plan, &backend, &storage, &protector) + .await + .unwrap_err(), + DocumentError::JournalUnavailable + ); + assert_eq!(backend.0.lock().unwrap().writes, 0); + assert!(matches!( + recover_sync_restore(context().scope, &backend, &storage, &protector) + .await + .unwrap(), + RecoveryOutcome::NoPending + )); + assert!(!backend.0.lock().unwrap().pending); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs new file mode 100644 index 000000000..b6c6fb57e --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/tests.rs @@ -0,0 +1,604 @@ +use serde_json::{json, Value}; +use std::collections::BTreeMap; + +use super::*; +use crate::cloud_sync_e2ee_protocol::types::{ + DocumentKind, LogicalDocument, Revision, SourceDevice, Tombstone, +}; + +pub(super) fn source() -> SourceDevice { + SourceDevice { + id: "device-a".into(), + os: "macos".into(), + arch: "aarch64".into(), + app_version: "2.0.0-Beta.3".into(), + } +} + +pub(super) fn snapshot() -> ExportSnapshot { + ExportSnapshot { + source_device: source(), + exported_at: "2026-09-26T00:00:00Z".into(), + generation: Revision::new(7), + base_revision: Revision::new(3), + preferences: SecretJson::new(json!({"themeMode":"system","showCapsule":true})), + ui_preferences: ui_preferences("en", "medium"), + channels: Vec::new(), + provider_credentials: Vec::new(), + dictionary: Vec::new(), + vocabulary_presets: Vec::new(), + corrections: Vec::new(), + style_packs: Vec::new(), + history: Vec::new(), + activity: Vec::new(), + window_positions: Vec::new(), + retained_documents: Vec::new(), + tombstones: Vec::new(), + } +} + +pub(super) fn sample() -> ValidatedSyncDocuments { + export_snapshot(snapshot()).unwrap().documents +} + +fn change( + set: &ValidatedSyncDocuments, + kind: DocumentKind, + id: &str, + value: Value, +) -> ValidatedSyncDocuments { + let mut set = set.documents().clone(); + if let Some(doc) = set + .documents + .iter_mut() + .find(|doc| doc.kind == kind && doc.id == id) + { + doc.value = value; + } else { + set.documents.push(LogicalDocument { + id: id.into(), + kind, + schema_version: 1, + value, + }); + } + validate_sync_documents(set, Revision::new(4)).unwrap() +} + +fn dictionary(id: &str, text: &str) -> Value { + json!({"id":id,"phrase":text,"note":null,"enabled":true,"hits":0,"createdAt":"2026-09-26T00:00:00Z"}) +} + +pub(super) fn channel_snapshot() -> ExportSnapshot { + let mut data = snapshot(); + data.channels.push(ChannelRecord { + id: "llm-one".into(), + namespace: SyncNamespace::Llm, + provider_type: "requesty".into(), + name: "original".into(), + enabled: true, + order: 0, + active: true, + }); + data.provider_credentials.push(ProviderCredentialRecord { + channel_id: "llm-one".into(), + namespace: SyncNamespace::Llm, + accounts: BTreeMap::from([("ark.api_key".into(), "fixture-secret-original".into())]), + }); + data +} + +#[test] +fn all_current_preference_fields_have_an_explicit_registration() { + let source = include_str!("../shared_types.rs"); + let fields = source + .split("pub struct UserPreferences {") + .nth(1) + .unwrap() + .split("\n}\n") + .next() + .unwrap(); + let names: std::collections::BTreeSet<_> = fields + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix("pub ") + .and_then(|s| s.split_once(':')) + .map(|(name, _)| name) + }) + .collect(); + let registered: std::collections::BTreeSet<_> = registry::PREFERENCE_FIELDS + .iter() + .map(|field| field.rust_name) + .collect(); + assert_eq!( + names, registered, + "new persistent fields require a reviewed sync classification" + ); + assert_eq!(registered.len(), registry::PREFERENCE_FIELDS.len()); +} + +#[test] +fn controlled_export_keeps_real_service_keys_but_excludes_local_secrets_and_grants() { + let mut data = channel_snapshot(); + data.preferences = SecretJson::new( + json!({"themeMode":"dark","remoteInputPin":"fixture-pin-never-export","codingAgentEnabled":true,"cursorContextEnabled":true,"marketplaceDevLogin":"fixture-login","splashSeenVersion":"2","activeLlmProvider":"llm-one","codingAgentExe":"/fixture/never-run","futureAppearance":{"accent":"violet"}}), + ); + let exported = export_snapshot(data).unwrap(); + let raw = serde_json::to_string(exported.documents.documents()).unwrap(); + assert!( + raw.contains("fixture-secret-original"), + "ordinary service credentials must not be masked" + ); + assert!(!raw.contains("fixture-pin-never-export")); + assert!(!raw.contains("codingAgentEnabled")); + assert!(!raw.contains("cursorContextEnabled")); + assert!(!raw.contains("fixture-login")); + assert!(!raw.contains("activeLlmProvider")); + assert!(raw.contains("futureAppearance")); + let profile = exported + .documents + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::DeviceProfile) + .unwrap(); + assert_eq!( + profile.value["preferences"]["codingAgentExe"], + "/fixture/never-run" + ); + assert!(!format!("{:?}", exported).contains("fixture-secret-original")); +} + +#[test] +fn local_asr_channels_are_archived_with_their_credentials_not_globally_activated() { + let mut data = snapshot(); + data.channels.push(ChannelRecord { + id: "local-one".into(), + namespace: SyncNamespace::Asr, + provider_type: "foundry-local-whisper".into(), + name: "Windows model".into(), + enabled: true, + order: 0, + active: true, + }); + data.provider_credentials.push(ProviderCredentialRecord { + channel_id: "local-one".into(), + namespace: SyncNamespace::Asr, + accounts: BTreeMap::from([("asr.model".into(), "fixture-model".into())]), + }); + let exported = export_snapshot(data).unwrap(); + assert!(!exported + .documents + .documents() + .documents + .iter() + .any(|doc| matches!( + doc.kind, + DocumentKind::Channels | DocumentKind::ProviderCredentials + ))); + let profile = exported + .documents + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::DeviceProfile) + .unwrap(); + assert_eq!(profile.value["channels"][0]["id"], "local-one"); + assert_eq!( + profile.value["providerCredentials"][0]["accounts"]["asr.model"], + "fixture-model" + ); +} + +#[test] +fn history_never_claims_that_remote_audio_exists_locally() { + let mut data = snapshot(); + data.history.push(SecretJson::new(json!({"id":"history-one","createdAt":"2026-09-26T00:00:00Z","source":"quick_note","rawTranscript":"note","finalText":"note","mode":"raw","insertStatus":"notRequested","hasAudioRecording":true}))); + let exported = export_snapshot(data).unwrap(); + let row = exported + .documents + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::History) + .unwrap(); + assert_eq!(row.value["hasAudioRecording"], false); + assert_eq!(row.value["source"], "quick_note"); +} + +#[test] +fn validation_rejects_duplicate_ids_versions_dangling_credentials_and_excluded_preferences() { + let base = sample(); + let mut invalid = base.documents().clone(); + invalid.documents.push(invalid.documents[0].clone()); + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::DuplicateId + ); + let mut invalid = base.documents().clone(); + invalid.documents[0].schema_version = 2; + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::Unsupported + ); + let mut invalid = base.documents().clone(); + invalid.documents.push(LogicalDocument { + id: "remoteInputPin".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value: json!("forbidden"), + }); + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::ExcludedField + ); + let mut invalid = export_snapshot(channel_snapshot()) + .unwrap() + .documents + .documents() + .clone(); + invalid + .documents + .retain(|doc| doc.kind != DocumentKind::Channels); + assert_eq!( + validate_sync_documents(invalid, Revision::new(3)).unwrap_err(), + DocumentError::InvalidReference + ); +} + +#[test] +fn credential_batch_validation_rejects_oauth_cookie_accounts_and_omni_id_aliases() { + let mut data = channel_snapshot(); + data.provider_credentials[0] + .accounts + .insert("github.access_token".into(), "fixture-oauth".into()); + assert_eq!( + validate_credential_set(&data.channels, &data.provider_credentials).unwrap_err(), + DocumentError::ExcludedField + ); + data.provider_credentials[0] + .accounts + .remove("github.access_token"); + data.channels[0].namespace = SyncNamespace::Omni; + data.provider_credentials[0].namespace = SyncNamespace::Omni; + data.provider_credentials[0].accounts.clear(); + assert_eq!( + validate_credential_set(&data.channels, &data.provider_credentials).unwrap_err(), + DocumentError::InvalidReference + ); + data.channels[0].id = "requesty".into(); + data.provider_credentials[0].channel_id = "requesty".into(); + validate_credential_set(&data.channels, &data.provider_credentials).unwrap(); + let mut cookie = channel_snapshot(); + cookie.provider_credentials[0].accounts.insert( + "ark.extra_headers".into(), + r#"{"Cookie":"fixture-session"}"#.into(), + ); + assert_eq!( + export_snapshot(cookie).unwrap_err(), + DocumentError::ExcludedField + ); +} + +#[test] +fn unknown_preference_values_roundtrip_and_merge_without_application() { + let base = sample(); + let local = change( + &base, + DocumentKind::Preferences, + "futureAppearance", + json!({"value":"preserve-me"}), + ); + let remote = change(&base, DocumentKind::Preferences, "themeMode", json!("dark")); + let merged = diff_sync_documents(Some(&base), &local, &remote) + .unwrap() + .resolve(&[]) + .unwrap(); + assert!(merged + .documents() + .documents + .iter() + .any(|doc| doc.id == "futureAppearance" && doc.value["value"] == "preserve-me")); +} + +#[test] +fn independent_stable_ids_merge_but_simultaneous_same_key_changes_do_not() { + let base = sample(); + let local = change( + &base, + DocumentKind::Dictionary, + "local-id", + dictionary("local-id", "same name"), + ); + let remote = change( + &base, + DocumentKind::Dictionary, + "remote-id", + dictionary("remote-id", "same name"), + ); + let result = diff_sync_documents(Some(&base), &local, &remote) + .unwrap() + .resolve(&[]) + .unwrap(); + assert_eq!( + result + .documents() + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Dictionary) + .count(), + 2 + ); + let local = change(&base, DocumentKind::Preferences, "themeMode", json!("dark")); + let remote = change( + &base, + DocumentKind::Preferences, + "themeMode", + json!("light"), + ); + let preview = diff_sync_documents(Some(&base), &local, &remote).unwrap(); + assert_eq!(preview.conflicts().len(), 1); + assert_eq!( + preview.resolve(&[]).unwrap_err(), + DocumentError::ConflictChoiceRequired + ); +} + +#[test] +fn channel_and_credential_changes_are_one_redacted_conflict_unit() { + let base = export_snapshot(channel_snapshot()).unwrap().documents; + let mut channel = base + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::Channels) + .unwrap() + .value + .clone(); + channel["name"] = json!("private-local-name"); + let local = change(&base, DocumentKind::Channels, "llm:llm-one", channel); + let mut credentials = base + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::ProviderCredentials) + .unwrap() + .value + .clone(); + credentials["accounts"]["ark.api_key"] = json!("fixture-secret-remote"); + let remote = change( + &base, + DocumentKind::ProviderCredentials, + "llm:llm-one", + credentials, + ); + let preview = diff_sync_documents(Some(&base), &local, &remote).unwrap(); + assert_eq!(preview.conflicts().len(), 1); + assert!(preview.conflicts()[0].is_credential_unit); + let ui = serde_json::to_string(preview.conflicts()).unwrap(); + assert!(!ui.contains("fixture-secret")); + assert!(!ui.contains("private-local-name")); + let choice = ConflictChoice { + conflict_id: preview.conflicts()[0].conflict_id.clone(), + side: ConflictSide::Remote, + }; + let merged = preview.resolve(&[choice]).unwrap(); + let channel = merged + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::Channels) + .unwrap(); + assert_eq!( + channel.value["name"], "original", + "must not combine half of each channel unit" + ); +} + +#[test] +fn deletion_marks_prevent_offline_resurrection_and_conflict_with_edits() { + let base = change( + &sample(), + DocumentKind::Dictionary, + "entry", + dictionary("entry", "original"), + ); + let mut deleted = base.documents().clone(); + deleted.documents.retain(|doc| doc.id != "entry"); + deleted.tombstones.push(Tombstone { + id: "entry".into(), + kind: DocumentKind::Dictionary, + deleted_at: "2026-09-26T01:00:00Z".into(), + base_revision: Revision::new(4), + }); + let remote = validate_sync_documents(deleted, Revision::new(5)).unwrap(); + let merged = diff_sync_documents(Some(&base), &base, &remote) + .unwrap() + .resolve(&[]) + .unwrap(); + assert!(merged + .documents() + .tombstones + .iter() + .any(|t| t.id == "entry")); + assert!(!merged + .documents() + .documents + .iter() + .any(|doc| doc.id == "entry")); + let edited = change( + &base, + DocumentKind::Dictionary, + "entry", + dictionary("entry", "edited"), + ); + let preview = diff_sync_documents(Some(&base), &edited, &remote).unwrap(); + assert_eq!(preview.conflicts()[0].reason, ConflictReason::DeleteModify); + let mut missing = base.documents().clone(); + missing.documents.retain(|doc| doc.id != "entry"); + let missing = validate_sync_documents(missing, Revision::new(4)).unwrap(); + assert_eq!( + diff_sync_documents(Some(&base), &missing, &base).unwrap_err(), + DocumentError::MissingTombstone + ); +} + +#[test] +fn no_common_baseline_never_treats_a_missing_local_id_as_a_delete() { + let local = sample(); + let remote = change( + &local, + DocumentKind::Dictionary, + "cloud", + dictionary("cloud", "remote"), + ); + let result = diff_sync_documents(None, &local, &remote) + .unwrap() + .resolve(&[]) + .unwrap(); + assert!(result + .documents() + .documents + .iter() + .any(|doc| doc.id == "cloud")); +} + +#[test] +fn limits_reject_deep_values_and_oversized_full_snapshots_without_truncating() { + let mut value = json!(true); + for _ in 0..65 { + value = json!([value]); + } + let mut set = sample().documents().clone(); + set.documents.push(LogicalDocument { + id: "futureNested".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value, + }); + assert_eq!( + validate_sync_documents(set, Revision::new(3)).unwrap_err(), + DocumentError::PayloadTooLarge + ); + let mut set = sample().documents().clone(); + set.documents.push(LogicalDocument { + id: "futureText".into(), + kind: DocumentKind::Preferences, + schema_version: 1, + value: json!("x".repeat(MAX_JSON_BYTES)), + }); + assert_eq!( + validate_sync_documents(set, Revision::new(3)).unwrap_err(), + DocumentError::PayloadTooLarge + ); +} + +#[test] +fn tombstone_materialization_preserves_all_old_deletions() { + let base = change( + &sample(), + DocumentKind::Dictionary, + "remove", + dictionary("remove", "old"), + ); + let mut current = base.documents().clone(); + current.documents.retain(|doc| doc.id != "remove"); + let updated = record_missing_tombstones(current, &base, "2026-09-26T02:00:00Z").unwrap(); + assert_eq!(updated.documents().tombstones.len(), 1); + let mut next = updated.documents().clone(); + next.tombstones.clear(); + let preserved = record_missing_tombstones(next, &updated, "2026-09-26T03:00:00Z").unwrap(); + assert_eq!( + preserved.documents().tombstones, + updated.documents().tombstones + ); +} + +#[test] +fn scope_and_restore_preview_require_the_exact_revision_and_device() { + let scope = SyncScope { + service_origin: "https://sync.example.test".into(), + owner_github_id: "42".into(), + vault_id: "12345678-1234-4234-8234-123456789abc".into(), + key_id: "22345678-1234-4234-8234-123456789abc".into(), + device_id: "device-a".into(), + }; + let context = RestoreContext { + scope, + operation_id: "32345678-1234-4234-8234-123456789abc".into(), + observed_revision: Revision::new(99), + local_generation: Revision::new(7), + target_device: source(), + }; + assert_eq!( + prepare_sync_restore(sample(), context).unwrap_err(), + DocumentError::StalePreview + ); +} + +#[test] +fn simultaneous_active_channel_additions_are_a_resolvable_redacted_selection() { + let base = sample(); + let mut local = channel_snapshot(); + let mut remote = channel_snapshot(); + local.channels[0].id = "local-new".into(); + local.provider_credentials[0].channel_id = "local-new".into(); + remote.channels[0].id = "remote-new".into(); + remote.provider_credentials[0].channel_id = "remote-new".into(); + let local = export_snapshot(local).unwrap().documents; + let remote = export_snapshot(remote).unwrap().documents; + let preview = diff_sync_documents(Some(&base), &local, &remote).unwrap(); + assert_eq!(preview.conflicts().len(), 1); + let choices = preview + .conflicts() + .iter() + .map(|conflict| ConflictChoice { + conflict_id: conflict.conflict_id.clone(), + side: ConflictSide::Remote, + }) + .collect::>(); + let merged = preview.resolve(&choices).unwrap(); + let channels = merged + .documents() + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Channels) + .collect::>(); + assert_eq!(channels.len(), 2); + assert_eq!( + channels + .iter() + .filter(|doc| doc.value["active"] == true) + .count(), + 1 + ); + assert!(channels + .iter() + .any(|doc| doc.id == "llm:remote-new" && doc.value["active"] == true)); +} + +#[test] +fn collection_order_is_preserved_independently_of_stable_id_sorting() { + let mut source = snapshot(); + source.dictionary = vec![ + SecretJson::new(dictionary("z", "newest")), + SecretJson::new(dictionary("a", "oldest")), + ]; + let exported = export_snapshot(source).unwrap(); + let docs = &exported.documents.documents().documents; + assert_eq!( + docs.iter() + .find(|doc| doc.kind == DocumentKind::Dictionary && doc.id == "z") + .unwrap() + .value["sortIndex"], + 0 + ); + assert_eq!( + docs.iter() + .find(|doc| doc.kind == DocumentKind::Dictionary && doc.id == "a") + .unwrap() + .value["sortIndex"], + 1 + ); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs new file mode 100644 index 000000000..eb872e563 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/types.rs @@ -0,0 +1,430 @@ +//! Logical, secret-bearing data boundaries. None of these snapshots is a UI DTO. + +use std::collections::BTreeMap; +use std::fmt; + +use futures_util::future::BoxFuture; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use zeroize::Zeroize; + +use crate::cloud_sync_e2ee_protocol::types::{ + DocumentKind, DocumentSet, LogicalDocument, Revision, SourceDevice, Tombstone, +}; + +pub const DOCUMENT_SCHEMA_VERSION: u32 = 1; +pub const MAX_JSON_BYTES: usize = 15 * 1024 * 1024; +pub const MAX_DOCUMENTS: usize = 100_000; +pub const MAX_DEPTH: usize = 64; +pub const MAX_ICON_BYTES: usize = 64 * 1024; +pub const MAX_JOURNAL_BYTES: usize = 33 * 1024 * 1024; + +/// Stable, value-free errors. Never include serde, provider, path, or secret error bodies. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] +#[serde(rename_all = "snake_case")] +pub enum DocumentError { + #[error("sync_documents_unsupported")] + Unsupported, + #[error("sync_documents_invalid")] + InvalidDocument, + #[error("sync_documents_invalid_reference")] + InvalidReference, + #[error("payload_too_large")] + PayloadTooLarge, + #[error("sync_documents_duplicate_id")] + DuplicateId, + #[error("sync_documents_excluded_field")] + ExcludedField, + #[error("sync_documents_capture_failed")] + CaptureFailed, + #[error("sync_documents_source_changed")] + SourceChanged, + #[error("runtime_busy")] + RuntimeBusy, + #[error("sync_documents_missing_tombstone")] + MissingTombstone, + #[error("sync_conflict_choice_required")] + ConflictChoiceRequired, + #[error("stale_preview")] + StalePreview, + #[error("sync_journal_unavailable")] + JournalUnavailable, + #[error("sync_documents_locked")] + Locked, + #[error("sync_restore_rolled_back")] + RestoreRolledBack, + #[error("recovery_required")] + RecoveryRequired, +} + +pub type DocumentResult = Result; + +/// JSON may contain API keys or private history. Diagnostics expose neither keys nor values. +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct SecretJson(Value); + +impl SecretJson { + pub fn new(value: Value) -> Self { + Self(value) + } + + pub fn expose(&self) -> &Value { + &self.0 + } + + pub fn expose_mut(&mut self) -> &mut Value { + &mut self.0 + } + + pub fn into_value(mut self) -> Value { + std::mem::replace(&mut self.0, Value::Null) + } + + pub fn from_serializable(value: &impl Serialize) -> DocumentResult { + serde_json::to_value(value) + .map(Self) + .map_err(|_| DocumentError::InvalidDocument) + } +} + +impl Default for SecretJson { + fn default() -> Self { + Self(Value::Object(Default::default())) + } +} + +impl fmt::Debug for SecretJson { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretJson([REDACTED])") + } +} + +impl Drop for SecretJson { + fn drop(&mut self) { + erase_json(&mut self.0); + } +} + +pub(crate) fn erase_json(value: &mut Value) { + match value { + Value::String(text) => text.zeroize(), + Value::Array(values) => values.iter_mut().for_each(erase_json), + Value::Object(values) => values.values_mut().for_each(erase_json), + _ => {} + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncNamespace { + Asr, + Llm, + Omni, +} + +impl SyncNamespace { + pub fn as_str(self) -> &'static str { + match self { + Self::Asr => "asr", + Self::Llm => "llm", + Self::Omni => "omni", + } + } +} + +/// Local readiness/test results and OAuth login state are intentionally absent. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChannelRecord { + pub id: String, + pub namespace: SyncNamespace, + pub provider_type: String, + pub name: String, + pub enabled: bool, + pub order: u32, + pub active: bool, +} + +impl ChannelRecord { + pub fn document_id(&self) -> String { + format!("{}:{}", self.namespace.as_str(), self.id) + } +} + +/// Exactly one controlled account map per channel, including an empty map. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ProviderCredentialRecord { + pub channel_id: String, + pub namespace: SyncNamespace, + pub accounts: BTreeMap, +} + +impl ProviderCredentialRecord { + pub fn document_id(&self) -> String { + format!("{}:{}", self.namespace.as_str(), self.channel_id) + } +} + +impl Drop for ProviderCredentialRecord { + fn drop(&mut self) { + self.accounts.values_mut().for_each(Zeroize::zeroize); + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct IconAsset { + pub mime: String, + pub base64: String, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct StylePackRecord { + /// Full StylePack serialization; export removes iconPath and derived active. + pub pack: SecretJson, + pub icon: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PresetOrigin { + Custom, + Override, + BuiltinState, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct VocabularyPresetRecord { + pub id: String, + pub origin: PresetOrigin, + pub name: Option, + pub phrases: Vec, + pub enabled: bool, + #[serde(default)] + pub order: u32, +} + +/// One source device/day contribution. Never export an imported aggregate as a new local one. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ActivityRecord { + pub source_device_id: String, + pub date: String, + pub count: u64, + pub chars: u64, + pub duration_ms: u64, +} + +impl ActivityRecord { + pub fn document_id(&self) -> String { + format!("{}:{}", self.source_device_id, self.date) + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct WindowPosition { + pub window_id: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// These are data to preserve/review, never executable paths or permission grants. +#[derive(Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DeviceProfileRecord { + pub device: SourceDevice, + pub preferences: SecretJson, + pub channels: Vec, + pub provider_credentials: Vec, + pub window_positions: Vec, +} + +/// A coherent capture under the Host's write barrier. No directory/keystore enumeration. +#[derive(Clone)] +pub struct ExportSnapshot { + pub source_device: SourceDevice, + pub exported_at: String, + pub generation: Revision, + pub base_revision: Revision, + /// Raw preferences plus preserved unknown keys, before UserPreferences drops unknown data. + pub preferences: SecretJson, + pub ui_preferences: SecretJson, + pub channels: Vec, + pub provider_credentials: Vec, + pub dictionary: Vec, + pub vocabulary_presets: Vec, + pub corrections: Vec, + pub style_packs: Vec, + pub history: Vec, + pub activity: Vec, + pub window_positions: Vec, + /// Other-device profiles and unknown preference keys from protected local extension storage. + pub retained_documents: Vec, + pub tombstones: Vec, +} + +pub trait SyncDocumentSource: Send + Sync { + /// Capture all registered stores/credentials at one persisted generation. + /// Missing capabilities and failed credential reads must return an error, never an empty set. + fn capture(&self) -> BoxFuture<'_, DocumentResult>; +} + +#[derive(Clone)] +pub struct ValidatedSyncDocuments { + pub(crate) set: DocumentSet, + pub(crate) revision: Revision, +} + +impl ValidatedSyncDocuments { + pub fn documents(&self) -> &DocumentSet { + &self.set + } + + pub fn observed_revision(&self) -> Revision { + self.revision + } +} + +impl Drop for ValidatedSyncDocuments { + fn drop(&mut self) { + for document in &mut self.set.documents { + erase_json(&mut document.value); + } + } +} + +#[derive(Clone)] +pub struct ExportedDocuments { + pub generation: Revision, + pub documents: ValidatedSyncDocuments, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DocumentKey { + pub kind: DocumentKind, + pub id: String, +} + +/// Origin/account/vault/key/device binding for protected local state and restore ownership. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SyncScope { + pub service_origin: String, + pub owner_github_id: String, + pub vault_id: String, + pub key_id: String, + pub device_id: String, +} + +#[derive(Clone)] +pub struct RestoreContext { + pub scope: SyncScope, + pub operation_id: String, + pub observed_revision: Revision, + pub local_generation: Revision, + pub target_device: SourceDevice, +} + +/// Exclusive lease blocks every normal repository/credential mutation until released. +/// After a crash, startup must recover a pending journal before admitting ordinary writes. +pub trait RestoreBackend: Send + Sync { + fn acquire( + &self, + scope: SyncScope, + expected_generation: Option, + ) -> BoxFuture<'_, DocumentResult>>; +} + +pub trait RestoreLease: Send { + fn capture(&mut self) -> BoxFuture<'_, DocumentResult>; + + /// Local-only side effects never enter cloud documents. The encrypted journal may retain + /// the minimal receiving-device state needed to roll them back exactly. + fn capture_rollback_state(&mut self) -> BoxFuture<'_, DocumentResult> { + Box::pin(async { Ok(SecretJson::default()) }) + } + fn restore_rollback_state(&mut self, state: &SecretJson) -> BoxFuture<'_, DocumentResult<()>> { + let empty = state + .expose() + .as_object() + .is_some_and(|value| value.is_empty()); + Box::pin(async move { + if empty { + Ok(()) + } else { + Err(DocumentError::Unsupported) + } + }) + } + + /// Validate native representations before journalling or mutating any local data. + fn preflight( + &mut self, + _documents: &ValidatedSyncDocuments, + ) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async { Ok(()) }) + } + + /// Replace registered logical data, preserving stable IDs and all history. + /// Do not import OAuth/permissions, execute device paths, or run retention trimming. + fn replace(&mut self, documents: ValidatedSyncDocuments) -> BoxFuture<'_, DocumentResult<()>>; + + /// Reload file-backed caches and invalidate credential/test readiness before verification. + fn reload(&mut self) -> BoxFuture<'_, DocumentResult<()>>; + + /// Must persist the pending marker; dropping a cancelled future cannot admit ordinary writes. + fn mark_recovery_pending(&mut self, operation_id: &str) -> DocumentResult<()>; + + /// Persist this only after the encrypted journal is durable, before the first data write. + fn mark_journal_ready(&mut self, operation_id: &str) -> DocumentResult<()>; + + /// Missing journal is safe only for a pending Preparing marker; Applying must fail closed. + fn recover_without_journal(&mut self) -> DocumentResult<()>; + + /// A durable receipt prevents replaying an old committed journal over newer user changes. + fn completed_restore( + &self, + operation_id: &str, + ) -> DocumentResult>; + + /// Idempotent for operation_id. Commit advances one Restore-origin generation; rollback does not. + fn finish_restore( + &mut self, + operation_id: &str, + committed: bool, + ) -> DocumentResult; +} + +macro_rules! redact_debug { + ($($name:ty),+ $(,)?) => { + $(impl fmt::Debug for $name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(concat!(stringify!($name), "([REDACTED])")) + } + })+ + }; +} + +redact_debug!( + ChannelRecord, + ProviderCredentialRecord, + IconAsset, + StylePackRecord, + VocabularyPresetRecord, + ActivityRecord, + WindowPosition, + DeviceProfileRecord, + ExportSnapshot, + ValidatedSyncDocuments, + ExportedDocuments, + SyncScope, + RestoreContext, +); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/validate.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/validate.rs new file mode 100644 index 000000000..0f51ec367 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/validate.rs @@ -0,0 +1,812 @@ +//! Validation after authenticated decryption and before any local side effect. + +use std::collections::{BTreeMap, BTreeSet}; +use std::io::{self, Write}; + +use base64::{engine::general_purpose::STANDARD, Engine}; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value}; + +use crate::cloud_sync_e2ee_protocol::types::{ + DocumentKind, DocumentSet, LogicalDocument, Revision, SourceDevice, +}; + +use super::registry::{ + credential_accounts, excluded_extension_key, is_device_channel, preference_field, + validate_preference_value, PreferenceClass, +}; +use super::types::*; + +/// Input must come from the protocol's duplicate-key/UTF-8/depth checked AEAD decoder. +pub fn validate_sync_documents( + mut set: DocumentSet, + observed_revision: Revision, +) -> DocumentResult { + if set.schema_version != DOCUMENT_SCHEMA_VERSION + || set + .documents + .iter() + .any(|doc| doc.schema_version != DOCUMENT_SCHEMA_VERSION) + { + return Err(DocumentError::Unsupported); + } + if set.documents.len().saturating_add(set.tombstones.len()) > MAX_DOCUMENTS { + return Err(DocumentError::PayloadTooLarge); + } + timestamp(&set.exported_at)?; + source_device(&set.source_device)?; + let mut seen = BTreeSet::new(); + for doc in &set.documents { + identifier(&doc.id)?; + if !seen.insert((doc.kind, doc.id.clone())) { + return Err(DocumentError::DuplicateId); + } + value_depth(&doc.value)?; + validate_document(doc)?; + } + for tombstone in &set.tombstones { + identifier(&tombstone.id)?; + timestamp(&tombstone.deleted_at)?; + if tombstone.base_revision > observed_revision { + return Err(DocumentError::InvalidDocument); + } + if !seen.insert((tombstone.kind, tombstone.id.clone())) { + return Err(DocumentError::DuplicateId); + } + } + normalize_collection_order(&mut set)?; + let mut limit = SizeLimit { bytes: 0 }; + serde_json::to_writer(&mut limit, &set).map_err(|_| DocumentError::PayloadTooLarge)?; + validate_references(&set)?; + set.documents + .sort_by(|left, right| (left.kind, &left.id).cmp(&(right.kind, &right.id))); + set.tombstones + .sort_by(|left, right| (left.kind, &left.id).cmp(&(right.kind, &right.id))); + Ok(ValidatedSyncDocuments { + set, + revision: observed_revision, + }) +} + +struct SizeLimit { + bytes: usize, +} +impl Write for SizeLimit { + fn write(&mut self, data: &[u8]) -> io::Result { + self.bytes = self + .bytes + .checked_add(data.len()) + .ok_or_else(|| io::Error::other("limit"))?; + if self.bytes > MAX_JSON_BYTES { + return Err(io::Error::other("limit")); + } + Ok(data.len()) + } + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub(crate) fn value_depth(root: &Value) -> DocumentResult<()> { + let mut stack = vec![(root, 1usize)]; + while let Some((value, depth)) = stack.pop() { + if depth > MAX_DEPTH { + return Err(DocumentError::PayloadTooLarge); + } + match value { + Value::Array(values) => stack.extend(values.iter().map(|v| (v, depth + 1))), + Value::Object(values) => stack.extend(values.values().map(|v| (v, depth + 1))), + Value::Number(number) + if number.is_f64() && !number.as_f64().is_some_and(f64::is_finite) => + { + return Err(DocumentError::InvalidDocument) + } + _ => {} + } + } + Ok(()) +} + +pub(crate) fn identifier(value: &str) -> DocumentResult<()> { + if value.is_empty() + || value.len() > 512 + || matches!(value, "." | "..") + || value + .chars() + .any(|c| c.is_control() || matches!(c, '/' | '\\')) + { + return Err(DocumentError::InvalidDocument); + } + Ok(()) +} + +pub(crate) fn timestamp(value: &str) -> DocumentResult<()> { + let time = + chrono::DateTime::parse_from_rfc3339(value).map_err(|_| DocumentError::InvalidDocument)?; + if time.offset().local_minus_utc() != 0 { + return Err(DocumentError::InvalidDocument); + } + Ok(()) +} + +fn source_device(device: &SourceDevice) -> DocumentResult<()> { + identifier(&device.id)?; + for text in [&device.os, &device.arch, &device.app_version] { + if text.is_empty() || text.len() > 128 || text.chars().any(char::is_control) { + return Err(DocumentError::InvalidDocument); + } + } + Ok(()) +} + +pub(crate) fn deserialize(value: &Value) -> DocumentResult { + serde_json::from_value(value.clone()).map_err(|_| DocumentError::InvalidDocument) +} + +fn object<'a>(value: &'a Value, allowed: &[&str]) -> DocumentResult<&'a Map> { + let object = value.as_object().ok_or(DocumentError::InvalidDocument)?; + if object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err(DocumentError::Unsupported); + } + Ok(object) +} + +fn required_text<'a>(object: &'a Map, key: &str) -> DocumentResult<&'a str> { + object + .get(key) + .and_then(Value::as_str) + .ok_or(DocumentError::InvalidDocument) +} + +fn optional_text(object: &Map, keys: &[&str]) -> DocumentResult<()> { + for key in keys { + if object + .get(*key) + .is_some_and(|value| !value.is_null() && !value.is_string()) + { + return Err(DocumentError::InvalidDocument); + } + } + Ok(()) +} + +fn optional_unsigned(object: &Map, keys: &[&str]) -> DocumentResult<()> { + for key in keys { + if object + .get(*key) + .is_some_and(|value| !value.is_null() && value.as_u64().is_none()) + { + return Err(DocumentError::InvalidDocument); + } + } + Ok(()) +} + +fn optional_bool(object: &Map, keys: &[&str]) -> DocumentResult<()> { + for key in keys { + if object + .get(*key) + .is_some_and(|value| !value.is_null() && !value.is_boolean()) + { + return Err(DocumentError::InvalidDocument); + } + } + Ok(()) +} + +fn choice(value: &str, choices: &[&str]) -> DocumentResult<()> { + if choices.contains(&value) { + Ok(()) + } else { + Err(DocumentError::InvalidDocument) + } +} + +fn validate_document(doc: &LogicalDocument) -> DocumentResult<()> { + match doc.kind { + DocumentKind::Preferences => { + if let Some(field) = preference_field(&doc.id) { + if field.class != PreferenceClass::Portable { + return Err(DocumentError::ExcludedField); + } + validate_preference_value(field, &doc.value)?; + } else if excluded_extension_key(&doc.id) { + return Err(DocumentError::ExcludedField); + } + } + DocumentKind::UiPreferences => match doc.id.as_str() { + "locale" => choice( + doc.value.as_str().ok_or(DocumentError::InvalidDocument)?, + &[ + "system", "zh-CN", "zh-TW", "en", "ja", "ko", "es", "fr", "de", + ], + )?, + "fontScale" => choice( + doc.value.as_str().ok_or(DocumentError::InvalidDocument)?, + &["small", "medium", "large"], + )?, + _ if excluded_extension_key(&doc.id) => return Err(DocumentError::ExcludedField), + _ => {} + }, + DocumentKind::Channels => { + let channel: ChannelRecord = deserialize(&doc.value)?; + validate_channel(&channel)?; + if channel.document_id() != doc.id + || is_device_channel(channel.namespace, &channel.provider_type) + { + return Err(DocumentError::InvalidDocument); + } + } + DocumentKind::ProviderCredentials => { + let credentials: ProviderCredentialRecord = deserialize(&doc.value)?; + validate_credentials(&credentials)?; + if credentials.document_id() != doc.id { + return Err(DocumentError::InvalidReference); + } + } + DocumentKind::Dictionary => { + let row = object( + &doc.value, + &[ + "id", + "phrase", + "note", + "enabled", + "hits", + "createdAt", + "sortIndex", + ], + )?; + if required_text(row, "id")? != doc.id { + return Err(DocumentError::InvalidReference); + } + required_text(row, "phrase")?; + optional_text(row, &["note", "createdAt"])?; + optional_bool(row, &["enabled"])?; + optional_unsigned(row, &["hits"])?; + } + DocumentKind::VocabularyPresets => { + let preset: VocabularyPresetRecord = deserialize(&doc.value)?; + identifier(&preset.id)?; + let prefix = match preset.origin { + PresetOrigin::Custom => "custom", + PresetOrigin::Override => "override", + PresetOrigin::BuiltinState => "builtin", + }; + if doc.id != format!("{prefix}:{}", preset.id) { + return Err(DocumentError::InvalidReference); + } + if preset.origin == PresetOrigin::BuiltinState { + if preset.name.is_some() || !preset.phrases.is_empty() || preset.order != 0 { + return Err(DocumentError::InvalidDocument); + } + } else if preset.name.as_deref().is_none_or(str::is_empty) { + return Err(DocumentError::InvalidDocument); + } + } + DocumentKind::Corrections => { + let row = object( + &doc.value, + &[ + "id", + "pattern", + "replacement", + "enabled", + "createdAt", + "source", + "sortIndex", + ], + )?; + if required_text(row, "id")? != doc.id { + return Err(DocumentError::InvalidReference); + } + required_text(row, "pattern")?; + required_text(row, "replacement")?; + optional_text(row, &["createdAt"])?; + optional_bool(row, &["enabled"])?; + if let Some(value) = row.get("source") { + choice( + value.as_str().ok_or(DocumentError::InvalidDocument)?, + &["manual", "learned"], + )?; + } + } + DocumentKind::StylePacks => validate_style(doc)?, + DocumentKind::History => validate_history(doc)?, + DocumentKind::Activity => { + let day: ActivityRecord = deserialize(&doc.value)?; + identifier(&day.source_device_id)?; + let parsed = chrono::NaiveDate::parse_from_str(&day.date, "%Y-%m-%d") + .map_err(|_| DocumentError::InvalidDocument)?; + if parsed.format("%Y-%m-%d").to_string() != day.date || day.document_id() != doc.id { + return Err(DocumentError::InvalidDocument); + } + } + DocumentKind::DeviceProfile => validate_profile(doc)?, + } + Ok(()) +} + +fn validate_channel(channel: &ChannelRecord) -> DocumentResult<()> { + identifier(&channel.id)?; + identifier(&channel.provider_type)?; + if channel.active && !channel.enabled { + return Err(DocumentError::InvalidDocument); + } + if channel.namespace == SyncNamespace::Omni && channel.id != channel.provider_type { + return Err(DocumentError::InvalidReference); + } + Ok(()) +} + +/// Common native-vault batch contract, including device-local ASR entries. +/// Provider-specific headers/temperature/protocol representability is checked by the Host. +pub fn validate_credential_set( + channels: &[ChannelRecord], + credentials: &[ProviderCredentialRecord], +) -> DocumentResult<()> { + let mut channel_ids = BTreeSet::new(); + let mut credential_ids = BTreeSet::new(); + let mut active = BTreeSet::new(); + for channel in channels { + validate_channel(channel)?; + if !channel_ids.insert(channel.document_id()) { + return Err(DocumentError::DuplicateId); + } + if channel.active && !active.insert(channel.namespace) { + return Err(DocumentError::InvalidReference); + } + } + for record in credentials { + identifier(&record.channel_id)?; + if !credential_ids.insert(record.document_id()) { + return Err(DocumentError::DuplicateId); + } + if record + .accounts + .keys() + .any(|account| !credential_accounts(record.namespace).contains(&account.as_str())) + { + return Err(DocumentError::ExcludedField); + } + } + if channel_ids != credential_ids { + return Err(DocumentError::InvalidReference); + } + Ok(()) +} + +fn validate_credentials(credentials: &ProviderCredentialRecord) -> DocumentResult<()> { + identifier(&credentials.channel_id)?; + for (account, value) in &credentials.accounts { + if !credential_accounts(credentials.namespace).contains(&account.as_str()) { + return Err(DocumentError::ExcludedField); + } + if account.ends_with(".extra_headers") && !value.trim().is_empty() { + let headers: BTreeMap = + serde_json::from_str(value).map_err(|_| DocumentError::InvalidDocument)?; + if headers.keys().any(|key| { + key.eq_ignore_ascii_case("cookie") || key.eq_ignore_ascii_case("set-cookie") + }) { + return Err(DocumentError::ExcludedField); + } + } + } + Ok(()) +} + +fn validate_style(doc: &LogicalDocument) -> DocumentResult<()> { + let style: StylePackRecord = deserialize(&doc.value)?; + let row = object( + style.pack.expose(), + &[ + "id", + "name", + "description", + "author", + "version", + "kind", + "baseMode", + "selectionPrompt", + "voiceEditPrompt", + "prompt", + "examples", + "tags", + "createdAt", + "updatedAt", + "enabled", + "recommendedModel", + "compatibleAppVersion", + "originPackId", + "originAuthorLogin", + ], + )?; + if required_text(row, "id")? != doc.id { + return Err(DocumentError::InvalidReference); + } + for key in [ + "name", + "description", + "version", + "kind", + "selectionPrompt", + "voiceEditPrompt", + "prompt", + ] { + required_text(row, key)?; + } + choice( + required_text(row, "baseMode")?, + &["raw", "light", "structured", "formal"], + )?; + optional_text( + row, + &[ + "author", + "createdAt", + "updatedAt", + "recommendedModel", + "compatibleAppVersion", + "originPackId", + "originAuthorLogin", + ], + )?; + optional_bool(row, &["enabled"])?; + if !row + .get("tags") + .and_then(Value::as_array) + .is_some_and(|tags| tags.iter().all(Value::is_string)) + { + return Err(DocumentError::InvalidDocument); + } + let examples = row + .get("examples") + .and_then(Value::as_array) + .ok_or(DocumentError::InvalidDocument)?; + for example in examples { + let fields = object(example, &["title", "input", "output"])?; + required_text(fields, "input")?; + required_text(fields, "output")?; + optional_text(fields, &["title"])?; + } + if let Some(icon) = &style.icon { + validate_icon(icon)?; + } + Ok(()) +} + +pub fn validate_icon(icon: &IconAsset) -> DocumentResult<()> { + if icon.base64.len() > MAX_ICON_BYTES.div_ceil(3) * 4 { + return Err(DocumentError::PayloadTooLarge); + } + let bytes = STANDARD + .decode(&icon.base64) + .map_err(|_| DocumentError::InvalidDocument)?; + if bytes.len() > MAX_ICON_BYTES || STANDARD.encode(&bytes) != icon.base64 { + return Err(DocumentError::InvalidDocument); + } + let extension = match icon.mime.as_str() { + "image/png" => "png", + "image/jpeg" => "jpg", + "image/webp" => "webp", + _ => return Err(DocumentError::InvalidDocument), + }; + crate::style_pack_archive::validate_icon_content(extension, &bytes) + .map_err(|_| DocumentError::InvalidDocument) +} + +fn validate_history(doc: &LogicalDocument) -> DocumentResult<()> { + let row = object( + &doc.value, + &[ + "id", + "createdAt", + "source", + "rawTranscript", + "asrTranscript", + "finalText", + "mode", + "stylePackId", + "translationActive", + "polishSource", + "appBundleId", + "appName", + "insertStatus", + "errorCode", + "durationMs", + "dictionaryEntryCount", + "hasAudioRecording", + "asrProvider", + "asrModel", + "llmProvider", + "llmModel", + "pipelineMode", + "asrMs", + "polishMs", + "sortIndex", + ], + )?; + if required_text(row, "id")? != doc.id { + return Err(DocumentError::InvalidReference); + } + for key in ["createdAt", "rawTranscript", "finalText"] { + required_text(row, key)?; + } + choice( + required_text(row, "mode")?, + &["raw", "light", "structured", "formal"], + )?; + choice( + required_text(row, "insertStatus")?, + &[ + "inserted", + "pasteSent", + "copiedFallback", + "failed", + "notRequested", + ], + )?; + if let Some(source) = row.get("source") { + choice( + source.as_str().ok_or(DocumentError::InvalidDocument)?, + &[ + "voice", + "quick_note", + "selection_polish", + "selection_voice_edit", + ], + )?; + } + optional_text( + row, + &[ + "asrTranscript", + "stylePackId", + "polishSource", + "appBundleId", + "appName", + "errorCode", + "asrProvider", + "asrModel", + "llmProvider", + "llmModel", + "pipelineMode", + ], + )?; + optional_unsigned( + row, + &["durationMs", "dictionaryEntryCount", "asrMs", "polishMs"], + )?; + optional_bool(row, &["translationActive", "hasAudioRecording"])?; + if row.get("hasAudioRecording") == Some(&Value::Bool(true)) { + return Err(DocumentError::ExcludedField); + } + Ok(()) +} + +fn validate_profile(doc: &LogicalDocument) -> DocumentResult<()> { + let profile: DeviceProfileRecord = deserialize(&doc.value)?; + source_device(&profile.device)?; + if profile.device.id != doc.id { + return Err(DocumentError::InvalidReference); + } + for (key, value) in profile + .preferences + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)? + { + let field = preference_field(key).ok_or(DocumentError::Unsupported)?; + if field.class != PreferenceClass::DeviceProfile { + return Err(DocumentError::ExcludedField); + } + validate_preference_value(field, value)?; + } + let mut channels = BTreeMap::new(); + let mut active = BTreeSet::new(); + for channel in &profile.channels { + validate_channel(channel)?; + if !is_device_channel(channel.namespace, &channel.provider_type) { + return Err(DocumentError::InvalidDocument); + } + if channel.active && !active.insert(channel.namespace) { + return Err(DocumentError::InvalidReference); + } + if channels.insert(channel.document_id(), channel).is_some() { + return Err(DocumentError::DuplicateId); + } + } + let mut credential_ids = BTreeSet::new(); + for credential in &profile.provider_credentials { + validate_credentials(credential)?; + if !credential_ids.insert(credential.document_id()) { + return Err(DocumentError::DuplicateId); + } + } + if credential_ids != channels.keys().cloned().collect() { + return Err(DocumentError::InvalidReference); + } + let mut windows = BTreeSet::new(); + for window in &profile.window_positions { + identifier(&window.window_id)?; + if !windows.insert(&window.window_id) + || window.width == 0 + || window.height == 0 + || window.width > 100_000 + || window.height > 100_000 + { + return Err(DocumentError::InvalidDocument); + } + } + Ok(()) +} + +fn validate_references(set: &DocumentSet) -> DocumentResult<()> { + let mut channels = BTreeMap::new(); + let mut credentials = BTreeSet::new(); + let mut active = BTreeSet::new(); + let styles: BTreeSet<&str> = set + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::StylePacks) + .map(|doc| doc.id.as_str()) + .collect(); + for doc in &set.documents { + match doc.kind { + DocumentKind::Channels => { + let channel: ChannelRecord = deserialize(&doc.value)?; + if channel.active && !active.insert(channel.namespace) { + return Err(DocumentError::InvalidReference); + } + channels.insert(doc.id.clone(), channel); + } + DocumentKind::ProviderCredentials => { + credentials.insert(doc.id.clone()); + } + DocumentKind::Preferences + if matches!( + doc.id.as_str(), + "activeStylePackId" | "selectionPolishStylePackId" + ) => + { + let id = doc.value.as_str().ok_or(DocumentError::InvalidReference)?; + if !id.is_empty() && !styles.contains(id) { + return Err(DocumentError::InvalidReference); + } + } + _ => {} + } + } + if credentials != channels.keys().cloned().collect() { + return Err(DocumentError::InvalidReference); + } + // A channel cannot be deleted while a separate credential half remains live. + for tombstone in &set.tombstones { + if tombstone.kind == DocumentKind::Channels && credentials.contains(&tombstone.id) + || tombstone.kind == DocumentKind::ProviderCredentials + && channels.contains_key(&tombstone.id) + { + return Err(DocumentError::InvalidReference); + } + } + Ok(()) +} + +pub(crate) fn validate_scope(scope: &SyncScope) -> DocumentResult<()> { + let origin = + url::Url::parse(&scope.service_origin).map_err(|_| DocumentError::InvalidDocument)?; + if origin.scheme() != "https" + || origin.origin().ascii_serialization() != scope.service_origin + || !origin.username().is_empty() + || origin.password().is_some() + { + return Err(DocumentError::InvalidDocument); + } + let github_id = + Revision::parse(&scope.owner_github_id).map_err(|_| DocumentError::InvalidDocument)?; + if github_id.get() == 0 { + return Err(DocumentError::InvalidDocument); + } + for id in [&scope.vault_id, &scope.key_id] { + uuid_v4(id)?; + } + identifier(&scope.device_id) +} + +pub(crate) fn uuid_v4(value: &str) -> DocumentResult<()> { + let id = uuid::Uuid::parse_str(value).map_err(|_| DocumentError::InvalidDocument)?; + if id.get_version() != Some(uuid::Version::Random) + || id.get_variant() != uuid::Variant::RFC4122 + || id.to_string() != value + { + return Err(DocumentError::InvalidDocument); + } + Ok(()) +} + +fn normalize_collection_order(set: &mut DocumentSet) -> DocumentResult<()> { + for kind in [ + DocumentKind::Dictionary, + DocumentKind::Corrections, + DocumentKind::History, + ] { + let mut indices: Vec<_> = set + .documents + .iter() + .enumerate() + .filter(|(_, doc)| doc.kind == kind) + .map(|(index, _)| index) + .collect(); + for index in &indices { + if set.documents[*index] + .value + .get("sortIndex") + .is_some_and(|value| value.as_u64().is_none()) + { + return Err(DocumentError::InvalidDocument); + } + } + indices.sort_by(|left, right| { + let left = &set.documents[*left]; + let right = &set.documents[*right]; + ( + left.value + .get("sortIndex") + .and_then(Value::as_u64) + .unwrap_or(u64::MAX), + &left.id, + ) + .cmp(&( + right + .value + .get("sortIndex") + .and_then(Value::as_u64) + .unwrap_or(u64::MAX), + &right.id, + )) + }); + for (order, index) in indices.into_iter().enumerate() { + set.documents[index] + .value + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("sortIndex".into(), Value::from(order)); + } + } + for origin in ["custom", "override"] { + let mut indices: Vec<_> = set + .documents + .iter() + .enumerate() + .filter(|(_, doc)| { + doc.kind == DocumentKind::VocabularyPresets + && doc.value.get("origin").and_then(Value::as_str) == Some(origin) + }) + .map(|(index, _)| index) + .collect(); + indices.sort_by(|left, right| { + let left = &set.documents[*left]; + let right = &set.documents[*right]; + ( + left.value.get("order").and_then(Value::as_u64).unwrap_or(0), + &left.id, + ) + .cmp(&( + right + .value + .get("order") + .and_then(Value::as_u64) + .unwrap_or(0), + &right.id, + )) + }); + for (order, index) in indices.into_iter().enumerate() { + set.documents[index] + .value + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("order".into(), Value::from(order)); + } + } + Ok(()) +} 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 new file mode 100644 index 000000000..2eac760a5 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto.rs @@ -0,0 +1,393 @@ +//! v1 cryptography uses RustCrypto primitives, never a custom cipher/KDF. +//! Argon2 is CPU/memory intensive: callers must run derivation off UI/async workers. + +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}, + XChaCha20Poly1305, XNonce, +}; +use std::fmt; +use unicode_normalization::UnicodeNormalization; +use zeroize::Zeroizing; + +pub(crate) struct NormalizedPassword(Zeroizing); +impl fmt::Debug for NormalizedPassword { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("NormalizedPassword([REDACTED])") + } +} +impl NormalizedPassword { + /// NFC only: neither trim nor case-fold changes the password sent to the KDF. + pub(crate) fn new(input: SecretInput) -> Result { + // Bound work before Unicode normalization, then check the normative NFC limit. + if input.expose().len() > 4096 { + return Err(Error::WeakPassword); + } + let mut text = Zeroizing::new(String::with_capacity(512)); + let mut count = 0; + for character in input.expose().nfc() { + count += 1; + if count > 128 || text.len() + character.len_utf8() > 512 { + return Err(Error::WeakPassword); + } + text.push(character); + } + if !(12..=128).contains(&count) + || !text.bytes().any(|c| c.is_ascii_lowercase()) + || !text.bytes().any(|c| c.is_ascii_uppercase()) + || !text.bytes().any(|c| c.is_ascii_digit()) + { + return Err(Error::WeakPassword); + } + Ok(Self(text)) + } + /// Apply local password policy v1 only when creating/changing a password. + /// Unlock remains compatible if a later release expands the weak-password list. + pub(crate) fn validate_new(&self) -> Result<()> { + let lower = Zeroizing::new(self.0.to_ascii_lowercase()); + let signature = Zeroizing::new( + lower + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::(), + ); + let weak = [ + "password", + "qwerty", + "12345678", + "abcdefgh", + "abcdefg", + "letmein", + "welcome", + "administrator", + ]; + if weak.iter().any(|word| signature.contains(word)) { + return Err(Error::WeakPassword); + } + let mapped = Zeroizing::new( + lower + .chars() + .map(|c| match c { + '@' | '4' => 'a', + '0' => 'o', + '$' | '5' => 's', + '3' => 'e', + _ => c, + }) + .collect::(), + ); + if mapped.contains("password") || mapped.contains("qwerty") { + return Err(Error::WeakPassword); + } + let chars = Zeroizing::new(lower.chars().collect::>()); + let unique = chars + .iter() + .enumerate() + .filter(|(i, c)| !chars[..*i].contains(c)) + .count(); + if unique < 4 + || (1..=8.min(chars.len() / 3)).any(|period| { + chars + .iter() + .enumerate() + .all(|(i, c)| *c == chars[i % period]) + }) + { + return Err(Error::WeakPassword); + } + Ok(()) + } + pub(crate) fn confirmed_new(input: SecretInput, confirmation: SecretInput) -> Result { + let password = Self::new(input)?; + let confirmation = Self::new(confirmation)?; + if password.0.as_bytes() != confirmation.0.as_bytes() { + return Err(Error::PasswordConfirmationMismatch); + } + password.validate_new()?; + Ok(password) + } +} + +/// Not serializable or implicitly cloneable. Secure-store/journal callers must bind +/// exported bytes to service origin + GitHub ID + vault ID + key ID themselves. +pub(crate) struct DerivedKey(Zeroizing<[u8; 32]>); +impl DerivedKey { + pub(crate) fn from_secret_bytes(bytes: Zeroizing<[u8; 32]>) -> Self { + Self(bytes) + } + pub(crate) fn expose_bytes(&self) -> &[u8; 32] { + &self.0 + } + pub(crate) fn copy_secret_bytes(&self) -> Zeroizing<[u8; 32]> { + Zeroizing::new(*self.0) + } +} +impl fmt::Debug for DerivedKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("DerivedKey([REDACTED])") + } +} + +pub(crate) fn derive_key(password: &NormalizedPassword, kdf: &Kdf) -> Result { + kdf.validate()?; + let params = Params::new(65_536, 3, 4, Some(32)).map_err(|_| Error::UnsupportedProtocol)?; + let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); + let mut key = Zeroizing::new([0; 32]); + // The caller-owned Argon2 working memory is also wiped on all exits. + let mut memory = Zeroizing::new(vec![Block::default(); 65_536]); + argon + .hash_password_into_with_memory( + password.0.as_bytes(), + kdf.salt.bytes(), + key.as_mut(), + &mut memory[..], + ) + .map_err(|_| Error::InvalidPasswordOrCiphertext)?; + Ok(DerivedKey(key)) +} + +#[derive(Clone, Debug)] +pub(crate) struct EncryptContext { + pub(crate) owner_github_id: GithubId, + pub(crate) vault_id: UuidV4, + pub(crate) key_id: UuidV4, + pub(crate) base_revision: Revision, + pub(crate) operation_id: UuidV4, + pub(crate) kind: UploadKind, + pub(crate) kdf: Kdf, +} +impl EncryptContext { + pub(crate) fn validate(&self) -> Result<()> { + self.kdf.validate()?; + self.base_revision.checked_next().map(|_| ()) + } + fn header(&self, nonce: Nonce) -> Result { + self.validate()?; + Ok(SnapshotUpload { + protocol_version: PROTOCOL_VERSION, + payload_schema_version: 1, + owner_github_id: self.owner_github_id.clone(), + vault_id: self.vault_id.clone(), + key_id: self.key_id.clone(), + base_revision: self.base_revision, + revision: self.base_revision.checked_next()?, + operation_id: self.operation_id.clone(), + kind: self.kind, + crypto_profile: CRYPTO_PROFILE.into(), + kdf: self.kdf.clone(), + aead: AEAD_NAME.into(), + codec: CODEC_NAME.into(), + nonce, + ciphertext: String::new(), + ciphertext_sha256: Sha256Digest::of(&[]), + }) + } +} + +/// Explicit ordered JSON array, independent of object/map serialization order. +pub(crate) fn aad(snapshot: &SnapshotUpload) -> Result> { + snapshot.kdf.validate()?; + if snapshot.protocol_version != PROTOCOL_VERSION + || snapshot.payload_schema_version != 1 + || snapshot.crypto_profile != CRYPTO_PROFILE + || snapshot.aead != AEAD_NAME + || snapshot.codec != CODEC_NAME + { + return Err(Error::UnsupportedProtocol); + } + let fields = serde_json::json!([ + "openless-cloud-sync", + 1, + "snapshot", + snapshot.owner_github_id.as_str(), + snapshot.vault_id.as_str(), + snapshot.key_id.as_str(), + snapshot.base_revision.as_str(), + snapshot.revision.as_str(), + snapshot.operation_id.as_str(), + snapshot.kind.as_str(), + 1, + CRYPTO_PROFILE, + "argon2id", + 19, + 65536, + 3, + 4, + snapshot.kdf.salt.encoded(), + AEAD_NAME, + CODEC_NAME + ]); + serde_json::to_vec(&fields).map_err(|_| Error::InvalidWire("aad")) +} + +fn padded_length(json_len: usize) -> Result { + if json_len == 0 || json_len > MAX_PLAINTEXT_JSON_BYTES { + return Err(Error::PayloadTooLarge); + } + let len = (json_len + 4).div_ceil(PAD_BLOCK_BYTES) * PAD_BLOCK_BYTES; + if len > MAX_PADDED_BYTES { + return Err(Error::PayloadTooLarge); + } + Ok(len) +} + +pub(crate) fn encrypt_snapshot( + documents: &DocumentSet, + context: &EncryptContext, + key: &DerivedKey, +) -> Result { + let json = documents.to_secret_json()?; + context.validate()?; + let nonce = Nonce::random()?; + let length = padded_length(json.len())?; + let mut plaintext = Zeroizing::new(Vec::with_capacity(length + 16)); + plaintext.resize(length, 0); + plaintext[..4].copy_from_slice(&(json.len() as u32).to_be_bytes()); + plaintext[4..4 + json.len()].copy_from_slice(&json); + getrandom::fill(&mut plaintext[4 + json.len()..]).map_err(|_| Error::RandomUnavailable)?; + seal_frame(context, key, nonce, plaintext) +} + +fn seal_frame( + context: &EncryptContext, + key: &DerivedKey, + nonce: Nonce, + mut frame: Zeroizing>, +) -> Result { + let mut snapshot = context.header(nonce)?; + 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, + ) + .map_err(|_| Error::InvalidPasswordOrCiphertext)?; + snapshot.ciphertext_sha256 = Sha256Digest::of(&frame); + snapshot.ciphertext = URL_SAFE_NO_PAD.encode(&frame); + snapshot.validate()?; + Ok(snapshot) +} + +/// The authenticated account and the exact metadata read are mandatory, not an +/// optional caller check. Authentication is verified before parsing any plaintext. +pub(crate) fn decrypt_snapshot( + snapshot: &SnapshotUpload, + metadata: &VaultMetadata, + owner: &GithubId, + key: &DerivedKey, +) -> Result { + snapshot.validate_against_metadata(metadata, owner)?; + 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, + ) + .map_err(|_| Error::InvalidPasswordOrCiphertext)?; + decode_frame(&frame) +} + +pub(crate) fn decrypt_snapshot_with_password( + snapshot: &SnapshotUpload, + metadata: &VaultMetadata, + owner: &GithubId, + password: SecretInput, +) -> Result<(DerivedKey, DocumentSet)> { + // Validate fixed KDF parameters, ciphertext bounds/hash and identity before + // allocating Argon2's 64 MiB memory, even when a server supplied this header. + snapshot.validate_against_metadata(metadata, owner)?; + let password = NormalizedPassword::new(password)?; + let key = derive_key(&password, &snapshot.kdf)?; + let documents = decrypt_snapshot(snapshot, metadata, owner, &key)?; + Ok((key, documents)) +} + +fn decode_frame(frame: &[u8]) -> Result { + if frame.len() < PAD_BLOCK_BYTES + || frame.len() > MAX_PADDED_BYTES + || !frame.len().is_multiple_of(PAD_BLOCK_BYTES) + { + return Err(Error::InvalidPasswordOrCiphertext); + } + let count = u32::from_be_bytes( + frame[..4] + .try_into() + .map_err(|_| Error::InvalidPasswordOrCiphertext)?, + ) as usize; + if count > MAX_PLAINTEXT_JSON_BYTES + || count == 0 + || count > frame.len() - 4 + || padded_length(count)? != frame.len() + { + return Err(Error::InvalidPasswordOrCiphertext); + } + DocumentSet::from_json_bytes(&frame[4..4 + count]) +} + +// Two independently bounded 15 MiB snapshots plus transaction metadata fit here; +// this local-only ceiling never changes the remote v1 15 MiB JSON limit. +pub(crate) const MAX_LOCAL_PLAINTEXT_BYTES: usize = 40 * 1024 * 1024; +const LOCAL_ENVELOPE_MAGIC: &[u8] = b"OL-E2EE-JOURNAL\x01"; + +fn local_aad(scope: &[u8]) -> Result>> { + if scope.is_empty() || scope.len() > CONTROL_BODY_LIMIT { + return Err(Error::InvalidWire("local journal scope")); + } + let mut bytes = Zeroizing::new(Vec::with_capacity(64 + scope.len())); + // Separate domain: a network snapshot can never authenticate as a journal. + bytes.extend_from_slice(b"openless-cloud-sync\0local-journal\0v1\0"); + bytes.extend_from_slice(&(scope.len() as u32).to_be_bytes()); + bytes.extend_from_slice(scope); + Ok(bytes) +} + +/// Local-only journal encryption. The caller supplies canonical scope bytes +/// including service origin, owner, vault/key IDs and operation identity. +/// No journal bytes may be submitted through the snapshot HTTP API. +pub(crate) fn seal_local(key: &DerivedKey, scope_aad: &[u8], plaintext: &[u8]) -> Result> { + if plaintext.len() > MAX_LOCAL_PLAINTEXT_BYTES { + return Err(Error::PayloadTooLarge); + } + let associated = local_aad(scope_aad)?; + let nonce = Nonce::random()?; + 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) + .map_err(|_| Error::InvalidPasswordOrCiphertext)?; + let mut envelope = Vec::with_capacity(LOCAL_ENVELOPE_MAGIC.len() + 24 + buffer.len()); + envelope.extend_from_slice(LOCAL_ENVELOPE_MAGIC); + envelope.extend_from_slice(nonce.bytes()); + envelope.extend_from_slice(&buffer); + Ok(envelope) +} + +pub(crate) fn open_local( + key: &DerivedKey, + scope_aad: &[u8], + envelope: &[u8], +) -> Result>> { + let prefix = LOCAL_ENVELOPE_MAGIC.len() + 24; + if envelope.len() < prefix + 16 + || envelope.len() > prefix + 16 + MAX_LOCAL_PLAINTEXT_BYTES + || !envelope.starts_with(LOCAL_ENVELOPE_MAGIC) + { + return Err(Error::InvalidPasswordOrCiphertext); + } + let associated = local_aad(scope_aad)?; + let nonce = &envelope[LOCAL_ENVELOPE_MAGIC.len()..prefix]; + 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) + .map_err(|_| Error::InvalidPasswordOrCiphertext)?; + Ok(buffer) +} + +#[cfg(test)] +mod tests; 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 new file mode 100644 index 000000000..1cb43fc50 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/crypto/tests.rs @@ -0,0 +1,341 @@ +use super::*; +use serde_json::Value; + +fn vectors() -> Vec { + serde_json::from_str::(include_str!("../fixtures/v1.json")).unwrap()["vectors"] + .as_array() + .unwrap() + .clone() +} +fn unhex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|n| u8::from_str_radix(&s[n..n + 2], 16).unwrap()) + .collect() +} +fn metadata(s: &SnapshotUpload) -> VaultMetadata { + VaultMetadata { + protocol_version: 1, + state: VaultState::Active, + owner_github_id: s.owner_github_id.clone(), + revision: s.revision, + vault_id: Some(s.vault_id.clone()), + key_id: Some(s.key_id.clone()), + updated_at: Some("2026-09-23T00:00:00Z".into()), + payload_schema_version: Some(1), + ciphertext_bytes: s.decoded_ciphertext().unwrap().len() as u64, + ciphertext_sha256: Some(s.ciphertext_sha256.clone()), + last_operation_id: Some(s.operation_id.clone()), + } +} +fn context(s: &SnapshotUpload) -> EncryptContext { + EncryptContext { + owner_github_id: s.owner_github_id.clone(), + vault_id: s.vault_id.clone(), + key_id: s.key_id.clone(), + base_revision: s.base_revision, + operation_id: s.operation_id.clone(), + kind: s.kind, + kdf: s.kdf.clone(), + } +} +fn test_key(v: &Value) -> DerivedKey { + DerivedKey::from_secret_bytes(Zeroizing::new( + unhex(v["derivedKeyHex"].as_str().unwrap()) + .try_into() + .unwrap(), + )) +} + +#[test] +fn independent_reference_argon2_libsodium_vectors_match_every_byte() { + for vector in vectors() { + let snapshot: SnapshotUpload = serde_json::from_value(vector["snapshot"].clone()).unwrap(); + snapshot.validate().unwrap(); + let password = NormalizedPassword::new(SecretInput::new( + vector["passwordInput"].as_str().unwrap().to_owned(), + )) + .unwrap(); + password.validate_new().unwrap(); + assert_eq!( + password.0.as_bytes(), + unhex(vector["passwordUtf8Hex"].as_str().unwrap()) + ); + let key = derive_key(&password, &snapshot.kdf).unwrap(); + assert_eq!( + key.expose_bytes().as_slice(), + unhex(vector["derivedKeyHex"].as_str().unwrap()) + ); + assert_eq!( + aad(&snapshot).unwrap(), + unhex(vector["aadUtf8Hex"].as_str().unwrap()) + ); + let json = unhex(vector["plaintextJsonUtf8Hex"].as_str().unwrap()); + let mut frame = Zeroizing::new(Vec::with_capacity(PAD_BLOCK_BYTES + 16)); + frame.extend_from_slice(&(json.len() as u32).to_be_bytes()); + frame.extend_from_slice(&json); + let padding_len = PAD_BLOCK_BYTES - frame.len(); + let padding = (0..padding_len) + .map(|i| (i % 251) as u8) + .collect::>(); + assert_eq!( + padding_len as u64, + vector["padding"]["length"].as_u64().unwrap() + ); + assert_eq!( + Sha256Digest::of(&padding).as_hex(), + vector["padding"]["sha256"].as_str().unwrap() + ); + frame.extend_from_slice(&padding); + let encrypted = + seal_frame(&context(&snapshot), &key, snapshot.nonce.clone(), frame).unwrap(); + assert_eq!(encrypted.ciphertext, snapshot.ciphertext); + assert_eq!(encrypted.ciphertext_sha256, snapshot.ciphertext_sha256); + let recovered = decrypt_snapshot( + &snapshot, + &metadata(&snapshot), + &snapshot.owner_github_id, + &key, + ) + .unwrap(); + assert_eq!( + serde_json::to_value(&recovered).unwrap(), + vector["recoveredJson"] + ); + } +} + +#[test] +fn nfc_confirmation_preserves_spaces_and_applies_local_weak_password_policy() { + assert!(NormalizedPassword::confirmed_new( + SecretInput::new("云同步Cafe\u{301}-Vector2026!".into()), + SecretInput::new("云同步Café-Vector2026!".into()) + ) + .is_ok()); + assert!(NormalizedPassword::confirmed_new( + SecretInput::new(" Good-PasswordA9! ".into()), + SecretInput::new("Good-PasswordA9!".into()) + ) + .is_err()); + for input in [ + "12345678", + "Password123", + "Password1234", + "Abc123456789", + "Qwerty123456", + "Aa1Aa1Aa1Aa1", + "P@ssw0rd12345", + ] { + assert!( + NormalizedPassword::new(SecretInput::new(input.into())) + .and_then(|v| v.validate_new()) + .is_err(), + "weak password was accepted" + ); + } + assert!(NormalizedPassword::new(SecretInput::new(format!("Ab1{}", "中".repeat(126)))).is_err()); +} + +#[test] +fn identity_revision_kdf_and_aead_tampering_are_rejected() { + let vector = &vectors()[0]; + let snapshot: SnapshotUpload = serde_json::from_value(vector["snapshot"].clone()).unwrap(); + let meta = metadata(&snapshot); + let key = test_key(vector); + let mut bad = snapshot.clone(); + bad.owner_github_id = GithubId::parse("54321").unwrap(); + assert!(matches!( + decrypt_snapshot(&bad, &meta, &snapshot.owner_github_id, &key), + Err(Error::AccountMismatch) + )); + let mut bad = snapshot.clone(); + bad.revision = Revision::new(1); + assert!(decrypt_snapshot(&bad, &meta, &snapshot.owner_github_id, &key).is_err()); + let mut bad = snapshot.clone(); + bad.key_id = UuidV4::random().unwrap(); + assert!(matches!( + decrypt_snapshot(&bad, &meta, &snapshot.owner_github_id, &key), + Err(Error::ContextMismatch) + )); + let mut bad = snapshot.clone(); + bad.kdf.memory_kib = 8; + assert!(derive_key( + &NormalizedPassword::new(SecretInput::new("Str0ng-Other-Phrase!".into())).unwrap(), + &bad.kdf + ) + .is_err()); + let mut bad = snapshot.clone(); + bad.nonce = Nonce::new([0; 24]); + assert!(matches!( + decrypt_snapshot(&bad, &meta, &snapshot.owner_github_id, &key), + Err(Error::InvalidPasswordOrCiphertext) + )); + let wrong = DerivedKey::from_secret_bytes(Zeroizing::new([0; 32])); + assert!(matches!( + decrypt_snapshot(&snapshot, &meta, &snapshot.owner_github_id, &wrong), + Err(Error::InvalidPasswordOrCiphertext) + )); + let mut changed = snapshot.decoded_ciphertext().unwrap(); + changed[42] ^= 1; + let mut bad = snapshot.clone(); + bad.ciphertext = URL_SAFE_NO_PAD.encode(&changed); + bad.ciphertext_sha256 = Sha256Digest::of(&changed); + let mut malicious_meta = meta.clone(); + malicious_meta.ciphertext_sha256 = Some(bad.ciphertext_sha256.clone()); + assert!(matches!( + decrypt_snapshot(&bad, &malicious_meta, &snapshot.owner_github_id, &key), + Err(Error::InvalidPasswordOrCiphertext) + )); + changed.truncate(changed.len() - 1); + bad.ciphertext = URL_SAFE_NO_PAD.encode(changed); + assert!(bad.validate().is_err()); +} + +#[test] +fn new_encryption_uses_distinct_random_nonce_and_padding() { + let vector = &vectors()[0]; + let snapshot: SnapshotUpload = serde_json::from_value(vector["snapshot"].clone()).unwrap(); + let key = test_key(vector); + let docs = + DocumentSet::from_json_bytes(&unhex(vector["plaintextJsonUtf8Hex"].as_str().unwrap())) + .unwrap(); + let a = encrypt_snapshot(&docs, &context(&snapshot), &key).unwrap(); + let b = encrypt_snapshot(&docs, &context(&snapshot), &key).unwrap(); + assert_ne!(a.nonce, b.nonce); + assert_ne!(a.ciphertext, b.ciphertext); + assert_eq!(a.decoded_ciphertext().unwrap().len(), 65_552); + assert!(decrypt_snapshot(&a, &metadata(&a), &a.owner_github_id, &key).is_ok()); +} + +#[test] +fn malformed_padding_and_oversized_json_fail_without_truncation() { + assert_eq!( + padded_length(MAX_PLAINTEXT_JSON_BYTES).unwrap(), + MAX_PLAINTEXT_JSON_BYTES + PAD_BLOCK_BYTES + ); + assert!(padded_length(MAX_PLAINTEXT_JSON_BYTES + 1).is_err()); + for declared in [0u32, u32::MAX, PAD_BLOCK_BYTES as u32] { + let mut frame = vec![0; PAD_BLOCK_BYTES]; + frame[..4].copy_from_slice(&declared.to_be_bytes()); + assert!(decode_frame(&frame).is_err()); + } + assert!(decode_frame(&vec![0; PAD_BLOCK_BYTES - 1]).is_err()); + assert!(!format!( + "{:?}", + DerivedKey::from_secret_bytes(Zeroizing::new([42; 32])) + ) + .contains("42")); +} + +#[test] +fn rfc9106_section_5_3_official_argon2id_vector() { + // Independent published KAT for the primitive, not an alternative wire profile. + let params = argon2::ParamsBuilder::new() + .m_cost(32) + .t_cost(3) + .p_cost(4) + .output_len(32) + .data(argon2::AssociatedData::new(&[4; 12]).unwrap()) + .build() + .unwrap(); + let argon = + Argon2::new_with_secret(&[3; 8], Algorithm::Argon2id, Version::V0x13, params).unwrap(); + let mut out = [0; 32]; + argon + .hash_password_into(&[1; 32], &[2; 16], &mut out) + .unwrap(); + assert_eq!( + out.as_slice(), + unhex("0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659") + ); +} + +#[test] +fn local_journal_has_independent_scope_and_local_size_limit() { + let key = DerivedKey::from_secret_bytes(Zeroizing::new([7; 32])); + let scope = br#"["https://sync.example","123","vault","key","operation"]"#; + let plain = vec![b'x'; MAX_LOCAL_PLAINTEXT_BYTES]; + let envelope = seal_local(&key, scope, &plain).unwrap(); + assert_eq!( + open_local(&key, scope, &envelope).unwrap().as_slice(), + plain.as_slice() + ); + assert!(open_local(&key, b"different scope", &envelope).is_err()); + let mut corrupt = envelope; + corrupt[LOCAL_ENVELOPE_MAGIC.len() + 24 + 3] ^= 1; + assert!(open_local(&key, scope, &corrupt).is_err()); + assert!(seal_local(&key, scope, &vec![0; MAX_LOCAL_PLAINTEXT_BYTES + 1]).is_err()); + assert!(open_local(&key, scope, b"invalid").is_err()); +} + +#[test] +fn maximum_remote_json_roundtrips_with_bounded_pad64k_frame() { + let vector = &vectors()[0]; + let snapshot: SnapshotUpload = serde_json::from_value(vector["snapshot"].clone()).unwrap(); + let key = test_key(vector); + let mut docs = + DocumentSet::from_json_bytes(&unhex(vector["plaintextJsonUtf8Hex"].as_str().unwrap())) + .unwrap(); + docs.documents[0].value = Value::String(String::new()); + let overhead = docs.to_secret_json().unwrap().len(); + docs.documents[0].value = Value::String("x".repeat(MAX_PLAINTEXT_JSON_BYTES - overhead)); + assert_eq!( + docs.to_secret_json().unwrap().len(), + MAX_PLAINTEXT_JSON_BYTES + ); + let encrypted = encrypt_snapshot(&docs, &context(&snapshot), &key).unwrap(); + assert_eq!( + encrypted.decoded_ciphertext().unwrap().len(), + MAX_PLAINTEXT_JSON_BYTES + PAD_BLOCK_BYTES + 16 + ); + let recovered = decrypt_snapshot( + &encrypted, + &metadata(&encrypted), + &encrypted.owner_github_id, + &key, + ) + .unwrap(); + assert_eq!(recovered, docs); +} + +#[test] +fn wrong_nonce_length_and_permuted_aad_never_authenticate() { + let vector = &vectors()[0]; + let mut wire = vector["snapshot"].clone(); + wire["nonce"] = Value::String("AAECAwQFBgcICQoLDA0ODxAREhMUFRY".into()); + assert!(serde_json::from_value::(wire).is_err()); + + let mut snapshot: SnapshotUpload = serde_json::from_value(vector["snapshot"].clone()).unwrap(); + let key = test_key(vector); + let cipher = XChaCha20Poly1305::new(key.expose_bytes().into()); + 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, + ) + .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()), + &serde_json::to_vec(&reordered).unwrap(), + &mut *frame, + ) + .unwrap(); + snapshot.ciphertext = URL_SAFE_NO_PAD.encode(&frame); + snapshot.ciphertext_sha256 = Sha256Digest::of(&frame); + assert!(matches!( + decrypt_snapshot( + &snapshot, + &metadata(&snapshot), + &snapshot.owner_github_id, + &key + ), + Err(Error::InvalidPasswordOrCiphertext) + )); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/fixtures/v1.json b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/fixtures/v1.json new file mode 100644 index 000000000..4c6e42d9c --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/fixtures/v1.json @@ -0,0 +1,129 @@ +{ + "formatVersion": 1, + "vectors": [ + { + "name": "unicode-0", + "testOnly": true, + "passwordInput": "云同步Café-Vector2026!", + "passwordNfc": "云同步Café-Vector2026!", + "passwordUtf8Hex": "e4ba91e5908ce6ada5436166c3a92d566563746f723230323621", + "derivedKeyHex": "c96ca4c352b1f4812e6e79f31a0b47ad10de613cf9a31bfc54c0d31a2fd32c4a", + "aadUtf8Hex": "5b226f70656e6c6573732d636c6f75642d73796e63222c312c22736e617073686f74222c223132333435222c2262653430366361352d333766612d346232362d396139612d373463316161643438656165222c2237323530643162642d356133652d343262302d393164632d646630636230363038323231222c2239303037313939323534373430393932222c2239303037313939323534373430393933222c2265316438633332652d643230392d346535362d383733352d626336366238363834633731222c22736e617073686f74222c312c226172676f6e3269642d786368616368613230706f6c79313330352d7631222c226172676f6e326964222c31392c36353533362c332c342c2241414543417751464267634943516f4c4441304f4477222c22786368616368613230706f6c79313330352d69657466222c226a736f6e2d70616436346b2d7631225d", + "plaintextJsonUtf8Hex": "7b22736368656d6156657273696f6e223a312c226578706f727465644174223a22323032362d30392d32335430303a30303a30305a222c22736f75726365446576696365223a7b226964223a22666978747572652d646576696365222c226f73223a2274657374222c2261726368223a2274657374222c2261707056657273696f6e223a22302e312e30227d2c22646f63756d656e7473223a5b7b226964223a226d61696e222c226b696e64223a22707265666572656e636573222c22736368656d6156657273696f6e223a312c2276616c7565223a7b226c616e6775616765223a227a682d434e222c227465737454657874223a22e4ba91e5908ce6ada520636166c3a9227d7d5d2c22746f6d6273746f6e6573223a5b5d7d", + "recoveredJson": { + "schemaVersion": 1, + "exportedAt": "2026-09-23T00:00:00Z", + "sourceDevice": { + "id": "fixture-device", + "os": "test", + "arch": "test", + "appVersion": "0.1.0" + }, + "documents": [ + { + "id": "main", + "kind": "preferences", + "schemaVersion": 1, + "value": { + "language": "zh-CN", + "testText": "云同步 café" + } + } + ], + "tombstones": [] + }, + "padding": { + "pattern": "index-mod-251", + "length": 65250, + "sha256": "736a3902822ea691871b34455e9081075270deca4ce13a7c4da362121c02353c" + }, + "snapshot": { + "protocolVersion": 1, + "payloadSchemaVersion": 1, + "ownerGithubId": "12345", + "vaultId": "be406ca5-37fa-4b26-9a9a-74c1aad48eae", + "keyId": "7250d1bd-5a3e-42b0-91dc-df0cb0608221", + "baseRevision": "9007199254740992", + "revision": "9007199254740993", + "operationId": "e1d8c32e-d209-4e56-8735-bc66b8684c71", + "kind": "snapshot", + "cryptoProfile": "argon2id-xchacha20poly1305-v1", + "kdf": { + "name": "argon2id", + "version": 19, + "memoryKiB": 65536, + "iterations": 3, + "parallelism": 4, + "salt": "AAECAwQFBgcICQoLDA0ODw" + }, + "aead": "xchacha20poly1305-ietf", + "codec": "json-pad64k-v1", + "nonce": "AAECAwQFBgcICQoLDA0ODxAREhMUFRYX", + "ciphertext": "WbAldhT2zzHbU8rtTov5M52mleQHMWHypydZqCFdplXlHlubHmYZiH_1DSntJ5WwicmswO7X48Tdro39WS92_nwxZDTo6_ghp8fIdDT05UTYTrMN_RRgXUBX9pBtLuqfo7p3bn1eo1KrQtNbuu-V0r7ba7mp_fjbgC9A3LBsVNQEBB5W3LtBqqFk2LaRyzDwZY3j0ne5LJ7q0QvhGgY21hzGqcGFvwSD7EVDVo25Rsy5FPFcpSJkL1ZnIat4L9h0vf_eY1iTeLVoG1d2VTaEVU1wasJfR2K0f_uS3KYl1oaBSJPLPTESglHFyzhVwhyrE6hxQlx42s-Afyfpk2hEBJnfB3APLM5pqO1jy5RLgZkoY3tPhWKSD7bgmMlfmQPzOTV8xQ1KqUCceILN65WM116USeLd3Vau6V6OpwWSA67D0k-pv26dNizI2TMu4zP7NW-g2W_N0wI2fwHvArFOMLaXpBE4uJreNT3WFf95bPpxjxSMjBXUrKPSoVHUgXbb2p3udZn51Wc_iouWJBJoEPzpyFTCxXVVuycLSL_-Owl0HBRR3r-BU9MjoHviO-vMx9NoxxlMd9-hDMITG3zqUjyIT6RxtRfH-1clMZDcEm0SDaeAdvXRvQFxxV4zX22HQJxVbSvP-Z1CS1IqoMvor-vJJ36fMrsHHuGxLyzyXwlxYW4lUSgmjv3UOZX_k9-TgjLQPq6Y4Pc81IYydtgwHSIdZQ7AAb5MxoR3xtVgM7cq8xWN97adNca2cGGWNJjvtJfk6hCe3GwUijvEJPKmubYRkqXeeihmS1tB55GkyegjlZlA9hsC8fXXRd9wuURc7qmao8cGrK9syInTSP8ozFSNvom6td5aJpLm-4n5567auDuVlfVXszgszjdDekEqwdXVkD0rlGXrTpAMRm0-RNUvHOaR-PiUVXPdDXp1hmaCJb5HHAUD0jIvwkJfwGloymNhBYJ9988rz8P9ainCPD8R31ZVoTjHDyzyBRKGC3Tyv5SJwCs78wQbz6KmLam4kKUbaMOLj0fxrhP_ZPajI5h_7JUmB-0jTpAE8ti38pLxPhFwlmEi94mbkH0V0PvYKa-d32nG7jBTZEjUURYSe-dPXx1jqmCHjqi7VgwrBTtnhQC3UumgMnOci8hmXcI4T6Bq-dpGuu4IcjGDq8Jwy3VjL70a2fslGQdnJ5_kbcrxy3KtEoODRW9iveyAaA2tZFMXlJxAoEG1Pa_657gj7-iEWsVz-rEU7M2UcAAibGJtkz9tsPAbHCinOoRqRg8lX-ndbOhCRmlxPOS8U97LGAiZw1htts-4cPXPuR3BKcILAtGrj6v8-DraMl54lVjz-6aWjkGHRfY0t2dlqG9jLFS1nJ_xwiX5aRxTxabOZU0_Wch0UPCjLAKa3q42sBTjTAS_zRC-4Q2eokKoRo8FpAAoKgv-476emu4ysHqnRJrd0pIUMgBrhKhgqjl0s89n47ccGJnES6ih9iKfqffmml7WJ28v8lrTnXMK4AiOluONMGnK0L-OHIV3FEt9x1IMH_WPGlydn6CqHNclDOWmr9a4NfMM6yeWzNXLFnYlkzidI9iyOFbhKHrT9rdxUihz1KzUxgxzprJspVrxA7sUk7dj7ytXYMuK-go7uuL7fviU_Py-vplAehvztKneajfnRJITUqr6BIvqrGJgZ050J1VzBuPoHgEFU247QbCSVF2zm6HT_u2Ebb81tD4fgvFV3pxRtbToIeQajbnmEVUYKHHq8BYetwX3O97_cLF2_uHNbooqS9cxEGdKi1nYuHOU_jlGWWEJ5SNzt3tvPI3jN2rNKJADPcblzsK8f6jy9ysOTgbGMYb4D2GM1QP5cyX-ye72umQqDAB30UpwNU5gDor1dMp-tybPiIIutszuleo8UhHVz3Em5GLdARmnEpxWo7NeN74pZ47lswUzDhWVy0bXOpHmeR-Q0YeIjb5StJioX-TnOgjhOkULEi95Tfw4zD__QMegdKhm4WNE_aQzefAMUMc49nn9UNGJmfIjm3P4aPq3hEvnjqvQa_YeKTsSqFiqpv5sIwFL1s98aPw0Qnr9VXSGGbSwzgQsBV8RR9yDPkOsI2Yc9ppoNt7LPEa_GVv947oTeeISWl4MbXaUaujSvQIudVJ7gIk4tovl5p2py4Z7fc_5KD-WuBS3tPkQxs2_H-U59KEUF_Vats8gdNKE6tvwLJ932erTj7qa1iDsIYoa-DTvs8-0xL4jt87AEC5lC1u1Fac5wicYvjnG3GJEIAv6Y1xgt781w4roIwXMNlYL4qvyM2dbQrZLVw_39K19FMPqWGbutv3uH_lPFYJMVN6_ma85SDt2OBtxcM9UnNQ_TF7KxfEGZGoo6eLo-fi35RWJcsqL9LQ4-TZZz3lzG8nurqOBpKbUmX9xu8L90oqQO4RqfODmT872mgJW98qjW6xNt3wdcSz7kk6viTFzw_WOESmpaKono_NL7hsPYfYnsbYo-R52q7kVNfsV2j2WVmmmCJAsS1RDDN2Z6rwLuLqxh9l9y_FfgOoczWWkp_ikDPP61hhjVXJVZ6wGHgHctcG-ai_gl7GQXxk-dWDb32quSzIB_uCOuBnG-Ap_6M_x7KMAE4B4Wx8DIiOrm5Zulb-W8ucVbFqVmSS4dGv2TblxjOK6FLk1-MnuI0AgIcegMXZ63MFRPvqSsYgNdsiIbNeSlyq7Rn_SdoerhkFOJiKzx6vPqsx_gJx3QcGgDgYMC39fuXhrrUphVu-5hB6FVyT14d-vDjIyHwq2zgZNHGtaMmwTxZJeRrbvO_hejh7TW2joeozpuDQlmBBseV4H4ICI7Ra2R9Hou8YrfLMZ6zmTQUeU3upxvCb1fggZAjwXX7biBsfhz-Ez5MZvdHm6O98tL7ZTDjZ_R-_IAGTLnyk22-I2UCexRc3ejpWVDjQkhy8UDFXoX2IcM8MOZ86X3szNVjyPhIBtXUQQVNPwpJ-QoZzoi3627XfDX7GA60U4RAdBGiL8aK2_gSGV30F-21vYTw3zIYGgR79M9xej3Qr4geLHx3c64Dr51arJebVpMviobCHUfO9OMgYOEX_QOTDIHAB1yB2QSFQaN6Z5O5VchRgZHqnqaMAs-OWo2qol9GbPR2hH3FlFL_fbTQAR6cL4dPNT1hqd6hLo12k7XlCoMqNHFaoASpNjGzwrXhcmYC-w0pdvtdmoShij39pp9C85DwLKURo4dpEnP89KKt4LxfhtePDv-5AFU4-EsCr0tTpHZXgscByYU1a5h1E-WitM6KIg_Dqxkidf3dpEY1fF5hjY81am8itK4P_FSWhGeeorFSeA4O__GK9l86GzbIMJd-iddV6Pc6jF2OUhwml3-OTm1irSeNxQYV1j1whF_LsmgdyIBxtx8XVHo1VO_5fuEk0h40uZA4fAQXY3Xe0xnYeZ6xOs9jRBLIICASRuAb0e4BYs2_c0djY3GYLaEj0lYlpELDdIDKfa4trR3EMInyQkMw2u7M7D1Zurd6FSMbdAyLHIAUv9LKl41JOh1-7Gkc772v12SM_BWogrIHHXOcqeHDm2m0Kl7h06cfO8axkk0eNvsOr48hYqzUbOzCOWF5InUNi9G9_YpOYiYl_lZsDWXyEK0GwbSK61EF9lycS5yr3dedMex9jvB1aKji8FgnZgSnZSqN5t8F9Q66adjNVWVa0-U6VU_z_IBLJT_LbY48o58YV25zUAOpUFAP27UtKtnCU70397UcjHtS3-x5BCU3koxzh3ZAapktf_3bNb8iOEmUSCCuV5j7S3DGDmuTzgFx3MUDGhclvZr4_JBiVgwZFHxD0IzdNMvzKnJzmZ5tmrXJZOObx7RMw5OV016eLcXNJ7uqNsxG8fjVBpWNiC82opwHOM1OPZUI-PG6XblKGPBFjEdO_d4f1my5olKPrGnPmHFL_t4aHTDyHwULTRNkK22-Y4PxNOyhdyqStFSi_iMYy7GI8ig7UsmBvp8PgEZig4WhX0mrPI5APXT_daiIwEk9XlpssI1SZNCBtqx7IctspZ9EqtfCEg7pM6Gve3njgx5teWoRAkOd-kczBmX-FQMbrBk5-MJ-WTTUNV8BqKIkqxioQ1tFEfgR7cKesdHk7ONU_If0sji3Dxyst8lfxBa8CFNPRzDTiNlYLT5iFhuJP_0otRoL4pjHFzCylX1DSb2zJ-bFCJtOmXA2pCdXyFQ6JxqbE5de6dnW02r_fjHf31sdleZcJVufhqGfzD5Px7SsOIkC2Gbp_-rsPi8FZOh6QFSbPtr01iFzVj2nGb7m5F6B4MFvkBky0V6nGoiURivPN4vEr2_G9CUK7ORfED3rBG9H2kv4_CMIDJD5NsOXdRwkW4CXXVuVwL8wSmCpHDVEEnVgbT7woi9yaijREvK7IMZENIYPVee0M1KczifTGZaRodpudq138HOtGP8Hb2ObhSyvNuOfBC26Oltd0W21Rf4fj-lQP0b56x6b3qvw7dobLqCDHa3TcgcJ1sUlmiCkpQTqfyr6Igpwqc3oRwTcgJO152XJlm-A6TW0AvbMts2hg8KKgpchUmrvWrDIWJTcRxoMz-m7zzPLLZty1euZNbffp4Wn4i8ZqsfLPhqDGK420wQ-n4aZEcW6woGpAq2FiVRzUn3QbUaeh9ykD900APu_xLG5dJdU074hN7K5bFGf_5zudznbfUjjRay0RNUgza75Qxt1OvFYzdBm-MclaXhvpAF1kyTRNAh6Vea_hU9G2z3S1zWylAMJBnQmBhsAMhjtdICZeae3nbW61ntvJiX-12AncrRm-JYG8E1_IuQMrICK0sugaQYzfiGxdgitU5M85hjSLkvDMVN23Z1EtNuSgZ479bGfA5x42QuJU2aMUaprLMOhNjaW2gWj5lImm9hI_Q6Tg7vF05CNk5cKVdhoNwEeXbEiPbWz_MoRz-6lnhElTxVJ6I0zb0nTillEMbDUlWWO-p_djISP8crmd0vTnxhMtYHtyIX_ZEOxPeEO3AhY1Bap1MwtUDqyYPVxlzUeshEo13xdVS4MCopGPoeR9wnSebW2cUu0oY8rhlJ1AyMC3fHC2G7yl49qzg4M-0hzi0jH89rVnarzjLXO6vkCBbK3EjZtPME7LzTMxrzUURz2m7rMw_jXMKaD5mIR_wpPBsCb8B5T_Zxbfbg7OTyLBDhLewn0Y0RtY7wO8TTIIiacdueArzNLOnY3MroZy6gzTdW01Iy8U75K_12xXxUa66HY6VHctlG8YFekfHwCf_i3lC1RkwqciLd2Z9-F3OEuaXrqzmcczvMUNTKABXhtXA40KyW3R70HRw_Prkam8iKF9jU-DwhGCxXUpuvMFsFbPG8ASVNQLfzX9nKasuBygE-PFy5LmlDmG-xDDLfeuEAib1R-xnwvPpAd0ZI1gZClH4qknynn_caKf5X4KrQPjEgu34BNZXBfcbVQsb8w5PA2lDgsi-OXI6ezZBsJ4P9tu8gxX3GIVlKKuva4QD-GbogNYxIgCk7nmaMIrvHKecvxS_ZZlJzHBONdUBTwi-AGA4ed8auWZbuZTAeMRx5yNm4FZIYgNcx-p8ZPtdZSGiu1tVypUiUZugN68ym-PolSHAZBOL_Ssp9LV2qWcYcV3M4Q4QV1zx_TJy782wqO6FQQQJyqQfWCKUhedSYRmXr0CtlTr6GYc1ijn1h5PlsWh_SzalhqkKTgRApIEJ_s6ghXIjzx-KmuHGIB7sxzV7y0KpQ8jqDtoPFnp5M5hzeZEO_7h8-LU7zNg-Q8tMiU85R0KnMWLJM65T5xWKykS7HZy7cjT-8rXnuWMj3s7wDl7HW1IqzquBK1vh2N632X0_nZP-XrL7iHc1H6-tmD5pKExH2oL1aMTeWVOvN8c7JdJmAYU7XLU0Zh843f11488NG8sgJysIhKcquJGwlmcHicEBtQfOuNU5I6bzZ-Q55aRm0g4ggjG32zDATrC4X3zABr3MBt4cKeFinanTWaorkh0Si3UCQKYrIBXmEeD6LEPhwuuLLsYfIs65YL9-bjCcuBrOBP_5ThemcC_ClHMQOeTTSiY-oKlrpdVx_kpXCHYKciOj-cGsgLe2tPhue8g7h_FQG7e-HlYpJOu0Ib457GE3p9uqG-GYY0jlsE4Jt6l35U9jpfGKfDE7bnPclYGrMr3s4HHCrD9yQg-CSjqlMmShb-uzry7iJDH04SXGxpps9E6_hNcqmCjV008Jj_vgW7iRaCercLezMjWKDIadDGgvyjhlfswhN86VFDxonIcIcBMXtnhGJ5i1TsAQm0FONqigUiBo92cp0afju98QCz7aQ7ZN9941JzON1LD_B0vklRWiK4RxstcdcXu9q3VIJmSTW_UMzfqiTADmiMmTormC1OvAVOaMKRpekXoagVr_Y21FkfAMB1no0mydDrW6oTMuZOfvJL_NWBNg_-XBekNNA8VKotei1Jpi8eJXA8MAF_gZyVPb7zaD-Fd44jUaQrZulmom_kYjP-Dt9Zsxa_YRFdvw_V2Uk8RiD5VrWpZxNn9t0ZYBnpB8m7JXGXgWEFLpSYrIXP_fBbDxF7q5TI_Cek4ljijVC7PfK0ocoOTHVoCcp5-ByKVo-g09zHcIhDnvmIO3PWL3Gcou-E6f5XinxzdLD1vYZA8b4yoz7E-R8CIsyjP2B_GJbSEXCJs14XBtBlzi3f4ILrDHgVDqVA84UTv0J2O8kSORi19Y0Y7yS0cxf8rwl_t-A1EdDH6gxUKZsk8q00ddadKoKLPW93AdpRMjVHFC_xM-L3XsDUsaCikY1beaOxbJ9B4Y5vv61-yBPee9g9pLuSV-sZdLDWlF4-1VsVk08DvUA4vaphAfN4WEOY-2QYPLjMXw-Keqh-dbma2-qnKpaBb1SDdf9W213osd_P12hehS6CSQB9GPUP0TqtMIdjG019Gfzm_WYOWrBk_E-twn9A46Ij_bz0ixpT8ujFxgKIdj1BslH-YophBeOWvUiVwMBtIB28MCq5nCKaEzkjSPKoxlBEoYGY9Gir-6C4r9kN1dwHRPTKIn_IPSSqdQqQuxodmHJA57_Os1jAai99LkbHk94iJrfSxa8MjXiaWpCv4v5B8PkqGqxkdiFlEFwqb6oyBS7x1IL4lmWjM2uViNlYf4kP13vaFiRsZsplAJImexWyj3Ohort0fnzOeBPq3We3kRctYG277mSrbqWp0Gav_sSJHLGqX4XTyC3BOao6MlgRdh3RLgcnMx6ITgw7QQhDgaywkvUeGR7CRcD1lWGqTW2c2gtLE10uUFjEYJNFQHcZQPSw2Bg5L_GYFTe0U8RJBXSNe6udyGJAXWLNfMwgelkZwNMn0bfy5azfCns9SHqzJ3-1HYF5R6tJHISi9ItT2UWRQ7eg-Ch-Cy67j89FdsvCRH5I1czMppOIvu5yW4VkrL0SiAgeYapODrsDVyVCQgDyQxUfNZuP82OZk9h4icfuhyCW6d-5OspEtiUrg6doVjNWeGCMbfMlYDUHbAfHFJPkyJLo-pmaSLk9VM7RzryH-Wc6RLThJDPlTa08BB3q3v67Rr29ZI03QsO-OzYlczjdh_slDJFAd8JydFtvLtbFdsY8Hxxsc1-tO0V4-YnLYT0MtYmnuxbIqtjjHDx53XsfkpRfEK79b2sB186oqmGYaBZoCJyg3Z51ADwb7PyT-gza_3m8319vhbNxYlTSCzDcC-qSWHArwWsgFrn9qp0eM48ZcogYy4LIlyiWCNa1Xv_qhrvDqPzovbdQcArDZhNuBJjM9lTTvdZ25b3-TQ5UVTk4g8lWXkjKHb9HcGcQVvAqVCk9TWw82kUY98sLa6THH2ji5L_lAQSXV6o5XAftBBiW8U3BLPpQvYi1jy8CrczdsZe-vdMUuP8KUWhdacbIUo3UZRGv2NPfc2p-mqADYcJFsvjvehVNrDYY4UAV3IVpkKCBMaf5tgK8b0HKqeLvFwHiJPMtA6sRS8tA2qfWoqKc_pJIwwA0bhQqYrx3FjovHPyaZpN1MV9aaOA0npDwWbLrOplkymexP_GT4etSDYln6CVYF4PcnLN4xKZKX578fF8iU2R_fcab6LDzLbYyPpC9iTuASF5fDpuS0pH_OIzmqyiZrrZcoHrdr3s0OvCdpf7Es-KOmYKFw07qFhjVDK_hkHXZHnr3NH3Ta0OO1FACVkfM171FUh684vGJeRUsGFgOFo_M1tFAAMER5deFPcaFQdNKIj46NeNTX1FftaWi06qVOz4nca2LlowjK1tpxfZaT8mQpwhwVNIHVV0BCGAsrArHuiy6mlPZujFB1k3Gt7Bb0_VSIg5yghbm26CzXVnNBad6vLAsvFLwvB_o9KG6f8I9ZFFrLhc6vlcFzC1Z7rV8WyTj3RGCLJHN-6nNZ_VhL3Re984pjnBUqnGuh-1ygKlAGbV4GnUXfahlFn4aeer_NJ3epy2n8Qho68vTOTHkoLNZij1e-qPRg9okoMi8mMr8I5gqIHVzNvBUSBXtOttZylRRBuNXayup7ilmmteVlHuCv432B9V_G4O9xVvwgzMMvI9Ffj0CJ0wghVfBwEeEi0kuwrrsQMuDdAYg7HU4QO2Z0g4gMyylgmaaJq8GiXL9YUbm8-jYdj4kDVabv7pc_Fgh0_vbZyqm8JvBW8V1xnQEIMmZNKeeDin5bDXSB52y88v1-mlEk0PG0nkbayFWk6tgj8eHdQnA7Ky_bkJgi-99VFH9oQQF7HN1fm3UduC1CohWj4uhf2_Edykrm5htZzdIRJAh3ni5inA5aK2CRpjQkpUv7leCcAkVRL7HYWV_yBE4QtSvpsaOJMiyYrny9N185aVz35AE2mRq0_7-EsadEkHVWiequru_SGWmPyQBVz_4GNKXYbYaV9NVXT3zOARWFZSZJA07aAttgJ6H7QK-puC33ynB8cArScAb2sPyARfsvEIM9TShskXK0uuVRWEZ-49iCQES7Nw9vdYBrWZTnpJuUYwCslWyKeixTNF_xUH-1JUWf3b47V5gprqk4LEYtvmabTD0patnlR0wlXB-h7zlpqtVTcb91apcDuMgVcrrMb12MPk4HeMlAbO9wp_Gzqac0klBfEcf4zOcuDPXQjf7AFuJSeQdVDMNaDzOP-qZ3bvr4ljaY1YfWJJ6ArXaW_85lRc9udR_VbFIZFtscABPDBq8FA7IyV23X6u9-oNY44Rnv6cx2pr6V3L-GsXscxB9QnHrGBghv0k7B_2UtpuZ13JnkYmTvg9ijZB46DSIdKHoitjui0Iq0U4y3NGsvH_6bXT0Xqxx8_IPUdHoWwqjKfzv44-HMWUZ2OzYNnlZR1rkl0ofx4J_N_w1FHZ633ibIzHdXkTileQwg140zYkMacHDD2hnfD1H6aG0SXM178B8KuJjRtKzIe2tNl0tKa1HM02uwTq7p9CXrJgl9umjT9f1Ahfeq8ju7IsUi1slksM6hj898EVp8iBdMe4PMS-A3NFeaFQjKJRlZ0Vl31TmMHhV2En83-6dUngd1KaWtZbf7RGmrPxCgS51CaO4FSSL-5hR5wkYdOQP4qMtWSPP8OWC2Xo1YG5LpxsjzviM4BuhfVKIaVX4zqHL8CH7x6DatP0q069WqYJjLqjIi8kYWdzjrLj1QP1RSA_wo_XLV2XoQAE0s69448xo3xS8cO0yDnSshcgd-2E5Gw6IPQj5hs8tJmPOfJU0phMOi_ctk5DD2zrseK1XjtTbVXhHMVgQ8kvDPhqWxw89sodfDHGbzdeiqEUS138xkfPtEdW2Gf1h0hdSAGwFWqpmL8T_kqFEzN7NxHILpVc5FZU2NDnhl_hxwcM6GFBnLBsIatTk0hkEUKn829zC-zDyGouBUbkZZ9GAFH5ikBSTGzSqsjkKsxXez2F0BWhL3oljWhBzrczIxIjMhuw3PAiPo40_6r4ubE8TnDS-4U-UzdSiU0YPgE5e6nGkrKyZTqueRdPT-MPNhPKdolJQ2uRZm_Qq6e3yR8oYJZRSItlx4nCKSMMBMpGrLGF_fHmjSVK6EJbnoVWAOmzeu0xxtRyZERCrhR4lklmqnX2BEv1NSh93G4lpSAVDbEkUiyjRU6nkweXGt_pJhxk9k3_0mHyODdJMphIZV_uoXEEM7dRRcj5V32VzunvZ7SJVBOrx9LMLKXyDiG-D97-GbqU6b0_itZljgrRhHaVQIMNJ6Kb8Eb9EZepti7nknGu1r6lFnuIM3BJislEKhfSNOqwG3B2ZvubZbSvszbeOT_e1GkyBTt8rdqpG4dFWTqZcH89jVQArGIcM5PlwI_VKak2IOTyWpDLpHrzngSieOxuG4nRMqlm8WseO7W0i-yCiwYqkrCjnNcyMIAvJJzsUG4kP-VGApThVw6QTIcReEtkoz-deSzp7AyLnMAKfjCJ_kZHPAx6FEWARJ3YXcF0Ng9QYLJ9hCoZNEWgBPhOsF4ouR6qzGMPgrSzDfRln7ssDSHKAYwN9IrqhSyoRb_-f7WQYyGEhX8g6xCJy-3jQiJYLHYiUbANTPsuQEprGrppWlAg_kzDU_Te1fZ_ni2zusJikMBgJyhtsCUvKe8DM1QOkzmTLPWAIXJrPphpSHG_uz5ryGZu0KEeoSNVmVOnBmEk88nE2PWPDOoAUwMwpVBQkoScnGTG-Sj8bVyp_fVdWANLUPqJQNinyWlrgIh_NBbsHcd-qkNWd-Q2rDWF5Uo3ELLJu3M9SQMCta5mQiEZ7IZhTjh7rwNvd7s2jXsfBEUH6wrpdQhP2-2OOCff2emgAm1AWeiq_rupEJ0h3DCIa9CmgLVwsu05rHEmSa7MMRDvTEzQImIylaVY2Yatl1C3hQgRGNmjXc5lZn2GGjAkeCZMguIgd_TBCJH5D6Lf7Hyz37wJ3ksQAuk2EofO-AxKwGuWqe_oz7UTkF78yq4Wu3fmTkUwVpPm8nH7e7fr0rdfoBVL1TLD9dmjZdcM_sfT8RqfQzxUgJ90b1rcd0UPlJLHvvWc9pi0Jnn0tN519LvEcBtO3AtJ1gJA0gBk0rmBQ9p_rGE2saabaeyHIrjBLBKnet3pNUdi8zpKCaA5jOlOIufJDWB9jnYiDMh4Q7JaSO_3ThQUAe-FbRplhKiQMdSZAU_A9c6ERAnffZhcEGjWAZqOZ523hiCrW0Y9CyjELTCLr2JFiis2CH12u47e-GhfZgwuALk46h_ns7RzXETiGREpuoahydBLElWOnlAETj0tHDOmsNoGytM9zYA6MPOgTPYu1Or3bON_s32HkrYp4zToYFx6_tu7Wf9-cImUEkX_7QFESoidlyPF5F6-udaYPNOLY6yDFwNIrUtHlbLa0hFKGVPNYzz2DOIln1Yad_RunL4hXJiK09VlrmrXqGiegMJrE6bJ791qvxcCod6nJZI8J7bzcfX3XdMATiJVqMus6LtS-EjHiTFwXeJR4IpgbYyp_KLtBEyFugrKdtJq6gf5qeIdPqLrgSL8BeLKkUPSMC5jovdSHegWv8ppa4xj9BtGTZrzEdSI_saL0z1tB_M8bojkscxUmTsy_r-NVhttIK7I-1nqF3zh3mdAXLhN5wyPWg1yODHppxQbJpZHxPQauhrxKVr_fK0b0lkOVG2juvroNG2Q3pfNMXvwg3NOiQykb-bsL8keXFlaabAhzetUCDX1yFFCanxHLj-iNtLst41f4AWj4gozbHtfhdivPZrb29z9hFXwYe3Of7wlD_qwnMA3QSEyc0FXqqAQP3SLFIHDYQvyKi2kSrpV_aYIxYSNa3VQzflZ0dF1LY7ZMTw-zl60dv5ecIshk4Stwc2mzzatGrkaxBhuH7RTaNR1gct-mBT4Mi9899Y9urAFeBr2Ed6-vRov6Kns9NE4Uahu7khvYRt006AY_CLO9OId9KNEa8DsJmCN-zyTYyljb4Z7YYb-7uMJII3-4fvnSzWjZm9h8A5bxiE-_k0l7s3AcDCNrcCL9pyey7sJADhfYiPPXAXzGg3jLhZbkqpZiFVSbxIABXGQuKjqeQmoo1KDS65UomvuuCcaAHJGmBX_xHo5D3PfFdJC5sc5IGUeYO21xkUslhoLR_MiJ_2ORHEtYh4lxKdBexkytcDGoWKqINIRiDljaZriVpqQqOr0uO_Wk35ozkLx0q52SxwSkj_XiejwtLwt4JjNWAysuiTnMcZkkO9CSFRgoXe_eI-m70CsnytabJtSBW5TB0FEaFgsPMZCcb15hpTc9pEO4P6iFLktedeTXtawmoFo__gSGY4k432KCnNBpLWNsNITv0DBYObricii9fG-Mug6cu98d5PJcFritWF7cXcOl_0oia8VO0_OXFebVNEfQbi5h4jLJ-q05HF15oARBAPRgO37OF4E3FCA9wd3gRG0zcr9De9HjsHRzDl5G56LoY95zcZsikFUZ9UnH0C75M01WVBB1Mwe4FloiBqrBZ0HpBzh6G0SVjTKYNVJu-ZMZYRCNWcqgyzDUUV0pv187teEWPOZS7uV6YBLh9_6l_vd8YZOuOo095gyY3w7GwomNjFuR5YrOJzlZ9MnUcto_WxlAwIGV_P9nf1a4pRsMsWQQ_ZM1SPSDeWUuO-b1C0gc1b3Fd2a8I9vNdZKimnawy1g9kVAzoGmTm2BB8HZG3iEhFmqZM05FXUYkLBLzBz4qRM6AKhabCgtwBeWAOJ7BHWD3OKy4qCQ0L_iXLHLsvl6bmRl96Kx13AoAOwdC2g2st_jZv0zSFV_y7A2tgBUCFCg43LLCQVtMbHofuA9kiCHkNNpNy6SBuH16micUheMn2npM7SIbFyA53MuF1EwRJZVwEppAhhbAIuo2ophso2SMRa739wEyWRgreZRPomgm9F_KMUpzvDJeMIRGZ97oZLWyDwzWMlPBAZL9sDiM-ejvI8f_njsX6EGCv2qGngtnzdyWu0atq7rRD36dMtuTEqgodz954pCQLEdiFidFboIKOCMZqTywQYXJQriw-zGhAWUWWlNfJhF-tzJhFbesLr2pQ14CsgbnwMD7CVJWb6dwASFCBy8GX7WXwFrtBu6Wkvvavz4koXp3U14gpkcJq1DX52OnrW0OA8vpjWbS8Gz2hozSjVCHizUWJq_a30y6VNPgRK2vIoolv4Hp3UuPAerzR24UnEbatYX6WdsmaI7sueKpOVn0EgenXsNdRodNa6TA6CyfDq3V2zq_Gu9S3g_NIPDObT9eNkD9mysS9VqEew08pTxcN3rWcRNrjWpex_9e10EEjibpmzORxCeTdn9MkHRzhOtQ7aN_hQLhTRjX51tMhwi1jVWjQY2W0N6WmKEsky57DAnrC5J_Ytxo8fIgN991c3crqu6h01mRXDyh_ImlVh6Utd9i1BDyPEDwb7ad9eeBnVx8J2irllibF7OQmlnizX-Cr1m8ivIJINwo_AaRgI08ErjnJOYD1i6z2W1GwoTyUo1sd2sNN0Hyk2C_ojtZKwxFzYeURuLwK-EYHpoo0GUm0sOEaJkSQAUhuUNJk8iaPsdpkHD1QlQFnh8lsN7RbFEwlFdODawkvWufFRXiFN9GUPjkPlVCNRXIXstXmoF0Uh5j0j3PZ3hAnz5a_-WqbwBtkOOmFEdn7Hi95UN5YyBM5qaCjvSaxE73IHJJgL64Wvv3oLH3rJubSVqucj1yFxxR41iKfgUHOBofYA5WARe2nzrm673szeCjOLplqjBb0n6bXBbeVSMPWUrug1e1UOZPkzMYru7dxJkF1sxN30dzOMDRMKGa2d0nB1YOnP006tK-eFRmK5tZIQNx5iKpIMQonZ_Gmbjj0QPnFQH5O4BXBOT5mUO-ORzIz0yrJJSH_WQJaJEVRq35hr8vL_B01qiYRQZvEiXanZ3z0Mt5Xf5yaiJxs5Jl6jjB2gaU9SFZBv1iYHt-NNP9zf0i2S7s9yF0_Wg3qNZDoUbA3RESZsM6Dz72VBs-APrqJepcKA1SDMFlyAqsuzMASZmQdfDg_axlU8hcVNPI1KxPONuIUWiqwBP4-JUspZb7p9sun18fnkFI3aH8nze_yQCI3R2_yQlgBUyDuHxFDTxchVMTc_LB3seUP7_jQ9wE7X5zHmz5lJN9ZhLJt4_EnFO6WoZQtpH2Vg7oXwFCQ4bFLbDEQIeA7iEOt-qX0gecMehrFCm3BlC1Lw9ssuGLX-EhjEWdQAc3M4DKeqbHouKjKiNUgBfWZaob8-V8g_OqV9hi6yGqB3mY_VqfpQ0cD2PQccVjEu2dKc01_tjXV4ubcrK3MIdTa_t25UUNPWyErjFVgZBVsT7so7pH9os935NsdkzUMze8e2LguKApI_xOxCjXvO3CE3aPK63B-C7LHYeS09v8aTMnrqp_vUHbpgj9Zhg0ena_aOxoWau0xYDDUcLG0b21713KYbfi3H431wbDIqsxOexU3KPzsUSP7MTyqOWQdZygYSlzcUZQjdpJIG7DeFGe_UrNGaAC4Pjzr-bwp6DdkPCfzVCeWupgpE3CHA4ZGLr6OPcbKnICmMWgei-e5bytWjIPQh9zIPWkNiev246MEb_XOnSeTyccJI59MeKK1_3ANf3kBmSP58ltSEqTKhv04npzbZ9QdrqdcY8xdLocrs-mt2khoRkEfb1DOcDjxbUjIGrB1jrY-dgNOZlhQqKCQYYbrg_E7gipRGv3YNtIAEBS7hDFeDeE20UrxBSsNj-0HJbiMitPT0HAAZXN_J_Ea1nFsPvPc-rxMb48Zs02o5qfGVZcAjz_AzDzw1-impv-2SRhUQCEsEBI7xInyg7NuuPNd0pAn89l9NkARhHGgK9GLhkJni9qsE2D_7Ge0v4BFSoXjOygU2BRmcxIzRyGXuUDmWqR0o0uzsWZEw31NI6XbTyQzKCr7pAQt__bxjhlEML9fTVsyocmrw1fwImGhU6h2RfJ-ub032PrYqgodTU35sJv1YFkRqEcvTyyWswmjdihq4DqiSs8ATiIRVpgyPFAqMpXrVn269NJEdXUSNCw4hRzWsiNlM--t3M_jaxDc9aAUSLERvScDo_3REne7sjM1pmUAJuxaPzc2hABrWr2H4q-z02SstiDwbu22cycDm8u3W13W-rmzwMISHHbelzdcEhJjRXGslDO6JG-DMEQOIsR5d0ASUtqrZz_L3ayz4YxkChD01dC5IOmLoe_S_pB55HhzxP_eifathvz8S8NIliHyqk5yXXRLfmqpZ_m45fLJI0-KDNb7N7-V7d7zvOoUcUc6Oot601YdU7zr7kyVsvuB3IhSZX1fmARJu7LUsnVlWLYxQvJHCV23JZ6b6AWnJJZjHPcjZTgV6FLO6qBv5iFkm5xyxEGm4I4ljs4DHPHInmvJbRgkUvMADsirF70VyBRERs0Jy3dmbWrhB8WyEZg_3NkzIsPLKMXwdvAc0tQZg_3er2AowaKb8NaDhyYEeTNdrgj-4wjgHrIC98iDsXG3VSxxMK3irdVOrpeSURnPQTLFiB0iwBNTBbQNX4GDC70ijDWAXw8JCy6ymtWzWyQiurM-KtKTiZ8koR-1ll4udLPPpPQGlIBkCo08FckIDjCZkIq3surCqNzjEQedBl5SvmHu2L6VgSmOl8GrycuIP61u8yfc5NFbDInQRofv2JMQU6txpUkzwGFMCD4G1BtZ9LBIAMhbDAFiVpvixIVsoaZgqP-3vpUf-1F-mqFhwov9CMLcKGB4pVjlaqf7tOh5sbB1V8gcnRk_n3u-jFy5EphWtJSWBZ2-7pDY0usINfT_1SqrxV9FRORrNF8zZ9iSoTbELyPRgjOWHa_yEccYfIoi8tyaSPO4YNGJTV9KJ3b-1umfOtgphSYRmyyc3vBdbS-ciAktD8oEidCeoofuPqYJQkzWP1j3c5PbvY_qp499--jIwhHICWD734_qAcZryIPfGD4TPEvgsUidWFelVQgvvo2DMUmoAwEmJfDvyk3_14wJaXuBz1cQOkOmyFMWstkh6x4HJ633fugLKTFZTfLmmKa0tdKo2pyWEi6EYn-sHRq8yAtBS-kwj_CUbBVcGIdZsj7trmYnBCJm11e-SRRPn-hpwX6QXEa77uDKYbvWecMYnSO_x004uewUXusaGKgaMr-dVxM3ETYxmX-irKbIhYQNSF0mwVG-ByA7VVHsuOuRH9eqeURt_gjCFUgyh1RKPpUeBLZoneCZMW2-aoeMasM4CfwGMe3XIwGunqwQ_JOsR4m51Ae1jWp5lX70N4n1oi_7iphIWxp_FndVhl7RgXuVs64plFafDHszDEOCm1Tx0qRDA-hMhF0KDvOdHbvNPPcttueqf0zR9m-nCbcv8bHZ0oZq1zrtmexJq4Di0Vuf4NksMYUpot4gfTt2qntuJDspQn63X_nRL4bcZe15FQk6cPae3WxtXb3m8dVnVi49ViQ2zsNaFi0V9fL6LN5mc2M7gyYKNWWrjtZ3MGA3KlA3B7jcx306qCpHvWyHjgXIDru562jBv-NoCg-SEiROCXNE0Ntzx3ZAnl9S4D9WVrF7TOKgbT0LAktybCzosNnY3FWwuhTQVgw2U8LhluGqnJf8NhzUXAvjB7fhr9QjxPU8zxKwmXAhTj5Aem33bVLh6FiZMTqHxIvDYj_mJlsEpGkBKpSb6bTq04u4mlBX5zGJPFX2HfeO97J_v6X594lsstjQjD33BOixXjWltWc8o2Lk21_SSRmroKZiH3P2cKAkJ1w-Iw0c6fpbbc_trKfw-q1qreF1pDCvVlU8MnvpfvWurToe71Tb5YLNC35jGcKarCMp_cBlb-qjBbYdfPb88Ke6RlOIVdhgdVGfDbZoVSgjOdck0T5i8NLkBlECH7YH3y1IECIawQvzfbfAmRncgKuxfFJqx99QZ4LyJMekiQno9gqGAD4z24287k0Wgc01LhQj3vWHsUVVeTSyaX9TtqBeh2uEt-JeipPKEP1USDaRGWBNvAoAHUKkyPTw0NYe4teHIxB2vc0U_HLCTO6DDDTQx5B7x9L82ArCMdQGaNx8Eu0MWUubYOoxA0aJawNpvz6Lw7mSlyarv9W2AQqsbd7A18CYOGX5dzvGrfpxeOncKWtYGZsAQtiy_7sPc5aZhJ7Owa7JIyswrBeLNNtLTM787JPCJNX1wqZaw7JJ5m6YJtUhr6vlrOAGXWk46sOn1YKUvXZW4pPX4evlUv1H5gGIt3a7iafiPBC1gqGUOpvRwVr5CB0HsVEojxiqi11a18ausHy5OOp0fn_XemnKxIX8AwCj-6oO7UYV7VgnbBgrKhGHHKn6oHXsZpatTfzrLwRl3RwbIuctj7ZBhfdgoy_SHAexXg3_Nu8mg3fE923VekKqo9skEQTjfN_NOH5iwWEjiv8KR9A1Ym56ZigjdJ5lHkfv4zL6gG_xncZ6zq6QjxbF3tGkStDGVsvwqWqe3udNHfIjSX-lFn-wX022PXWa1DIXoQLfSt--6nhfLHb8GoasMPcRTMd0jFXPLbsCupYElLJhM0xSQXVq010hnu5aCe7N78CKadWekhm_TrUEPcS9AigsPEEtoPG_HkyrH7oN0ePrxNGCBfwyks_QwqFH_mt4qyxJkP4wH8uU4yI1AfzpPOXkelOIMUPQpwbaZ3xQ8-NATCOk8pUYS9UeMifpxfv-kKkTaCeRHxnZDv1fWSZjHmmDrbBvtBNYp6_3eQaX8SCSj3sv2ijLDYy3T2clecLutRIe5V3zDEiZ9h_O4M2Bv12F_aQAiORCA1TVCuOlbCZU0XdsNs8sk7HaAQ_kcrRySwbxxvDNIkLST1anhnCqOr8KJayD-b1LgSdiyjBd9LMKBVNXP2kjy3cOdMxIwpN6zT6SEUnEoQKl0fgLLP7f_yGWxleE4GQcRRo7jvgag5r8EDb0PSAL4BiFEva4s5VdeJpz_TrQ4ZZEooEwrbQCXUng7qkCo52f-EWV_Yx-d2lvLc4ksEpNAhuACCbLfZgOmSnnR8s4Q5i4ZbsmiEOaQm09ERCwwkUL_LyJ3J2tarA_PKVMrGV5vb73V1XBOmZxhWKoVi9uMBNUO5q1b8TuqC-ZR9RMLtWf-su3lDq5PTgpWY_lgYZ6ijTAWiafeexzTQ1D5Sl6F-z6VsLt1-xK42mzwnzg3_A7FFwCNwVc8S_4FugsbOOpAandHV5YnyPwUkwIovw_aGY7ziGuiRP1VJVkQ38IypmNASfvpkay1ZcDwMrN6Nv7T2BHMJ9MRaR9unxDjkBihJEzC_GuhmJ1Bf3NGt1v3DN0riUiEIeCBpNmzzHccwgtz1cHM3HZvSgtEh2SrMAlx7v3w3xljHzY03m_aKJbTlF3shzI984kRjUcTrG4ZTjKnVlC2RWryoLkGTBurM8h2kzCSWPr7B29yEYGDpujzu81Xsw7Hn7rbB6UQxvx1r5DiV_LQ3YTFRIy8XqbosCe5mhifNftRApcTHNLxUwOyVjLjBWfOTQGZJUAKqZdyBYgXRwA-jTT2y_2A3aseGYLW1WWmRj6vKiawWnfvgWyJ-2WdKtMOPL68mjMysocqnUiIdUDpNcP3lr000VJVJTkVG-rnxCP3zgEhi4WIMrL7JTrs9UD_X6n2p0QSdPkOJg9YGb7YNJ03TKoz0FTYk0dF00gzmLzy6iUxl-RAbd4gyrtTJ9U1hLqXt3et_zeM2Byr2RIWzzWKduT4GU89DvbgQf0BDRy87vj2jURy1bqA-zmDEgMw0ZW_a7ohOVm6wQeJZfCKiqDbjTSSO622XR7oMSJodabZ6us-iYuACkQf7GPNsCddo5nNwXw3HHbdNDf_GfwBplUybtVtiej0QDNyyeWAd5tTpKgga0mDV884Z3D1SDYQ_3-G-r2EgISixYYdY_Pkg7T1BUcuBNAKcKBEhgepa_e7_01pvwFKHaO2cXl_p4I5z72b1Tpz3La9g1gh7x_h5LKg2L2JRTWREtyxUlooLscJ9PIXwNVfJKxPnniMDn-Kwu4jXIfrnAq3RzAOESzMgXz19rmMQmY7_pjfaVFOE2lb-04xXRO-58M6wQ5fRfYZul4IeUJOX5Oq5u_WyPPQSuadY7x4_mlI8X9_ekggziG_YynuTvbHbpPLuh-wkMJwCDKzCxWkcG25qHJFz0raehb200olhe6C5HUI1xTeRDuoge-S049_NZCGFN_BtD2ctbAW-uiFi4mfs5YA8SvuP9wmrnO49s0KvggM5UDY03LFf-IcLptCl7cx8Ipvq-0XUIZn9qVWKDHHCLZZ3Rr2pPILx8OC99U-l-6_SLIWe_wj2J68iYNn258FfPsQlu9FzDsjoPLnOIGpK5uy_VPoGQLOaMCjb5MZDssf80fElzbDNYgC2fU-xWz9VAoQch52GPd-7WU4fI_Tt8MTrFue1DRO0Y4S9Fb0f7W3LjEZv8A4hjmZWb2WqfZGI11XhDQ_9KO6EJtadMHQPvZUuE14besbC23kaGG6jAEo_oIMK9pf72FFVKCtMDZ1_ILHCQyjyI7g4gi85OSoxQRwt50ULdRo3a_HUS9rtv8GpMKmHTc1ATkFqrIZapQPltvmxQvLlgD5MGk_LZTe3yK4uEoNGF1l4m5NZPgLspZYq4sPAawNSBOCxX9z4KwcCPko3tWj7Rgw8hGbuHwSHmeN93HQGMdbiinmidEQePEExDREvxL4c1KlCmVaTs41PaUKOr04-8z8DeH5fxf9VO4NBGKvGHjevKCWidJJbwK92uLAJUSkvF2ncd3ZadQD1DE3JTZE3u9u9KZ2bKD_kbUj0tskiBC0uABJhyeSAtTPeaI8IRI4Cbhlo5N9eQbPX99dTowmZxQhx0zhRorQUXctGNKyoSQLSD0021rbPSwtQLXWmSLm8SmdfX-QIZpZkIrnEgyVO-L9uMjQEODvJX9hL9Xkl1KhC1hdnnJuF_YD5Nw9RYqz262F7keqIBi0-TYqsuLFEfoZ_pJzu4_tQWlreqeubx3aHks9Ni_UBPxVT-UwMh3XAGHDaGcosUql4d3zDWQLX61rRRq4dGObHxGUIjhq3JzET4EE9IoFvJSFEEVPbmDiI5YctYz5-kBQUMMk-37oUEOcdNzHxN0cOcS2EtENsxImkuCLhkJfOcIGGpt8xButgKePYkL6h7Ptgz0k8ZU7TgiNmlqUrO0Tzsw6tYAXEqCZwCRSPLjb9Rs3F7AACDlMyyyn5Ub0rLtUT-8esCYZPhF4XTNMtheObrXryJ4wJaHbLVxukArlptXPcyVhRAk65CjgJn8d89j4AY9yZBevPjMSZRHcA5xVsjlPy3ciWb8KteC3_YCScAwHz3HooRlndVdbOTz2z-Rq83paHveHn2p_Ta-s2LKiUQT4UeDqYJslwQ6qnOZuodJr8gA2iEqjRvbdGd2w40EM5522zcK0LrD4BhBniv4fwekjnMLsGdBUpwNV8MUBr6ejuB4huXsii22_1yjUnwi8qt5z-1ap4_DBMJex5bhv3b-U5_dV5BU13f_xlaBABHekuhJLEVJF0RQm858Uz9ve27cD11EjgF50FGWZw_ZQwMOMlCXg3WMPokXvOtGtwYD-ro8NGOoHPojlhwn9hftIfb8v67LOXPIUj1ttcxlb6NjubU0yuCaceLRNBRxuWFleTcsHulf9yQ09SpQ2kMh4555k0UckTLNd_qiXK8mOh08aYgJ-EenxWoMf7nSB92D97jtC-eEEkGGWEk9xrErIrLvabr9Ad0kxOmaD81COyEw5k5ZB76q4pDSETxiYryuxdYxUBOk4r5fj83YHgzTOh6Uz4EGJftQmJRR7HDS54O0ZwwTeR0wBRgfBUvXn7d07Oz7PKULlBDv7dvi7Wi-Cann6KjKHvb4T3PDo1A63yqmiElN0tEyP6dxc9PUXPD4n2BxDAGdFsdL5Fnkyn7KvJqDHjT9j6z6mKv090Q4OlvsH4CPAqPPQ6NpJz5_Z103mUR3jzFuBpNZV7pdTch_IIFsSelSBFm0kHO1Yz8-YD3rtuj3-xqOXBBn6lzN6r3RYYbvdN1s6tKxjwpywE6F-ryiVYQRcM98-g3lmF4DClSfhDKsMCn8YXvEhPVoTUWpPfwrfhgVBjgn0pEsr0JSnt9BWc1cjC7mz76ZNZPVPP5PUN0hSEoDQJXAs_QNsxHOjIVn7IU2ljiR_rEHePUkqRXhBUVF8hIj5mAPVEm8BJxy--V-WQtTR1IRRG2kUFC2NEbXiqic6AAUKToEJK7mDNrS-OjgZj8JtcShlGF-asoxAsIFART41IquQuA_BAFzhTeWDrpDrF30W7mdfTes92bULZkgDl5jmZeSeZfEynwaNvjoEJaNfAMtaeBVp2NkLm4oPB7256WZpALqXFr7arkpePXNVsmwWR6ZPIjCr9XBVkve3016blyUhJnc4c2O5QV2M8zy9JHFmvlerRomYf38XlLAP5HuwwuGdRpKpUgpPRmOLIEn0fJ_G_i5kvNbA3MhVWxDXwYe8wNK_O-JXb4pY_RidV9u0pvlWlDFUeWb5Bq0KBNHIHIeOvJ6H7FntVNIuTvBNh_395w7IkhOswlyqQAnMqhYMLmsho978ZIQ0PwNuzCzQESe3sos8YsgCk6602Zam5d3XkJvcSf9aJ825mPVKrUc2yKpfPtZobzinB9Q2PfzWM55KeJhVyLP6ctx_l1yQH5d6JmYnmYm9m1-UD9ij75Z6TlB-Enw4sq__aTTl6I1ebPMZk5aSq1mA2YkEdZ1YQy1uSefDNaLMpanDVXoZ3cm_lENRUBi8tcCzaDjfcXA3Qray8BlAfBERJb5M9sRnczkxOFXa0fUfKkc6xzKY_Ykzc1VP_ODPIFPJleDyb8QtBYrgVqpGnWdmnUc5IlUIQWVi2Mle6T6b4hldZt3fVWji5nP70Eyjpt_uawfwQf7m2gY9ToGKYKFh-D9ief_9_HiZfCCmwW3dD8LG1uCgUimxpNZbPadYhXF_FW2-9FvXlZsEb43aJbOtbP8NZSr1Qkt5HjIA-pWgL6gjtC9UEqUShQtuM14CDlepxYFataqeW0EwpkpHrdxYtFNGuuN1a-ptNXhbiT3pO7csK8d-waUKWab4QgfPReM19wswonuC_7R2giDzR3-CromJ0O2ujKL76qyfpmw2249p62yjGoi3yuQ7jAIyCTlUkZ-ZwllC-RV2Jke5SCJL1XQ2s0BiIIkDiK26z8P3VlYfN6yfHDkVF1VUpqII5c6SMzOHnAXwZ3rC_SkKaY2JTxGYduYlLrptGRXFR9TKvaCPT_i-LJxWe-K6f1wAuBjiHguEYEc7KhR3zL4cF2kjk-6Dvnt2TWhXygIOyyEw3iTX6vk0DJ0z7luOkQY0yuB9AXZIRSejjtaKobsQToq9TePj3rfURSfq5cNl_QoGctKRqZDnuk2_3ysAf9ipy7hH5VJWl65NRj-4swoo-z-nvML2uF5KYFA8Y4_9Fpu733v4LQVC_LMTXpkreIhEGQfS8WSLsQTPy-p2nvPw_ZthHDFNhafN58C4GfUDAYI4Cjq1g4vYvmDivKUC4dHlh-bvXgTnMhcH1wBPQ3DostPhJfd_mYL5ULZYYAQkrU4GQhHyxQPY_hCX8V4WlWRWpOXhsCON9scXy3ZxldEjAoU6fctxmE9X3ZyeS6I4HzRAbEf7i0w4k8lLrqPewMG3QzZkt7wbg55vwdna9CZQipreqlWUTDUbLYD-MM_yv6nr5fYb-0B8QaAKRdWbjqWe25nd-7i_wgCO7NKwXhsUmS7KvlI8qOOqG90gPdAaofevuPiPU2JlohVLgLuvpyic94mNf-UwAXCGqSppB0UGNrIMr7YC08QjNllKne3ltJXiSpkNifYUI6BKvukdBmVaRi22JvIL0VwALPPJlLD0mbjX4Y3UfdssL2SO2zs_dX5_5WvOvJ-w7-416YMnwKcPEWt1CvBdc5F3Clo9koLY448FgOpxqu4-3JQ-QuMRHnZMMFqMX6eGuuS-Ep_gkQSSO3MYFt-kZa2iPvRnWShXpafmVZftHNEr7gSNN9SN7I9X7bduojqNT44BE5YzpgJbRpy4NNfRtFd_vIGbuREgpdQLUqbM_2WrWegCfDMMca3tTfDzOpnTM2j_0NWsSeXsOOteCpbNoa9mRuZuw12dlHxSx9R0wbvRJukTOjmsMlkryJFbO55XQkwVwi-1iXgP1mDClOw-WTju5nl-vcPe5dKq82sp1LSjVVCxmaWh0FL4k5qviK865bt7bEAiy0GgtQ6n8OV0gnpZroXFewi2HH8aBktkR1Rfi3WORJpySwvX4QeHC6TJu0rSJhJTIvwg0pTuJCEzyOaircdhdaJrPg6LHAWVVwQTpEHXQUkph3uWwIf4x_Gt9gtTvTTXvr_Xoa_vtNrh4dL4EM4UvB3BN-Yia6c0tvljL07XUmx6PNsaFJuAl8ec35YvN8tXrRhbq2BJgqn2a_yZtejGV0XcEB0zNbkGVSykoaS_4EEil_8Il76PS5edh11TtbHHHRbh5JqZaBJ1w40K5RUJN8aAsIqMk5z9tnxeWU6ABTQp2shm3ozmYowOAi-A7777EJMUAZ1U_jyW72vxB12lVK-lKgilkz5k1ytS5wBGGAACZ6iwGdJOETfdxxS7xKSCVWHV1nle2TaXjs8rnuGbt1DdJvs8QiMTv_yTSUG8_PFhX9WfOhJLWgJHY5KFZOQgXdxuAFK1lI_hphriNIa-2JAmO7_OCsMYecivF5CiDR_5wKSbbjin58qp5hbpdqUNqHiMPNgxwpcFheCZBi1cTil2y-We39n0PuPS2xynsndjAxodn7Il9OkXjZJyD5zvxsdgXMHrcePO184BqpZD68gKksxdcJBfw6uhUSyUa2InOIvKttM40Wm1-fJOksn7Xn16w4-sPIJ1rlwgDf6b2vEUde32S6Apfg1ANlBftUOtzMUDSNNVT15rXME7kQ_96ZFhwO0aM3_A2OpumG5DO5AofAe02hnz-gb_E5Mgk826hsXoj3xYHnMSJMr07xnlqt0EQg-SaQyBfGbc2xoGAPfmzCdj5sBenrdC_F-POrd9Zc84GbJzSVonwhy95iYNvVvUMVjO5OXctfxUxGSfMVOG-lpHWaGrzOZIt_iZ7_XJjthAZMo_-vtupyF9VDhgG7ZG0Drmv3_i1NKtS7aFmT236Fs85X6i8xUO6wK6WqC2hqXiAQOODHeEKd7ajfO1sJ-PgKceK2lOA7NA2--Q4zjY6Nn2XUGdzNz08OVMr1FNX8aDhVZVAUSNdLUVK_9iFJ7Wo8tp9NErXqlHaROKxklLzQeDyYWyf1z3tfFxYGJfB8ANjIApEzepu57_gPAVIzJEA7uVY0BTGnPoKIOmhbg2CtaRLRTUkbnHllcPw8COAn4qa3i2j9yVeKwo5jaFZV0O9qIeI5FbhV2NjTF4Ui07480kL9btrz3z4HuQyguf0YfTIIC0S5QyWjh8kslHVdwYFwfwO9doo8PTGoHi8I4OtE7QtEVg20uaY_jM7bg5Xlv3ENMQkqYXvRZOmV319B60Z1crn-Yuqi1kYXLBPSTfDKFCrWQ8R8y4wYvUPzNalGs_4IiDihZSEV7vDUtvTLorf_8SfFAmaPpQnyCImIZrktiVAL2JBpZDm2jIzr2pSehvCiK1-RDeRVFCdzGKOKOCsEta4zmaWsIDSnQylSxTVHCLCFISpN4Zozbv49PHJ2kO5_G7v0Zq9sunsyOuqTy1AZq4HLGjXuX40UElZEz_LV83_-02f0Kon89yV_qh5O0knvt3WpbqCCJqteCSimK-gOFoyxGs6xw19zeWExQ3no-OTrwHHTspsecUhKBSEcx13XUVyh1cDokHZFFNdDM6ousEajmkAgTuf995gwDOmJDhRLzeXDPm5eylR6z3q8Mg9jlmbuT_PCeAJXw5K28jCIQX9xDudM83Twtepg_d41wvV6gqnhKFUQTm550Z7ybDkAw8U-MsXC7qi6clMaMEZNPGVgRxglH7-uYXv3YlxdseMvqDxxAQm4BGwjQFrwnS5MyQqnUc9hNvBpphDQXCM8t0PuSoWZLD5xewPviF6q6MlhY0gmNTv5kOByH9bVdfLT-YCBqHr5z1e-HFw_v3f_Y3m0FdI2Lk5cD9Q-Xmf4if05PADqOsO1MRUYDreN6GiDvT5AOj1QI4yHn5BBXKI2RGbwgGZA12G-A_Kph7Xf1CWqcwIRm-8yXVBvukktdtlRchl_x03feFVwQCzOMdtwS0pANWGd2YMtzcdqYKMfxeooFZ3OE5bigU2BbyQoyQrXXTBnq4HcLTP_hE3eS1E963ZmSsEnvXE1jFQk_Yfbf5dnMqjYWTFe8jaWKmZpupJiUxgteBr3-wZnc2T5xXmlGOaFdZCMVJ-ztpa0Xl2KQbFtty1uKGyOix5bSOuT66J0raWRtAjCjCVz8s5GDs11UK_1FsbI9sRbHbiSMhYo-SrdxH3wMp2cLu_snT6Pqo-4MGcqdPLYS7049u8pOX93Wn7QYHktn4OFrulBRnVfcubqFP-p5QfCB8qe7KEUjBWfYTjK9uMCTjGW5NwzUca3xfDOK3hHsYnI5IK_xOullQth1HvGSr4WUOaPC331WVexG2Eg3JFhwT3grLW1skgFlACZzSjZiTW5-ZvJNIESsk1zpwoT7mI9_gn6IVZy1-Myjy8WW1b9ZKBdTRSpxt5pQDYvmAxTxOcTpka6p1YtyzFC6vF4rd-nmxGgWwEHK5DgiOoW1tmTdYhx4DFKLWNyqmiak2gyf8kqrwNhfUCyRfD-Qy0re_Wy4dHtHhYdPgvuFRCtcpdX5zuy0kOd3Bp9Zd1ZyI210kN9FkQYZMDhbcKyEpTaM_Ze37sC2r5g5QHIvHql2F1ENf_NnOx0hQtmqWGLgmqA1kVtBc-e1U4-SLdB4tSNJjQ0PXpeRuXwvsAALNLcCe1wbvZyovox2Mj4zw3W2xC-KnD8o7CuaVKuRQ98u0kzvs-QMxOcc1jMCeKicPH4c3ZmYEMPrLA6tIWLJCHtxmVl1ZwnfdWWi0-TylsWRgUGCTUT1Ygx4MwWolj55CHy_V2XOABfHxmpk4Gx13oCJSCfLu7A1k3jY7nwaLOOnpA9NU171AflCk6sO0FbEELq8ycsIXOUViRI_JMHcR2bAGhUCIjjiw0OZvjxP1NN0mPYQQDTkMV4inQfM-9n39CXdjJA4iQWPBC617pbt8nxj2OFyix_-Ouqv1ra4dy50m_4AKuoZSXWHQb1tgfauT_Isf9AkzgF0Zh5u0ON70y64ZD44w6PYmWZCvPKiRiPmfRaDJUoO-D0ToBLJvhCchrFjmeMYf2-cF-QjL_9D_2p0mEieJu7d_afpiNJ0xt9WScwkRS5NB4yyC0egAH_MMZFIH0G7uEokNjIvSrEIlog6miS1ZOt7jGMlkV7mroeBA6rcYUSmbPx7kFFtC-Bzk_hHQbgkPvcbEY8O5yq0i_L47N6E3rKqa8_8uJEJ84Qe6uy2naCD9KVSCbyfeDW2vVcG1spDXEsxSFTy3I6bZ9jvxN8t7Dw4DEs91v2Ir2c4HQvxjLSrqlrMjjsKoLT7pKVMwcnonR475eaFU7iWn067viGDcEQ0jirYyLAWP9z---N_Ti8fdZuW5i9Y5NxENNiiir0vPWqpgdA7i90BUiZiJ48kptq5nOLrCKjtLiWtkTav2Zg3NTk6Qgogwcovobe2dMVg93jBDAGLpcrvsZp8W7-N9u5A6xKTC7PsR_mA303Bo_L4qfX_IzrdezAIqNoEGxn-jgfjk1RciA3O_vnorsa909cHIe-gISLpLaJWYxjORKfhthhH0pHzBHWZuRlJsjzGGZVHK63lEorY3v0PNTpkap9vdW9067cFmE6fKKaD02lGXUK3NLCuc2CVyCfXFw_ETLDfdL23PT20x6M1GFMxDoJQGEebwljgcqopBMb_IUTSD583mOq75-Mae9f_dLJidLLd2KVoSKMQcj3uAvetCjps419-ASKqJaM4VJPy6DraQwEeJFq9hJ6MLNYbIomqhSxmVXSeNWEfy3WzA_BgpqFXhlbTtK5E9ZdA4K0LcQVE1ngcbS_PQjmcSJAzxvO3JRN-YouDc1PGYGQX7k_nVJ188wpV3fQhBQGhpeRQqv9dKM25sw9i-QzcpjQ3MZ5YnyXRCm22fxFUoQW_9vcBnKjHtD3mN6OcBRxwAf22qTPnm6Z_PRtXLilz5aKyLSixOIQomoRzv5rBOIAVEn0ht42rlj6fp2eYYP66UYuNXtFZX6OM_YhR8EUmfSxlPowdBqSSOqn155SLFulbIaTHwHse0zHJPKJ9QZ3Hb8xHr4FsRPjsITYqOFAh8l27StrOYV6iu1nQ-mBU0uDyE5G_t5wQiV-kJ0Kry4cAqSBWUUazGBN0OAXAuAAqlJboUcNwPeTK5DPF2e2xJ5CNFBOTe2gpVUJRxEZ1k8qzTZ6NehuBbNnAlmrIBs9OaOvM2JsFpbFNKejG6dfCd5ZV0kzVlAGVwMEKHLrnw4CZDthlFN9m4m32eA-uYlihNUsv3Yi8f0seuQ5gY7JcrmNxq8yaOCECbLUbaQQlilplyxv7PF70jJgbhXSC00hHBZOrxrm6jBOA2OR_XWYBXA9PSwth63gZFUG4yroTkSXt7xmKyxcvzWc-jrAW3pSS8kBp2LM_nmWBYlI9hZ1ctRZKvlUX1PCvZ16qO-RT7sDXYFeqnM94iM6ctXJp1RWq9fiT9XBYBWHIjb25W7lpNKeQ_4qZb9b8wTaSIVViLtMG5HlaZD3VQpg4Ayndsv-Jp5uMYJfAiR5lc9mG3Wkrp4aZhslEbjpGOA5nL954BedfhUHzeRAjHc5BWS6OwoPIC-IDDvqzgKMF_So7NmZFQUvXstd1oSgWBuqhVJMhOOtnF0K8fpmlaKDu5v_ban6LxpwvNLcVMXgWIp8q1mH2bMSOlXru4k0crQ7y0FdaAiUP6ApF8vRnRsYwSUcA5c_OcA2ejk5I26dP6xTsVIds7GVCBPUTHPrsosGfqaj2h4LuP_sHF-5-on3g0lUAIdwiRmefQ-o21kGeVG8YvaNfV69z2YM7O8oTzCdUKpvKqvBFYO15Unxd9B4aUt18iw2K5g92DTThbQ3V1N9D0vk1FUhj1QbmYSJlXH6DydqfDFiyZQx1GBUo9UbdnmSzwdZpwnh9-qxF_DpZrbTDLEe4ydZDhPnIIhU4O502DEvkLVyV9KbYFUMbRakkv21xnLO9ylezWPhRyL89x_oNC9mJf0YzITMKW9VXApapR-mWV8kzs14XVkmTHPAqUDTnRjJGciVbsNbNLRhvb-sA8jU4upd1S1KuPa8r2xS4UYhjBOzW3A08XSE6Qxj4V7Zley-JdYIIhbmVF1B67-_yiuAmwUdF9Ms6Cai2YaPPmegYziajGETJHOGQKt3OwpS4V62EKcCWnQQ-9X_gbnR_vyV0d0BMKbZ8p3-kZj24V71CLpt6_WC74zuVuJQh8onMtZNeGGpx1g9qABh41QRLW0asz42cXeikWlvq4gqBlAtDZ-s7_aXIBgowrVE-iedxhperFEB9j5VqudZarWUtykM_Hy7-jCfdCG4_91M6UAeH8KpXUx5rJskqVnY7FmMoBqlKU2AM6GZr_00c4wGn425NxnJWZ8iUyb08UPOThwpPf5uGx_NgsQDci5aIMhcvKsiK5Bhuw7BMingnok62_SwWU_aFJnSmAnyMqk4NYe0KCIRVnMvzFVsPHQZN8bS4z6uCAmqyo4vaxQXtEoUpzbKMVz7m9SB9EaFTiLiOBpeHsg_nzyqGJvk1mWFtj2CQn7Y-udtM7FkFO_mVuZhaNMUSroLYbItF_D5Pnd-57inKwyITB8nRIWKQZWf4Tecf3bRUdYELyK0jvxb6PUWPTi1lGoajc6jQRG62qjr5oKroQN18CaVKVW_t6CK1o47VYJObNpnYu33uAgG_TkupHwAdNC2qwYSLpMGtfLgRDwhhj5ZblTEnf37iZyKJYomN7-t5T5IkbZoiNBlV-NYfJXFcAQsJaXEO1MtNwgdjbAbHGejUIfzUyc83z0fsVDEp_21usjdyFEHq_FjPDy6QjHsc0Njpd9ZCrnyvRc6EsdvA1AizdMJqe10CHkFdzysD2nqhJ7CJV5khoj_BbtuAGj6cc_xijlCVbC-meYYccxJJMtJezAeccw5PXvhiqxferNnZF-oOwVQG9b-xlhv6c8xj48YE2TtZiPdEbhBmCb36gtN_ZC68ThgPYDJpZU6cUfjJyyJcnLXf7aZSPXHVXxQ91u-nWG5ObAxDkV83kFadl6yv9Pz4trYuEqNx8PYAZsrXfihuzKzmDSmlnHK7o4L7-LngWKJ6vGrphJGTo8wkABEu5OWbmVS0vh5QBcgdyqnnQjNqaM2Nf8DHCRFVHlYJ7MFtpNbm_8T06wU1WZAtHcHDS79o86ETZNaiH4MR3LxfJyp5E8QPhhpYpIEX3Si7FPYYzvRCCFoL8Gy6EXqnNMXnYhDTYbpT2u0xNf8cbGkdFLJvFiaL1U-eCXxUjtAh9yId0IAHcdSgn7s23oAUJ4FXUDrJ7IcoudyUksIxEzIRxJ-6H5_Tm4Hyq05e1eSPMWuI8j_hOXXJt2-yg4G_UktzlLNIHTTwLDwavlT1mp6uBYGA-PFT5C_e1SzX4MbRhb7hH8uhA8o8X61jv2944nDdz6fiV_dF-aa-pKWKBA4Jh1aJSUd3eh_zHEyTqzpwdd3DvFtY59ycTm47DbO50WAHtOkaVshtuSSynUB9yfBaAw7DQSW4ew8eyHChzIN70SS2OcVTM90pTrSfkpefRrf6LxNkkx57g580KFBa6gWpGwpSIT8Ut2AR8FMNtv2LBMVPzNyu1YfCVj-3-9ZBNJFl3O8-quuTfY6wav0hkncA5m-nXEyTbnmuSZZ57mq5DkdzVUlN6dvDPx5YdAgEqakpB2_59bXbovmTOkhXKZ3CvbW7MvbuYflOfELqp9Hp34f25OWue6KtlEZQN5BKpScuu49wr3I1o9NiYDAeMHf_qXVQyhr9BzSaKv9qOS-DgrTfMSm4qHDIOlvARtj95x0UqqE4x-QmVv3Rd-GCPIzYIpmt7hwnR9h4frf9cpC0WT7Xo2uUdAbd3VDg24N50kyjsTyqeZ-4Tubt8F6Z_tsA4pG76apGUuK3S6T7znT7-6sLBzAYk5iP6pkeYPRb_Wrg9tQtEn5hpPS3lTvO8atxjhxJ4xliAwNNa7AqoAd2Ar1tRUrQHj3S7ZVcbcAC-llP57zg67bKnuObo1oPbtQFqj3WkOF1MZCgcuuP8MZgQzut-AXyy_ONfBS-vaWO_GDkO0XoaSqMzcwcsJqBC_wc4_1_zyN518ASfM5qN1h_FKVisv6_LH94p1LOoQkDyf5c3oYyK8fZBrIprz1A_h7TXcWqIllEqajlorVcfm-jClqqsZwUYUqAPt08yE8VJkwKHMEiY5SDINLbudBxq0FfOp5uUqCOvatD43mNWldDNW3g-AJu2TvFy5fkByBPSqQzk33BHXQCNcrXpHQMT5cnNQv60kA0bbtYPXM2Lyu8aTHfKUqMz2ac68OfkqpJEitcQ4vNT-UvN0R2yDw377HhC8sXS7krDKjp85J8pseu1QkGxWf5Q0tbWIRQgmokcfP4mAWNtfOlHm3bUspAGkBJilgeyi5A85WiUSMj6OQ8EAeQmZfDRUGK1Hxn2UMo1NakabYsA7fUClgGldDV9V71tcSOXYkiIj8cG_lmvE7N1kDab40t4YcweVxEBKVpedptbNPUB2JtYviEga5qQW2aMKAf_zPPv80cvGCd9FeY41GDC63t2Z1vnVDeq-15ndlbJO83wI66PhFSLX3dXK4jEwPSDHcwid4itsLOEXDDtvdFf2VCNCMzilFENfXSqVvsFHX455n6ZqMPLHJKZn4mQpp5c3EJSsgsjORmaovJSNlO6Bjy9K5VsWSP2MMKTYRiHon_HwJ1I0ImnZWMSAVYIHzGHRNkhiQobn1FZ523c-qyEX4aWvj-xtyFaxLgSK14kZFruw_YP6De5NwCjbLampYo85GrhXnAdkpg9cz5xQ_orzwx07568alPPsdXd4JvWSz-m-CSWHG6qduHkVUJ2aCtlb4K4tf0KM1lMkGD-LQ8oSQ3T9_daIG_5lXaNpERmgeFfHuHrDIEeaOP9GHiDqpZVHHSCi-c6Oqhx62dcb9UyymEAFKkbOIlB_gaIYIANlXVrLkDmNf2si_7gfeDG5UHLTt-Xlo2shZhrsxRG0KzZGPnQ6fc1C_4z9zGzrOAo0iP1ZFuOsLR3oNPHg9Bhv1gJgNseKEzZuLb3xRwO_NpC1yJ5B_TTr_XqtQOsoTFFJ2kJCX0SbBn3tX-swx62t7iO7QhqCd7gKewJmpBHVJfzDu7SgcDJ_dwE8CXIY-qb9Y_H2KDSYYlcOTRm4fPSx77uRVyUq3ex14_6fVPRnZZE_AWc6Y1fHdcXHMWu5z7CI0jXY2oqDePQXjdB8LKH1GeZzdXWq9lhaHQLa5gY2siggOuF48PQcD7CFTYXk-P17GjkPRHrgTsPDE8cY3vhoRDzNUWRyRhT1jXNtDCFg-N6ij2A65SJcmwwj3fneabl5vnnj4ahO4aSHh2aFZO1KbD8P8xVCHAr07U9HFTjvZu7un2eEWL14pm7t_Kdhyzb1qNqXAKhHgRmgCg6XOeR8056DRKD6DtPuLJwysX8TExMpeHch5AZ0UA2H9rUFKo5po9lFg5yuQ2RnfYttfvwPizBDFefj8A8ZAABpGEinZvVG0WB5bIPxHxgoDrCao8w-TJ23FkpzXJeuHc7tfHP6gNiWw5cbcJ4rgMVm8Z9O4A1NM05ClOfIFqUlGLbM-sk7ia8okuIVY6lqbMbYAfj7yJoNMEB2wefBB2YcZ9VWQ-lTzoOyDviwOmbQJYy5VwVHaiIEkZBzY34xxAq31vrK2EXW306ZYp9rwnyKx1luaWnznyoSrRh4mAMH_Vj1LZteWrCxS5dO6n54d3bkCGXEGVJ-ePQfeVpO13HVjmkAnhf3QJ3n5vFQudgKNb8VTmlqAkDfoMOoIcKg59jhRwgOW1JIAkdkFomR4-Jqgf06tG88iYMur2M3hGNMg5nmQN6fnmEuNPiFVzU-skJtDptBlJcbi0ijwn7gsMyqjJgaSh_7kx5ZxEFSrspiLUKnRzOzomXpnt98Kv1FZzXwjmFyW2_Bs0m9OALx7CkgTS-HO6gMHsjyo07s5GKttU3SkZ6tukWeeUTVmA1JJMUvek9ZIcmKY1ACgStJU1T_XruFTxWmoTpY5W2TDlSW9vlFVPChD8kaeB8048aNRSaVM81sOupUP59WrdWT7KTNdu60JorI_IKpL489iLgxs6oTLFSZAEnMP8OE8qdBGkvT51pzRd5FXt5hGIX3aCw6e10yNwAZEedE1X4nGurNzs47DCK0fFsAmBpuwQVxt_JNP2uJ-3blbj6Fbt-gsw0HlWBs-7_GJgcOLDFivcF_cciWTB9xSz38xOp47oPAh6E6vuJQ1YcZQHrOFl6-0aopM7o5SDoZgNwWrAnZBAfXXjkX67BtrFzf9f0cmASyx-0dt3dB1XwdzJR-l_eoQiuaidsm6iJLOLDtaN-RnZxfPSq7jppGZJuT9Gz_PWnsj8h2_ayS18cC-hQDYzN2gTTaxBZ4KxFGEzO5UeO6P9JKY0_Doj2YNB3IguKafcW9i8Fztv1Wm575O13-z4NYNIahJHaurF9bVVhZUwZkjnA08QNkV4mqgGVFC1cvY4VtoFpIk9aCjfk2ToMxHkBqaSwoYYOMkSrfWbTQFfQhIaF_lif_Em3aYKe1nW9hyknXLQ96KrpopT1NHYJsvuUe6HsEbuWU0fnSxfB3LPjnM-h5tF-zCsEqbbgP4joxAJYrUfKssqd1FztC3fNt6P99V7rY1Y8jzJ9hAPll-yCy2-HmzSWFJCoBEPZOfCnYmzUJ3gv6uaztRiFNrNYKGxNkvP_WgB3hDVppFG11OclQ-jruvhGKr-rPHDAYhiCgfwm50RjaCEuwgQdVSmZ5Yys9mlmkUTqXa_7LM4wUxjv_lSF4MX-g1kni6mbxT77_5lSBYKpAyOVo9W-WPOD8bsQSm_TroHLVSBjQNUOHUg7BFNrzFxKYbwc7whJ1wgdfBvsEHI5NBvyAbYGQwuG3ycJEnBvIPuCZixep9xh3YkMc_EC_jiNACr8BC_3f5k2edb8_N73Y21So27w-Nn79r8P6S_ihnlXHoYL8KRQYooxxUtt8aaKvzSQ4heoW8wOg3kO8z9Fy8JBMwK_HG7NFtc_gRYCRpIEK2wkNYVvU8v87uoXtthOIQsf2RyIH-FakHrg5cWuD52k5vyoHDHd5w3Csvd2bEMruuDPsk2ZPibMUHGPhiO7goz7RdpqcNYYMdfgWoIRWNjQ2MPIrd8cvzhL0n2wzZN-vcwQ6OwuJhjbkDJ51ZZUhlrUCXR3FHOZVz9O4bkzgoxB5De2LlOTSW7Vw971LRXZDoqhx1Olq9jUECre3lG-HawYh46cvHMoFpX_KsVu3ezIKruqpca1T1qQiuCGf4wLaaiS4VUawA1000p7Ms0pBuWn2a4y67t4AP5US87m_EcQ-K6jZ0HfhPw1jE-_tWtBaOkL-TOrum8AQdT-T4--VxaexGyYAuBu2p0kIWNm44zOcpk42nWXpLtH-WvL68l6ZDguBygHEACbnU-urJG8aWG6AijpeFdSyPWYSajVNX5hdpZf7pCe3VN2QvNYWVYFzf3Xf_YloNI08MG6ATnH42xkfKKSuL0--TB3fm_gPGFnjP57XojYjHbMo4QG2ppnt_NlGGs6GQ_WA0L10G4rLbdHJAP6vPIDoaRi4JoW5IKWX7FwTNG4NJY-nN8tlOlPbhunohEuvglzl2km85dNHtci0-6OhyuWsF5yHo2KrgOClN3jSN1YUpKSVVtGgsCgp9I2ecJqKXZkTFfWJKyASKNtpIgHR64hO3_uC_fzwzxwUYl0vzmy8ZtdjBjwCltPsyw384-_tMYBE2QoiADJ56AN5aez3kuo9XZ1fyR1x3zG9ne62flWtFl3y-3WIa3-rOEtNc6woOdZGR7Yl1bUv0iXqyM4rpgW9dQUa6BcQ37FIO8T2nmGXHbxzUr5pCjLdlNPntT1q3HYu8u3QyWtWLBr8TX8ug_whj8rxNNxdeE_DpwbpcvGfCedLFX17PeBfH5qKIOSSwaLKtCz_NoZjGHiqmSwxEbabTyoLwgj8132yN1Psf06jWfU4yrjOIBXT7ykLeCNUV8f5dHaz9lbEaFJnlK5uUGKsF5Qn3UGEJhBk9lXmrhwMnYF-P5a6_p4vcQ-xo_4NDROJmmDjbxDcvAx_Nb4-1-seFYg3t1w5wrJs5bM2xqe8HcVeISRc6zKPezdOSFAw3aK1BBBLSvI8h311sySEWayPiQx-wEXPdz9uWGFvOFSAdYNJmKswqIWhaVF70fqzSnw_zF_sBfJEr08zatn46memCxCkv6uA9jx67-hPXxMFfoeF8Azhq9QiHQCZ69ZThPx69XrSIkGQk9KSAVwqyurd7ePLxb3LD1RRT0442UR0oIy4yQRU0Ee4374f1bBl_j-UBgY1b0WSl7Prv0lHE84AU_kgQ_ZtW05dKEBXXDRzR5paEMbYC8PEM4A3cefQ1DXky5ym6DeTI6TDr0Yo1qmpkLKxq0xODVqmgMDbFT_2Ed0p-Ukup6tc84CuFBJnyPjZXluYpbhXQnmniq0LPxjTEGgy2QuWJ8BGtuoJ9vv8rggsIPWcRVXHoTuTyPUxK-AHSDfjpYzcqO2h5CICYgEx0O9MK5HmuhTYswioeqbd3PAT-Ij-xqO3nUPO6VVVWg9IYyBbSeNUYKCJ0_XSbtIYincViYKuJDx6h9EAYHeqgC5y9DfFtnTt2WCHSkkQh79_8aPanW-GR8ei5pKeAV622hf7pNO8OpxSz6nSPOFPw48aWAHecO6NiJbAFl_C4GCaZIW1X5T-tV5U8rUgwCRe5YrEBrWwsOoXpA5UKoGHRTuX1BRwsqAOV5-8SD64uRLe56NvOo7ILzR_eocgJiMB98Y_KSgTcxDyKhx5IRahw280_Pk4XG_QPJoiXAlR2QLMQP7DB2lmK84fA1TBZkfEX5snMUhp9LTXC9W63O5jY0CbI_EQ0a4htFiVuIM1eQa1h-iV5kkTT7d3UCalUTanYufqAIn9LmRVGw4FNGUKzEVSpcreM9CXSE_YJt-m1cz9u9Zn8DXhNK9wvTzEs2xM9VBgH_LJhIIPrqi1cSEG-BfnzccTY4mj_02-4EO3sTIH32d8DTsQSPxVuYRpw398NO0uhTSQontLe_huJhesK8YVkSa7whzsREREA7ZXljy3oqnJiA_PLBckiFwkSPumdME8cOdQ8VuPt9gnyyvObe2a4aWTywNbJplpkUJpqwf_8X68G1bJVXR9OGPr7_pW7Bsj1wspF_GzfOyGecxk8Oh_EDkgExb6zj1Kf74jneCTNtq159y5R_Q-kaRPH0ZggyncGrPEasMtv7QXNih8dmEAoYM5iYFgPiYXOnkA3Ewt-vU4qBI02FKtqo_kcCEJJCK1Eh8h3kwjv4kClHB2XrYDbHcy0nx2HLs8b2QrY2CvU9Umx1sK9oRj5PZmXUJfrkx70zGex1eloXloscC5hBiChTRql_l7rzTxWc6USkqdymhZdR6wjdWp48WGzSz78dS89bxuLmn6VvTwGkO-gmuQh_B19TwDAQzaUwD5o0RZbyLSh38hUMEnb9d130cfXbnjmbvi8sa_fiTIW5MArTGN1mwSM1T6xvf2cSkAZixnYyFNMNUobvYBBGoizSPdHT92vAt0OhOicUpVwb4bhotsDpnj2yqE9ulrI-UAkHIUspsq3bgnwmYwRu2lImdH6A2ktjrdrX-dZgc9IiGFnxEl-8rd9BzYtVxvhqcqr5_OsFDVvRnFYTVc_OaNhxCTJvwPILA4o5Rw-VC2F-ASCphs-Pmri61qk285uROYp7J3aVR9jVLDWLM5UnATxMrxUJ0cIanRZh3ugAq9e2Le2W1r92SK7kBZxi_yPYrH01aWdJZv3W8LRlZRcysG5Lj0DaIh7Sn6S8eCplfYxCZSWjNhXnwV_iSIK-ZyTOvodyPBsJbI8gotw5X-o_MjJ_ZwlMrHRndJSPlvb6kksjdKoqtMYEwMQ3kfu3zJd7ayohf8boEmBP-MJHp1P8YkH1kI9HsmFrwEV0GfH9cFlKqw7SvyL1P5iZ8nXyTU-a8x5OtI2yOk1Q9N-EQCo2t8lVKlcKh1iJcCFzztfVKF2q6OFaptmnZJL5-mzdk4Hns9j1EAXqvICBOj4jBuBMS081-xz5FZt4S5YiPWTeapY0QBNZFjF7u2Fc-6M-mi-eIS5QoXFQb_eYogr9-UKXSUz8YYAjDrwckIIQDWBPZzz3t1RsaT4FYovrqUxIDS1tcwbZ5qL7dOjnhJkhvq-jCHdQ8Pu1Nd4CmKdz_QI7uJ8H3LtaNEJ8dJtEos5hCB70owtnWxsHjCEdTR-WLmDwSz-aozXzbIzsR5uQ1YIV0Idq7xdGNigXJmEJgQDVHzcRrC2T-J_uOSKDl36A5ClCahJYH8GkC0DBfQzqGsw8-RGWm3CS2GwItfkms_a8umiuXgAXLgGjckYsoZizYXbqFNB_vs1p5XJBZYBRWCnunuySQHSedb4csy-MnjD0t4DquuyI0Gc_4YZg-SbcsVJ_xLJDpVc-J8N_N8tATLXYPNEVPUa4Ed5s3Ykk0NLGUOOebndP-9C_D4LXLOJ5u5GupoEusmSJZo5pj0rpmdW2r7hMyICVN9fg-cs0jBUihf1xh6rZpZ7pt858RJDwBRinrFZ_hO9ZdmP8klasOxcZveWFa9ceVdbmAaFL8PijKUwdgzjYeBN6826lL7qObgZgO1mhLzqb3vTNO5zUTFK29KpnWkqVgkLOejlCD0JRlRMETCGiZwqP-SlR1ypHSbYEaR7u9JpsDGiSbgrHF-f7rwEfU2AlWOPylmG0Hgq-3CkR-NsB0we_AHJ0Cql-9DqP1B7QN_-8d736PphQPYOUQs6EAVpGJo_l3hqL8b2i2T-EB9wQdiBOrvAdGCzKXkytUI5pu5RmzpmL8hPdfczgYMbBEQGn5uaqY4M_xmNZAMwmbrjLnazQ6lO0WM0IGCmZEfDo2RZ-sUp6SsyDfHHmq1ZPmJUBegB8TiI9Vle11py7NsofWRHO46Av0cAnNLYZEFxxfbGLNpYsVX7lys_bdwEuPuruIWrnickbRCZdvpZGxIJew2RU5VBYHGoB6PvauerxSa-dZWEQjb7O3f2w4RjgOcCtWS82Xm-Vnz7OeNVm44hCDi_zczgYTAj25NYVt7sHkfjXFVUuZFuGSbDl_kpwlUiAuJuE50Vt-kzQOGDV4i6yG8cPdBte1oEqRGOjqDdvV15GyldcCa_XHBH0Z4rFdk415TaLbKz--vfg41vPU_UCnpqL5Jm45lcN2sE4HupG1sunlSczKxdq68y978jmKAIRMAorjBkhbtLs9szFZGiHeiI_INi4OKKtpq7yxDiW4z8P7YBqOqhzpNVEapP-86uJJBMKxSiQ2IE48wpzdpIOshtQPBdzT-1q8RsN4Fs4TtsBsoodsZhUHrJ73iUY1_ZhcflMg1Astiev1xoIHHDZSS33wG8mHxYNfsBlD8o5SimrpkvUaM6VtIqOn7LYJFmGtJddzQ4SIzi9osOPxkkew3gcd3ZFElwqy0kmQCbxEnhYlrqJrrOlsO_6HWnb74T9XiL2z-uqbRe0dRTxYranjDv_jjM-8NSlaPt_ZNbM4ngzEDAPw558wvpSbYsmDUS2ymo5gpSkxZwuxnkVz4pn7m9L9dUGIR3teP94F08SLbVn02CvOJdtirqClqTywEpTCFf0xTou7g5NX9Phy-FyAR-Xom6y3mtE5G_P57mJf1a26IQN8p2dLDP0lac-a8u4ehB7ijbo--VCVO-q0Yfd7nJA9XpFnZGuORDFpZ0KseK3t0GxGpZgvmv7B4o5gmySFhjNvjjAYS_hhhRRzE8q3Q5RaIhKIasexuKaAPNpxuC534mWecsHNb2UCCz9TJvHJE9ALdQJRB6T6hWQTfwPhmEDuXsKRAlHni5amW5p28QE4PwoVYmI-AUcdLOMW_6CD-l0TwDbBFwFa5i7mev_Gjwd1HVDYHgCcd_5LKwH2_Ap1CtunGKny4cp2RWRbalD2tVkp-XErnn6LBifoN-uCtuH4fWmo-2ETrIO7mLOD-K76pX1WXBP7aWRx2bsDgkcHNJ2mz7X_yIb2g2jZYj0GtjkimMe_zbf_vyI8KKwn_U6OKaW2nGXFERd4uSnil_da7CMYcoKd7yR5wy30MsyBw4zLtT_1JhesZWdEit2wurCZMVA0chNMiwJOuIu2dbE12bvJ3LWtd9p1YQaS12w5AnmVTs9sRxUXxpFcBYdpyx2yEiQCqqwGVh313bP8LI20IwEn0ZTtXCtkSGc5CR7UYeNn5qq2OTDfrvN8s07HievXH5WU5wWqBgkfFhXNO5s9zkaAfHlkVqv4a6QaOW-j4vnmKYZNT8ylSpsq12yKu3lWOXgzfMpizmCR96vOVmkS2uw4Hu4sMoLvAXhAmmKgu9jAjHSBFukMIoVSCIhqqt4sk3z8tGGjqK4Fd9_4lsXk5c1XX0BffqXKcyhul_0FShPvM0PDyqAktrElGFMEDJNx59Z0c1mevEqgnslRvYdHKLV07n2alOpTHPcqMqERxtxhRDfKpUqMpkNQ0GteCwyWunl_8on4v942opRMXNrro4bSoNYmJF_SpFlqL8M9VOBnbfgRWTWxZZ67gjtuypKuKf_GPX1vGy8R80MNHdSyc6mAMRPotkbPShqKua6Z6CDzYQfOTia4wD3gok2rXCGQ4y7nhH0mjbK9Gr18wgAt-i32DnO2BGoiiNVPgvLBHTrqsOwsVXhaYoWRUgxuP9YkkmeSTec2OQbvN9oDeBGcS-br14_OnOxquhdaLl1smIs6mqpCoWhSmbIU4yn5uUYNztf_wTai3WL5u3aUNNvQBvShsyEn9KeE2JyKucpHHMn6SCkmnpuApwNoQ-xdkfn3avuZ3MHuJ2mLAPnIvFY9_7iSgaWlr-W1KvV3QBmk7176Ww5yvNYNii4UpJyQ_4HgRaoBNCHusbvGWwZwG7tm1U-LkB54V6Nuh6Fz-ABpcTgyhwd2jaIKd5OymUScKPuYIELOf4HqttI6dEdt1-gL3qhHajHfdKBIat-vXQBR9vyoyiEIWfQdzZkN5Tv4TMgGPT4Cuicg8jzIsuyR3prGh0PVEfHVFRSTOFOdfgDa6IF5HsM2pV8aJlljUiQIifjRWnWcSIUlh-hQDH9ydYnuTBk46y2gtTYYRLAalLFmo5Ut9hVBWppGx_j-FftdJZ7cL_Qk35xukguW-BkbWhjSW4bw6mVb1qlWPFMEGzL1lu9S4y1fsAjZZ6CvnQ0-gocopD2QnG2D8UtlGFNME5tNhlGoou5-2XA6k8usC0AFh-4pwQbWh7K9oAT7Pvzm_idqoO6i39SgOGUwuQtJG-1hidbglZvgruwY1hfrzMjYEzSwymhiBCrVJv6u6nsfMDFOh8XeWfCDAiR6QlZBmGhuF2BQtGOyXgqosLGlzuqQFFydQBVRTNupyq_nbTjcxi0t9HALq10kn7Q-RJXrQPeruSfHtI7F2Km2_2dJEYC_BseDWZmWTCf8ZMNTnXjxTZFwF_G0G9z9T39gIrutMIeJ9CL0y1jbvi9xUEAvtmIFmsSzm6pXOZSOn1_SQVgBm3Of5ctwiFu0y5FFb3mPcdf5uaFggirc9RazRW4p73SksB4Qd96OZ_QhVI-5w_Oino5HpgWOij3MlVZQ_O0EAQgL8LxCgDBo3OA-lXybPzVvrY35aLhLG6f4KTCBQlXCcBa_jaGVA5GJeS4UMVMO_DnPjdEaH2wy6Pzr7zx9njMl0DQN35KqofEaBGErjvSIKvtnWdPL87SXCrIxXnnow1hvrWfnMrP5-vIkyJ2BIuGdI1taubqzVaiXjf0la3ufHZvxNVhrcveXpdxkGOgQ4EUuAiNW8l_edYINPrkbkZJuNA_4SPEsKRBVDjkWjmdQ_N5IZY3nHC9lwQFnMUwrkuXf9Zyb4v6Ayl_iNANgtu8N4I7jLbX3kHTHvnPCWCJjX4R_Y-NUJ5HHx-9Ji6whz_6rvH1IxFquN3qYAgyiekanyPawxnUhwa16zUVw7IRNsSwPAH9cz1SiW5KMOBxmm0tCz0GiLocZJKVXaADTOFAPzm7B2-IgVl-m3QfV87k8w-mi5Kd9zyXv6uoHjPVu3RmGBt3ql00o9hirVZIKalSTQTAP4wEFVQ3znwTKfFneLgFk0ClZzED5EV5Qh5cUNRlw73pj8ssCMNOUqiC0lMTuHjQ088UeFPE_6bF3WZiKuI_Lf9wZ8Djf0a_iN14xBk6qOHagXtx6iVbX0xFyMikMSWfdaJGv_D2qTmmnFGQDX_xnzzbJGQ9w6sppKu1HFVxKFJ1NyoPpBv1Cy8qgwsxkYPbox7lPsmvKzUGkObCPjxPnhXCCEgI5MJO_0lT_5Y6bBllCOwYOtFCGsK1CHvCmE2LUbqhDOMrEbFsleJ4gFIdeL_ROPkaBkGZ5yen7eRB2KdmCae1XrDCmlPw9feP61EaN-62_PWkQidfWAcqL8HTRC49USb4Yp1iTLBxMPqkZZMD9UoV6mSZuzVVVSfCmni7GhvvjzH2N_gK4DxW-Y5ixspP5THZf2A13eqwqhw2RrgmRVCKI3FtbxC7gdK-qS3_gQ4islXNm8gNGz5GO-WdOHaV-3bdaBHhzlbD-eL4J7CCGsvATZA-4_wVedQVgbN813aBjkq4AhUmTtftortdBwiHsySB7-CmvejqNw8SuZRYrozCW--JFPB0Cgym0ZGsirWILleHJFOUep-oNZlAdcGjv7YIHQkmKouFVKPubeTUdycx9T30FcQBw2lxF88JmtFYRW_582wNJEGECwfTxb69CZvGgmX6ogupffV1LHTqutckBhH3iJwxik7UtcRy4UoHgifpP9e9X0LAXPvahko3RNtKbCApquwWZH-pvETFOuanWsMYhlvLjRsxPhkmas3fFlCe6ElPulAwpIYDiNlMD5nhaONXg-FEgNpEjc8qlC5KSP8imdx6OfiKsIVudH6d-ELeUU9eoTUa6ckoH9f9GtXBnItcs5alRaXKn76WGTKhKccEQyX9uc9b7lKDDBXIW4hlebojcDhtW7MqT5dmkyzBCgxdKIq4PjuK6GP71R52CWOMaba-M11tZySVf1FP7x79rlC_Md9mAgt69F7vdWMYCQGbKQKyr_miMdb7fE3xH6Ay60sDfIETtuPnkHioxd_w0FBZcNuwZaEAOII0irBBiCPVTK_Z7ZinVeONnbi_Ecszp0-8dIvtZXKTJCwDVBE1Fh9FdBEdXtH16CB6ablDtNASp4yhlxSGwExbCccIVqiLYFj2MUGHVeSXRkpUp2I0ttSvpIIYAnHwCEb-3Qi3lMa0FAGprSK-zGVsxtcgiCMYUad_4Yq-Bm0rX1BOuT5ejO0KnlUVJFvZ78Fan0-aERX5SdV-U6PQWiXaTOiwTTdgCY6iPdH5O7X4gFatr1nH92Bb6hQD0WEHmAF-ht5oqWRebYMEjZEX746RocOpEUkDx6Nd_dWqiSwS3Ko0dLy2MAypfF1IQck-lJS3KtoIvS3DLmNQet6xtw0wk_0HJQmIvS9Y51XuMwErpXrPrv_62OKlnIWC2m7Ju89MvpymozoVGlZQAbQkgH_KngffWLNx-MA0yzkcLJyjPY3prfbBC-7slrqHGoq-q1teGihVSGxb9ZKflfVR10WK7viC6m-817zBpUdyKIN9KmBXY4VbrMI2-OQ0cy-_WZqOame1zj_Y-vdhqCShWpdLaO-NNTbpn_kzgMgt_lgP2LIemj3YfzjGOjM712H1fmPTwKBuQ-02AnLbYaxV4QyHUkxzBaAnq9YweqMbZ9APlE452q4co_ovQg4uDIcHyVO7jBkA8ArZXqV_H-wiAmuFqtxQIlmvnwIdvKA63XpWhBUyxcAyXkSS_KMSwwSoI0ZxMIwJgNnfETGDRe2gGSBxNGkMCxdHoPHaVa-UK6GVVq6ivQB0rYPuOIQehQQSt68oxrdeF5nXM7w1xWr2nNr1vVv0dt_s5ovx9qCcbC5zGo9Hzr7BJz9NNVM9s_K1lbKRHvtbOGJl1QrQQ9S3hbYWaT_pWyIlYqHwfuXTnqPnR5zrpXlFBpV63gHLQT_UVqv1NZKa-Ig7z0ervV-dEpm1vJghxsQLX7D1ARBhgv-jvSAbuyD4PCTj7-NNItvokk5PxrfXSX1C-wxad8VwdJ8YpBOCY4xtAhAYm0_IXs0B6nQ9BVNF21of3O0C0CI0wOk2IjsdM501-RZg1wluHYGXxxqB8pYDL0ZKluM5JXn-WQEVF1tlrRpMnUjVeWBO7-SDfpOwZGCU4mzfSY4W_Knn4y_jEH-jzz2mcfja47entdWyFQEyq2wGPvWHfhmhDodozlY-6sFJ2knoWR6C1KaysbeKjq3jz8oJvQrX8p_rCEek93fOReGvMocExN9FJVhYnLYfKokZXxxsryATOuU3LleRnq_2taSf5L8XLSI9vswcHa9GFVRak09cx13x0du4DyzVToNROidpcchcnCaTQAdzUWxcni8yCDpgAY49tU7NoCdXcQtkX7WFsY9qPEC-0hcnOGoSO6tewKQFuxbWUCY-aWK6v1fNvkz2Sx28fZy4yykRsrFDe_Q_8y2QWcYSiOf_M4XEUee0jdS-oRRPa8fg2xHomfwFSWOlEapj6Pjt5tDFYBsXNc0bBdBsj3SwnK857oeVKjNa6YzjA_U4uN4STA_317UN0EYWHrcEdLtQcljCvPByPqWZpADRAyCqL5pUUGQ6RPNGl_NrtPtNssLXjWeXMl0AZRthCSb6t-7lNW4s_qmG3F_qqr_UL5-r6FRKq65qCn01ERkjZG6tRnMK_Y-80bIJqIYf7bXeNH-whsDsyKLi0d0C-riWWH9Dt4iBD1M6BcrjG4z4hTMlZZ7qW8kCOCfGOt_1iXpK3Mlo2WsyBGr1h9Ml0hz6LSl1ccs-MjBh1Tpiddg0ea8mvqiC5lzqPZ1yw8RTSwZJYrI5hCN22YdLBLeMKeLLux-wPSNYZ2c9KQKu1diigIS3nmO65FSveagZtazQamFkcK8mCTbzqonMh8L0TZVKT5MTn96u1Gts4mDnLRcp5_KkRESg-Eskbghf_etNIy2eBPsCBkDvVnu5-GQ0HHFlKyN_xDFIcpZB7wfWoJ0pDyL7w9wi3De5wabfw4kru3GKodTwDtSgizLQY_NxqH0qOA1UyMJQS6wVDnbe_zaJYroluzzPsHkehEGdG-p1D0UslX7zBSzpnbLcrRG4ma5pD5tnsgIflWvxnVyjTHazxwYMb_3HVVBjU_9jmo_H7WAvtQXwq047OYR3Mj_AbV781BVy5AWF-69miUJIc2sdo6gfxiunpIEM9JGxKkcn7H7wBeUfgFqJQJ3ZFrMlw4JLn_3cTeffV620ADw04HYShYJV36IUMxoiS_CzngDqY5p3U3ibeBKaa8QgsEqusF5NlewXrVg6GeDeMtNbJDZO4bb0QYed9VM4SdJdHgfBFotnCBST5AcHUIWCThRFo2rIw7-lTKRe_8a2ZLDbhYsBlqnozdk89udmeea20GEPPNBohefs-nM2_L_kn33rSariw6QgylMNG8sxNKETEihzO4wFT3HtniM00hsFOjujAHzvklwClihe_ow-XUjyNu5V6gD9NkvRJy4CXnutFe9JRZxAm8Yjfx_5EB-Icbi9-7nW-6lHRMJ87hz-HdQXgSkAL82f6kkEApyIw_fV-GYlPEx3CD0dkkaXH7BRnEwxoTunjjK39UoPs6jjcQk51rXUxv_r9VfPWgdFJVVHeUKg4IRqiOyQxl9z_ZB0GSV-nMPVPHgHK04Sv3vrXeyiYqzvjhZty2xSdosVrBXrD87Sdh-MqThjTKqBIGATA3g7ob4E7J67odCLMDYbc6Uqblq1_a3XIHSib1wm7q_ApBdc9spqJlIP7JxjNqXnq89csJku9qlxIme2HxIESNgLiUdeP6xo1TGQqdLjuVjZ50DkTxf0Ou8z1ZJU2Hf2pT_VhIgZI0rqm_s6IahZhYEe1j_LR6ijyYIAn4P4-Omg0SELJewGL1Vg_P4C9QKlKj2SqO1P9tMc4YZqNeDK--OEKOgvtjZp-Oyq-Gj1kxEnDxQT8JV-6tfiyEdLfvgJzfC8WnR6MCAvROpjjppTGI198Z2DVpgn0LvYGA6Zx9ZMwa9kkj5iQJZmEO2axG642E7ae0SyXgX0bAt-C58ZXiVggIApJpfNmjUx_VNTNMytheMNrBz9ZrNCgIFowH7QbhklFqZkhY2ABj3WfS7ZibFWqbEki9yq11X19tsqwnwd8uEx_cyfyTnZzwd-VyC3Qo9Hngaxh3qzhXJTY0mqM5-AoBiV-0Ufus7YEGoZR6bT9UMlKsCoFOFyPrKbt3o-kypR3zUyaL8tUbFAA8BbRNTQOmyu5_p7wsOJGK_ph12zz_lOrklCvtuXSNW_Q38Ytfy910z3tFp7u2MN4laNFiqBGTLU1v5vFARQZgjWRTR3DHEiV08dpIKdKMdBvUT4_qARYKXOXz09wxLQMMDFjDO4YlaIQDVCvjWbtvBsyduze6E-dm6DYRe7NUalu_QzzIZA-F8C46Xj84C-f_NBccvhXrbgrJG7csRGeEfy9ANsZooHNSmNnu5rcwM1k0cfVw9waHtzkqj7T-tTXcv2Y4XG-18wkG8ajQZU1OYAJQzmDCjGK-WVo85Sizkle4-DVlwZGGcXzDiQDbKNdT0hJWATXfAjQ8mL7wFvMsA7XbSgoEl_E1hqMlNwknTRg_qItZIab_DneskEbe2kmsMcOjtovg5NlXYOZfKWSPuxAhvNcxBWnyWnVRetVB9Y32D57QbW3-h-IaQceXJZ5TgXP2KD4OT7tLhhFs3c31m77rAftw9LicvvShgdmNyDqWRq-NqZpCm4kRRu0GbW7D4IrTAyFwV0HYfqEfRpwdLNVQt3-aRdsAzr9NwBwSUAlOZ40CWjkh5gVjTZMBWOKj9RFkVeNLuljH7_naZcSeP83Z7qdxRJxPoa6Mq70t-nFhR9CqzAhX6UKzSQfHjqfVEaT92mfK42ss7WYcznmiAmU1Dhqi_cmrZfTNiuJp1g9EmFGh-uyq9LjzX5eAjMpMgc2oyq2sT_ZBxZjBis96BCdC3HFHJeznYNauBLOlxefARWDxU_922fDCYwllMkZHphSwQLX72idYouE3K1ic7DTdh8lS2su8IUzXyaXd3HpeB4gMuLoJpQP0lZf9bPqGX5grzQljyKm1t532niHOsRJeCWWwED05G0NsO0ksqNKcj39h1ECca7k0xFs_-TpXIv0oQrzSQddtKgYr3C0fxj3iKOFhMVEUd8EWOb-EbcxqcyR9LABdv7YHRJ4vJIDZ7GcP9ekr0myxiOAFhMkeNANYe1N_vx9UaoAdVR7o5mwH68M8ztpMmYOCd85KYTKk4DmcRmFLwboT8vdc6NAeI_QuLJ6L1NMaHAo6AXAb-6k_SNGK9kMPMWSx_xjAG3zXhY91b6XF9aZuAhSjkXNZ3n32R4ERqwTNvhqHEqnDi-Zz7sbb4h8BboD3tdapW5phKpb1a2p9NG5CFCYGqpb5Qko2AEqwCTWyP1XexxWUKmqWJoDgbKrICHXoxKlJNalUy93IRArX_ZfrSvZKDkp0hAdyGQNs6vU0ltCVcsZ3Whcs10kri6wPhVu0K1tt8yy8VEUcB1att_X1UJs3e16L_ouO_baqCmlrVaj2NAnWh2wAhn9ilz0MZAWoyz16OCo9wQE_mDq-9fC6v9w4DQ12GAR7D5iJ0z4guORTmdKVuyt9TSWD2t9DUGIfcZqJGdKahx4aGcoH2SZrHjLfV78yyCm3AmhPGRk2R74efRVMOS2_ASII2NlEZRRV93lDsQqJXkYDHBRe315GJ9sKmUERDAso1ch3WeMVK1fukIYyWsyWQa5Ted_cRadpo_4cvU_xTon8sTYATafJ8oQLXMVpJKcqhq5xqziuOYhdJ2-VUyapqD6sL6yJ-NA-Xizk3wPwcoLGxe70xQcNAvbPQjuak_4QPScYTNiARqFJtzZf1Y4108-xV17TECZqxLIZg5dwCKNqBXNX2rhPMi8JCXMV8lGwu_xJFjafSHGnmlfVag9bWR2HCUG4sdYlHmcwU_YaCKi1z5tIps1JfrMlX2SbLSWRJkzNiIC1ei5J3XT2AwfxYLrSoAR1x2wSt1UIriRVruJ9BsQhS1hlOWsR1gwyq-RWU7PQK7x_22SXhVG_7J2xmubwtDWngorao_vB3se_ZM2fczX6kk1R6KfukkBUp7rmgWF9-AZQYMyOkUhwhq-OCdksoYOpJZ354CalafLeQsrzk0ZyKCSiQScMcHLVz4PYSF4TRF6VQofkumjg0PQVj1LGvu5qcm5Ptvaxr8Qo8MGImB67U_BpOsG_Jh0Agxk_F-jFb2Mh0t1Ppgl-FRZ52qq2IvSKKN8fPmOxMMxjaXEbGV3Ks-rox6Q5fAJhb3JnMq2NJh5IhAwPrIXCcIRJrp76e1Yra1L5_AvHpo4JAbIPmWTLs93CCyRNZvGeasZIic7XoMxRl5WODm-UH1HUQRPP8Y_R3OT-A8SMzMED8B3L10OAdsO3hIcR_DVtIzl6m5OqekZuLE08pgnrDANfHMZYW_Jfidx_-14K0M-xkvM3mGwNiMQfjD7cEdF7LksQmX_VD7fS5Ao_k9wxSMknifVN8A3CSFGODy_WgZXlqhQo3zSP-i_M99ymKNAIlLHtWY3kkh7ZrWzzTY_dXQ62umF5bV604BuAPSm-96PBfyRYVZMBKuY6rmI1c8mIvkHsvx1lQjGWTtw-yNBL_9KKfefVB4FTO0KukU2YuIyTCuVMADIbepdsmVfV8FKlNfZBjTXOKJk1IxAKMZ9DvftpJp_vQehrw6ygPpGIohpyVTBONVjnbyhsniHl5Io73vl3CLbtzKKNhmznKR2ZfaP_zTfP2X8ay0Hg27WxeJSUeZs0TNtCqbnMd_WunCePAY2afevtcmD4fw60JAXxPAjelF3i7hoYu0RzUt6rt9OHMZz-Rrbiq-h5w0kesuewL6p-XuFzL_b9BUjQ5qYMl39ZmPyQfdnupLppQXx4CIV_ZKKXDj56GXQsCymuBOjvwhcIXyEQPb5OliBI3xMg5fp017Px3Z3aupxjsfRB1AxBMGCMfFJn5HdnskYYK3a6WXq667YYnyvsnGqPoAsCa0Fk6jpb_yRcqJBQQKe8to-S5Ho2nk-r_vUZesVqoCEnU8_M5bMas4WEMQItwPS4OJ4lziB5zB3VarkAregiwtydjKkX5IqPdwkg_VdobzIvEY9ox1iWwQaCxi8pVYgOzmD_MdlpWBM2tztD6UP8ozKC2lsPk3qJIIPDNBjTBfFYOtqw8ocVCSls4UqYyjOgs1o638ZowQC56lvd1YfZwO8StTbP8XZeRkZYScG2AFHd8g-p8PuYF7Gjm-l9y240c_RfuJlXRtORkquqJBVg4ZkKCHgmvD-kIXWNNjclEvY2xvnJleJ-BBWpvzUpGrNz4EfuRaYy_9oqIVGjQCs2Hw3DFHbpUCHbnWb7PUYAz3pd9nWfqpPPgwRngSwv6ghQ2n9LIBr7x9oJ5-e7pIm1mfQG59q8JbLAb8XaZtY_ode7OEELmrnpROFFFqI1wv1fossJ7qYQ6btiIaJOtQ_keIVYlX71m_Rojwyu1qW8q89zlbJ8ZIVMUdoBKwe3C5wVxRU9s0dO0hmcJsDtCpdWaqW4qBjCY0VSp7eMTEyF_ZzDvAgBt-Vdby5wR3TMPG-qEnRpVnge3qf0e34TJmHJ5QZWOFHRlLBW1P6VqjTS7DUJkpKpsVoBKQXxpyWfMYcPmUdCOjCP6M0SWpKkzrzdS8j3zn3l7UkEMUKhos-d5amO2E2balmcmyroCPbWXFcLNI7RfRTge_YQKGA6bwsVm7Luo3KXfb2OxRacl3K4kKHTKPJXCqc8iGm-PRXGUkaqxds1O6kyU4x_YwGacsUYcf_Kfu5QFiCbPfSY6UEn_h6NVMPQB7NTEo5PS9Zjird-vDsimwWkeNWLCd4oCo-pG-Zj-_dA_fbf1gdY30-FapMi0DHf-TA8pxsHmLnqi8_0c7rlG0Few5WJvyAUzodFA5Ba9dqkmkn1u8dFoSifOudSQp64Re5HLj5K1pd88asKx4MXyBxnvMzu4RiJKpKG8XDgPhMYbtjW-QZQKbG9rdjNVos69h3ngGxfRzyT_JyUts5-KmimEyf7VHq6h7AmTTvdnIRmoOY0MW9cPq3_mQRQyEgAf6BqekOxNLvGYoqa0efivsmEghxB1AzZZljAQ7xBPWHo9ZfzfW68w-m9Y6wjNQHlM4JVBWfqzM-Qv15JeYJ-WriXSI0DkqSw-LlNfBveR3RII4r3ZCRWtdU0M9KP1CRCM98uhVQuzXzDumECBD8VPoTR2U0BSC2hpuPHqP5nEGwlkJKNZvs68qoeIHbCPyl27d1ElwA6QMxqpJ4UaW4nE2zH9S_HgUO9OY3GKK4c_ULEq7ePYMGyzxzKReawqsPklrJ52RbRQT_LmIltuvwTqg5iu8zLcBR4FLC3iAT3FTxsNFVe87nDwanxfAblLb4A6QHRmwXMo4BqRkFME9KiXkTI9GSnSy3sKcWAQ8EfEz9FkRCd82MkyBn4FovC0mUox8WIyV3149yzs0KlENP_HPDt3Up0AYHkNbLJHDgpcVrRlvz3Ha8CLURM3fpQqZ0MZgpTynnrH8HVuQmdf2uTKuLrm9E-2Us3So2RrSctmzbvFBTDYvZKBsxLczZrjLV1SB4nq_gW3AC3PtW-NCX_NtRZIZyE_ntZsOpUDoF_PUOVLHwelo7gAq139-mJSLmkVnm77pojVfuChnOZHj6_HDwH8JUTA6mg4_uMqhfd1z568fPgfLxhqcM8S2x0tLlQ2A6_TuY5dLnXEEoYKF0AwfbOlJsLpihqPsDPcZsKxBw1EnkUf1p7Ipfq1r47mNK2XIg21AD_e_3ZZ7fMAcY77_HENhve0LygItxz0MGXOUkMudfg7z-OlzdwPPKcSzQtDrYyaU-XOiJc5NyoE67Ydkqcm_kwH9eT1rdVRyyJ2cznIcgLT9lUNAVQ1NSJx-_gxq0SXXOf4FNret67cFmdjaIRcHzUuqTOgZBEUVlL-QILLjL219mpKx42jk_TXmuktW3uVDyoGdiqs0zUTFy0QsvjnOG_i0qD3rFL45dGpqyUH8walXpYvj53UnIU36mfIUH34ooOCOBnXN60PwxoY-0l9l9P3vRCn7wA2w-k97Ar3HufxXUL5TqTQ4hD6jxFib-ioAc-nhKVcVq2TrMSKshRflLAHudC8Xh9khQoS_DR8_xaqaAaVeMEw_8Q-aWs-ve8sy141fHT4mYuDq5KHE5VgM3OcHpG-S9s57dDmmglqWLOmSdYlEreUtPH5DO5GTVshYkdt536LODUoQNW8LzgLeMSw8MRnEF-PlzfvpSjbiBcDu5kD44fe9Kt0jNx96AC9kxCCXXLDjWFZn7bHQ6G9WX3Bg8V4DzW3N1uExW36UJ66nNxWUlP3Kxayn4LwgkjKK6745jOiwnhkKOUkvHsbrl-JVDQD85zOGPaV4VMXzL1Cpp1Z9GxMV5Ukmf0nVJM5PNsLBL9QirZutTAE0tG1JkZwAJ4cBDyHe8rn9QkNpgQOlPz2lUtbSuky524qJ5q43-QIWbMciSyGDx6ehx6ezxhytutFdzq9wJn5vsDpA0yIjX9p0pmxIs6czOgqWi0JIWfzFhNLft3BW8MOt9YPBWS-_-D1jQsDnIioI6gD9zFJ5tQMiPj01iiK5HPgOV3rZyErbqoOWH-GCa1S74O7rVZxGpYVdEUrJbE5NRsoW3VtTbunz8ehpDmak-wx2oHLv6s9mfXtgjwqIKqEWSKIZOuehVMQ27GFU7BpDX6emZWJj2M3CxO3fXL_NoRH5UGNvDaQ-bshkgn7RvmrEoh-gkxkR7g0tKlBqkmY456VvhltHQvjz3r2u95vd8nn32NOfqyg4rUqiaOT019hDP-JKXvcF88DlJaE_bZ1MK07lpDXbO1aGfZkzRYy1es5nc98KhdoD43lS3N9QT7_qnxuxFuvv-o3QXJOZL4dkIEv8Xt7rhjwPrzOh5HzBYdn4eQHNd_iYQYn_ngRq2EjOYj4ONLRTX0Giy9ErMm0l6FW5ZvHvVV_RdUob_Eqal9ItEQwaycs9NudGhiO6cjskhONU5IXjRq5tlH2ltTfCSNxauEZjRpBLtdZ-xHaFxs2NePGr9auctWLDQPr20ODcmiIUO7xqL6bVj5WoH09oIVrMHBSEeHiXUkjWm655Uhfx5PB4hFgmTEJbMEMp7We2sjHoUtGMay9rCt9VbvQFSeehW5EBiaoPnw_Fe06rMs_boKos2TQPfR4PcNMji-Xe_VrwwjRZUGBY1TD2n2s2Q1_g2UOrL4_byw5X61olFwWottLFXup9F5HtAlIqmoUQAwB4v--C_Nh7Xwg5RGF5Nc-AwKicpGbGhJ2lmfTK54t2M2zhCTbZqgDC2Zz13O9RXxOaFlkwQQk_I1BSCSrQ1KQMUfJSnx8DQRMsWmUNgjtpV-NZPRIHPoAO1IP1Af8Dctu-sgzFErQY5w5BtyFXkb63GgjucBrLT83XEMZ_j0fLkK5s8ZBsg4Zl54qnePiT7TAOXZ4NQzUiMToO5gsceVyF-ryWyflyr2-LURrhXMN887i0-f0XyPlvurrssXuu_0j5nG-6fva2-6cIV0axOyLblkZc4fnQ5vX1R317WnmVS_e8VG5GAq20aUkTssdEuswBPdJBFsdmFotxCwdyKJiVIh0j-o23v9f2Ow4FcdwpT3OJBTZsGj4F453-QpoCAxCvukDlHanchdbO0jjeRUJGDtSAjuJDG1UNPHQQRMDpbXBozLZFH_F1DT1uAu1YTcYMO_ORpd8Mknth71wUOUzybi8EHYPj1fICVZAHt5GNr7MjS0LthLh62t38Plygu4lGRKgJjJIWxMGwCXywKKazrHqqp3MBKMzuSY19jrkaz2fk20mP3WFNjs_CMYgIp2HUNMrYIsybaprNiG-O9nvYc8vaK5ug1KkQ0MJvzGd9X6wTMJoWtML6F2SRoHLsqv5YpAoi2jGXhWbenudV2BMMyHOFj5iJvxtuZ_PdI2L7Hx8xrw0NCSd5NVfe726F_FK9Vnk0vLlRvPlvMfrA6BaAzSifL6iZw8Sl_yF47xijm6svAdI2_8XoRF8j_scMtlSeYBmhdHL652_rG9_QZwyZvFHQ7s5i2aag8qUhYysZcbTYToo92QQJtrBuK37pXIg3pjAD6r2_X1w6SotfxEiQd8WnQL5epCkzBIQGxtQYYJsOwi8u3pUcVOQh9f6gAW0SbJ7qx9qMBbT3kLRcls6DMFsi7LCOcxmLovNfpoC7P2d1r5bt-XAWVrdpaeeBZlmqZBbnt2y8bYL3l8dCDwCNLqdkAbjVEl-PbKjpvJrSvRBdyzdVaDaEAF16hKjYe_NHNTNCtS1IgRlzveH-b25mK9UPUGsvp2L4haMvIqkIiHHtChZwXY5oUQc7tU7s5cqidUyX4SEfRJ4rUyD1QnqCm-uy5HMyS9f76ETteHuJ7WFUHQ1YDOPpXyKGlICu3ojE6l2Cfc2fOgwrzQZlh5OQTgha_daSsZIBeHOx2j3l8Cn-eW1LggDddUFgxZVD32-mgG5ARknobuoK2MW6HEr-McFm86ZIZUq2S1el8Cq6IDpKAa5YuASybgsWnE-bwsggEYvsLQ3caEHTJzciQBI2vE1CzBhe5XPqd3brKah_tXz5ZRxDtC9GtvR_U5pwFQrCU7vMQ3kPeNQC35EvHJks6mHezd58ysCvGr58g-6_P92Tnz7wJ6QVvnwqhYwiOtr4g9h9w7_lUaP4Sui9YVdN5_iPlJTfbc3TMwocv4usoqEIg5H8N3c_8hwjFtRBP_OQOqD6Lzm155jHeEF7ITnCBaMMjVwdF2aPybjwTstSC67T73-axigNfrDilbrSVvqfX62RryBOlVwDpeY15Ax2_u_jdpVmCZtRV6ZlXiBiWd1aEK5aMjRauqu3Vijm-_aYqOs5UIePjpy6zY-rzfxyMp96vRenHrdqdIuPmh69AURNt7X2rGoBEHROYB1UhRCR7DMEeFvjGtmLfVot7bYwacFa6-6BCV8j78mnKxuQaQahZFR99pHV4Li-KHkI8WBGsJSHITiwo3cwB04JfWSH-ubOvcaso1l5MBx5JF2Y0DMueGRraxp31eKgPjoY6np1zT8A5M0MnPfW0M-R7paS8m2pUXCk8xjrrC5GIp5xrDmXzoHlOcK0v5G3us64pcNGm12ncbFgqiIwsCnf_aB-tVAnGC-S-JQfNvyggUdWbUND2HExNWSj92zml80jk7uZOXjvT0Gp2yb4kek98NT1yd6NddKlQ2mxZhWx1edw3wiocHnfvq9Eq9wkRBzlb8scxLojIrtWfAqGJs0wl2PoxdE4X5ujxa2igae4riYxk2QupfUTOXTkNMdQqLGqynutcxYmJyJ7TMXr8HGbNVKB4hg2PdI0q_m0yAII9RzEThv6ImQRjWgDwAC8D9FD4WqBCwuf1SimVG_id2PdIwlA78FEF1V-ami0D_GaW4NEr9fDQMtF3lHjUkSp9TZncNA5izx3AzwCaAEtJVEWj6yk3ESf7ktDP7amdznTaiidL-fCl0eeMVYeY5_vvzEXxUOM9XabYeyDeyJlfxUhq0LQazVBW3Vxu-rDjBcASBL0T_xhXAFnKdB25B7S8Q3mAEoTk8-gOY4zpRv2ncZNIqmmZDoQsC1aYOchkWzpbKBmCcv-jEZCDzzYyjNwbSB1ODIyqChvo8AcrfGBd2TohZS8giNzjw1BsQj6bpa-KxVJhwi28z54RiTP7fQPR1J2M_DXGQK6StV7Bta-fL2vSiOjYlXy78qgJlsj0XWdUpC89Grlu4hqfLaKjpbCy7KKDl0P6q_v2YtGu8bk5uHU9iOS1b362LPLk_0_ZueIXNgh6BD6amCCH-KlJ1bm4N4y-_yowEzpd9Mt9lX5xHF7632RsiDIG3nB8452FrOeOQ28RjGByPbyJt3uHm_TOXKFjFiNn92ZTdhEgldsi5mSoAQ-Ya-Z8WkWGaaBOaP2UdV-PWW_Njlus2nVfkwq1QWIgJDV8KxFonzL01xu0YkUPP4vo3FsdOZ2G_6qS-wd3RYXaeAD08bu1PZNPJ8IDaSU7-gHNdMiK8OjS7RitP2xtMz5P5LBOQ9h1DcSFyfLRHEKzpbCYRoVTdMVe-EQqgASkR74tD4wDJkqry-RKZGFyjWA68TDWXlLXyj00025D8AQjiUfhT16n9BKoWa-XwTVhux_YgFBYD9ilKdwd4Jl-K1dG_saq5cgHsgQB37Y1KefAih-uAQAUc2Kgjw97sStj6OpjAhbv4GqckoSX4raW5KjYwv5mRzptB0OJHzE-q_Krem-AGdkO3hW8yoc2EL2wK578uKXRP2XQnq-lscoJEOjixWeSG3tl0MdbsFRg4HoV_KV67RAeyoA1fE93QQENVR8cx_cluITFTi0WRdO2UEyeK7hkCVPl4PE_eQB1Yv5rwj7QXE3EtEFzlYd5nvtTuyqYYudNdOk-Ll4icJhAItwfOMGHcbi7jm0i8OvedqAn-xItCVdnod_bBtSPdR0I9V3zV4JyQb6MOTdGSqIo_KEclSULxaUlWtSMil1FVQ_0hxwXd_CyPWXpDAuTy6Pn7-M7ItLppdoVt1hejkHt9h_zAICxNLafmn-d4EJjBsNDvsVSYFTls_zU6517auK-cISSb1yw74gyNy-SCsxebT2aE8BmXyyni4N1Bp-uI5ysT80nuLpQBSNBqsy85iicE54-tonjvu3A8Wp96QWrMz01d25cbTL0roe6AGyvLlbN8UTgXK52WiL_7AhNH_nLsfKItU-HRTnoASFUutPmiDcvTnIo6hhk5npUfi09UVnpApDi6ijaquLzHodq36OqYxC0uWvPOBEAWOc-h2EXmqoFyVtl9TeVwzrkkkgCatIKEzZnVe2-S8Z7bhGfGHeMyZKHHcwBaNbtGAyt4-4RPvjvqNghg9cfVWn4bclkqczEnGk2kXxMD7s7seRsV6F5MyXqfBmZdAu-GXdEY5POwPOoll_EIu78rZp8BfxPcmxhnETuvRPJMAtNpXdAZpeo3mYozb4lD0F6CIMDUJtoSJyh3kd6nAxlJKhcNb-vQ3IsGGLE8lF8R_pg-AdG2U8s0ncqit4dnuQPY8kIJSmZzPmvd90yph4vzIQqCwfLBqGnfIH1s9euvAUkFTcExAgoNROh9GDNghKe15rlWP3qLYFGVaPXDb2L_mJLSz3q4IZkwUYzkw8yPu9dNAS4bunLxi4BvmJC6zkaFtu7ZTqrQ4QAXg3BmIs4xCFM7ZJmhDskwr_gs5iCl9YI8ZEp1ik-8n-xcrXd0D58M5N1T2MDLejspI0zddyLBBsTZhMACzbD9fCg3SkohMjXni21wcgIJr92QRDxDUGXtcj9ZyDJonxKRsaj70RlUZvMSsoqgNLN4p8UhHmo8jorS8rOmHFO6M0aBtPjw4xgg1Y-tdjMtFWCMeyHnVupWIySAwMroZUN4_YG_XKWdDK02t-LRjRuAEXiOLRhCLfOPRqxo7b54Y9DeHrewpkkHQWa87fQCNU7D6cEjdnQj5yuDaH20wI0m4PckQn54kgGbakTv45uw8IjrBspDcWTdAPFF7InKRAqtlbAK6_u9A_qj1Vvrrvn5UVnFK3iqW2Y0_8hCbmUGF9a_SBilGwsP6xOeXA2nh3T1uIjZp__zvwph7B_FfAxfL4gsKXgmq-ZJS-GAlcT0aM_z0jTMLOF9K9z32jknGz0Bak8OleqwJXH-Dd8pvNRbwojwu84JHsI68op8Tt9zdcIyRFSSz240c9YS-saT4KpkayUNpG2gjQiZ83Bb7V364i8Osz8ca7PB0SyAf3i-yrbJvFrWkVuCuc619eDrimy288cpJfBzPzgpDS9qF-sA6MGpcJmA3P9LibxI6OOtPont__oi-ZpL2fu3eYtydipsuJd3SqAORzoFk2WGoo6g3Al10N-trhNPpaVw3GtjJi34khdNXMhjeLQj1HnbzAYlkpJHEJczY_HOxxY3aCL_6VH-NEED8j_O0o3XqNWEWZQHufRDdgt-Y_4on4IqtN2DFk2SJkqqrQvZynNX_NX0SvGfE8rWZf5-XtUI8p-_zTUoMHGjq1gbvjoYeXJciSm5kbVj56y022tL0i-ELjdbixWg-8Qmo4mNTrv7JLF8KOEHRdcBsjzDXdcXOrz3k-K2t0c1qM_JTcmuqYqa7u0fDdU9iwqHCdMhLsaxEXuu-9ShnwqoQHaMnCoHL0cVnl06TZ7ubceLPi0vPt8CsxtY0_KjuVDGLa9EsK-aaDVOfrDYQah6qTiGNR8ZOzaWIRgZjmLqW8o_LFLZRgOPeo0sfp5pLYShBH25l_k3Ncs71icRU6z2C510kcV1YBNMbqpfOVQhKDYIN1-8T6sdEjel4CV976IZOPLd-0z8U9qO6Upo9ri4uCr4kc-Qda12jwe3Lyw5hogdaMHyzj_Ye1gtGwqlojY0mlh4nAvbsmtZrLV3HFZIWKzC49iEHQ-drgD4riHkvhjQU1WL0T57uDKFC8icptSt4KFgsewe7b1dlFYFxic3NfnJvuj9bZM2wKRacV3bdErN5_-2wtJDFJNb9Zv4la3tfS8ajLqVSaezBxsbF8mnEIkNUB0G3zGwh9_9zpG4ZayQvLtMv14PKbq961ocNbVrOGTd4qDWU1sTOof6d2oTLfHj7kxZA_I1Xyp0q2YekjjqHn3Nk8I798cE9BPvU3XKowIKtwqfu79ub6DEVS8lz46MjfhA4M1DP1rDkWf-9fnG72U82yFiIjzmz53eDEdMdZq11vc--CPFiTyNVgnf-wHJneMVYu9qRlkXO1Y3oeS4M3kPSXNNxgfH6fZG-8we0M7-ZgxxFR9OzYy-Em1QQfBG65iy9tWDhidQyUi-cY3Eyx9pOnU-2QeaVgWnApbfk9VRbYbZkcu6GjMli38ixlFiOsGa-ZuEa5NiEQ6CxJpVIKtwgldnq-cdneoMOG4QJYJGJ8lhioXi6QqJEjuKsLOWSwu8Rkoq8TXvwmChLIXkQKAbUQy6RL_evDkTUoKeklIwCLR3uhUW9huR5xgFxYyHwHikKM2MHX0Y1TTUt_F-3Mc7lY0lw5w0azXNYpB3xO-JY1Z6R9-wJ2J78vRFYXWvo99MsX7EofzytyU83F_t6VcCbYBl3equ_ugEzpzDgXEMbi67p2q2urEip3Kav95Xd7x_jJ5QLhIa0EyZfSyOPZuNmpCorhRu18hEr9CqGndh9a0S_GWKmnX1S6iEcq86Q5xWzvCzUSYPtjkH7ww32hUQfYITsk1E9V8i5flPiZNGemATjz30YNvwFQAeBlOwWEcTw2lFWXfnVnCpDbOm4jU4qvZ1OwTSP5OO0COknJ4yzMYGmchKeayolXVGdkgOIJQ9EzAwiTDPV70evaYT8W0MHPgVKGSF_vKTvi6o3S_nhd2OAsX2JiftWVy1kRNB-zru1BtAxVsGq7pEiVE5kPogGwYuIQwDsU1SbZbGmas0ARz7UHYzTpezUW9H4R73twfNnAKwIrGrlvSBimDR6F0snyBhXyo76pNNQ_Be432Cua2Mh36vg5UYrK8T9qWF0PdccRIihwwF2zIEMkgNpWcjpdH_qvTTorhOcSe5XUIGc1vHEM4YdGcUjFRp-LdQtJG7wVoj7nHab3TnbFeEESOqWocvy2Ljo2j3TGlatT9t-_U1NB0F3zLcGSlCYIrH-7pw70KdoD6CbIcoL1X1Ll5jc6lfvMyvE-o2ygAVVwlkZ9cVrafYqpqjxeMAn7X3OVSXJHzUEUTAw_Gm31QEYULABNTbYg_gYfwRn8vgfqgDd5bBuU5Ob45KZ3HxVwhChi2L6yuY667BYiOCAN7d-2ou9OhL4vqVKoMwYdVBJAAYj-SZYd1SFlmJLwrg2z1y-8az0vezqUcDk9CZL72M2mKT4Jo_DaOlLV36GXe5zjJxNOvBB_JMEHc6KkHgXomqsmVLEyAEsLONnbbFqaRpTDlxCI41H_yhjuQRQQ8Y-50lR1_RCrUwbZxS3JmwUzLIwqrGqKTuGYPg-uGBIS7CgqfFDOYZZu9v6pSYjsCebj6dSyRoRKfRR05YVvWF9KGeNvRUT9uuJB_3nLSp2rPJk14eYnW4z0asxDqfWZh5At7px9ANpdZfmoIc5ku7Ztoat9nR9q_35_dC_n8JOq-Sr14WTcXRDdjPSOwrKHW8XdQMaMF38dez7c6Ct6ncEQztUkyowipqNEaxxsRLNZSOivf4Rfqm2N4UXP18D5Gu385QjbfmHfv8HtqYVUWXj42ZRx_jONfD2HWjPYegm05qte3oUNMnC7meJzob6D39WBkyJGHbMCzkvnvuh9NDo4UKMQVHHalQf8cASf2Wm5n-T2_lqnbshEeVAVevRhdIu1Un9iWvEdS4WHPjarRVz8-Eocj86xjebkvosZJ2y7OMVFJ_HzWUx-wuAJus3jGYS-fckLd2lnk_MEjETJ82W6pxS1LAfnhwOEBYQbEvCJ_HEZ0k2m2ELnGAunreqzQ6STGnfnpN0bv1qsodrMkgeyYKyO678nYLsIlw7dnkuaERUmt1HgMpD-Fhiu2fs4Z3FSFGRz2CExNjm7mWHIs2SrnhPCSE25oWQSSiUBMz4SliAOITobxnIRwpD1JDAziPc6WEZU-TEtBw4ksT-PcL7rb47gWseNAEcVNogFR-nXHygErIGmjuDMZSg_uIy43gmn5x6mBvBh8iDlrGZgDWjMlryjQN0XgFfjFKalwgSqYYElGXPr7xsS8aulTD2D0MderybfXWVfBKvr6kpx8IQr-Ait94ur5tmW8SEZhbC1Ne0clSU_XgBtZY3OuhwmQDnRNNUx0vi0H80-XfjU26kWAIKy4q1E68II3U2FrW1id7RJfjo9OqpMbUHomXgZsBNDEJ_6tcSq29LveayKyRJQvRmSR3bI1_iuN0NPVEcPIl0jnl1wRSoF85Yj9J6sGLeU73omTAGMISSFoPO6z2nnwpOSpBiDJ3U5SX6kDFkCkopbc6emD_6SbrDPliQYz51GQ9pBFQpzMjn7bmPjyiFZ6Xq4KL-twiUJRDGlXG321kZ92Nu-KP96vN1aFs7Icw8pzO87xMYmZqZEz5_CsHxxRATgB99CH4K4NfZOpN0qRCheizgzB4a_FxrjUi_LHsFHAWtgKGTcjVXVDMWuHXnSjhizu7rJhj_e-ZiJjHkKmmKSQRWQGoMVDe3J4_T9COOGdlOaHOrlLW_7oW0_2BE3WsX475mcGT7pooyGY8xDWCf1AWJSI2NVgUVTHeBhZYl-mGHk-5uQdtNQts5vv4h6KEVZK6cEWiA6FPB26OihRpOzieqIrxAqb3zzE5r0P_4BBx06nzAftS4uEA-ch5WUjVHaqv4s8wECcsusMI8XD_y5CHVX40ASUzcRUFKsZnQafl7upXgLG3u73Q1gGPcSMyxhK0UcAu90Qk-_4xfEFSbXMcSNxWgO505kGJT8eTXvSLfqSWJTEXtyb71Kw0WhNnicnCODumPfPyUNyd54XhogcqGePPqSBIIScy-ySbmvOhCtyVrmNPIw_ZBBZSR9kggBpkOKs9-2Iy1b6tA6Nbj2H33K7PK-GlofCs7sBpTY0jHBiM8y-JONpZuh1qA9mbH_T0AWFX-tYN2wkrHnD2gi4FtAK4KMSQzCiDuP3MIEWhjbExJHTlvefs-9djwdbdcP3D2V-2V2Jm-NQsHooW5hbBtxezz40rEQbT2mzV7ReaYXS37V53bim1WiY2Ech3SOrWAnoqIxfWNGbuxK3G1BDN2PnelkOCZqjUWFbrBrSoY5hgzeDuROXX3qJ1MMYY7KtW2N1CgI5k8LFxeBMetzKEaxX73yE1nQFrXUJ1ltbSAibFWLx_9OwHBiLO2V7pmj4aJsliMtyJ8R4FFNnS_lMC4yVMmfZEfzmbkie0jPUIA9MBMBBnjyodOW-l8EWP5-7_6ZtInAKUrkJL6itPfyT3mTnu2xhxu59COtkcW4KlWjlklLr-IOsTjm_fHaBq2srW3-0K7-sL_yVSwV2bzqv0mLK_ha-jdH-bA3syMmEAahCFZY6xz_2PBNQptlrbBvY8vmNEnK4PBCzjrNrYClieic2l6I4pFi24ayBNMjmjoDpAYxXS_ZcPuFBwIfCz_sVZix2lBTLRWRPJf8g5VLh5ctYr399nvVZItEgtbAeQKElOooz7V-Rdn3cWnqXG3gnzmzkEp89iVHHDILrS9ggcQ_9zX3Lw85E3T816GLPIGkF9PVmR7iInhaXQJpRduGMT2SZ76JijVyHcBxRXzS9EmakgJgJnHlsBdK2mtJ2RZSnkv_i9ZCB32qcvCLo1vYRDWEojUPxMW9-ZsLzNH8vzwcMQ-mwLCE_28WvDkj6BQ7u7Lr6snOmIYIhOsokd3WwJBdm8flqRSeD0vYv0C334eUVRCiydPJie20EiZ6BviATKKnYcGPjAxcnCKTxd6HQ1unFcnNDbBio8sWML1VoOdbIUwhtP68k9L30Nn9YVw2lhvKmuTdYUkEPo0y30tfKTG6mQpC49nFa6-ZU0RTBDlhIexz7tZabwNh6gdz9dJwk263hHgaIRCLEUZrTnImkzezKY-jnjNLY5v91mQFsGVbNh1fx2F6sUxvq-FytWjozQ-30bBax77gbnnCzw6o14ia7GNPcu1U9LdAwkYDy6fMVROdRAuz57i55ZvrYIut_04c3uq79GEQChSZt-GFRAb1OXUQzU2mnyg5MCCnTNmBUHs0N0MRPrSNWnJppJxb7xSu_J_XK2T7mZwPp3rpDbKLeaSjV80saj6yhim0Qb5KG095taFCUsO02vqzPKQV62eQsKVH6wBdsyIE4I1cXlmr3qeXM1UYTqKxMypBaO52MLkFzUwzU9tYr-3-PFOsRL5JV3j2qJFsPyL53hZTEnnIbpd3OSVupKcc46pOQg_pMzTHW-i5OFjidKMkFkKqqeA_Jewn_zq8G0BjSBHT679twi_fO89gwjyB_Yw5YY0gwoOemz9pdHRt9Xf_6iXIC5fPDixU4WYFAS17PYcrJLbH-mUgjW3U8stWfn0o8g3X024_S9IJe0B8aencLw5Pu8yXcnDEVt2MiUoXW1_6JC8NJObHjxI3X1cIBI-xHcXqU954tZb3gFxOPi0NPv3imInxJUrzk6nXXI4tL063RIm5a7kfnIIXcKuJVwEUpYCQE6uy2XqQUKInWUhPgXpMK6lJ2AgIeid3lfEkaUWyiVeiGuwmNlFvFnSYsVaYHV68TiE57jAlOjvp4CwE1mpJkr3QmHyn7PTqJPAo7RQ94TvIcjfC7Y6iNKMm3OK8Cunphf4P5AzsVWfWbf5TLmbgBBElAtC4X8cWPT8-eX-qRJroSkG7QNgMFKEoP-H2crbSnmHt-Tqnkrx6A5QE6QoOIR-ITNpX4zTI2B3906ooZU8ScjCOmbTeCe-2bNE1YLg_ipOxGDnyrt_tvblMpRz2NpHPbf3Kxv1SueilxQclZeuApG0PgtJtlCxMrO41Pylz2TG-2zvksfBWcPnNkhcSUkpI8zWVayS3Fyj4RIfWDP7C0XHtKi2X8Q_RDQhCnWXQmBrYPLYlseZTPi5rrAh38fYSTCxAxJHY4Qi18JO5dB010zWcQDtTLqJk9baY3vJk8hvDKUtX19yhyjPuYK6rEfiTAusrrzI08WFFMw_U-V4XxSDp6tviMPrNt33zRCXRvo7yXHLv65HRVwRyLj01mRvI952Tk6dfP25vqX2fPnpFrBmsmpqV5tmIp5Cc9_kaoyTqXU42t6rJ6iCYpYhUFkOopFUtT94oFYoAkgbi3smh15UCt_7m506NLF_zu7Y2AR78tJALWhPlcYI8UVUYf05G2SuVZeZKbyjcyDeX-i3HdX60ToJd-c4uC0Io7iF19Ds8Ecj6-z87-3AJphwqJW9_c0coI5Lc5Lp46Mkz389ToJiDMyT5JxDcfc_IuIB_rPRbVtL3uXVt--L2MamVnXzBh5kL0qjeH_bohNr0MBJ8v5uPw-r5oea3u9573XFemq1zSOg5Uy527ztPngMBR166h5If2qbY0iTf9a3PAX_4AeUfZl5GJVlbqZZsjpMj4y1pO-KRXsIUELaNP8aCoeGfeRy_sn9LeKjvFY7deuaj8sKfjjuk6DCPSeqeMNFmhpN4FfmxAmuHurUCTZ-_iEsIoLnuIAV1Q0KX61YoNmZtCTYB0Vsub8uRoxDsvHtReaTU78YDRUk_odOuJIl8IY6nDrz5Zs3jrNtbCwOxAvj5ITx-DY9tSzm94ICI9wOwDVm_9-tCcZmpK_1WKCXJABzj99UzFdrvkrgZI-io4N51NLtWemnPqIiP6Usjj7WITmy-NlTwPYVw-78yx50Uw_lYkC2w1UqxCwdqgk8eIGQeJLzo9QanEjHlZJdm8C2S-iQYXkL_D_fqId2Ihveg0aBfn9u-9g4m3_5bfW10EUtpM790p4obxNTx2IH85fFkG0cx6xxV4bPdjLck2UTkX-f4V1Oh4jOZUp7gFV1otFNV1J36Cmi5y2KvmfOq0fWAf1q-pmbI0yg2o30c-JW47gBhAxpW5v9o_3ZMb4FKeelM1f0_RZVGeagI592oJ22nzJLoYBvQzDS1UItw6hupBsoPiF9hMleC4_vtsHLPS0rjlpq39l0P6J9o-KuuwhChH41PGbq0bbctYXmyqfMD2fTreIeBKGby0WkdqIecbn1UW9TVlZLIi8KR002LlnBa69kQDmFYj_JD9UQihW-emhkwNqtAnQBb-m19IrBLggiv8HS9yB_q2sN0PD97yfimdM_4hpLeF7RgmcJ6_Xq0ODeyVkdCifhpqf39B78KBTxK7NSF75AEAOrPCHuqEvK7-5GfU8jhCvXHgHkmW9d2CujlGA7ThsJocuSmOCA5Eowlc7Z5L_yF0-SQ7yV5g_39wgC8emt1G-sIdA-1KG12eHz43xPVcWOE1LOE5_VNVKoKfvpPFraDTE6z1ITNxedWCJz9vQzsZVaXlEFD-aA-KBP8FuEFX2wAPXOLpIo_zqhzp_13NdqRXqzt8Ke_ZqgjE43MrWZmPAVNSgCxxlw8ZzB58FAmIHJLMS8JO09TFS5DVg-e3mDOWnvawidozCUG00vuZIHcEPbOOpJN3nK4tjUa03vGl_vtpJbEhDWwbEegbWmSyl93nHt-nYvuBCqDKB0n6boV0ltp3j18EVgDMkCTzx4n53YEM6bCNg_V6cJcCPR23ax_Vx1gDQrhNop-Z2YLTWJIyU0oTcuOKL5bhqtAZrXAp1TDlEjpt7ghe9ZiIJbL-YukDU2qMR_-avypKWEYTBlZoVhCtY_LHrAHMIcltgUhi-0nsCKagJpAh-2xFrNX-Uu2XpKQftJsp0JQeSkfD8z7SLWXJiiQjlkoqRIhYixOiX2aGgJGVmcLjoR2UmwRYCe0aNVM84QW5HUpKEmI6etwxgWDc8jkIkOZyP7lCEi0tIviePUjWOdv7tXMr-pLajW8bz0FAb7kRPatq_HTflWTU4BVSWaav8LmvHysQTWuJnXFlnfzj2aNlo_Nu0pX0guvHZMGPN0P2rkH89c-LqXnrQXbZrtCt7Jk_A17cODKGU--yvDdhXolX0vrfCoEGb8FTsDBkOIsRlYRXMV2Uyyrk62cEBBt6UMo_9w1JtsSFMQGD_6u9w7UDBJstBH7mFO4HcXtYAioyZ9obVZC6vjq5Hm69-wKH0_hF5peJnKcIYxt2CumCL6PLsyuiEJ21XlRyMqm4zibboMyvqv11EcPfhRROLTquxdnP2qjEV0tdUI5TOUKcKgz_F9HqkNAgjDOO62cnXt96J4intIdHbTCt_lNvU3kKVkX8J9ePt2rgA_pqzSwGj7_e4TLC6yU1A0CU7S8EfA4oLPli9YTSgcsck3Xu5AE4lOH7Y6v7v63Fj3PrQYzrMRzhzxNkKQVjP0PcC_yOT_shDHkAjbB1gvLl1P0BE-bU-CX6Bx5DzC-vmmsBCaacjxqtWY-BOzogsfRx2l5uneA3Vx4koxieMa1N5PZUrG_KGT1sGxtSMTMhTnEuZa0AVbfKnBWVgLOwkeSU0Bl536-BkXUo3KF9Ybfzrv57iy36R3i2-2JO7NZK4A_50_VXz4yYlXMdCsXY2-DmMqjqzlagaJY27vLhnmpHEjNGk0FfKOozs-4pVm84KLVPVXqYNyqX_qKOBrIYcL6ypZu8bWx03yiz7MBFG6ot3j-PWk8LO1bTkB3_YwHIq27PrY1qpd-tJF6U0qlQ78zeV0tgFvt-u6ILaKlkRyiJPYyabH28VvEa3wSaDTDJ90vUZ8lZ0kTVxx0OtczgK_KKshuulYTmlrdwEeukrfp5zPcNsPqIBYhJiAm-p2wFgfxPeWHtiqheVC4MYLdE_zj8jpSSjeGNw8Z7h0d7PHE8rieDkiAM5KENp4Pc4LIuK4s3EDcuYSCwULkZZqgGNOWamTDmuvv0ih8MyJBm6oOt8GqEEb2lFaWYZCQsIFZwzSUAkWFkwZ_JyiprFSvEXagi53OLX7jpeiVACyNrqo9JbpBKCID9WVMaAjrHrAHst3KvMnEZp9jECR7fhDLe_V-8RuCpX8A8rDqm2m_nYGTHTIe8hyKyCDiyI9RPMzslshN4TO4RK3c1Kp5ZDVGPPAdV7v0JY--VFzh11jyMZB3lB8LWFZZoVu2SQiJyr5nwv9r33kEsq9-PE7RKBbfkZPyeMzKzzfTbBmm4QS9QUMKwNEk6NRCJtBAno4cKJcE3C71IlTd5M6cLh5rgWo-BZ2fGuhKc02KgryLXPj8DfuveB9Vmum5iKw33Gexe0aDJz4Whxl-0aghCAafEufteUrFFTw8M-KH0KD9fiNiBW3nSfla6R_1s3WeyFxjc0ajrurAr4S3qKIN8HLp5STrXdmkDQSBvqXo-qNl7h3m9xMiHD9lKZYaRxXmjTkyjXC1CStEpvLc76JXoSJITKIogXtYbv6gmMxSBSRpsEGBDVYrq5pKVBrjhBBu1V-bD1GUx1GQ-CJ1y1nsYSltiKIAbtUALUCk5XGIYZk0DNEPFxs5HjljH_JFTeXwshxax4UBBwDCUym-lKjMBjsRo36ikaU9co5Ii3PaK0hQsyDob6OliRyN74Ou_UMAlDFuG3EDde4QVYUNsfePR8WlEirjKh5k2EI850hyuaHG3NIfcuXGhezJu5Dr7xHwQiBsO3lJwMRyZQ6bhgF7IGRmFDmdFywD_cr3_hXUL4RUUGeF0v3J6FmEucR2Cm6ILxg729giq6Rr39IbkoY9U0u-fJxZg5RpJewn3D8147pofjJibx9qiIfiCmKjfij9y8opzdC51pYM4VyOxdcZkti8bfDRPmQjcCAgvKPZp2geBUn9YqWfHw8ta86n1-CqV5qojtU5e3piTJiayrcEsWlMlESpV05POoivHuvigKPGXxocxn23RJ4X0wEVloc76hxhxbRalm2t_szlumUbOhAvX-aMo3RZJIBPLxZvOGZTrgFo5fjPrWr8qG2UPuNCQqFHQ7jOLVN0KMIUpTjqr8Jipa8L7wKFKmjjRbeYiKEQr-uh1UKsZzee4zhmFz6rHSoJ1WcUVfcQ5W4lx3RMqyiM89rZyhtRAtmxUEtzLij9-drApxNrrvm8E19OQo1iZ5neWBII32twUN4BcTLDLO4Q8Bsab0zKC3WqT0QrmhIgUxeehpLhKDJzYgogMRv9u6GiDDhMR6wdBYu7z5_8lmCAih6DxuDFAhyCWp-72RlKgBT4bIP1j1X5ZCF1y1wEd_1DUETMXkmsWuiyIVnyBb0dW3FMwhoahlwSGg1s-Q6xIx8A_4vBLi3zAYIQ9nyYRiv-xRCeUVUjk8iOucogMTy4PaJmqHgI4TCDRLq8pXYSZpM61COrIJS4UxmBhZg8hxJbovzAGSfCkIqDw1aEHzX1Fr7_QyefFrqA5oC8IUyd9dhkIAuhk7krwJfC3eacnciNsQjNnSXGrAccBF8Ip4z8u5hNR-ymAdYDbZW86ACZBl-h7BMngXA9d8FJYoE-7saUNcDc9UKnE7g5XDW76N5giGs0r3iS06eFi3s4fR8qSICLr3ffBf0MNkOGQjFZ_UiLbHJ-wNXfgJQRD7PYF8nXz2dgoJuKeXqnUyniPCoz2bdEAYUUzh1TJOgRfmo0HNED8CSrVr85nIw6nJiyKvso8NiNb5PRurduhJP1QBJn4CNfOuJ6EMSX61XB_ADJPt4jcyj_NlS62_sqUXLkoGjc0AfAPA_GZudnZH_KZAqMeK1Uf-65fwBJeUwzfv32lOM8vYxv5z5hLZ0O2Sh5CEKNrNqaC_UaQS3OmKhAB8LvPqHzqm9JEgTS3pk9BbQMWWy8XzkUvhMmOAIKW9PAVbZcuh8Xi_2Kha6co9OeemobF4AadiXXiEVVbPGWcgefWm5wT8xw-WfMJZ1D4QrQvgfJ43w78XllSE7tpfk0jWnXP1Xn_NcRY6mikvO0ckJ-wnfyVtg-YKiTPCG3GjJ9RS0xFx6wAnWjAhZkwLHfOVua_sqjjukB4qUAYdNhUkcdQt7NQ2j_pJOIBwGG8SYjECidXfVvqowFZRvv6vCK3rVGeuBC6GrNPGLeU_SYTTt5ikee8JmUTLwNV1TOi9bKWbKabqi3JZdkIKrSUsd7vsg2yFZCbGK8AnbF6svcdNINU0B4a0CRZsnl0ggr07_QqW6KG8bROXX6Hipz4vNKlxdUqpv0RvgvInTp7hoS26tIwO-OwhgpfsY4K-nR00gwJBcsYZtz02rVu9v9pbx9khgIf-DFfoCu1Tq6dwOg4kk5T5vAmGMXKUamb4xclYn8_q7jDFi7ayAbYtVGRjDbMc0S6KtqzTIqvqmwm-ztHjXmiOhZ_6PhISJBGO16j-fzYEy7UE0hV1rCRTK9JhgJQsxI7Ucnlh-pp0DcGLu4966GJZ4ot1QdekZZG4gp8R7sL3zeuto-FD5rV4YmqJBuEv-pcReH-EoJtTQqJ_M_W1vTFplH8tSh7D3mm_pt-ue5SSrDChu1XE8ZhuMipxmj7MqnMQ4rCItUvyFjpu7849nB7M4SgI60HdO2L4AOvJSWrXh5tzKYNZhpMPLk1sLAifVSksxZdqRbztDMTswyxL5KtPM-U6o-eRrWXQ1nCfZe2kcxt0x13CVtykXcJKf21_zjg1yKaSHW1cSGmMviYJ-Fy6jUDVQs5PJVgMUqHkFWVCJniswh7FwfhRwXCeqgsSGHTZ2SxHnBJiSq8PkZ_TUgQBem5Tbo0hqod_vE1fe5NmGJ38cT9CrhcEjSGMCfyjqH5Vx1Q1dBfnJvNfyjgiV0Kjp5B27UrzV3qsMI7n-6x4GJGw3cmOoUP7mY_C8KBMdT0w8tRRi9GaNAwmwvmNBznwQ7mpU5LkGTz8_GywK7HjevyEX9iNu_mqFbZcUXj3s6lQdICAYFh54ot6KUwRduftovjSgoYWkf1--4sm4qMfxhd_bH6NlMfMCJdGSC_tVj3Vmx6qo0PbjFeAB7SOm6ZQchoaZDXyRGKcRtvGHUBlGVXow0AWFwcAfmpa6Tmn373QITS2eFYhAHXzNrpLiinFPwifhZ1c-3DJ9GA6pD5v7E2_6Lw7zy-Rb7LyJYnnAJna2OZ7DUKekuQgJ10A6l_GVTB3tHacKt8Bnb_gvR2m7_auzBgjQ-U1kKQcH7iiyFASZUj-U4TjRKQCPpSYcClL-UwuScgnbCG7ufuno-WORA6E63vidBEfW6k1XWWacOenAwTThHQkoCndVTMOaqHYTfYJBgcCOiT3hf-Z2d3Ory8w-GqEpRXYF4hIMlXc7Y9zCz2qXpNCZxd6tmbtlEFivSR9Wp9IJmNiY3Lm7KmeCTcueXnHjPXwU2bazx9BFNlVQOg9q69be2vYSzbtOlc9YptVvm-TZml_ZrS732q7cqCRbyBntkA_mKdKDC7KZDfLknm-lHZE4EAbEPW3Q6AFS9yww8YhZ_YzTEziD4TUBNetydcVE0zz142n7REGNpM7NuEDKZbkf6WjOF3oma6p482VftJwCbuhdSG9xz95Wy3mkT7xwewW0cxWi9QQwBdXKkfPVWmaJmO7rsZqtDClhJVeMAqp20_pZFB9JrF1xz8BDdc65ZyCNT9PcYm9z30wFA6ysV0fCkK8lEcFjNAAL7sw2puE4hXfded9k6Ptz6fFVNF5ua5TCS1vPRGPf-BDU45K5HhmWsmJNm6VJnaNcoYmVr5J9oIv0eZPRmOO0be2mdCgQbboncbNXYuzfLP8Z-qQI56DtMlCoctV7mLyDpnE9u-lt8_U56P_nzs46hiaTKCDRD5NcBk4BhnnQ_89fHH77NY11SEfSGfvkQPToGWU9sd1mV9IKYKujnxKPjlhsgDJCi4areinrolfraWDMOnyFbSNEuJXRJNql6RfLRXFPteQvVNx4H3KhULBtbX-VVf_J20UHsb8FbBKq2sJkHQ_kYxh7fBd1RxULYJ_bxUwBLNZgqsQLJZmqwRAagrtsxbnBhNjXtMBhWfog1JOEijw0Zz8BdU6zmwyD--1DSNkK2d0srrmuTOalIsjEAjfNN810kMZAiElo8Px_J7Mk_zNi0-oZb3tap2IGQ69Iisp40xmOk-gyQ_YAWZQfdQJfsIZOq_RqyL0TrVz3D5S-En_BihqZd1Hot-ubxJ_LtXSaMilcn9_dMpfeJKJ7vkRcIWPjNlL68Dqyk6ysGdWpdzxG5YVAU538BK-V5LkD1vgDQm9rMv_qXbAT3KgYTE5d3VzJ5yQuIhp_R-LJtdd2dHk-UglNIO2epG0gP8X-SoZf3FdcIA-7IcnaoLLbCRjhUPWAqylQ9NwDLNGAAgJflD8AENH3UA0sgMt0hRz0cvNr_CLFCLKrEVtY-_O8QYrxth7GSoI5suYLiHG41EDeffwQJk_4dowgVEq2BLm2TEQyEGc0Oxf6Agdg-dV00Tl5ZGGTK_cKP_e49vSKU9ReaaRENHnXFN2JnIseesSynVgdThc2auxjth96i54KQ--jPl8oebuk6_c0BbQ2oa8-hpynfURDjujCuZ2TjcokkUr8K7F6-cVVCfgiDNteuqepPHtH2wqdtFJ0XlX-pvVIcIphwBCf6xqwTnQY_Og_ZN3DDt2s6ArPo14kWPjk2NCproSZJ2bpPUEfCxlaU6FUJQpeIjFHSwzp-gDuJDXM-lppr8kgb_ZUBUvA1fX3dHqZHXaoFrQoabzTIcs_o6Xuc1oohJX7M9tL9jFVjn4jxLS8qY9ul9Niqi2iUXov5HHFGHh6q0OJCZ87lozGWl_KX2DjDdUVEDopxQamXXW3aegHgTl-lHqUcbtIAvQiHDwjZhGNGDvGAymLo-hSlG8exDDdIecP688fGv2UiqmhHP7cJvip-puTTmNhUIAGflB8KOkbQAF2i8YOKkBfEeFJ54V5FnAWiYQv3Uu1YSJ02Vzh90vD2ZndyukFKQBYjmpVeqlWeGFpcpEff6dARROPAxBvIO32zAYPsEVVIYI81b8BbHWeqsUN21zrcJSIRj_l0AlORvpk881bhVVyYhw--WvXHvtLW7wpz9Ui0PZULXoXK6XDF3MSyYVrIgp56pJKLOZNVthMM_-SbgngfP5wacrSx8TeALAwKO0W6EJU_tcQvQgxJWCrgBeZ86WrgwQ3gNGrawIeB8meiYwy7yPby8vwtLbAq_Y0dLt2YVUtHMJmXqz3h0ppJDAoJF3nLnrNXQ0pmnkrtdpZnmcBXMl_ETD8vTtXrsq0qBbP-Lrb2KwHtsClfQjzLU6TDzJ4-k8T6dYTxWcBmBVbO_vnG2YF2uQwfvRBe__hVxhZj7ypdIdjTu9O9wO5MFoMZRvhM3xrLbHtt6oLc5u_IQGRVFvGMEYGhBmXhbTJbB_C70eyHWOTjMNaHfVVoev929MPtgmAo3bh0AED4spK_8E83VljQdIVl4WdQdMw7qKZzBsg1jBKtMKa1r3bsirIHu1x6IfDUZEKlwGPPZRyrrfrKvn0SWJg4wPsfrqhAKGlQSMpvZSX_1i8Nu-LgqA3oV3ZeeHxcmKMAK4SLD2EJKNjhdCtY1WGAFKacYGIOQSo6zTdxozJMR9M2qvQvmqesDLTMkyDmqe1gGdL1oH6Dmq3UCNOgzgeRQ2_tYIBBUKP_TYbZBnFrsnL9xeZqTQxCkWrN2Gcuz1TGuUUAXrlRa62fps73LaV1yXcHNedbxHsRhnBmx2zkF3eCanq0nCbhgVQekyR_USJRbm73Y2rGMgK0MrE_uu7D-3S53gbLygJY-AsRAJoX19G6tnuoctsamn0Tv_xxGrSV4NXuCLaUdjf2gwSFyqxTjcCnFgR7or-wZ5fXqBar3oNiCDOenY07k5Ft2YrqQIkOQ9j_fS9L-RivuJjQ4d3UfvyFjVi6FAILiVqpHfyBtT135BXURzLtmhhCnHT0Wj0oiYRB0gu1aCq8DK7OU7nE9Q86K1SL4Lqo67IZVnAKEjrBHrWZQB7wKLJoTkiGzbEVdEef6Su_dthXFwizQgQI9UsAbLOhdL1sdjG11d6t_KA1ljIODPHN9AAiW4vLM8V5guXhx6pdIlEnchWWl2S2QOh56z0szwdBGq1a1cQapqQL6NwZvi3Gyr_nT3Cn-t1a7mSt7L11hM2RONLxIwB3Q7zUFwkG5WGU_I9uCmsMPH36AtHq42Hdzz6Khac80UkQcV_qjL46mDQ6OtuCnZKUJUufv9ICcC4BBHfkHFimZzcOFl6EiGaDnbdSH3vr1f6M7ONQbdZXLDrBnwu5M9Uq6MbV8jixEh5JIUb1HDcFbyAH8iGLd7dRAFmpgRwQQV3ZBlOs2BSdp6GR26hFNb8PbcB_cL3ibiTmda0umxYpj8cGoAxC_yYcj-zF0kjrvcfk-jSDMveslk4zz6btsgHtU7JKmOjwMelJ_P-aPKg39boQn_r1XgbBEsZSjQFKiIVltgfuU3-Pv7H5Gm_pQJ8BeF4svsFjJFBfdNfH0yz8AWl8Rp_nIbjmHyE96xw-TBsJEdByEtwIqSy1FEpSTStOpmK5rrhWeNmudIKZTVCqFUf6txBrwZgNqbGSC3mbvwrNMIFCew4o-ezq-A4Gb2CUdCmDSeEpGw3D8C9__8lIRQkKCdt6Hykg7u6z-kEhGVajqjSua6QvEolpGu6LUwTs1zlwrf1JCuKqT6u_LfCG7XacSULhdW0Zv13RHlfAkBqAVU0VlweSEP7h7WySpBEwjM229NOEj55-FWMKb_se08CIhcODs7T5MQ5ny2MssGC5vVYCiqzGdbG14KF_fvPrEcE5gyA7SaHb8Sru9t8jvXTm0OTgQJfSNCvlJtj_4NnEMZiPkLEFWij6RJWWifKEwjdQQjSKyEcny7rbv0LkXPdS6jm4Gwf-sDXJf9AKMIgv34bBgw8HVAjkOTlHUbneRedvVPwhdYrF9HxfFKSff-Gg49h810bTudRId0vTZoQ0N0OkikcKxLB16xukAHWxlxtE1uMC9av0nAESNM_NJY13neifMm5RpIazg_YuixrJxIoqiKBR07VNwoH-0RzRVJjYPs_KnSrGEjHlMJ58GqtmZllXvvlobI24n20q49Sok5dotkmLiG7z8_KihU1tYtnoWG5SxVMi05eTodHfvU1fG4fxkSwmjpcVd3nXg45d7wcGDwzeyRh4hSwlCuxwuDz_P-EApZnswi2xoCVVznKfnasnJ0K3c0MCDVRfo4YnAkjUrW-yumCoafLWtS_QnlYc3hlH9CPfjpefFAOyE1boyHyokOCizh5FH4r-XFFv44C1BnAGmktclx9M2lwr13GXbmC5wQjXHuFchNxsNNS8Vpv2UNATRaUYD9qtADx4bnZ5ccm-KqChfDECCHZe_Zxr_fQaEI0EX0qC9oMe5tm5vHBoL0nRnz7GyHk8z-Qo3ZRjoL-_ujxifI8RSMLtTM5cSzpdWtNmOwkgKpf8mjnfzWubwbwlbLMxryjT1Td7hq6ftaThyhhKibbzYjfcpHGVKs7CEA6dR_3A0Lsh59UBiOd4n8006R3wRAoHwVZxW23jIK4DnJ5VpUJ9pk4eFQkYj-R69FRw2MIIaIyVvGPFGWDVxb1B8D7daMarvkWp87Tr9QiGMn8p3jkCeKJau7FNOYYvKvTfqnuppLpZw0j9UohlWOF0uwnw3Up19KkEkrE3Z9od9K_y7lE_rESl_tt49v9QCKP2FyMr2nRM-3vE133Vm193shsQe7VUw4U9K_Cn62ZtWmL6g1H8qRKOAbwP6-m2SNS-cmHmwjPn1xUnYLO7t6VF2ecmN3JNhyEumvm31U7RXB6CVNw9SPUUf8mcXi64j-OnCyeyJgR-AdghM_QbqXSmxTaZp9NKo2znVAAbdYTA0sEG7mELz9mXpnXnUO4E85sCvm-jqBQhuxK5kN1nzFScAbU7sx87jl9bO6wbvMX_nZqtrp078aK0nC0KJqQ09IvJF6zPn3q3kbYdRf6nOVQUOV0YPih4aqyTpdlNnp8Q7zAMaYWIsbeh35LxAncjNHE32I21Q2JKZKbQALxP8ZzqxRulXV8s932TxLj1GCQOJnw5nN-vjOKtDNBM9JoS82wvuBpUOltzv8dxVZoLRlyZ3hukczKJ0jMsuT9UC10QMTe1qQi83ZjbC8ACqzZhcRY4OvPv9kS2Uyrh4AMt1BMpUHcFpbosfHQycWRNx2svdemszwQnwhXGC4X-3uJrji-42dQmaB3niNWYRSeaa4YTK7znTenPG61R_rqVOYNjCHHDb0WspHBBU3L_pg1KoSrEOHHODGBBCAwXYh7GJileFEtAIhc23FMacF6T4NJjwLhJ4dBwJqkLUlnfwbCxQcTGqboM6ee3KsSJKfnWy_CZAA4bm5l31yOEOTpXVrrmOnemK_A2Z7G7WDpncNN6K4wlh-caKHZUsyOSUfxzqCIUMlnRFLwO-x7IdjdpLJg0LW-D_IgfWzp2fK_eVbOM-vSDCsS89-CRGda74pl-kTw9DhrdsWTeoTclucVyuihKlQD8xG-vwVzcEYcTSqffP9NvbPYsxgTnX9CRBZeBqgBMi_e5CsX-g2kzb8R8woy71P3psT8SnpUrJw7SIfrLTgB-9ljZIAx0OUkSVnlteg8GlEUYQtUBBPb9Vrr73w3ytg-1cHzoEcH_OAiXqlnwjyMutWu7VKNO3S72BbuujQ2RILNyKbcG9IFWFNQui0ZOKd4nRC67wFSw_cXiJq8WT3Oft9PW-9qLcTnjK6YC9-6DgJ70SgvAVoblUzX6WNaqhNeVpIcNEpt0NpGsIKUwLj9a8tkJla-MRPkUwB-VxbkI_IsLFU7SMsEkWOoSoR6w3gaILucJKHWYj1mY5z8TuDi3L0UdbsV-AGlnE-gnwJh-hSAgw-DibxR_S8QzRMHnbic9ZajGYjJz-L-Q2RH-dEK7gQnp0NeqaUdEP9DiTlTH4uIZMNueRHMcSi-altZo7PozPnYnCwrWuCKrs-n8RlKYHqQj5wqKuBnRjJwrSM1RhBui2Lsr_Tj-Cv9MOtwX7hWvy2ViLO301PTL41ZwOGeTXA7dBoJ2phSU-cxkzq0zTzesTsvadrOhVaUzgidLv_7IxdYEqaPSPA0SMd5QT7PXusQRvA8iZYmRI465sTEicRZk6Zib-WDrDuviEGBNL6MxJZbXH_ttQzodoRVUTkvcccI32CGjmTrBI8Mr7JDwirZoKggxQi6N7LYc56qHTPhilkyYzu2Oiti_HATmPcTx0o6lXZxk-EKkCkLFzgtjUvcGwfxlkxuMOZ9yussN_vTH6ZkO1wuY1YzEwD-eGey8yrfWqsvxSoYVFvn9fn53csuCTEQxD6b0eF-6t6O8QbY9-58bDxvEJ0wnEEPkU1XXrYHj4xDLLW-g6_tpcCcBiqR-81H8JBs5_oWzcZQ7MIcJavIBUMF1rvGOx6WxTU6jZs2FR9r_Tk3hwxRRn3M6V2kOZtYoBJBEA1-YWmlQoB5vfk_xm2pjkH9GY11EvIW9CB0VvTm-KW8I5_1S-zS6EpQVouEEOGdgPIgnj3nvSlBv8a7RL50sNwEByqrXc2EZ09yjI5qKvCaxx6wAF_nnk_eXWm0Q5oR3x7dgrbhXAgHafHPSa9dkGhwCVyKQhsUoc5rUjQqRfKfiefF2xtjoa0Tyac5Djfcpz5Wzs_Uu8OK2rvmvcR4Qi0K8krkixwsSXoDc863YuB1rjzRKlx8ywlAxJZZrYWB2eNCO277iwf2y2UAkOGLLY1X-tX7eMXqdIe_zO1_OzuLgDH15_aKX6LjgYDgSCwoSxh6P4swx1avoDyl2ME6KrWBe4I99ORLh83PwuVWBpulAmfXWOTgUuJYuljBwK1VrWvyBworKU2mTcDAvCTB0I0nhnecCtzXN7QHHJCEzGECLDkF5-6Da1hrOi22HGItMa4_GG81T6WRlqbecDOKccwYQrlalqtrO-XfRZjfx4dwC4UXODQu_EYRJykZ7siTiRV3fH_T220g7L3K7ahUR8qVsotPSSRSzB32Q4WOwY1egUhW57feSjmY6-NZmKiAJp4GxpaCOhp8zAyFkUBaCKiuF0BI6r14Ccif4e-0ye8SWrfJuy8fjTdTp_CR33S9OHKcEBkSFKWszidZSXut7NDNa-KiOiPLckDdXEHl-WLCPlGx8F2umnP_Rmee54YoCj_l5SVNxvsSu2DPssyifOPP0Dzvu1mkWIPDIDSctjoTrLuZ3zdev1nlRXx-naLgGiM_O6lsNDTZh0quZ61GoG0-gikQp66W9fiSeEOMB3BKQ262qMZDZ7NROQyTLPWaX6ev23fxxXIJB9fbm_ZDIGBKhSlLEerJcYFWDJA1WuepNZVtfVX4FeQBh5mNnOTFi8uLJMFT9DcM6MFYSZqy9pYu5pMff1CitFmN61zloDXJ4CYJgwZSK3rhVWAxo-cJtBrA8equcrcY142yuu9d5vrxHktF73DaO6vvZeZoiG4hZ9PGrjgQgjWveDzo59Y1Ac4baqdMqh7Evjq3tyzRCnXitRuHLhbUtCD8cy7RNjcOPVKsg6xtIV3a80Gkc2w_wmVgfsUgWfB7ju-WWE41bwlU3qf0b6R2IhMrOU2O__4oXKz7Y_U-xGU34bwy63I9TAbmi5H_CZ-IE2WNPli8cOI1LdMfkdw2ZEmu9MMy-pzngXaK1g02HDeRo5f6Fe3ko4ulD5HhN0Wg9CA2pG1hJSc78yJyCiYrY-8dPzQWiRH9H-5jiL9T_Ne_12LJBRKPkfM20ZW-DGocLNp-aWUoujI7wEAypGH2miYkfpTioejK2Tk_H3A_Uv8BgH2fM8awKM7EhemvmprW-zDT9n5lIaQDdUbZIn9Wk6vGzi96eoSEgvamJZCh2ZpkfVUxU7Od8wg0m7_CcHbhxG1W0o65NSD8ExhmBt5mfS6iE_a3GCXUwG5pB9Collmt0OAeJ6GWhR_1ChYw_KKyEl6shSEOor62JP7YcyqVuzZcn_cej7rbczTGkCj8yxuoyviR33nN-CJ-TXQU1rr-1EjuqaLSIe_BCIy9uCDW4b_bnjl71fJmXYZhV6SVkUdycudmIilTHvlZ7xF4C8RwnGgqlRJFU69b7Ic0uQ4C2wh2l322U4VuWx_TF8hHKM4mc8GphNr5EpW15ft-CnUOgDrf8ZkaTOc5L84jGNTtxQvetNAlEnR6HmIr3DWc9mV9UIskZHvR7uKsks8zVnUfLZ9r1WZl0K-L8ayrQx_38A0ZmhdGABcYmWMfOLPsPsPooOVLLqG29q_4WhUFN8aDiP7WkKBdK_9FsbM0jwGZbdUBqt0_zQbjDgG-JL83rKJp4cqDEYqIWNkostjZAz9bcBZL9kb0yMNJdiP3eNdf2Wumm1GuYAZURRIgczx5s9vclDoXlwmPJmJb-_6dVqi14W96Z1eoUi9PWW1AEv5cuV9CJn4Lh3m9tEDmsQb7V5m_jGwG1SSAG5WfergvQ32J93pWC4b-L-wn8Lt9QDtYWsDV1XLaCb21nWbQuwjsHIgGXshssXsu0xJEkMUOxfLF2j6wxC8TI4G-flJS0zeVkxL6qzq5O5LMJb7spu3pjCoiIRg96-TD02lwWMNmTgdtNY3DtH-VpcxqDSEvlOwq2cFQbnVpaITC_OQ_tKZ62mKumI1ZKJlJpCTAc6JyW-Xl82yloXoUp14IoEjbraEXfXYRdCXjd05qPpQorT9Pt6f5n0eiwzh1uYSQh9CDypLQC6fi4EgIgI2zYFM4jbB6PfcQxSCS4wkV2O20LL8fU4qXu-ENNB82sMVi7LEga2fs0MHCQW00rEK5YgE4f88XEqYrEFLj8kuygBI1biMeLfZFGxyaxo0CyoOiF4C8zIcEGh7JF6XjzqU3Re1vnEv9jA1pMmqr-uw0tAc14z9ouAd5s__kKnHl-4YUcxGBANRqWhjvwNljKzKJJTzzV6l3TedaIPJgi7yLfQsK2e0qW41dY9p0EcR9GqZdFt0PKUfVqA7eYCvjvX6KLZD7t13U4VXU2mJEyr-F_XSLrVpKjatqCuiSuoSEA6RDxLUOspNhkJcEHSZHOoHnnxCeV04swMWda10YM5qkmnse2_xxJ33rpQzCKeyF50RHmtwU40GGLEFVgu_uixRmGHNNitiAkSABpn3UGNmwmv3nCduZuhmHOcrHbTBBXqk1FmXRSLlE7WU27HAs5MYMBboD2Smspwp9wj3LlZkBICaIU_tz6yEWRIU39TLZ10eo5BEr7flBDhx30WcSWwq4vYqs90btwbPQFUf_HwWa5FG6QOVkSnLpGbknxgfHZmLH-LPqPUC8qaMUhIOJVn64cLWyXE4Tw0oMXEvm9emhGF1I01CERiazUiF2GI8ZvtAmVaYxGKxHEfJkd_7nKrke6lIIneqg69HPx-sa1ERhwAcRuKVUUsrQowK3wytjFE7ZPd59oxWn_7UjlRQInr8kK3w0exyIqzhLJ38aP3YWIhMNW1GrqldzPUxuuGC_oPmv1VrW1QWjeaTCcJNUfQ7nnsE2-CusoFOf4uhyYtXm7aE1TgRJiqkFYlXJcsq4fZl1toGOSml36TyNBOu3-kTivC9OGsfC49lJYzrOPJhAPJ01U77z3KYxUAu6MVfyZj5LL3pX9o2jFym-VuGm0magNd714BE1nWy5z9_xe-fZkpwrOR9KpDwdJ71jG7B86uv4pFtOgK4JWW0a2c1ZJf-YpxktmJslEf0aU9jcZbXrXPUROEHITTEhzxWvdAH56_39h48TQ1_4kJK6ygv9VRj38Oor7dO9YP7jcVFDqieQrQYmhazFQ3pTZJCC8GP_NkpbXZA8YUwpcUlha74pH5IXO_6EvM-OZSOxaaW_SowW9D0McoReyINzwwNL2bA74w-SIpLO63zzomZnp7iKO9DDfHEG0p25LbnIKp6pxay10xThsr6lvEZsCDIvJ52yEwsH_5YZ7KdMEvhuCvjud5q9iK7is7ek2I73gcEON4VAmlGccmm6oawGfR5EzlVNEVdXNfeLkRXc_TyY5uYmNrIZRC1dQwL7cOqNm5LxNhs4c7CnV_4uuBs_o08ClKZqb3Rm8nuXan-4Fpe43Ikh6-JpGaaMa7X8BwoQXvNwj5AAxVFZhI5YQHdCSH2HABRuByZibPaYEbfC9MnyxmMhQmhOpQXqVZg68pdpsvh36gnzS-NgHl3bT0laFcp4ifOsba3F4fDgSY7tKUbfl_NxcM9Ru_rB5VIWxJKRAbIEFWzEE1eh0fHp6A5DjLLBYpKOBUGs_W6bE_sqjbvVXi77zJXwtIm1S0hHNBmA2_i6WcX2SFildXas5ilb70U6AMS_f57XQhGOKoSk41AQd3SHbCbQeufTGSDS12E2RPSDBlxgqqo11wcuHlF5Wefn2B-tQ0Dg74e_sns3qel9kI2-6IVZXcV14Z3wyqnbwdRqzbmIOyZJ1sm6hcsHRdnB04FLXh43qrCFnkLs_JOFpco6Ix_WMb7_sBAwBpsyIaJDzmSy3VDvLEEsHpbCwICM1MaSvpT8r2Y59XKFSKS3umFgSqD3gvb0ELxyH17ZZAKIUHQBqNaALlXKt_Ag563dXWCGzbqjkMBl9ts1MwCWkd5rjnUVGF77WetD0ZhArvgVk4YM2Qv82PP8G4FGdSBYihsDIKADqXPxC23vluZZwvZmfiZGLkf2jVdrLzrU4m_XUQZ98HkPRDNL3RPr9Euw4RtoIWbjCs5gHRxATnHqfjm4C1hKOU4ck89AAw3LKQU0hLbTU9trH9Aq4-QLCe4Tth4US6H-wFxUTlhA4XQq-LXxzEJIk7yetY5YBIfO86H8au5qjAbhlhmnSuyP6dVp_sK2uNjhGssz5_X5WVpO1nq-ktRDrFjbn7Fef7NIv3dUY6Y37E-ZeQf6x86OvyOTnQUeIaVhBYJlx-VH0sE6Umd_8N20WuFJ2IazrEKe7Vr850vcwalBpSL-vIKAowbKSMleeyJW-66qQd5xoW_C2Dl7YD7P2QB67yH7ZLQFKYCsz7Q8-r8TGBTjDHggESjt4uOEEBgHZjqg7M865wA2AhScq8VmGlMOW-AOJaocCrN4EmS8ZvOl6KflWkcvdZ5Qlyz_PeS425OMj7CmR_voQMEBSqeLMa1jXihQIdOn7Hlz9HqbkLpf45mPCt9lEEqJ2w_-2C3z3llyvu_RJRA84cau2AMWOqkYR62PjYYoz1NJSovgnyQQVgPe_TZJETbkrY_aKjkWFAA85kqVBUYyChXaeqfUdikV6yzDubogwRRbwxF136MSkmFRAvziZwAj3TuvLK1qFX15Hbzn9pjzKFBF5ho5PqN--6X2Y-55KMqVKNIPP2IXAJun-oPYG2N_2-Dy3yp-ZM1oub3acjPAe6dO-P0flTOdTsQ4Y0JiNKPbnbcLloA8VJjVocRXuczhHHGO_bz-q2ajpkEu4Esppfz0NF8Wi__ySfVgGvx3i5JGOyaJNQhC6TglEu8vF0mtrwpcoMFF3KqfMQ2X80ekxQM5i_h0C1QU--cWwI6mUCVKqKJEGyKsHCy8Mhr-0NwOwilgWnxghUjRDU9hppQYIDN8iiLq3SMFgSuLcp6O65YLzNivBedmLHxCzUonx0YmvaNIpvXUyPCxFDe-BjW9Wc8L3vNqWBUoCVI0zHUCdDOLPqmCJlVWbxw7zLKG1zfNlPp9udE3zX_wlnr4Q27DxB2MdQ4onqGUDGfNeFN_jW7XbjPM0O6a6yQ700AjMdvcbcjYNezdhvdPu2VO9TaXs2cArslKvwsbTP-zeNP4M5FdNZT1xfgpxnThl2c0Nwmx8m-Tv0fBMdPKc5bGadxbOWDOOLSTRO6A1FOR7mX_lBH1U6qrK6YOsYrwwcaxhX_3FoRCeOWsEeQaTpLZ_dUH7AkfmwlvYPPKg0G2fupyrOnNZLzkb5dduAvDYBOoksEAe6fYRwVWVVlr5Rl-s-gn9vIlbSPq7Xc5bg2sw-_GlsJTTI2c67YDdUgC3DWdvFI6JiD5nyDDA_jik3n9WSFPvK0PImlYnBxO4XG7_i1G9nffNGgjIgav5LkAHxutBzAZCzkXUr8-RZNJV9MyQcvWEoel6g_wFcOmU0XLqyqhA3nlqvA_n5Rz4l9y14FWQjbwWHYCnHRowel_77b1t6_6662Cb2LCgrqjDWjmfP76SPlklULnCbhhmaCpYaLMFVWN_DDMXFnHPm5TzrnoLfrUi9oQTuOeQdgA_-7D7ovRvOgNU38DPac5LHdidwvGwoSFNWFp6et0aObdQe3rZ7NYCMTgAGFcj98T2zIokEdvAjKogkyHtzeksaEsG08BzLQB8mMiW70MIG9huG-onD_B27ENt-KiADuy81pODD0br23hX9GL3Hkazz1Q11HU8WgBPfFbEt-m900Szo54iYkDP7GKk3bw8dZQcjM1lqUrc030w1bEaz4qC8S7MnVcbe3VXNunPy6NHHI7mcNbrCyeGgYxo_4_DZx3TBeRnAwrZjxjWqWBFPUhyLKTrVO5gJoR-n0KtkqRoUdTXM32K7ECKxQR6EvpxKY8tsIusLUF9bLzfX2yea6ePBEJe4RFZ5h4aGXO8Rxp2b3PvKoP67hopheDTfxFDdWG2DO2bseOFD7sCXycJ-q3KitF0XcLMwZYIVVPSWVp2yVoc4e-qAHNMzszV9dYmvdjLSmqsaG58CK4JTZO2OOVZY-Ais-UHxa0ALRLf7FdAPzjH3pofCvR4UYdrdQxMa7qaiOyJB3RKfDFeIAOk_KalqRpdGXP06ekGorbyFjosm5WPYrAXlgZ9u_gUKpQUY3ubJQI2iV2N_QhfAPoeo4IjDg4Ed8Sx7ld7BRfMpQLVcYSByGSQviD3rW9Spc6zutc97g3_xBGHmF0Am7AObJep2s9oR35_1d3sxCv_YBzjCnt87YIF5TRboRPqSXNrOLTqU0MqUkRVamGh07bHwse9jiGurK_rwf2uokpwHg7wuPBa0mxuy7URgWQWWotKG6XKfLx_Qk5nArwcn6LlkEcRhyIfB6fdqNmoHf6CR32o_koT4SoCjbfT11inmrrKhgz6wbC7Zy4Q5qTGijKLVe9_161W_rgP7a5OZiimbxrunLlSHCSZDUW3VxLJMY5p5F3X6VrvvvzJ1kj3f1oTcQKC9EsLgrWvypCwNmO4XFG0icYFWsHBLnMPr-K3t3m2vWQZ9loBWWp-xPGWUf_D8z30y1vIh5iNcc4GMOqwyKZGY3WvS6jMbVQgCbMkL4QX4u8aIrNRxI3IH_MUoTZ2FAlgl6T6MhMwgNsYlvndymkY_P5e3KlC9FBA47XbCCb3J-7pWJGYVs1WxCj22Ob7Y5UELMt8Sm5ygkDWRjMWkUkl06Lv4ZB3lNIO0K_XkVSySPgE2kubFlMJIObRBzL5X7-lL4NGx64w8JrIwDFppc-vkCq3_20IlBC0sTFwZA1kKu6xOFovmTgN60_HOmwqQ4Cchs6oGiAd7F3V2c7ZZY4k3kZLAvp23mIvYMi_QclgBm0xkRfSvKIjq3RL4Ogb7JpehLlQPXGz4kAxHuC1O3dXVaPjK7zffIh7aIsZqqrq3B34BjEKOn25P2wyOPnMZ4SFOTTv7oe0rzZZZ1VqSxu88IaitOj6vC4JkaEUWQ33Xs1ZfYLsH-NuqKWetS1QwNzW6K1DbjNXTIdVxvQoECbBZkb21ey8_plhgWzkOv5DKbGmW70fFOtKjNFzXwZGsfFrRcwZF6YYfYHPqFFs_P8SFVe9-k-VOXpux1kvOOpA9nBCdfFLmbX6O2_7PO9zjcQRMrxaW-YdU0PE-sG6CtXaSWGjSQKY1LWQsux0wepH9KlG6lCZ6srxJ1FKhrPKN9gms5ytLoZIbqpRIy7ojSVvG-tOUA4VWkz7eAXSUvsDw6YutoO2X6zqI5EOQSscTKKskWNz1d3COFgFZYxz-r2hs4CLEph8qiQqri0w1EqWQ5OoZj0pxH6Pwo2qSN2mHLCEWzA1WcddNJMqNr5lf_8TcuhQnQa3L_hPaq7hycswqBxUHfYP-_pTSeRldT3rlNPWaY9QR7cnjz0EfPUtzZEUCv-RaGyQPET7O2U98HWSwl7TRWmEeQEBMAndEBoCQ4RIbW9A3UCE3feFtpAVB9iss8QU2i_wFXC-3t-RmQGTB2bqsl464ytbSH1pdToXAZPbumXsqN2FyK7fuU-JSCy7Om9D4aMvoUCPbFIbQwq_1VaOizUFrjZ16y2KoYV9ZioTklCew1xP_bgxe5QgWqh87rCTSxSOHnbkTwohN_ieSB796pcUe75ZihPF29nH9ixGvtyRvLVIT01ikheEvO9O0-XzHcUjqQbIbXridMI0j70ydBjl5s1HaPSzKjKKe_A7g1K5YZP73aYleNiW5h0CIjZEbj2uPTkL2Fi9INUtRuW8w0H4wLbq1bk82xf_43nfgVvAdc4N0UM17PsZ31Iiva6AQqGnNZzOKLEAoUDHG3PVdZLO1SuVQwlZvNOcoNjoUIo78Jz1F11LFpz1_CrN-U1Yfv_75RBGMb7v4vUw6bE_AbM2hQSbJso3BQXh_4kOFW2J7olDVaxuA641Q58CJFk6vAH_zelp5HGoptLjrQFOyok2LSInSMBJ9SPGvXy_pqQHmrUUnjsBQgB1GDlYazHGLsx0g378l04PRO7tUycC7iCJTRbJTejXHhKnhnO9RqypB8pAxsPiddhCGCG160V1bJ87QZKq2pHGE6EKuGy_pcbJgSURucQxES8qYcqhkfyOF5xIj7MwHYQwNS9nh2e3mEfmED5ETFZU_sbteclZX7_HDkVjdjWg6u23dUG4LjorQtMZo4bRFXRVoEQn7u68Dl13bv3YcyHd075HyM0SwH28XMA6NznejSvy83g5XMEUcB6NfjktGC11TXvgOeDjfVSFdE_SJqAUGvok6Qd47cJkJcgDebXsTha6mGyWbRrAjAWD23gplwdrTp7eDcjZjWIkR9hEAMaW7DH3fobY4M3OVqCluQ2hRKl-fOzoLrcEEBFUff4ULn-5uMOm4aLe8QP4H--tjC820Hsal7lo2k-9WQINAqpGqriLhe5VeUcBWyxWGAaWA2uPQ1N2afJ-vq0lkzZBsfgjcKuMH7IkhIlEgqemSax19DuCJXjJJzBjKEQ6BGdExTDfz8OcwaNs-zP3IR3QFztg1aWHMARoSaDhKhzfFXBfa7NhIadmRTI5Nrw7MJu6u8jyZVFMJ_xHs9yk-GdudsA4Fu6MiHjgHkO-nf1-spfRsoB04rtqNCN4Q1e1Xe6MA56qq53OrhELKIrUGu8So3-U2VVlQ4VaSayyrK-l-U3gq1Jt1FuYwf9QCCzvM4tBp15HcS668BEzxODeVCkErYuz1eXYTDQ4znbhMB6TnOhjQmhmdSovoavwxz3cftRnJjmVuxxQPq9cjHYSDeODkoFcG32srbMq1lLU1ZGRzP1-P7GkHND6x87QmWtMnqbE-akTCjnjpQXZ3NGIOCAT72Ubl03BtD6TUhFzh2Mhgw7lfrp813sjBa99ROhkj7bC__UpO6FZlq0eDMohJ8yYERP1FJm8Q5mRMKN9zkV4tNAfhSusFrkulfbU1vSOoDL32c7sRSkS38s_aUFYHdKO6kpEKoeXFrk6cRU21OVgclXHKWhKp6xXjc9tqB469PnMD1h3z8beY5zbRlL940riV_TK4YmWIN5cGRCKp9nhkS63RMS7F84XTSFLkxv0VBAdn_5U77FDGrzmqq5Sp0zCbus7aI-E2Ys_3wgTPdD1U32LB67ifVG43WmPvuITSooW32vAYdLjhDDAv6bsUAuqegjqKCkT8Br1TA0JMKOxtkwGrhNqM1LJFxE1Fa-TI7t15jk9_HP2Kr2Fa-XkP4CQMo4MHWBS3brIQC8f30ig6Ceo3c_o7lh_779ybGvdQJy4FF770bWqqfkkgytlywqrDwaKi4rr8Qg1MszGkF26vluZYa3IHYXEeVJmmqvFb5OdB2Dicdq_KHpQLNcauipnMPsL_j4veo8k77l4vBHuyeD8B_lImxArmpAFW5Voe5JsaYNk5joROIkijLy6t-laOPY50cA7T2DBMkWA-_PAa-9DEeJlLxf1Q8dC5tO8L1DzXzpmZSrMinIs0lmnOgk93bsAjFvZFeMK32T0MWsWNRXDDc0S3fpiYx5zZYFd_gsaX_6qykRDu51F28aIJLPy0QBA250uZ1QaweQz14cT1I2UbPmALaagTZjzD74G1mKKKsjaeDbiV1QXWzsn3jGiSTUuHC8E_i-HaltYWeBBDDeZvJp3-R4xZytqVhTzIUdeJiQzPTaWMsflM8z1NPcwv2f6tuhAgPYAvKMmdCK8suyQuNqPgtd4N1wDGaBFMaVrW_-ZgsOnDTkTUfbAzV-gaUMNVkpR1ZpAVQvWJJ0ilUHxxnoOVxVd3cvIOyMU5HVEcI3ls7P7KdWFgQk4uMx3tcV9YNMZToo0EKjs_keg8Wn3zi-3z5x1S25x7oVE4Vd-krsxux51t0Oys9hlrwHSmy0-r-q4Z27nLMdMplkpoEemuiR9PcNU6_yNB7r7eQk7rMNwd9J6Whk0ClvkfjJ3uQbAQQ2jxosafB4FD8F9EMj2hcSYYM_Zc-Jnr1YqC6cWeGQraSvWOrelpilt8scCCUaqNyuar3QCvMm3nPZHaV2qsVmsOaRFBxj6V4cDHa2dJyBaZrvXjYrsh-Im9k7l9cDC1ZQRC_eny-M7NFst1xslP4GbM3fjZE9YZC-I4UG40a1r_959IpO3QAsWCJTYFSvh1CSoUHrtiCbYxcPf94rg3HgvNE8FRvQMzbIg31CBZcM--ZTWsOwbLPndsvwkyO2xEduVtWsIzMzq8H9HuqITY1hEspZX6k4YZLMB1dLrVz79ulEe_0lv5W0vDUxVhMgxQlb-hq8TmEI_tZJ7PSHGTbyKoL3-wz6K842q4sUGsZHdgr-Td6T3MQSRi2GgKR2ABXo3Hc_XDEzsi6rHC5GdV6z90zMGk3w_DNQdqf7A2oGg_jeD4OSKPZhmdpewSY4997kNk_VQ-efWSNJ4c2umFGAcWJHre-1yxsKkljaU6GzyTSlQji7-f_1f4qeoJMXenfcchZJ-hysA3_l1oPnssSRT5c9XhUg18e4EENHe-kDtE3800ncUGOt8Pdvw3kkAZv_N6XPjMwZHEFnGbquZDqqVetBgASsf42HAHz8ULziPxULi3_h7_amC8F8z70HY2WJ3TiwltiFssnsel9THYOSoahRBrNJWSs7mzSRNFwxqVUlGFicprijVPVrgxrQA7USwIEceJJBnYjDdnBZecMhUuZeJCFX31k1DzZ6Ul89NkNRdD_G5_Pujgrz_po9y8ijRZ1h71QrP6rjs-4t8YLvcrqyAUOP_PsH9D7zLwUYfNXn1nTYONIAz45U7SiwySU3-5OM91qWtgBQWuk7k_3zPW6bUTGxV567PQTmV1P7r1gpSSAPs1mKhLY0IRI_NloL9q3kc7xCN0BVaCsu73AFu1bxrNl1_hiPky3eUtQZc25Pas9kf7niO5oZIOJlskLoDeZDqsmfrc8sikLqTB-T9n9bUnxjT7MDoOKXOfYEMIXBD-YWDvYE8vdVszjCNzX4JYiD_nycOuzHcaIBVRRHfLorE97B9lRCcXNrIzzU0BlwS5Nz6A28vj1av7TJ-WHbpqOBrMlpDfQGin42O7Xe-p91wAiNxSLvMrFx0kjtm2GUYxNTn1E7Cn7hcyirWglX7Xg0JmZRMNKJDgfn3ycSIsq3AL23Ncj7cnZDP0nBqZe_GBc9b0SYRfigcrN6sNeKM6sjHjqnEIJyi5VALEwaVeaNtlqj_DnfJKYdSQgT3p0_kz6yekRWLrC62ZP-A-ZshN5zy_nND65dgk752uC_YoNIdD0HtAnlbeotegnfZouEKQrXnyvPJXjvHfPENHKmAULl3sNMPA_rG9mG9ZlAuBR6ckYy96X-TguB-bmM67ZOVb9e2Hh5BGxjJdM21_krPKJCB8dYzYhafs_t0dvUZA5i7jrcI34G0i4oGYq9qO2QhyW1Nk6mbG2mC2s7nGfaEsulpKf36Zx2QASZ4UvRG20oI_tHbP6nHlfnjde3RLRO4jZDqUMECwOfquW6o9kD9XcMi2nTNaK3cmmALLifOji89L_QN2Vmh-jbQf-Ql9rMO9_lJkFJtAtji5mMHE5bRc2IgR2b4rEPsIQaecGX_DBXuDShb5H2m4CbjomYPex2ElERqywElxmIsgJkchRKBhjOR7xDUXgC0mewdb3OUJDJabc6MkEJzvpZb2vX2F-lljr9dIzXeE-8OT2gob5I6Cnx5bDZPlxU8V7GEPoC9y_2MugYOqaanZ_FO0nv6kBODirzrAQQCtASxT0pqM0kr35yWiM0HBsd-z4Y8V9hhwPxK6cM9AjfnmekVensj87MozySt0z_GUi12PWE3eVcCVNXkjIY-7t2BdIrGJJFLcV8QOLChyyb9wxN5mIrAZEC42u9F3X043MX9F8c69LfMsJ3Kx2I9Z7-M8VvebTZixeT80exkNBNBIcp4yu5B9wDQebsNl8N4gLJa3DlMAVhlzgT-KyWvGaxsDmKz0Fm5ysNab2rIzXDYG0a9jNRDdJ0ITM08UmUMYbKrhXh4sNMMtz5WX_Lh97g1kN8i2WJXBvUrS2_ofjSwGkTu5qqNuyYx_Heo2Wx67_rUCB_HI6oN-aBzgzYVBj4KZQ-P3UcWMcqWCZtPMPEr6Kn2XmE3bX4iwl25Kv_vBHPeld1g7sZM_mGZHcmOTxvq0GeWnac2tlgitXeNHz79-6keO7ydlGuUtUbSnoR21vVYSgyYsQBGTCx0hmPLsZAlKfMnqCfPBxlj1-4jfFO39ZfSjQgVXpTCGEberXepJGmLfpnCwZIaChrtJUgVFQ-tw0mhtR-_SBVUaGhKS4xvx38LT_12SCjpQJ71evHyMkBVeWlB49cRpy6M3uQ-AUWpvrB26ZkfidWFu2-b-kuJvnBY1b85fDbcOzWyVUkqfTD8-HO_OcXs4jBvJV8qKHqnLp1K889loP1bAFkM9esXmVfnyTTckzrAYviloUDuTUjyY__4u5asSbyHUaFD60Z_P7Cnn6DQal4uIM-1DQfkQmPhMSRQed8MMoJrLfIRxTagvSsRBE6irH57c7tGN6UR5LD4-fljr_itOvDcAFqNJ8D_Vqpm4LIPnOd1iululov8cEHqMUzV7g5pOZ8zP6SUmZ4F2h2FuV5nuOwlkBbLDD8BB-buU94ad-jzQth9QuTgZ7HIlH760Ulx9hIaPK2kdWuGGJElS3VJrR0UE6ciPgna4Qvqa_o_7FKhoMZgoCyyBkV8PGHquwhRzd3D6sSlVrn3Tvcu81Zu2pFQQPmPwNPKlJYYLzpLH8d1t4pG-Ni3qucazYgh0GywGgNL02UaiRcOMe49bTBYEGfGSShjng4CqJKk44l6SMpb9HQLsODXArHfIvhwE1hYuY5sIqOnRH40Ja2Em9Casxe1-WRLAMSwj9Iy-Jkg6A3DkLvqbE_-3t5pblhRVA4TwnVNCSL15a4prQUuKW3jzFlA2qceuVT659IN03TMkrr8SVzKOO4V3fXUU-xpNmYoWuezF3CYDwYnVdYg0wOljNcRoqHcKETO3_0AQqkG3UUSBsZ-lCDOF1LxNN7SW1rvF6l_VBf7FaL2ug0wCQyXXMNo47sgPHMkkRP_gPOfl-pS1Oy8sK68_1xlrVPZQCWvGoKKTHIpnsdj7mblEIjzU2XUgIVwsgAXGBP2UxSTvVi5LE3PAnU4d6X8k-ZFarUJDgQXsYGeX5hzqx5MGkzI-grnyKJ3FieCoymyL2zleT6VISH6kFPqqEi_K1E4HDis4RJHdXHlRH5N02zZdooLCwFVdJpirG9MdJLuSKStHFxYvx-88xjklcoSbbUhYDbDp10feuPc9DlENIeeLU6dJ3drK3esLNxW03OHiV-8LBCxGY-hN2J38f0ar7oPHEkGXFm9DD_10dM3Wyjh0leZtorFvpA7njwWQhz3igkTBvUmSz_yDx2w03w6sOQSQ_nDxCazJGJx5iMtFn3fVx1ZVavhmIHKBmMWFXztlGdyqySpMGNi_fhqU5GoW2n8ZXA49cHOEe5ImDc1ZrpxkJXizkYs3KBl2AHjnQl1KVY_RqgGnyqPC9RyahyJ1A1ImpT-wC05owPguM95lOREKVBBmUJcxjJQvgR-XXQrual_CTsFoE41JynPoXl0Gm_IyfcNaUvQahM5SVnnimcufkVnOpNZtUjccT89ljrP3znSA4_mQswVO7-PCEv3oRxVQclLFRxHKJ8G4x4dBNedbzgaPq4-UoOegUCsjpQuLcnaQtXmvzcEyysgYizI0A_bhL2V6tc9RnvzDtD2DHq45knO4K4BY8a0Z5FjdSDKg-cMr-1Sb8eTd9mTOleci8Hzy5XAHTHECZb7J1mBQWcIO_yPf6BJj3AOcl07Kq0gPhvggcxNnQRhujlnNJKrlkGXmfQAYopYk9lONjxRCTw6_2mmL3uIwTYAfX7CfP1Lz1mKi3IeBjyqcdTuJkHPO6GzzLT7097ueIGeiUc399kWQ02AffLIA5pJrvtIEwL0A-Jl0MgcLW6xyOlcWlbgbOeIcHV3GzU9m6etPdaDWBb-jrfQ56agR2O7h07TXBplDDzzTeIcVMzTK9LftlajjO7S6TCO2Eot7Q80eZ-F_5YtFMEdTumU1pqocipoXYEnU6tzgsQdUk_mZCoCltuU3dtqF8T7iBrSwD30dVDOEaOLaSLtltqKoQE3R2iwRbEkGws151i-9_dLrzBf8UNeaLOSpM3VRtERzIrNCCub-Jhv5zWX50qAiZf2REME", + "ciphertextSha256": "d7364b82b18e1eb36110f8bf494aeafbd168efb4ec042de385a374587e5561d8" + } + }, + { + "name": "unicode-1", + "testOnly": true, + "passwordInput": "测试A9-Interoperability!", + "passwordNfc": "测试A9-Interoperability!", + "passwordUtf8Hex": "e6b58be8af9541392d496e7465726f7065726162696c69747921", + "derivedKeyHex": "5028db15c37d656a05bcdb0766193a7f69e470b8c05cf37f2c5ec3aa0e204670", + "aadUtf8Hex": "5b226f70656e6c6573732d636c6f75642d73796e63222c312c22736e617073686f74222c223132333435222c2262653430366361352d333766612d346232362d396139612d373463316161643438656165222c2237323530643162642d356133652d343262302d393164632d646630636230363038323231222c2239303037313939323534373430393932222c2239303037313939323534373430393933222c2265316438633332652d643230392d346535362d383733352d626336366238363834633731222c22736e617073686f74222c312c226172676f6e3269642d786368616368613230706f6c79313330352d7631222c226172676f6e326964222c31392c36353533362c332c342c2241514944424155474277674a4367734d445134504541222c22786368616368613230706f6c79313330352d69657466222c226a736f6e2d70616436346b2d7631225d", + "plaintextJsonUtf8Hex": "7b22736368656d6156657273696f6e223a312c226578706f727465644174223a22323032362d30392d32335430303a30303a30305a222c22736f75726365446576696365223a7b226964223a22666978747572652d646576696365222c226f73223a2274657374222c2261726368223a2274657374222c2261707056657273696f6e223a22302e312e30227d2c22646f63756d656e7473223a5b7b226964223a226d61696e222c226b696e64223a22707265666572656e636573222c22736368656d6156657273696f6e223a312c2276616c7565223a7b226c616e6775616765223a227a682d434e222c227465737454657874223a22e4ba91e5908ce6ada520636166c3a9227d7d5d2c22746f6d6273746f6e6573223a5b5d7d", + "recoveredJson": { + "schemaVersion": 1, + "exportedAt": "2026-09-23T00:00:00Z", + "sourceDevice": { + "id": "fixture-device", + "os": "test", + "arch": "test", + "appVersion": "0.1.0" + }, + "documents": [ + { + "id": "main", + "kind": "preferences", + "schemaVersion": 1, + "value": { + "language": "zh-CN", + "testText": "云同步 café" + } + } + ], + "tombstones": [] + }, + "padding": { + "pattern": "index-mod-251", + "length": 65250, + "sha256": "736a3902822ea691871b34455e9081075270deca4ce13a7c4da362121c02353c" + }, + "snapshot": { + "protocolVersion": 1, + "payloadSchemaVersion": 1, + "ownerGithubId": "12345", + "vaultId": "be406ca5-37fa-4b26-9a9a-74c1aad48eae", + "keyId": "7250d1bd-5a3e-42b0-91dc-df0cb0608221", + "baseRevision": "9007199254740992", + "revision": "9007199254740993", + "operationId": "e1d8c32e-d209-4e56-8735-bc66b8684c71", + "kind": "snapshot", + "cryptoProfile": "argon2id-xchacha20poly1305-v1", + "kdf": { + "name": "argon2id", + "version": 19, + "memoryKiB": 65536, + "iterations": 3, + "parallelism": 4, + "salt": "AQIDBAUGBwgJCgsMDQ4PEA" + }, + "aead": "xchacha20poly1305-ietf", + "codec": "json-pad64k-v1", + "nonce": "AQIDBAUGBwgJCgsMDQ4PEBESExQVFhcY", + "ciphertext": "YIcww_6TeRxLreX4T3PWdQpYU6WzuUJhw4TCptzA9WtjRAu5C4XMiIGzASdcn-fev9kjthyLMLdUNutRSunjJnrIRDhQgC2D6hPDM9drcWCuWoliCmDJGOFSA5AQsCJaw0_0Z50yENh2-f9GfTiqduYvX-KZKgiCC5uSImT7gqlEqR9Jx58tr-rqiw1T4YCcDC_SfzB51jBX8ROiZR4W51uXLSLmGkdJmEnO_5sLUxhWE5QnZj_TN5ud2t6BFenXMzzi_pRBic8No87qSXDfpxcSY6jJ0A17w0zI0uWjgmZG39lixaDiVvWZx-X_LXgtC4pNZJeECyRKUzW__itZanEEwDAAjbZD9zLul7bQkJ279HBQ5Zkd-L1BxbempMf6J3Z7witUJvHJE3pxoA0DdiDS1HquOM6q9qoas3R1yfhzGFqG1UyXkwRVyi1_wpf-ZELIr0EOtDFC3Ie_llId3t0U5VCFYKgcW3BxBlEBy7qao5PZUbhpgIQxFvhJhL-rjTVekNalsZzVFqOCq-HHPbeJQ_gciv7Vb8MptTB8rY02gtxJ0cYGHpTmDmje2Uo4qQrAvwmP_6IhWDUGo0Lxkfj0haRsSEDCA7kA41lZs34Ttr3tmfkUqww9MOpT-_SVw0K5AdlsthQeNiZvjixj5refF0PTQR2yOTUbSea0Z31U67wvgxfQUtIWX2H9_TFzLXh6P7X1r4zP8e9f2V2ZKQdMOsF_1XKzkofft8C-siViEwzb2HGbVR7IR5BD6YtLvsqiW7jZVJedeET3u4SV1vKOp07xvj6EW_9OSuAhfZboraBnf-6KgErgK7z3DztAIJicnaCUEpisRi1l7_wd7P45mZx6gZ8azIulYkVx7sXHgt-37Z0DUmHDna6iyK_96OZkgm0rmAcgpdyLNemKjkJO2cSyHdBSZgAhkb3lM0GcgTBtCPuO_VRMPgf_tz1TKQ1OfWdoTq3n4ZuW3RVrzY1-UTgp4RQcHv9JeeBXkYF-h98yKFwNPvLdb5YJfbP5VdWKsFx4hECMsM3LyMVqE7LN4pSNq0w96mlixGef69ToPzPZaZSHHI5fxupEmlRjX8SMiUR1tjoP-5Z7SjpM-o7SpbPd0Z4EHzT0JE2o2LTa3ZZ2GjGlX3Ft6cQih4fxMzaVLepSn5D7HbIjKeIzCrv3KiJDijjJ0kEpE4Ko_Lx7mGUPCUZz1p9VIOk64MfwkwvGl9vGn0woL3RIRgBVckfxNVSYUEmY3oGkEg8RPbhEHusgkJotftRj-xkfnNOXH9U7UXrxsU1QfFcKvdyxYcZvXnmIayvFDUg-1n6L3sODqv0L--HnsJ5Iogv3BTLYVpj_Q-hErKcy6K0rqKRQRBjwPVPoAVHGylU5zwTtMemoVd7mOwehdDsmM19MHHgRXmxb0qT7vZU3mCTTqTI6UeDtrw-2sHolWOdoe7f9-mVkeVjPOEkHIalTtkLSUozHQ3d1POfAFFQPhFdez6keFm_iTlRNxhTHuxBf4O4a6gTdFQHaJave9xrSy_W3hdGh4EwxzC2IJZFRijQ7YNYxa44YJO9y_bLhEM_Co7yczI4AopIogMkYJ-B_jPr1EmG--74KKE3pmhe4cdBpeI0DXaWPiYWg8NFvAY2qPnCprmQ0wM74iAlpvDcD8eLRKpgjl71s-NUgMnIWJHk0_SNpbyRHXrRL85rBCqqJyPGNy0b8mtyhPwW2djyKU5auCYEs91gAEf3YLPuRz9sH9UmI8ZR18cNGj0vrYu7Ul5o4O2DAcfR4A4GRYMxc7aNmQGvMLMi5D7pcFelmPbMLq2hf1tda1Y8Uu90GMiOBK9Ccy3-sp0ebYLlacSH90HOmBHMhfqRLDc8Rk1e0VajLB5Tbfl-bvNG55ViXt37rCcK4JXxBO8FFdYD2Paq86NneSbWkFC8PLd3fIX8p3RZAsaeLOTzfO7NABhLLQpEFAVuE69sZ-LN95Q7TI9sOM-HrAcRb-UelUEG98RJjOY6aD6PyKy0ATkXp_pUSBjcbPkYZa-cHtNlMbvDnx6FN7yl9Y6CAoqjdA0xm_VsMp-qcVgF98TC1T-ddnhLua1pupLP986tye39F9YzmqgydkO4zbWh-fRuZXA_Bqx4M2VuSpsKCp0n2htNOcWS9KFPfAkWzeYr7U5q0eGtt4xchjYni9CFERV6el5gZa1gDS4uNLVuxY5qewuxiPFfNiAOvdPCjgQ5X2UKSxYbh9e5f-tSMZgsySly1DQSRGhKVZJLG3ETlfDh62L4c1TsXanf7o-cDuHOLzHm15IDOD-JFLRz2dwA1KUCtEflnhhggyHoaoeAoHQAsgK74WlFObqQ3rNAiu4FZqs7xp_jpc5H4F5Mp5NhNrzW-r5ElFv7P9r_irdvXWm81LIfCPrJ5d5c-XB61hCvuyO5ALW6dRb26912QVqiiDwG8zZrPgCrkero16LGhk4oNQxg8GAlLckSEk-OgauUatEq7I3AqTXy503ACatBbLutSbR593g2PcgaPei6r-SepbkmJOm8h2SU4bJwvOa1ARyGDQnuat_No-bN2FXp9kMc-bI8qj_U9m8aYrNhYdzDpmLStdGHjfy4hdU5lEr0BnTF9fnNLrd8rXrg7JfPuOvKYNVdfRs-o6iS9nrpoYIbVIgiKeQNKPdjM4UlxrI13spLiPwgVEdzE5QeGI_pNXM9bdEsOFiMaMMsWt-j6AxKg6Q8SQjNdY6a9mEc8BCAEzw2dYus9AsyLsgwOnCt_nS71NnOEv95H1TE1C3_NmoInpuRrplz7Km6_Az_4On812MJ_fu6Mf2M4nOh8Oxj4am9-0iRecDzWDmMsLwvFLvZBX1W0D85-stMDoRH3ycQA5YxCEHcjzlo-PHYqdtDHcc5K_KHRoQ5SbFiQAebV0GTFc1fooUiux4U-9Le1MtAuqOQwpGd4idSX7XephPGj54hOzUYJUlvAw58VIgsGLyt_dC79BRJllq8PcTiD3FwbOn3PdLsuxhIjY5yfncGZfjSTsEXLhaFowmfTwQ65SqS7mLtjBbZJh4-GcI3GB8KffAclisY2KNUB1ybvss4Z2R_IZLq-D7KYFiBVL4AUG94915NF9M1NEwt7kB6oCXsSE8Q6rjSHc2GD9elbe8dxFYwUiP5BmFAh4QUJQGfcbPUKjb4hvfF_cIfL7yeVVTCPwikvchLlwf0qdZTZn0K1qQU4iEIi8TjhgdKmrLjmlNqEKMPsog6RmqbBSe9dx7FJZzA2OC1u6om4La0LKy60jec_y1xxTKXpVjqN2iAg5_8YjYWLeYZESUHY-52i20801DcotvUiK20l099dlB_DP8RwMSYBInx7bljpnumrxRoaATkm3YqYsmZAbA7-PwkEYm7pJpCsWPQPre156mXin_7qbNG3Mzf1AxEk5GOVeZugAAFfsYFRCSKFoyoboG3TCUbUjc2udZG3krJ8iycwXqjBMx4iFtdgryxgKdLVNDnlWGXhKZkxAwtjqS7Nhulpb4oUPKt7OQZYp2J4NPGpPgXIvDHRjqQzCzkN05HqK1KMiUn1-aWO6aDNQW6oUzj_r2yfOL0g7NwdACDKG05ZIjS1_sbYQs5ufclkecsigAqx7-QsCe3g-0-aURiFSQbfgnOwGCa4sb4FmFR8DplPUjGeZw17ydWfmLG8KFs_sPaHSKNXV7nukohlSPUKElxnF_crSRBPHzdwCGnnyloqSVJy6NAIZ69FLd0I28exmN95dk9nIBHKL0FaX3xhOKa_MmkWKyw-rF2D93D7Ur6mQnSdO3Br6ixkKD2MUGVa-DGQ1SVwNK2H6oEdcWDlM4YaK2uDuNVMEkEgtP-PJBL1ib5cOI4ro0lI4v341QKI7L92IqLBfzz6gjhNvULMu5_dVdtHvaDQbJJnKf1JZ0ZdzpYEaoVzEFkpgy7-mPDGaPsYDoCExOKsZDL7lBdCbXZennrLIMUZeROgm9qXN75S0FKfqTMfSCJopbzZBI1Rk2kxYdl-_ybiXgUuqd2XM2GnEO1fmx_JeEsB3ndb368qP92XCbZN7DuyFHZQTMXnWkZH0HEYdrmofC0ptOA1wMWRHKGit0BsVrE6zNe1um3LnQ6HwJgM64UqroJrGBk0mn282L494rvK6QiuZjeB3eXPOwpIT_kG1AR_UdQKmM5feGLWMPlT0trz7F2mpk5sUoVjVJy95LASnCesHBE3dtvU43kCReAjHSLK_HvyQpW8MXEmnIbeEtHiKSKS4xh4To6MhrOnWqDrx8jmhzIiybMZGoZtLMnhhJxWSd_I6SdzgFMLrRqYdlY7D98yz07HmkncOf0aZEj_joAVVG1EXU3hAL01pV5EUBj2iKB3ElzPYzwlJylLo9HV3X-idgcghGpGGcp1gvAIQJCwkZU124WRZReMEG0vSb3xbVNb3XtSCNclwCOgkbSTqaXvzszVgxwXRpn8YbBHW7upN3QK8ADBGf6hje5WSio7TKAqplCBCXgPmtVeRRMIGd2EqyYXFAwjmyZ_IAlFRKVYq6-yu6YV_ZTB4Xf7nRzBlhhXCAGodjvF0r3u4bwBZsYAkv6IWLna9dl7kGjU67id-0Bdawz53XmmdPRVnJkEODCDYvFymOkwNsQKKKKARQ7UWyT7Ho71WibvWCnmz1Y3P5K4QBLzkW-zPzGihyPWd0a7YjbnrBKjDmiZBJPGk8B7jXSifWzkv3_A45eVzKjuuKj2VKU9bklZb-B2JGunscS_O2PXKw2wdXYszVVf9a2pcXkVT6G7s9Y6_69mnVkQYUiT2iKA4tvkO5YBQApfWP7NCbdSgwUR_z7MgE58CFirijDFKYcj8Tc-UYpGPCxCgKx2jeEKHDhB37qZt8Fm2veA8OdmwcmbjvnY7F5NCgw7R2zhHMfSYJHWNhX4bXrvuZvG8X0ODA8zlf1y8-JanfVcJ9QG9DsPOOuMY1ct_i0q-pIWPhgENFfbgaKQ_pvPnXgHBnbTJn0-wazM9wLTXu20qi8jOd7McwwbHOLd6QU9E6cAygRcYyUOZCvVo5zJphwoog6GFKI0uWE5rBNzb3dFhMSv8xf7DYKvD9BbUzv33PPicNxi3vfTHf5qpq-Ac4aKh9qA75dT4Qyh8VevKJnka3eMnN4o6X4d0IWHlyvoz-W_3KHFofHNPSpGEe2vsUyY_LttY6RwKclhb3PYVgMQTzrxjzWiyA54osp1Uvptua8bSTPakiXYuUPBsHMcsxSuRZnQfOuvdwyVEAiQQcf92fg_dPxZBUkuDkc8MLWbSMgJaMIgSN_UlFuAGyel0tBFnvUyKzZADE_3QKE-aa0YEz0WKh6a4thCP657Ohcg3RevMXPyCux2vLUhvwRNeLV5gffAOaBtskf8i6HWSb27sSAo9xMp-BJF_nGtQXX-Jpcx01ciqIWTiAVqI1hTt6wFUdmUTBA3dAOJCAI8Dr3ZxYHQ9UYLXoPx3dNtrm2Gggge-XTJXOZUfRDmb86sBoAkditWEnoILxX-ud2Y46vH21UKIybkrZ6N0pUFZRUTCULjqt0dYDRktiDpl7RBLiyrWvIyA8M5dwIKmST_t49_rQ2tAg8W4BZ1LaJynjPmu0pyeClV3httAuJJ4YZyhPzTvdIKBVNlURHBvG3-uwQv5gpqBpmXxRALopDvCKOMzfjRqntBpz4vXMWL05cKejPJB00Ob6LiGvtpCA60_4WEz8_zJxfZuxbT5nLbRma5sUyL6rBmD56wmW4aF06n6cJrdMh7y22dXSMe8gLgsC1TZU7XwIOibZ4sBjk8lx_xgoX8j-GNqYwN9fq8l8MfvoFvT_XwvWuW8XF6_3WnxxhdXgKGvZeg02QHZgLuo60DPcc_dyg1n2oHcWxIs3Vh6TZsyWyNb22uREVuqDtLJ5b6cQT7MQEU1Ksb4b6lpz5QA_fz1aLWnDLOqY6qyDLGXSADR55tllC2t6V0XeSWmfLIl8sjF9DnmfJBDuzoP-jLmGI0RdlZoKKM11yjZDrqVhAXh9hBOPTQR2ji9gWlOYYc9F6rJTJttgdx5u02HKulbuqyqy6SVPMJSyjAHPZ_j6qaAXoYLY9qejFhTeW3GE2RBMJP0KrUJsqvA4vHnrFVZYck4ozEvzSYJz7laippuxn0jpe4gjybFTg_WcU3588nS1CBFrHBjNnIhQ4vzlfpR8vfeI6wRFRwXUeA9xwBEg0-YQB0acBvra6jp2_8aZ1_S1Tgtz6PlBKjeXzBJo_kv3zvqR9CS4IRijT-x2zlU0hZUHOJjtmy7tt9-9B4Y9Wub6gtvtYtMo627zPuNwZZkosOucyYnJhJ_qzZ1UDLEz8h1P0xNFbkz-itS_X4FEPMEQMHycY55cnobeuSZ2AlxaJsHmioiSmCHl9axSTk1ETBP0tc0u1LvPI6upnfwwFf9hrEI97Fu3nJjI3xXysvk3eBCNSnC-AxK132HzfLygr26CDzkpgflLkU3uB97Nmg3s-UQzV-_NUtphEIkq_sYhxrz749tLMz-ySbspD2NkHS8QOek0m8FgekQYvPHkdfMVSfBXG4ZGARdC0r5a0rResHs9GM5H1djpJ3kVDJf2FW8irgcbmAMvGaFzEUhL1PDEA5358KiXYdzMNFdG8dp0b153Z6ZX6nKZm6n9MO9c66dWmrU_Z0fBZ3UPe3enGuKdGJx3Up4ZR3cpj8zO-LmNsVdi_YB98XYOZmera_Ci7l2Oq2qeFrUchIdZKv5QnCLBbsE0vuRouU72aLk0E_u4NJo4l5xeEzvYALhJjSjRLwju7WXMxynYXlZoBW-gVPu3MrDQS7A4Lj2Nf0s_JlvUishoaXNc53RYtwzRmJ-UREzhEz_102846sbF0xto7TYsxu-Wvp5eZ5tdCqfS5CYk1MrRDAndL9mhf0G_Go5vj17WDu-OHMimK5cmBn-nDqpZYVMPjJJ0nsLDmiKpexbwLPmxPCOfalSA_Pl_CY7lKpqQdzamigZHkDLdlLQzaZ_pqPjW65ijgvRpKCJa4oK-ZC8sDUitZb7r-GzwwZfiSDUhMv7skQT3svY5QvIroqOOATM6tmoDTTNPenMskp1ZCau_HXqO_OtRJO3D_SFjCw9hCBDeW4_qAPWkfacTAipih7qcrkkSxfas-tvsH2dUB7xDHtDb26IViVH-WH9hHKOKIsUd_7_cG2ErW-bsYody5pimMoCP1Mm5iu6phZtH6q2z409rV_eqv-p3o0foBs4QGcKNHOHYsAyHpPU6DaVHFz-dmD0CG3huoHb3l1Ly_N4V4Kk1IgSi2UpglO_KOKKnsL3bkBYwafyPhXlnTzSXA3IA8hWeGPoJNFsra1jwSKeNPT9wvhyz-UCuqmuvIsi8yCjmHfZk3ODw5FIStieVS0iDws16kjV_mnVgOGskn4R0ZkIAvh96LjJZaayDBL6oUoBeYPXCU_xPE9_OlNiNdcL_Z8UY23FG4v9iw5xpuplHqGQkMj82I01cowSyu27FbmTOpWyaPiF_MTckmj_Hky-oarnXF1faLZeazQrPd4eYdL6WOq2_FmCYLNhgfakjz5y-Hw7blGUrepHJiN1yj4nDw4ut13DaKSZKRsKLUBCY8JjHV2DuOwu6gEqnFVn6lDkfH_ItbjYiFZZMoDaMzodC4wNLNEwAsWjpmxel9avirxJZMXC0ZXVSsyp9wU4lZCXNsTto7hA_9rJDdVU9VuyTV5XL3NoFcZ_oX5jKFLOVP5wiDR_oy_YPW-5YJAHx5vEpUdqGSiCxbfGYGCIHiuDT5fgmuhHy0_td5H2EAPOmgmwePpDaiv1SQnE3YXzqhZWCyVWvKH9ra5NIlG9wEzedtL1k6gFRaJcFDpOpbKqG7KVzdqh3UieO9neqb6-6ZcpHckCpU7Z3r1qKd_AXbSx8RkmXTGuJdHGkIISeSqwHTCPFL7GeHftM7W7RFiz8r5X6WdNsCrc0RVaLVvpqS_3-Ld2CFi7k-pBfukWVl6RyJAnnSfo1L8F-clYSw2Ko9uZqaYNb4lgZlkRSmfpTdnzIHcJpjcP-XLIylEAZg1US5KWdHjZkOmeGMqLMKqvyyMnB-GBipVpUEV64lyz-x3DDuMu-kdWuJ8j2S2IV4M57Kda9DZuwT0IWM-pKGJT74k1l2TEOHtENcOYaoXEQTO900kxf6VB0Bocy65tAFOn3BoI5mRDtLe5gx-5cfztmtv6_bwwcZz-kN7AfToULmPzdxmdi96v9GNq0DZqYTY9qXaeTTZwTxq5orzYfDEDowm4VsgtKghkv8P0kvodxMcdnsnyvHAaJ53hjfvpGFWPcIl3NnKYTsV6ga_uVeU49lKOEFZdopMEtRI5_HCWY_71wG0V-JJKbEA7AsIgF3oCvmCeZvIJtPPFNQKvjm0fwea0kVhf3pmoXi5fU61TRqO6_Iw5URjRvU-4tgph341zyEg13cTrSRONpJWeD_PqtVEECGoizRN9Z2aplrIeoFxFd6QJorMXnKH5lCp7v3jhnl8bL4Mj8mknYQKagbSUxmumEDM_LVhLWWTQNCaRNp8zpArCLVy8z989X4uCMVyKo5cBPxOtZagcUfIGllyp1kYBy6bXtYLlX46l_Mvv9hKOorRhlXblZ7Ml7CAPtxyTM477mWxMGIF-w0s0K_Ll6Y3ASSdDcC4SsGXA2TvL4zGUmmHsbanXeOsclAKFzD9Clhw1ANsQeXExp-jOhVXZLlVl5UGPCor_F-rbUk5kYXVh6SalE3Q7u5wEIL6oAIQDriE-jy5r3K41OeFxxVxisvvyeCBxZ96FGmlM5fQH0CRle6Mp4a-J3YLl4BwAEyjjLcrr7w2X4y-ZcrlY01tIFBFCzhNAMu_MQnXNq1j6a7aRK9PKns90NZG_h0YN-qfWQ60J7vvVJCJ85aU0NY4gj8Fz7SBJdEVO4nmMbMs-uZgEjGmeCzVmoVVw3usnW7h67SKKjLsbV4Bgt1dLQCBh-MpDAX4RnBQSkfAJ234zkyWTWydvIXyJCQaZtLNLutUoYb5teHbwTJTyh3yi-9mqOM4noIK0rKbez2JF2_mdGJioKf1khdTSlP-rTK9jJsHMJnpi6Q5h8dSC1q6z5xQL6DQzEjrMKeqb3-v9XN5dw--StPAj02hZ15bBqlZqvkWUMNc5hSlZS6Q2vNKwkvUMmDjqJ9XoDSW_W7Woh4iruIzBjZr92PsIGrsAjPiIyBXQd9F887XqacwEIB2DSxHnFRWRvtGgIFhb_aVHacuUQ7yy2nYN4GrHdku_knu3zBarQv1zj5ofqOxzz5vLi-EkNXpxUvE_0Z4WCC6uY4geY72xRzhuMd1F3mP13h27GIEPpIo0j5QhHcb5QBQtyDg_c8STPnYYeLa6H7nG_7VNR6IHC72sTldCgqXcrm6ewL_6BrKKxsutEypmBp4HmG2IZ8UbLmN_VNE-zg-6SotjIkjwmi5SlU1nxpSgJTYpzq9yxzqmpKeiSZQgtklBWwzPxOV5ODP0p-u_fuifTlFBhNH0Wye7P7qWS5_ykj1sqFbRSzEw_DhZrFFtgaKm9jL5ItSwRPtswvViKk0-4z9I3Wco6qh002zDZ8_V0zem9r7qRK5nGMIQJrlaSo2wWZUizMMOXESWB3JmwZPF9C5hzOGkXGSHDaVaTWPbRrJyc-P5onRRXu7uOfEHdmQ3UfcDf15NKmO8ID_wHRKOEQZPww22k4V59HQlO8d_9ddv2se2nLoFSXgrc6Ekj_F_LRsWuuVo8QZ-mXorQTXHJX_JrZiTKhNm7VgIYFzxgIVWTeO0KzReI_n882yA9pKhYERY04mqLze5HiTyjmfXQXyS5yoF22OUGIKB_cdpebVQ-QQuDYZyfkQNXbtRqxHVKaTYTcAwCaUBZ3xyGmFAOsJOOiRFx1UIE__QY0NAKYBkPska23Mg9D1sqi7QDO36IAa7gfp25uau_e5IM6UyCU3C7g_Mis0UyDnwMELq4V0hA6pzK9wSzJISNtW_ck0pqasrji-0kPWpJKMVPhOvp2HaXcqdFwDwi1jJRRLor-m3EW6MG9Vgiul-S2kpvKJ7UUByDL_al2kMAN3A4ufNNqpiYyIJqwmODcQTCXP3s2uQWJYz61V5mkUykDU3fTyTUPCUvx8wwjd-1GeNqjcpzKHy1v7hat757I01Dq9ZcsYJ7-BuFQ70BatKz1CZ18VRRvIbDSlGKciDpm6TZeCjusvLs4HMfArCZe7UJ81fRJgl2vipjIcpaOoR796jwUfKYP50FBEF8sGZbQ2oMJCiXlgrN71ufCK55aN4hD0Vt57ONon6VQXNJlmcoToQUQLo03FnuQZ7-MMwOR3rE3M3M8ruCfW205PlO8jbGNSs56d9_-QitGhMtXeDqHWzcx4fXNI19RFX84QWu9vlSkQjkuIkJyP5lhawMO_GeYipzQ5Nvzi0Uo5-vDthrbo1pUhwm1UaK7Q280Lr4X1mCZtQqZ8nnKYMi1Grq8shrYAq2faUlAaaFdvCot6Lp2vWzO5Ou-zcm2mS_VUsng9xjm4M5MR_Yl4H0nObN7QZ0v4JnAhjRkgAFJDUEtl6LRAIXmy-3eEsidNTY952UdFlp5VSYVyqyzPfdUYERibBZl_alLhXIzOoFYonJ24fvtqVL5cJpsGNIgR2g2tL1nbzq_8siGmRYAazhsQakgPVG0peFiPzG8FU96WWxHMit2vzs_w7dWgyAxqj1_w_zXu5CI0-3zEd1zKWDE6QmKX-md5bh6tnW4iedLG4zIPKV7QEojsQMyQ6Mh8kdH2OZ7srVw58z1AvvlD99qROQwruyEzsXBREUuOe0pcxjgD1pmBPIherByZIBzizHMAn2kn29QCPrvwhLxiGuf78DJjv_FECBhMzSRqcj8EwwOHau4PuXPd1rEHNi05Y8FCQ985eGSM-1Dg9Pd0OwY8z-95LPSt-rWemPhXM6JuWOXKo5nd1K6FB1sibR6in2830sEv4MYVbfRmXYrAD1mnI7CkS_hthv5ysGGtwNfMu5GArtONw01pR2yHYye5zbJdapxLeRJQjtOhe9g-kQkvXBccyMrujfRoFXtJXU-bCJkmM9MAqWYZu4JpNW8_e6eQd8LVL5_Dpg58-qLl9IxPXnoDOZTU6PR4FqsRmEAfwLzp1Q9bOozAw5ct6AGQjc4MaVCvKqqf_dahJbmcNJsmzLBUuUASLwamASbGArS5ie-TktBFdoW6pVM1uoWTXfiOxu2cW6Tt26VYqlD8ov80RoNip-EksOn2a2-PTJNdhq-301DFwAdyqwLeUTn4QdlwRLaMey4mhxTaFigxWHcVoVGYaSs17NIa1eSgql4_GPFVmatu4ADo3w-ZeOShdfRbgKoI7_HcjxYlE2CAG01I4-xozjJVpI9V5Smubxc37sk8x7pNp6AcC05SpORa3RBAnfA6tc6SJATcxBJ_e2qBYFH4qS__ZlzmZ1aEqPrx6EmAy4-r8q0oHvRbE_c90D57XjuJVCwRgQ1gJcQ1-0Y5ILimBkYu8TkSGn2PWE1CBtmRrY64xDzdRA4W7_NKKmgaQ1YTWOwFaZDOFJ84URHks3Q5mh9U8RGocVMQ84XgSeNPUAlJ-R59FL-w6Y0jRpoUM-NAj0zc9GsI7m4qiOm6z5X-AJTFU1G5hzvs96_lBSZ9XWf3brUv8qhMIjr6bILBuP2KRAcoi0fxqJGyxx9mSKINRHQTbretI8RWQfEQQ-OMc5-PTIX-rUdnrX6jEaZF-qp2-dqWs2Z3SGCRmmKzOrC27OatUkSolcO6qOg14539yvMShLu1YXet7Xtr1eWfcsKGtua3lRxC2w3pACYMMLTv3Gv9fRfdA6-yWpjm0I1yTz4myd9sJmHIz4TBUTzN7s3ETU-ZHI7bsf2QDWU1E8Zsk0H6xEwVYsJY45iqwVc52H0c6eAnukMPnu--C8_a-1U2hrbAQh7vKW80PaRazUEIArhvvMJx4m7MGua-Z7Pk5Vn6gwNGlk0Nn-Bl1vp3HaPPyObMx2e9fUo871a4qfZZ9r555xc4PLF4b9i70uWeJDmQO_QLmQszdZ8u7AFCJnskN3Z6iYeyb56aTyYK44MFi-JqwVTdh2tHkjV1TJsqs53ErNKgq11MoPZ6BL9qkCBdE49oybOeolfEsfQmEAws3saSCcgDF6XW96-qCJzS6SQpnbIF0jCWBi4uhL6WmFmpJ3FYrl5_-utK_8cBTM-Rjlh6M-cZdbjOp_EoXlBU3Pw6uSULsUZuZVfBhQHVsnRAe9ZxbBoiFz4wKD_JegdqGVzxnuxpcfsg2nBoO90ppB17xAA69qUmu5W1ia_EnWaSg7qgxfmSIRUpySOTxdu7VX2XvnUH8LzVICR2ssHnm0VyBqj7BJE1IjO9saJ2b0MV_OXRRm2iKRIpEWQjW7OKms1QOJPb1cWTUUK-bsSWvbO6RDUshGVskE3ltTdutEhMPWILvkVLZhnCPF_iSsBwqf7TAj9qjT334hBoUxvnBH7heM6SrHV5MSKOHgkRz64EPaiq-9O4dB_t8x6hkEE4wHoOgzFga_cS8r_hib9Jun3zxK_LJfRg-2mOC3-7vY8OvqK_8UD1uUsX74ha2MekWFSuTHNcG8QnL-EjixLGAnOpoKMHD16wOxjNpuBtrJim7SmUlzQ8RcIabvmsDrom21QxMJrO8Fp61t661HJpNyfxx6ETC-ceCPmmOD3WHxtuIJL495fr3kuhUQp3p2Pn3RcXJUJJknY60l6P7ekdKMSjHbuHJrk5vWEAOxAWcFJVxGmgmCZfMHt6bJsxYeIDuamlPOovw16UYLMav0fnDLBbmiJYxWkFpyxEV9OL3GpxcNSsmMSCkdHu33-FaFAlbSHmi3126lthNgJN7Poge3HVPtHl8QDRVlx-H1kb-cl48p6XjVK96OuO0t5HeWxuYARUN3NU-B6ShnJ58003DcmJ8ZW719cXdpy7Am8Zhht8Ww6F21QxJc9ar0HUGvm8A2cd3J3eaD43wWmepZb4EmXFkJ3CeSKvz_87EqxkFbfKSVBvoWaZRFs2Zeufxfd5PSrVozgh7hAbAE-rvp8yHTzBNyEFUtuLwDV_AEqli27kToTpmgLEQ7-eX_uMufpgoJpbb7orxLoXrg7AaGuK1-SgWJzJuX1jo7EfyayW-ULr4yprW1Ityb18g3m_fyr9udhVKuXC5ikymIrlvQXqgugD3PdMXBL2e5mJuqKpXttK3TszPE5NovZMCIDncWNg21l5cezvDvY8d0ateiIBcty5af3p7kDfKpXPHfM-UZoxHscLAr52u9V0n7I1AfuavmgSCdO-IcIpoGPONeOxaVOSQj2BMsf3_uA-pDp3IjaF7KQuxFOJRvv9kf5Fneq5Zk6aUwtKUy34zIN7TKJry0bu4D6ivuXS70mjln3VSvq6qcCRRRcoVcy_CSkx_s2ARsdqMYwc-FgSTNZqSsP30WZ_4sSigv7e0slqWyzQQvZ-SVwO_BAdaefFtx9H8v2gfZrxHQL2XyYLQsQQ6Hs7RjI9jAPygW5trnxzoZzSLDyQAbTvwjqrvWGk3dPIXolMqnW_6yKUVHTUNJ93ia9ibh9Y8hrxkvVOEBZSY2VW9uynhZEn3E5te0C1Vd1wRu_TAP1mcw8eFWxmW2nFtW1VnvXak23fX5eoyHNCY7J-C0FRdKWLEmXjrkt8qQL0rPQBIaE2GQfc_nN3JueONocVZZt1mxTRwHomhW5Akhdo0o6EbX3TyFI9Z93ZEKg0uVMCa31D5Ya5KCuGFod0qKm9PWDFvwpEOlvo-m3SjsZSMVm1c2mJYo0s4JcJ3U3wDxqwsxX-O23AHzxEaqiXKNqnBUrjG_u7vxu_xJHtx7NuQ4ymPcbzr37D9CPDgJeZnImAx_DWBJBC8UiHSpJr_LzDaMKDBuxmegMZuVmOlW0kn7HP9UWyeILp_3Oqx8OIxeOFrcqJat9cFgW-7JFq5unUTRmcZucNZ4EruG7UEb_Qa3L4orhrBAQQSw_pdd9vkIXVVsJpitSKp19Gagco9hD_vdx1N81BwK2w9yTgKcy1TOI8w-nlV0-JSSTAYHxtN44LIiawsDzcaFZA4MTfaBO-NSAe2iLRj_8fYPNYybdsWmb9yM0nVXka1GeNeZrc-DdwjP2rukINixQBCMFmePB6q0EwuO7ioiD2G9myAQ085-ki7J9cIanCHQdhAVbKRVU_uWrrk67QO57vfgZnrGpkYx9cvJybUAYAglegnF9XYcKO1GdGckDOylTC2Owquq7BwSvJCLCTxMmW-ylGxyi9Q7MMCrmBnilNMEtipfFJ-nyvltTuY2425HdWtoFtg_fo7BsFlxCUFrUWraSt9nC87awlmHZw45RdRyuxbKv5IyWOmLhP8AzF-lsz-sZQtTFAFb_28xuwbRNc72Az4hgOutFzeEFH3r3YCllq1drQvxujzKjrDyng17gAIPV5KqUpE-QVdjnt0nCllAaPQMRFk1zJReO6GVgE98vA5B5dc95eN0REYFtzVWjYv37GwAsJIL9B53FY4RBAjri0XDtO8lv407YwIyvZLbnsS_BisG7B2Jm_84lxrX1Djitb8Ggmi_Z1zaaH02DCTzoC2tCTEpJxazRP94_HEP6V6CRr4qbQfiyAQu86gGJGsoRx-4cfRHgB9y7Nk92Fkzd3NXTeKJ2YlMbxcBmufom8cr_8-Hv6mbekEjcGyvi5GJTI_xk0dXTCq8hq7SgzdW17JWCh_NiZCuoBN7LT_3rVe4GJxt2jQ5QfzdZpJNT4w4mHtYGNEarXqz55eR5t_3dcUHMU4UmIEHM0PNJHvYNlyRpMQOJSqfvmP8_-T6W42ZoyQkypLBMhV8ZoFrr1mpz_ckAh9DyAbO_0mW85QeZzRb6FIloPCKBZ9YaVAaJL_T9oE2uigjKzaoDFg0VsExDdyhN4E8i-qFYdvKCdTZFaHS58C7bQY2SSFPZtFBzFQw-CP3cAL0u5KRMtUwaPDtg7vqlEo0aeFEszNZ6kBiLi6PxCvqX7e89MFtjbTdnh3JwcyJs9HS_tfpKVcEFK6eOJIzcdKtwBH-R0n1MueNLQBqyRI_w167Fu5Y8kXnwImtqy2cGIDlVITaV8S5T8-CudysSH60SjhRVR0dmg5cidGzHPCbMkrDstjfslHd6152kztNN-5iG3oeS2_Q_l1SAfEVi7CYlsPBeF_qfIgOMB7_OgBGQdDiqDxJ4pgCPsoVfXt9a5yCObINhCzGp6Y8vOnVr3TARo5IE16v28l8ZSYDCM5aExrSaFHjGje1-Kv1OQsfI5AE71bU2sbvP-IgjAd6G4EqLRzhM3ZKjigybHtjkxQ0aJPdWBCuPV7fyFseVwv42BAaoHyDOYj4W_tCiASsIn1ED1ZOSiTZ4j739pETRpO4ck0ssAcnKRjXn_4DC-KLnUw9rM0Khps4Aun_yt5qkXjZjc1AtWc3S7BVqZox2kNCN0KzV0DKo4HZV34GO2Zz0z4zV1d2CaqmvQRE7YAQC-JKxKyqnO7AZjSVTLvX2LK9lC2GoEnwFJFJ-SzhByM5ePj5XCDuyGdkAwDprfQtcZe_1WjS84Vzin00C5OnyE51rvU51jpOzFrbBAfjVH3KL0Y-rkfJ_oUnbjYdp5PW9czOv6KXhCwltwCGA3EMPv-jATsZ3XessnGOLfwUc6DM57_H2SrQNCsEi5yFES2TnRa0xN_k-BUmg6z6kKhBeSorN7AwqsjmIK-akFEt8vP4CFExwVKQgXkqJB0X3Kd1DtQl-ZUOchDzUmENJGLMEqtee-c1bX6T2n1x7gXc5VMFQ77RjiPq6bQf6uBdWwEpJGC8VRA6mR7WZ81J7iZ3tRSea6OJRDtpSqfdKslHxGHwbcmiEsEFFbzFoL77DXtRu4Lk1HOWxELo9qPqGCOGiB3cq5tchOXncHvqsqHAQdEbqyGTx4VJyQcqNJPdmOm6tQxDz6RTXuajkYOmx8aKWQq83pkDBIkvmVCcHZZ8iDrqHWSPmF5d5RFdVhUIf-hCygsSHRaFrajePTySAmhQdCrlXbPYcFkf6Vqo2oZP1WQoyn8LudIbv9qRhR2W_bljphmF4gEB4oEG65z2PtrlgkcqSEqDWe8N55CkCQGLqOsVvATDNzcGYCZ9cBZChNCTVnLtyrbUFRyALOVm6RJlke3D8CvGKFiC2HNVS4YHlJ8WbteQS-BtSMHaLFqh9xn1Jvzz3IkfpP6adUc5_bHpvVkm_BmGNPppdSrAUDFJ6fGQBoEXpdpflSHq4jg1mQC8FbZ_5cODkhN0Gfy1BRxegHAS6e21Nk9ST6Jyvyy63mIpkuBKIg-fulIyUXRz-Vy-C3BHOFDMZ3yEvp2d0njB_F_vO09GEAwGpcrKb4iMFbEsTuL--29PxLD-0ZBZm50-4XHDSbr-Kp83zzad6WgEHGSaaUJ-A0aW3UDB3rAWnFYxKBv3hTysZmnPPHprUR8JnJMJhR1MIW8lfyOzStk9vhCWLeEE8ERme1lSywtnopnp1kNAbNuA7WPtu0-j-Yn6YaYJ79n0xDPFDjwQNsjk7d0Qt-UHLfcpOx7udSPm64anEy5VPokGowVhkqT5_BWElcHMAtqEwEgXgBY2yEzICI6uJts40wb3z5fRSU1GG12ufOy1DO_-lSK9Gxmu_qdpk7F9gf5AuxXqLLLp0rPouRfEdTa4tPbJ3cLesGShY6tPIbhYCS8xPcUpH5sbcx_O6djQP6AFc6sZ1Oua9c2Se_MtcKMA5JkwURHQcJjHVaA3Ej8aALoRVGr6OUbjex-x5RwNeof04HC5b_Mn4eE8dxiCwmcSYXyfpAhS8cxxLaijdkHXrlMb_a6WLiyh77BHGaaWvD8s9M9hWAY04voDSlIcmjNjzDbcLQIdDkQoMShzj-Zo1kcswiytjrgX1WHD-9iQ71x794uHn7MQC1AaJV8ufB4sb8opei1D7r7N9aDaF3qmztqqPuIszv5ywlV2IlLbI5CiuBC49TA-siY3jk-xACINQwpGAuCTEBsB28CG2ffMA75U1xA4P9o7EpHpzJM8Gn9X_vKrUMTtr_-lv2dj8rLw9eHn2WIk44Y68EagdkHIEPaEQfuftKqk6V_6VAUoGimf_uE6VoVu1JDWcsF9J2A2t966_K4BHuQzzvs85z5lz_-jNM3ITLxdCkEiA0dB2ce_SyTCXC9h0yFfLSCDwA7NSIivZHntn8xiFcqEzv33gHZ6DrfryUwGJmhJ1anuXybss1X7abYk4GJpVz2Ney6d4Z-DXpKWEVLpaGsQp9q0cEk44NEaGT1lrcX2eq62DsKtwoBmFnMSWAXM2lBTLuYNz6CYFoek8ZCV07SZsqRZFCT-M7vd7UMzZRdwIG_1zl0JopXztSMVB1kautU6bhWdgcLE3LcD6bNdETLiMyfJtfN12LAjlS-Wh8_y0KTiU_6eHMzMqqCqZ0IpGyxk8rBf4j9KmYFpZOpSKs2K1uzDe06IqLSk8-PW2sB3Ag7ffY88_uQPnU7ONZ91ep0M_JCyB-PHfmuYdmvCA3ViTbKEPaWirnjZjPUmEz29KQsLBTaGrNgYyztLK92PZ3odxmV1r8NIz5C3qJZpzMHwPAkMyUAHThqjwQgfd46Z5E1-3TlOjOLf2cy9MsWEF0YZiJJNFd2KyTdoczF8ly9pn5GM6V4vFcX8SAEyRPSeziSXAbKT-ghU-GLQT7ZmSvMgiT7ByX5Fxna3BOLYIzMe2DERC7nCXDX0VAo3Dk8kL2UFl_dssX9vk80k3TfJStqPckPe-9WkACtiB93r4XTCrL08vq_g2kOpWBNZBqa_2lfIIWKmQSPsLX0VqLrsfQRTo95xJZ6gzm8c71kfyjTPRjfQA8zyZSepUDPXyZIhAA5GJfxsSBoRfv07YGvd6iXMcGCVnKZSJynLlvghADNCVuL_wt_57AecC2dKFEZpJC_pNrkhKS16wb3t13cDCWG2W_myPl_MFCnU6v3rXJ7NwawSPTeaRFdN-FMDOtAaYtxMd-sMRm7Uo1ATZpLVKrxaxFxXCzlKeEApxqOoYbXwiSl-N2KS0Gw_aOgB26Yw49nyPSGYIQR58HiaQVBdfqks4kG7b5tsB8rKJIfH1OZbLJKzIlK8oS6FMOoyH_E9VP3ykySBoUbN-bWOT2M9H0UwhL3Hm1lX2RUJlCqqS9vGot7VsXNFBZGXhOY3DVvEPLSB4DA11KaMkct_pglSXKBuKRCwhTHj3PeXkW77NEzyXYB64Owp5I7ASjHOc3eyko5OEigTa7grliVpw1INNfXS5qnH_CxjX_9MPqJsZgtGHdLlKZeAE68051J83dCXy6MIWjyZd8q34iGmpi_7N3E_nX8l29OeO3xqDEMWkVeqX-2S5OkMfM7n21EAAgltEQ5cPjuTHBQqB8GG6094OgA6uzyzkdM7WV4sbT0ifFTH5SEva1CwApfvXvK9a2Czc2CZG1cIvP9ImHVLYHe-18dAaGIhiVvAHGmlvgYepetL1oV17CX1Vqwe3FNkXTeNUUxyQr-54PEV3ptgu9XDi_TnBo_XEN_-k3g8zJPvk4IJ5biduk-vKZ-fzm5cNLGnFapdvrYkNIbM0vHRtf1AWliOcqnYhdINUDqiTTxmKXi40vmy3zyFOYh05BuQZXgx1zcb5kyfDEw8TiQuXW3BYank9fiG9lRHJy_vuqQXOXQGn5A_FWgONetRlUJaFPm_RYOx9JFhNlDRgq709Ob9WKNEd3-g0chahJfXq5FVrRGAkZIday2a47TxOOIFBy41j1t4Slw0tcVl9N1sPCvFd8lhp-sR74DU0bAtni5vt2qjES-73P1zX_OcWnQDZ5m4r0njL3ZhcYne7JztTSfEcyIlrV03UhYG7vest5Y-MPuAkdg6fV4-_LVuT4AfiYQ6muhF9Bmq35qFaHn4prfyINWcSYPFR4V3A_urc_R75oD-7fnFTPRSRsuXO05otTqBYC6Oc5cxW23nYZDDJ39AFj9xMxIAanbLW6DyVlLgywzPTLX2cZR6n_lQIziHB7BftXBgQaOFf7c77pW9RpCFPINB5yGX6wg1bJxBE08VBKIJH8agPjKyihIYdGOP1L0E2J53LL_bdBwo4Ek28KSO-qUhpSTsD6yGD0nG2v-jp7Ul86yCJXKPdzmS3WrUgxOoWoISx5HcAQOZNDbgzfZBnqgOOsUULI7RsLBBzsUYj5ZRQCbNFtgWcPTCQnGFYHUycx0f6I6-2n5SadrMT9tzcgUtIHeu-Qokj1R69B4RyZkr3K6W6GIHAATOsgBVzSNXLXKlto5nEOsxw5tveNgoc6XfOf43QxMvHtNV1h6rPNWlb7u2WJmYuTAqXathhD_QQ7te4o20U8ogiPmOS2dyAmgjeGFtk-inZKrRx3CRktMECGdBU6xWM7PA-FKk1SYeI3gMrubO3PKeNeolqXLrVnwyHgZysRbg62_Ch1TvBC4otsBa8fjY2sMKG2BZDN9_fpI4mHpZ2d5j2NFZnVEZFviaAw96MHOXfzZLOnwWK4RQgWz6V1UXZI3ag-zBX1vbo67Wld2uD1fSTnYYvvMXas_3d76ZhOyGo3su2PRuBmZenHCq2JLRlJOOlv6CC-0QhVCz-GPzlMl8lKAeQs3oLqqMno2iF8XEMEc_2ugZJf39s0psoERn6dYx0DxR4NMKZfHf1DrD-b0cmTbnSP0V-btE_r3mAYH1UXvK2JBuW4S-PfXRQkUGJcx6IlN1JB7L5RCP1p024VCy5pRnd0vKpKRXfxnE847nO1-hg24saWcDqZ8RUeQ6wM3GuQiA49E0gxfzkQia_XhITmejKxTAz_2HARPrWspm0ohOGX9tofcZGuyXzduPTQgDg2hld1ZzIPB_hk_A0wZ10cPTNfufDuoY-pnJ1bprLi1Ghg1Bq3zzjXTan59Gcbrja7JZj41TmSdnTmPeLiBUs5WAbzNGYhyTFoGW5tPFjhgcVLvvEGBk-vgYfRtZmBns2JXVP9URnHX1Vrh_z9tce3XWYHWQQcARf2AnYQehP-UqUOUkzW0TI7UptmMAfWhLT0UftshGgEoRbJjyEC0y_a6-pvOQRzh6a0DMPAwm761Pa7pDtkSaLrnJRiMyD1QZpEasoYBmMd_Lm11tVAeJ-yEKeGbCf1VzTTvwn8B892cQ60VjLM1ypTFVfPRDdKqxq9clTteSKNmo8wDOAErRrDkcCmiRfjS3KD4hKlD6Wt1YZSz-Reid97b_qE92m9AYWvDyAMYQ45yv7ABf2kCSm_GG-JsKVJSLUpHNib9tXgqlmRxZodix-SUgDBYZDF8_vZy3PEGTshw6oijQ9ewlW8mIArxtBY4haB4nLuvO26GqTKIuiQjH4t7mOKEzQh-z79d1XS_PstluayTeZ_tEvU-eMigLsf9p3y3ZsjRZwGa3Kxc5vq08dAs4PtNdp9c-uqiBGIgu42Bs6xiTEoVNB1NjIBDmC53wUkaWU1fEv1UisZ3JO9OHObnb0NVUH1mxm1VTlvilWToL9IVctOAYJZaKEJq6N3K0ZVF74LrOozQtfu8SmshsAhjMoFZV6heG49n9pTpPstrT5yZt0tHLuo4Ui5gKpp-2-o2ecoeWHhIDzwVC5unTeY4NgBkeZ9HH1yPQqSmj1wuFmP0X8V4SIWWXEE_bPl1RvEFCTsqZutTaXU-dG-T8iOxBTs0L9-DcH18bbQqzenmMLG5UZXGLLUoCTxnBGd5YPyF-2IC3vB7JD3teNfWIhyNjPhDTRCltyhH0_nE3KSfz0Dva-j0Mqlodo0HfVI96MI8xO_xAe5DyyKzJ3GevUk8n1OIalCXLQViLgcVpTQ0hx4GWJ575QJGRDfts65f7Gn4oYWUTH9A0N2JDP7PA9wDebQrwnZ_MEFm8N9qOw66Ph-YQxZxV342GJzkzrEIKzSnb6KrD-_AgsI9oi0nWZ2xwXDIUFQjCs3swxeDFkRQ5cmAHPB6a9OHxDwUCpjL8FzlMGOelptstCpQqe_He1HzY_Y6xeHyWO_SXQd2WfLe9nzOTOnNdHItXE_JKFfxd1BMhrAuGl3YlzKMcolsjBfADcj3IwzBz_9Xf4LS7KKtvOxV7LJkDKKEh6YVUqk4yP2BcMJOfScEMYMtEvKstksnVrf7Dq1urtn1ha65yDr3-73jSLIj_2FUV8otWfRtEoFJsRlFBX3sRsgdjLG-TVZoj4GHgsu7xFyBycNbxMWXBRmPy6jriHFLp13ypjHdHoK1PZShIOCAoTd-IPu4qiIFfbqHfcPwQrvzDaHxoQhxt4LvuYw2eo_LFpz1VA5-Z_drJlYVR-2M25v8CMRwtVu17r9PDILgFOWG-lt1UDhGJUHpPhjKnWyFnVT3zbwHw8-W5sVL-NJI6uMhnMpTV92mTNAWxbt63FNEoW3i4OA0VId7rvsR2aXY3JuGpcLqmmlZ_TT5rHeJ-30qLi_7i5KWXhCUsCDuZrz_i1WBpecwe3a5w9Gwi2jRolsXg-i4BwSASa3DXMkTqpcl9IvyWbNn3ufw9M3QKePzYmgt-SP7JP98NcGjRCkp-lNFKIvbhA8fyV_P106RSc0uSNwD8TBXRmmSqkunIQ0r3oV6p97fryGrSABcoBp4lEk8ZQ1XFtIoEGzmo0CuZWlotNMhjCsTSl3fEtQsB2Pl83YjT77WpydanWTNoqYZioULXW8ezEG3dpI-0oM7f-lNIHOu906-kbbXOJ-bx_lxowtpuD0aV676LBDBPGE817Ks52b5QirPf2G8xi2Mus88MZwK2YNsYC93hG6bPilyQOeh_o5mbiA1SwBdMiKMgOqfzHrQABS7T_PYDFfJbU5cPfQiWJUPfDmWoDs7PNdaOLMeKz63NN9zL6xebXPhV36CnNhfvas3zSG6LIM-igzbsdAo5YwsNEBrBaU7LylZTEQ6zl49zoJk0KIoSLGLSqPNmQ-PRxmlL1JYzV7xhLddGx59O4jI6d1jP2LULtMZU5FQ9qfkXzsPwCHK2oaUDuKEPcq7FrZwidKbQ5WNqvug4T7VA12BPrdYSSGJx4o-Fs-k6BKrQxTQo2l3Y-CFNbggk8lmeC5yIyFir32cQmoxD_YLRcDoDk5EhXf1bis3jKel_tEtEwiLB-pEYazR6vGrmhm25bmYas7ykIOFF_ItzHWTZzlhtfQpe8MSj-sZVUL-3PRyYw83Z2Jdm-cUzkjDelSw1Ck7WsjBnH4EjZG6lXhz7MUbjbekpdwwIKl8YrFfYPrwe3KaUJWn5sm7glMGw8ZJyhxFm2Rn4j3Jf5rvgql-9yRxll2n5tMHflCGJCF9K0cf4D189shWq6mN8BfsKiVpFJALqvagAx7NHKEd0viutQDuJQMoJc7pCXh_1wlWy2NmT0DdqEs0xn1Dp2mzzaPuJQQmefBZR_8tzuFAO6A6tgvfPSLl0liTs5w0UYvhZAC6ilo64SR2oTKrmjiNszFYtkK8Q_n6jdeHV2nm38uqQsI4AfMPKOFP0dhaBj175JDkq59Oi1qpTtzdJkJRY3ceSDAEutjnts-0sdaecY3YKXB5CMFPfrVMiiTLuEE5TfzR-JgV2ZThQoUuWFr6cDK1QhSoXLftoi1NwNpDBkq8IIa8YUCv_Lrvl-V7coGeHYHzX2w5viwkr8eggTaEhrDUuY638heSwKWGgxHludd0o1_uo89GUZOSR0-8-8F39kPchU0WaErM7ART9abPq1UPy_EpU11WuyHubDn_l86PF6N2JkCaO-y1RXeQrTJh1wHD3p_5_ljiFa037qLNh3YS0dn7oQpRuT6y8Z_6ZmZq9VCSlbb5Si-75I-RY3wZrgCO-AgPUQjBe6ciBSwPoafLXhC9G22GrK6mZ2_Ie-0hWkSCvyEypzZW8_leYxt50yKFt9sCYt1VrCO99yyvwYiN4xPP2iFoDrbUHhJRIk5NwtUvocIbn29YxxaQdFa8zvNP1s1a_6ZkP7Ck3QshRheH0hcruGXpDBo7uAVQ9Y-hAAA2Z1wRKJD1diz6FPg-rjreeH8MMfhCBUxqoAkaMOWD0zmrkekDNB1JDTIq9xNGUIW2Qxg2ZmLJ0vPNyqMqSEfELWiQEiSgqanQdEi9Qf9Zs5HsEdm5RemkZ3ejSVhdsp-dA5rFegR67_wgS6sKMdqCLih2GNWgQ6Q1UzjeLYw-b20PtUiJoVfMLod2hZKEWBmKQpt1Ehnd4WFJroezIjv1It4leJNDhNov3L4RO_6LrRJCgZkysUEScHcc_Uw8nPUMradyy8s5c4qk9aVKnxUGG2nwU916RXdLozpaahIW-TMgULUClMKJoXzdtS9_3WIPcp30ublMVyMJWJNhDQetK2I00CsoTVY8zENjvzlDtZDUzYhhFwh8mYiqCEBRlOQDt4g9WQPlWPADmOwy5McKr0312N3E4UMkO67jBAQrOR0f4XqZvTyJBf_oUg_QUn4FZFsf5iawPQ4lcy2Ln_0xMeXkzZn-bQaCjNy075YA4aTT3qcv1hjou9Yh2exuNL3mChO3splph5Ct6AJWJbaRZyOYd9EX-t5UDwT4NSImuJOeWyKcEh-JkHrBNTPSWFK-GbzvLIMz2NftniWNhjKvW7PemjY-NpbE1zFNmxTalcjT4oiEeNCaEdjg-F0cUOgxYhnnzs3fpAy5ZVp0JKspEUdAA_6mY1nt-4iSm9Xg0xQn5eCKALKX5wr2_eJdPGyb0nSfzSwj6EwswUm-OsAxuri2ympGKaBNOsLyKZkt_bg8QjFl0lhVnYOk0LdwbLW7GhfVYdaATSNyyt2av3kYotS9G_7qU8sfjkdeS9AigcHMKOdkxyeD5Q1sUsPTr8p0duvOY6GjxI7dUJJ5c0tsULxpMfwdDA3rHKfenHRHuwynmnMNUsgLZNlJMALFASR7otxgDJszDwmPXFgo9OQrM9gIInqzI51hyaFmIp-tk-Agn0yG1Kvfl-ZLcRrymENMnYwvyxgjPADw09RzljirQay2E1vd4cwOo623gRkr3ocHHOkp065b2s9FtL55fAVHtGkNeXstbozVahdmW7KBml_djf7356iAhnCQIKqqdbxWLn-4EHoOe1hmA_o7syP4rn7NXYISvvDORfuutzd8FR8KWdYSJDwN7a520iyrTW7JVWFjmRkDFCNM7VkqXiIumTWrdZAftxXFzQ0_PEyG_RbTCWBU5kpacVMSisxGWVOiBugtWaOeS_owruNDmEEOLzilREwI9FP2MwEuj-XyYwR7sRpZPsUXr1jCJOGpt05xC_qz-xmWMNhIQt9y8jxL0p8BLVL1ymWOvKonHO3FCtlv1QUy5wx7tbvMSanktBg8_rPE8sg0Ftej5Aaq55YfujXFvbtJWHFOKkN2PNX34R28bYroWR9WUeYKBtempY0XQY50IkgBbjVuCdzwvk26-dmZ3vig7NfoTNK6bhLy8ROT088UV5jDfd12gD6sVi0zeR28IFoiG1dFBYCm1uE5sOeJjJl3rTn3cwuNNNk1rAeQl6PbXULf98zR9lPPJHFOzr6EMe2cRFfP57jRbcbDqjpQvw64T1hMrByKPhmgEW_ZFVTUnIqm88hS6UtqM9JoM5O9UAUAQpzSU24a2_GDImDPrcs-uiOi4S9NbskF7ni0DONg7X9gFFYZlKat6D4rg9X8M3D7_Ccc_G05A7WGowSmxIez4FL47TC0hkh5PG7j3A-yawi_JagXXs_SDJrPslmK3ixlcBr9_QlxWXkU6f9YMlzUdiLnnpB9yfkiQVeiOemdWQp5yktPo2AMuey7OcYKJX9qVYwgLvsc9-uQLQ18sLxQ47WKDf7rV8H9UNNLMT-Katy48Kv-GUkYD7qtc379oMuQ3MZR67ajXryi61w25Qt3R81m34M81HUmlWpSIfaMutQi6qo_NS1qPKwqW2_3j3P1P0OT2VN0LRWyw3n4qJHg4uUM-sS6-nyQv3Ih7SytTHLNn83Ql4WNnbEd_9RcQUkYRrYA69Hm2cl4zAG5wW4NQqtKk_EFMimDFDUnTcmec-qgsMxzzCZkKb9AcRzr0qyPsX4Dwv8DkRmjk34l26CPQ084m1u5eZByOcR2j6GbwIrK-XixtUYKwZo-QkJ4lcJZxJydaB-uKETW7l88dHbhqmzWDt_9UMxn-G01jGJa2mGe7BW7c44xn0gzl0pwUCwwHYIu5zBXKD_tgiTGG6lyi6sdsDODwKzxmZwQmmbCgEwgJu9fUwzeGjSPa-sVFPKUEEZ59888Zr2cI8TwmkmYkNNWnJKPUUeBbTX28I4RlDqgkO_JU4oi0vK7vyyR2gVcfoCgM3Zkr4Wd2OTF3q_ZVbGGGGGHTwFFAMLXMcNuJGxGEU8gP0_gGwaRilDiPs7gHpkd-O0KdxRGJQJaLTvB1dWkj2ncbxyLjbEaI4dAFqO5jvvB-5_WDKyqbbJWLB4WS003Ff7GQusfplejI3KoO6X7V2kmoI6e_SysA7OtpxYf4du7yhMIe5aw3-APcKyXsE1uyvaqf5-v4Mu-K7q4IAv-6_H-lEf8pmRCOgjBx8fwdRdwVJ7R6SeixfhKjSNTd7AcgRyow3IslhHT7NRFOqXZYt4zTRbj7W81yJ9AyokJN43XMMho17Qk5l31z2GFxiVZ61wu-phxTL-K4WCcANpg_7uWHDj6xNxNAa8qgHCD22_BQ4L0AehaCOBF8KR8I2wvUfoNm1AM1YtWIg4X9fKZRGnQJo1YtZ3_TVtKO6aQM-RwHJhNhjThVZs3nYeq-MlxeLOsCZnNJ6JpUvxe6Sky03eeGGt9ZR13bCgxE0633KaOn6wga2Ws1y6LBzjA3cAcSc4kcGxJRE83sI9vCh20wK4rksKaYHHLel_oM0uE_sWYjSfh4Qh_3mBgRRSuJY3UPEMwGoAagiHG_yNAxxxlYRIPs5RGWuXyT60BaYHHSSbJbAGUsGoo2jEa8oynz-cUf0jagoJr3kdbnfrkIggKSz5AgxZUviyJ2viWa0e57x6pNb7IeVc2TNijts7QZGKL1ZVar-B7BbpfH4otKk_QXv9rjbKuNPh1CFrHPtckEW-kzIZYdwkZitRH8b_FXXh9N7sboJUumBn7iwbmeUbZSFdb88iQrfxYy-OIr6qJpgDKrs5BWMkT-L430QCOrpPXFlUq9uKK-PX_sE-Sk29UkBk572N-z3rT10r1Dxj-df-toXvrFXfSr24oXBuOFjnfmIAD78MEVRP2JHozHM8zuNE-Gf6WmLD62yWjs8N5pEELvtgRLvCpoAN28we-HpSXhckxcuaJzTzW1JYE9cFRZRkAKxKTuc5Od734wM5xBP-9NAahZ3yV_11rdHrCPbvbq-pJJx9NDKZ7rJUabR7-lgZI0zr1xv9kxArzbY-1Bd-kjZqjK3Zk5Q5bKaLLQfRk5a7ZWOMvNE6jByE87OPhWWDIT7uBjrU5g51o7yDAt_8ydTqhUkm_bU3e1noA-p2GHV0I-lDuijPn25k9sxLPfIE-UTp4nWGymuZe5ft3rgZCyqw1FCyN9ggsSRUEPqEWL132bkZ915LMAHVXKUzEUPnewJDoVubPLiWGeF9JXf6Az--Hx8TYHLJg2sak9X_rEVdZmK9IzsXDwGz3DFCvda-eEAUAvtV4z0RdgTs-ctDE1Swb-X1q0ntoAD-2fey36n9heEpT8nxYnq4izAQ2c85BVLCon4cqG7tE6c4Cc3DrNFS5dcppct6GrlGUodOL0zNW_muWGAxhpjlN4mt-l9NU_VUMyW_2pfzObDURG6qmn7haCUoAUYrwO6Nr025ljpbMyK-F5maRE_kS7tegCevwqIVc-CSgDuliRn5uJZSt1rvF1VT_y65HDnIPQA7PDsMtubG47UMg8bdYhJGvN002_ayi8ILcbYkUlTTB2Z_H14gVI4U6Xjh0XTU6prHLlsngmREVIFDdkXxs20Os4Li6Ir0rDg6EQ5KP8exXS9B3UcsUlH1OQ7HXVOU4acw4yk1r4SHRs4vjiZr4YFDsjCk3mO-hS9h-arKB9LntFSKdjQ06gDn3Laulo_gEwCcOyHbnJzdNNlgu7FqZpfxMR3vDwmD1f66GCXhfNjED5jh53uB-1Mqyo_J9yDsg_fxWZm6SBz0UQVzvL0cAsg_4Fz107X4zIi-oZFSTK_gEGozb6vuC0L348IpIC6bCz_ZDqcyPdb1Ca9YY4QFWPi3lCfW6dIhNBPIp9pdhXWDF0rQ51TsXKOVOaJwZBgo5PTqymfkC3gWiQXjoOvwQluNm2-0juJZwXuQaRoUKk6zbsvb_5KL7FlmFNIMCu7LfcLZw0Wkk9PQWh5B7cES9LbQwa0VRhX83sNAxUx-wiGrPwPEx4cjvU3fCWRUM-f23W0N02r3bOCUoZpH1_lTyLce3K3HivfXmwFZCU_0XGR8IGsENkpce1pzafepgoOP86bP_s4wwP8VSPf7lyPhK_Lxa5EIpdBIJahLJOLipUa386wjMQqz2jJ8mNTbUwnJ-xwCOQ8y7A056q1Y_L8fQE2wxMgoC2kzCyn8BnW0Jmr9njWbBZd6yVPErH-Rp4gZCDWYqnKAXuLT47gjL5bMhhP8KYWmArh7y2_AXEeeqPdj2fKmBAfNelPQ7eG9smevj9Y87GVbi5xzhNYkJpos0WQ82WW5BIap9sw0sO1daBubojw5ZjjU5IlzZ8BhFg3q6fZVdo-8IpT3OQlGh56e3HD6or2O8v5o0VvaVb3GpWZFuBQ18-SCycjaTMyr3DSVJRoWCI3lCR7pOge2fbX8_AA9iNUnUk1TzjDxZqi0iQ85qjCbySA0sj9x9DapcCH-U482A34ANL5jWV0dRYK2wUKAW2HIl9edqudqD8HAQxBQmJiIOzfulMMY6eS-JUjliZfwO-heAoaW6Q38V8XKBt-_Y3dpc_zFhWjDjTteJgleIzFeWE_Z869jHR5YubCPlPOHMTmC9CgF0IG0pDk_HQh_C0qsZ1PhJTcvKsvBPLNi_LfFSynS2mHrU2HahYyK3nLLtMhIMBXt5RutIJs8TDQ0279PVfQBsndSLt4CBByF0HimSOx3gqVxY8Dtp30GA8bO7l8V6Ez7SqCu14jWkZUmDrT06kPS3qlRKQwbMS3tBgqhGT-6zQANPhkqCSSzaNOREAFzQzGm4RD0uRh5V09VCLCcvQJ_4zOwy63uFqwB7YXeFLHHanT3EgukXFLheFfVruP9-eCUG_38SmbJl7oFlBw_8ZmNVFHbv4cFXdiSmKj-NG8a9vRpZaFgo2X5c1aT1IHYKMnSRf3K6Ae5TdpRzOpJUTheKvDD3xUlYh9E5W9iKz6QCyb_yMGNIgyayiapDtzpzW7fPYEuOXaIoU4QY50cdY9LalSGHKencKK5AAFjCcZ2haJzMHKUxcRyH7Wbl9BUUeqDvSfhmk5UvhrIgZERuIe1vFvyFyylf2ti0OwK0XpSsi0_6k4VoHyJmE0_HQpfOTZVQeXiKNB_T8sxH2Ix_IRgreDtl5bNTYPimQ4-PBYyYkx6EOj52NuZ0OTlELJrcjAd_0oOYYNMoPT6VR24XuQFOuNqgT05f-3h8_pIhZFikgxwEVjAKDCjWGg_uDh-pHWfpGmcz-B4b-fDvDWEkTjNyrtQivH5xkSbuR_ZPwm4bXHMbYhxm4yMtLOULCPzZ9c8Q7ZtIBLt2Jzx0zFH4aGVzxBIwnWZ5NPTRLRg0sw2y14n_CP9OTFneL_2hasgHOKzLdrVPJdz0XO-fONSwJDrcT2peSZdMgxW14x9IqHLvPZ_2JAfEFxm3L26SL_Sc_SqxRM9ZbiBOs5Gi49yK_xPQLepF9Pgu_Pzo6abv78wLAdQteYr5oblfg4LLSA48hyxH_6BCru0wVYIR2DVJsRS0KTTX6xgn6bJuqeNIuRUK8Dqm0jC8bh4sdJVanpoavgfWZzLSXbRY3ttz5-K5Ak777tBPimBCS_P6nmOdA_8X-wzWth8eC4wwlXJjzYneLpHeT6mQmXL4EuOHZ1byt8NorSUGyTteDMj-dR0b6gmrokxyM1gK2YTsyhu77qfrqNnNkvIebYMI8sgUlKVC1vOv6IcDhw6gVoKOdUIR0QPk5lQXYjMfCQhKA3rpAArgSE95r3ypGtFjPY1P0At1gyyAs_X69izZl44qCrdtyXzM8pWZPbDZGXlHFITQ5wJQDm61WkSP2FmN-nPxkUZH2_nF-3riDNHd3WvFVi-kOWw1gn66Dk21TF31moSiH1nQ8qrKTZTAo_nRpGL6JELhDslHe94HVGNBxnh5owls5DLCr4aYAUgZcscyoFnYVO3YxO2VkzB0lyzNdluSruEPYOGugEXUvMFYPwYRIO9hruY6nwUZogKkiBlPVzZCzzq0VakJ7jlN_34TOrNV-bmIC1ga1Rt1P_JoZ_cXppNXOXzqE_RqXp1goSBMP8FNVy7A518a-P6GZDLW1QBjWj7MLJ_LcqD3qTmut4OQ185cPAQtB7FbJLLzWvhYt9W9wjLZDxct2DMP0ldP7HYL1b0MUak6wAHQk0DHsa80t0dzUWF_WDWHf3IFO6xv--j9bPTNukciwqN3t3U5UbN579eB6ww6MdNAyek_2YE4CmTkZClvZ14LfyF0IvT44gDrtPN3eto9m-sDLO44bBbLMcOQ0T29UMoH8Eo5f3KC38ZlEWUnvmdkjhsajZrJjEBsGYmXEwrMPyINHZlfnTfhkIrarIAENyFg1YkzNW6oWjTJKniB0ehAyFNGEcrzyxWaIlWv1aLDejaWEsuhk1g3DzvhBvxuE0xiRfIuP4OEKk3-Xgfj7av3ZEcL-SR_z-j5n_1LER0FzYXi8gzbUklg3EKan8n-CXbuk_XkbM49T4snl6m0g12Klm9C4TVT_lVFYnm8tuJOz67SXO7yehYBa_QaF7g1VKGGOWB5i_CA-MMeeCUwuRK4R6lpXK9ZQvvbjvucB7I0pzYKINKrPNRxlDwOYCSwShqCNJI7XnfnBh7LiZDtXt8JHvV5fnuJn0Abw-6h0QJtoAruCMzfezyWQ3z3rSOB7rlmCbw-QzgsObS1_dvDuZQIRJ6_m7_CXGha1gxwzuvdIqjBDa6PpbdPMkQdf11nLc8JhQSpFlFfIMksyrH3iIY2tt9S2xgETjJXnA_UdGeTfpuLg-US-eETX-OzhGmQe2hSNzEvWaeTG9ppERiWXIgdAVWNH2kF8O_vDiijJ6GGDAPN4GSYxKt9MTySMeoaWQPOPMTzqrFNEBRTlmZ4_eT2KS2MsqtCbC6LMCjhko6yUzZx_zHatpvNaXQZqd_8Ii9OqOLuJmciXuLuhi_zKcml_0EcP8roxBlkDF66daUhiO24GTQN0N1-V0UNxjyJ0cmR4KxgrcAv_7MhbmLww0DHTvgfmnmKWso_5Q9Y_oaQKpKgjpjyDxE6glcP1E8KAVO_uCRUkBigp6zQxlz22xVn_c9CNqNtyTj79WlWQyqTQnscLwsAP4vx5gD64em1ZE4klM6M0FS4hKVG8LurehxMUwgeqSmcXRvLOj5n1hygy3JWjQ7C4FWZ-m91p2YvfdoJaxvDM-ESdIJAYD0-LdC5YVcF4IH0PHU-YIeSUtfb-FCMMvCihv_fPrctcoB9MWzFdcysfkVdN2yXBfPYayRGHpulSGXzZZ8sdubn0j4yLnh2pvX09oX-xJ1aSETIJIMfyWogBFR14aOytQX5BZg9OSb1EQ-05PB5Ih1dUdNsZB_6GAQnaTnqdd-TDEORS_-NSLbJ8BdbPhrn4W6g_7re8oGsanHvxGWht8REoj5piS9rJg4g5qx1W5YKAasFQOjlDwirVIgqbekkQBvAxoWuHDpg3VbFj0uWeNrcjFJ3WA1O6fenRYz-E7HBPPJTAUZEcZmJtRVYf4VfSEQB35l3EhaDtwKT9BF2Q2_3hZi6xBk2EOirq8HSpeGltTHY_Cmm15TYGHAx6RhNKwdJdBJp2y4RwjgcA7k2enkkC8OeN96ZbGCZ2v-jGDGy6AnEfDw68hgMuaweLcy_1ya4e3oaqVE8GAKkNEROSA2jc7Vgww8c08Geh8aB1RhIonP_TF9A2WBYbBZAfwbZm7iDxfFGtlyeXLD78ffAVCwyoCkq58EExBz_IGD3qBerPbs17DxVYPBMyxGm57bGzMJi8fMIPW4czA4hqszATCM9mhOVIAwd6VUQaG_P1P9kevFEJPtt-U-6y6nRGGPcutXk2LN0ylotvJJ1ejcN4j33THiww8vaycMtjZ6DL4SAWqPQqFOgZKeyO0ivMQXA7Xk-lGM65SMrBf-JbLf5K3Fh1YWeXD9QTnbUd9bt6lP4SNhylW5v786gQ63UIwkOuvGsVRbcwbPbKVHGoV7di948ooFFOIwrzwezxrIl11BiCVYCofUYpuxFYx3h7qoOWEPEULTcDFkd58aCR-wj3IF-RlVoNOkWBw29sfses4eb39Hix3Vj9ZVFvXhsv-bW7H06sXgLvFLe4DBWNdum_qXqUAwddlJusWHRVZC4D-KV0_s8F0xXF2aWQ0m5xuauZ0mdUk1EfzJ3K6saBAPVGmawmc0CHSFsSHcUISzar6mWIF1KNXWmxonXQm1hpLvsDSgk8m4VrUMHdegIs3Hv8zP93ZZZP0mH6UX3qHOg1VWrfIrEBc53dqFe8wd3SWcjFZcWMVJKy2K3CZgZP45EjHCBg0nA3Wr1ljZQLcEmYp_5SkFGPvCjJN4WzND57pTijHt5aJYpry5hNEVfu_gXzBjPHkkWStctZONioLB3ctg4jqx0mLKUS8yrtxPrJqn2YD_02zgBFyEHIWvtgoWeT_-u-E8Rdc9wkCqgwCZwrJ57SIoM5cUuMre-SLeeewTd7Cm36VUAsV-Gz0l3emzBPL_s7TfWzVYQI1lgKwMR6OYdZXq7zZyzslbYdzzKXA-06EOjUE2lFFqD6oQdhndOPlq2Xr6pgjAicVF4KlmOA6ghCF9p5V3daEeCtA_OuqIObyPv3Sxtsj2wKWMIyZFU89PyIwDw_gkIx5urUEQGcUL6-XCwG2TEFbEBTCxVbcM3X3D31RokN13M0q_LicSOpXUzVchoNTdk3o1z6Qf4aWpGANVq3I5M0z_7S4bl3MRbmDFn4fE0EJfw9uHSC6vrBF5LMMpWDH0FSMEQg5TwLztoHBMqFOl_Boey2JdD1DAyP2P7UZHbze2GPmhaa7_ETr7b1eLwLuTVpkBNnBI-5b1LrMxkKO5OF5KJ9InTS1K_fLBMy5QhpOVDHmLgIDCfiN1-YLXy0KzWWQKk3X9RDJ8-G9pE-M4BhuspETwsZp6tocqWCe-YyZvM5joSpbqzn-o16sS6WqE3fxkUYkpgRWjL89JG9JIbG-h_eoWMnqOIkOyhiwl-J9ez_3UfiAhsTYK1rvWFv457k4moIdl6xPJ_99oXaXUUfZhLpJ16fqP-M4tiGfwDakmZNQ4gjVzLWJ92y2ur7TLvFJ71GyFHbP5JQEElsYAsBxYj4YpyiM016tO4jWPYJ4MMUH0cmNPG9IKv8a-qD1vyMW631DwT_zLLftzJoSXIklTEAQSMqHRwBtHYuyOtG_6oyOTiWAaDbkdZrcyV71xZqayDhZaOKJr3SYyhdspwK_1c26lngRDn03SqfASRQ7B6GveZdy4L5tOWd0B-T7iVup1ERmV9yGXMn7_bnPhylhLQoFdGnRjn3N5tlhMfHKv-jP7wnwez6b3GfG9ktp8lnIU2X-D26gtiJVLm5WDcu_mg_jk47npaE14yfegEcxxv3rPzUn-iPhJZoKDSpp_s5IS9UrpsxVKqlxROSaZz-wHgTMKXa9gIm8fA030wdq9JuEUC4c3yPaqxyIJnOSpytBkKuqijex2f33BXuJUIR8uzO7P6dO1q_G5ExgY0xAjUCQXak7foV6uKzitQPEyB9kQ3uSE-1IG8wTxVUer6fZZu3i6jvojOPXY1j1UQexQJqmuXBbk82Bd6Iky3PlE36Ko82kZy_SE8StQrRUn8gPYPaQO0xOBppXwp08ibaRVDoe43HHJ4Cq01G5q6ydNROvq3t86b9dPfXeinyn5M5jhRIvavn_WcmZwdxGailHttk3BNJNXbStr0zhhOflfc9f_sxvFMD2hS2idTRIsTcA9c91zNxtrnJVGXiSlHdMw-iVXAymiwBV5wmKQerDO_CRQxLwNT4C6okBEmulgvUd197r8jHtyYOzYOccfV9KWqpw4MEh6P58Uvj6zerxwvDejQnTHPmdOZ_bzI9qyd-Q1pfjcm80zqQubNyEbb76VQ3VVXxYx4BtUymCLbi8AhU1EkwgkFKBp52Yh6vrGDycoBQ7JOdTFwapD0Ykw_EOjEmDL5F8Yqp6_gw5icx450lzKOB6oKRISINosSJX3AzEh8u3CRkXBUXybPMlGRr5L4H2qhig7fXW03_Hkj2eM2kAiBTdc1F60yXT4RyBlKUvpkxCiktNCyFi3R03bAhRSeqUAS_aO7oONdIjUouYv90jeDUPfAm941PTPTxiJxxSPrGj_2WGEjbTx8MjrgmNLlEpm7HCsAD4NR7KAKwyuE6UVY5C5H3uS6E-ZW-3kFUQT-rgoUUQII7cw1YW0l0EVq8p6WaqOa1QlpSgKweyrDlNT6YvKmnU3QkFzDOhj2VnuZ3y8TF3mszsDLFVGUNmafVN0sS0Qea6d3CmvJRbnk1CaSd7uHjk8hpojBMgbOGu4uakqMQw0-FciC3JHKpEMZ0_FDN5WPJRlfGp-N6t2w83Uyeu8cJO5_WVlt6avLKXms7hfrR2c_WcKu8VRpPvfaNo0tocddkOjluOV-rkNX1DyqvmNZHcmC6cdUsgcmS49RHAaQtgrwFVGGl8I5XZ4vIU9xH_ZqLBjUbYDcwQCfy7G2hyQCGWWIFnK2V6csX1INncHHFbSkq7WwHL1Ywpa-2jOxuzRgxiswXWQr_gWfHwU5NcJ-t97188ESPI_uyMU3sBzcgo51JXLVbMJiY9qk5MSVmot3QuIujOGicDHaKOhAvxnpHuugmVbzD9AQs9dPDoR0D0j04lyqBWtVFPRIOHlm8Tx8Y_PaDlsKF6zKASPJoiwFiTw805usjiCYdLO1fe3Jwz1h7K144uIPuXWZW_FvGKVHg40c2FfDuV0pyDjAEO4Y0DBPt9XkTEFtiAfLkmWsN4dSkMhVY-758h2L5cQ5G6ZV9cC6M80az-pyfRZQYZ4ZcU04ANT__Ycy3V5gH8crE-Zr5uz069QLuFqH-lvPEX_OgP0WUbzkTUjIirAbcWuJ3x3rGy2YqLOpC-COiMebW0jXo-KBKgBWT7VAnAu_nLndN9ODAI_afkzkIrWedx6QH_F_wN_ic8hzkrs6yASqO13A0440oH25rS86iY0lwK-OlNVJspFjnBlxTMRdAvhMwo4hvz-qUHHHvaJuNI8Ja4aOpW5f14XYJEyCsxi-I0C0nq9qPFXi-DrqQ95NQVWgY3EYzGBTWUPgTVwoYjyRtZU_Tui243jKAtD0LOsj7Scr0zAllAkhcJuWy1LwJgdUIaf2ny_SXvc84erICP0IZEDxKNSoGn2N1nCff9LtP1RNRHPeSWPByP4G02qPY4ob0OY5P-F_H1p2zG__rlQAdEcyHE9n5JATx72NN7bnSNA7b9Y0p_kDU9EhGcRhWhSxPpleRtMEbfeP4UyE6gTN4N3yLLXyyToN5t7_qcDX6O1Icc76nGx3RgJ4X2a2OpwFZ8aGP-XmdqXHNbzwjqmJc3KTm5axFWlcyyxFft3ipGAhYoM2sevrTkkGDONuQ_4o8eniEQxl8j6LFo-3mwUK0NY753JUQCvhxjQLhXzrBzUuG7oiSt7pMwkv4yzKS14q-MX2C8s1whpzX2gGYLkeIc95zMc--9n32Z-YF6QOuAOD5yIyFZK4dEIAJnnGsGNrJnbprn1Bg8geMAVmAe1i_olEKceLssIw0fr0dwXbM-Etw3PjvdjiUv4MD-aokIJSffMenIq7FNbDq10k9g_Egb1G-iQub8Hrssn__h7Nt0zSgQSsU-RFtEyC_ZicSNwP9Rmdvn0CL_qvkx7OZCqNHQb8xqyFreH681bNihvQ1tSK07Z6W01snJqI2NOQdtBJUByPy8FRNBZqjOHEBOzwoyvSgUDr32Ck2b2hJPM235kTZOkuGglAL2zg9T08nfbE-Iknw5p5BMYOgPgbWFJaG2FyTlHQu1FrrF4yLpA5suFzy6o9okMbH0TX0om7wVf4uulKBTqvbLQ5djN2GJaYVbZ2AdBhiGbyPdfNs6pzRkvU9JQoOEtx_TSXqVyFrqYEscghnxtil5_5GK9K6Lvv2dGXepJFNjzws3M_NptihePrr7HWc4XpY3vs0nIzx3is9sD3UTvH5PbTpY1KNR-YWDqZtaEmUJhZLxvxQKm3UrDvrUAz3V_ayECpLYNdOJIRCnnjtJIBK2ds4d4WN0Iv0ZCIaY_EHWFEuCM6uqM0QxwRi0v9CDnKuhUBVH1JfW0n8S57sRmbu1V7TdDqdoFUwUZblvb_kb2r8muqXeT6Q_5S_dPiPIyS2vetjyyqs8iFn0-5GM4ostQrTZgVN1N9gRE7Z0t7i97ew6HYQm2WTX0YOygbTH3wVXp9qi9HDObvBo0no3SARXFmVv0W1I_u3LT1kczWrKSbYF7uH9eMuqVuAzXlf8ym8uAK2quZhjR6t7YlYmkjIHRv70LojuKnqN5cNkBSAtmT3ptOLe02WjsfE0e2CBXG8O8TYw_z5FWb16Cag02eJ7asLM9WJYMoBLnjC9IkC-y8edfX-CYYrdm5s-dh_4ZNkPYYFHqTq1G8kSw7BPDubMm3NFXvVLsUC1S1BpeP6CEYhB-aZQWdl8wRCyVBGDmz2hrRh4AyfI1W6uaHgUdAp0Lrrizfm7dPfnUT7ioO2uK5J0QMrt2OU2G6t-W3ZTCLGDgRot7tj-uN7uNj4m-9Ykook7wkbNmWc_vv_KIdg-r7K4Hn0sT8a_e66Dme4NhLGQ8E0CT2v2WEBgNnIHBF7WjAYGYfMPCHw-HTPYAU81J2M7bijP_K9GbMMaDTLziqFB7HG7-GJqd24ep2-faYYL9GT_9DUXUTuL7KMMV4RBxnXmsJDSenBg1TG3R0CDd6joNwa_7vjtKEFV4m-l2n4H6EOrbW3s11ITp3M2khjBcJHokikCEybu6Bujyp7CzawGtsfGDPuoKqLYQip1X2S_ui1o0TH_o3j8PijRToboV6hK7Rnwqs53e3rnkug8Gm9GXoukCJoYZKJOnw78eDPFS2sKBEF7lhXZcs1cAR6xLOQc0sRSUnruunaBTNEr6RLesDs0Jcd30yfQpUifIdUsJ9ox9kzcE6t6lsygoL-8vvWMyABH6iVIp7j6dNlz8uLZqsSd1w2E-aOdEfSywNXWq_ufUXFyogaXpaZ2OnIdRymdDzVKrP86jSKmdTuM0-pGEHaiSe-1kaUaErMm6ONQ_ZhwcZYdC0gPSnOliwDvNf3YDT0oDUubES2LkW3r-tfULl6xrAzcs0w3EksHwVKj9bWyw0GNXX-jHrVqGY5rOeC_XzyIF4ZAk1l_LshqusFZ76ua6j3pUU9Gu4DSrGblILnyUZbM_TGa4i-Ey-isuraf91Hjs8_u95ZET87KMuRWmMGgtWVJ2sWOdn-N3aHkd-uT1eG0SLkr8y5t75U4d6jR2JOr9VzpAbsiWvybdOSh6hWs4ZuhL-PRnJ5xosht1GvaB-LAJkdX_R1GNVswKJt38irOI6aVk2ytDHjex7-k9Fc_KQNA62g9pD3B--XEBS2wxWAALAHhnLa7L63bx49SCVH1Xud3SDXFeJ3qWoDPTnrMagsAtMoseVzfJPFvI8prk1gbkN_FGHMcA7i2QAziOjL5LhAf6LyqssLa5NTPTCPNYFqAErjjKL_Ij9pKS28kcqQ1JPSf_c0Qm28-gkJYyfQ69l_keiwcsRYBAmNPV33xyBWDU32S6zvw0P0R5ZIY2U_SIWabGufr4SpRi48YNJmEYVtz--54egwfqPybxPPOs0VuqF9ZxMMQVFwUPHpvS2x0TYFTOHbm-C9Xcpv6INmg1dBMPmnD73QUyCv-Zf11llGJGKUJtTWnFLX4HeGZNXjbE6NwJqSn6zTVlHlO7ie7EOREXI0IzXqxxzRu_6a0htT3nvPagbqGES_3E08KhUTfZ7UU1DU_n0TOedX2nEPQYN7yYAeo74caVRKwumyglJnDMJGmD0fJ7rS3RxrJXQ38J5xXhKANL2wgh8bxCOcFmTtDRtnusoyl1ktJ94MvahaunNjpAm-g_OxBgNXmpbapMYFINkyAXya34ktqjxIE6rmjAzU_0E_AbXsXIUnPNXpJFX3CrFr1ytNYK76fnJ6fTDwOzkylmIuev2D_N6YjuTJcG8npJ9YFMln294ROYQYsS36hfyXUWuWJPsaAXyRcozaztdcCMVimHp6S3hzQL_RhvC7yGR4QJVggHdDwtdoxbM9qb1sS7PZvxyvPRTTN8JHWgZimpo2_wrRZZ55kbAQAlGkXuuvW5CcoKkMgWdaIJ8AiKW4LyzqukytIY_PSG2HWXWyRLMomNekrkyru8mBhe_GeU9fD3j3KgQyE4Lno0MF7jYWxmmJBEV616SblNi0C1tfVcwLXQThyhbmsau66ydLfWHK9OToHM72YJ3e9HDC65k3JlHscbJI7q0ZBEFF8VbXU1a1VPSM0nuDWtklwtHR8oGt7n5RXvvSt4FuTy45XX0wBY480QSz95dj1HKmuNSZc5dVk-EaoWRALCg_DbcoK9Su2ix4BB10AQ51UfOn_oLUgwhhGFhaTRo0SYM8DuACZi74ny_L2ZAKWMVKZi1e-hslIEObbO1dlUaWdLPHTuH8s7RslhOfi03Bjw9H-2mcBmIEDCo5PGrHLIoWt8HFPRvDT-vZKkX7eggT3QA0zWxw-4SgC3vjlJn6uvk_J-P71Dmr7XArrFkjsufeWK2Py8MGqWOtcFiJQtXSDhfYoAfzGZV9f1Z-V6JPSK5w1sfStyay9172ss9KAWMFlSDCB2uXY4t4Pb0kAho-5Ud0T8FqqhE1XbpMZaD8CYzMcDjYUCFpbZLXUr6lHeiMN_3Ng-CetRQ-FprVJ_wQVMhY81PvEiIXe1kq8BtmVPxXK_tFPgzOUTuF_O079IdEvV1XBX9mtGttNsUZPrLsZZcZ3p3tLwaowOX00k9QN9qNy_VQdbANg5VZ0q4zVTK1Hh2Shc6nxfA6LlC_aCt5T0vLVo8B1fbPwNIsDiipvH6Ojf0r7YbMTSCxsextAAzpJlXvYmyADdn65Hg-_4b-z9HaXifMCZRN_P9Cg-h7BvUfp5dFMUdIcTH54hMFyJOkBxh_WO1Y_7cFcevwm49V885szhE9c7XtByxp8SfrbKJ9-xYcwIx_nmZfnFr08IfW603lv7DHc0vHNWSyDoWLX5AFAsDv_mA7TmOVdikDhHXojxq2P5SjP-N3xqZU09-7VncLOHqKL6CbYLOC6kQ00p8dxICcNY7rc6sHCiSyCEKj15xpCJZ8I8FP33SjQLs2xQzDzVuxVBsD401XQRAagBn2zTUIrl8MIMpkh34KOpOO7nguchxRRc4TvNdEcCvraKR0RJHTYfW_N_LLvYHw52msohM_KySnwstzdWgAMPQEvG-AeL8iIiyCiI9NkMBvn0Um9x-zi26a5E42AFeg-pkEdh3ItnYyEIsMpKGQtfqkY4rkfKeTrfSTntDryzYyOc3xnCUmQSTSBg15ZTMtx4ZAmgROuMtCocuEQz-4MOrEm_7EIzrUUTX4_EZHwKOlCmh2rS2XdLJOH9tuQGECBtCkX5PqKXeAKqldAK_KhTSkIwuBDVqToqWidD7IjLat3DvRtWAnb_YFBQWzOa0L5Gwn0_0Y_WwMpxeONoNiNE_gAf9_ZHEf59PZbv9tO4mAVuv8OgYv25WeA4h9x5KcN6UNBqbA3OcIWwcmLm_214nz4K7gP9M9lyoI7cy-NcB8jYFyPEPq-e8lfvcsPXMjn2fw4BqXdIYCzwsLgXAHA0SR0CMiNtZyM2ehagBK63HCLUu3BcgPA-paBOFScdQcEoHOLN2FZo6O1ynbDauMc9mSvr28F__LfKTHeOIQjrhbZh17r2Scy4nsp1jvHSTj7BO7u2pXkYAHphONfxnp8e9eI-gzSGMUcFcDSyZH5Ft1Ab9UeOmpw923iYlDTdeeXuWJDQYEiRyaUiDDd11S-FTrJo29pj0WP-BZMqQ8LzSW_6YU4EGX-NW4riB7_i0Dxcfy3v15J7OhGY7P2ut_uFqWvIz8e4fg41N-jTk5XDxzFGlxpkBAWxSv3XwiyfudgTUqcYrscXBP8DbzDIHqbBcfGFEE8aVDrcOMZ1OTP4VlEv2xp4tVJ2GcFMBSAaF5qvZa43Xxuo-s2vXx9sf-KR0WiqQkkTKjh7hAr4Rj-FnOdWBJvqj470ETdfO8RD0lZqYJ8ENXcl1p5lNYAgARkF2P4Efv7H6_g2Ot9OBhbLdHpeZZSQnEO8yhPZE38aNJw0ii25Jpc4QY_FIMH2q53alc-UNktrZGgoCueF59i5RJljdeQtHu2hOdCjq0-dUa2q_QpQ70lE4EiE5KaWP3ewOxJnlkuJn61sh4JCviUO7XNfuf6IANxksOSOdFr2p1Khd35NHZwefGvKlNar4R32EFR-gxPRu-MfzKRd7WUIzG8yBoL1ksfyuG2gFTDb98BrgpkO9jywK6WGHysP2UQCwKkTN6eb3yA9JiD-BDM6t1MSzuw2LFbgC3_DYQPBAaO845SbXGEtD2kk4Q17yNvsDTsZHKva3fDG5VJu_-JHWQJN70HB00tbMDInDxXIKSi2YUhveWaukFskVSBZGWBehwrCgck3FYAUVakYMg8sA8rpqlQ08h37QzE8z2vC3wa98uDku8BHyut8752nYmBMYPdCdpG11zjFX0VvddqhwuyRzWzpLbFtUmSjS9_7OOGqqACON_I_AEooLm6WDtsl3sQOWiWmIshD_zWMjGHYLJ1ZCOJqhDmQ3bv-9r19adeq3NnnOh_0d59Db_Y7ORVZTiRrjASokRxj3nlAIPsA7LyfQw_vmm-qT4unKZnRZySv-HFAZIcokb7lJ6kyzSyRxzVtOwa1gArmwH90Gsa4qDBLPABn51Jq9pqcK7KXx5uAcn-FPMi1_qgbAJwqmihC1CJhPPgwY20ypVmK0_hjjwLXl92v8nwxpUzQkyraDP062Jj6yB62dB2wB79_c1cZLWrIJgKv-CG8dg3WmhUftWtatBIavMTXE98O2fEx1RfRwhuDQSoZvG4KZYJJxq3jxtNjNWHx_-0PsTLvg5xu4VHDeQNofm4dEJfzqvlu54lt8jIlnuKAimBuVOYXZ2CB8Dt4FilGtl2bLh455KNY5dn-6hLE-oFeuBQE6vdsRq-cc0WEhtpZxMwnLX70PdPvvTQcuV4KFOhnUwbG5sMuwIYJucDLx8uSBj2_wcM43ogLz-HKMx__QYGPgObXf-mNasQbPFrJIGad32qKla_Vw4sN6D2GLB7dgSFCHvh6kkbQdrh7T4fJ7lp3Si2hOK5T0wyybZ1Qt6--gA-czXpN9jvuMGHW7RFsY0RC0lPyrDGNHB_hGL7xoFxpDf-Xwx-o6nCq7aP2nj16_EBotYx19ZUrZ3jDezCr2u6B7E4xU6qSpRfIR3cd_z3rALpc0e-R6R-3Y0ArHiG83-SDMn-WnborGr3dO3u7wH3BHisJvCxN3aAAswl3ezqyCKIX9iFI4ASVsIkuGhUmUD941te7kclCoUkkaIR2n-zkr7Zr7afmLevBTvWplECmbSEIAzP_0dUb_6Xtv0eI3cp2hnCPf6L5C96wKisDRvF8ufcr14i5vqB-E-PMXptScK0bU_wU8gslZo_fpJlFpUct8C2Mln4DJFa4JEAUtVBXUPsTRdadh_duUYMAq1B5mcQ5VptGbW7qjA4Vk8lnnAI1ZVhpzM3LPB_e3YROFo9EqT7t5I5AbMt8RTsjVOp5MedRz7Kq9vP2HQPnl7uZDGmsZdLHnXUIpbzQqBIVk5T0j-S56HI1ozCBznCOhLXkTXAjJKlXZdImowKqe-U7hZ6jiuE_XKIhrm7NjYLzXsXQXMjapj-tqU3gqMKmKPa0E2cRr11VO_6546XgAJWHVA4wIGTp9DDW6Zi7RUv6PMbzfngP10REIopW7tPpUobFtaQbERgZMqoWPH8KizqItLiffNSCLKcnwYMBUhkzhoSxvxdwrmmkn_mr3tJKRTZK-3vHYmngNIrZCGwLZba4Y5Y8dk7GEHHOx4kRV3XibKuHUJPcIjDvGePFBZb-F-PbVkK4u3CJvvMIUXx8Ei8BAdmEKcoTfOQtGJcw2pCqev1dUiM1IOCTYorySOyHjTtjuTolRvGgV95rFY1lysFdxdQbuQhVyfikymrsJvgS8NWI3Dj4TzwW9UMpJCzXEzpZK8kbjw7UclOyHGcWgRpl21fg_VtIeyEEgK230qfCiTq7ZWd5Q3vPAh_aJPEIxmiL_b2JrerRFKrPRmrDnwGXexuRgHmXoi7bGGNTlNHd5L9LbqVYRn2LDuuM1VC9DSqLNBv1FJGn_sIQ3YsB8qU9rpg3uEaDkEQOsSLl-_7wsu5epF99b9mvt8FVWWxTRaBKYrvww_SKSRwtRo4a5tYuvhtsLTwUsBQdxrUne-7gwLFk1Rl63Od8P4-XMn7XuUYZ8MCI0XAbJfG8K5NCm5hFcp8EFOev9UGCcEfPCllazAXZWHTBuW_jOGN67Pky17Y_AXu5uI-dcj2I-0W1QUIx2TJDcFjsZaFw3lynGdKyimvx3GSPRCGVh-WUPvRg_LumqbjN2bkht_TPC_krg7GTJwO5BZS0kW889h0vGoBtE032FFJ3uFgPFRBF5LfcCklIOnABObOIo1NkLnptvVld2UtNSDfzJTkk6gYOGncCYQycIt5RdHzAB2GN-IiWWwPMZb65ehiJOjlRoyeIhveTKSJuGhnyRSHweTUDK8gfYhOFOJe4U0O1NN5CKUnl8JVJAPExkqhtsQd2hRTy5H7MUt6xKcCb1Uh9YSaCYTT-8ZFyKUhgs3gmQ3zf1l9-pIrpfjRoATxAb82_uwoXMyOLedV_vhFbqtFZjhKc3D3JbiHcUWrRm9YucEmdRb8SzyCI57jHVTlNu5M_jtR9KQdpI1LA6TU0u22hdWOtVm_gGa1aV7ADZoZLWuFbC9GSNtJhvBkXs3obNzPqCS__fJR2eBH_BpKSYh2J1rWBTQh-hOeiJNsVN2c6cJiggOElTIScRWh_WNEobCBGLpMx9C4buoOw4uvt0NCx1bJVMFeOnsMEfIxvaDhE_TCnluaVAROLZzRwxwvZRrOZZpxSEoo-uetN-BcAZGwuT4j-oTLPo0doB9BsQqABRSnQ6NPQ-BMctQE0aimgYZEu1IZaYDb_9Cqp7ujYWpFRi2pe2py93txSaRhbs55WJYkMMDtjXJL26Od5a46U_YuB0aFAaqluy0A85ir8Dq_9KWq3HrI1bwggpPjqJnORFitt3v-yr5pXDvXGaX5oDz0pfUH3KAlBLsjRvW_HCJM4goC3qgtArv_lhisp6jYXjbEKT3WN8SY86aWQJzdYKyuFUefKPCkuuI2dp1ucqST0L8SX2L3iyFBovVz4cL2lEVQ0i7fXYGK_gWcsSL6SI4QFdIYY5yh7avv0-cXDF8KMWAACHBwhmy9HXsPVIXkjDMj1OZxHjzBpwCQ2Bg8LllSzkeuGYDeM1FxhRdclgpCX5S1feARFUTZgGxVdx11LyEwmmQO5fnzu8S_i4_NDngpqqzvX704Q2ekKzJZc9wkrQ2gcvAe_tHY5Oum0zx4KG_TLpbr7WPr1PaF3LgMRs26WiEtzktda-ch3aJL5E8Y0B0q7YJP-YMAy1cuCVfpwj_cEK_TQiTLoQTgpCSqwiN4aZarciD3GUpAmmomMNRALg_pMkDWC7ZUd96goO2_84QpL8Gw02xkpQukqYA1lOwY_aYyhJ50ycXBjYQFazx-cXEC1nS_YkgbtCJS5tiZerecJT62V0HpWVZ7RCYGf4XE8X4FJC9GSGfCitjEn-JNZrcLUyJPjD91uTtZ7Yucrvr4rRLeoVLKCxqFYEt1UvjxhQXCf4ie51ncyf_mGxR_n8lVlOijiHqlgXZbzQR5cVX2UR77o1HlvZg7TZcin3F0abHNwlNp9HG6AVtROi4woh9DVqfIHvqhuIhkE9CREDc_gkV5E60F5rsUVudgpKnzqGrm_QhvK_1fmyTSMnvyqmCiBttN10nPvQe3rUy0X-nUTcQkEN2zfi4S2m53dboMiup30-1wQTdeiIEEQvd3CG2v11D64vD3V1zupx4W2_jBLzaJzDDvuacesH9Z646hcW4G7SMeYWJnEHfE5n23j61kbN842v7UV71aRtFBk6FFtkJckYREaVH8IK85A398vT-cT1sudspPaKFQz1SUl5PABsd1ppKhoWqlBgQbjJxub_vw0L8y4r_S7vBEow-wzlmy0DomIObzyzMA0Upz0DAGvjFHbKnxzPnvbpKVDEwm9uf_DkUjE7JQHNl8UW6t393LOgWdga9ToHGlCmtvjXfo0-DGAmk1E9A5hQ6nOjs3f9YpE9Zqe1WqCrFy-b3DcRKky1lrIhiGrorYxPZpaWt8GNCForqSCoo4R3G5Ky-JVBhjcfQJ_iy7Ihw_KNA7Ut8lSY3rnBR5xKKyCT0SoTI2szVIhUNQ9TlEPTAkgPTGTmUxW_dvVhACzF4IzEK-cFJKyuf_Wp_jPbvXujw6XFmSZ1OEwi4HxcmLsMljaF3aEvFcobIacbjd_2QFWw4MyS8Xxki975w7EGa7Trb_xfSUwpUafgoTkYK2emaVUNvyOOALTvfoKxHPXXPiPVGzhcL4IzEFD_wwlYdRWKCOsmjbA8bjfohFjk0T_19G3fxtl6VBZ9ldFGLxTkDzp7jGjfnBkDn9d33SqXEettOatPiPPSZIk6vuH7ST_Ppwp9z3reIxVH7aXITFYVaIq0b7Lx_R1rFs_utDiakOJ4DDTlYAZqbvdqrfEQgRspT7umKhjwUM69td8XhNn2RUq-nrp29gnyEkyGY0fqhCopMDhdqzHXTd9lLmpXGZLDykSpVRvorU38tg-5hOntZIt5XxNNalR3epgh5wHAoFngpi1XW4xbbtPzrl0FPYwlQAUSYuVywCpRaVyoNqO_KxergBQW7VvK34lOErjUYjb8NnaCl2dWMQ9cRiINVPiqF4LZvrBBzFVFK1fClie7ujixG7uanhbEEWgr1VNbbhpccqQK1XCKYwm1Ghv7NVT2E6ye1za_A2K7z7ihassEJUneQIMnVcD_ZgsI3wwtPFrVVqjQACws69v4tLWech6ppYE_V5L05VbwN7t2xYRA1Jws-mg7y8a4oSBYwsSRNW7yDQmtWm0KE0YtFe0XKqbg0zPFB0oPH4bAmmAbpVTQeyFyMJ2klZE9icqn7zXVne41e8FeGaBWnt1uebsyED2y8EAZmYZpWrEdf5q5rc7oMeBdzp3kDl8xdNJ44SAXZZuUMpKYU_oYl6a5_O82DFRYAQUw_H0LJ_9YiV7p1wWWcrknfd4weYoFB5MNjxF-wJo9UbEN3DI7PNbpCbCzyDu3OIvUdwk9_810a52_OV1uQgDYvanfLYfWViqGUKg6ICGArVUgg5qKoKrbVKk92ebsiT4HnjK4qWKx4nyIuVGPGO2m86aSi5CntjQq5kjjvLjRd8qAIfTd5snYFmHMQgxeuu636HkwkBNgRWwfKp2fQ5MH0y1x-Y_mborsHjYfoxRwC6nG-EqG2DZlGk_tLaRJm3aefOI6E9M9aorSG0Jy9eagetNios1fnt2wJ-nrXVGMmqP9aa5WkneA6evkf9BJ2wpzuwRyEBYdhM3eFoNrlyNfOq2HHOIJMgs08_yMZfW78_yodF6e_xw-4eVyEZ1sV28_jqCVOeQb5QX4q4e5Q3ITw0VbF6m1FyztGpZgBG-b873vyRPZzZmJSDaBlfTAq1MClNFYlPoO3W1RRb-9gsfLgZ749S8Z1GMZZUbs_zFnMvx3qEODVX_r9AgFPG9bNixWJC3BSNLwNcsS9LJoK8LTe_43BG27AX-wVR6G6P4o7KukLCAKOVV2kYYZ0Ngu99FJH0nV9gTk9KWDmZNeMtCaojrayGbm9E4XbrweagQO36opYKlsNZ0Tf0sZ9op3Mq5_6EXL4oD1RFlkhzoePPmcLLWiP0nXc7CQpTac5vaWXHKhMjOoi6vJ_XVHkAU36N7isDgaDPxmxYJqGnMQWHMbdMmSbC1xmWJ1cMsfDItucdR7ceqpTzHJRy1J60qqRb6mJ3dyZQuL2gd8hHKIjw7SNWQUU1mriv1NyVaIJJ3EB6TcLXyu7MCc0xipK1ZQIumLXYKUl1Vebo9Ld-_BDpY0iQqxSY9135824qmrWMd-PP6Coh7DhRr2OXbJ6MVn8tcUsBwa7GzlCB09mbEflzlHcRk8Yo18KYXsjtSy2w4Z7VBYGqfJqob4Ah914dXaiMoL9_HjkLW566fiUNlL97rlHVLycGEVqc0dPdrxODoguoXpRHZ9HiGo16uFnT9DAUgbOZd5a9aBwV5MZE10XmYcir1bNgg2crnxh4cM0ob0WQxQPXkpP4B5nmly2wprCAy9dyrLRtOXGYzuQqO54mY1UEjyZc4zgbpMGeojnyVWUW7KWfe9x5DNAAuvGoKLtdUu60qMTEADLptnpwu2yhIF6MDGvGSWEsOJ43v95VTq-Bhz1D9tDd9o_eyRa69v66XBILgV6Snf09uF8t8NZLp3OnGtKEuFjZluhHxBgAipKHSfkMrFXIqa8JWVYsxNYDgWuy3D9YDtZD2JikavId7kC_fmg6rPGAHxuYX1lDwbg79iyaHVqpCubeEjyKp4pLzUvZTeLTEBANZ9U_MdSZBEebAbb3H5xO7CuZQjJF8q1uNf38f2j-0idg6Oovn8R4z71Wq83RU0HmJMxHusfatKFAMWmqvFC1IkfX8jzlLizMlsZLlMyftvSrkqQQCnKCQ14_RUmWCGlNG4tTNMFHyK1HlBh4X7aHlHZrdXcbvpgWdaypeHhEF-kT3ylECSppvnhhG3zeC1qCU8eUkwlOLWmKLq16IvvXB8nmMi_tEF4PTijp1ds00zVtRGKaYLOhgA47g9j7q5W7MKgt61VGx7swjSCX31O5N41yRAxuoR4o2AfG8erFAKcnhsTYsssK26Obm6NjyPRBZDLkK54EF3wHoQDWfUZTJpyf-YBBiVRqBTQFizI8yaHwgYr-r3EW_Z-1zvXXK5eSmPDzVS2pN9ztiKLvySQQmJkyXif83XqtATaPFAn1r8GFF73KIK4Qt1H1PCDZQJYjOFVNeEElyKoouimi6FjovsN9tManN8R-Zn6_mlX6QkCaE96OZruT5ozlvqats-VBwkEhbNUqKiCUokvXGGCWFu9Uvu2_K1BmsOmvnGa6ejGW0UF-8zJ1lYmV4YG0KxhxIM1_GYeQlmDp4vTY2TH69Q5ejqPVAyF2Ub5-WsCQ-sM7L2JTtoelK3LpODpruMdX7vPXbw1mwhojeO6T1ApgOSDJAdLyyAbiG8Epc7UaT2-odYkoCJ4QUI3TAv6eeKATPZsuxqNeiBpKKOJwHCWVfnIOpuEy0QmP8-hwYmc4daFtQkGtoV83_U4DD857xx1va81S7TKM6cmwqtzZ3yIG7hGNe_LFwrPaSqGIqZ3iKH_CMhMaxKIl08fYEf92LeH990XAigNG6z7QeHlcoMuh4oK86FhWLXF-KlRm1GhwXRY9MHMrIbzGNN1uiQ47XzwjaRC2OChS0eygSvHeulQxhLKpGYOfcUErO3PknwB0nkUS2gkA8RLaBcVUxMxoICdekJ8YzzYsvujQSLR8C57XYGYsXRiKD3q8l9IFWPMeBw9p6tI2L7CubiZVVQqpi2adeLCD0ANpfOqIbpzEqw0yVk3ZeXeDRuTyX4RPgZ111UqicovkOU_79TtWlipgTWdlX7LEEDD8KmNwwdEaOk9UrJnO5AWFge-9-dgab2hICr885Q5XDkbCA1I9BTZwKalrJfzR8X_PP8gN9_wTmuCpYj0UWxX2pwx9xvNobKoGown0IP12ehWwJGOyNe627pAy8o-KJqw0p_hLNCQMyOc9i5ZURwzGD8PP--v3RzOsP57SA7deN-lHKq1Jl7_OMNrAloy2dKLJZ1RE4Xqk_GEj9C7KGKcWl-CunyL4NRx5KCo5wwL4sjdtGUC3Hq4Pi4jB6PIuc2xgCbB8nYs-ZeB9qGXSfLn_PQW550uEmgbOt7yLsUONQG6byzM95Cwdlxw-m78fO_CQQdm_5paoKc9hflyQRgAm803nzAEAojQdF0UISiKeWkkkDqFUsmQv_aWefobeQqWg5xxVmLyuiPvCDYg_aOL2bBgr9TAzd6rahSBcupIGJhjpRNsM3Mz01o_Pmz7Dl2eCwwA3DBZIQTnnA4qgekHUxtfItvolpSE9TfgIjkZIqFWLgGWoFIQPxpWmstb4u10jPIT-TLzDaNRa-7l9xfoZQYuHPPqVP4VcbwcvuHYdkTI1cpR53JHbprYPLr0YGTNC-7ObCfXV0aSvMk2By_qPz6AuErIhYdeL-eIN2Tnz6VZ5A1oj4V8pB5sfLtkw2gtik2Oo3JY2IzKtjrfo4K9FfcPZ1clNVCGnahUYs7JqS-d1aVTM-ms6TV-lD6Q8lJz-RMkZAd_CSf5-JhRyhGqA6M8D-Np139RvSapNAmorQVrn_9UPgD0dIRf5C_oxwGWmxKG0awCE3Y6QVB8z4camkWMvq-5kQcuXGV2_ep0vycv423NRh-chMRR2Ni1Sxma34Sgsgx_Bbq-2x87ot36qWJ2jRGvtivHqWPZI5o69Io5R-PEh8NBPnvRq9bFxqRUjfO5G1HsB4Urdj6U-E1r-MsRNKuk2V--kgBKyW-50BkoalXnsUzEbL5UMjL9Dr_G-u5Exq6O1aJi75o4HbBVhhVSL792ciSma-M4TJo6M1Pqe_45E1IXVpcmSEGEB3G_L9HsnuEpsxOa8yqIYNURYQOof_sJzG9bOYpnAYtUaHcaFIgxAzfXOZREaPH4rNMLnX_FVpiL1clmzyA2IdN7URZxp1onwrJvRNz86sfC14z9rtDDp7J9PPbkqxDjB6-OHRyM3EYA8VzRoFn4S9O8LMnFMK8VhenGWyNARbJdWCE4t0txgOJmYaIfReIZXigAhKGSEz-6jTBNLJ-KDTJUJWFM0hS5BPw56dXSt-TcwXkpJGDHgLkFNs6i7bozCn7M1mKKZciC0zY8iqyx0FYkTyyfhHLMUsJT_7W87n6quzdMUT0S7XOzzFjSCV0zwaQI-oxnzLICnVhh4lHMUiN8uWgE6OHDYgaFxCrI-reJBeGmm0uydh4yAJFwtfyouz_4z0C_T281lT79sP46T-9pHBxHYR8ns-qhArvk4N8I0V5huMJcAgx6fF-uAFhg7nq-uvYP-npVpvxtFYXJ4awpuC3XJWGidsbaN6-dtY2GvEMU9V42gN9XR0HEHjn99jHHtxIyj07HlDq7M78on7Wqp6zZPX0ktNyq2uctmi2F67NzG072tpjxPyJ33iGa655hxlXPQAMi6nsyizbscOchpw7kg7lZd7Ey44B4f2tsMe9g1G4amKx7fB4L25zo4qtMnLNCeXy5iMSJX5yc7B_-vn67tNAva9QVm-AHys9JIabG8lmgZhqGPS_D93C9nb5szg0ZhZryiqmZdjBWaW68s2pkY7Y22KUfVdzjZdtW_E6rCOKSBx1QvUcu-Ty7m50JuOQ1Jgcb1Q7jvjeTYQD6hNX2KMOQ3XqxgvYX8xukfTujW5MUuSQrZxdmkX7fdmNSdIeBYglVR8pX8iZJdXOxzXPfD0XskkVRRN7MtEZyxTV8LgpAJxbbSF7sPfquLDR7IvfHFDW_TGS_7lSDUSjfK0W99TKbj7pYAwdJpmrS_olt0JIJXJ8P_UqPNY0y8EnUsv3bzFw7IzqjdHljqamFxRMKMW1wXMKaPbaMeeSnEnxxK2PwbYzBBSt1lRNvYqDn7Ows0z5sxCoOA2nPlKDIxao9o5SW2mtxYv_xfGtk-HmNtJBxj91hPzZw8dirso8txN0ckT425JHT8Kx0nbBnTuOHsn5bCAALk3MvbSeJT6RkRUjOhZwDVzoBbK-eNFUhoaVKIncyQHDj_A5pmRkivO4JAKBc4W1F7W8tucnKtditIrPn2HFoY6Mccz9-SNnON8firt02HgtTG8atNWqdKQ2VIeXOeLi1GWVzj_UPQs0So4B6AlcDe2nig0u_GyJyWglfVX8s7ZVHlNiW9PzlQRWyU0CfBOyr8TD_VOarEnFASJ0zg2XEeWivCIyeNE4spu-sc9uU7-YtAcI0kzRsKfdEVN5QopuMp-hQ0dHjhAjjPmMJ94An4QsVViTvXQ29oCBiR5IftT1v5EtEU35upcreOmeKcRo517n-SbzNYdFIMKhS1TIdQ5NeSws3PkF4D-LfKBX2ebZARqF7Bksp4pY7mwP37KxQu1lYY5-ryP8fKo7R8Vd0YXNzo8JeznVzsFDI2j4bftp8vbAottd2iei9p4MGCetp5zv_Jnq4VnU1DDE6OCV6xwA0uvaFD5MJWTX7VyiRPfE6BnoU9nk8qyE2_olqKziRsmMTjUehjKHQ7lVZgU-jNc87xJYnFTt6KUbib6sVW750h1CfwMOeGqEeT82q6DFGeQsjl5DIKL1e3ZuCUOAjKX8CMXiYm7dlynBxKJPHm9TjP7xYCQgRWCIeeR2NLWAmUWtBgnRYoypQfkcegYbOEiRJpFznQ0RyzhnfR4yUsQWaR5VUAsC8F6bWGmIHZcqBufsmXppeGOTCld9PJ_LwmL8hIS5_BLtQbESvRJcmrF5SnGQQ8rHiFCiBhEEDh67_Hf-_Ib7atoT6yfV8HX2ft4sUGPCIwpCcK5M-JC-43yoBxcw-Rjy3PUo9wm3CYFb4zuaiY90BQUnK_Dq9guLxRqtTGX_7j7g6WzjNmifITa9h46wU0tUMQwQGl0d5IkmEQnWwOL9XR283-y3Bww20CUvYtngvPXnnk97L0kTrKjhjT4ZQSqBtjdMx2KBF0vmY2W8KEe2jgsf64KczsoeyVsrZ-8jkldMWf5QJ7MZI4t_qqO8MceWpJQk14hadD_Qk0C4DXz0El9YXuZkapI9SRrroWmFzRVr1G7ORR3P50W2yTG52XEsEvq08vAs8datlur4sLqb6s2DNozDjGB7V7pdl9_mNAjHml7_DJPP3YqNFtl4VtveFp8PevyNn77rWR9UThxEqbhgJYZF9ym-ZuCcDpZ8bujQvNfJnauX1ZDfpfvx5SXqjkBkRZOFIjWpyx7PnOSzW5sQ_PYMWhX_TxWjw22gVplPWAyRfepVYb8ju3Ih3W5trmkU2I7JGrTQ3r0vVRS5YoDgzq0MAY8PpoTlWFsZOgSx-JeoaqUeQ6x4zX6Bf8ker3A5JroE5w-fxh5euvlvCBtVs_geJoWYVgMhwjnMLptaBX1RbUA5XK5tRnG-XLoSRfpXaN-IgoSxh3TfEq23mppFicrlH4lESIdDmZvddzZsjitQ9uf2EL-1uf4JcQDRRLXQmMH-wBEiWpldY8Nsnstdctgi3Go33SL4Dty9xgopgk2iZwI2bz1z00mTszJ8iqznC3we-0PsarVaFhnaMQX-2WYoVn2-sevYAgGXBkgCEAIkYDH97EOWcWdWRKDJExpl1gAt-nrkQYmMDmfRDShdMAuNT5603VBlIIMxpXwU2_8EGUDNMFyD06khj0hok2TGhxxXVx1DNySbMqzaXivi_nhJ5gr22c6ZivOTeaKZF9qMHWB7zc7DFvfEeNmRFAlj2-UYUldU7NfAf7chbWXz9QzffptIcOmOc5EuWUd6oYi9xMb2UuumqfkpFrsGNi-thzLZAo0U0g8FRjvyxdLstClGiLXlwpyf_RrSaycGEQjwskXQM79ewkffN1JuTjHm34ycjqQeeQucDu8urxf1HQ6dL5rZ6tyl2jOOLbf1gWkVoIvwGXBT0SK_uzFh_AuGJPr25_C6yY_GMJ4tXxSnWbFY_orkkj8dy0KoUYcY7XjH5j_d6PzD0kVem0ZxINrZWtEzFzjBooIYjEJX2AS6P-bQZthShD5JceByEZP39n1zCDiXzcbl3lAyi9lkhB3Huf1idnmPLpjNFZUeqZJcsudAtVS8JANqJrsGLo6oaKNI2F8IFnlZeDgWvIoFQKUYIIjZPhjItB16U_NrisWiPYR2lIVnAG8Ko_YyiWpJr-bGwlxPm_yrS7pfExIaW_4Ym6nRk5lxtcDc29-JslTMGmG_SFI4w4AkipZO_biwUahXuVrK8SMZushftX2ItL7lX2pa5pNd_kE3jN6Fp7VA45Yp9xZJ7gjG72aam1FH-ZY7aNUv-t_P3OqhuGHCJAg0OsmFlzXP-6PB4nf5ipEX_8Rfp8qmMgKUMYtgxb_iXoSB4X4rWsiFvbNiEuX9z_wOoz227PJlwXIhgXYUiHHaqv48E3UorGOREXnrPWh3UbhyLrRxXpcVHme96SnlnsyyKKixl52eR8gg9pQe04XDjDoKT3nUP3LhXo_SC6zJjtgWj-dzqsAXtc_N-Rw0fRaE7nplmFdyYGi1PwRjTDXB3w3XVokFx57mAwTOeY5Eo4J8--WmRLj6F7_AsnlrGOrUjWwt09wNcDJIDDQHcs9nw5MXQHvnky0QUxTe_TZNcCbLA9V6-nr2q_0nQ-MEYp0xMk7SpoGkOJ8dEDlML2IJGpihxj88OE8TG2uP9n6-30mbI7Xk4n4uIxQk1EJfa6nnXYs2qRq7ESJkFowzfTdBFbUVIvR_GxbniE_mn4tYC4eCDYgfk0Rlyh7gQxBe5v8fO79bX2VbMU4ZZJ9mg8Pu-yqdEFwLnskqDfSGMJ35bzPonz3gqSWi3L4yikWjwOp_vzKy-FpRRKNIQyGtLvkqv19slWRtwFXlA_Myj0LeV6r7POtxejCGVn_lYEBaL4-wtXN-3QFKOtOCx7JUXb1tBm8hLw-uQ8hNeSPbMOGnNHMV6rrBe-K666GN2eaIWFSakbx93LHlm1WByRM9-jf40TdTM3ETCAX1GA7DUFnrwiv4XH-MJDg6xCnog7VUBNZesEnIgfLelYCrvCpqDdCH1dtvdjY-8MoTj4TUPFyeg25H_AA0u6BtK-T-i1lsA-gJ1fiycur7Ohvi8q3_AelisFMvAwHI3s2Q3eiPJzetkL4JJ01Sfp7I6GcZQ32BN6ACV57u5fXijCwQ6DekNnVJqOq6XjPpiAWvlaB7d7qbYzCWN0oCmkmK-m_m4ZtwrNQpCpUYCLdnGBzKdHsUTr7d7H4-amCQ1cnBf2At8tsflhfs8qebxs-XIDhYjinoXdfV7AikKuq05pkBzSZDSI6rJoU1oWhGC2DmKXb58ZlIn_HsN2idx9JnTlVAOjIyzyOz-Cp__x1bwlfLivJxKP7Sk7vSRorvirgKxzYpH4rXibF3w6jhQOv0rKBUEtDNijfMigo_5MMWhN1EKULSSdY0ybjR7rJTIlD8Szys7lhSA1DjjRDuUkixviPJxBjlf61hUBreizWrt7Trh3s-XVbrNqDg12CJshNSTlcgHnEjI-Iz-A_kNTX68PUbViASa3htUIQ-dNrJ6qONxm1OxeHJ_j1H_duNfYaAGB7hSJPtbl4Er1uMywoXc-SNFmxEC3kA63HNY7h_Xjzbx1f2BBar94whyeuLM2fN24Xm02E5QRrlkMTGhep69E_zrIOqTCSLFx-2XWvBPgUo_farZGzabNyDxlvWvffxitREfFED_s0L8dtrlD5rtA8wKv5cPxUbRxz75C4BpYb5Q-ICSOIJes4MHKwidZ8r32Duqm1CfxWfpkOoyWvOCJRi8A6RooRysyZ5GTwlG5Hg0FGZpxR4xnZBZ6GfYCzTsIHe8LPp8EJ8PMobLFszX0vvbxhF-mtE2d0SZ5JW7EiMyrfkkunriCq7SE895KNqRBprN_3Ho4vzcx05y-O66h43V0jy05p4fYrk1AO1ZwTS0djkj66pWFcMTlwky7EW6YlpUpcshixQBAMnJB6C7Nea_z_yx408JHzhffzG1Pw8iPeIBSI2018JwQzK-Js9duH3FIaw32VU9OyWSK4Q0kHe0qzrBNPC_q0BKPcbY71X8psK_8rryImdFkaxADLxOtut87IVj4Twcw3dBt3MgFTUp49Kzw6nOd6JogiUsfu1PA52OjMml2DZ0KMzPiNsaIiosgSYwPBrR4Fjz0_v_qiRWQK_lWY-uCiAOFZ5yL7dzool42y03Rbo-LmUOZVS4-TdyCibuHfuhFgWwjLjcsbEK-z6NiMKSYvpxs06WurY24tkToeVHwD1S1Vd4VjNHb0Pr659xkusRUmRPDdtgZQC21OuEy8bdjpQkuslr9TqimqEX4XsjoHbEk1ew8gjYmUxR3o4m2OgF7QIY-tp6oiUwpjilYnWjHc7grJlQMCfwWJ-t1Eh8JAHHc93HMn6rRIecnxzHmkPXh624rolmTPorK6GiTdZrr2FSyXoPPmhFcRcTQbcS4X90zIaIpLDuzdK5CuiYaIrnXAnDRcwlFl70N5Lk-AP6iQD_0EHgOARydgruvIum2oSydndCYQQpLEsisn9_KZCKGmmhhJkL5cjG3wjOAIWR5jP7naQp4UEXdEWlmh1b5mdHBNNhzQ7OBmphJ1Otd8iO53M-Ygcqa0qt5XSEx9WX604VM9v1_ahbVTleXabP3uZJGUTqiTkepVF_vtvW6_x7kZmx3Yurepgb3i9MYJSWy1lFwuwPZZtjW435o3isAsWcPQHdk4HMv1NuU2jTMcPgS2qqRHi1fTksB2E4atR8KF1hYkmAtX0nmm1yT3fulRYmhsRBcQIVmNpLVcqTz5lfDrejHQZwxhM5YO-BsaBCqJxgcSGiASopoJqfyVqlK5VhRSnW36Fubl_4qIz8QLZGnXfWg4IpgtMjLDRLPexooa2mHWGyNpnePIdH0kNfY1ryXZ6tFlrDutCuKEvZVa9YGC6WOqRVL4Wd1KUyl5Thgp-m0ge2LWwUP3Kjgy3UbJvcR0ATl7H7WAtHoRjUaFNZJKJkfRebfMw7GtndG9nr9umJDa-SVxPtgkcqUxZdt62n7Tz6QPPusBBf60J2Fl_KzuMOD40fDuOLU2ZGXfoOHMQwYyTcaY9t8f7Nt3fKiW2tq9zQEzbmdS98HvjuiGXCQn1uB3dRHY3Xf1BIQpXp79hEHD2HupiuXaW0uOYyX-a8zNCJ6eobf4bBdAhz1JLJ5lan1ggQDOBo1OK2HQV8VSCmV3s6KYLn8vl_zz5CvF8o3nI7PkyrHlYAFvdxw4AZKJF81tIiZVmNy7i4wzjugY1GdHTrWampVuiYovjV4QAXiMnQxDHZLJYtYZGhXaCnrDZOCo1xERRrcUWibawnnCS5SJOdJDCt_ULtdEnGr4n8hMhyL3EG2tezQgAXmIXMcMbGi9iQXWiyfOjoVC1XFCSOeyM0PsLcftgQ1QhN37ZMRGrG5LgomSLw5yQE6hoYgfQ0KvuZcR6d07S8Pw9IuiHznrRSGXJ_tcUflGvkHV-OlQDZB4Fv3LwxXGYvIhBRmF5C5V-lpAAFkGFg17osliFwHD6fcy07obYbVGmYJE3demRcdKiCH-II2zr6gwGxZOcC3ZCuqQJuKuCAO9tOE-MRXFbgHjPydh3ldt8yNEvc5IkkoJxVylEdO0B_vmLtMqF7GDetLGKHT8jz4EOIYheGvzUicetIY9N0JH4-o2RdRVj4_c1khkj5bTyNGDGgVcRAl-5aTRX5Etnj0lLTzWNng0lqLRkeLnsn2YtRj0oA9lhVBNCwoT8d05WRPkOMryF4LvhKZ3o9EbtMOYnesHN5jSLGj28Nocv8WnxIn5B5OOc4rCzWPhDxPo9EpTrXCL6Hl5ts22uMPQP5wx2q9-d47ZNdeEO9PsHRBtST_xC_emH15a7CWJBuhSey_7Ep_QIhw96eANxDrBcV0BXpMSLfyd3Ucy-aLbngk50W8dAj7f4XsSAaTOX_amW87edCBBT5rb3bBa22cwrp0azv8FPGDdMw1n5v55rzFJa4iXJYImK76vKyxemcWx-X2kPjcd8Pob3C9HA_Ms4obZeu9SUq7wCUY1noTTypHhEcdklm3IBfUtnc2NHlTFzDZFR2IMXvI80jxtVaVMOBW2fNmvmyow5X-WS-ZGIM5y2okxYuSA24TIPtaA0EE6UicaGBK4I-ijV-994HtuqewrQkPGUB1U4mISGL9Uk-tCIss_driH8auTqshPW2XvpJPVEusIVz97t7KXCoBymKtV8Ymvldx5SRjvRkuFw9uvtCYvU0on2bNv2gJd8OZa6mS5ceQB0zh04B29GVWUhzcB5pazCuoC1jBHHefmMo1zDmYMpeMED6TsOcU8F1H3QH2THvllvMwwMtsIM2HHn18wVVFj3rwqY1dqDHTDgBC-Pg35N8ou2gjtsosCeD-0MpnsM_fbJ69Udh4Gp5nfDrZsrY2F2-y8fTHQA8wJ_xsN6_RxCMV4-om_NWYCTzx5eOT-U3rx2TKlDD1Z2aPs10A9WRGJf2gvsVmEzn7HKa4BMMCIXCqOdec5GDnjm8UCQRP4Bn67JY15vo2Kldh7rl72vADhqxBP9YKOGoe1F9DZV1zH71SIuI2UiUSj7jgjBZ9DIC3aUodchJxAFCOAJLyDdDCyCxC0is1Ri_evjwtwIYDsJCJl-wAVJbQT7lfo_IyEC4x47LQSp1TsXMOqkFtXaEyozlupNMZI_3KoJH3NZBhXOmQatvtXUvFWaaT4R8MUi3qNWNdPP_-ExQk5nO5oxmxDCaQ3T5WBBRbUWwVDRYGmtHhs5qszcq_rkN5p1_gum3J31-lsqsiL0Ptu2Z4SNEhEkmaMzOynClyg_ujCyZUtw6OLgS4QuLxA7RUTBDPIuojJECWg4E89cVXR_bxmUqI0fevI9uksA7Hu1r-uC4FF7sAeDG3WAL8qRQAhBQJDvV1saeOtY9OK-mvkX1wlHH1XNsdrTgyE0KSeTBClQPVhXalh3Jlt0dtbf0FIYgoA6o23rwBrzvOwl_xxSaFlAqB-XwDfhFRWJk1IiDnMRG7VA4bFpmqKdCwrdsUTwxdOD_pRJKomXZ2214MQrTsVhbEYjBohzR-2oWgXFrV7vQyudDzimnUy07ddC6bjXxrj3_fYwPUtqjDLf6QvhltXQ8FfIyz25Zbz8ID7grtKj0OGi2y4ChSXTBeeiqc73mxA5uaDZaUj_TKlgcxJuA437vVLRiLeh1IQ_nvua0YwSBWoNDkzUwyy1wKeWteCRiPS0vcpuHFR3A2jD2fjBuawkZF7MmRK4Q5eO39gbGnbiDoyWZnxfsiYVqNX38suakrVXFznZkpDrS_qVpPnPA96HW33iZYn_R1jKDcnGevQRpWTifNN9G8aHImRkjb-AO_PnTx1dng5__tvkPT0oJhmtUjeKRHO_zJXAlx3foR7Hmr6B_qj4kWaLRzNP3gN-YIJVKM6HnDw-yJqx6j97ayOvdQjfJcX5YhZ_6KiIf-SftCc8P7m_xQDRMgN-T82wvkH34A-TgyY75KRepmIRFcEbgNPMEufKdt5wXRr8r0g8VKDup8vwtJIJFupgoayeoN5HsC5O3xoIfU58ooYu0s3jKWY9rLqe8R0hTCYCHuDUES2g5o-QPJp8UsD4Zo726Ycjlx2ve3chik4gMI252DXg-le1-h11c_h7hq0mVO0H-KBo7eFL76TgQg9iHU7cj4U5ht6QZJ0pDQ8lpZVZlXQBGewaLfmvcpFFQJ50c_jHzeSldrtmRemGSk0l0rKbb6a-e7Sz5vsFdhaQepyHDLTXIrMi1HqktHBLN1iW4ktObsnvibGoXAQqGxioMToOklM_ZGqaoH7_65OGOVyy8rTBwa-EzLg14qoqy1jwxGdw-GAzzR3v1bL_XnkU6V9Bb06Ro6ciGnUDxOiuW0QdvZuoyAA4I3ZYkxsOUw-yjuYfxpnrK9cYkJhLeP5ZDCWXkuFwOGzNDCuFuS85BWf5hy4N8YdwKNsWDI9ghcoYntvOirHXpHJ12RHTFdIC5YbJDfK0mj6ATrnKyWjGRaAxnofzFXoctBchtDYCvyQ_97yoOlKDw2hdPakrufWajcBRRNwcaBVOk-b2wcStXB-qJFAZiXUgGuL17HG_hmXHofjRAhT7CdNKjxgNpLwdelejXcgpwkDtEJ0_EZ61CjTMquF15L_I6JhJwmyak3FzCoYrcpCOjrAGqlZtUN9LEd8k96PVBmt8KM-ue2Y1rzA-bZKZyVwugb9I4Z8mi8GluMLcsME6eltWHoqnn4QudllLCbV2igivKy_xHUd2jToU6RUvIPjZvqSW4pl137pDmkCLptQgeVoA6fTUH7yW3zoQIQztSsj2OlFxwjD7Heikox2LeVqz2wXGf2fY1MkBbAiyPqB6gbV-ljp665Mn0kEy1OpgW33ZNG36T1hHDpeaLvpHbgjX1ZO1CEz0LBMU5uBFMYLLOVZytM4coiLhlQJneQmK4EpPtrcaObNS01-dD9shEOXlpQdtkYAOl5U283O0q0ZQzpro6IGhcbIgGRrcnT_HeL8OvGTuiSJJ6b7eBLb0h1HKKbq3MpWbZdfv1RbS77X5ZyVAMfzY2xnTzlEILXxTOIUEFGR1P1S8FH2a90nq63YqAF0JP-FqHG_JU7OCnanPeAcknPmtObhaZabCrkxVpCcTQO-glVdWUih75mItume27wzQKbBO7TKjNHMr3944XAVhkW4eqb1MLI4u5nxUvwks0rScZ4X3b5C8Xj5z6X8j64_PsM3KGMBNWmZBDhJsruzu1mQpXyrj9qqhbbTA4qeeOF9zGJ730KVqdTnYD8pmyUOclf3LeKjrKTsc88Q156UEhpMe9sC4u6ijc1Z-1Jtdun9yjDnRdVmsKv48Tep0-BY1lxVS-76ZjtA9GP-w2bbLCquox7ZKXkeSuSvMLEaGElzhOMs7AEtWnVl2_d3V0nfDB9duVLWgYM-ipz2rn1_vEpgvWkNUUpUHxJ5gTqLjqKhrd6L8lPqxsUSWjVen8QhiZMvqPWf48gTjQpydgpd4cgrWMl1PligB3Bgv-LIJxUndaaFnUFHGP-5yYFq298fJc19b5zefq9S0pozQnviVDeyUtMjcRD-rUlxuCRw4nBoiggNgtknRzV-rsOi9yu9gqVzWLo8KqEXZ3t-3u2ngAU-8YSUT68ond71mB7MJ_4pBi0Xq1pT1wFA-uQWjUFtTuzx1y06m9JSkskMt5qHFqP4G3IRMm-agpuqNJOvLB2KC3wcrx7KUGt7Vi85V8XGZO43qbb4BWNArDljdFjMKYnEwYGjlRR8PJpDK_8xnOnBJsytkqxweDYlrejLI0s6ZnzC4-zvsc36u7dxkWe0KnWkAJmjRnsls1egc5VnLo-pkMQkP-gjIRqHZXo_78af1L63iIjQ49ybXZvdIie1oSsMp_waQTqsnKADIu2nsLNhZ3jaz4FNFLY1RZAHee9crz5tn1bjPozCHNWDL0oo6HJVB-O93Vm1jTx347DrmPnMsNPThDbnRRo7K2UbPkEJ5idKTqjteyJ_H0Ubw-OiUHaXXUqBOj0dgchIZw1N-0XriEaTGtC_5rECuJTpBVeMdjZfc49ePxXdLg1Z616olRFTefWuTvn8zE5MKQ31D570kfjRlPwOwxzNaMEqBpmfVHRwMu4pUKWOIUognx4PRsbimrFN7GcICCg__-gSCvPXr1XNJFlN_Nfr7L-dV4YlKKsbmuaxu_oGS_x6gqpXfyJ0C9-5sl--bKgZChF40SgpebpANOpFs2dmNYHpzCIcGL0OpVe6o2ellhMYtTYDRgBxouO3uY8AajrinBWIIPvASAb0WyKuymIXDClyQ6huynMFS9MrNR3rfc0qMVmXgvfuxY9wpVkqYdVcC-rzfUeswOJCDgBiGmxrW-gmz-Xpgfsvtvj2ZO8h4AccVPBmMrB_zROgrFLSjYRs-OMHh3ahqrq-u2-8JWPomEx_JyYHyvtTjemWqMlx5EVJlx8TQtb0zzex1lStU0tHVe1s8JWrRkn80l8_OD4j11mL6M9nr64n3VknvqRO3hCtvO5A9XQtEnh2zbOOeKKQ2_oY-mSDywZajyU6aTnUVKk19uJJOAT57SedgHr4-hjT5xVqdCdr0x7URorhKKdRyCgQqXDjaMt5fmgjeH6jFcYl9boxZPUK38U8hL1H-AzxrcbJ9qLCLuEfn1E6BgfrUuszC8VvW3qq94HCGTc8cMC5GM2Z0gcRAOl6Um6kTIhIMDL7Uf_AtldsgwiXMbbukcFJH3cCNK7QEMrQRlCHVpklDN9CrYwuj1XXuANhLZg73pubCyj1xq4yd5-eDFioJj5yDHKmd-Dj8eLMAT58stHsKZHgKc7uP5O1qn5HIlSVxKvnRdegrsKj21FSSp73IjpTVbCO3x_iyQka7GdiWs5YQUWSNThYQ7ao6HMDeGmZPLxjyuDcSoA6m3QyE88dk-9-BCpx9M9fADzDbO1UdnmhQe-DLlNkI8gs3uD8J6jVgEW3W-0_JlYHra2xOYqZW1wY7pP-mCeZva1ovvBIwIQAIVxjdFkPkPg_ewGJ8BDFNpVScZdJXpxROyvOvYvlY2C2ScuS4IJ7CQyQ-VM6IScSORFkm_USvkTYvCSbAGs7ucKwISdnPYhOlvD90O1SZ_3OELrTS3-HJ9BZ-U0Kp1aDzCYX_Tzt67x_DrVt6N7RJMVjmyw3g24lR1x17-AW9UYpnGgHR7X-J_NZBteajF4DRYZ3VQKUtuiSxwr4XVlMpjEILJeQm2fa6jBNOTSSFV3L9C0JdFoQSS5t5atuoXmroIpbLz5z4eyITBZlMA447Q66gBs5JaU2BQ25uSNKceksf0Q4WTcra2Fg_uVsXbt3FthXSNJ9ZXjHWtZk4NvorsfrC7KcQncKlaE6pczqWfEZmrRLJ3kkE3BD7EVRWPHIwBYScV-6K9rgEenNnt-P5zZBqdySJ-r-XTopffmRlJviOQ5d5jb-f6vjoHoGKemfzPllinreUblCdH06tBQ9bLSINOWy4OD1bRrYNnvb4aYUS2-0vpLaNatAvLy26gI6enJgO7WkVkHdfNIR9wBRnIuWBLu2YQq1F1X-r6T4u3qREvCTqCKbUtXumuvGnsE8-7L4ySZmxjaic-nvLvltNUWWG2v_K4Swmgq7AD2w947owWwzBizTtJYsL3rTbTkozDuczlcxW27HFUVRneB9wf5_rcab5XI2l8unBnt3eKZuVAJpmi47VMy2xYPiQckA_EU0Q5CtIpFTB5ZdSdabBzB73mmDxmsagpZxebZdmOTSaJSIoyJe_2XHJsgqkonczb82aIs-Ui6neoTuPMHhWEiid6ULyQx6J_RrjUNsw0bzSukewk1gy1lEi0FrYar-eD3koRRvhrXvTYkCQe0AYo1CoH1iU5vBDp8X_tSo8bHGk9Db4HFWvv5k2Tn1tK-gWnYeGyMoSZckpImME3a903IqK77XP3QFikDcU_SfEoRqTJkGl0sz6ZXtpMReBrwr0mQIGrQFrcKUF7HExPAnyXK2vJM8Lxpi0qqSP9-h50P2v2dhR5dVMThDLrxD3r03-oDND1P0qXeXHQ7hSgX5TAGGBBmd2ZgBL0AukvdQMn4VtOcAcQHAu0FO2AWpXrcxodUo8BJVOtJ7F_JMMX7vhWXVQDPcSnvfkFw8KOuIk5pA-Rtc7WZzeIRMWxzV0fwBMPWk8ZVqEtLKdz-SO84URh8s06LoUcBpnh57U2pr8v8cvHTWzRLDfAhFDbiHu3FcDPYqN_cG4PiYPXW85hPoc_ykBDn1lzU8jdMSHPFZyw9LhIHOrN-vlnjZgWHNzeIKyGNjCj4n4m850hG7CQhgfHGjyTqxCmveX3OBi5u4oXqeLpiJJvujpLrPtaD1DZp8sJjd3DN2Mx0Ty5VQJ7BykRvEmayojF-knCbAZpVC9tt_2MwdZE5QqJDWYX8-xLQpIwmtyIGpzPW_-nf-zi2rA3I0C3h9dgRHFowyQaNrQBYKysp9Dx04T9Wy4SqwaqV7rJKAp4lnRa2slSEY8qpeD4ZXT-PlI1BSYdFb9ez2UOMKfobP03dXHjmddTQL0IQSYRREdT-1wXESDNCF9vg7q0X08jB82JxWzKr17cqfoHrc_51XIS-bjzu1x_44sO6asiqWPfV_XhhWs2jInmGyqsbOLWEDvajLuF5uMFgKFdN5qIqfpHeP1lrUi-psAiEjALKn-RNo20m4RDq1qJsg1ZET1Hw8G84xCOP0k5KF-bRm4O1uVtoPi1hHNjbtsu53PMt9RVC4CL-QvKvAp1xKN1PZYbxUFasjnzrCZXTVsE_OmhRj24JbOvlwdn4CYgUk682ZPm5h5IIvamXzyVDLMEG0a9uOcvyCoz3zDvN9ruGTaBm2zAy13Bc-R1tuQzETVXbLp_euZ_UskJbFIUCd_WAwPDbxJoPDkZIX6nuZomj3knqyLHMifPeetF_dW_qg9SPdi6-AIjSvdr_PmCZo6J635v5_K-Po_GNk6n-wosG1k0ng_rGkQe3510IbLhzZlHyudxDYRQND3vJxfyuuX8pQca416INDGjjYsFfbmW_Vrcy4ZcOtbRA_47YOKQANWknTTJkYMBNdqoHd3vBjZE-Q-rqFceAJfKTwpTzWFlPNlJQNfBO7OdsZuz-LOtTiAudE3L8yEHsAprSE_3UHHuQJj72PNhz8MLi7UGQWeM9svANU5oe9B9VmsXHZ5zvg9qjHnnnPDoUz7TL26khGkgT57daM1O7Bopvs8z2VtAZgxgXoNEgEkRn39MPOAFXTcts5BlOP53-TJ_ooOO51qCjBqCk2JFvROmtRG6AhyC796LGJzKD8vv1UrSajmgZSWji5VCfcU3KICRCRLoGn74VBMSNu0oO2zXOsNITdkmCPOMYelk_pVls4A9-7zbQk_X34Pnk8X-JS5e81Q_b3Azx_oh-ZAU66Ed94fhnI5Ev7NRssgBJxCZRMd6B78EvVkq6LQJm2FM3v20kDySbSkWfT3axHoaR4S-_S_Qy3yD4aQuCI8Vo6ytHECMJFlZdppXfuyoMQsFSIjcrGbo8XYDOL4WJOw0JdPNIlcp0DvPhjlVMU_3aBLgRG7jRgp_XNMifasdkKK-k4_T_1UW1UPvpEbnSYRykGzVdbM1wDadOSGSbFZNaTsbxnnQbCX3M3GNasEKdJSuuMtYAm5arZ1TBzSIzTwkNxxAf0OssR3H16moS1KzkumOmr6M6UIaidZ1ZW_a9jfC6jjAEYpDUleVdGNyfy1i8I1dodlNb-AEDXjukKsy79hpHLnCcg-k_tPoTvf0QKzFZga-7AQZNaTEYLbEwQRPI0nrOYZEESx-9ttLv6q6PNrJG1-QuZXsJVnldpaEAREUAgXN1Hf7DWaDID8XrzMZuXGG0-Ay4VnVt_0enFU2zTkcDvm9k0x9CbcAKdDmZvNwPRV7mGVsY2FnP5kHeA32qc2O-zz2OdiXjU6ubSZDU87a-o8MBbNaaUkOZp4BLQ4t3wa4BUmyhgJM72cebIe6osMvtANXU8lr5XJs9AWgwqsvE8Q3wY0WiZ_8-qZQLVEs5Flv8QiesRWH1OZ8zktHK_v7K_2Ewbb1erANre2T44NkyJ_MTcIe8kMOnIMx_T_nSQ2Mw3YKimaaqsXJf4uqVPQjBWtFHSeP6J6-D9oKY-pJ9_jZ8I7jqBF9AuqwAEpsfkzv-U3dX_zfd8M1v3ICEZbvz_FvtGQoUL2-kOmqwLTu445m5eCSQ88ImuupNDbp0aEl5z1767M_6810i1lUTHbT4qmP3nzun-5XEuza6VmU7zk_h0m9NWNY6_-2jHXXsPiwquzfIo_73JHg2OMWz1wToVB-ouc8ihcfIOwgj0nhEB9rLCG1ACd7O_YDn0njKVW-adVw_DRgSmpWPzC2x8RdMSsm0JJ8MUZTXP722HNw-DQcUFhqmHT7rK67kaS4zr7m9a8Ozk4A-jBEP0oWTynbU4r47COvXxB_dd_FbRYh_t4UXAZDTpVB4U02jNeLCrs17olOaz6K5VAXkeTxf3rnSFkbWABE8yT2z9n60G9dPYQS9D3avPE1O2qRyyLpCK6WcX21C5icdeEd0SQ_7l8uGhVMXylDc7FbNg19BaT9Bm-wACI5dc66b1jUcehwXazFP0YH-wNfq4aCBEhSCp52nCHQWuEmp-tZehqF5mWz9Yz_EgjjfXCqxUAPgPBKJMHb_p91rv-UrX-YYIS1RyDXHjYqsFeBJVA_HP3CGS7mqFQDxUCuKAxqGypILiRmMCdIVUetdm-6xEA-zJuuzOyk9WD1N4Xzlk9y3u8HRH046aX_FT52wSvIW4MNnEr5_JhI0bDxdJlRv2zaDaNwZIvxUiVkCeC4hqGqD0jIk67OStHlWEmoIHOsJctnSvXdBDY1gw-aHdm3STRxA0yQapNzXWZfDyMU_Z3_7WXlporQfApOqx-Sk8paT5T0YEEj1NM9LVjG63S6zxGhYi0TiM5gPQBLsT1FhNxUZkVHLFpYeAur8IcMXq-3iTUtw_uUsrTLdt7BGOAVbwI-SD5KhE0gNA5i0Uz-JjMTDAaFD5x_i8eN02FHWWShg3ORhm83Fw2JLXWBjRdrBzKA1h60fsSyRBApyrk5h91bnabs0yHqj4ECO5tVc02rpysDmXaueX4djYiVXw9ykUtx4eBvAVm67j1zoF2EzZNFMOXXM_deD1XBPYk-XXkVsZmjTbeWKfuLYfKoL4CUi-Fv9y_C8VC0eBibCHaLkLe9LaVHH2dPMm4Mzw7qc3lEfxxXWDnSYe1flOtwhKsMng4f_wmxPBkk1BbO4vVrbP2V3sWTTgPH0jWXFsdmXsuQrJOMbXNJfVhgpP0HZB-69tQ-gA4sqJFeltKRCTCsJBAU2XT1pGVJmFpigRWRCD-9i3mJyxNFztnwXWRouce70EaLmOXC0JFF9DyhEKTUgIbZ1LTHeaQNXk3Gue8B0I4Yx7HJo297GZX8D2hNy4330n4B_5pf1C9oghDIqqKmi19rL-jKpvj6tfWkhmmJw4fnjhmUVkItAcJnzICNeQZgA0AJ1VGcy42Err1tDL_IFXrPtH1XeWzmsVPgGokVG2WyYyKWShgSWtCgZG-s6QXaxK8Dr8ErLAeiQfosuxVdqX1-vOt7HDuxBRieMwmumEKP89clun9jhQu6Qh-j-x1Wfu7Qx_nRxbgledu6CDQiYHR-p7_IMGdOxYosHum3Y_ky-YVi93MtLmvv2ZxRjxlbycl9u3ejlIi_cszjl1wjQ-dL74lU9gwquipl3PaEkHI4wDCCeJJ3msJ1VE9SlRivgrUwGBvsShss6r59Jmbk4ElW94o9FyDRCX4sbGfjVB1EXir1joqBuX1_dZHWw3lBYNwB7UqtC5_GEKTBmVV5LysNbCQGAUjFWUJzLTbqTYBOfeGdr2NZYszMtP24LNi4Fu-EpV6eq-E9V2woUY6mDTRZNZ3zpSakdikdSOtXdSTLULtpzx_EOTqJjq5wgIUsMBdooDM22-pc0p2Mol_ZS1Q9pS7vSEE-0Y94vBlasfnCK8-yoDWJDAEcy5Jk5MRy7A5OZTdxcU1yW18svvq9TH8mNtqzMVVStcVhId2cV_GvukyV-xBgkAMfnKrgqaNh5iUyuiQ-TIPwZokuExAirO1FlXiHJuZ8Y984Vx1CvykD-fNo1iihSRQyNqkZIH4Xg5rCEPm6wWT-3J2eS2HKgNlIMohZRhRjC7MYXm_QbnGbdHPAeBfWkzgtfhSAklY_PVYP2IJ55SJyYrvXtsrfPxblY-OL6l8V7W8qy-stA3uX7mpqzmLpqmH5wk6nXItcrxAuzdfmLdjsXXs1h1Vdx1KTSA0lfRvpKeg18bfQ1ndyVLiNbQJLUURH2_hTg2jVcYVNhcfraN7WJu-DpPNy4qWL77HogFvdr-NelD3BB4gc4xCSeUXqXVu7LFFhcIsWQMWwl-83aIdkNmYanci_ilgcbwByK9f_lsp7iojyBbSloO_bh1_tjLsoSkdW231kQNrNcQC2GZLsadqHCYnQmGuw4SNAK5ArB5lVYy6Rinw3GeMAaZoqUgpv6nXRl2BfS5SPsjLMJCdRpvizOZkyrSFJ7zIu-CKhxlxQq9QkbHKKA7LJ1bF4hik_kiS3MUPNelRFTZDbX2IFVgQ_i2nKdQSROg9Qgp0KVGjoL8GM_wN3Es8pTIkdpP0ZPRarrzze8WvtDKqGFYWOci6VMF5QaGavlzUAATIc2MDEQDWXdiCEkLJ1fTTxURKiXERv327czH8LAlBN-ijthQ3_7jfs8iOM4ySwprJEWDMQBu_WPMhX8BHPT-eylR0Job78lWmJjbqLd9R2g3G-7dYFbyIYxRbdUjmckx-E1YE1CydJ4uEHmLl7WfGOHYalxT8ivJL7ibKGL1aiR3PWXcCX5YnQ14MMVkbyDk-GjiPuNJNR98cRS7Svk6fubmt3yy424txaNyXJNGRcxviCIHIMDyC1DrJO1oMBCQW2LRNyA_32YS1uAFnhuD5ZBxfihpHbErfyw7tflV0y_Qz5M9z8jqcWzct8l5i4yZmDGcNFUUGHTh6IaKuujV6YLFJFkLT9i4j7JUY8BvtQp9NdZdL5vZY8ZzfOgBPOs5HSK3FfuaKdroNRNZIwZi_x53-cVxKmZ7i8rH1GQShpEeIaqUL5YVEf44UmHXdumuYfCh3ClpVg1uVMXPAZb35iELYpk3A9aAdVvXybWE0_TlE8FikyoZuFYoa0PU3CgfE6Uv_OstT5n_3QB_0TB5EVcw5WIiCN2eF9JYcDFbsOjlFwF2QJSUQq7Ua2hvsgQHSpxLbGPHaL2B4wg0t4sX0RyCoc7ddluzRHd0G2ljDgAggyFJ_jAis_Iy8g1jIH0EgR7qZ657Czjx5cG-yUqmKbObjTzUIbC_Kwnbsh6tBKK5eTJsrxoGyIUS6PZJ5zXDLfGRtmOX66QOxyvo8E3Tj1rLW6acucmIkgqyoW5c8i1vSHoKVXMm_5mVkQ0N21ANvRYWStwHRmGrcMJXwZEsjZzyyPPzPp7LSP4Wl5ZPlq95ndfe2eKKXNua5JGQmLjpPla2vCwY5oYWjyPq2cPDGPvSBJ4hrLBhLPETj75ra1EFjy_T747Eg0TtgyK8Yid8HKmh_y4vH-ZLCfIgOjexUdDtbb-yq7OJEnW2lnJYInTJFEjyZZKwJ3r2PBKpEROK-wLd-vWkdC6RjeGa31WfKBFUZVWEgpRiCWCuuMn0sbfMGg7c1e0sAZHMIDAWi99T11GjwrVRpT6ZT19XzVZhyUP0CO9xKx0INRlkuJ5AdTSjbZAHAI98Xv1TbLygB74R87RjGS1-Dv1u33D-dAYm7agCNiOQm5NKwYI9EYqg4O0XaU61RSdkSmLtyBf48bZuaDL7qc_T6RN0Q3XDxyoMVpdFhBEuiS289zjssqFOfnxKg3dK1ppXSDTEB_3V4jfpNJypBuVBJOG3QcSLTIlWKuyls6wRDycae3VOMgnpX3xEoP6I_lN58y4dzoNXcJqea4LZDk76I0RKydV2-I5xJOgc52gItV0sMr360GQfAAAtmgC0yp2OLSgEsOlSsqY4UHD_SGB3Xe5_clkSpKswWdcyOqHdFk9WzB1bfc520wFaPt1olpFgygPz6EgrsIZqrRT9KMAZVOtklUbVgt6rmkYcZ-nkNVAk56wUjBgVW0FzDCHvZejdUdsq8LMTkM3DNonxUt_m_9vgUUAPP1Qorzz8J2dXHQkXifPgK1ygn0cPE2yxp_8c_9NpoIIBNE_ovTD6k68OfYfMwT_geVwzQgnbpT1jWjB-CbVA-Al0-QwhnD_u8cPtSOJM3tnvbyROLhK7_QBDw6O5NhfLplHeaxdrc-at6M2X8zgqAQs4PhLZ8NsUQYet4eQiSq3xojFwLLQjAwg57M51peyN4-cntLTBcCChT9ckB2j1pN4hsKWIlpdxMtzgHDfxwDQxn0MkrR75n6ZVRasEc7Kvh7ji3GVRNjl-Qv553hoKjmzjJ5MJFIVVwKMa8Au48RAD99KMu6O63bkXg5Ojro3negpNQ7xQ90vd6i6RXIDyPiBGZnAI9RQdZS0-Ur7sv_GRQc6VgwZjw8yWkkeijlrh3kYoY-vhhHQCFezDYt3SWdJ83G4XCrA0HK2UiQ_bMEg4C7D5bTd_AaI_FPfV0Yh-Jd1i8i60onHjohQQIWHqI3wkiJfi1yM9tEidRu48ExCd93iliPDxm1XHXabHkKFdxRetxNxfsbl_1pIyxn7Hz6R6LtsJb0Kzse-OPuSl970zKV3lSOGaUSyyoWO563_cmm7W1fB29zMi_rGKhK_Xn52GCqjzKnxz0rIxOrD9MTSgZ1PfHbe3CKHLifwYLu7yWWRYdr3OcEtEs3ScloHxpTEfIAbwAf27X3mf_HKiIqfPj_16gKYY7elNVhTTIFYnOqSNlAe6F3uh2BrHLxatvXdsJUimEVgA3iOD9BQef8Ghuk9nwJLHI2AfPHvzIm_t8x3zDcHGRvQfo5p1kEYLmsYf-DZhg5AdqjF7DNw3ne14XoFVIFXFUYcN9bDvkg5IrFwGb-9h63ybG3GwO829ZNSGbqzD0iBjs2hci7dC0fcZzRNOA26KlsS5vIk3PVmseeQFsi6wQdQ_atnxofi3G03FQ-7_qevlLom5251oQkoZLho4i5XtQR3y9cWS1OefU3x7QcBFZAIw0Tje0nGWEcPps7ytA_Tl00C2t8eviklbaNSz8jDcqVeqeUZZha55GE0IAjKMlRFrOlz1rZa90DsWnW1HmRz_aPO3-0dlKN7cvRiC4q0TYZC2flrhfLrts3DWoJwflZD9sfpN0q4KqHTIDROY2fDl4f-8gsvAtaCHxJmmpDQUWyRbGDa8WWdnxwSZKMt_XYMd1O6quLBqAx8Ku3iOythSHo5yHhHAzl3GNxnT0GlU0M3B-21MbMIKD63BbEAfPP4Z_48BlgXHcbHWwBBv5j_AL-DWWosf0zKI4UV07l0jteMnIjJzwtyBKoFgPB0BGHzYjzVU7a_GC3Bfup-nlf41zwj4_dKDE3Hk5WWKlWXmZVXu7Glgz73WNdub_jMtIi8Mnsr-UzYKwEao2YcVn_TgQED-RyUUnIlksEdmAzPSwkq_z-e73jugqNPqOmAlUzK9gDdUQfXHBTtkqoZIt0oKFE0xoi-xKlQV_K_UIQXmBVKlQPLi16HTDyHaKzn-S4bFf-lZkfe-y9JgPCFTI2OsMx3dJL1f58k17a0_p29Dqu13h9lPUIAG6I5wio2RnjQW_P7huZlCDeQXxzMe_2WUS6j1IGb-1vqU7Uq8PGNTmtLttbeky5h8e8dA6UWnBvA7j1WSfJMKMybkVwEGEDOVmJJrx9K6k2lQudp5FC8VEwnthninDy-aTz2pABv5JwtDZIiCAu1ylj-0WHPmuG8vmMh5fWn3NYFYM-zZYmBlsAaaiVz5VK6E-fFZj6wYzkJJ6UPSjsjiZte0A6jOgB_AMMg0cW_De0i2EYbXPAUVvCpTReRy5LZ_T3iA-fp5QaV819ppNWYMnsELbRMffMnQIdYz_E1BEbyjWf9dIOdwaaY0rsyv478mLbbZkSEWwiUBw38Y5SyXCKE-Kts_ftgVtWb-3ycYKoGh_4VIlofk5NjaRlnFTp2syxqQHtgAOSbv-443vr8kY09yA-e9bwPm65_eoDnKMLjeALVgPnuvgC9XMcDJQQF8SPabZpskKIH2CsTYCB1-vVOuSghqEXa4miOlFjLfe5rgEkXA1kY4zlxIWCaCiAMG-tHZNVAbv-BsSTSPKaNayISOdy2rI0mhW_qlcJuRunsl2wsmkYiy-ub60YMQPcz24O3kQ0lznRQLBdAtwYtHuBqBtYfhUpI930XsKWgFOf2tOwuDSYdZqFo2Hc2Dzlh97I1f2uVq-5SCd_CJKR5cjRSBuVkSLKXrR1Q8aXtKJVtQhYiO6IhF4bXl30ST_uum3FR-PAEH7u8lTLfzXrijei7wPcEiHSxbffbhg1g0PVuXyxQHSjtpKmSUNaYmEPnOT_oAgcSFLNWnChZT8FVtTLWnOhTxuUZwyIbE_VkwvCS25ssWV0UNWV3-RbmgSXqDmCHMTXoYH_ig3kgea0t_Nwp2Du60jTI5O_CCrK6yJc-aZL98IZAIz1R-E76Y0KiNv2k3rA0LrldNF8gM0NQ7wWRP74thdZYgBds1fdG6qssvnafQv5sWIBLRpIfnF18k068smokVCQanHEP1aPmgn5I3OHvH2pUB04oHMvkQj5nPgHAMh7SaQDkP025krpo6bkRtguR1z00gorPTF75gl5-W43WNDor5jwZhyWuV_75u2kLral05IETmMC-P5ft04thgxNc2uzPzXvSlCNY4r1SN9uMBCM6CVQnPzL1FM72QVSkx6nPWDPwVtLk9XlpF-Gow1KD23FpWP6cTYhETWCjCf51VKQnaRUC2qZ08ClMAgGF-i-mPYgEEtgTvx1jIVJ_Rki1I9Tetj3wp8QGIGWAuLOnZjvl36t-XR89t8Xka9xl2npX3sfnmkux2xrn3GviUkHcGe7eVIZmED88Gd0_s8J7Y4_KjJQjQlQ9FGik63dCi_lkHmnSJ9NJaIiTnBpxPP6TpsZ55ATT3RZCy-u2ooH5inbEoR5EM7yfdgMgIUG2IrtgDz79rp5p12guWPpxH7Rd8T9LGKHSnzZ4gCJG5QcI0u6qvcWsqCCWAukOTEWhcx3FctYjCMnweWrtCSUpTfJpE8iGw8tMkQt7XHVAh3TsEIu3mjUKCRb_yq_86GMiZMFeFtQZV85c1ugDyA0JT_WtO5Gj2IHpFFeNq4bk7KC_QaDEJARyAYxzKF8NsEXkU-C8j_Bxt4J4FyynIAsNXXEI2kKv5cxB6SOYqeCCcHe8nahlATrZ5PM9hUY407SNMffFynaaWGWD_J3G5RTpYIswio_CUY80MitoEsmXw1HesWwxESy_XsYBGKdr2o2Ad-ymzsIn7iTmG-O72hyoelNdYBjifNPbce4uDoI8AGgKwMfBA9NwPUMoSqfHbybXzoeDaa4xsrlv2T1Yxj4EKY6XiG_PvLWsGuPUR5mYaeCkkHUkr8WA5uXTtg9jONDGh6ro81QfTTFn8xNhsSTDszTYUBbjRlSJXFhytBroDhFM0sk-CcCfGsjRGuu7-qwnYLoZFwwMVAmYd8Wf75jMj2IPMkUrARqVGBuybC_sOueifMu9gQKB0Quxd-8jSYcz3pr7NuwZLPtFmUoa-NUZHLtrSI3ZVTTLSxJGVXdmCGVpf6hnYlqvrSSWFlwZKZ-bJornGSrSTzFnVXog-4CNNFuqm8Bvj27Uw5UGGgd-NWX8-GyNkW3-TcQr4oCC2GztGpDHSFbWSYPZ4LnrJR8xtRnoWpHsti8pP5eWoGy7KvaNhQKfrTOmt_bLS-9OQQFRqQc64PRNdoB7AAMNKaJPAPYQpt7oqJs7vA6dNsqIXjVUgopA8YOUGie-2cbvCcXgA5eE3TSzrRtcnql8E3j7BkWMMrj8QLPQWTtFO_c4dgwiBM3LxA8gUjbEULfv5lsLO2h-FtJUp8Xml_XIW0A2DTk5EV--_W8KHEMtD7-avusVLObcWgjMqn2zXmFBMABUICwG0khyfgei7SuUden7B2FoaGrW3cgXLCkDvialUh6NipEsbU2jZolqOmqNWuYPPsRO5jpmJbM0wmYQ0ogOn8Z59WQnOc9SRxelLtqHFubWjfOqZQ8MRhXYvhrgJPxDZ8pGro1O9gJBtSxYBFSVnG2Xj4_4zHxP1Zic5ZIE2yIVzNdR1booFlhioQ5EvqZy5MzXTcJv8_4c-W4rb6PWqtdqN5pUzE4vHUzj4uIkakA2WgbLxFx4aLYUxEI317zVwV6MGZMOL-YcN6hL_nuY91GigYKecUUtJYW4udw4eLWMr9qlaQB-W5BaE5Jb1i7I0IdS-WkqzVw5S17uzN1DyWDlIvB3o4rXdMsz6IrRNF8ol5YRfL16MkAVhu3rGIRq2B_KVh_Pl1rq0sdc91b1AsC40OdYidF_U-QVxgh9xyQwF9y2G09SDfRoTlJ3SPH64P9vnU6rN33PJK88js6obOhbDQ3nxvSY9Uuy_kpjCoodtuVF6kGiZFtGnTTjqbD-PMBJ_Zj6aHv3jkcqOnlDOS7snPg9ayCMfkptd_5UIEENXyuZ5Fbt5QBEz-0G88tNdUO4DlApPbBPVOFQglXEsdr7z2ZHBbpGXa8tFm4lxfgNpH0Yj6GiDjcJ040mUYDs1f8KhN69HO_FBTP888WcZK0IYy8ZZSuJMhZPQ_j8h5dQwg1NTuMAt5p4IrZBq3LEOZnVNxK-tH7mI2r8kCPiW43k0WcjfGaao85Mu0HXOTaVYL1JDRAfb5Ev-0EdwHeKCtmyek2hTFHf4Bf0h093aCca1sGiiwr7QXbK1bo9b0BORqWf_hlZCmt1Vb3IFrHmvNUerZoXMiaC0zMwtcmzSCE0VH78hZILGt7pjtc59aVN5foIz8XFPFFzAgWaTqCQ22A0fT9Js8czf0b4843h1KXVSEI-VZVPJLeeoPAfMTu66KNJc9cW1T4BUw1ZR84Fekk10CY6UVFJmwOG6URb9-wP-wfSXMBP89FEtHzosT0IUlQz1gUQxUxpR3i7kAvDkcoxKLRyp4DHwhfdn90mNeayW2kITBe4Lku_8ZypY5Qg9Bcbbk-dH7iYNQ5UbNp42W0L1b8ZLzaEkASK_IVMPWbLUYlhVdz963zD78KiyxuOckLJEZ25wykvpZ11Vh0MFc1jL3bTWg-v7jm5j-LHLmdt67_gi-oqeKHwWQzfCFQVN8D1ggiQf4_Yzc7bBgfN-wNy2TUgxH3hArpEnon6iLjVzvQUp0_WEz89mNya8R82mtYafbmQ9859jUsAPKM8fkOnIT_sDxNKTVyVV6Z295HcNt1R_RGvWPeephPd9iBxDdiJXbYzF91AmaZPBTnPAW4ad9bYrVugqNmlIYccMl5349IOL5-u8rEHYWt23ONnZeAuOsumMeshc-WGdyJMXmT8dqWvCnu8C4CMzq7q5fFSgHu5JozHx4ePu_s5U5bCO9bXMZmrzgq7bKiYnHEiSsyz923n1dl3L4Qy_5AjltLPGj5WZwSaXPycXs8jX0nG9Gk70ar80rRfjZgQrVzEZlqv-jo5cXioaITc0HFhhsimGjWyOZA80LwDT6ZOLXgY_rt2U6tA8PRGJ04TNpCBZ47GIMwFEY-lBfgzVXUzXzJ_z7q6Da-Zunz_xr81ACDv3WlDDItBDsUmbulY48JgvDqQ0zA3KxTA8gKAqR6SR68yuhPARm1LVrmPMpQK3uPwepqLPWiZpQaDBJ7366D44DRM8B_-kbth4SxV6_JU40c_xNYO0F6EcSRyRhSqf_AJSURoiy3DkR2vIaHBUAvJOE7hQHAPmHcMo0ncxErh1nVg7vlVoa2Lokcx1Tlo_cmOokxIvCix9ybt0GtUFz7O66I8WRyI8F6ZQAVcXfNt71RksbrhzvXptk3z0SuaWi9clUIBoAgLfSpUgWB9APLqN53NqFiO2Uelmit7EdKD_tlgyB_tIkCtN69Fi8brb7-TnEX03ZX_C07NIf3mrMJ3HJvvfZtD4yy5LYBF4jBYnPbV1YheuELwX20rL46J-NO0g72pZ4eNXTBsXJLF-KX4pebTIAxnpkuRJTQ8fCatUP4u_OVfWDTZOWoVeFmJxAgpO9o_foZVHgVGm-BHVwHLgifzsHWKVasCbzHCtaxo59c2Y00jcm-f4Pux1r8ZsT7LINHLdLKMnPRlXI2rIyU0YjKHHqpyoP50fUX7AKoPfDhJPQA4PU4N1lFMtNOZQ3PPFSd3gqQkwkqjOpfWDygeBpkUay_gnZPqsIvfi994SWViKa-AcvS-OddZ9zSLtcUWk6BQm9wCUuzniJk3MTXKVtsSiB5gpqce2uCoYkxDbNsh_0rpDP4taPXW9qVl0N2AUvXgdELNT7f159XS4TA-5n9UFIp5kvOmAjkmQqWbFhUa4eLE_uKw3iLrgTktZVJVood5wYOczRInGT-YUT7j82tsOwznsgnD9qFMmMbr_pQW96Z_o5uFRr5yKc1mXgf8iNd6oUiR3OiirujT71Lu3MKuXZrh6aV3pXyrofi_7_4_PGuQ2hGGEnQgvQgpfCj0EwN4p4av4g9b_x_x3A34xzV5b3B_iTKB0_zy2nbzBqDtpCBWDvVoGOIZXyHoEDIMqcSbJ9GkyAHIlC7gft1z8eWCC5wjm_Lt-TP2OfdIFStj-qzaquT7V13s94JuIbqS6Acy8hGDJPX0rOa-0KPuXnDD556L9gB8SbxZyCrsyEVFotwxYEvn0ZdNCumNZ8BMgP9VmiOg1-grEyE2W1vzkNA38SH-fO1wGxY3dnCpyn4PRpjU7XPGXnBtzRTgYyMJe9DUkTaxClKlJCb_5hTwM1w6qUPvoJFCigsJEMh_h5_O7UjUnQv_NRqnTQPYCVBSoaq42SoiR_D9yo0L8dbCE6gaKCAePJX7w1pE3sN7AdZdKkqd49-w4U4aLbjA2pmilY5qUk9DNHK4JeZ4tLyybP1CcmVt9DJR1swMxruHKPCkiKtwaTQV4WYb7A2Yoq0MgrGtinic3zRrPq8ZjUqiIn6NROSnBwbDvgLfW6hRzg7Cv9bK1w4T1T8YikOXg6afCpnSIUwW7-1smiTHoGwqalP15Os7hJtfIytDbJyAIABm3mlP2TCcsDJtlwRzicblsbh3inhuQyuBdTo6URcs6U_1tdp98q_fH1TuahI1la74XDeyoYIDD8lCkGE7Hj7nHE4OHcxebe9jOk3j0Jefh82zVpxvtaL80QEbhT-Vql6ivtKqR5Uz9TjYabtJTj3D_YneMkSHV1oPPqrBFE95btPskV3XbX1BCmnbyLJxy7RMTIjbbDq01kR9ULbl7qoUVbjXmO5mfCLq3ADpTdnrZ29P49nMP9I8PF-yg89U7bJjswYBmELpbXl7e9CpIXEbhsbo1HN4snELliCuY6qGMIsJik7geU4ionC0zTvtRX89eQJHIY1Kc10bSR5-mpOdeiUh_b7Tt6hJqG_yKZI5xTpDpAGI_HodzWBVAOEwfE8Y24oB1nHFPoQk-dxfyWQpYbl9YO5cuu6WSne0nWqOIYslO5B4Jd_OrQUqHBujVq0dhcLIO8_Ev8X_QLBa75ycTqTZTxXnUGuPDlq9IB9Ma5m3FM6rprs_rL-m0Ssn_JQOz7yP4YtFcyzuvYioyc0moy0Ec8KvsVDvI_CIxGlrmtWENkdzpl7DEES46tXPQrYvlp6M0Bwb3rqDyn3F1yuAVGlQwcVhw0S4yXzFQe_pJhd-R6vCZcK6Nif2FPWRBcaLBa9ku4OCZ8BU2ocMREMAfKIGRSI6Hq9hDtx4vWx2ROYLjVGLhRRmbHplRssDMfG5ZSpkk5RiShujytvDQtG74q1PbxLDjuVAed6BeHXDLh19Sbv502ZsX5g6bcgi-FKqB2igVMbVjiz2AvpAG7KqfVIyT9TRty6bRss8cYZxLxVdrbjVZvtX7hwJFspTIUr7LXzByZc42pT4-bpfu2J_4x9qwSz7WKHJuNOHgDbEsNuAxbpNfQHXqBNQd2xVuQKG9CSrjbbJKDW7STAqq60FJk6pw6Oo6VBtFtEkFsnzmdJTpS9sJ5tVr8rAz7bRt9q1OZ7n6t9Q3wJhiqiqcffK3E360oTiaYZjg4yhAiU6wqVP1quQMALIxycCFG86NSmF1nHUr28uktUIbFpzxpAc6QZWd2s9SuAlGpAezNB0JapCPCOwF0woYjNZpqyl2JTJKK277UCQjMp78tWb4BIx-ptv5HtyqeQHdGxXotOTc1LeyFT9LS9lnIfTvFMbD5PnF2bbcDBa2WrW4iHgMfBqVPtS7MRqb4cATeh_w1n6-eJRU5PkybGY-8kG3fwEFidWyX8WanigLmys2USrFGLC34vrPdr6SCPDbLyseCG5YX52D7wsFLHQZUDX1PwTpGCR37IYzok9XEgPSfNsGBw15hfc10QEMLV__ATM64Tdj1TnEu2_vQ2GY9Ix42P1hvbMvC77QU-P201YLBhD16K2pS7jEFderjpY8rwuRJeJCIibxYS74S4TvK5MlPX_vdpMOJrv7TyNP0Da9_2UenL42bcE0yQY3v5zor_g_Oy1ggAILYp5wbcd2NPtPgNg6xkq6qKxlBGnLm1ZF24AlLWYgFt2LRVuRp-VMdXxgftt5K7qoBP4f9IIEdpcXjIPO5hjEmr9j0aWcERCxhZGCvwCZPtEwJSgOx1kKIYO-e9HMe8jXZR0_Q4gH_4J3n2D9jx1XXPyfj3w7Sj_VQGLAOQxlrO-VKY9GFCqmqii1KFZYGGoaEUQAEgomJPCCV4wJBPCzceCNEPOWJTjrvM-ev6EICfQRIOkVWkeH5ynrTrcBSQFba_ErZBIbNnfN_bMZh92C2gJpIxGFIRh9RumoyJTQMyDmHaWoXhEZYXdlToyDKHz4KBbluIoJ9oFeNq36vewT4EvBAC1OY3w2kHDIu92jeL-N85troRQvI12MS2W5dhHvQNJuAhVGN8vBAe-2KFkP_RVOzyMCINTJyv12RPBA1J8t0ysHJ16hYqQMSRhQ5Fc6w4It0RneddvvuebTM5AojNVYnRtPATiAer8bptsGw9L_BYuiIcN2-4NeAWv5N8mkCmQjoGNKYGkXd04uDyudErXpQ1zwGa_GCUE_dn0Q4X_9SyevVSnIx5fNUBSJK5lIhrVK4I7GPkC6mmvp67KYh0UCla5rNlH52mMhce9UWmeu2POXs7sSPh2RstcsNeC3Jb3huuA-QqZWPpxgOfBvak_9dmBsAiV_Ifb5rZyOSsGlT5O0_tdrOnvx40CQ1xV2knWG7tpbehEQMMQCBLrUFk-a0crY7Pxjg_sCkznqWnO7gmIm1IhsIX4AAZj7I4Nuy7mKOMKsuWSRgIGYR0xyH0KtPExubewURTPK1-n0ECRQBS_xWUbrFeORui4YR1OuzNHwj-8vMda4-iKIyvcWlCwlrQOqCYvarnFH0zmBS4zLJh0kVc7QB9UGqGEw2KST8EB6Ml2idSeNEHW2dyNo5gbD7qxQL5ZTgDf7F_HgTV9mIql4eFFBu4NVn5ovNCi2BQ4eYeaRY5sZOryNzft7XGi9ts-XwJTjpUwgiaETk7GQTgJVrs9ADziqtVbCC-t_V8QojKNsIfnhdxPGLTWVURButSUhuBnlHkNuTiYC0FrtfgmQ-15e9E1xQx2JC6Y61GaF6dytMIw68ta35X0PBwaoi_yuE7ktP-BFZ8-oz3pec1fIGV5vDIqa3C1jcwFwH_VOjDx_AmecD6VOuSR6U2mJMGont8NTr1BLiIh_LQyklUC_jMdwA-z1vCbyDCfU5EFGjzOj16zRsY4uAVcS6zXXU4BCQgGKakqLzVb6hF--5iTAUuOympB4jYQSNk2pCRTCHsGtcJSa16zO4AGx3sQnYUdONFkJ3tVqhFW4LuaiNzo2RVrjmlIrNiqidwrWUpr1cWP9Pbp0qnaHXtPLOipuEh4_cDWpDAxqDsgYjOH_KFDrdwoELWmSBwavKUSGX6bj9rgUTsjvg0BwmRk10B7tNEJU9Ujq_x1Isz-JtJO7yQmnbaNxDJYC26AfGjixf6WNIs2uIUWD_Q8u_--xlJGKCAFtdMDCGG7oBRh9I82nBMIofvs3LNbw6hIdLxX5cRuQRiAvVYzqNK2oR3Ml_VYqvRAB_wDduj7ZiXY8eko1zY4stnyyJe9i6mdIVd2uY0Q6yQ-SLr-sGGdmCrz8W8AhF0Vf5eAzkgpzsIXhQTYTG1BB2sQv9Z-K_YO_WE3az7DRqOHAY_9jhoAOcqJPc8uAHpzZYTK6JHNiHMwMfe9g2S03I3HUpg0lI-6Iy23oFa9N1-zxmh61CZwhAvBZUeMnWQZrchTlia3VX8M8VVsvu5BkPAcXecf2qV0QBM2S9ZVSTjzvYAGYgnMIFkJLKVNnUJGSw3Ciom4K97NGgUc7U0EJGBKvhbd-uhlbUk7VhVOAz9T55Pzlzt5yAcqAeDhqy7-4aYed8PwEY-fFfg84dzM4puqNsxJJwyvlEdrC0izVL_IlGzpbMMAiNbJ7QutCSqgV_zjU8q5N0I-q0nbjmo-xGuqcePJgtbSZwDhbv1C7iai0kIHnemoKmlgOwnZVbPsE2VKDczbS0DDcFtVVWnswWPEKwLPHBIodeOGfnNGN1H1B5N-DIsFK7xSQnqFs0We0OU6pZTYeXKYDHMEqWVeR9sB9yrzlk_OxZjBoDvnxcfKnSO97DYwID9sEy6lmuAyhCmv1ez_sBhPBiN4MkS-TgSd8ixIVO6qPGGFmPMtLP5CBHeAQgCLs5QrEfpGN8Xc1sinncyuVkDrObFrc-3Nw-Hay9TTJkw2LrPwJY5rBw9fe9E6HGhOtA8lrH-AAy1HyySg0KhyYa7ZyH_yEB1IMiVgjKcvfcMJOT_vINnDTr3Iosb1fh96ZPuuqG08AwJ8N5p0vIaha5453sKbkY8Hqi4Tr4KwEOyETldjFzA7LmJo4dvxwN34Xo7BcQUuqnLLShRmtJONqcGNU4LeAOTAfijHB0hd7VPK7NxVUP6Cw-cY7GJQcmydQRTDGfhzTp151BZJBzFek4iTM0WJ2XCJ_wJ_NH8Ja0SVLsBSKXi7BAdY3RIru6BXTJfJZAD44gj_wC9is2u-KAPDZCHgf9pJ9wOxkNWtiLodbX4OPIfAb-rGx4fawGK1ytJoK2c9nhHrsfxoRW5LHd9kBrB_0nxOxOnDKqFzBYQmQs7ZeB-KiwLE9wJA3wYqC8WzwdOZ_BCPCWj1oZOmwBwQdV09eDPPw2-syIuFwXjSzYeWNkh8KdsJjhEsS4ndGaJHnEJDOhVZNGCBvwXGKFitS_vWC0dcpkMC6dljDcoI8X1rTVgPme0hCk7TxY-KN8uZ4Zzwcp2Gtk2i9YaH_1irAbpFbUCc_uGfj4olbrq0dbdRaO4L9rsIahOXYscYBh79L2Ifhb62zOgHbWe7KRtJfUdyJFgh0TFMgMZ5IN5sk_F05_5qkCFSEHFHKpEVWMIdrmVRGFK8Lh7vM2_n5K2ORL3eU9aB23uSqpkt3BLrVA56PUZAJXPc5rM6iF6KPQPMNrfA_VAQYICOeuYlF5-0LblULShE4YJZPIO45W3UxeB5DH4lgiRcGnpdY95rITRidDQ3Xw3nKU-2EWhtSYvrlLTZfSzbuNvQraMwmd87i5oM1KvGoKhKorVqcXRlQ1z4mBr5Ax8FV2A1ZB5cZmEfDf9o3r2VU4Q32B6-xF4Y0NKTtX4-T0xXtRAkHqg3WhUea5C5X-DNvebCSOEVuJHCJSYt6B35hGJuenNAZ70weugfNazOlcPQbRVib4ZPxPCam12VgUuiB4NIgZFnv2g9AlVDGoK1pqNxm7gRhN_hrQSWn9Fu8epoujg6pU2TKz22CCFct-TUcWUHkdvHg7empVvZKb2cVp_Igh3YpblU4KoNc7DoYWtkt8KrUqArM5vYH5fymJQnSVPQ1NgJpC5L4qP-CmMZU7vWUVuzyGgirWP5Sj9r7tuNA8Hf16fyxpuGrYqJMmxI5r6VE78Sn2lfAF4PG4cBBhfQQMKAPJDci6bzo6GAx6R3zqtaOdVbQpRNSvEV5vcKKRqNXoip_9Tt45XuhPPgysB5TikSPSKfaEoMXTKIcsOXTyFsc7uCrf8WSPeGSwbytBQODlL--wFlwQZGgxyD10ThCUJ8pp17MCSw-E6o-OIDWB4poMakkGzwLxKqK-axoG5CTzA2pY8ITV0ydhuN7Hx7EE7_3bVzeoDemiw5xYfLJQNskwwBaeSQDGTfGDP2FpB6C7UbqnvilkFdADF5SKULxEjS2jTEmLZDmYpXI_bZW8p8EIWE-7sCmTR9P1FPMQriMNY-cVUdJqBcm0u9xPQWIdHboMVrZLpYzTr95ZXFI0XEB13lT6UkiD26NMMbDGx-3F1aCmkPvsMBN7dhn7QIZq6xGqHmakqPpPS_QakzhBGHFLS5oEVWrZMljl1K32beLXcZ2Y6XLnJhlpLZRKCO4T8i0ZHUVo9ejMiO-eURxrMNIuBJGBWKq8uWxHxDXMM4Z-7MIBdaE5t4Dr03hg4KuR4d-x4EeOiJ4b15XnyHvRDUKq68wR6X8WggV9OHEbNx4FeiFJqXGyegvS-dDIs_qYPCcpOLs8Nu50KgyOsEfqiKdwNaAtxYx2AGZh8KqBbUfaewKfVZ07AXW-Ud99FZZwWUUIm5CmXnk7JzdDa4myrULkooHv5oJ1dBWgsi04Lfx71GzANanM_Y1uMh04_7t4AHJ-BJaQGxZkfL5S7gpP_6H6LyRQKNiK2ndOnC1jnSKTVIJSqdkvp2YmMoWaKC8khNqvcU3z7kb-e9sHn5TF6gn1QEpZdMXqARLJHJ6gLBpOJnDi4_4HmOcckjUNeGu9G2CUffKVALe7-Y2LTW_iCkVg5MLpYo2wLpOCg9m2dbPiYF9djrRalxRnIPenNVY9BvyZRpHoX1vUmbl-NE-z3WErqZ9EYiYjAnvWc4Es6E3t_cBQNRsibfKA-PNlYZoVxA7lofZsbEs6yyVWW0o6WileHKmuFCrlaocELkQcpGA0soJ_615WgI2XmGWtNmoFTIPIh_5UJA38IZ7n39kxTcMrQhAKCjLA5MiUTnGrn1Y2Z4XOYtm6qrbCniUn9YkXH0gttel96pDWAIVoDBA3au5xpQDmh1GCyG3_glxUm6W4Mrd-prXFt8PObshi8z1qvu-fv8qb0pZrfi5ifCuxPe1Qt7vYzGISpT0rFeICm6VLF4ISXpcm-OT_eDhnbXQKR-DwjriZuhWV198hMdxOWaWi0IADgT_F_D_-p7ZKg67Et7nQdJCCI5Xcw5TwHV1FCRDY_K0V1RKHU3iG5M2kN-e6B-0P7zmvdZzqpwVO2CTlKi9QFxS7TN0HPq0qrD863baVp9bqgrJEb5xjbFmLmpajl1PvVI_hl2y8IgyrwAEL0ewMJ3YwK9UttelQPX_Xw6iDLi_DfhCCa-tvrzcDlAuI_oisMcTZmLctcD6hWZcPEAX4NgLys2DOP6mGf3HFy7fPkEoYvJ0C1TFqONDlI1B39dWwVKLBaKeNWItS59y3b6AaCzXkWu2rAUyqYLI5citX7Ngu2lmjBmBhLsblqHk6Sh39e7amEMGT4W9wqMMkFiKG_MJZxHkdbb8PlX1Q2U9pF0qG3f0bGSULH7HJglXAhvA7rUzw1oPTSj_MXBEeq4feGnTaG3uVxUIQdtacxupqLP2UVFFw_uqH6hyuXVsIkIFX7BkKFZBusc7Nql0mNFHm5FcbPrvdqB16b1FO5CNiSJ_KdOn0Q8uYXNnEiQyu9N96SO3iWRgFUGsi7L4Zn1xpkeGjOzbTymyHMGcTJkH6Oktn7cnLyZQH67QLOYcIUAXZwXHQdtPRD241pJtTgNW6LdaZgbke_KIq2cZzADnxWxuROrnse1pqZ6p-1kjJwkXck-2rIiLrKXxQn3L5yLIO8TzfCalCK0P5y-9KPrZGvvrzJ6WLexBHSGWK44PJnY1-O7SskUmnusMNDtd9gS4ez6SM_YsViBeG_7PAeQDa_QmgW7N89zvfEfj1tnIkWRysAUY0yfoRdYbESabeQeEPuDfqkzp0Qr1nit1a4UOcNOlqFHb17dZKZGCVzwUoOM3XreNhCfkE7bpzINgbt47i7n5osfLlcA8QWZ1sYqTUJarVFFmkV2s5Ee3PWlEAC59kY0jzZoJOeD91TG9zIZCj0jX-pxfTj3l70xytolcmRHAFMB-tov5ZYulDYVs1LF7IGkVfO05J4A7cmMTQgwFZrqLTZRAXuwCcqDCH7iHSblbwF4lhrxV1S-4rrFrh2bIbTdrK_pWcW8Wt5WuwmpDScvMGnT8pVCGlPjXzVI5J3jEi4prfTHDglQHNhfXXwi04G5B6ENUanjAefd9fm59WmmHK3MeUIzzPUY8nVUwh2oKJve_YFshbt9De49664_TAPKZSxnOK_RHO-KJMmOcPNsmg8orQIprJp4wOpaPskELxunpRiasUK7wAbL0AbtqkjWCdJaKgBFXiCl_LMCcGMyEGyoMxojrM79Y66hV4HJv7HEYied_4U33M2InPvABbBN8pe48803SK9oMqnSCZQf_YlLINH5gBxM-fSfdBjBqqeD9k0GmalM7iHN1h4x2PEqIf6e0d2M77Ol7gSLmK00cEZ1lxsizxMX8OVKTMQkNIIaPpuKY-BJFORB_LfINotFXBiOqGd-dUW5bmmf9WqvnFeP0sHw3PZNlZAstY1m9A-IT1XEsdoJTCVk2moxZuS83xjr9F3tec7EAewxF25jIBlCEh9BZWJEa1KmlJz_C3rGZ5p2kQPCbNVIJa3RYeyJmYd7ZoU1Osf3tP-JKTYCUP9LYwLExl-6ubJRk_b1OT-zvtMxK5uPZuBOiiK-p9euioCxDHrEAcVvRLR3xL-62noRkHUyDDl_UZAxFN2QsShiVy4TwgzCFrfP5UY0C7D_piiS_qxs5fH5lAT20zqIVnjTtX8bf3m7iAQzjehfiQS5SO2qNePZ9SxCwhnK6anPskfwQfbvpZ2COMcsoy7eq4vV-o_ec7N2V29_MTiCJj5hXTxK8Kd8z3ZkId4mn9nZ56aLWRqtHt3oB1Q3xDA_P6rKbizQHrq5ht6Q1CwCRyMbFvvSqGse5QxykU4k6EH1KrjCRtFFbANYG9HNo4eJk1tqp0W5X6e4EijZtnZAGWFwVTiR8sYzkpbWpDLuq6KnRgU5MARHmZOkWWyKJeXzGNSFwu0NtRFrq7jTiHGrzbYxdMeS0GIyOq01877dvrO7ys6bpuErhT7TPNqWgwcitbuxMoHSyU6KOX2xsEf8pWov7cH1xVSGDYohLWUYv8d4ftKvsTg3BhHCRC6Zj3cv6BX0MW4HOOuAGT3n8N_gV5NT7BQwJiY98y8IwHWBaldUgjco63VLX5QwANrCE0rIALb0E6x7jQXP89vrWtbzgjfJKHCYKmDCdgDeSFnQzl0qEQItDpJLNbkwvSJuNWGXytxp8ch7mVYxB5DYP0hHgNjoQywiP_VILkIZRh9A2XIlRmIXzA661O9RD9UO2R3HP-nM6W4gW50iw0BhbBQk_jImf40IHnBRhiluUsTZCFeeX6pswcfVQ30V7kR0Pv85qBKxFDXsIOJBDtMlonckuW9cjVTKEbi6oM_68C10mIRQBZdxrtwkhRdU9w1bWXm2K54NpOlMDICl12sogNooTyhVYY_pI6hJEw9js3bkCBPbZTVxIGUCAaMWv4nV2EAVFSky3UG97qgXDAU30G57b7HbfB5ncLh4xaAMn-iigofzIpPLcr37BiheTpAnTvJIhvUP1fdgdTv5qFbA-OhBDbMM4AWQ5VmwFj84OxrMzEWTqDObDQ5IL11wwYi5sPkaSatlC8fEexXhOMFw-tGlVE7SdsHtbhPhUCcbY5xAdVDHT2a9zQFskvtVdgXtMDBA11ZzLxAEjIDvgGq90i9UFsmGqrx66zW6WAu8kwwBsBLMFn90CW43nTDsDGSnHWGln2Fgpvsj1dldRGKlBal1JWAHhC6qP6mjxCuPZMrbQGJlGeJyi41d14pog0cRSObiE2cSUiHKo2yqvfDdGp49FryA0V_653gUmwwYJ6pweU8tgD10OfM1N4bnH06e2IlidkcdRz3bhzT7GdmBdNDR4vENDB7KTRMoPK5F2oHChtB9SlN16r31tFTCjXeN3L7n47A2DaF-wrIhA9kUMRbAWPdvj9uZg1t80L_QsPz8Sot-eufKZoL1i8vfE5M8vef565Klzp6tXDx4l_os9LLG6_CcHrGZP2LNv_YTUi6ixZhm5OJO4t8ZN1kYlz0IT-yN3hISx9Puzt6yBmp8Cf0NwrBq0mO7C-lbzb8Hq-x9hlpfFtt-6Y9Oc-EP8nALUqOtKsNcz3-IQOMns4PHEeVWePBwljbHUYqhrL4rXI6DbO-NQTL2FqDT-ck7wBQsF4ehSxkfY_viKNTA2bVTqkX8mDtc4t-eI0nO2nBFTm4sCNH80IYwY3qh6P_sMcedTyREmZeESJuar0T16Kpj9UYlc_75WFz3dms3NNMtIdJSgryAnvly1zdzoCOEsZPApFPab_4ui6vFsewpOi3SV-vCgMiQCOZw_X9H-oj6xprV977Fyw1stySazc1WUHh78xL4XQLfY-XTG7qrb_sSK0JZoyl25tJ5BOHWA1AIxe7s4ha6BBnX2odj4ToYibPuzdD5f7JMAJKnjzOpuVO96Khu3hqC3XSBqoXP0wdLz0a0-Q43N0tl89lhoDzetfrM0sNLMZ2q_mLGxhD5b4WPaC655V6ctgfekXZGkt5srcfxJO2EMe_byEYBI6u1RvCNMfKmNbD7CQfM3z1oCj6SZC2rnoIdYpJBU6rBMXP5CpRHNiOJLL7H4m-TJtCjie237ZZvrh3S8P8gXibOMdm8oQZtgbiyR3w_UwUIiBBcGXE8REcbnuU1wLrl-_cvEmNkTIEQrE3PUHdBZQ7UHMullmcV6TqWVI-0tPDl1AFlZBVJ1Qxlns_QkUQD4l-5rIzkIS_fBituvOD23Ok52wGxV3xLkh3keqYMt4bKPGOvRMZ7zzbO27TWAF_jKAwrTR0-_wp9aBBEB7hN4dV8ARbS3BS-in3RZbjClW8BytAi5kI3_RHNt6A3PeyXbf1q0LoPbxfzFxVYe20TR38Fghms0e2S06Pnx-iWka3FaIfsv5vcL3QfGiMQ0VKQ46wE0T9LsNeJI6ZgAKQGLE3qfWQcRY5byjgiBglxekwMVee2NOYLYNKThfEZZ3MbMqdVcsJQGXzkCjB_qY9EYYT6PqSGUX7D1DEEmKbNhTE2ZdE-cNRZT1Uq87DSApyzt-c-RJMP_wFUS21xAoX_L6Vnyl2aITb1VeGv4KJPLvNaZMkBEyEYTBWyKk5-eNsE2ZtNrfD2ZOonWj9JQhuyOesZWXNrvhlOO_cK9DPajWyf1YcB7ysy0oL3qqfjYrnBEzNefhMAJkeO22OKGOTtXCIb5Wy__-ANXnsPK9P6yIr09RGOPrhqqhrcGCG0V2evm0ON5zm77kiKgffpnISuwhKX31zFwTLaTK-v_TCSno6ZgIn4XFgoF9sNVDevNwkiIm19nxEpscQh5ja5bNeTBqfC7Z2TXV9_4zQjAypJrGdH59ms8XunCLfOIwhR7C20QTcdtuBb4fXxpb4B0V7bww8W3pe6lDi3NYfoyxUn4GXeSfzVAaKo1LDdLlyNqG8PYgeSUdlLfnshTVy_JBiFQR8iAJIxgC1ulcM9zdj9B3pxaCRebimiia-B0SzKHErlLYNktZqhosSejMUQc79VosgToLhm5hXuyEDQN9AMpV6cYzhlJkGiVKl7nJCQauTh2kmVuPPX766OUSHQoDe1SpWVivMlOMWMvhyA4gPltW9mh26H2wG8LA5GnxHSi00t-XSgDFITR3FEwPPkEVHAwiTqaylRVqt7V3I0VoLT6dfo3YoN-lIyeeIQcO-s8hdGshFVnFsScUO3s7MQWYdVwWjmz0BdnxhXEhOmWBByIBA7PeghA8zFO1xjyKeL6SY1qeH2to5Jx1SdIh0fe0m6dsodnHppM1DzNx_iXFEtip0bVVey2UA83-aTZ3RMQa1NJiQkcnh9A-PT2G3svVkPIhL9FQvx0opx4qye3vCaakm6NAxvJq2DC1Y2sKAqKp4MlMR5X-LVQGGYHMjlvGqcxaVXQQ67dmoppwaK4dXjrmKoCOugQI3Q1nNVG-nGLlqvNncLFMo5LG82N3nqesZXhUxh20VHjprzXTBvUBbp0HKVahyN11UncaQSjM60RB9Jg7kKHhPI44eThItpxEJdlHC3OS7IABS6TKYywF1qzNqARMm-Ej64cpvZbND6ibs5xM3GJ_MeINgjHrv46u_RvFSWdPxHYtTuoJJ5rfr4kru4vZnZnyMCPFxDTSMQ9l-m-HJqWNHDaxaepfFomfmDmJK-yL_dwwsSHmjc-5rk0IDHCg5M_a_r0kt89gXZpeKj72_dWbjQNNi0lAz3q0OixsVlHiixj_B71yE46t5_eAmEODXdm6W7T97Xjdg9Oi1YiK0XGJk45CrA3cwdNHRc4j-vBaT8onM2msjBliaLBVeH6xxZAVSzW6c9Ketu8Ggc0qo37iA9rO54HtVvhUe8duOGp0eYFKw9INHdlckHEorxLOnwpslSJHMSKoBgE90nGUN9bSuOqsQUpGFRGrVUUgS1IHD0qyKOapoKZz8WD_fMIKPZV94fi0FimSc0r1scTKVFP7tMS8pLxVv0I30CeDKrER6-kt7AKboL1XomKvAv4n0vyMpit3oDieNuKu7UpFLJDeagND_0aZicyjzryjjLYm2usaueuGF0yxHyA6zDAYXL48EhoAUFnhvq7Fc5KkRdzIpGgTviHgeMJ9kcYvgZICA9-UN1zCtdOYar6so0hSboTHzcfkycypSMO9VHTtmqIsMLM1nA2v5mtspsHT_nupo_8sYWrbP20eNw6AEg5RFGs_nvAkPW2IQPTCwrKVMS3izgJXGbtrlEUq9Z4uLfSqlHyXogWU0Yk0ENdlNEnukR3kcpz9ZW0GK2TG1rki32c9QqDtsnT2f72aC_Q2YNWP13FZ4p0yiQM1o41mMmC6ZBoFbTwm0nwctdjEPmWcnVWWy4bLgWp1ySPs5eLtaV8wtIQJIsmZpfvHUYOSrxkyohWVmfq61-HNfdSJeFC5egM6HqhdOHYK1e4zSNyfR8e9YBY4UrqcfwtsnhUhbaRq8UYlrkvcBrdrpqetbJZCyNU1_QnhaRqIYsBTq6ZHhIw5gQCEpJ78W3FSxN7QgXbWTYRPsWIWKq6wdk_JwAyV0HHuNuN1WoGypCzjQoMoBEeTFhfMZ_r5hfNqTHfLpYBAJUIuZhhRwhmniv54KjzOhZDi1daEx3pm9etXrOIrHH21qOvLbDzBaY3blm03U6pcRtz4vNOUb0d8wLddyi4Z8FNw9fJPoqPqqsz0rOYuIv3O4P_A1sz0KPXiZsrl6AEsvkL9q5CnAT5QnL7PVU86YPNpmYZiPNNdn4KTHTcP0FHPUfZiw5NL8RxlRnk9fWJqLpvPjsuVxEQTN9ogbMya50oXF_DY6OoMTGi8vBNcI-jGJcto69A4nP9IGkMKD0brlXNpKB9F_K72an_IcLv6350t-gN9BEKHOo_Keofn9tIJs4UtxjccCtyk7Yf5HWEeqNxdreDUyaSrh__hdC0DQ4IOPduuO3RdM-EOowYleFfUnl68nzUaXXxupiurtvLZYwbYUTRaKn1yQhYjCj-9nvx-z-OKpvnGpC1q5iFRokD13zGD8cGQJcoxLhXi23wWvOTM1GYJkOKWh0jDACP9sw7ijNKja6fkWk4iD993XqWu0-KewbJSQlrgh7TyzD6XMF-k2Oi8rAb21Tbf9_xg9Uvz89O7VtDiZvV9tkLNIFrh-_54hOVu9N4E-dV0MSGYX9typwmawXaj5capi7oLEGB9IuF7OCRMPCfIznuNhMc4OYcKZmpMisnyRKc6kY_BEOvX8vvD1jSFR0-v50SkoIDZ7jYBXd5377ZhcZyqZo36NbFFv0RObbBTqkhrKPSWLMrYezF1UbEp2rd7ECOt5QHb62KUWPBrE8YZQCh0ZHHHj49JHqjLC4k6rPDflF3Tn3TX84RTQY9xTqMDzwdfzmXUcCLbMx-0F3_663Vwq-IB_H3dbZuQfArkK0G3Mit2nAmn0qQG9AZgONAdx4IcAfaqM8AY-udesmjliFcJEwISBYpEYcnaxiWH7F1weJNdGOVorISIOd8nWejYEqYI7_KRAViCeH0dijx3VWTOxsGNidVL8tGE9-Vd9xNFpOD2rCHUzWt-FO-5fbmoTgCtlTZprDsa5Swx8iLmN4xEi72yGGUg69YmuQ3BTYgxC1pTEL8TszZFuUBtcSnFBZOQzGSP8ld6ZPIk4_EFC_14b5xS3cMuDT5Aw2n1b_-ov-piFgm_GGKh0S2vGHG__DVkZl3J9ZRI3LHC-zXQ9AExlzLp1J05SfchCHVlsFHF0yckoEf41D6kFfnwBToQzzl9oO5z-MsnxuHTNjcpcS2AZlIm1xz1NpNYI4iwQDCNqckZJJnNpJXzKH6XpfcCSOErj9CpY_gHFHmwnBWaDh5oplOiYqOObu2ai7taz7Y1uMGSQnALALRlNqf8MOEyFNx0qhhNGY5J1r6Tmj6PXj_IeR8xZEG2cuWVedgn3e0QN19QiTcmCq3AXx_b2vASDkU5jq6Gg0AvVELIDJwMx2BVXBvvj9Y4PP4DNRhJIahTra0bCRlVDniLSRWXKQTELgOOT1EFAholaXkHBd4mJIAXZeGmlr8DiYgpnrYfLSxU1Pz3PCNMN0cU8TQB08Qi3vwvV2LUeGohRtjwjloOAhR3zM", + "ciphertextSha256": "2a443510ab94d3dfd75fec57d2e462ab91dead64147a5027361c75ec37fbe9e7" + } + } + ] +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/mod.rs new file mode 100644 index 000000000..ea67efdab --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/mod.rs @@ -0,0 +1,50 @@ +//! Native-only encrypted sync wire protocol. Never expose these secret-bearing +//! types through UI status/events. Scheduling, persistence and restore belong to +//! the caller; this module never retries a write or applies a downloaded document. + +pub(crate) mod crypto; +pub(crate) mod transport; +pub mod types; + +pub(crate) type Result = std::result::Result; + +/// Only fixed diagnostic text and validated protocol codes may cross this boundary. +/// In particular, no reqwest/serde error, server message, token or payload is kept. +#[derive(Debug, thiserror::Error)] +pub(crate) enum Error { + #[error("invalid encrypted sync endpoint")] + InvalidEndpoint, + #[error("invalid encrypted sync response: {0}")] + InvalidResponse(&'static str), + #[error("invalid encrypted sync data: {0}")] + InvalidWire(&'static str), + #[error("encrypted sync transport failed; a write may have committed")] + Transport, + #[error("encrypted sync payload exceeds the protocol limit")] + PayloadTooLarge, + #[error("encrypted sync account does not match")] + AccountMismatch, + #[error("encrypted sync version or key context does not match")] + ContextMismatch, + #[error("unsupported encrypted sync protocol")] + UnsupportedProtocol, + #[error("unsupported encrypted document version")] + UnsupportedDocumentVersion, + #[error("encryption password does not meet the local password policy")] + WeakPassword, + #[error("normalized encryption passwords do not match")] + PasswordConfirmationMismatch, + #[error("invalid encryption password or ciphertext")] + InvalidPasswordOrCiphertext, + #[error("secure random source unavailable")] + RandomUnavailable, + #[error("encrypted sync revision exhausted")] + RevisionExhausted, + #[error("encrypted sync service rejected the request ({status}, {code})")] + Api { + status: u16, + code: types::RemoteErrorCode, + retry_after_seconds: Option, + current_revision: Option, + }, +} 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 new file mode 100644 index 000000000..85f21bcb1 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs @@ -0,0 +1,2365 @@ +//! Bounded HTTPS transport for the encrypted sync v1 wire contract. +//! +//! A transport is usable only after its capabilities have been checked against +//! the native application's configured OAuth client ID. Mutations are prepared +//! before sending: cancellation, a network failure, or an invalid response must +//! leave that same prepared operation available for reconciliation or retry. + +use std::{borrow::Cow, fmt, sync::Arc, time::Duration}; + +use reqwest::{ + header::{ + HeaderMap, HeaderValue, ACCEPT, ACCEPT_ENCODING, AUTHORIZATION, CACHE_CONTROL, + CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, ETAG, IF_MATCH, IF_NONE_MATCH, RETRY_AFTER, + }, + Client, Method, RequestBuilder, Response, StatusCode, +}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use url::Url; +use zeroize::Zeroizing; + +use super::{ + types::{ + parse_wire, AuthSession, Capabilities, DeleteVaultRequest, ErrorEnvelope, GithubId, + OperationKind, OperationPending, OperationReceipt, RemoteErrorCode, Revision, Sha256Digest, + SnapshotUpload, StrongEtag, UploadKind, UuidV4, Validate, VaultMetadata, VaultState, + CONTROL_BODY_LIMIT, HTTP_BODY_LIMIT, PROTOCOL_VERSION, + }, + Error, Result, +}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const REQUEST_TIMEOUT: Duration = Duration::from_secs(60); +const IDEMPOTENCY_KEY: &str = "idempotency-key"; +const IDEMPOTENCY_REPLAYED: &str = "idempotency-replayed"; +// A valid JSON request stored as a JSON string needs at most twice its original +// bytes for escaping. The headroom covers the fixed metadata and bounded origin. +const PENDING_RECORD_LIMIT: usize = HTTP_BODY_LIMIT * 2 + CONTROL_BODY_LIMIT; +const PENDING_ORIGIN_LIMIT: usize = 2048; + +/// One captured Core setting and the effective routing decision for this origin. +/// Loopback stays direct even when the captured setting allows system proxies. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ProxyPolicy { + use_system_proxy: bool, + bypass_proxy: bool, +} + +impl ProxyPolicy { + pub(crate) fn for_origin(origin: &str, use_system_proxy: bool) -> Self { + Self { + use_system_proxy, + bypass_proxy: crate::net::should_bypass_proxy(origin, use_system_proxy), + } + } + + fn apply(self, builder: reqwest::ClientBuilder) -> reqwest::ClientBuilder { + if self.bypass_proxy { + builder.no_proxy() + } else { + builder + } + } +} + +/// No caller-supplied HTTP client can weaken the transport's TLS or redirect policy. +#[derive(Clone)] +pub(crate) struct Transport { + origin: Url, + client: Client, + capabilities: Arc, + proxy_policy: ProxyPolicy, +} + +impl fmt::Debug for Transport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("Transport").finish_non_exhaustive() + } +} + +/// Native-only, origin-bound credentials; never serialize this into a UI result. +pub(crate) struct SyncSession { + origin: Url, + session: AuthSession, +} + +impl fmt::Debug for SyncSession { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SyncSession") + .finish_non_exhaustive() + } +} + +impl SyncSession { + pub(crate) fn account(&self) -> &super::types::Account { + &self.session.account + } + + pub(crate) fn account_id(&self) -> &GithubId { + &self.session.account.github_id + } + + pub(crate) fn expires_in(&self) -> u32 { + self.session.expires_in + } +} + +/// A metadata representation and its opaque ETag, bound to one service origin. +#[derive(Clone, Debug)] +pub(crate) struct Metadata { + origin: Url, + value: VaultMetadata, + etag: StrongEtag, +} + +impl Metadata { + pub(crate) fn value(&self) -> &VaultMetadata { + &self.value + } + + #[cfg(test)] + pub(crate) fn etag(&self) -> &StrongEtag { + &self.etag + } +} + +#[derive(Debug)] +pub(crate) enum MetadataResult { + Modified(Box), + /// The caller retains the same account's supplied cached metadata. + NotModified, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum MutationMethod { + Upload, + Delete, +} + +impl MutationMethod { + fn http_method(self) -> Method { + match self { + Self::Upload => Method::PUT, + Self::Delete => Method::DELETE, + } + } + + fn path(self) -> &'static str { + match self { + Self::Upload => "/v1/me/vault/snapshot", + Self::Delete => "/v1/me/vault", + } + } + + fn body_limit(self) -> usize { + match self { + Self::Upload => HTTP_BODY_LIMIT, + Self::Delete => CONTROL_BODY_LIMIT, + } + } +} + +/// Local persistence envelope, never an HTTP request. Cow borrows the original +/// bytes when exporting; deserialization deliberately owns every string. +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PendingRecord<'a> { + record_version: u32, + origin: Cow<'a, str>, + owner_github_id: GithubId, + method: MutationMethod, + body: Cow<'a, str>, + if_match: Cow<'a, str>, + operation_id: UuidV4, +} + +impl Validate for PendingRecord<'_> { + fn validate(&self) -> Result<()> { + if self.record_version != 1 { + return Err(Error::InvalidWire("pending record version")); + } + if self.origin.len() > PENDING_ORIGIN_LIMIT || self.body.len() > self.method.body_limit() { + return Err(Error::PayloadTooLarge); + } + StrongEtag::parse(&self.if_match)?; + Ok(()) + } +} + +/// Immutable retry material. Retain it before starting a mutation; durable +/// pending-operation storage belongs to the native sync coordinator. +/// +/// Never regenerate ciphertext, an operation ID, or an If-Match value because a +/// request timed out. A missing receipt also does not prove a write failed. +#[derive(Clone)] +pub(crate) struct PreparedOperation { + origin: Url, + owner: GithubId, + method: MutationMethod, + body: Vec, + if_match: StrongEtag, + operation_id: UuidV4, + expected_kind: OperationKind, + expected_revision: Revision, + expected_vault: UuidV4, + expected_sha256: Option, +} + +impl fmt::Debug for PreparedOperation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PreparedOperation") + .field("method", &self.method) + .field("body_bytes", &self.body.len()) + .finish_non_exhaustive() + } +} + +impl PreparedOperation { + #[cfg(test)] + pub(crate) fn body_bytes(&self) -> &[u8] { + &self.body + } + + #[cfg(test)] + pub(crate) fn if_match(&self) -> &StrongEtag { + &self.if_match + } + + pub(crate) fn operation_id(&self) -> &UuidV4 { + &self.operation_id + } + + /// Export retry material without credentials, filesystem access or network + /// access. The native coordinator owns durable storage and its lifecycle. + /// Body bytes are kept verbatim, including JSON whitespace and key ordering. + pub(crate) fn to_pending_record(&self) -> Result> { + let body = std::str::from_utf8(&self.body) + .map_err(|_| Error::InvalidWire("pending request encoding"))?; + let record = PendingRecord { + record_version: 1, + origin: Cow::Borrowed(self.origin.as_str()), + owner_github_id: self.owner.clone(), + method: self.method, + body: Cow::Borrowed(body), + if_match: Cow::Borrowed(self.if_match.as_str()), + operation_id: self.operation_id.clone(), + }; + record.validate()?; + serialize_body(&record, PENDING_RECORD_LIMIT) + } + + fn validate_receipt(&self, receipt: &OperationReceipt) -> Result<()> { + receipt.validate()?; + if receipt.operation_id != self.operation_id + || receipt.kind != self.expected_kind + || receipt.committed_revision != self.expected_revision + || receipt.vault_id != self.expected_vault + || receipt.ciphertext_sha256 != self.expected_sha256 + { + return Err(Error::InvalidResponse("receipt does not match operation")); + } + Ok(()) + } +} + +#[derive(Debug)] +pub(crate) struct MutationReceipt { + pub(crate) receipt: OperationReceipt, + /// This confirms an earlier commit, never that its revision is still current. + pub(crate) replayed: bool, +} + +#[derive(Debug)] +pub(crate) enum OperationStatus { + Committed(OperationReceipt), + Pending(OperationPending), + /// Keep the original operation. It may still be in flight or have expired + /// from the server's receipt retention window. + NotFound, +} + +impl Transport { + /// Check capabilities without credentials before allowing authentication. + pub(crate) async fn new( + origin: &str, + expected_github_client_id: &str, + proxy_policy: ProxyPolicy, + ) -> Result { + let origin = parse_origin(origin, false)?; + let proxy_policy = ProxyPolicy::for_origin(origin.as_str(), proxy_policy.use_system_proxy); + let client = client_builder(proxy_policy) + .https_only(true) + .build() + .map_err(|_| Error::Transport)?; + Self::check_capabilities(origin, client, expected_github_client_id, proxy_policy).await + } + + async fn check_capabilities( + origin: Url, + client: Client, + expected_github_client_id: &str, + proxy_policy: ProxyPolicy, + ) -> Result { + if expected_github_client_id.is_empty() + || expected_github_client_id.len() > 128 + || !expected_github_client_id + .bytes() + .all(|byte| byte.is_ascii_graphic()) + { + return Err(Error::InvalidEndpoint); + } + let mut endpoint = origin.clone(); + endpoint.set_path("/v1/capabilities"); + let response = send(client.get(endpoint).header(ACCEPT, "application/json")).await?; + let capabilities: Capabilities = success_json(response, CONTROL_BODY_LIMIT).await?; + if capabilities.github_client_id != expected_github_client_id { + return Err(Error::InvalidResponse("OAuth application mismatch")); + } + Ok(Self { + origin, + client, + capabilities: Arc::new(capabilities), + proxy_policy, + }) + } + + pub(crate) fn capabilities(&self) -> &Capabilities { + &self.capabilities + } + + pub(crate) fn proxy_policy(&self) -> ProxyPolicy { + self.proxy_policy + } + + /// Canonical HTTPS origin for native secure-store/journal scope binding. + pub(crate) fn service_origin(&self) -> &str { + self.origin.as_str() + } + + pub(crate) async fn exchange( + &self, + github_token: &str, + expected_account: &GithubId, + ) -> Result { + let response = send( + self.request(Method::POST, "/v1/auth/github") + .header(AUTHORIZATION, bearer_header(github_token)?), + ) + .await?; + let session: AuthSession = success_json(response, CONTROL_BODY_LIMIT).await?; + if &session.account.github_id != expected_account { + return Err(Error::AccountMismatch); + } + Ok(SyncSession { + origin: self.origin.clone(), + session, + }) + } + + /// Consume the local credential even when remote logout fails. A validated + /// 401 means the credential is already unusable and is treated as logged out. + pub(crate) async fn revoke_session(&self, session: SyncSession) -> Result<()> { + let response = + send(self.authenticated_request(&session, Method::DELETE, "/v1/auth/session")?).await?; + if response.status() == StatusCode::NO_CONTENT { + return empty_response(response).await; + } + let error = api_error(response).await; + match error { + Error::Api { + status: 401, + code: RemoteErrorCode::Unauthenticated | RemoteErrorCode::SessionExpired, + .. + } => Ok(()), + error => Err(error), + } + } + + pub(crate) async fn metadata( + &self, + session: &SyncSession, + cached: Option<&Metadata>, + ) -> Result { + let mut request = self.authenticated_request(session, Method::GET, "/v1/me/vault")?; + if let Some(metadata) = cached { + self.check_metadata(session, metadata)?; + request = request.header(IF_NONE_MATCH, metadata.etag.as_str()); + } + let response = send(request).await?; + if response.status() == StatusCode::NOT_MODIFIED { + let metadata = + cached.ok_or(Error::InvalidResponse("unsolicited not-modified response"))?; + if response_etag(response.headers())? != metadata.etag { + return Err(Error::InvalidResponse("not-modified ETag mismatch")); + } + empty_response(response).await?; + return Ok(MetadataResult::NotModified); + } + // This route always represents empty/deleted state with a 200 DTO. + if response.status() == StatusCode::NOT_FOUND { + return Err(Error::InvalidResponse("metadata route unavailable")); + } + if response.status() != StatusCode::OK { + return Err(api_error(response).await); + } + let etag = response_etag(response.headers())?; + let value: VaultMetadata = response_json(response, CONTROL_BODY_LIMIT).await?; + if &value.owner_github_id != session.account_id() { + return Err(Error::AccountMismatch); + } + Ok(MetadataResult::Modified(Box::new(Metadata { + origin: self.origin.clone(), + value, + etag, + }))) + } + + pub(crate) async fn snapshot( + &self, + session: &SyncSession, + metadata: &Metadata, + ) -> Result { + self.check_metadata(session, metadata)?; + if metadata.value.state != VaultState::Active { + return Err(Error::ContextMismatch); + } + let response = send( + self.authenticated_request(session, Method::GET, "/v1/me/vault/snapshot")? + .header(IF_MATCH, metadata.etag.as_str()), + ) + .await?; + if response.status() != StatusCode::OK { + return Err(api_error(response).await); + } + if response_etag(response.headers())? != metadata.etag { + return Err(Error::InvalidResponse("snapshot ETag mismatch")); + } + let snapshot: SnapshotUpload = response_json(response, HTTP_BODY_LIMIT).await?; + snapshot.validate_against_metadata(&metadata.value, session.account_id())?; + Ok(snapshot) + } + + /// Active uploads also require their downloaded predecessor, because metadata + /// deliberately does not expose the KDF salt that must stay in its key epoch. + pub(crate) fn prepare_upload( + &self, + session: &SyncSession, + metadata: &Metadata, + upload: &SnapshotUpload, + previous_snapshot: Option<&SnapshotUpload>, + ) -> Result { + self.check_metadata(session, metadata)?; + upload.validate()?; + if &upload.owner_github_id != session.account_id() { + return Err(Error::AccountMismatch); + } + if upload.base_revision != metadata.value.revision + || upload.revision != metadata.value.revision.checked_next()? + || metadata.value.last_operation_id.as_ref() == Some(&upload.operation_id) + { + return Err(Error::ContextMismatch); + } + let expected_kind = match upload.kind { + UploadKind::Create => { + if metadata.value.state == VaultState::Active + || metadata.value.vault_id.as_ref() == Some(&upload.vault_id) + || previous_snapshot.is_some() + { + return Err(Error::ContextMismatch); + } + OperationKind::Create + } + UploadKind::Snapshot | UploadKind::PasswordChange => { + let previous = previous_snapshot.ok_or(Error::ContextMismatch)?; + previous.validate_against_metadata(&metadata.value, session.account_id())?; + if upload.vault_id != previous.vault_id + || upload.operation_id == previous.operation_id + || upload.nonce == previous.nonce + { + return Err(Error::ContextMismatch); + } + let same_key = upload.key_id == previous.key_id; + let same_salt = upload.kdf.salt == previous.kdf.salt; + if (upload.kind == UploadKind::Snapshot && (!same_key || !same_salt)) + || (upload.kind == UploadKind::PasswordChange && (same_key || same_salt)) + { + return Err(Error::ContextMismatch); + } + OperationKind::from(upload.kind) + } + }; + let body = serialize_body(upload, HTTP_BODY_LIMIT)?; + Ok(PreparedOperation { + origin: self.origin.clone(), + owner: session.account_id().clone(), + method: MutationMethod::Upload, + body, + if_match: metadata.etag.clone(), + operation_id: upload.operation_id.clone(), + expected_kind, + expected_revision: upload.revision, + expected_vault: upload.vault_id.clone(), + expected_sha256: Some(upload.ciphertext_sha256.clone()), + }) + } + + pub(crate) fn prepare_delete( + &self, + session: &SyncSession, + metadata: &Metadata, + operation_id: UuidV4, + ) -> Result { + self.check_metadata(session, metadata)?; + if metadata.value.state != VaultState::Active + || metadata.value.last_operation_id.as_ref() == Some(&operation_id) + { + return Err(Error::ContextMismatch); + } + let vault_id = metadata + .value + .vault_id + .clone() + .ok_or(Error::ContextMismatch)?; + let expected_revision = metadata.value.revision.checked_next()?; + let request = DeleteVaultRequest { + protocol_version: PROTOCOL_VERSION, + base_revision: metadata.value.revision, + operation_id: operation_id.clone(), + expected_vault_id: vault_id.clone(), + }; + request.validate()?; + Ok(PreparedOperation { + origin: self.origin.clone(), + owner: session.account_id().clone(), + method: MutationMethod::Delete, + body: serialize_body(&request, CONTROL_BODY_LIMIT)?, + if_match: metadata.etag.clone(), + operation_id, + expected_kind: OperationKind::Delete, + expected_revision, + expected_vault: vault_id, + expected_sha256: None, + }) + } + + /// Recover an already prepared operation after a restart. This performs only + /// local validation: current metadata must not replace the original CAS + /// precondition, and receipt retention/reconciliation belongs to the caller. + /// Expected receipt fields are derived from the validated original body. + pub(crate) fn restore_pending( + &self, + session: &SyncSession, + bytes: &[u8], + ) -> Result { + self.check_session(session)?; + let record: PendingRecord<'static> = parse_wire(bytes, PENDING_RECORD_LIMIT)?; + // The HTTP exception is compiled exclusively for the loopback unit tests; + // production records must have an HTTPS origin just like Transport::new. + let origin = parse_origin(&record.origin, cfg!(test))?; + if origin != self.origin { + return Err(Error::ContextMismatch); + } + if &record.owner_github_id != session.account_id() { + return Err(Error::AccountMismatch); + } + let if_match = StrongEtag::parse(&record.if_match)?; + let (expected_kind, expected_revision, expected_vault, expected_sha256) = match record + .method + { + MutationMethod::Upload => { + let upload: SnapshotUpload = parse_wire(record.body.as_bytes(), HTTP_BODY_LIMIT)?; + if &upload.owner_github_id != session.account_id() { + return Err(Error::AccountMismatch); + } + if upload.operation_id != record.operation_id { + return Err(Error::ContextMismatch); + } + if upload.kind != UploadKind::Create && upload.base_revision.get() == 0 { + return Err(Error::InvalidWire("pending upload revision")); + } + ( + OperationKind::from(upload.kind), + upload.revision, + upload.vault_id, + Some(upload.ciphertext_sha256), + ) + } + MutationMethod::Delete => { + let request: DeleteVaultRequest = + parse_wire(record.body.as_bytes(), CONTROL_BODY_LIMIT)?; + if request.operation_id != record.operation_id { + return Err(Error::ContextMismatch); + } + ( + OperationKind::Delete, + request.base_revision.checked_next()?, + request.expected_vault_id, + None, + ) + } + }; + Ok(PreparedOperation { + origin, + owner: record.owner_github_id, + method: record.method, + body: record.body.into_owned().into_bytes(), + if_match, + operation_id: record.operation_id, + expected_kind, + expected_revision, + expected_vault, + expected_sha256, + }) + } + + /// Every error (including cancellation) leaves the borrowed operation intact. + /// The caller must reconcile it, never infer from an error that no commit occurred. + pub(crate) async fn submit( + &self, + session: &SyncSession, + operation: &PreparedOperation, + ) -> Result { + self.check_operation(session, operation)?; + let response = send( + self.authenticated_request( + session, + operation.method.http_method(), + operation.method.path(), + )? + .header(CONTENT_TYPE, "application/json") + .header(IF_MATCH, operation.if_match.as_str()) + .header(IDEMPOTENCY_KEY, operation.operation_id.as_str()) + .body(operation.body.clone()), + ) + .await?; + if response.status() != StatusCode::OK { + return Err(api_error(response).await); + } + let replayed = match single_header(response.headers(), IDEMPOTENCY_REPLAYED)? { + "true" => true, + "false" => false, + _ => return Err(Error::InvalidResponse("invalid replay header")), + }; + let receipt: OperationReceipt = response_json(response, CONTROL_BODY_LIMIT).await?; + operation.validate_receipt(&receipt)?; + Ok(MutationReceipt { receipt, replayed }) + } + + pub(crate) async fn operation( + &self, + session: &SyncSession, + operation: &PreparedOperation, + ) -> Result { + self.check_operation(session, operation)?; + let path = format!("/v1/me/operations/{}", operation.operation_id); + let response = send(self.authenticated_request(session, Method::GET, &path)?).await?; + match response.status() { + StatusCode::OK => { + let receipt: OperationReceipt = response_json(response, CONTROL_BODY_LIMIT).await?; + operation.validate_receipt(&receipt)?; + Ok(OperationStatus::Committed(receipt)) + } + StatusCode::ACCEPTED => { + let pending: OperationPending = response_json(response, CONTROL_BODY_LIMIT).await?; + if pending.operation_id != operation.operation_id { + return Err(Error::InvalidResponse("pending operation mismatch")); + } + Ok(OperationStatus::Pending(pending)) + } + _ => match api_error(response).await { + Error::Api { + status: 404, + code: RemoteErrorCode::OperationNotFound, + .. + } => Ok(OperationStatus::NotFound), + error => Err(error), + }, + } + } + + fn request(&self, method: Method, path: &str) -> RequestBuilder { + let mut endpoint = self.origin.clone(); + endpoint.set_path(path); + self.client + .request(method, endpoint) + .header(ACCEPT, "application/json") + } + + fn authenticated_request( + &self, + session: &SyncSession, + method: Method, + path: &str, + ) -> Result { + self.check_session(session)?; + Ok(self.request(method, path).header( + AUTHORIZATION, + bearer_header(session.session.access_token.expose())?, + )) + } + + fn check_session(&self, session: &SyncSession) -> Result<()> { + if session.origin != self.origin { + return Err(Error::ContextMismatch); + } + session.session.validate() + } + + fn check_metadata(&self, session: &SyncSession, metadata: &Metadata) -> Result<()> { + self.check_session(session)?; + if metadata.origin != self.origin { + return Err(Error::ContextMismatch); + } + if &metadata.value.owner_github_id != session.account_id() { + return Err(Error::AccountMismatch); + } + metadata.value.validate() + } + + fn check_operation(&self, session: &SyncSession, operation: &PreparedOperation) -> Result<()> { + self.check_session(session)?; + if operation.origin != self.origin { + return Err(Error::ContextMismatch); + } + if &operation.owner != session.account_id() { + return Err(Error::AccountMismatch); + } + Ok(()) + } + + /// Loopback HTTP exists only in unit tests, with the same redirect and body policy. + #[cfg(test)] + pub(crate) async fn for_test(origin: &str, expected_client_id: &str) -> Result { + Self::for_test_with_proxy_policy( + origin, + expected_client_id, + ProxyPolicy::for_origin(origin, false), + ) + .await + } + + #[cfg(test)] + pub(crate) async fn for_test_with_proxy_policy( + origin: &str, + expected_client_id: &str, + proxy_policy: ProxyPolicy, + ) -> Result { + let origin = parse_origin(origin, true)?; + let proxy_policy = ProxyPolicy::for_origin(origin.as_str(), proxy_policy.use_system_proxy); + let client = client_builder(proxy_policy) + .build() + .map_err(|_| Error::Transport)?; + Self::check_capabilities(origin, client, expected_client_id, proxy_policy).await + } +} + +fn client_builder(proxy_policy: ProxyPolicy) -> reqwest::ClientBuilder { + let mut headers = HeaderMap::new(); + headers.insert(ACCEPT_ENCODING, HeaderValue::from_static("identity")); + proxy_policy.apply( + Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .referer(false) + .connect_timeout(CONNECT_TIMEOUT) + .timeout(REQUEST_TIMEOUT) + // Keep byte limits meaningful even if another workspace dependency enables + // reqwest compression features through Cargo feature unification. + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .default_headers(headers), + ) +} + +fn parse_origin(input: &str, test_loopback: bool) -> Result { + if input.chars().any(char::is_whitespace) { + return Err(Error::InvalidEndpoint); + } + let url = Url::parse(input).map_err(|_| Error::InvalidEndpoint)?; + let https = url.scheme() == "https"; + let loopback = cfg!(test) + && test_loopback + && url.scheme() == "http" + && url.host_str() == Some("127.0.0.1"); + if (!https && !loopback) + || url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.path() != "/" + || url.query().is_some() + || url.fragment().is_some() + { + return Err(Error::InvalidEndpoint); + } + Ok(url) +} + +fn bearer_header(token: &str) -> Result { + if token.is_empty() || token.len() > 2048 || !token.bytes().all(|byte| byte.is_ascii_graphic()) + { + return Err(Error::InvalidWire("invalid token")); + } + let value = Zeroizing::new(format!("Bearer {token}")); + let mut header = + HeaderValue::from_str(&value).map_err(|_| Error::InvalidWire("invalid token"))?; + header.set_sensitive(true); + Ok(header) +} + +async fn send(request: RequestBuilder) -> Result { + let response = request.send().await.map_err(|_| Error::Transport)?; + if response.status().is_redirection() && response.status() != StatusCode::NOT_MODIFIED { + return Err(Error::InvalidResponse("redirect refused")); + } + Ok(response) +} + +fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Result<&'a str> { + let mut values = headers.get_all(name).iter(); + let value = values + .next() + .ok_or(Error::InvalidResponse("required header missing"))?; + if values.next().is_some() { + return Err(Error::InvalidResponse("duplicate response header")); + } + value + .to_str() + .map_err(|_| Error::InvalidResponse("invalid response header")) +} + +fn response_etag(headers: &HeaderMap) -> Result { + StrongEtag::parse(single_header(headers, ETAG.as_str())?) + .map_err(|_| Error::InvalidResponse("invalid strong ETag")) +} + +fn private_response_headers(headers: &HeaderMap, json: bool) -> Result<()> { + if !single_header(headers, CACHE_CONTROL.as_str())?.eq_ignore_ascii_case("no-store") { + return Err(Error::InvalidResponse("response must be no-store")); + } + if headers.contains_key(CONTENT_ENCODING) + && !single_header(headers, CONTENT_ENCODING.as_str())?.eq_ignore_ascii_case("identity") + { + return Err(Error::InvalidResponse("encoded response refused")); + } + if json { + let value = single_header(headers, CONTENT_TYPE.as_str())?; + let mut pieces = value.split(';'); + if !pieces + .next() + .is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json")) + { + return Err(Error::InvalidResponse("response must be JSON")); + } + if let Some(parameter) = pieces.next() { + let Some((key, value)) = parameter.trim().split_once('=') else { + return Err(Error::InvalidResponse("invalid JSON content type")); + }; + if !key.trim().eq_ignore_ascii_case("charset") + || !matches!( + value.trim().to_ascii_lowercase().as_str(), + "utf-8" | "\"utf-8\"" + ) + || pieces.next().is_some() + { + return Err(Error::InvalidResponse("invalid JSON content type")); + } + } + } + Ok(()) +} + +async fn bounded_body(mut response: Response, limit: usize) -> Result>> { + if response.headers().contains_key(CONTENT_LENGTH) { + let declared = single_header(response.headers(), CONTENT_LENGTH.as_str())? + .parse::() + .map_err(|_| Error::InvalidResponse("invalid content length"))?; + if declared > u64::try_from(limit).map_err(|_| Error::PayloadTooLarge)? { + return Err(Error::PayloadTooLarge); + } + } + // Control responses can contain a sync token. Reserve their bounded capacity + // once so Vec growth does not leave an older allocation containing that token. + let mut bytes = Zeroizing::new(Vec::with_capacity(limit.min(CONTROL_BODY_LIMIT))); + while let Some(chunk) = response.chunk().await.map_err(|_| Error::Transport)? { + if chunk.len() > limit.saturating_sub(bytes.len()) { + return Err(Error::PayloadTooLarge); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes) +} + +async fn response_json( + response: Response, + limit: usize, +) -> Result { + private_response_headers(response.headers(), true)?; + let body = bounded_body(response, limit).await?; + parse_wire(&body, limit) +} + +async fn success_json( + response: Response, + limit: usize, +) -> Result { + if response.status() != StatusCode::OK { + return Err(api_error(response).await); + } + response_json(response, limit).await +} + +async fn empty_response(mut response: Response) -> Result<()> { + private_response_headers(response.headers(), false)?; + // HTTP permits a 304 Content-Length to describe the full representation, + // even though the 304 itself has no body. Do not mistake it for payload. + if response.status() == StatusCode::NOT_MODIFIED { + response.headers_mut().remove(CONTENT_LENGTH); + } + let body = bounded_body(response, 0).await?; + if !body.is_empty() { + return Err(Error::InvalidResponse("unexpected response body")); + } + Ok(()) +} + +fn serialize_body(value: &T, limit: usize) -> Result> { + let body = serde_json::to_vec(value).map_err(|_| Error::InvalidWire("serialization failed"))?; + if body.len() > limit { + return Err(Error::PayloadTooLarge); + } + Ok(body) +} + +async fn api_error(response: Response) -> Error { + async fn decode(response: Response) -> Result { + let status = response.status().as_u16(); + if !(400..600).contains(&status) { + return Err(Error::InvalidResponse("unexpected response status")); + } + let retry_after_seconds = if status == 429 { + let value = single_header(response.headers(), RETRY_AFTER.as_str())?; + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(Error::InvalidResponse("invalid retry delay")); + } + let seconds = value + .parse::() + .map_err(|_| Error::InvalidResponse("invalid retry delay"))?; + if !(1..=86400).contains(&seconds) { + return Err(Error::InvalidResponse("invalid retry delay")); + } + Some(seconds) + } else { + None + }; + let envelope: ErrorEnvelope = response_json(response, CONTROL_BODY_LIMIT).await?; + if !error_status_matches(envelope.error.code, status) { + return Err(Error::InvalidResponse("error code and status mismatch")); + } + Ok(Error::Api { + status, + code: envelope.error.code, + retry_after_seconds, + current_revision: envelope.error.current_revision, + }) + } + match decode(response).await { + Ok(error) | Err(error) => error, + } +} + +fn error_status_matches(code: RemoteErrorCode, status: u16) -> bool { + match code { + RemoteErrorCode::InvalidRequest + | RemoteErrorCode::UnsupportedProtocol + | RemoteErrorCode::InvalidCryptoHeader => status == 400, + RemoteErrorCode::Unauthenticated | RemoteErrorCode::SessionExpired => status == 401, + RemoteErrorCode::WrongOauthApp | RemoteErrorCode::OwnerMismatch => status == 403, + RemoteErrorCode::VaultEmpty | RemoteErrorCode::VaultDeleted => matches!(status, 404 | 409), + RemoteErrorCode::OperationNotFound => status == 404, + RemoteErrorCode::IdempotencyKeyReused + | RemoteErrorCode::KeyEpochChanged + | RemoteErrorCode::VaultExists + | RemoteErrorCode::RevisionExhausted => status == 409, + RemoteErrorCode::RevisionConflict => status == 412, + RemoteErrorCode::PayloadTooLarge => status == 413, + RemoteErrorCode::UnsupportedMediaType => status == 415, + RemoteErrorCode::PreconditionRequired => status == 428, + RemoteErrorCode::RateLimited => status == 429, + RemoteErrorCode::InternalError => status == 500, + RemoteErrorCode::ServiceUnavailable => status == 503, + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; + use serde_json::{json, Value}; + use sha2::{Digest, Sha256}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + task::JoinHandle, + }; + + use super::*; + + const CLIENT_ID: &str = "test-openless-oauth-app"; + const OWNER: &str = "12345"; + const VAULT: &str = "be406ca5-37fa-4b26-9a9a-74c1aad48eae"; + const KEY: &str = "2b1889ab-7278-48e7-8d2f-284195de2484"; + const OLD_OPERATION: &str = "e1d8c32e-d209-4e56-8735-bc66b8684c71"; + const NEW_OPERATION: &str = "f1d8c32e-d209-4e56-8735-bc66b8684c71"; + + struct Reply { + status: &'static str, + headers: Vec<(String, String)>, + body: Vec, + chunked: bool, + disconnect: bool, + } + + impl Reply { + fn json(status: &'static str, value: Value) -> Self { + Self::raw(status, serde_json::to_vec(&value).unwrap()) + } + + fn raw(status: &'static str, body: Vec) -> Self { + Self { + status, + headers: vec![ + ("Cache-Control".into(), "no-store".into()), + ("Content-Type".into(), "application/json".into()), + ], + body, + chunked: false, + disconnect: false, + } + } + + fn header(mut self, key: &str, value: &str) -> Self { + self.headers + .retain(|(name, _)| !name.eq_ignore_ascii_case(key)); + self.headers.push((key.into(), value.into())); + self + } + + fn without_header(mut self, key: &str) -> Self { + self.headers + .retain(|(name, _)| !name.eq_ignore_ascii_case(key)); + self + } + + fn chunked(mut self) -> Self { + self.chunked = true; + self + } + + fn disconnect() -> Self { + let mut reply = Self::raw("200 OK", Vec::new()); + reply.disconnect = true; + reply + } + } + + #[derive(Debug)] + struct RecordedRequest { + method: String, + path: String, + headers: BTreeMap, + body: Vec, + } + + struct FakeServer { + origin: String, + task: Option>>, + } + + impl Drop for FakeServer { + fn drop(&mut self) { + if let Some(task) = &self.task { + task.abort(); + } + } + } + + impl FakeServer { + async fn start(replies: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let task = tokio::spawn(async move { + let mut requests = Vec::new(); + for reply in replies { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut wire = Vec::new(); + let mut buffer = [0_u8; 8192]; + let header_end = loop { + let size = socket.read(&mut buffer).await.unwrap(); + assert_ne!(size, 0, "request closed before headers"); + wire.extend_from_slice(&buffer[..size]); + if let Some(index) = wire.windows(4).position(|part| part == b"\r\n\r\n") { + break index + 4; + } + }; + let head = std::str::from_utf8(&wire[..header_end]).unwrap(); + let mut lines = head.split("\r\n"); + let mut request_line = lines.next().unwrap().split_whitespace(); + let method = request_line.next().unwrap().to_owned(); + let path = request_line.next().unwrap().to_owned(); + let headers: BTreeMap = lines + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.to_ascii_lowercase(), value.trim().to_owned())) + .collect(); + let body_size = headers + .get("content-length") + .map_or(0, |value| value.parse::().unwrap()); + while wire.len() < header_end + body_size { + let size = socket.read(&mut buffer).await.unwrap(); + assert_ne!(size, 0, "request closed before body"); + wire.extend_from_slice(&buffer[..size]); + } + requests.push(RecordedRequest { + method, + path, + headers, + body: wire[header_end..header_end + body_size].to_vec(), + }); + if reply.disconnect { + continue; + } + let mut response = + format!("HTTP/1.1 {}\r\nConnection: close\r\n", reply.status); + for (key, value) in &reply.headers { + response.push_str(&format!("{key}: {value}\r\n")); + } + if reply.chunked { + response.push_str("Transfer-Encoding: chunked\r\n"); + } else if !reply + .headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case("Content-Length")) + { + response.push_str(&format!("Content-Length: {}\r\n", reply.body.len())); + } + response.push_str("\r\n"); + if socket.write_all(response.as_bytes()).await.is_err() { + continue; + } + if reply.chunked { + for chunk in reply.body.chunks(4096) { + if socket + .write_all(format!("{:x}\r\n", chunk.len()).as_bytes()) + .await + .is_err() + || socket.write_all(chunk).await.is_err() + || socket.write_all(b"\r\n").await.is_err() + { + break; + } + } + let _ = socket.write_all(b"0\r\n\r\n").await; + } else { + let _ = socket.write_all(&reply.body).await; + } + } + requests + }); + Self { + origin, + task: Some(task), + } + } + + async fn finish(mut self) -> Vec { + let mut task = self.task.take().unwrap(); + match tokio::time::timeout(Duration::from_secs(5), &mut task).await { + Ok(result) => result.unwrap(), + Err(_) => { + task.abort(); + panic!("fake HTTP server did not receive all expected requests"); + } + } + } + } + + fn capabilities() -> Value { + json!({ + "protocolVersion": 1, + "cryptoProfile": "argon2id-xchacha20poly1305-v1", + "githubClientId": CLIENT_ID, + "maxHttpBodyBytes": 25165824, + "maxCiphertextBytes": 16777232, + "maxPlaintextJsonBytes": 15728640, + "idempotencyRetentionSeconds": 604800, + "maxBackupRetentionDays": 30 + }) + } + + fn caps_reply() -> Reply { + Reply::json("200 OK", capabilities()) + } + + fn auth(owner: &str, token_character: char) -> Value { + json!({ + "protocolVersion": 1, + "accessToken": token_character.to_string().repeat(43), + "tokenType": "Bearer", + "expiresIn": 900, + "account": { "githubId": owner, "login": "fixture-user" } + }) + } + + fn empty_metadata() -> Value { + json!({ + "protocolVersion": 1, "state": "empty", "ownerGithubId": OWNER, + "revision": "0", "vaultId": null, "keyId": null, "updatedAt": null, + "payloadSchemaVersion": null, "ciphertextBytes": 0, + "ciphertextSha256": null, "lastOperationId": null + }) + } + + fn snapshot() -> Value { + let ciphertext = vec![0_u8; 65552]; + json!({ + "protocolVersion": 1, "payloadSchemaVersion": 1, "ownerGithubId": OWNER, + "vaultId": VAULT, "keyId": KEY, "baseRevision": "7", "revision": "8", + "operationId": OLD_OPERATION, "kind": "snapshot", + "cryptoProfile": "argon2id-xchacha20poly1305-v1", + "kdf": { "name": "argon2id", "version": 19, "memoryKiB": 65536, + "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)), + "ciphertext": URL_SAFE_NO_PAD.encode(ciphertext) + }) + } + + fn active_metadata() -> Value { + json!({ + "protocolVersion": 1, "state": "active", "ownerGithubId": OWNER, + "revision": "8", "vaultId": VAULT, "keyId": KEY, + "updatedAt": "2026-09-23T12:00:00Z", "payloadSchemaVersion": 1, + "ciphertextBytes": 65552, "ciphertextSha256": snapshot()["ciphertextSha256"], + "lastOperationId": OLD_OPERATION + }) + } + + fn next_snapshot() -> Value { + let mut upload = snapshot(); + upload["baseRevision"] = json!("8"); + upload["revision"] = json!("9"); + upload["operationId"] = json!(NEW_OPERATION); + upload["nonce"] = json!(URL_SAFE_NO_PAD.encode([1_u8; 24])); + upload + } + + fn receipt(kind: &str) -> Value { + json!({ + "operationId": NEW_OPERATION, "status": "committed", "kind": kind, + "committedRevision": "9", "committedAt": "2026-09-23T12:00:00Z", "vaultId": VAULT, + "ciphertextSha256": if kind == "delete" { Value::Null } else { snapshot()["ciphertextSha256"].clone() } + }) + } + + fn error_envelope(code: &str) -> Value { + json!({ "error": { "code": code, "message": "fixture error", "requestId": OLD_OPERATION } }) + } + + fn typed(value: &Value) -> T { + parse_wire(&serde_json::to_vec(value).unwrap(), HTTP_BODY_LIMIT).unwrap() + } + + fn session(transport: &Transport, owner: &str) -> SyncSession { + SyncSession { + origin: transport.origin.clone(), + session: typed(&auth(owner, 'a')), + } + } + + fn metadata(transport: &Transport, value: Value) -> Metadata { + Metadata { + origin: transport.origin.clone(), + value: typed(&value), + etag: StrongEtag::parse("\"opaque-etag-8\"").unwrap(), + } + } + + fn prepared_upload(transport: &Transport, session: &SyncSession) -> PreparedOperation { + transport + .prepare_upload( + session, + &metadata(transport, active_metadata()), + &typed(&next_snapshot()), + Some(&typed(&snapshot())), + ) + .unwrap() + } + + #[tokio::test] + async fn production_constructor_rejects_insecure_or_ambiguous_origins() { + for origin in [ + "http://127.0.0.1:9", + "ftp://sync.example", + "https://user:secret@sync.example", + "https://sync.example/path", + "https://sync.example?token=secret", + "https://sync.example/#secret", + " https://sync.example", + "https://sync.\nexample", + ] { + assert!(matches!( + Transport::new(origin, CLIENT_ID, ProxyPolicy::for_origin(origin, false)).await, + Err(Error::InvalidEndpoint) + )); + } + assert!(parse_origin("https://sync.example:9443", false).is_ok()); + assert!(parse_origin("http://example.com", true).is_err()); + } + + #[test] + fn proxy_policy_records_one_setting_and_the_origin_bypass_decision() { + let remote = "https://sync.example:9443"; + let enabled = ProxyPolicy::for_origin(remote, true); + let disabled = ProxyPolicy::for_origin(remote, false); + assert!(!enabled.bypass_proxy); + assert!(disabled.bypass_proxy); + assert_ne!(enabled, disabled); + for origin in [ + "https://localhost:9443", + "https://127.0.0.1:9443", + "https://[::1]:9443", + ] { + for setting in [false, true] { + let policy = ProxyPolicy::for_origin(origin, setting); + assert_eq!(policy.use_system_proxy, setting); + assert!(policy.bypass_proxy); + } + } + } + + #[tokio::test] + async fn loopback_transport_records_policy_without_allowing_a_proxy() { + for setting in [false, true] { + let server = FakeServer::start(vec![caps_reply()]).await; + // Even a policy derived for a public origin is normalized to the + // actual constructor endpoint before client creation and recording. + let transport = Transport::for_test_with_proxy_policy( + &server.origin, + CLIENT_ID, + ProxyPolicy::for_origin("https://sync.example", setting), + ) + .await + .unwrap(); + assert_eq!( + transport.proxy_policy(), + ProxyPolicy::for_origin(&server.origin, setting) + ); + assert!(transport.proxy_policy().bypass_proxy); + assert_eq!(server.finish().await.len(), 1); + } + } + + #[tokio::test] + async fn direct_policy_clears_explicit_proxy_without_changing_process_environment() { + for setting in [false, true] { + let direct = FakeServer::start(vec![caps_reply()]).await; + let proxy = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let proxy_url = format!("http://{}", proxy.local_addr().unwrap()); + let seeded = client_builder(ProxyPolicy::for_origin("https://sync.example", true)) + .proxy(reqwest::Proxy::all(proxy_url).unwrap()); + let policy = ProxyPolicy::for_origin(&direct.origin, setting); + let response = policy + .apply(seeded) + .build() + .unwrap() + .get(format!("{}/v1/capabilities", direct.origin)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(direct.finish().await.len(), 1); + assert!( + tokio::time::timeout(Duration::from_millis(100), proxy.accept()) + .await + .is_err() + ); + } + } + + #[tokio::test] + async fn system_policy_retains_a_controlled_proxy_without_external_requests() { + let proxy = FakeServer::start(vec![caps_reply()]).await; + let direct = + FakeServer::start(vec![Reply::raw("503 Service Unavailable", Vec::new())]).await; + let address: std::net::SocketAddr = + direct.origin.trim_start_matches("http://").parse().unwrap(); + let origin = format!("http://sync.invalid:{}", address.port()); + let policy = ProxyPolicy::for_origin(&origin, true); + // Apply the production policy after seeding the controlled proxy. The + // DNS override also keeps a faulty direct branch inside this fixture. + let seeded = client_builder(ProxyPolicy::for_origin(&origin, false)) + .proxy(reqwest::Proxy::all(&proxy.origin).unwrap()) + .resolve("sync.invalid", address); + let client = policy.apply(seeded).build().unwrap(); + assert_eq!( + client + .get(format!("{origin}/v1/capabilities")) + .send() + .await + .unwrap() + .status(), + StatusCode::OK + ); + let requests = proxy.finish().await; + assert_eq!(requests[0].path, format!("{origin}/v1/capabilities")); + } + + #[tokio::test] + async fn capabilities_are_checked_before_auth_and_auth_account_is_bound() { + let server = + FakeServer::start(vec![caps_reply(), Reply::json("200 OK", auth(OWNER, 'a'))]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = transport + .exchange("github-fixture-token", &GithubId::parse(OWNER).unwrap()) + .await + .unwrap(); + assert_eq!(session.account_id().as_str(), OWNER); + assert_eq!(session.expires_in(), 900); + let requests = server.finish().await; + assert_eq!(requests[0].method, "GET"); + assert_eq!(requests[0].path, "/v1/capabilities"); + assert!(!requests[0].headers.contains_key("authorization")); + assert_eq!(requests[1].method, "POST"); + assert_eq!(requests[1].path, "/v1/auth/github"); + assert_eq!( + requests[1].headers["authorization"], + "Bearer github-fixture-token" + ); + assert!(requests[1].body.is_empty()); + assert_eq!(requests[1].headers["accept-encoding"], "identity"); + } + + #[tokio::test] + async fn wrong_oauth_capability_or_duplicate_wire_key_prevents_authentication() { + let mut wrong = capabilities(); + wrong["githubClientId"] = json!("another-app"); + let duplicate = serde_json::to_string(&capabilities()).unwrap().replacen( + '{', + "{\"protocolVersion\":1,", + 1, + ); + for reply in [ + Reply::json("200 OK", wrong), + Reply::raw("200 OK", duplicate.into_bytes()), + ] { + let server = FakeServer::start(vec![reply]).await; + assert!(Transport::for_test(&server.origin, CLIENT_ID) + .await + .is_err()); + let requests = server.finish().await; + assert_eq!(requests.len(), 1); + assert!(!requests[0].headers.contains_key("authorization")); + } + } + + #[tokio::test] + async fn rejects_auth_response_from_another_account() { + let server = + FakeServer::start(vec![caps_reply(), Reply::json("200 OK", auth("999", 'a'))]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport + .exchange("github-fixture-token", &GithubId::parse(OWNER).unwrap()) + .await, + Err(Error::AccountMismatch) + )); + server.finish().await; + } + + #[tokio::test] + async fn refuses_redirect_without_following_or_exposing_location() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::raw("307 Temporary Redirect", b"secret body".to_vec()) + .header("Location", "http://127.0.0.1:9/leaked-token"), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let error = transport + .metadata(&session(&transport, OWNER), None) + .await + .unwrap_err(); + assert!(matches!(error, Error::InvalidResponse("redirect refused"))); + assert!(!format!("{error:?}").contains("leaked-token")); + assert_eq!(server.finish().await.len(), 2); + } + + #[tokio::test] + async fn metadata_preserves_etag_and_validates_conditional_304() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", empty_metadata()).header("ETag", "\"opaque-zero\""), + Reply::raw("304 Not Modified", Vec::new()) + .without_header("Content-Type") + .header("Content-Length", "240") + .header("ETag", "\"opaque-zero\""), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let MetadataResult::Modified(current) = transport.metadata(&session, None).await.unwrap() + else { + panic!("expected metadata") + }; + assert_eq!(current.value().state, VaultState::Empty); + assert_eq!(current.etag().as_str(), "\"opaque-zero\""); + assert!(matches!( + transport + .metadata(&session, Some(current.as_ref())) + .await + .unwrap(), + MetadataResult::NotModified + )); + let requests = server.finish().await; + assert_eq!(requests[2].headers["if-none-match"], "\"opaque-zero\""); + } + + #[tokio::test] + async fn metadata_rejects_unbound_304_and_never_maps_404_to_empty() { + for reply in [ + Reply::raw("304 Not Modified", Vec::new()).header("ETag", "\"opaque-zero\""), + Reply::json("404 Not Found", error_envelope("vault_empty")), + Reply::json("200 OK", empty_metadata()).header("ETag", "W/\"weak\""), + ] { + let server = FakeServer::start(vec![caps_reply(), reply]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport.metadata(&session(&transport, OWNER), None).await, + Err(Error::InvalidResponse(_)) + )); + server.finish().await; + } + let server = FakeServer::start(vec![ + caps_reply(), + Reply::raw("304 Not Modified", Vec::new()).header("ETag", "\"different\""), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport + .metadata( + &session(&transport, OWNER), + Some(&metadata(&transport, active_metadata())) + ) + .await, + Err(Error::InvalidResponse(_)) + )); + server.finish().await; + } + + #[tokio::test] + async fn rejects_non_json_non_private_encoded_and_duplicate_headers() { + let mut duplicate = Reply::json("200 OK", empty_metadata()).header("ETag", "\"tag\""); + duplicate.headers.push(("ETag".into(), "\"second\"".into())); + let good = || Reply::json("200 OK", empty_metadata()).header("ETag", "\"tag\""); + for reply in [ + good().without_header("Cache-Control"), + good().header("Cache-Control", "max-age=60"), + good().header("Cache-Control", "no-store, public"), + good().header("Content-Type", "text/html"), + good().header("Content-Type", "application/json; charset=utf-16"), + good().header("Content-Encoding", "gzip"), + duplicate, + ] { + let server = FakeServer::start(vec![caps_reply(), reply]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport.metadata(&session(&transport, OWNER), None).await, + Err(Error::InvalidResponse(_)) + )); + server.finish().await; + } + } + + #[tokio::test] + async fn snapshot_is_bound_to_metadata_account_and_strong_etag() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", snapshot()).header("ETag", "\"opaque-etag-8\""), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let downloaded = transport + .snapshot( + &session(&transport, OWNER), + &metadata(&transport, active_metadata()), + ) + .await + .unwrap(); + assert_eq!(downloaded.revision.get(), 8); + let requests = server.finish().await; + assert_eq!(requests[1].headers["if-match"], "\"opaque-etag-8\""); + for (field, value) in [ + ("ownerGithubId", json!("999")), + ("vaultId", json!(KEY)), + ("keyId", json!(VAULT)), + ("operationId", json!(NEW_OPERATION)), + ("ciphertextSha256", json!("0".repeat(64))), + ] { + let mut bad = snapshot(); + bad[field] = value; + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", bad).header("ETag", "\"opaque-etag-8\""), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(transport + .snapshot( + &session(&transport, OWNER), + &metadata(&transport, active_metadata()) + ) + .await + .is_err()); + server.finish().await; + } + } + + #[tokio::test] + async fn lost_mutation_response_can_be_retried_with_identical_bytes_after_token_renewal() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::disconnect(), + Reply::json("200 OK", auth(OWNER, 'b')), + Reply::json("200 OK", receipt("snapshot")).header("Idempotency-Replayed", "true"), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let first_session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &first_session); + let exact_bytes = operation.body_bytes().to_vec(); + assert!(matches!( + transport.submit(&first_session, &operation).await, + Err(Error::Transport) + )); + let renewed = transport + .exchange("github-fixture-token", &GithubId::parse(OWNER).unwrap()) + .await + .unwrap(); + let result = transport.submit(&renewed, &operation).await.unwrap(); + assert!(result.replayed); + assert_eq!(result.receipt.committed_revision.get(), 9); + let requests = server.finish().await; + for index in [1, 3] { + assert_eq!(requests[index].method, "PUT"); + assert_eq!(requests[index].path, "/v1/me/vault/snapshot"); + assert_eq!(requests[index].body, exact_bytes); + assert_eq!( + requests[index].headers["if-match"], + operation.if_match().as_str() + ); + assert_eq!( + requests[index].headers["idempotency-key"], + operation.operation_id().as_str() + ); + } + assert_ne!( + requests[1].headers["authorization"], + requests[3].headers["authorization"] + ); + } + + #[tokio::test] + async fn prepared_operations_cannot_cross_account_or_service_boundaries() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session_a = session(&transport, OWNER); + let operation = prepared_upload(&transport, &session_a); + let session_b = session(&transport, "999"); + assert!(matches!( + transport.submit(&session_b, &operation).await, + Err(Error::AccountMismatch) + )); + assert!(matches!( + transport + .metadata(&session_b, Some(&metadata(&transport, active_metadata()))) + .await, + Err(Error::AccountMismatch) + )); + let mut another_origin = transport.clone(); + another_origin.origin = "https://other.example/".parse().unwrap(); + assert!(matches!( + another_origin.submit(&session_a, &operation).await, + Err(Error::ContextMismatch) + )); + assert_eq!(server.finish().await.len(), 1); + } + + #[tokio::test] + async fn active_upload_checks_key_epoch_nonce_and_base_revision_before_sending() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let current = metadata(&transport, active_metadata()); + let previous = typed(&snapshot()); + assert!(transport + .prepare_upload(&session, ¤t, &typed(&next_snapshot()), None) + .is_err()); + for (field, value) in [ + ("keyId", json!(VAULT)), + ("nonce", snapshot()["nonce"].clone()), + ("vaultId", json!(KEY)), + ] { + let mut upload = next_snapshot(); + upload[field] = value; + assert!(transport + .prepare_upload(&session, ¤t, &typed(&upload), Some(&previous)) + .is_err()); + } + let mut wrong_salt = next_snapshot(); + wrong_salt["kdf"]["salt"] = json!(URL_SAFE_NO_PAD.encode([1_u8; 16])); + assert!(transport + .prepare_upload(&session, ¤t, &typed(&wrong_salt), Some(&previous)) + .is_err()); + let mut changed = next_snapshot(); + changed["kind"] = json!("password_change"); + assert!(transport + .prepare_upload(&session, ¤t, &typed(&changed), Some(&previous)) + .is_err()); + changed["keyId"] = json!(VAULT); + changed["kdf"]["salt"] = json!(URL_SAFE_NO_PAD.encode([1_u8; 16])); + assert!(transport + .prepare_upload(&session, ¤t, &typed(&changed), Some(&previous)) + .is_ok()); + let mut create = snapshot(); + create["kind"] = json!("create"); + create["baseRevision"] = json!("0"); + create["revision"] = json!("1"); + assert!(transport + .prepare_upload( + &session, + &metadata(&transport, empty_metadata()), + &typed(&create), + None + ) + .is_ok()); + server.finish().await; + } + + #[tokio::test] + async fn delete_and_receipt_reconciliation_preserve_the_original_operation() { + let pending = + json!({"operationId":NEW_OPERATION,"status":"pending","retryAfterSeconds":30}); + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", receipt("delete")).header("Idempotency-Replayed", "false"), + Reply::json("202 Accepted", pending), + Reply::json("404 Not Found", error_envelope("operation_not_found")), + Reply::json("200 OK", receipt("delete")), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let operation = transport + .prepare_delete( + &session, + &metadata(&transport, active_metadata()), + UuidV4::parse(NEW_OPERATION).unwrap(), + ) + .unwrap(); + let result = transport.submit(&session, &operation).await.unwrap(); + assert!(!result.replayed); + assert_eq!(result.receipt.kind, OperationKind::Delete); + assert!(matches!( + transport.operation(&session, &operation).await.unwrap(), + OperationStatus::Pending(_) + )); + assert!(matches!( + transport.operation(&session, &operation).await.unwrap(), + OperationStatus::NotFound + )); + assert!(matches!( + transport.operation(&session, &operation).await.unwrap(), + OperationStatus::Committed(_) + )); + let requests = server.finish().await; + assert_eq!(requests[1].method, "DELETE"); + assert_eq!(requests[1].headers["idempotency-key"], NEW_OPERATION); + assert_eq!(requests[1].body, operation.body_bytes()); + for request in &requests[2..] { + assert_eq!(request.path, format!("/v1/me/operations/{NEW_OPERATION}")); + } + } + + #[tokio::test] + async fn every_mutation_receipt_field_is_checked_against_the_prepared_request() { + for (field, value) in [ + ("operationId", json!(OLD_OPERATION)), + ("kind", json!("create")), + ("committedRevision", json!("10")), + ("vaultId", json!(KEY)), + ("ciphertextSha256", json!("0".repeat(64))), + ] { + let mut bad = receipt("snapshot"); + bad[field] = value; + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", bad).header("Idempotency-Replayed", "false"), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &session); + assert!(matches!( + transport.submit(&session, &operation).await, + Err(Error::InvalidResponse(_)) + )); + server.finish().await; + } + } + + #[tokio::test] + async fn control_and_snapshot_responses_are_limited_while_streaming() { + for limit in [CONTROL_BODY_LIMIT, HTTP_BODY_LIMIT] { + for length in [limit, limit + 1] { + let server = + FakeServer::start(vec![Reply::raw("200 OK", vec![b' '; length]).chunked()]) + .await; + let response = client_builder(ProxyPolicy::for_origin(&server.origin, false)) + .no_proxy() + .build() + .unwrap() + .get(&server.origin) + .send() + .await + .unwrap(); + let result = bounded_body(response, limit).await; + if length == limit { + assert_eq!(result.unwrap().len(), limit); + } else { + assert!(matches!(result, Err(Error::PayloadTooLarge))); + } + server.finish().await; + } + } + let server = FakeServer::start(vec![ + Reply::raw("200 OK", Vec::new()).header("Content-Length", "25165825") + ]) + .await; + let response = client_builder(ProxyPolicy::for_origin(&server.origin, false)) + .no_proxy() + .build() + .unwrap() + .get(&server.origin) + .send() + .await + .unwrap(); + assert!(matches!( + bounded_body(response, HTTP_BODY_LIMIT).await, + Err(Error::PayloadTooLarge) + )); + server.finish().await; + } + + #[tokio::test] + async fn errors_retain_only_allowlisted_code_and_validated_retry_delay() { + let marker = "SENSITIVE_BODY_TOKEN_https://secret.example"; + let mut remote = error_envelope("rate_limited"); + remote["error"]["message"] = json!(marker); + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("429 Too Many Requests", remote).header("Retry-After", "42"), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let error = transport + .metadata(&session(&transport, OWNER), None) + .await + .unwrap_err(); + assert!(matches!( + error, + Error::Api { + status: 429, + code: RemoteErrorCode::RateLimited, + retry_after_seconds: Some(42), + .. + } + )); + assert!(!format!("{error:?} {error}").contains(marker)); + server.finish().await; + for (status, code, delay) in [ + ("429 Too Many Requests", "rate_limited", "0"), + ("429 Too Many Requests", "rate_limited", "86401"), + ("429 Too Many Requests", "rate_limited", "tomorrow"), + ("503 Service Unavailable", "rate_limited", "42"), + ] { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json(status, error_envelope(code)).header("Retry-After", delay), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport.metadata(&session(&transport, OWNER), None).await, + Err(Error::InvalidResponse(_)) + )); + server.finish().await; + } + } + + #[tokio::test] + async fn logout_accepts_only_no_body_success_or_validated_already_revoked_error() { + for reply in [ + Reply::raw("204 No Content", Vec::new()), + Reply::json("401 Unauthorized", error_envelope("session_expired")), + ] { + let server = FakeServer::start(vec![caps_reply(), reply]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + transport + .revoke_session(session(&transport, OWNER)) + .await + .unwrap(); + let requests = server.finish().await; + assert_eq!(requests[1].method, "DELETE"); + assert_eq!(requests[1].path, "/v1/auth/session"); + assert!(requests[1].body.is_empty()); + } + } + + #[tokio::test] + async fn snapshot_rejects_another_revision_etag_or_owner_before_returning_data() { + for (snapshot_value, etag) in [ + (next_snapshot(), "\"opaque-etag-8\""), + (snapshot(), "\"another-etag\""), + ] { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", snapshot_value).header("ETag", etag), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(transport + .snapshot( + &session(&transport, OWNER), + &metadata(&transport, active_metadata()) + ) + .await + .is_err()); + server.finish().await; + } + let mut foreign = empty_metadata(); + foreign["ownerGithubId"] = json!("999"); + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", foreign).header("ETag", "\"foreign\""), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport.metadata(&session(&transport, OWNER), None).await, + Err(Error::AccountMismatch) + )); + server.finish().await; + } + + #[tokio::test] + async fn missing_replay_header_and_mismatched_pending_receipt_stay_unconfirmed() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", receipt("snapshot")), + Reply::json( + "202 Accepted", + json!({"operationId":OLD_OPERATION,"status":"pending","retryAfterSeconds":1}), + ), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &session); + assert!(matches!( + transport.submit(&session, &operation).await, + Err(Error::InvalidResponse(_)) + )); + assert!(matches!( + transport.operation(&session, &operation).await, + Err(Error::InvalidResponse(_)) + )); + assert_eq!(operation.operation_id().as_str(), NEW_OPERATION); + server.finish().await; + } + + #[tokio::test] + async fn error_response_uses_the_control_limit_even_on_the_snapshot_route() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::raw( + "503 Service Unavailable", + vec![b' '; CONTROL_BODY_LIMIT + 1], + ) + .chunked(), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + assert!(matches!( + transport + .snapshot( + &session(&transport, OWNER), + &metadata(&transport, active_metadata()) + ) + .await, + Err(Error::PayloadTooLarge) + )); + server.finish().await; + } + + #[tokio::test] + async fn deleted_vault_recreation_keeps_tombstone_revision_and_requires_a_new_vault() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let mut deleted = active_metadata(); + deleted["state"] = json!("deleted"); + for field in ["keyId", "payloadSchemaVersion", "ciphertextSha256"] { + deleted[field] = Value::Null; + } + deleted["ciphertextBytes"] = json!(0); + let tombstone = metadata(&transport, deleted); + let mut create = next_snapshot(); + create["kind"] = json!("create"); + assert!(transport + .prepare_upload(&session, &tombstone, &typed(&create), None) + .is_err()); + create["vaultId"] = json!(KEY); + assert!(transport + .prepare_upload(&session, &tombstone, &typed(&create), None) + .is_ok()); + create["baseRevision"] = json!("0"); + create["revision"] = json!("1"); + assert!(transport + .prepare_upload(&session, &tombstone, &typed(&create), None) + .is_err()); + assert!(transport + .prepare_delete(&session, &tombstone, UuidV4::parse(NEW_OPERATION).unwrap()) + .is_err()); + let mut exhausted = active_metadata(); + exhausted["revision"] = json!(u64::MAX.to_string()); + assert!(matches!( + transport.prepare_delete( + &session, + &metadata(&transport, exhausted), + UuidV4::parse(NEW_OPERATION).unwrap() + ), + Err(Error::RevisionExhausted) + )); + server.finish().await; + } + + #[tokio::test] + async fn debug_output_and_malformed_bearer_values_do_not_expose_credentials() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &session); + let text = format!("{transport:?} {session:?} {operation:?}"); + assert!(!text.contains(&"a".repeat(43))); + assert!(!text.contains(&server.origin)); + assert!(!text.contains(snapshot()["ciphertext"].as_str().unwrap())); + for token in ["", "secret\nvalue", "secret value"] { + let error = bearer_header(token).unwrap_err(); + assert!(!format!("{error:?} {error}").contains("secret")); + } + assert!(bearer_header("fixture-token").unwrap().is_sensitive()); + server.finish().await; + } + + #[tokio::test] + async fn pending_records_preserve_original_upload_and_delete_bytes_after_restart() { + for kind in ["snapshot", "delete"] { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::disconnect(), + caps_reply(), + Reply::json("200 OK", auth(OWNER, 'b')), + Reply::json("200 OK", receipt(kind)).header("Idempotency-Replayed", "true"), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let first_session = session(&transport, OWNER); + let mut operation = if kind == "snapshot" { + prepared_upload(&transport, &first_session) + } else { + transport + .prepare_delete( + &first_session, + &metadata(&transport, active_metadata()), + UuidV4::parse(NEW_OPERATION).unwrap(), + ) + .unwrap() + }; + // Simulate bytes produced by a previous client serializer. Restoring + // must not reserialize this valid, differently ordered/formatted JSON. + let original_value: Value = serde_json::from_slice(operation.body_bytes()).unwrap(); + operation.body = format!( + "\n\t{}\n", + serde_json::to_string_pretty(&original_value).unwrap() + ) + .into_bytes(); + operation.if_match = StrongEtag::parse("\"opaque-\\tag-8\"").unwrap(); + let original_body = operation.body_bytes().to_vec(); + let original_etag = operation.if_match().as_str().to_owned(); + let record = operation.to_pending_record().unwrap(); + assert!(!std::str::from_utf8(&record) + .unwrap() + .contains(&"a".repeat(43))); + assert!(matches!( + transport.submit(&first_session, &operation).await, + Err(Error::Transport) + )); + drop(operation); + drop(first_session); + drop(transport); + + let restarted = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let renewed = restarted + .exchange("github-fixture-token", &GithubId::parse(OWNER).unwrap()) + .await + .unwrap(); + let restored = restarted.restore_pending(&renewed, &record).unwrap(); + assert_eq!(restored.body_bytes(), original_body); + assert_eq!(restored.if_match().as_str(), original_etag); + assert_eq!(restored.operation_id().as_str(), NEW_OPERATION); + let accepted = restarted.submit(&renewed, &restored).await.unwrap(); + assert!(accepted.replayed); + let requests = server.finish().await; + for index in [1, 4] { + assert_eq!(requests[index].body, original_body); + assert_eq!(requests[index].headers["if-match"], original_etag); + assert_eq!(requests[index].headers["idempotency-key"], NEW_OPERATION); + assert_eq!( + requests[index].method, + if kind == "snapshot" { "PUT" } else { "DELETE" } + ); + } + assert_ne!( + requests[1].headers["authorization"], + requests[4].headers["authorization"] + ); + assert_eq!(requests[2].path, "/v1/capabilities"); + assert_eq!(requests[3].path, "/v1/auth/github"); + } + } + + #[tokio::test] + async fn pending_record_recovery_rejects_changed_identity_and_untrusted_envelope_fields() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let account_session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &account_session); + let bytes = operation.to_pending_record().unwrap(); + let original: Value = serde_json::from_slice(&bytes).unwrap(); + for (field, value) in [ + ("origin", json!("https://another.example/")), + ("origin", json!("http://another.example/")), + ("origin", json!("https://user:password@another.example/")), + ("ownerGithubId", json!("999")), + ("operationId", json!(OLD_OPERATION)), + ("method", json!("post")), + ("method", json!("delete")), + ("recordVersion", json!(2)), + ("ifMatch", json!("W/\"weak\"")), + ("url", json!("https://another.example/arbitrary")), + ("expectedRevision", json!("9")), + ] { + let mut modified = original.clone(); + modified[field] = value; + assert!( + transport + .restore_pending(&account_session, &serde_json::to_vec(&modified).unwrap()) + .is_err(), + "accepted invalid field {field}" + ); + } + let duplicated = + std::str::from_utf8(&bytes) + .unwrap() + .replacen('{', "{\"recordVersion\":1,", 1); + assert!(transport + .restore_pending(&account_session, duplicated.as_bytes()) + .is_err()); + let mut wrong_service = transport.clone(); + wrong_service.origin = "https://another.example/".parse().unwrap(); + let other_session = session(&wrong_service, OWNER); + assert!(matches!( + wrong_service.restore_pending(&other_session, &bytes), + Err(Error::ContextMismatch) + )); + let other_account = session(&transport, "999"); + assert!(matches!( + transport.restore_pending(&other_account, &bytes), + Err(Error::AccountMismatch) + )); + // Restore is synchronous and performs no request at all. + assert_eq!(server.finish().await.len(), 1); + } + + #[tokio::test] + async fn pending_record_recovery_revalidates_original_crypto_header_hash_and_cas_body() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + let operation = prepared_upload(&transport, &session); + let mut record: Value = + serde_json::from_slice(&operation.to_pending_record().unwrap()).unwrap(); + let body: Value = serde_json::from_str(record["body"].as_str().unwrap()).unwrap(); + for (field, value) in [ + ("ownerGithubId", json!("999")), + ("operationId", json!(OLD_OPERATION)), + ("revision", json!("10")), + ("baseRevision", json!(u64::MAX.to_string())), + ("protocolVersion", json!(2)), + ("cryptoProfile", json!("untrusted-profile")), + ("ciphertextSha256", json!("0".repeat(64))), + ("nonce", json!("invalid")), + ] { + let mut changed = body.clone(); + changed[field] = value; + record["body"] = json!(serde_json::to_string(&changed).unwrap()); + assert!( + transport + .restore_pending(&session, &serde_json::to_vec(&record).unwrap()) + .is_err(), + "accepted invalid body field {field}" + ); + } + let mut zero_base = body.clone(); + zero_base["baseRevision"] = json!("0"); + zero_base["revision"] = json!("1"); + record["body"] = json!(serde_json::to_string(&zero_base).unwrap()); + assert!(transport + .restore_pending(&session, &serde_json::to_vec(&record).unwrap()) + .is_err()); + let mut weak_kdf = body; + weak_kdf["kdf"]["iterations"] = json!(1); + record["body"] = json!(serde_json::to_string(&weak_kdf).unwrap()); + assert!(transport + .restore_pending(&session, &serde_json::to_vec(&record).unwrap()) + .is_err()); + record["body"] = json!(std::str::from_utf8(operation.body_bytes()) + .unwrap() + .replacen('{', "{\"protocolVersion\":1,", 1)); + assert!(transport + .restore_pending(&session, &serde_json::to_vec(&record).unwrap()) + .is_err()); + + let deletion = transport + .prepare_delete( + &session, + &metadata(&transport, active_metadata()), + UuidV4::parse(NEW_OPERATION).unwrap(), + ) + .unwrap(); + let mut record: Value = + serde_json::from_slice(&deletion.to_pending_record().unwrap()).unwrap(); + let mut body: Value = serde_json::from_str(record["body"].as_str().unwrap()).unwrap(); + for value in [json!("0"), json!(u64::MAX.to_string())] { + body["baseRevision"] = value; + record["body"] = json!(serde_json::to_string(&body).unwrap()); + assert!(transport + .restore_pending(&session, &serde_json::to_vec(&record).unwrap()) + .is_err()); + } + assert_eq!(server.finish().await.len(), 1); + } + + #[tokio::test] + async fn pending_record_recovery_enforces_outer_and_per_method_inner_size_limits() { + let server = FakeServer::start(vec![caps_reply()]).await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + let session = session(&transport, OWNER); + assert!(matches!( + transport.restore_pending(&session, &vec![b' '; PENDING_RECORD_LIMIT + 1]), + Err(Error::PayloadTooLarge) + )); + for method in [MutationMethod::Upload, MutationMethod::Delete] { + let operation = match method { + MutationMethod::Upload => prepared_upload(&transport, &session), + MutationMethod::Delete => transport + .prepare_delete( + &session, + &metadata(&transport, active_metadata()), + UuidV4::parse(NEW_OPERATION).unwrap(), + ) + .unwrap(), + }; + let mut record: Value = + serde_json::from_slice(&operation.to_pending_record().unwrap()).unwrap(); + record["body"] = json!(" ".repeat(method.body_limit() + 1)); + assert!(matches!( + transport.restore_pending(&session, &serde_json::to_vec(&record).unwrap()), + Err(Error::PayloadTooLarge) + )); + } + assert_eq!(server.finish().await.len(), 1); + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types.rs new file mode 100644 index 000000000..001525ac0 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types.rs @@ -0,0 +1,1261 @@ +//! Strict v1 DTOs. Constructors/deserializers preserve canonical wire encodings. + +use super::{Error, Result}; +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{ + de::{self, DeserializeOwned, DeserializeSeed, MapAccess, SeqAccess, Visitor}, + Deserialize, Deserializer, Serialize, Serializer, +}; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::{collections::HashSet, fmt}; +use zeroize::{Zeroize, Zeroizing}; + +pub(crate) const PROTOCOL_VERSION: u32 = 1; +pub(crate) const CRYPTO_PROFILE: &str = "argon2id-xchacha20poly1305-v1"; +pub(crate) const AEAD_NAME: &str = "xchacha20poly1305-ietf"; +pub(crate) const CODEC_NAME: &str = "json-pad64k-v1"; +pub(crate) const HTTP_BODY_LIMIT: usize = 24 * 1024 * 1024; +pub(crate) const CONTROL_BODY_LIMIT: usize = 8192; +pub(crate) const MAX_PLAINTEXT_JSON_BYTES: usize = 15 * 1024 * 1024; +pub(crate) const MAX_PADDED_BYTES: usize = 16 * 1024 * 1024; +pub(crate) const MAX_CIPHERTEXT_BYTES: usize = MAX_PADDED_BYTES + 16; +pub(crate) const PAD_BLOCK_BYTES: usize = 65_536; +pub(crate) const MAX_DOCUMENTS: usize = 100_000; +pub(crate) const MAX_JSON_DEPTH: usize = 64; + +pub(crate) trait Validate { + fn validate(&self) -> Result<()>; +} + +#[derive(Clone, Copy, Eq, PartialEq, Hash, Ord, PartialOrd)] +pub struct Revision { + value: u64, + wire: [u8; 20], + length: u8, +} +impl Revision { + pub fn new(value: u64) -> Self { + let text = value.to_string(); + let mut wire = [b'0'; 20]; + wire[..text.len()].copy_from_slice(text.as_bytes()); + Self { + value, + wire, + length: text.len() as u8, + } + } + pub(crate) fn parse(wire: &str) -> Result { + if wire.is_empty() + || wire.len() > 20 + || !wire.bytes().all(|c| c.is_ascii_digit()) + || (wire.len() > 1 && wire.starts_with('0')) + { + return Err(Error::InvalidWire("revision")); + } + let value = wire.parse().map_err(|_| Error::InvalidWire("revision"))?; + Ok(Self::new(value)) + } + pub fn get(&self) -> u64 { + self.value + } + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.wire[..usize::from(self.length)]) + .expect("revision constructor emits ASCII digits") + } + pub(crate) fn checked_next(&self) -> Result { + self.value + .checked_add(1) + .map(Self::new) + .ok_or(Error::RevisionExhausted) + } +} +impl fmt::Debug for Revision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} +impl fmt::Display for Revision { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} +impl Serialize for Revision { + fn serialize(&self, s: S) -> std::result::Result { + s.serialize_str(self.as_str()) + } +} +impl<'de> Deserialize<'de> for Revision { + fn deserialize>(d: D) -> std::result::Result { + Self::parse(&String::deserialize(d)?).map_err(|_| de::Error::custom("invalid revision")) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct GithubId(Revision); +impl GithubId { + pub(crate) fn parse(s: &str) -> Result { + let n = Revision::parse(s)?; + if n.get() == 0 { + return Err(Error::InvalidWire("github id")); + } + Ok(Self(n)) + } + pub(crate) fn as_str(&self) -> &str { + self.0.as_str() + } +} +impl fmt::Display for GithubId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(f) + } +} +impl Serialize for GithubId { + fn serialize(&self, s: S) -> std::result::Result { + s.serialize_str(self.as_str()) + } +} +impl<'de> Deserialize<'de> for GithubId { + fn deserialize>(d: D) -> std::result::Result { + Self::parse(&String::deserialize(d)?).map_err(|_| de::Error::custom("invalid github id")) + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub(crate) struct UuidV4(String); +impl UuidV4 { + pub(crate) fn parse(s: &str) -> Result { + if s.len() != 36 { + return Err(Error::InvalidWire("uuid v4")); + } + let id = uuid::Uuid::parse_str(s).map_err(|_| Error::InvalidWire("uuid v4"))?; + if id.get_version() != Some(uuid::Version::Random) + || id.get_variant() != uuid::Variant::RFC4122 + || id.to_string() != s + { + return Err(Error::InvalidWire("uuid v4")); + } + Ok(Self(s.to_owned())) + } + pub(crate) fn random() -> Result { + let mut bytes = [0; 16]; + getrandom::fill(&mut bytes).map_err(|_| Error::RandomUnavailable)?; + Ok(Self( + uuid::Builder::from_random_bytes(bytes) + .into_uuid() + .to_string(), + )) + } + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} +impl fmt::Display for UuidV4 { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} +impl Serialize for UuidV4 { + fn serialize(&self, s: S) -> std::result::Result { + s.serialize_str(&self.0) + } +} +impl<'de> Deserialize<'de> for UuidV4 { + fn deserialize>(d: D) -> std::result::Result { + Self::parse(&String::deserialize(d)?).map_err(|_| de::Error::custom("invalid uuid v4")) + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct StrongEtag(String); +impl StrongEtag { + pub(crate) fn parse(s: &str) -> Result { + let b = s.as_bytes(); + if !(3..=130).contains(&b.len()) + || b[0] != b'"' + || b[b.len() - 1] != b'"' + || !b[1..b.len() - 1] + .iter() + .all(|b| *b == 0x21 || (0x23..=0x7e).contains(b)) + { + return Err(Error::InvalidWire("strong etag")); + } + Ok(Self(s.to_owned())) + } + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} +impl fmt::Display for StrongEtag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Clone, Eq, PartialEq)] +pub(crate) struct EncodedBytes([u8; N]); +pub(crate) type Salt = EncodedBytes<16>; +pub(crate) type Nonce = EncodedBytes<24>; +impl EncodedBytes { + #[cfg(test)] + pub(crate) fn new(bytes: [u8; N]) -> Self { + Self(bytes) + } + pub(crate) fn random() -> Result { + let mut b = [0; N]; + getrandom::fill(&mut b).map_err(|_| Error::RandomUnavailable)?; + Ok(Self(b)) + } + pub(crate) fn bytes(&self) -> &[u8; N] { + &self.0 + } + pub(crate) fn encoded(&self) -> String { + URL_SAFE_NO_PAD.encode(self.0) + } + pub(crate) fn parse(s: &str) -> Result { + if s.len() != N.div_ceil(3) * 4 - (3 - N % 3) % 3 { + return Err(Error::InvalidWire("base64 length")); + } + let b = decode_base64(s, N)?; + Ok(Self( + b.try_into() + .map_err(|_| Error::InvalidWire("base64 length"))?, + )) + } +} +impl fmt::Debug for EncodedBytes { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("PublicEncodedBytes") + .field(&self.encoded()) + .finish() + } +} +impl Serialize for EncodedBytes { + fn serialize(&self, s: S) -> std::result::Result { + s.serialize_str(&self.encoded()) + } +} +impl<'de, const N: usize> Deserialize<'de> for EncodedBytes { + fn deserialize>(d: D) -> std::result::Result { + Self::parse(&String::deserialize(d)?) + .map_err(|_| de::Error::custom("invalid canonical base64")) + } +} + +pub(crate) fn decode_base64(s: &str, max: usize) -> Result> { + if s.is_empty() + || s.len() > max.div_ceil(3) * 4 + || !s + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + return Err(Error::InvalidWire("canonical base64")); + } + let decoded = URL_SAFE_NO_PAD + .decode(s) + .map_err(|_| Error::InvalidWire("canonical base64"))?; + if decoded.len() > max { + return Err(Error::PayloadTooLarge); + } + if URL_SAFE_NO_PAD.encode(&decoded) != s { + return Err(Error::InvalidWire("canonical base64")); + } + Ok(decoded) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct Sha256Digest([u8; 32]); +impl Sha256Digest { + pub(crate) fn of(bytes: &[u8]) -> Self { + Self(Sha256::digest(bytes).into()) + } + pub(crate) fn as_hex(&self) -> String { + self.0.iter().map(|b| format!("{b:02x}")).collect() + } + pub(crate) fn parse(s: &str) -> Result { + if s.len() != 64 + || !s + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(Error::InvalidWire("sha256")); + } + let mut out = [0; 32]; + for (n, pair) in s.as_bytes().as_chunks::<2>().0.iter().enumerate() { + out[n] = ((hex(pair[0])) << 4) | hex(pair[1]); + } + Ok(Self(out)) + } +} +fn hex(b: u8) -> u8 { + if b <= b'9' { + b - b'0' + } else { + b - b'a' + 10 + } +} +impl Serialize for Sha256Digest { + fn serialize(&self, s: S) -> std::result::Result { + s.serialize_str(&self.as_hex()) + } +} +impl<'de> Deserialize<'de> for Sha256Digest { + fn deserialize>(d: D) -> std::result::Result { + Self::parse(&String::deserialize(d)?).map_err(|_| de::Error::custom("invalid sha256")) + } +} + +/// Intentionally neither serializable nor cloneable. Moving an IPC String here +/// transfers ownership; all copies created by normalization are also zeroizing. +pub(crate) struct SecretInput(Zeroizing); +impl SecretInput { + pub(crate) fn new(value: String) -> Self { + Self(Zeroizing::new(value)) + } + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} +impl fmt::Debug for SecretInput { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretInput([REDACTED])") + } +} + +pub(crate) struct SecretToken(Zeroizing); +impl SecretToken { + pub(crate) fn new(value: String) -> Result { + let value = Zeroizing::new(value); + if value.is_empty() || value.len() > 2048 || !value.bytes().all(|b| b.is_ascii_graphic()) { + return Err(Error::InvalidWire("bearer token")); + } + Ok(Self(value)) + } + pub(crate) fn expose(&self) -> &str { + &self.0 + } + pub(crate) fn validate_sync(&self) -> Result<()> { + if self.0.len() < 43 { + Err(Error::InvalidWire("sync token")) + } else { + Ok(()) + } + } +} +impl fmt::Debug for SecretToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SecretToken([REDACTED])") + } +} +impl<'de> Deserialize<'de> for SecretToken { + fn deserialize>(d: D) -> std::result::Result { + Self::new(String::deserialize(d)?).map_err(|_| de::Error::custom("invalid token")) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Kdf { + pub(crate) name: String, + pub(crate) version: u32, + #[serde(rename = "memoryKiB")] + pub(crate) memory_kib: u32, + pub(crate) iterations: u32, + pub(crate) parallelism: u32, + pub(crate) salt: Salt, +} +impl Kdf { + pub(crate) fn fixed(salt: Salt) -> Self { + Self { + name: "argon2id".into(), + version: 19, + memory_kib: 65536, + iterations: 3, + parallelism: 4, + salt, + } + } + pub(crate) fn validate(&self) -> Result<()> { + if self.name != "argon2id" + || self.version != 19 + || self.memory_kib != 65536 + || self.iterations != 3 + || self.parallelism != 4 + { + Err(Error::UnsupportedProtocol) + } else { + Ok(()) + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum UploadKind { + Create, + Snapshot, + PasswordChange, +} +impl UploadKind { + pub(crate) fn as_str(&self) -> &'static str { + match self { + Self::Create => "create", + Self::Snapshot => "snapshot", + Self::PasswordChange => "password_change", + } + } +} +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum OperationKind { + Create, + Snapshot, + PasswordChange, + Delete, +} +impl From for OperationKind { + fn from(v: UploadKind) -> Self { + match v { + UploadKind::Create => Self::Create, + UploadKind::Snapshot => Self::Snapshot, + UploadKind::PasswordChange => Self::PasswordChange, + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SnapshotUpload { + pub(crate) protocol_version: u32, + pub(crate) payload_schema_version: u32, + pub(crate) owner_github_id: GithubId, + pub(crate) vault_id: UuidV4, + pub(crate) key_id: UuidV4, + pub(crate) base_revision: Revision, + pub(crate) revision: Revision, + pub(crate) operation_id: UuidV4, + pub(crate) kind: UploadKind, + pub(crate) crypto_profile: String, + pub(crate) kdf: Kdf, + pub(crate) aead: String, + pub(crate) codec: String, + pub(crate) nonce: Nonce, + pub(crate) ciphertext: String, + pub(crate) ciphertext_sha256: Sha256Digest, +} +impl fmt::Debug for SnapshotUpload { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SnapshotUpload") + .field("revision", &self.revision) + .field("kind", &self.kind) + .field("ciphertext", &"[REDACTED]") + .finish() + } +} +impl SnapshotUpload { + pub(crate) fn validate_header(&self) -> Result<()> { + if self.protocol_version != PROTOCOL_VERSION + || self.payload_schema_version != 1 + || self.crypto_profile != CRYPTO_PROFILE + || self.aead != AEAD_NAME + || self.codec != CODEC_NAME + { + return Err(Error::UnsupportedProtocol); + } + self.kdf.validate()?; + if self.revision != self.base_revision.checked_next()? { + return Err(Error::InvalidWire("revision increment")); + } + if !(87_403..=22_369_643).contains(&self.ciphertext.len()) { + return Err(Error::InvalidWire("ciphertext length")); + } + Ok(()) + } + pub(crate) fn decoded_ciphertext(&self) -> Result> { + self.validate_header()?; + let bytes = decode_base64(&self.ciphertext, MAX_CIPHERTEXT_BYTES)?; + if bytes.len() < PAD_BLOCK_BYTES + 16 || (bytes.len() - 16) % PAD_BLOCK_BYTES != 0 { + return Err(Error::InvalidWire("ciphertext framing")); + } + if Sha256Digest::of(&bytes) != self.ciphertext_sha256 { + return Err(Error::InvalidWire("ciphertext hash")); + } + Ok(bytes) + } + pub(crate) fn validate(&self) -> Result<()> { + self.decoded_ciphertext().map(|_| ()) + } + pub(crate) fn validate_against_metadata( + &self, + meta: &VaultMetadata, + owner: &GithubId, + ) -> Result<()> { + meta.validate()?; + if &meta.owner_github_id != owner || &self.owner_github_id != owner { + return Err(Error::AccountMismatch); + } + if meta.state != VaultState::Active + || self.revision != meta.revision + || Some(&self.vault_id) != meta.vault_id.as_ref() + || Some(&self.key_id) != meta.key_id.as_ref() + || Some(&self.operation_id) != meta.last_operation_id.as_ref() + || Some(self.payload_schema_version) != meta.payload_schema_version + || Some(&self.ciphertext_sha256) != meta.ciphertext_sha256.as_ref() + { + return Err(Error::ContextMismatch); + } + let bytes = self.decoded_ciphertext()?; + if bytes.len() as u64 != meta.ciphertext_bytes { + return Err(Error::ContextMismatch); + } + Ok(()) + } +} +impl Validate for SnapshotUpload { + fn validate(&self) -> Result<()> { + self.validate() + } +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Capabilities { + pub(crate) protocol_version: u32, + pub(crate) crypto_profile: String, + pub(crate) github_client_id: String, + pub(crate) max_http_body_bytes: u64, + pub(crate) max_ciphertext_bytes: u64, + pub(crate) max_plaintext_json_bytes: u64, + pub(crate) idempotency_retention_seconds: u64, + pub(crate) max_backup_retention_days: u32, +} +impl Validate for Capabilities { + fn validate(&self) -> Result<()> { + if self.protocol_version != PROTOCOL_VERSION + || self.crypto_profile != CRYPTO_PROFILE + || self.max_http_body_bytes != HTTP_BODY_LIMIT as u64 + || self.max_ciphertext_bytes != MAX_CIPHERTEXT_BYTES as u64 + || self.max_plaintext_json_bytes != MAX_PLAINTEXT_JSON_BYTES as u64 + || self.idempotency_retention_seconds < 604800 + || self.max_backup_retention_days > 30 + { + return Err(Error::UnsupportedProtocol); + } + bounded_text(&self.github_client_id, 1, 128) + } +} +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct Account { + pub(crate) github_id: GithubId, + pub(crate) login: String, +} +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct AuthSession { + pub(crate) protocol_version: u32, + pub(crate) access_token: SecretToken, + pub(crate) token_type: String, + pub(crate) expires_in: u32, + pub(crate) account: Account, +} +impl Validate for AuthSession { + fn validate(&self) -> Result<()> { + if self.protocol_version != PROTOCOL_VERSION + || self.token_type != "Bearer" + || !(1..=900).contains(&self.expires_in) + { + return Err(Error::InvalidWire("auth session")); + } + self.access_token.validate_sync()?; + bounded_text(&self.account.login, 1, 100) + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum VaultState { + Empty, + Active, + Deleted, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VaultMetadata { + pub(crate) protocol_version: u32, + pub(crate) state: VaultState, + pub(crate) owner_github_id: GithubId, + pub(crate) revision: Revision, + #[serde(deserialize_with = "required_nullable")] + pub(crate) vault_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub(crate) key_id: Option, + #[serde(deserialize_with = "required_nullable")] + pub(crate) updated_at: Option, + #[serde(deserialize_with = "required_nullable")] + pub(crate) payload_schema_version: Option, + pub(crate) ciphertext_bytes: u64, + #[serde(deserialize_with = "required_nullable")] + pub(crate) ciphertext_sha256: Option, + #[serde(deserialize_with = "required_nullable")] + pub(crate) last_operation_id: Option, +} +fn required_nullable<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + d: D, +) -> std::result::Result, D::Error> { + Option::::deserialize(d) +} +impl VaultMetadata { + pub(crate) fn validate(&self) -> Result<()> { + if self.protocol_version != PROTOCOL_VERSION { + return Err(Error::UnsupportedProtocol); + } + if let Some(t) = &self.updated_at { + validate_timestamp(t)?; + } + let valid = match self.state { + VaultState::Empty => { + self.revision.get() == 0 + && self.vault_id.is_none() + && self.key_id.is_none() + && self.updated_at.is_none() + && self.payload_schema_version.is_none() + && self.ciphertext_bytes == 0 + && self.ciphertext_sha256.is_none() + && self.last_operation_id.is_none() + } + VaultState::Active => { + self.revision.get() > 0 + && self.vault_id.is_some() + && self.key_id.is_some() + && self.updated_at.is_some() + && self.payload_schema_version == Some(1) + && self.ciphertext_bytes >= 65_552 + && self.ciphertext_bytes <= MAX_CIPHERTEXT_BYTES as u64 + && (self.ciphertext_bytes - 16).is_multiple_of(PAD_BLOCK_BYTES as u64) + && self.ciphertext_sha256.is_some() + && self.last_operation_id.is_some() + } + VaultState::Deleted => { + self.revision.get() > 0 + && self.vault_id.is_some() + && self.key_id.is_none() + && self.updated_at.is_some() + && self.payload_schema_version.is_none() + && self.ciphertext_bytes == 0 + && self.ciphertext_sha256.is_none() + && self.last_operation_id.is_some() + } + }; + if valid { + Ok(()) + } else { + Err(Error::InvalidWire("vault state")) + } + } +} +impl Validate for VaultMetadata { + fn validate(&self) -> Result<()> { + self.validate() + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct DeleteVaultRequest { + pub(crate) protocol_version: u32, + pub(crate) base_revision: Revision, + pub(crate) operation_id: UuidV4, + pub(crate) expected_vault_id: UuidV4, +} +impl Validate for DeleteVaultRequest { + fn validate(&self) -> Result<()> { + if self.protocol_version != PROTOCOL_VERSION { + return Err(Error::UnsupportedProtocol); + } + if self.base_revision.get() == 0 { + return Err(Error::InvalidWire("delete revision")); + } + self.base_revision.checked_next().map(|_| ()) + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct OperationReceipt { + pub(crate) operation_id: UuidV4, + pub(crate) status: String, + pub(crate) kind: OperationKind, + pub(crate) committed_revision: Revision, + pub(crate) committed_at: String, + pub(crate) vault_id: UuidV4, + #[serde(deserialize_with = "required_nullable")] + pub(crate) ciphertext_sha256: Option, +} +impl Validate for OperationReceipt { + fn validate(&self) -> Result<()> { + if self.status != "committed" + || self.committed_revision.get() == 0 + || (self.kind == OperationKind::Delete) != self.ciphertext_sha256.is_none() + { + return Err(Error::InvalidWire("receipt")); + } + validate_timestamp(&self.committed_at) + } +} +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct OperationPending { + pub(crate) operation_id: UuidV4, + pub(crate) status: String, + pub(crate) retry_after_seconds: u32, +} +impl Validate for OperationPending { + fn validate(&self) -> Result<()> { + if self.status != "pending" || !(1..=3600).contains(&self.retry_after_seconds) { + Err(Error::InvalidWire("pending receipt")) + } else { + Ok(()) + } + } +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub(crate) enum RemoteErrorCode { + InvalidRequest, + UnsupportedProtocol, + InvalidCryptoHeader, + Unauthenticated, + SessionExpired, + WrongOauthApp, + OwnerMismatch, + VaultEmpty, + VaultDeleted, + OperationNotFound, + IdempotencyKeyReused, + KeyEpochChanged, + VaultExists, + RevisionExhausted, + RevisionConflict, + PayloadTooLarge, + UnsupportedMediaType, + PreconditionRequired, + RateLimited, + InternalError, + ServiceUnavailable, +} +impl fmt::Display for RemoteErrorCode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = serde_json::to_string(self).map_err(|_| fmt::Error)?; + f.write_str(s.trim_matches('"')) + } +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ErrorEnvelope { + pub(crate) error: ErrorBody, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ErrorBody { + pub(crate) code: RemoteErrorCode, + pub(crate) message: String, + // Parse the required UUID, but never include a remote body in diagnostics. + #[serde(rename = "requestId")] + _request_id: UuidV4, + #[serde(default, deserialize_with = "optional_present_nonnull")] + pub(crate) current_revision: Option, +} +fn optional_present_nonnull<'de, D: Deserializer<'de>, T: Deserialize<'de>>( + d: D, +) -> std::result::Result, D::Error> { + T::deserialize(d).map(Some) +} +impl Drop for ErrorBody { + fn drop(&mut self) { + self.message.zeroize(); + } +} +impl Validate for ErrorEnvelope { + fn validate(&self) -> Result<()> { + bounded_text(&self.error.message, 1, 200) + } +} + +#[derive(Clone, Copy, Debug, Hash, Serialize, Deserialize, Eq, PartialEq, Ord, PartialOrd)] +#[serde(rename_all = "snake_case")] +pub enum DocumentKind { + Preferences, + UiPreferences, + Channels, + ProviderCredentials, + Dictionary, + VocabularyPresets, + Corrections, + StylePacks, + History, + Activity, + DeviceProfile, +} +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SourceDevice { + pub id: String, + pub os: String, + pub arch: String, + pub app_version: String, +} +impl Drop for SourceDevice { + fn drop(&mut self) { + self.id.zeroize(); + self.os.zeroize(); + self.arch.zeroize(); + self.app_version.zeroize(); + } +} +impl fmt::Debug for SourceDevice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("SourceDevice([REDACTED])") + } +} +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LogicalDocument { + pub id: String, + pub kind: DocumentKind, + pub schema_version: u32, + pub value: Value, +} +impl Drop for LogicalDocument { + fn drop(&mut self) { + self.id.zeroize(); + erase_value(&mut self.value); + } +} +impl fmt::Debug for LogicalDocument { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("LogicalDocument([REDACTED])") + } +} +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Tombstone { + pub id: String, + pub kind: DocumentKind, + pub deleted_at: String, + pub base_revision: Revision, +} +impl Drop for Tombstone { + fn drop(&mut self) { + self.id.zeroize(); + self.deleted_at.zeroize(); + } +} +impl fmt::Debug for Tombstone { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Tombstone([REDACTED])") + } +} +#[derive(Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct DocumentSet { + pub schema_version: u32, + pub exported_at: String, + pub source_device: SourceDevice, + pub documents: Vec, + pub tombstones: Vec, +} +impl fmt::Debug for DocumentSet { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("DocumentSet([REDACTED])") + } +} +impl Drop for DocumentSet { + fn drop(&mut self) { + self.exported_at.zeroize(); + } +} +impl DocumentSet { + pub(crate) fn validate(&self) -> Result<()> { + if self.schema_version != 1 || self.documents.iter().any(|d| d.schema_version != 1) { + return Err(Error::UnsupportedDocumentVersion); + } + if self.documents.len().saturating_add(self.tombstones.len()) > MAX_DOCUMENTS { + return Err(Error::PayloadTooLarge); + } + validate_timestamp(&self.exported_at)?; + if [ + &self.source_device.id, + &self.source_device.os, + &self.source_device.arch, + &self.source_device.app_version, + ] + .iter() + .any(|v| v.is_empty()) + { + return Err(Error::InvalidWire("source device")); + } + let mut ids = HashSet::new(); + for d in &self.documents { + validate_value_depth(&d.value, 3)?; + if d.id.is_empty() || !ids.insert((d.kind, d.id.as_str())) { + return Err(Error::InvalidWire("duplicate document identity")); + } + } + for d in &self.tombstones { + validate_timestamp(&d.deleted_at)?; + if d.id.is_empty() || !ids.insert((d.kind, d.id.as_str())) { + return Err(Error::InvalidWire("duplicate document identity")); + } + } + Ok(()) + } + pub(crate) fn from_json_bytes(bytes: &[u8]) -> Result { + let mut value = parse_strict_json(bytes, MAX_PLAINTEXT_JSON_BYTES)?; + // Future document kinds/versions require an upgrade; never erase them as + // an empty successful restore. This preflight does not inspect value keys. + if value + .0 + .get("schemaVersion") + .and_then(Value::as_u64) + .is_some_and(|v| v != 1) + { + return Err(Error::UnsupportedDocumentVersion); + } + for collection in ["documents", "tombstones"] { + if let Some(items) = value.0.get(collection).and_then(Value::as_array) { + if items.len() > MAX_DOCUMENTS { + return Err(Error::PayloadTooLarge); + } + for item in items { + if let Some(kind) = item.get("kind").and_then(Value::as_str) { + if serde_json::from_value::(Value::String(kind.into())) + .is_err() + { + return Err(Error::UnsupportedDocumentVersion); + } + } + if item + .get("schemaVersion") + .and_then(Value::as_u64) + .is_some_and(|v| v != 1) + { + return Err(Error::UnsupportedDocumentVersion); + } + } + } + } + let documents: Self = serde_json::from_value(std::mem::take(&mut value.0)) + .map_err(|_| Error::InvalidWire("document schema"))?; + documents.validate()?; + Ok(documents) + } + pub(crate) fn to_secret_json(&self) -> Result>> { + self.validate()?; + bounded_secret_json(self, MAX_PLAINTEXT_JSON_BYTES) + } +} +impl Validate for DocumentSet { + fn validate(&self) -> Result<()> { + self.validate() + } +} +pub(crate) fn validate_timestamp(value: &str) -> Result<()> { + if value.len() > 64 || !(value.ends_with('Z') || value.ends_with("+00:00")) { + return Err(Error::InvalidWire("utc timestamp")); + } + let t = chrono::DateTime::parse_from_rfc3339(value) + .map_err(|_| Error::InvalidWire("utc timestamp"))?; + if t.offset().local_minus_utc() != 0 { + return Err(Error::InvalidWire("utc timestamp")); + } + Ok(()) +} +fn bounded_text(s: &str, min: usize, max: usize) -> Result<()> { + let n = s.chars().count(); + if (min..=max).contains(&n) { + Ok(()) + } else { + Err(Error::InvalidWire("text length")) + } +} + +fn validate_value_depth(value: &Value, depth: usize) -> Result<()> { + match value { + Value::Array(items) => { + if depth >= MAX_JSON_DEPTH { + return Err(Error::InvalidWire("json depth")); + } + for item in items { + validate_value_depth(item, depth + 1)?; + } + } + Value::Object(items) => { + if depth >= MAX_JSON_DEPTH { + return Err(Error::InvalidWire("json depth")); + } + for item in items.values() { + validate_value_depth(item, depth + 1)?; + } + } + _ => {} + } + Ok(()) +} + +fn bounded_secret_json(value: &T, limit: usize) -> Result>> { + // Count with a hard ceiling first, so serialization never grows a secret + // buffer beyond the limit or leaves old plaintext allocations after realloc. + struct Counter { + size: usize, + limit: usize, + } + impl std::io::Write for Counter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.size = self + .size + .checked_add(bytes.len()) + .filter(|v| *v <= self.limit) + .ok_or_else(|| std::io::Error::other("JSON limit"))?; + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut counter = Counter { size: 0, limit }; + serde_json::to_writer(&mut counter, value).map_err(|_| Error::PayloadTooLarge)?; + struct Writer { + bytes: Zeroizing>, + limit: usize, + } + impl std::io::Write for Writer { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + if self.bytes.len().saturating_add(bytes.len()) > self.limit { + return Err(std::io::Error::other("JSON limit")); + } + self.bytes.extend_from_slice(bytes); + Ok(bytes.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + let mut writer = Writer { + bytes: Zeroizing::new(Vec::with_capacity(counter.size)), + limit: counter.size, + }; + serde_json::to_writer(&mut writer, value) + .map_err(|_| Error::InvalidWire("document serialization"))?; + Ok(writer.bytes) +} + +/// Best-effort cleanup of decrypted strings, including unknown extension keys. +fn erase_value(value: &mut Value) { + match value { + Value::String(s) => s.zeroize(), + Value::Array(a) => { + for v in a { + erase_value(v) + } + } + Value::Object(m) => { + for (mut k, mut v) in std::mem::take(m) { + k.zeroize(); + erase_value(&mut v) + } + } + _ => {} + } + *value = Value::Null; +} + +struct StrictValue(Value); +impl Drop for StrictValue { + fn drop(&mut self) { + erase_value(&mut self.0); + } +} +struct ValueSeed(usize); +impl<'de> DeserializeSeed<'de> for ValueSeed { + type Value = Value; + fn deserialize>(self, d: D) -> std::result::Result { + d.deserialize_any(self) + } +} +impl<'de> Visitor<'de> for ValueSeed { + type Value = Value; + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("bounded JSON") + } + fn visit_bool(self, v: bool) -> std::result::Result { + Ok(Value::Bool(v)) + } + fn visit_unit(self) -> std::result::Result { + Ok(Value::Null) + } + fn visit_i64(self, v: i64) -> std::result::Result { + Ok(v.into()) + } + fn visit_u64(self, v: u64) -> std::result::Result { + Ok(v.into()) + } + fn visit_f64(self, v: f64) -> std::result::Result { + serde_json::Number::from_f64(v) + .map(Value::Number) + .ok_or_else(|| E::custom("nonfinite JSON")) + } + fn visit_str(self, v: &str) -> std::result::Result { + Ok(Value::String(v.to_owned())) + } + fn visit_string(self, v: String) -> std::result::Result { + Ok(Value::String(v)) + } + fn visit_seq>(self, mut a: A) -> std::result::Result { + if self.0 >= MAX_JSON_DEPTH { + return Err(de::Error::custom("JSON depth")); + } + let mut out = StrictValue(Value::Array(Vec::new())); + while let Some(mut v) = a.next_element_seed(ValueSeed(self.0 + 1))? { + if let Value::Array(items) = &mut out.0 { + if self.0 == 1 && items.len() >= MAX_DOCUMENTS { + erase_value(&mut v); + return Err(de::Error::custom("document count")); + } + items.push(v); + } + } + Ok(std::mem::take(&mut out.0)) + } + fn visit_map>(self, mut a: A) -> std::result::Result { + if self.0 >= MAX_JSON_DEPTH { + return Err(de::Error::custom("JSON depth")); + } + let mut out = StrictValue(Value::Object(Map::new())); + while let Some(mut key) = a.next_key::()? { + if let Value::Object(items) = &mut out.0 { + if items.contains_key(&key) { + key.zeroize(); + return Err(de::Error::custom("duplicate JSON key")); + } + } + let value = match a.next_value_seed(ValueSeed(self.0 + 1)) { + Ok(v) => v, + Err(e) => { + key.zeroize(); + return Err(e); + } + }; + if let Value::Object(items) = &mut out.0 { + items.insert(key, value); + } + } + Ok(std::mem::take(&mut out.0)) + } +} +fn parse_strict_json(bytes: &[u8], limit: usize) -> Result { + if bytes.len() > limit { + return Err(Error::PayloadTooLarge); + } + let mut d = serde_json::Deserializer::from_slice(bytes); + let value = StrictValue( + ValueSeed(0) + .deserialize(&mut d) + .map_err(|_| Error::InvalidWire("json encoding"))?, + ); + d.end() + .map_err(|_| Error::InvalidWire("json trailing data"))?; + Ok(value) +} +pub(crate) fn parse_wire(bytes: &[u8], limit: usize) -> Result { + if bytes.len() > limit { + return Err(Error::PayloadTooLarge); + } + // Validate syntax without constructing an attacker-chosen Value tree (for + // example millions of `null`s where ciphertext must actually be a string). + let mut parser = serde_json::Deserializer::from_slice(bytes); + SyntaxSeed(0) + .deserialize(&mut parser) + .map_err(|_| Error::InvalidWire("json encoding"))?; + parser + .end() + .map_err(|_| Error::InvalidWire("json trailing data"))?; + let value = + serde_json::from_slice::(bytes).map_err(|_| Error::InvalidWire("json schema"))?; + value.validate()?; + Ok(value) +} + +struct SyntaxSeed(usize); +impl<'de> DeserializeSeed<'de> for SyntaxSeed { + type Value = (); + fn deserialize>(self, d: D) -> std::result::Result<(), D::Error> { + d.deserialize_any(self) + } +} +struct SecretKeys(HashSet); +impl Drop for SecretKeys { + fn drop(&mut self) { + for mut key in self.0.drain() { + key.zeroize(); + } + } +} +impl<'de> Visitor<'de> for SyntaxSeed { + type Value = (); + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("bounded JSON") + } + fn visit_bool(self, _: bool) -> std::result::Result<(), E> { + Ok(()) + } + fn visit_unit(self) -> std::result::Result<(), E> { + Ok(()) + } + fn visit_i64(self, _: i64) -> std::result::Result<(), E> { + Ok(()) + } + fn visit_u64(self, _: u64) -> std::result::Result<(), E> { + Ok(()) + } + fn visit_f64(self, v: f64) -> std::result::Result<(), E> { + if v.is_finite() { + Ok(()) + } else { + Err(E::custom("nonfinite JSON")) + } + } + fn visit_str(self, _: &str) -> std::result::Result<(), E> { + Ok(()) + } + fn visit_string(self, mut v: String) -> std::result::Result<(), E> { + v.zeroize(); + Ok(()) + } + fn visit_seq>(self, mut a: A) -> std::result::Result<(), A::Error> { + if self.0 >= MAX_JSON_DEPTH { + return Err(de::Error::custom("JSON depth")); + } + while a.next_element_seed(SyntaxSeed(self.0 + 1))?.is_some() {} + Ok(()) + } + fn visit_map>(self, mut a: A) -> std::result::Result<(), A::Error> { + if self.0 >= MAX_JSON_DEPTH { + return Err(de::Error::custom("JSON depth")); + } + let mut keys = SecretKeys(HashSet::new()); + while let Some(mut key) = a.next_key::()? { + if keys.0.contains(&key) { + key.zeroize(); + return Err(de::Error::custom("duplicate JSON key")); + } + keys.0.insert(key); + a.next_value_seed(SyntaxSeed(self.0 + 1))?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types/tests.rs new file mode 100644 index 000000000..2e08deb1d --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types/tests.rs @@ -0,0 +1,175 @@ +use super::*; + +fn document_json(value: &str) -> String { + format!( + r#"{{"schemaVersion":1,"exportedAt":"2026-09-23T00:00:00Z","sourceDevice":{{"id":"fixture","os":"test","arch":"test","appVersion":"0.1"}},"documents":[{{"id":"main","kind":"preferences","schemaVersion":1,"value":{value}}}],"tombstones":[]}}"# + ) +} + +#[test] +fn canonical_revision_preserves_uint64_without_float_conversion() { + let v = Revision::parse("9007199254740993").unwrap(); + assert_eq!(v.get(), 9_007_199_254_740_993); + assert_eq!(serde_json::to_string(&v).unwrap(), "\"9007199254740993\""); + for invalid in ["", "01", "00", "+1", "-1", "1.0", "18446744073709551616"] { + assert!(Revision::parse(invalid).is_err()); + } + for invalid in ["1", "1.0", "null", "true"] { + assert!(serde_json::from_str::(invalid).is_err()); + } + assert!(Revision::new(u64::MAX).checked_next().is_err()); + assert!(GithubId::parse("0").is_err()); +} + +#[test] +fn ids_base64_hash_and_etags_are_canonical() { + for invalid in [ + "BE406CA5-37FA-4B26-9A9A-74C1AAD48EAE", + "be406ca5-37fa-1b26-9a9a-74c1aad48eae", + "be406ca537fa4b269a9a74c1aad48eae", + ] { + assert!(UuidV4::parse(invalid).is_err()); + } + let id = UuidV4::random().unwrap(); + assert!(UuidV4::parse(id.as_str()).is_ok()); + assert!(Salt::parse("AAECAwQFBgcICQoLDA0ODw").is_ok()); + for invalid in [ + "AAECAwQFBgcICQoLDA0ODw==", + "AAECAwQFBgcICQoLDA0ODx", + "AAECAwQFBgcICQoLDA0OD+", + ] { + assert!(Salt::parse(invalid).is_err()); + } + assert!(Sha256Digest::parse(&"AB".repeat(32)).is_err()); + for invalid in ["*", "W/\"etag\"", "etag", "\"\"", "\"a\"b\"", "\"a\nb\""] { + assert!(StrongEtag::parse(invalid).is_err()); + } + assert_eq!( + StrongEtag::parse("\"opaque,=!\\\"").unwrap().as_str(), + "\"opaque,=!\\\"" + ); +} + +#[test] +fn private_json_rejects_duplicates_depth_and_bad_unicode() { + assert!(DocumentSet::from_json_bytes(document_json(r#"{"a":1,"a":2}"#).as_bytes()).is_err()); + let root_duplicate = document_json("{}").replacen( + "\"schemaVersion\":1", + "\"schemaVersion\":1,\"schemaVersion\":1", + 1, + ); + assert!(DocumentSet::from_json_bytes(root_duplicate.as_bytes()).is_err()); + let deep = format!("{}0{}", "[".repeat(65), "]".repeat(65)); + assert!(DocumentSet::from_json_bytes(document_json(&deep).as_bytes()).is_err()); + assert!(DocumentSet::from_json_bytes(document_json(r#"{"bad":1e400}"#).as_bytes()).is_err()); + assert!(DocumentSet::from_json_bytes(&[0xff, 0xfe]).is_err()); + assert!( + DocumentSet::from_json_bytes(format!("{} null", document_json("{}")).as_bytes()).is_err() + ); +} + +#[test] +fn document_identity_and_schema_are_validated_without_dropping_extensions() { + let input = document_json(r#"{"futureSetting":{"secret":"fixture"},"value":3}"#); + let mut docs = DocumentSet::from_json_bytes(input.as_bytes()).unwrap(); + let bytes = docs.to_secret_json().unwrap(); + let restored = DocumentSet::from_json_bytes(&bytes).unwrap(); + assert_eq!( + restored.documents[0].value["futureSetting"]["secret"], + "fixture" + ); + assert!(!format!("{docs:?}").contains("fixture")); + docs.documents.push(docs.documents[0].clone()); + assert!(docs.validate().is_err()); + docs.documents.pop(); + docs.tombstones.push(Tombstone { + id: "main".into(), + kind: DocumentKind::Preferences, + deleted_at: "2026-09-23T00:00:00Z".into(), + base_revision: Revision::new(0), + }); + assert!(docs.validate().is_err()); + assert!(matches!( + DocumentSet::from_json_bytes( + input + .replace("\"preferences\"", "\"future_kind\"") + .as_bytes() + ), + Err(Error::UnsupportedDocumentVersion) + )); + docs.tombstones.clear(); + docs.documents[0].schema_version = 2; + assert!(matches!( + docs.validate(), + Err(Error::UnsupportedDocumentVersion) + )); +} + +#[test] +fn metadata_requires_null_fields_and_rejects_inconsistent_states() { + let valid = r#"{"protocolVersion":1,"state":"empty","ownerGithubId":"12345","revision":"0","vaultId":null,"keyId":null,"updatedAt":null,"payloadSchemaVersion":null,"ciphertextBytes":0,"ciphertextSha256":null,"lastOperationId":null}"#; + assert!(parse_wire::(valid.as_bytes(), CONTROL_BODY_LIMIT).is_ok()); + assert!(parse_wire::( + valid.replace("\"keyId\":null,", "").as_bytes(), + CONTROL_BODY_LIMIT + ) + .is_err()); + assert!(parse_wire::( + valid + .replace("\"revision\":\"0\"", "\"revision\":\"1\"") + .as_bytes(), + CONTROL_BODY_LIMIT + ) + .is_err()); + assert!(parse_wire::( + valid.replace("\"empty\"", "\"active\"").as_bytes(), + CONTROL_BODY_LIMIT + ) + .is_err()); +} + +#[test] +fn schema_and_errors_never_echo_attacker_content() { + let marker = "PASSWORD_AND_TOKEN_MUST_NEVER_APPEAR"; + let input = document_json("{}").replace( + "\"schemaVersion\":1", + &format!("\"schemaVersion\":\"{marker}\""), + ); + let err = DocumentSet::from_json_bytes(input.as_bytes()).unwrap_err(); + assert!(!format!("{err:?} {err}").contains(marker)); + assert!(!format!("{:?}", SecretInput::new(marker.into())).contains(marker)); + assert!(!format!("{:?}", SecretToken::new(marker.into()).unwrap()).contains(marker)); +} + +#[test] +fn control_response_limit_is_enforced_before_json_parsing() { + let bytes = vec![b' '; CONTROL_BODY_LIMIT + 1]; + assert!(matches!( + parse_wire::(&bytes, CONTROL_BODY_LIMIT), + Err(Error::PayloadTooLarge) + )); +} + +#[test] +fn export_checks_depth_and_limit_before_allocating_json_output() { + let mut docs = DocumentSet::from_json_bytes(document_json("null").as_bytes()).unwrap(); + docs.documents[0].value = Value::String("x".repeat(MAX_PLAINTEXT_JSON_BYTES)); + assert!(matches!(docs.to_secret_json(), Err(Error::PayloadTooLarge))); + let mut value = Value::Null; + for _ in 0..65 { + value = Value::Array(vec![value]); + } + docs.documents[0].value = value; + assert!(docs.to_secret_json().is_err()); +} + +#[test] +fn optional_current_revision_must_not_be_explicit_null() { + let wire = r#"{"error":{"code":"revision_conflict","message":"fixed","requestId":"e1d8c32e-d209-4e56-8735-bc66b8684c71","currentRevision":null}}"#; + assert!(parse_wire::(wire.as_bytes(), CONTROL_BODY_LIMIT).is_err()); + assert!(parse_wire::( + wire.replace(",\"currentRevision\":null", "").as_bytes(), + CONTROL_BODY_LIMIT + ) + .is_ok()); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/capture.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/capture.rs new file mode 100644 index 000000000..36da33b08 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/capture.rs @@ -0,0 +1,247 @@ +use std::collections::BTreeSet; + +use crate::cloud_sync_e2ee_documents::*; +use crate::cloud_sync_e2ee_protocol::types::{DocumentSet, Revision, Tombstone}; + +use super::{state::ScopeState, CoreSyncStore, DeviceExtensionKey, ExclusivePermit}; + +impl CoreSyncStore { + pub(super) async fn capture_locked( + &self, + scope: &SyncScope, + permit: &ExclusivePermit, + ) -> DocumentResult { + let state = ScopeState::decode( + scope, + self.inner.extensions.read_scope(scope.clone()).await?, + )?; + let snapshot = self.capture_native(scope, permit, &state).await?; + self.finish_capture(scope, state, snapshot).await + } + + pub(super) async fn capture_readonly( + &self, + scope: &SyncScope, + ) -> DocumentResult { + let token = self.inner.gate.coherent_generation()?; + let state = ScopeState::decode( + scope, + self.inner.extensions.read_scope(scope.clone()).await?, + )?; + let captured = self.capture_native_inner(None, &state, token.0).await; + if self.inner.gate.coherent_generation()? != token { + return Err(DocumentError::SourceChanged); + } + self.finish_capture(scope, state, captured?).await + } + + async fn finish_capture( + &self, + scope: &SyncScope, + mut state: ScopeState, + mut snapshot: ExportSnapshot, + ) -> DocumentResult { + let old_tombstones = std::mem::take(&mut snapshot.tombstones); + let mut exported = export_snapshot(snapshot)?; + let live: BTreeSet<_> = exported + .documents + .documents() + .documents + .iter() + .map(|doc| (doc.kind, doc.id.clone())) + .collect(); + let mut set = exported.documents.documents().clone(); + set.tombstones = old_tombstones + .into_iter() + .filter(|tombstone| !live.contains(&(tombstone.kind, tombstone.id.clone()))) + .collect(); + exported.documents = validate_sync_documents(set, state.observed_revision)?; + if let Some(baseline) = &state.baseline { + let mut current = exported.documents.documents().clone(); + let mut present: BTreeSet<_> = current + .documents + .iter() + .map(|doc| (doc.kind, doc.id.clone())) + .chain( + current + .tombstones + .iter() + .map(|doc| (doc.kind, doc.id.clone())), + ) + .collect(); + for tombstone in &baseline.tombstones { + if present.insert((tombstone.kind, tombstone.id.clone())) { + current.tombstones.push(tombstone.clone()); + } + } + for old in &baseline.documents { + if present.insert((old.kind, old.id.clone())) { + current.tombstones.push(Tombstone { + id: old.id.clone(), + kind: old.kind, + deleted_at: chrono::Utc::now().to_rfc3339(), + base_revision: state.baseline_revision, + }); + } + } + exported.documents = validate_sync_documents( + current, + state.observed_revision.max(state.baseline_revision), + )?; + } + if exported.documents.documents().tombstones != state.tombstones { + state.tombstones = exported.documents.documents().tombstones.clone(); + self.inner + .extensions + .write_scope(scope.clone(), state.secret_json()?) + .await?; + } + Ok(exported) + } + + pub(super) async fn capture_native( + &self, + _scope: &SyncScope, + permit: &ExclusivePermit, + state: &ScopeState, + ) -> DocumentResult { + self.capture_native_inner(Some(permit), state, permit.generation()?) + .await + } + + async fn capture_native_inner( + &self, + permit: Option<&ExclusivePermit>, + state: &ScopeState, + generation: Revision, + ) -> DocumentResult { + let repositories = &self.inner.repositories; + let preferences = SecretJson::new( + match permit { + Some(permit) => repositories.preferences.sync_snapshot(permit), + None => repositories.preferences.sync_snapshot_readonly(), + } + .map_err(|error| capture_backend_error("preferences", error))?, + ); + let dictionary = secret_rows( + &match permit { + Some(permit) => repositories.vocabulary.sync_snapshot(permit), + None => repositories.vocabulary.list(), + } + .map_err(|error| capture_backend_error("dictionary", error))?, + )?; + let corrections = secret_rows( + &match permit { + Some(permit) => repositories.correction_rules.sync_snapshot(permit), + None => repositories.correction_rules.list(), + } + .map_err(|error| capture_backend_error("corrections", error))?, + )?; + let history = secret_rows( + &match permit { + Some(permit) => repositories.history.sync_snapshot(permit), + None => repositories.history.list(), + } + .map_err(|error| capture_backend_error("history", error))?, + )?; + let style_packs = match permit { + Some(permit) => repositories.style_packs.sync_snapshot(permit), + None => repositories.style_packs.sync_snapshot_readonly(), + } + .map_err(|error| capture_backend_error("style_packs", error))?; + let activity = match permit { + Some(permit) => repositories.activity.sync_records(permit), + None => repositories.activity.sync_records_readonly(), + } + .map_err(|error| capture_backend_error("activity", error))?; + let presets = crate::vocabulary::list_vocab_presets(&self.inner.data_dir) + .map_err(|error| capture_backend_error("vocabulary_presets", error))?; + let builtin_ids: Vec<_> = crate::vocabulary::builtin_vocab_presets() + .into_iter() + .map(|preset| preset.id) + .collect(); + let vocabulary_presets = vocabulary_records( + &presets.custom, + &presets.overrides, + &presets.disabled_builtin_preset_ids, + &builtin_ids, + )?; + let ui_preferences = self + .inner + .extensions + .read_device(DeviceExtensionKey::UiPreferences) + .await? + .ok_or(DocumentError::Unsupported)?; + let window_positions = match self + .inner + .extensions + .read_device(DeviceExtensionKey::WindowPositions) + .await? + { + Some(value) => serde_json::from_value(value.expose().clone()) + .map_err(|_| DocumentError::InvalidDocument)?, + None => Vec::new(), + }; + let credentials = match permit { + Some(permit) => self.inner.credentials.export_sync_credentials(permit).await, + None => { + self.inner + .credentials + .export_sync_credentials_readonly() + .await + } + } + .map_err(|error| capture_backend_error("credentials", error))?; + validate_credential_set(&credentials.channels, &credentials.credentials)?; + Ok(ExportSnapshot { + source_device: self.inner.device.clone(), + exported_at: chrono::Utc::now().to_rfc3339(), + generation, + base_revision: state.observed_revision, + preferences, + ui_preferences, + channels: credentials.channels, + provider_credentials: credentials.credentials, + dictionary, + vocabulary_presets, + corrections, + style_packs, + history, + activity, + window_positions, + retained_documents: state.retained_documents.clone(), + tombstones: state.tombstones.clone(), + }) + } + + pub(super) async fn baseline_locked( + &self, + scope: &SyncScope, + documents: DocumentSet, + revision: Revision, + ) -> DocumentResult<()> { + let validated = validate_sync_documents(documents, revision)?; + let mut state = ScopeState::decode( + scope, + self.inner.extensions.read_scope(scope.clone()).await?, + )?; + if revision < state.baseline_revision { + return Err(DocumentError::StalePreview); + } + state.baseline_revision = revision; + state.observed_revision = state.observed_revision.max(revision); + state.baseline = Some(validated.documents().clone()); + // Do not replace retained documents or local tombstones: later local changes may exist. + self.inner + .extensions + .write_scope(scope.clone(), state.secret_json()?) + .await + } +} + +/// Values, paths, record IDs and error bodies may contain private user data. +/// A capture diagnostic therefore records only an audited stage and enum code. +fn capture_backend_error(stage: &'static str, error: crate::BackendError) -> DocumentError { + log::warn!("[e2ee-capture] stage={stage} code={:?}", error.code); + DocumentError::CaptureFailed +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/extensions.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/extensions.rs new file mode 100644 index 000000000..5d9cdf1e9 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/extensions.rs @@ -0,0 +1,50 @@ +//! Encrypted extension persistence supplied by the service's OS-key-backed LocalStorage. + +use std::sync::Arc; + +use futures_util::future::BoxFuture; + +use crate::cloud_sync_e2ee_documents::{DocumentResult, JournalProtector, SecretJson, SyncScope}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceExtensionKey { + /// Actual locale/font scale mirrored from the restricted native UI preferences bridge. + UiPreferences, + /// Persisted window positions, if the Host has any; never arbitrary window-state file paths. + WindowPositions, + /// Explicit registered restore scopes. Recovery does not scan arbitrary files. + RecoveryPointers, +} + +impl DeviceExtensionKey { + pub fn storage_name(self) -> &'static str { + match self { + Self::UiPreferences => "sync-ui-preferences", + Self::WindowPositions => "sync-window-positions", + Self::RecoveryPointers => "sync-recovery-pointers", + } + } +} + +pub trait ProtectedExtensionStore: Send + Sync { + /// Scope storage is keyed by origin/owner/vault/device, not a mutable global current account. + /// Cloud key rotation must not strand OS-key-protected local extensions. + fn read_scope(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult>>; + fn write_scope(&self, scope: SyncScope, value: SecretJson) + -> BoxFuture<'_, DocumentResult<()>>; + fn read_device( + &self, + key: DeviceExtensionKey, + ) -> BoxFuture<'_, DocumentResult>>; + fn write_device( + &self, + key: DeviceExtensionKey, + value: SecretJson, + ) -> BoxFuture<'_, DocumentResult<()>>; + /// A local-only change token, rotated on every restore write even when values repeat. + fn read_ui_revision(&self) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async { Err(crate::cloud_sync_e2ee_documents::DocumentError::Unsupported) }) + } + /// Returns a real local AEAD protector after OS secure storage authorizes its key. + fn journal_protector(&self) -> BoxFuture<'_, DocumentResult>>; +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/gate.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/gate.rs new file mode 100644 index 000000000..0ab61270f --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/gate.rs @@ -0,0 +1,1168 @@ +//! Shared write barrier and durable logical generation. Guards hold no mutex across await. + +use std::cell::RefCell; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock, Weak}; + +use serde::{Deserialize, Serialize}; +use tokio::sync::watch; + +use crate::cloud_sync_e2ee_documents::{DocumentError, DocumentResult, SyncScope}; +use crate::cloud_sync_e2ee_protocol::types::Revision; + +static GATES: OnceLock>>> = OnceLock::new(); + +/// Host entry point before constructing any repository. Reuses the registered Arc exactly. +pub fn open_for_data_dir(data_dir: &Path) -> DocumentResult> { + std::fs::create_dir_all(data_dir.join("encrypted-sync")) + .map_err(|_| DocumentError::JournalUnavailable)?; + let root = data_dir + .canonicalize() + .map_err(|_| DocumentError::JournalUnavailable)?; + let mut registry = GATES + .get_or_init(|| Mutex::new(BTreeMap::new())) + .lock() + .map_err(|_| DocumentError::RecoveryRequired)?; + if let Some(gate) = registry.get(&root).and_then(Weak::upgrade) { + return Ok(gate); + } + let gate = SyncWriteGate::open(root.join("encrypted-sync/generation.json"))?; + registry.insert(root, Arc::downgrade(&gate)); + Ok(gate) +} + +fn absolute_normalized(path: &Path) -> DocumentResult { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|_| DocumentError::JournalUnavailable)? + .join(path) + }; + let mut output = PathBuf::new(); + for component in absolute.components() { + match component { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + if !output.pop() { + return Err(DocumentError::InvalidDocument); + } + } + value => output.push(value.as_os_str()), + } + } + Ok(output) +} + +/// Resolve the nearest existing ancestor, retaining every not-yet-created descendant. +fn resolved_path(path: &Path) -> DocumentResult { + let mut ancestor = path.to_path_buf(); + let mut suffix = Vec::new(); + loop { + match ancestor.canonicalize() { + Ok(mut resolved) => { + for part in suffix.iter().rev() { + resolved.push(part); + } + return absolute_normalized(&resolved); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + // A dangling link must not be reinterpreted as an ordinary future directory. + if std::fs::symlink_metadata(&ancestor) + .is_ok_and(|metadata| metadata.file_type().is_symlink()) + { + return Err(DocumentError::InvalidDocument); + } + suffix.push( + ancestor + .file_name() + .ok_or(DocumentError::InvalidDocument)? + .to_os_string(), + ); + if !ancestor.pop() { + return Err(DocumentError::InvalidDocument); + } + } + Err(_) => return Err(DocumentError::JournalUnavailable), + } + } +} + +pub(crate) fn gate_for_path(path: &Path) -> DocumentResult>> { + if path.as_os_str().is_empty() { + return Ok(None); + } + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|_| DocumentError::JournalUnavailable)? + .join(path) + }; + let lexical = absolute_normalized(&absolute)?; + let parent = absolute.parent().map(resolved_path).transpose()?; + let final_is_symlink = std::fs::symlink_metadata(&absolute) + .is_ok_and(|metadata| metadata.file_type().is_symlink()); + let resolved = resolved_path(&absolute)?; + // Root aliases must retain ownership even when a later symlink escapes that root. + let ancestors: Vec<_> = absolute + .ancestors() + .filter_map(|ancestor| ancestor.canonicalize().ok()) + .collect(); + let registry = GATES + .get_or_init(|| Mutex::new(BTreeMap::new())) + .lock() + .map_err(|_| DocumentError::RecoveryRequired)?; + // rename replaces the directory entry, not its symlink target. A managed + // final-file link must never borrow a different nested root's permit. + if final_is_symlink + && parent + .as_ref() + .is_some_and(|parent| registry.keys().any(|root| parent.starts_with(root))) + { + return Err(DocumentError::InvalidDocument); + } + let lexical_root = registry + .keys() + .filter(|root| lexical.starts_with(root)) + .max_by_key(|root| root.components().count()); + if lexical_root.is_some_and(|root| !resolved.starts_with(root)) { + return Err(DocumentError::InvalidDocument); + } + for ancestor in &ancestors { + if registry + .keys() + .any(|root| ancestor.starts_with(root) && !resolved.starts_with(root)) + { + return Err(DocumentError::InvalidDocument); + } + } + let found = registry + .iter() + .filter(|(root, _)| resolved.starts_with(root)) + .max_by_key(|(root, _)| root.components().count()); + match found { + Some((_, weak)) => weak + .upgrade() + .map(Some) + .ok_or(DocumentError::RecoveryRequired), + None => Ok(None), + } +} + +/// Constructors may parse in memory but must not migrate, salvage-write, or normalize files. +pub fn recovery_pending_for_path(path: &Path) -> bool { + match gate_for_path(path) { + Ok(Some(gate)) => gate.recovery_required().unwrap_or(true), + Ok(None) => false, + Err(_) => true, + } +} + +struct Observation { + gate: Arc, + wrote: Arc, + dirty: Arc, +} +thread_local! { static OBSERVATIONS: RefCell> = const { RefCell::new(Vec::new()) }; } +struct ObservationGuard; +impl Drop for ObservationGuard { + fn drop(&mut self) { + OBSERVATIONS.with(|stack| { + stack.borrow_mut().pop(); + }); + } +} + +fn backend_error(error: DocumentError) -> crate::BackendError { + crate::BackendError::new( + if error == DocumentError::SourceChanged { + crate::BackendErrorCode::Busy + } else { + crate::BackendErrorCode::InvalidState + }, + error.to_string(), + ) +} + +pub(crate) fn with_registered_mutation( + path: &Path, + origin: ChangeOrigin, + operation: impl FnOnce() -> Result, +) -> Result { + with_registered_mutation_deciding(path, || operation().map(|value| (value, origin))) +} + +pub(crate) fn with_registered_mutation_deciding( + path: &Path, + operation: impl FnOnce() -> Result<(T, ChangeOrigin), crate::BackendError>, +) -> Result { + let Some(gate) = gate_for_path(path).map_err(backend_error)? else { + return operation().map(|(value, _)| value); + }; + let permit = gate.begin_mutation().map_err(backend_error)?; + let wrote = Arc::new(AtomicBool::new(false)); + let dirty = Arc::new(AtomicBool::new(false)); + OBSERVATIONS.with(|stack| { + stack.borrow_mut().push(Observation { + gate, + wrote: Arc::clone(&wrote), + dirty: Arc::clone(&dirty), + }) + }); + let observation = ObservationGuard; + let result = operation(); + drop(observation); + match result { + Ok((value, origin)) => { + if dirty.load(Ordering::Acquire) { + permit.commit(origin).map_err(backend_error)?; + } else { + permit.abort_unmodified().map_err(backend_error)?; + } + Ok(value) + } + Err(error) => { + if !wrote.load(Ordering::Acquire) + && error.code != crate::BackendErrorCode::OutcomeUnknown + { + permit.abort_unmodified().map_err(backend_error)?; + } + // Otherwise Drop retains the durable uncertain-write intent. + Err(error) + } + } +} + +pub(crate) fn with_optional_mutation( + path: Option<&Path>, + origin: ChangeOrigin, + operation: impl FnOnce() -> Result, +) -> Result { + match path { + Some(path) => with_registered_mutation(path, origin, operation), + None => operation(), + } +} + +fn tracked_file(path: &Path) -> bool { + matches!( + path.file_name().and_then(|name| name.to_str()), + Some( + "preferences.json" + | "history.json" + | "activity.json" + | "dictionary.json" + | "correction-rules.json" + | "style-packs.json" + | "vocab-presets.json" + ) + ) +} + +pub(crate) fn begin_unobserved_atomic( + path: &Path, +) -> Result, crate::BackendError> { + // Internal generation/extension files must bypass this lookup to avoid recursion. + if !tracked_file(path) { + return Ok(None); + } + let Some(gate) = gate_for_path(path).map_err(backend_error)? else { + return Ok(None); + }; + let observed = OBSERVATIONS.with(|stack| { + stack + .borrow() + .iter() + .any(|entry| Arc::ptr_eq(&entry.gate, &gate)) + }); + if observed { + return Ok(None); + } + gate.begin_mutation().map(Some).map_err(backend_error) +} + +pub(crate) fn note_successful_write(path: &Path) { + if !tracked_file(path) { + return; + } + if let Ok(Some(gate)) = gate_for_path(path) { + OBSERVATIONS.with(|stack| { + let stack = stack.borrow(); + let matching: Vec<_> = stack + .iter() + .filter(|entry| Arc::ptr_eq(&entry.gate, &gate)) + .collect(); + // All ancestors need a durable recovery intent if a later outer step fails. + for entry in &matching { + entry.wrote.store(true, Ordering::Release); + } + // Only the innermost owner counts this successful persistence. + if let Some(owner) = matching.last() { + owner.dirty.store(true, Ordering::Release); + } + }); + } +} + +pub(crate) fn require_exclusive( + path: &Path, + permit: &ExclusivePermit, +) -> Result<(), crate::BackendError> { + let gate = gate_for_path(path) + .map_err(backend_error)? + .ok_or_else(|| backend_error(DocumentError::Unsupported))?; + if !permit.belongs_to(&gate) { + return Err(backend_error(DocumentError::InvalidDocument)); + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ChangeOrigin { + User, + Restore, + Recovery, + LocalOnly, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SyncChange { + pub generation: Revision, + pub origin: ChangeOrigin, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GateRestoreReceipt { + pub operation_id: String, + pub scope_id: String, + pub scope: SyncScope, + pub generation: Revision, + pub committed: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PendingRestore { + operation_id: String, + scope_id: String, + scope: SyncScope, + journal_ready: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct PersistedState { + schema_version: u32, + generation: Revision, + incomplete_mutations: BTreeSet, + pending_restore: Option, + completed_restores: BTreeMap, +} + +struct State { + stored: PersistedState, + active: BTreeSet, + exclusive: bool, + exclusive_lease: Weak, + io_fault: bool, + epoch: u64, + pending_change: Option, +} + +pub struct SyncWriteGate { + path: PathBuf, + state: Mutex, + changes: watch::Sender, + runtime_blocked: AtomicBool, +} + +impl SyncWriteGate { + /// An unreadable/corrupt counter is an error, never a reset to zero. + pub fn open(state_path: PathBuf) -> DocumentResult> { + let initialized_path = state_path.with_extension("initialized"); + let stored = match std::fs::read(&state_path) { + Ok(bytes) => decode(&bytes)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let directory = state_path + .parent() + .ok_or(DocumentError::JournalUnavailable)?; + if initialized_path.exists() + || (directory.file_name().and_then(|name| name.to_str()) + == Some("encrypted-sync") + && std::fs::read_dir(directory) + .map_err(|_| DocumentError::JournalUnavailable)? + .next() + .is_some()) + { + return Err(DocumentError::RecoveryRequired); + } + // Create a durable first-initialization sentinel before the initial counter. + // A crash in this small window fails closed instead of guessing generation zero. + crate::persistence::atomic_write(&initialized_path, b"openless-sync-generation-v1") + .map_err(|_| DocumentError::JournalUnavailable)?; + let state = PersistedState { + schema_version: 1, + generation: Revision::new(0), + incomplete_mutations: BTreeSet::new(), + pending_restore: None, + completed_restores: BTreeMap::new(), + }; + persist(&state_path, &state)?; + state + } + Err(_) => return Err(DocumentError::JournalUnavailable), + }; + if !initialized_path.exists() { + crate::persistence::atomic_write(&initialized_path, b"openless-sync-generation-v1") + .map_err(|_| DocumentError::JournalUnavailable)?; + } + let (changes, _) = watch::channel(SyncChange { + generation: stored.generation, + origin: ChangeOrigin::Recovery, + }); + let runtime_blocked = AtomicBool::new( + !stored.incomplete_mutations.is_empty() || stored.pending_restore.is_some(), + ); + Ok(Arc::new(Self { + runtime_blocked, + path: state_path, + state: Mutex::new(State { + stored, + active: BTreeSet::new(), + exclusive: false, + exclusive_lease: Weak::new(), + io_fault: false, + epoch: 0, + pending_change: None, + }), + changes, + })) + } + + pub fn generation(&self) -> DocumentResult { + let state = self.lock()?; + if state.io_fault || orphaned(&state) { + return Err(DocumentError::RecoveryRequired); + } + Ok(state.stored.generation) + } + + /// Optimistic read token; no writes or restore may be in flight at either observation. + pub fn coherent_generation(&self) -> DocumentResult<(Revision, u64)> { + let state = self.lock()?; + if state.io_fault || orphaned(&state) || state.stored.pending_restore.is_some() { + return Err(DocumentError::RecoveryRequired); + } + if state.exclusive || !state.active.is_empty() { + return Err(DocumentError::SourceChanged); + } + Ok((state.stored.generation, state.epoch)) + } + + pub fn subscribe(&self) -> watch::Receiver { + self.changes.subscribe() + } + + /// Public metadata only; recovery visits these registered scopes, never scans user files. + pub fn registered_recovery_scopes(&self) -> DocumentResult> { + let state = self.lock()?; + let mut scopes = Vec::new(); + if let Some(pending) = &state.stored.pending_restore { + scopes.push(pending.scope.clone()); + } + for receipt in state.stored.completed_restores.values() { + if !scopes.contains(&receipt.scope) { + scopes.push(receipt.scope.clone()); + } + } + Ok(scopes) + } + + /// Runtime hot paths never wait behind journal I/O or the write-state mutex. + pub fn recovery_required(&self) -> DocumentResult { + Ok(self.runtime_blocked.load(Ordering::Acquire)) + } + + fn refresh_runtime_flag(&self, state: &State) { + self.runtime_blocked.store( + state.io_fault || orphaned(state) || state.stored.pending_restore.is_some(), + Ordering::Release, + ); + } + + /// Acquire before reading data for a read-modify-write. Only commit after its save succeeds. + pub fn begin_mutation(self: &Arc) -> DocumentResult { + let mut state = self.lock()?; + if state.io_fault || orphaned(&state) || state.stored.pending_restore.is_some() { + return Err(DocumentError::RecoveryRequired); + } + if state.exclusive { + return Err(DocumentError::SourceChanged); + } + let id = uuid::Uuid::new_v4().to_string(); + let mut next = state.stored.clone(); + next.incomplete_mutations.insert(id.clone()); + self.save(&mut state, next)?; + state.epoch = state + .epoch + .checked_add(1) + .ok_or(DocumentError::RecoveryRequired)?; + state.active.insert(id.clone()); + self.refresh_runtime_flag(&state); + Ok(MutationPermit { + gate: Arc::clone(self), + id, + finished: false, + }) + } + + /// Fail fast while any mutation is active; never block a writer's nested operation. + pub fn try_exclusive(self: &Arc) -> DocumentResult { + let mut state = self.lock()?; + if state.io_fault || orphaned(&state) || state.stored.pending_restore.is_some() { + return Err(DocumentError::RecoveryRequired); + } + if state.exclusive || !state.active.is_empty() { + return Err(DocumentError::SourceChanged); + } + state.epoch = state + .epoch + .checked_add(1) + .ok_or(DocumentError::RecoveryRequired)?; + state.exclusive = true; + let lease = Arc::new(ExclusiveLease { + gate: Arc::clone(self), + }); + state.exclusive_lease = Arc::downgrade(&lease); + Ok(ExclusivePermit { lease }) + } + + /// For startup journal recovery or a strict full-store reconciliation after an uncertain save. + pub fn try_exclusive_recovery(self: &Arc) -> DocumentResult { + let mut state = self.lock()?; + if state.exclusive || !state.active.is_empty() { + return Err(DocumentError::SourceChanged); + } + let stored = + decode(&std::fs::read(&self.path).map_err(|_| DocumentError::JournalUnavailable)?)?; + state.stored = stored; + state.io_fault = false; + self.refresh_runtime_flag(&state); + state.epoch = state + .epoch + .checked_add(1) + .ok_or(DocumentError::RecoveryRequired)?; + state.exclusive = true; + let lease = Arc::new(ExclusiveLease { + gate: Arc::clone(self), + }); + state.exclusive_lease = Arc::downgrade(&lease); + Ok(ExclusivePermit { lease }) + } + + pub(crate) fn clone_exclusive_for_metadata(&self) -> DocumentResult { + self.lock()? + .exclusive_lease + .upgrade() + .map(|lease| ExclusivePermit { lease }) + .ok_or(DocumentError::SourceChanged) + } + + fn lock(&self) -> DocumentResult> { + self.state.lock().map_err(|_| { + self.runtime_blocked.store(true, Ordering::Release); + DocumentError::RecoveryRequired + }) + } + + fn save(&self, state: &mut State, next: PersistedState) -> DocumentResult<()> { + if let Err(error) = persist(&self.path, &next) { + state.io_fault = true; + self.runtime_blocked.store(true, Ordering::Release); + return Err(error); + } + state.stored = next; + Ok(()) + } + + fn publish(&self, generation: Revision, origin: ChangeOrigin) { + if origin != ChangeOrigin::LocalOnly { + // Concurrent commits can reach notification delivery out of order. + self.changes.send_if_modified(|current| { + if generation < current.generation + || *current == (SyncChange { generation, origin }) + { + return false; + } + *current = SyncChange { generation, origin }; + true + }); + } + } +} + +fn orphaned(state: &State) -> bool { + state.stored.incomplete_mutations != state.active +} + +fn decode(bytes: &[u8]) -> DocumentResult { + if bytes.len() > 1024 * 1024 { + return Err(DocumentError::JournalUnavailable); + } + let state: PersistedState = + serde_json::from_slice(bytes).map_err(|_| DocumentError::JournalUnavailable)?; + if state.schema_version != 1 { + return Err(DocumentError::Unsupported); + } + Ok(state) +} + +fn persist(path: &std::path::Path, state: &PersistedState) -> DocumentResult<()> { + let bytes = serde_json::to_vec(state).map_err(|_| DocumentError::JournalUnavailable)?; + if bytes.len() > 1024 * 1024 { + return Err(DocumentError::JournalUnavailable); + } + crate::persistence::atomic_write(path, &bytes).map_err(|_| DocumentError::JournalUnavailable) +} + +/// Owned and Send; keep it inside the actual task, not only its caller's cancellable future. +pub struct MutationPermit { + gate: Arc, + id: String, + finished: bool, +} + +impl MutationPermit { + pub fn commit(mut self, origin: ChangeOrigin) -> DocumentResult { + let (generation, publish) = { + let mut state = self.gate.lock()?; + if !state.active.contains(&self.id) || state.io_fault { + return Err(DocumentError::RecoveryRequired); + } + let mut next = state.stored.clone(); + if origin != ChangeOrigin::LocalOnly { + next.generation = next + .generation + .checked_next() + .map_err(|_| DocumentError::RecoveryRequired)?; + } + next.incomplete_mutations.remove(&self.id); + self.gate.save(&mut state, next)?; + state.active.remove(&self.id); + self.gate.refresh_runtime_flag(&state); + if origin != ChangeOrigin::LocalOnly { + state.pending_change = Some(origin); + } + let publish = if state.active.is_empty() { + state.pending_change.take() + } else { + None + }; + self.finished = true; + (state.stored.generation, publish) + }; + if let Some(origin) = publish { + self.gate.publish(generation, origin); + } + Ok(generation) + } + + /// Only for a validation/no-op/error path known not to have persisted anything. + pub fn abort_unmodified(mut self) -> DocumentResult<()> { + let (generation, publish) = { + let mut state = self.gate.lock()?; + let mut next = state.stored.clone(); + next.incomplete_mutations.remove(&self.id); + self.gate.save(&mut state, next)?; + state.active.remove(&self.id); + self.gate.refresh_runtime_flag(&state); + let publish = if state.active.is_empty() { + state.pending_change.take() + } else { + None + }; + self.finished = true; + (state.stored.generation, publish) + }; + if let Some(origin) = publish { + self.gate.publish(generation, origin); + } + Ok(()) + } +} + +impl Drop for MutationPermit { + fn drop(&mut self) { + if !self.finished { + if let Ok(mut state) = self.gate.state.lock() { + state.active.remove(&self.id); + self.gate.refresh_runtime_flag(&state); + // The durable intent remains until an actual coherent capture reconciles state. + state.io_fault = true; + self.gate.runtime_blocked.store(true, Ordering::Release); + } + } + } +} + +#[derive(Clone)] +pub struct ExclusivePermit { + lease: Arc, +} +struct ExclusiveLease { + gate: Arc, +} + +impl ExclusivePermit { + pub fn belongs_to(&self, gate: &Arc) -> bool { + Arc::ptr_eq(&self.lease.gate, gate) + } + + pub fn generation(&self) -> DocumentResult { + Ok(self.lease.gate.lock()?.stored.generation) + } + + pub fn pending_restore(&self) -> DocumentResult { + Ok(self.lease.gate.lock()?.stored.pending_restore.is_some()) + } + + /// Called only after every store and credential source was successfully read/validated. + /// It records an observed recovery epoch, never declares an interrupted mutation successful. + pub(crate) fn accept_reconciled_capture(&self) -> DocumentResult { + let generation = { + let mut state = self.lease.gate.lock()?; + if state.stored.pending_restore.is_some() { + return Err(DocumentError::RecoveryRequired); + } + if state.stored.incomplete_mutations.is_empty() { + return Ok(state.stored.generation); + } + let mut next = state.stored.clone(); + next.generation = next + .generation + .checked_next() + .map_err(|_| DocumentError::RecoveryRequired)?; + next.incomplete_mutations.clear(); + self.lease.gate.save(&mut state, next)?; + self.lease.gate.refresh_runtime_flag(&state); + state.stored.generation + }; + self.lease.gate.publish(generation, ChangeOrigin::Recovery); + Ok(generation) + } + + pub fn mark_restore_pending( + &self, + operation_id: &str, + scope_id: &str, + scope: &SyncScope, + ) -> DocumentResult<()> { + let mut state = self.lease.gate.lock()?; + if let Some(pending) = &state.stored.pending_restore { + if pending.operation_id != operation_id + || pending.scope_id != scope_id + || &pending.scope != scope + { + return Err(DocumentError::RecoveryRequired); + } + return Ok(()); + } + let mut next = state.stored.clone(); + next.pending_restore = Some(PendingRestore { + operation_id: operation_id.into(), + scope_id: scope_id.into(), + scope: scope.clone(), + journal_ready: false, + }); + self.lease.gate.save(&mut state, next)?; + self.lease.gate.refresh_runtime_flag(&state); + Ok(()) + } + + pub fn mark_journal_ready(&self, operation_id: &str, scope_id: &str) -> DocumentResult<()> { + let mut state = self.lease.gate.lock()?; + let mut next = state.stored.clone(); + let pending = next + .pending_restore + .as_mut() + .ok_or(DocumentError::RecoveryRequired)?; + if pending.operation_id != operation_id || pending.scope_id != scope_id { + return Err(DocumentError::RecoveryRequired); + } + pending.journal_ready = true; + self.lease.gate.save(&mut state, next)?; + self.lease.gate.refresh_runtime_flag(&state); + Ok(()) + } + + pub fn recover_without_journal(&self, scope_id: &str) -> DocumentResult<()> { + let operation = { + let state = self.lease.gate.lock()?; + let Some(pending) = &state.stored.pending_restore else { + return Ok(()); + }; + if pending.scope_id != scope_id || pending.journal_ready { + return Err(DocumentError::RecoveryRequired); + } + pending.operation_id.clone() + }; + self.finish_restore(&operation, scope_id, false).map(|_| ()) + } + + pub fn completed_restore( + &self, + operation_id: &str, + scope_id: &str, + ) -> DocumentResult> { + Ok(self + .lease + .gate + .lock()? + .stored + .completed_restores + .get(scope_id) + .filter(|receipt| receipt.operation_id == operation_id) + .cloned()) + } + + pub(crate) fn forget_completed_scope_without_journal( + &self, + scope_id: &str, + ) -> DocumentResult<()> { + let mut state = self.lease.gate.lock()?; + if state + .stored + .pending_restore + .as_ref() + .is_some_and(|pending| pending.scope_id == scope_id) + { + return Err(DocumentError::RecoveryRequired); + } + if state.stored.completed_restores.contains_key(scope_id) { + let mut next = state.stored.clone(); + next.completed_restores.remove(scope_id); + self.lease.gate.save(&mut state, next)?; + } + Ok(()) + } + + pub(crate) fn forget_completed_restore( + &self, + operation_id: &str, + scope_id: &str, + ) -> DocumentResult<()> { + let mut state = self.lease.gate.lock()?; + if state + .stored + .completed_restores + .get(scope_id) + .is_some_and(|receipt| receipt.operation_id == operation_id) + { + let mut next = state.stored.clone(); + next.completed_restores.remove(scope_id); + self.lease.gate.save(&mut state, next)?; + } + Ok(()) + } + + pub fn finish_restore( + &self, + operation_id: &str, + scope_id: &str, + committed: bool, + ) -> DocumentResult { + if let Some(receipt) = self.completed_restore(operation_id, scope_id)? { + if receipt.committed != committed { + return Err(DocumentError::RecoveryRequired); + } + return Ok(receipt); + } + let receipt = { + let mut state = self.lease.gate.lock()?; + let pending = state + .stored + .pending_restore + .as_ref() + .ok_or(DocumentError::RecoveryRequired)?; + if pending.operation_id != operation_id || pending.scope_id != scope_id { + return Err(DocumentError::RecoveryRequired); + } + let scope = pending.scope.clone(); + let mut next = state.stored.clone(); + if committed { + next.generation = next + .generation + .checked_next() + .map_err(|_| DocumentError::RecoveryRequired)?; + } + next.pending_restore = None; + let receipt = GateRestoreReceipt { + operation_id: operation_id.into(), + scope_id: scope_id.into(), + scope, + generation: next.generation, + committed, + }; + next.completed_restores + .insert(scope_id.to_string(), receipt.clone()); + self.lease.gate.save(&mut state, next)?; + self.lease.gate.refresh_runtime_flag(&state); + receipt + }; + if committed { + self.lease + .gate + .publish(receipt.generation, ChangeOrigin::Restore); + } + Ok(receipt) + } +} + +impl Drop for ExclusiveLease { + fn drop(&mut self) { + if let Ok(mut state) = self.gate.state.lock() { + state.exclusive = false; + match state.epoch.checked_add(1) { + Some(next) => state.epoch = next, + None => { + state.io_fault = true; + self.gate.runtime_blocked.store(true, Ordering::Release); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + struct Temp(PathBuf); + impl Temp { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("sync-gate-fixture-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + #[test] + fn nested_completion_flushes_only_after_last_abort_or_local_only_commit() { + for local_only in [false, true] { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let changes = gate.subscribe(); + let outer = gate.begin_mutation().unwrap(); + let inner = gate.begin_mutation().unwrap(); + inner.commit(ChangeOrigin::User).unwrap(); + assert!(!changes.has_changed().unwrap()); + if local_only { + outer.commit(ChangeOrigin::LocalOnly).unwrap(); + } else { + outer.abort_unmodified().unwrap(); + } + assert!(changes.has_changed().unwrap()); + assert_eq!(changes.borrow().generation, gate.generation().unwrap()); + } + } + #[test] + fn exclusive_clones_hold_barrier_and_rollback_changes_capture_epoch() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let before = gate.coherent_generation().unwrap(); + let first = gate.try_exclusive().unwrap(); + let last = first.clone(); + drop(first); + assert!(gate.begin_mutation().is_err()); + drop(last); + let after = gate.coherent_generation().unwrap(); + assert_eq!(before.0, after.0); + assert_ne!(before.1, after.1); + } + #[test] + fn delayed_old_notification_cannot_move_watch_backwards() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + gate.publish(Revision::new(2), ChangeOrigin::User); + gate.publish(Revision::new(1), ChangeOrigin::User); + assert_eq!(gate.subscribe().borrow().generation, Revision::new(2)); + } + #[cfg(unix)] + #[test] + fn aliases_missing_descendants_and_escaping_symlinks_cannot_bypass_gate() { + let temp = Temp::new(); + let root = temp.0.join("data"); + std::fs::create_dir(&root).unwrap(); + let gate = open_for_data_dir(&root).unwrap(); + let alias = temp.0.join("alias"); + std::os::unix::fs::symlink(&root, &alias).unwrap(); + let permit = gate.try_exclusive().unwrap(); + for path in [ + alias.join("preferences.json"), + alias.join("missing/nested/preferences.json"), + ] { + assert!(with_registered_mutation(&path, ChangeOrigin::User, || { + crate::persistence::atomic_write(&path, b"{}") + }) + .is_err()); + assert!(!path.exists()); + } + std::os::unix::fs::symlink(temp.0.join("outside"), root.join("escape")).unwrap(); + assert!(gate_for_path(&root.join("escape/preferences.json")).is_err()); + drop(permit); + assert_eq!(gate.generation().unwrap(), Revision::new(0)); + } + #[test] + fn unfinished_mutation_survives_reopen_and_only_reconciliation_clears_it() { + let temp = Temp::new(); + let path = temp.0.join("state.json"); + let gate = SyncWriteGate::open(path.clone()).unwrap(); + drop(gate.begin_mutation().unwrap()); + drop(gate); + let gate = SyncWriteGate::open(path).unwrap(); + assert!(gate.recovery_required().unwrap()); + let permit = gate.try_exclusive_recovery().unwrap(); + permit.accept_reconciled_capture().unwrap(); + drop(permit); + assert_eq!(gate.generation().unwrap(), Revision::new(1)); + assert!(!gate.recovery_required().unwrap()); + } + #[test] + fn persist_rejects_unreadable_size_before_replacing_existing_state() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let path = temp.0.join("encrypted-sync/generation.json"); + let before = std::fs::read(&path).unwrap(); + let mut state = gate.lock().unwrap().stored.clone(); + state.incomplete_mutations.insert("x".repeat(1024 * 1024)); + assert!(persist(&path, &state).is_err()); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert!(decode(&before).is_ok()); + } + #[test] + fn lost_counter_never_resets_an_initialized_gate_or_existing_protected_state() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let path = temp.0.join("encrypted-sync/generation.json"); + gate.begin_mutation() + .unwrap() + .commit(ChangeOrigin::User) + .unwrap(); + drop(gate); + std::fs::remove_file(&path).unwrap(); + assert!(matches!( + SyncWriteGate::open(path), + Err(DocumentError::RecoveryRequired) + )); + let other = Temp::new(); + std::fs::create_dir(other.0.join("encrypted-sync")).unwrap(); + std::fs::write(other.0.join("encrypted-sync/fixture.enc"), b"ciphertext").unwrap(); + assert!(matches!( + open_for_data_dir(&other.0), + Err(DocumentError::RecoveryRequired) + )); + } + + #[test] + fn runtime_recovery_flag_does_not_wait_for_state_lock_or_file_io() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let held = gate.state.lock().unwrap(); + assert!(!gate.recovery_required().unwrap()); + drop(held); + drop(gate.begin_mutation().unwrap()); + let held = gate.state.lock().unwrap(); + assert!(gate.recovery_required().unwrap()); + drop(held); + } + #[test] + fn inner_success_followed_by_outer_failure_keeps_an_uncertain_outer_intent() { + let temp = Temp::new(); + let gate = open_for_data_dir(&temp.0).unwrap(); + let path = temp.0.join("preferences.json"); + let result: Result<(), crate::BackendError> = + with_registered_mutation(&path, ChangeOrigin::User, || { + with_registered_mutation(&path, ChangeOrigin::User, || { + crate::persistence::atomic_write(&path, b"{}") + })?; + Err(crate::BackendError::new( + crate::BackendErrorCode::Persistence, + "later outer step failed", + )) + }); + assert!(result.is_err()); + assert!(gate.recovery_required().unwrap()); + assert_eq!(std::fs::read(path).unwrap(), b"{}"); + let permit = gate.try_exclusive_recovery().unwrap(); + assert_eq!(permit.generation().unwrap(), Revision::new(1)); + assert!(!gate.lock().unwrap().stored.incomplete_mutations.is_empty()); + } + #[cfg(unix)] + #[test] + fn aliased_root_with_final_or_intermediate_escape_cannot_bypass_held_exclusive() { + let temp = Temp::new(); + let root = temp.0.join("data"); + std::fs::create_dir(&root).unwrap(); + let gate = open_for_data_dir(&root).unwrap(); + let alias = temp.0.join("alias"); + std::os::unix::fs::symlink(&root, &alias).unwrap(); + let outside = temp.0.join("outside"); + std::fs::create_dir(&outside).unwrap(); + std::fs::write(outside.join("preferences.json"), b"outside").unwrap(); + std::os::unix::fs::symlink( + outside.join("preferences.json"), + root.join("preferences.json"), + ) + .unwrap(); + std::os::unix::fs::symlink(&outside, root.join("escape")).unwrap(); + let _permit = gate.try_exclusive().unwrap(); + for path in [ + alias.join("preferences.json"), + alias.join("escape/preferences.json"), + ] { + assert!(with_registered_mutation(&path, ChangeOrigin::User, || { + crate::persistence::atomic_write(&path, b"changed") + }) + .is_err()); + } + assert_eq!( + std::fs::read(outside.join("preferences.json")).unwrap(), + b"outside" + ); + assert_eq!(gate.generation().unwrap(), Revision::new(0)); + } + #[cfg(unix)] + #[test] + fn managed_final_link_into_a_nested_registered_root_never_borrows_its_permit() { + let temp = Temp::new(); + let outer = open_for_data_dir(&temp.0).unwrap(); + let nested = temp.0.join("child"); + let inner = open_for_data_dir(&nested).unwrap(); + let target = nested.join("existing.json"); + std::fs::write(&target, b"child").unwrap(); + let link = temp.0.join("preferences.json"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let _permit = outer.try_exclusive().unwrap(); + assert!(with_registered_mutation(&link, ChangeOrigin::User, || { + crate::persistence::atomic_write(&link, b"outer") + }) + .is_err()); + assert!(std::fs::symlink_metadata(&link) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(std::fs::read(target).unwrap(), b"child"); + assert_eq!(outer.generation().unwrap(), Revision::new(0)); + assert_eq!(inner.generation().unwrap(), Revision::new(0)); + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/journal.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/journal.rs new file mode 100644 index 000000000..1cf20dc85 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/journal.rs @@ -0,0 +1,136 @@ +use std::io::Read; +use std::path::PathBuf; +use std::sync::Arc; + +use futures_util::future::BoxFuture; + +use crate::cloud_sync_e2ee_documents::{ + DocumentError, DocumentResult, JournalStore, SealedJournal, SyncScope, MAX_JOURNAL_BYTES, +}; + +use super::{state::scope_id, SyncWriteGate}; + +const MAGIC: &[u8; 8] = b"OLSJRNL1"; + +pub(super) struct FileJournalStore { + directory: PathBuf, + gate: Arc, +} +impl FileJournalStore { + pub fn new(directory: PathBuf, gate: Arc) -> Self { + Self { directory, gate } + } + fn path(&self, scope: &SyncScope) -> DocumentResult { + Ok(self + .directory + .join(format!("restore-{}.bin", scope_id(scope)?))) + } +} + +fn sync_directory(path: &std::path::Path) -> DocumentResult<()> { + #[cfg(unix)] + std::fs::File::open(path) + .and_then(|file| file.sync_all()) + .map_err(|_| DocumentError::JournalUnavailable)?; + Ok(()) +} + +impl JournalStore for FileJournalStore { + fn load(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + let path = self.path(&scope)?; + tokio::task::spawn_blocking(move || { + match std::fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => { + return Err(DocumentError::RecoveryRequired) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(_) => return Err(DocumentError::JournalUnavailable), + _ => {} + } + let mut bytes = Vec::new(); + std::fs::File::open(path) + .map_err(|_| DocumentError::JournalUnavailable)? + .take((MAX_JOURNAL_BYTES + 129 + 44) as u64) + .read_to_end(&mut bytes) + .map_err(|_| DocumentError::JournalUnavailable)?; + if bytes.len() < 44 + || bytes.len() > MAX_JOURNAL_BYTES + 128 + 44 + || !bytes.starts_with(MAGIC) + { + return Err(DocumentError::RecoveryRequired); + } + let operation_id = std::str::from_utf8(&bytes[8..44]) + .map_err(|_| DocumentError::RecoveryRequired)? + .to_string(); + let id = uuid::Uuid::parse_str(&operation_id) + .map_err(|_| DocumentError::RecoveryRequired)?; + if id.get_version() != Some(uuid::Version::Random) || id.to_string() != operation_id + { + return Err(DocumentError::RecoveryRequired); + } + Ok(Some(SealedJournal { + operation_id, + ciphertext: bytes[44..].to_vec(), + })) + }) + .await + .map_err(|_| DocumentError::JournalUnavailable)? + }) + } + + fn save(&self, scope: SyncScope, journal: SealedJournal) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + let path = self.path(&scope)?; + let guard = self.gate.clone_exclusive_for_metadata()?; + if journal.operation_id.len() != 36 + || journal.ciphertext.is_empty() + || journal.ciphertext.len() > MAX_JOURNAL_BYTES + 128 + { + return Err(DocumentError::JournalUnavailable); + } + tokio::task::spawn_blocking(move || { + // This clone keeps ordinary writes blocked even if the waiting future is dropped. + let _guard = guard; + let mut bytes = Vec::with_capacity(44 + journal.ciphertext.len()); + bytes.extend_from_slice(MAGIC); + bytes.extend_from_slice(journal.operation_id.as_bytes()); + bytes.extend_from_slice(&journal.ciphertext); + crate::persistence::atomic_write(&path, &bytes) + .map_err(|_| DocumentError::JournalUnavailable) + }) + .await + .map_err(|_| DocumentError::JournalUnavailable)? + }) + } + + fn clear(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + let path = self.path(&scope)?; + let scope_id = scope_id(&scope)?; + let guard = self.gate.clone_exclusive_for_metadata()?; + tokio::task::spawn_blocking(move || { + let operation = std::fs::File::open(&path).ok().and_then(|file| { + let mut bytes = Vec::new(); + file.take(44).read_to_end(&mut bytes).ok()?; + bytes + .get(8..44) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .map(str::to_owned) + }); + match std::fs::remove_file(&path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(_) => return Err(DocumentError::JournalUnavailable), + } + sync_directory(path.parent().ok_or(DocumentError::JournalUnavailable)?)?; + if let Some(operation) = operation { + guard.forget_completed_restore(&operation, &scope_id)?; + } + Ok(()) + }) + .await + .map_err(|_| DocumentError::JournalUnavailable)? + }) + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs new file mode 100644 index 000000000..97829cd08 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/mod.rs @@ -0,0 +1,600 @@ +//! Native repository adapter for encrypted sync. +//! Complete logical restoration is protected by an encrypted crash journal. + +mod capture; +pub mod extensions; +pub mod gate; +mod journal; +mod native; +mod state; + +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use futures_util::future::BoxFuture; + +use crate::cloud_sync_e2ee_documents::{ + DocumentError, DocumentResult, ExportedDocuments, RestoreBackend, RestoreContext, RestoreLease, + SecretJson, SyncScope, ValidatedSyncDocuments, +}; +use crate::cloud_sync_e2ee_protocol::types::{DocumentSet, Revision, SourceDevice}; +use crate::{BackendRepositories, CredentialStore}; + +pub use extensions::{DeviceExtensionKey, ProtectedExtensionStore}; +pub use gate::{ChangeOrigin, ExclusivePermit, MutationPermit, SyncChange, SyncWriteGate}; + +#[derive(Clone)] +pub struct CoreSyncStore { + inner: Arc, +} + +struct Inner { + repositories: BackendRepositories, + credentials: Arc, + data_dir: PathBuf, + device: SourceDevice, + gate: Arc, + extensions: Arc, + restoring: AtomicBool, + metadata: tokio::sync::Mutex<()>, + runtime_idle: std::sync::Mutex bool + Send + Sync>>>, + runtime_effects: std::sync::Mutex>>, +} + +impl CoreSyncStore { + pub fn new( + repositories: BackendRepositories, + credentials: Arc, + data_dir: PathBuf, + device: SourceDevice, + gate: Arc, + extensions: Arc, + ) -> DocumentResult { + let registered = gate::open_for_data_dir(&data_dir)?; + if !Arc::ptr_eq(®istered, &gate) { + return Err(DocumentError::InvalidDocument); + } + credentials + .bind_sync_gate(Arc::clone(&gate)) + .map_err(|_| DocumentError::Locked)?; + repositories + .activity + .bind_sync_device(&device.id) + .map_err(|_| DocumentError::CaptureFailed)?; + Ok(Self { + inner: Arc::new(Inner { + repositories, + credentials, + data_dir, + device, + gate, + extensions, + restoring: AtomicBool::new(false), + metadata: tokio::sync::Mutex::new(()), + runtime_idle: std::sync::Mutex::new(None), + runtime_effects: std::sync::Mutex::new(None), + }), + }) + } + + pub fn generation(&self) -> DocumentResult { + self.inner.gate.generation() + } + pub fn device(&self) -> SourceDevice { + self.inner.device.clone() + } + pub fn changes(&self) -> tokio::sync::watch::Receiver { + self.inner.gate.subscribe() + } + + /// Core binds its shared dictation/insertion/agent session-state probe before startup recovery. + pub fn bind_runtime_idle_probe( + &self, + probe: Arc bool + Send + Sync>, + ) -> DocumentResult<()> { + let mut slot = self + .inner + .runtime_idle + .lock() + .map_err(|_| DocumentError::RecoveryRequired)?; + if slot.is_some() { + return Err(DocumentError::InvalidDocument); + } + *slot = Some(probe); + Ok(()) + } + + /// Install a real, absolute Host effects adapter before allowing restores. + pub fn bind_runtime_effects( + &self, + effects: Arc, + ) -> DocumentResult<()> { + let mut slot = self + .inner + .runtime_effects + .lock() + .map_err(|_| DocumentError::RecoveryRequired)?; + if slot.is_some() { + return Err(DocumentError::InvalidDocument); + } + *slot = Some(effects); + Ok(()) + } + + fn runtime_effects(&self) -> DocumentResult> { + self.inner + .runtime_effects + .lock() + .map_err(|_| DocumentError::RecoveryRequired)? + .clone() + .ok_or(DocumentError::Unsupported) + } + + async fn apply_runtime_effects(&self, permit: &ExclusivePermit) -> DocumentResult<()> { + if !permit.belongs_to(&self.inner.gate) { + return Err(DocumentError::InvalidDocument); + } + let effects = self.runtime_effects()?; + let target = self.inner.repositories.preferences.get(); + effects + .apply_target(target) + .await + .map_err(|_| DocumentError::CaptureFailed) + } + + /// Ordinary runtime starts must check this while holding Core's shared runtime lock. + pub fn is_recovering(&self) -> bool { + self.inner.restoring.load(Ordering::Acquire) + || self.inner.gate.recovery_required().unwrap_or(true) + } + pub fn ensure_runtime_available(&self) -> DocumentResult<()> { + if self.is_recovering() { + Err(DocumentError::RecoveryRequired) + } else { + Ok(()) + } + } + + /// Local-only eligibility sample. Callers recheck this stamp after credential reads. + pub(crate) fn setup_prompt_state( + &self, + ) -> DocumentResult> { + if self.is_recovering() { + return Ok(None); + } + let probe = self + .inner + .runtime_idle + .lock() + .map_err(|_| DocumentError::RecoveryRequired)? + .clone(); + let Some(probe) = probe else { + return Ok(None); + }; + if !probe() { + return Ok(None); + } + let stamp = match self.inner.gate.coherent_generation() { + Ok(stamp) => stamp, + Err(DocumentError::SourceChanged | DocumentError::RecoveryRequired) => return Ok(None), + Err(error) => return Err(error), + }; + let preferences = self.inner.repositories.preferences.get(); + if self.is_recovering() + || !probe() + || self.inner.gate.coherent_generation().ok() != Some(stamp) + { + return Ok(None); + } + Ok(Some((preferences, stamp))) + } + + pub async fn export_scope(&self, scope: SyncScope) -> DocumentResult { + self.validate_local_scope(&scope)?; + let _metadata = self.inner.metadata.lock().await; + self.capture_readonly(&scope).await + } + + pub async fn restore_scope( + &self, + desired: ValidatedSyncDocuments, + context: RestoreContext, + ) -> DocumentResult { + self.validate_local_scope(&context.scope)?; + let scope = context.scope.clone(); + let desired = native::canonicalize_native_documents(desired)?; + let plan = crate::cloud_sync_e2ee_documents::prepare_sync_restore(desired, context)?; + let store = self.clone(); + // Detach only the owned, journalled operation: dropping an IPC waiter cannot cancel it. + tokio::spawn(async move { + let _metadata = store.inner.metadata.lock().await; + let _runtime = store.begin_runtime_restore()?; + store.runtime_effects()?; + let protector = store.inner.extensions.journal_protector().await?; + let journal = store.journal(); + crate::cloud_sync_e2ee_documents::apply_sync_restore( + plan, + &store, + &journal, + protector.as_ref(), + ) + .await?; + let permit = store.inner.gate.try_exclusive()?; + store.capture_locked(&scope, &permit).await + }) + .await + .map_err(|_| DocumentError::RecoveryRequired)? + } + + pub async fn record_baseline( + &self, + scope: SyncScope, + documents: DocumentSet, + revision: Revision, + ) -> DocumentResult<()> { + self.validate_local_scope(&scope)?; + let store = self.clone(); + tokio::spawn(async move { + let _metadata = store.inner.metadata.lock().await; + store.baseline_locked(&scope, documents, revision).await + }) + .await + .map_err(|_| DocumentError::RecoveryRequired)? + } + + pub async fn recover_registered(&self) -> DocumentResult<()> { + let scopes = self.inner.gate.registered_recovery_scopes()?; + if scopes.is_empty() && !self.inner.gate.recovery_required()? { + return Ok(()); + } + let store = self.clone(); + tokio::spawn(async move { + use crate::cloud_sync_e2ee_documents::JournalStore; + let _metadata = store.inner.metadata.lock().await; + let _runtime = store.begin_runtime_restore()?; + // Recovery needs the same real Host adapter before reading protected journals + // or prompting for keys; unsupported recovery must leave the pending bytes intact. + store.runtime_effects()?; + let journal = store.journal(); + for scope in scopes { + if journal.load(scope.clone()).await?.is_none() { + let permit = store.inner.gate.try_exclusive_recovery()?; + let scope_id = state::scope_id(&scope)?; + permit.recover_without_journal(&scope_id)?; + permit.forget_completed_scope_without_journal(&scope_id)?; + } else { + let protector = store.inner.extensions.journal_protector().await?; + crate::cloud_sync_e2ee_documents::recover_sync_restore( + scope, + &store, + &journal, + protector.as_ref(), + ) + .await?; + } + } + if store.inner.gate.recovery_required()? { + let permit = store.inner.gate.try_exclusive_recovery()?; + store.reconcile_native(&permit).await?; + store.apply_runtime_effects(&permit).await?; + permit.accept_reconciled_capture()?; + } + Ok(()) + }) + .await + .map_err(|_| DocumentError::RecoveryRequired)? + } + + fn validate_local_scope(&self, scope: &SyncScope) -> DocumentResult<()> { + crate::cloud_sync_e2ee_documents::validate_scope(scope)?; + if scope.device_id != self.inner.device.id { + return Err(DocumentError::InvalidDocument); + } + Ok(()) + } + fn journal(&self) -> journal::FileJournalStore { + journal::FileJournalStore::new( + self.inner.data_dir.join("encrypted-sync"), + Arc::clone(&self.inner.gate), + ) + } + fn begin_runtime_restore(&self) -> DocumentResult { + self.inner + .restoring + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .map_err(|_| DocumentError::SourceChanged)?; + let guard = RuntimeRestore(self.clone()); + let probe = self + .inner + .runtime_idle + .lock() + .map_err(|_| DocumentError::RecoveryRequired)? + .clone() + .ok_or(DocumentError::Unsupported)?; + if !probe() { + return Err(DocumentError::RuntimeBusy); + } + Ok(guard) + } + + async fn reconcile_native(&self, permit: &ExclusivePermit) -> DocumentResult<()> { + // No UI mirror or sync key is required to reconcile an interrupted ordinary save. + let repo = &self.inner.repositories; + repo.preferences + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repo.vocabulary + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repo.correction_rules + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repo.history + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let styles = repo + .style_packs + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + for style in styles { + if let Some(path) = style + .pack + .expose() + .get("iconPath") + .and_then(serde_json::Value::as_str) + { + crate::persistence::ensure_durable_file(std::path::Path::new(path)) + .map_err(|_| DocumentError::RecoveryRequired)?; + } + } + for name in [ + "preferences.json", + "history.json", + "activity.json", + "dictionary.json", + "correction-rules.json", + "style-packs.json", + "vocab-presets.json", + ] { + let path = self.inner.data_dir.join(name); + if path.exists() { + crate::persistence::ensure_durable_file(&path) + .map_err(|_| DocumentError::RecoveryRequired)?; + } + } + repo.activity + .sync_records(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + crate::vocabulary::list_vocab_presets(&self.inner.data_dir) + .map_err(|_| DocumentError::CaptureFailed)?; + let credentials = self + .inner + .credentials + .export_sync_credentials(permit) + .await + .map_err(|_| DocumentError::CaptureFailed)?; + crate::cloud_sync_e2ee_documents::validate_credential_set( + &credentials.channels, + &credentials.credentials, + ) + } + + pub async fn set_ui_preferences(&self, value: SecretJson) -> DocumentResult<()> { + self.set_ui_preferences_inner(value, None).await + } + + pub async fn set_ui_preferences_checked( + &self, + value: SecretJson, + expected_revision: Option, + ) -> DocumentResult<()> { + self.set_ui_preferences_inner(value, Some(expected_revision)) + .await + } + + async fn set_ui_preferences_inner( + &self, + value: SecretJson, + expected_revision: Option>, + ) -> DocumentResult<()> { + let map = value + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)?; + if map.len() != 2 || !map.contains_key("locale") || !map.contains_key("fontScale") { + return Err(DocumentError::InvalidDocument); + } + let locale = map["locale"] + .as_str() + .ok_or(DocumentError::InvalidDocument)?; + let font = map["fontScale"] + .as_str() + .ok_or(DocumentError::InvalidDocument)?; + if ![ + "system", "zh-CN", "zh-TW", "en", "ja", "ko", "es", "fr", "de", + ] + .contains(&locale) + || !["small", "medium", "large"].contains(&font) + { + return Err(DocumentError::InvalidDocument); + } + let store = self.clone(); + tokio::spawn(async move { + let _metadata = store.inner.metadata.lock().await; + let permit = store.inner.gate.begin_mutation()?; + if let Some(expected) = expected_revision { + let current = match store.inner.extensions.read_ui_revision().await { + Ok(value) => value, + Err(error) => { + permit.abort_unmodified()?; + return Err(error); + } + }; + if current != expected { + permit.abort_unmodified()?; + return Err(DocumentError::StalePreview); + } + } + let previous = match store + .inner + .extensions + .read_device(DeviceExtensionKey::UiPreferences) + .await + { + Ok(value) => value, + Err(error) => { + permit.abort_unmodified()?; + return Err(error); + } + }; + if previous.as_ref() == Some(&value) { + return permit.abort_unmodified(); + } + store + .inner + .extensions + .write_device(DeviceExtensionKey::UiPreferences, value) + .await?; + permit.commit(ChangeOrigin::User).map(|_| ()) + }) + .await + .map_err(|_| DocumentError::RecoveryRequired)? + } +} + +struct RuntimeRestore(CoreSyncStore); +impl Drop for RuntimeRestore { + fn drop(&mut self) { + self.0.inner.restoring.store(false, Ordering::Release); + } +} + +struct NativeLease { + store: CoreSyncStore, + scope: SyncScope, + scope_id: String, + permit: ExclusivePermit, +} +impl RestoreBackend for CoreSyncStore { + fn acquire( + &self, + scope: SyncScope, + expected_generation: Option, + ) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + self.validate_local_scope(&scope)?; + let permit = if expected_generation.is_some() { + self.inner.gate.try_exclusive()? + } else { + self.inner.gate.try_exclusive_recovery()? + }; + if expected_generation + .is_some_and(|expected| permit.generation().ok() != Some(expected)) + { + return Err(DocumentError::StalePreview); + } + let scope_id = state::scope_id(&scope)?; + Ok(Box::new(NativeLease { + store: self.clone(), + scope, + scope_id, + permit, + }) as Box) + }) + } +} + +impl RestoreLease for NativeLease { + fn capture( + &mut self, + ) -> BoxFuture<'_, DocumentResult> { + Box::pin(async move { + let state = state::ScopeState::decode( + &self.scope, + self.store + .inner + .extensions + .read_scope(self.scope.clone()) + .await?, + )?; + self.store + .capture_native(&self.scope, &self.permit, &state) + .await + }) + } + fn capture_rollback_state(&mut self) -> BoxFuture<'_, DocumentResult> { + Box::pin(async move { self.store.capture_local_rollback_state(&self.permit) }) + } + fn restore_rollback_state(&mut self, value: &SecretJson) -> BoxFuture<'_, DocumentResult<()>> { + let value = value.clone(); + Box::pin(async move { + self.store + .restore_local_rollback_state(&value, &self.permit)?; + // Excluded local grants/mirrors are restored after the logical rollback. + // The Host must converge to that final target before the fence is released. + self.store.apply_runtime_effects(&self.permit).await + }) + } + fn preflight( + &mut self, + documents: &ValidatedSyncDocuments, + ) -> BoxFuture<'_, DocumentResult<()>> { + let documents = documents.clone(); + Box::pin(async move { + self.store.runtime_effects()?; + self.store + .validate_native(&self.scope, &documents, &self.permit) + .await + }) + } + fn replace(&mut self, documents: ValidatedSyncDocuments) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + self.store.runtime_effects()?; + self.store + .replace_native(&self.scope, &documents, &self.permit) + .await + }) + } + fn reload(&mut self) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { self.store.apply_runtime_effects(&self.permit).await }) + } + fn mark_recovery_pending(&mut self, operation_id: &str) -> DocumentResult<()> { + self.permit + .mark_restore_pending(operation_id, &self.scope_id, &self.scope) + } + fn mark_journal_ready(&mut self, operation_id: &str) -> DocumentResult<()> { + self.permit.mark_journal_ready(operation_id, &self.scope_id) + } + fn recover_without_journal(&mut self) -> DocumentResult<()> { + self.permit.recover_without_journal(&self.scope_id) + } + fn completed_restore( + &self, + operation_id: &str, + ) -> DocumentResult> { + self.permit + .completed_restore(operation_id, &self.scope_id) + .map(|value| value.map(receipt)) + } + fn finish_restore( + &mut self, + operation_id: &str, + committed: bool, + ) -> DocumentResult { + self.permit + .finish_restore(operation_id, &self.scope_id, committed) + .map(receipt) + } +} +fn receipt(value: gate::GateRestoreReceipt) -> crate::cloud_sync_e2ee_documents::RestoreReceipt { + crate::cloud_sync_e2ee_documents::RestoreReceipt { + operation_id: value.operation_id, + generation: value.generation, + committed: value.committed, + journal_cleanup_pending: false, + } +} + +#[cfg(test)] +mod tests; diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs new file mode 100644 index 000000000..4a30c0940 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/native.rs @@ -0,0 +1,477 @@ +//! Materialization is preflighted completely before the first repository write. +use super::{state::ScopeState, CoreSyncStore, DeviceExtensionKey, ExclusivePermit}; +use crate::cloud_sync_e2ee_documents::registry::{preference_field, PreferenceClass}; +use crate::cloud_sync_e2ee_documents::*; +use crate::cloud_sync_e2ee_protocol::types::{DocumentKind, LogicalDocument}; +use crate::credentials::SyncCredentials; +use crate::types::{ + CorrectionRule, DictationSession, DictionaryEntry, VocabPreset, VocabPresetStore, +}; +use serde::de::DeserializeOwned; +use serde_json::Value; + +struct NativeRestore { + preferences: Value, + ui: SecretJson, + windows: SecretJson, + credentials: SyncCredentials, + dictionary: Vec, + corrections: Vec, + history: Vec, + presets: VocabPresetStore, + styles: Vec, + activity: Vec, + state: ScopeState, +} + +fn typed(value: &Value) -> DocumentResult { + serde_json::from_value(value.clone()).map_err(|_| DocumentError::Unsupported) +} +fn ordered( + documents: &[LogicalDocument], + kind: DocumentKind, +) -> DocumentResult> { + let mut values: Vec<_> = documents.iter().filter(|doc| doc.kind == kind).collect(); + values.sort_by_key(|doc| { + doc.value + .get("sortIndex") + .and_then(Value::as_u64) + .unwrap_or(0) + }); + values.into_iter().map(|doc| typed(&doc.value)).collect() +} + +pub(super) fn canonicalize_native_documents( + desired: ValidatedSyncDocuments, +) -> DocumentResult { + let mut set = desired.documents().clone(); + for doc in &mut set.documents { + let original = doc.value.clone(); + let mut canonical = match doc.kind { + DocumentKind::Dictionary => serde_json::to_value(typed::(&original)?), + DocumentKind::Corrections => serde_json::to_value(typed::(&original)?), + DocumentKind::History => serde_json::to_value(typed::(&original)?), + DocumentKind::StylePacks => { + let mut record: StylePackRecord = typed(&original)?; + let pack: crate::style_packs::StylePack = typed(record.pack.expose())?; + let mut canonical = + serde_json::to_value(pack).map_err(|_| DocumentError::InvalidDocument)?; + crate::persistence::ensure_lossless_value(record.pack.expose(), &canonical, &[]) + .map_err(|_| DocumentError::Unsupported)?; + canonical + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .remove("iconPath"); + canonical + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .remove("active"); + record.pack = SecretJson::new(canonical); + serde_json::to_value(record) + } + _ => continue, + } + .map_err(|_| DocumentError::InvalidDocument)?; + crate::persistence::ensure_lossless_value(&original, &canonical, &["sortIndex"]) + .map_err(|_| DocumentError::Unsupported)?; + if let Some(index) = original.get("sortIndex") { + canonical + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)? + .insert("sortIndex".into(), index.clone()); + } + doc.value = canonical; + } + validate_sync_documents(set, desired.observed_revision()) +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LocalRollbackState { + schema_version: u32, + coding_agent_enabled: bool, + active_asr_provider: String, + active_llm_provider: String, + active_omni_provider: String, + /// Receiver-only availability; no audio bytes or paths ever enter the journal. + history_audio_markers: std::collections::BTreeMap>, +} + +impl CoreSyncStore { + pub(super) fn capture_local_rollback_state( + &self, + permit: &ExclusivePermit, + ) -> DocumentResult { + let value = self + .inner + .repositories + .preferences + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let prefs: crate::shared_types::UserPreferences = typed(&value)?; + let history = self + .inner + .repositories + .history + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let mut history_audio_markers = std::collections::BTreeMap::new(); + for record in history { + if history_audio_markers + .insert(record.id, record.has_audio_recording) + .is_some() + { + return Err(DocumentError::DuplicateId); + } + } + SecretJson::from_serializable(&LocalRollbackState { + schema_version: 1, + coding_agent_enabled: prefs.coding_agent_enabled, + active_asr_provider: prefs.active_asr_provider, + active_llm_provider: prefs.active_llm_provider, + active_omni_provider: prefs.active_omni_provider, + history_audio_markers, + }) + } + pub(super) fn restore_local_rollback_state( + &self, + value: &SecretJson, + permit: &ExclusivePermit, + ) -> DocumentResult<()> { + let local: LocalRollbackState = typed(value.expose())?; + if local.schema_version != 1 { + return Err(DocumentError::Unsupported); + } + // Logical rollback restores the rows first. Require the same complete identity set, + // then restore only this receiver's original metadata, never a cloud media flag. + let mut history = self + .inner + .repositories + .history + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let ids: std::collections::BTreeSet<_> = + history.iter().map(|record| record.id.as_str()).collect(); + if ids.len() != history.len() + || history.len() != local.history_audio_markers.len() + || !ids + .iter() + .all(|id| local.history_audio_markers.contains_key(*id)) + { + return Err(DocumentError::RecoveryRequired); + } + for record in &mut history { + record.has_audio_recording = local.history_audio_markers[&record.id]; + } + self.inner + .repositories + .history + .sync_replace_all(&history, permit) + .map_err(|_| DocumentError::RecoveryRequired)?; + let mut current = self + .inner + .repositories + .preferences + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let object = current + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + object.insert( + "codingAgentEnabled".into(), + Value::Bool(local.coding_agent_enabled), + ); + object.insert( + "activeAsrProvider".into(), + Value::String(local.active_asr_provider), + ); + object.insert( + "activeLlmProvider".into(), + Value::String(local.active_llm_provider), + ); + object.insert( + "activeOmniProvider".into(), + Value::String(local.active_omni_provider), + ); + self.inner + .repositories + .preferences + .sync_replace_raw(current, permit) + .map_err(|_| DocumentError::RecoveryRequired) + } + + async fn preflight_native( + &self, + scope: &SyncScope, + desired: &ValidatedSyncDocuments, + permit: &ExclusivePermit, + ) -> DocumentResult { + let mut state = ScopeState::decode( + scope, + self.inner.extensions.read_scope(scope.clone()).await?, + )?; + let current = self + .inner + .repositories + .preferences + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let mut preferences = current.clone(); + let map = preferences + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + // Unknown preferences have an encrypted home; never spread future keys into native files. + map.retain(|key, _| preference_field(key).is_some()); + let mut retained = Vec::new(); + let mut ui = serde_json::Map::new(); + let mut channels = Vec::new(); + let mut credentials = Vec::new(); + let mut windows = Vec::::new(); + let mut styles = Vec::::new(); + let mut activity = Vec::::new(); + let mut preset_records = Vec::::new(); + let docs = &desired.documents().documents; + for doc in docs { + match doc.kind { + DocumentKind::Preferences => match preference_field(&doc.id) { + Some(field) if field.class == PreferenceClass::Portable => { + map.insert(doc.id.clone(), doc.value.clone()); + } + None => retained.push(doc.clone()), + _ => return Err(DocumentError::ExcludedField), + }, + DocumentKind::UiPreferences + if matches!(doc.id.as_str(), "locale" | "fontScale") => + { + ui.insert(doc.id.clone(), doc.value.clone()); + } + DocumentKind::UiPreferences => retained.push(doc.clone()), + DocumentKind::Channels => channels.push(typed(&doc.value)?), + DocumentKind::ProviderCredentials => credentials.push(typed(&doc.value)?), + DocumentKind::DeviceProfile if doc.id == self.inner.device.id => { + let profile: DeviceProfileRecord = typed(&doc.value)?; + if profile.device.os != self.inner.device.os + || profile.device.arch != self.inner.device.arch + { + return Err(DocumentError::Unsupported); + } + for (key, value) in profile + .preferences + .expose() + .as_object() + .ok_or(DocumentError::InvalidDocument)? + { + if preference_field(key) + .is_none_or(|field| field.class != PreferenceClass::DeviceProfile) + { + return Err(DocumentError::Unsupported); + } + map.insert(key.clone(), value.clone()); + } + channels.extend(profile.channels); + credentials.extend(profile.provider_credentials); + windows = profile.window_positions; + } + DocumentKind::DeviceProfile => retained.push(doc.clone()), + DocumentKind::StylePacks => { + let record: StylePackRecord = typed(&doc.value)?; + let pack: crate::style_packs::StylePack = typed(record.pack.expose())?; + let known_builtin = crate::style_packs::builtin_style_packs() + .iter() + .any(|builtin| builtin.id == pack.id); + if known_builtin != (pack.kind == crate::style_packs::StylePackKind::Builtin) { + return Err(DocumentError::Unsupported); + } + if let Some(icon) = &record.icon { + validate_icon(icon)?; + } + styles.push(record); + } + DocumentKind::Activity => { + let record: ActivityRecord = typed(&doc.value)?; + u32::try_from(record.count).map_err(|_| DocumentError::Unsupported)?; + activity.push(record); + } + DocumentKind::VocabularyPresets => preset_records.push(typed(&doc.value)?), + DocumentKind::Dictionary | DocumentKind::Corrections | DocumentKind::History => {} + } + } + if styles.is_empty() + || !styles.iter().any(|style| { + style.pack.expose().get("enabled").and_then(Value::as_bool) == Some(true) + }) + { + return Err(DocumentError::InvalidReference); + } + // Restored executable/workspace data never silently activates an existing permission. + if [ + "codingAgentProvider", + "codingAgentModel", + "codingAgentPermissionMode", + "codingAgentExe", + "codingAgentWorkdir", + ] + .iter() + .any(|key| current.get(key) != map.get(*key)) + { + map.insert("codingAgentEnabled".into(), Value::Bool(false)); + } + validate_credential_set(&channels, &credentials)?; + for (namespace, key) in [ + (SyncNamespace::Asr, "activeAsrProvider"), + (SyncNamespace::Llm, "activeLlmProvider"), + (SyncNamespace::Omni, "activeOmniProvider"), + ] { + let active = channels + .iter() + .find(|channel| channel.namespace == namespace && channel.active) + .map(|channel| channel.id.clone()) + .unwrap_or_default(); + map.insert(key.into(), Value::String(active)); + } + let _: crate::shared_types::UserPreferences = typed(&preferences)?; + if !ui.contains_key("locale") || !ui.contains_key("fontScale") { + return Err(DocumentError::Unsupported); + } + preset_records.sort_by_key(|record| (record.order, record.id.clone())); + let builtin_ids: Vec<_> = crate::vocabulary::builtin_vocab_presets() + .into_iter() + .map(|item| item.id) + .collect(); + let mut presets = VocabPresetStore::default(); + for record in preset_records { + match record.origin { + PresetOrigin::BuiltinState => { + if !builtin_ids.contains(&record.id) { + return Err(DocumentError::Unsupported); + } + if !record.enabled { + presets.disabled_builtin_preset_ids.push(record.id); + } + } + origin => { + if !record.enabled { + return Err(DocumentError::Unsupported); + } + let preset = VocabPreset { + id: record.id, + name: record.name.ok_or(DocumentError::InvalidDocument)?, + phrases: record.phrases, + }; + if origin == PresetOrigin::Custom { + presets.custom.push(preset); + } else { + presets.overrides.push(preset); + } + } + } + } + // Availability is local metadata, never imported from the cloud. Existing records + // keep the receiver's own marker; newly restored records never claim remote media. + let local_history = self + .inner + .repositories + .history + .sync_snapshot(permit) + .map_err(|_| DocumentError::CaptureFailed)?; + let local_media: std::collections::BTreeMap<_, _> = local_history + .iter() + .map(|record| (record.id.as_str(), record.has_audio_recording)) + .collect(); + let mut history: Vec = ordered(docs, DocumentKind::History)?; + for record in &mut history { + record.has_audio_recording = local_media + .get(record.id.as_str()) + .copied() + .unwrap_or(Some(false)); + } + state.retained_documents = retained; + state.tombstones = desired.documents().tombstones.clone(); + state.observed_revision = desired.observed_revision(); + Ok(NativeRestore { + preferences, + ui: SecretJson::new(Value::Object(ui)), + windows: SecretJson::from_serializable(&windows)?, + credentials: SyncCredentials { + channels, + credentials, + }, + dictionary: ordered(docs, DocumentKind::Dictionary)?, + corrections: ordered(docs, DocumentKind::Corrections)?, + history, + presets, + styles, + activity, + state, + }) + } + + pub(super) async fn validate_native( + &self, + scope: &SyncScope, + desired: &ValidatedSyncDocuments, + permit: &ExclusivePermit, + ) -> DocumentResult<()> { + self.preflight_native(scope, desired, permit) + .await + .map(|_| ()) + } + + pub(super) async fn replace_native( + &self, + scope: &SyncScope, + desired: &ValidatedSyncDocuments, + permit: &ExclusivePermit, + ) -> DocumentResult<()> { + let prepared = self.preflight_native(scope, desired, permit).await?; + let repositories = &self.inner.repositories; + // The durable before/after journal and recovery marker already exist here. + repositories + .vocabulary + .sync_replace_all(&prepared.dictionary, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repositories + .correction_rules + .sync_replace_all(&prepared.corrections, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repositories + .history + .sync_replace_all(&prepared.history, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repositories + .activity + .sync_replace_records(&prepared.activity, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + crate::vocabulary::save_vocab_presets_for_sync( + &self.inner.data_dir, + &prepared.presets, + permit, + ) + .map_err(|_| DocumentError::CaptureFailed)?; + repositories + .style_packs + .sync_replace_all(&prepared.styles, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + repositories + .preferences + .sync_replace_raw(prepared.preferences, permit) + .map_err(|_| DocumentError::CaptureFailed)?; + self.inner + .credentials + .replace_sync_credentials(prepared.credentials, permit) + .await + .map_err(|_| DocumentError::CaptureFailed)?; + self.inner + .extensions + .write_device(DeviceExtensionKey::UiPreferences, prepared.ui) + .await?; + self.inner + .extensions + .write_device(DeviceExtensionKey::WindowPositions, prepared.windows) + .await?; + self.inner + .extensions + .write_scope(scope.clone(), prepared.state.secret_json()?) + .await + } +} 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 new file mode 100644 index 000000000..3b743ef01 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/state.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::cloud_sync_e2ee_documents::{DocumentError, DocumentResult, SecretJson, SyncScope}; +use crate::cloud_sync_e2ee_protocol::types::{DocumentSet, LogicalDocument, Revision, Tombstone}; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct ScopeState { + pub schema_version: u32, + pub scope_id: String, + pub observed_revision: Revision, + pub baseline_revision: Revision, + pub baseline: Option, + pub retained_documents: Vec, + pub tombstones: Vec, +} + +impl ScopeState { + pub fn empty(scope: &SyncScope) -> DocumentResult { + Ok(Self { + schema_version: 1, + scope_id: scope_id(scope)?, + observed_revision: Revision::new(0), + baseline_revision: Revision::new(0), + baseline: None, + retained_documents: Vec::new(), + tombstones: Vec::new(), + }) + } + pub fn decode(scope: &SyncScope, value: Option) -> DocumentResult { + let Some(value) = value else { + return Self::empty(scope); + }; + let state: Self = serde_json::from_value(value.expose().clone()) + .map_err(|_| DocumentError::RecoveryRequired)?; + if state.schema_version != 1 || state.scope_id != scope_id(scope)? { + return Err(DocumentError::RecoveryRequired); + } + Ok(state) + } + pub fn secret_json(&self) -> DocumentResult { + SecretJson::from_serializable(self) + } +} + +/// Cloud key rotation changes encryption, not the account/vault's local logical baseline. +pub fn scope_id(scope: &SyncScope) -> DocumentResult { + let bytes = serde_json::to_vec(&( + "openless-local-documents-v1", + &scope.service_origin, + &scope.owner_github_id, + &scope.vault_id, + &scope.device_id, + )) + .map_err(|_| DocumentError::InvalidDocument)?; + Ok(format!("{:x}", Sha256::digest(bytes))) +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs new file mode 100644 index 000000000..73a2098d4 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs @@ -0,0 +1,1659 @@ +use super::*; +use crate::cloud_sync_e2ee_documents::*; +use crate::cloud_sync_e2ee_protocol::{ + crypto::DerivedKey, + types::{DocumentKind, LogicalDocument}, +}; +use crate::credentials::{CredentialKey, SecretValue, SyncCredentials}; +use crate::{BackendError, BackendErrorCode}; +use futures_util::future::BoxFuture; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, VecDeque}; +use std::path::PathBuf; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, +}; +use zeroize::Zeroizing; + +struct Temp(PathBuf); +impl Temp { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("openless-sync-store-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } +} +impl Drop for Temp { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[derive(Clone)] +struct TestCredentials { + value: Arc>, + failures: Arc>>, + read_barrier: Arc>>>, + bound_gate: Arc>>>, +} +fn unsupported() -> BoxFuture<'static, Result> { + Box::pin(async { + Err(BackendError::new( + BackendErrorCode::Unsupported, + "fixture unused", + )) + }) +} +impl CredentialStore for TestCredentials { + fn bind_sync_gate(&self, gate: Arc) -> Result<(), BackendError> { + let mut bound = self.bound_gate.lock().unwrap(); + if bound + .as_ref() + .is_some_and(|existing| !Arc::ptr_eq(existing, &gate)) + { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "different sync gate", + )); + } + *bound = Some(gate); + Ok(()) + } + fn status( + &self, + _: crate::shared_types::UserPreferences, + ) -> BoxFuture<'static, Result> { + unsupported() + } + fn read( + &self, + _: CredentialKey, + ) -> BoxFuture<'static, Result, BackendError>> { + unsupported() + } + fn write( + &self, + _: CredentialKey, + _: SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + unsupported() + } + fn remove(&self, _: CredentialKey) -> BoxFuture<'static, Result<(), BackendError>> { + unsupported() + } + fn export_sync_credentials_readonly( + &self, + ) -> BoxFuture<'static, Result> { + let this = self.clone(); + Box::pin(async move { + if this.bound_gate.lock().unwrap().is_none() { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "unbound sync gate", + )); + } + let value = this.value.lock().unwrap().clone(); + let barrier = this.read_barrier.lock().unwrap().clone(); + if let Some(barrier) = barrier { + barrier.wait().await; + barrier.wait().await; + } + Ok(value) + }) + } + fn export_sync_credentials( + &self, + permit: &ExclusivePermit, + ) -> BoxFuture<'static, Result> { + let valid = self + .bound_gate + .lock() + .unwrap() + .as_ref() + .is_some_and(|gate| permit.belongs_to(gate)); + let value = self.value.lock().unwrap().clone(); + Box::pin(async move { + if !valid { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "unbound sync gate", + )); + } + Ok(value) + }) + } + fn replace_sync_credentials( + &self, + value: SyncCredentials, + permit: &ExclusivePermit, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let valid = self + .bound_gate + .lock() + .unwrap() + .as_ref() + .is_some_and(|gate| permit.belongs_to(gate)); + let this = self.clone(); + Box::pin(async move { + if !valid { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "unbound sync gate", + )); + } + if this.failures.lock().unwrap().pop_front().unwrap_or(false) { + return Err(BackendError::new( + BackendErrorCode::Persistence, + "fixture credential failure", + )); + } + *this.value.lock().unwrap() = value; + Ok(()) + }) + } +} +struct Extensions { + values: Mutex>, + protector: Arc, + key_requested: AtomicBool, + ui_revision: Mutex>, +} +impl ProtectedExtensionStore for Extensions { + fn read_scope(&self, scope: SyncScope) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + Ok(self + .values + .lock() + .unwrap() + .get(&state::scope_id(&scope)?) + .cloned()) + }) + } + fn write_scope( + &self, + scope: SyncScope, + value: SecretJson, + ) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + self.values + .lock() + .unwrap() + .insert(state::scope_id(&scope)?, value); + Ok(()) + }) + } + fn read_device( + &self, + key: DeviceExtensionKey, + ) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { Ok(self.values.lock().unwrap().get(key.storage_name()).cloned()) }) + } + fn write_device( + &self, + key: DeviceExtensionKey, + value: SecretJson, + ) -> BoxFuture<'_, DocumentResult<()>> { + Box::pin(async move { + self.values + .lock() + .unwrap() + .insert(key.storage_name().into(), value); + if key == DeviceExtensionKey::UiPreferences { + *self.ui_revision.lock().unwrap() = Some(uuid::Uuid::new_v4().to_string()); + } + Ok(()) + }) + } + fn read_ui_revision(&self) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { Ok(self.ui_revision.lock().unwrap().clone()) }) + } + fn journal_protector(&self) -> BoxFuture<'_, DocumentResult>> { + Box::pin(async move { + self.key_requested.store(true, Ordering::Release); + Ok(Arc::clone(&self.protector)) + }) + } +} +#[derive(Clone, Default)] +struct TestEffects { + targets: Arc>>, + failures: Arc>>, + wait: Arc>>>, + gate: Arc>>>, +} +impl crate::config::RestoreRuntimeEffects for TestEffects { + fn apply_target( + &self, + target: crate::shared_types::UserPreferences, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let this = self.clone(); + Box::pin(async move { + let gate = this + .gate + .lock() + .unwrap() + .as_ref() + .and_then(std::sync::Weak::upgrade) + .unwrap(); + assert!( + gate.try_exclusive().is_err(), + "effects must remain inside the exclusive restore fence" + ); + assert!( + gate.begin_mutation().is_err(), + "ordinary writes must remain blocked during effects" + ); + this.targets.lock().unwrap().push(target); + let barrier = this.wait.lock().unwrap().clone(); + if let Some(barrier) = barrier { + barrier.wait().await; + barrier.wait().await; + } + if this.failures.lock().unwrap().pop_front().unwrap_or(false) { + Err(BackendError::new( + BackendErrorCode::Platform, + "fixture effects failure", + )) + } else { + Ok(()) + } + }) + } +} + +struct Fixture { + _temp: Temp, + store: CoreSyncStore, + repo: BackendRepositories, + credentials: TestCredentials, + extensions: Arc, + scope: SyncScope, + effects: TestEffects, +} +impl Fixture { + fn new() -> Self { + let temp = Temp::new(); + let gate = gate::open_for_data_dir(&temp.0).unwrap(); + let repo = BackendRepositories::open(&temp.0).unwrap(); + let credentials = TestCredentials { + value: Arc::new(Mutex::new(SyncCredentials { + channels: vec![], + credentials: vec![], + })), + failures: Arc::new(Mutex::new(VecDeque::new())), + read_barrier: Arc::new(Mutex::new(None)), + bound_gate: Arc::new(Mutex::new(None)), + }; + let extensions = Arc::new(Extensions { + values: Mutex::new(BTreeMap::from([( + "sync-ui-preferences".into(), + ui_preferences("en", "medium"), + )])), + protector: Arc::new(CryptoJournalProtector::new(Arc::new( + DerivedKey::from_secret_bytes(Zeroizing::new([37; 32])), + ))), + key_requested: AtomicBool::new(false), + ui_revision: Mutex::new(Some(uuid::Uuid::new_v4().to_string())), + }); + let device = SourceDevice { + id: "fixture-device".into(), + os: "macos".into(), + arch: "aarch64".into(), + app_version: "2.0.0-Beta.3".into(), + }; + let scope = SyncScope { + service_origin: "https://sync.example.test".into(), + owner_github_id: "42".into(), + vault_id: uuid::Uuid::new_v4().to_string(), + key_id: uuid::Uuid::new_v4().to_string(), + device_id: device.id.clone(), + }; + let store = CoreSyncStore::new( + repo.clone(), + Arc::new(credentials.clone()), + temp.0.clone(), + device, + gate, + extensions.clone(), + ) + .unwrap(); + store.bind_runtime_idle_probe(Arc::new(|| true)).unwrap(); + let effects = TestEffects::default(); + *effects.gate.lock().unwrap() = Some(Arc::downgrade(&store.inner.gate)); + store + .bind_runtime_effects(Arc::new(effects.clone())) + .unwrap(); + Self { + _temp: temp, + store, + repo, + credentials, + extensions, + scope, + effects, + } + } + fn reopened(self) -> Self { + let Self { + _temp, + store, + repo, + credentials, + extensions, + scope, + effects, + } = self; + let device = store.device(); + drop(store); + drop(repo); + // Stand-in for reopening the persistent OS vault, not retaining the old process gate. + credentials.bound_gate.lock().unwrap().take(); + let gate = gate::open_for_data_dir(&_temp.0).unwrap(); + let repo = BackendRepositories::open(&_temp.0).unwrap(); + let store = CoreSyncStore::new( + repo.clone(), + Arc::new(credentials.clone()), + _temp.0.clone(), + device, + gate, + extensions.clone(), + ) + .unwrap(); + store.bind_runtime_idle_probe(Arc::new(|| true)).unwrap(); + *effects.gate.lock().unwrap() = Some(Arc::downgrade(&store.inner.gate)); + store + .bind_runtime_effects(Arc::new(effects.clone())) + .unwrap(); + Self { + _temp, + store, + repo, + credentials, + extensions, + scope, + effects, + } + } + + fn context(&self, export: &ExportedDocuments) -> RestoreContext { + RestoreContext { + scope: self.scope.clone(), + operation_id: uuid::Uuid::new_v4().to_string(), + observed_revision: Revision::new(3), + local_generation: export.generation, + target_device: self.store.device(), + } + } +} +fn set_doc(set: &mut DocumentSet, kind: DocumentKind, id: &str, value: Value) { + if let Some(old) = set + .documents + .iter_mut() + .find(|doc| doc.kind == kind && doc.id == id) + { + old.value = value; + } else { + set.documents.push(LogicalDocument { + id: id.into(), + kind, + schema_version: 1, + value, + }); + } +} +fn validate(set: DocumentSet) -> ValidatedSyncDocuments { + validate_sync_documents(set, Revision::new(3)).unwrap() +} + +#[tokio::test] +async fn disabled_fresh_start_never_requests_local_key() { + let fixture = Fixture::new(); + fixture.store.recover_registered().await.unwrap(); + assert!(!fixture.extensions.key_requested.load(Ordering::Acquire)); +} + +#[tokio::test] +async fn complete_native_restore_keeps_all_history_order_unknown_preferences_and_activity_provenance( +) { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + set_doc( + &mut desired, + DocumentKind::Preferences, + "futureSetting", + json!({"nested":[1,2,3]}), + ); + set_doc( + &mut desired, + DocumentKind::UiPreferences, + "locale", + json!("zh-CN"), + ); + for index in 0..205 { + let id = format!("history-{index:03}"); + set_doc( + &mut desired, + DocumentKind::History, + &id, + json!({"id":id,"createdAt":"2026-09-26T00:00:00Z","source":"quick_note","rawTranscript":format!("fixture {index}"),"finalText":format!("fixture {index}"),"mode":"raw","insertStatus":"notRequested","hasAudioRecording":false,"sortIndex":204-index}), + ); + } + for (source, count) in [("fixture-device", 4), ("other-device", 7)] { + set_doc( + &mut desired, + DocumentKind::Activity, + &format!("{source}:2026-09-26"), + json!({"sourceDeviceId":source,"date":"2026-09-26","count":count,"chars":10,"durationMs":20}), + ); + } + let expected = native::canonicalize_native_documents(validate(desired)).unwrap(); + let result = fixture + .store + .restore_scope(expected.clone(), fixture.context(&before)) + .await + .unwrap(); + assert_eq!( + result.documents.documents().documents, + expected.documents().documents + ); + assert_eq!(result.generation.get(), before.generation.get() + 1); + let history = fixture.repo.history.list().unwrap(); + assert_eq!(history.len(), 205); + assert_eq!(history[0].id, "history-204"); + assert_eq!(fixture.repo.activity.snapshot().unwrap()[0].count, 11); + let raw: Value = + serde_json::from_slice(&std::fs::read(fixture._temp.0.join("preferences.json")).unwrap()) + .unwrap(); + assert!( + raw.get("futureSetting").is_none(), + "unknown remote preference stays in encrypted extension" + ); + let again = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(again + .documents + .documents() + .documents + .iter() + .any(|doc| doc.id == "futureSetting")); + assert!(fixture + .store + .inner + .gate + .registered_recovery_scopes() + .unwrap() + .is_empty()); +} + +#[tokio::test] +async fn real_files_roll_back_after_credential_failure_and_recover_if_rollback_also_fails() { + for failures in [vec![true, false], vec![true, true, false]] { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + fixture + .credentials + .failures + .lock() + .unwrap() + .extend(failures.clone()); + let result = fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await; + assert_eq!( + result.unwrap_err(), + if failures.len() == 2 { + DocumentError::RestoreRolledBack + } else { + DocumentError::RecoveryRequired + } + ); + if failures.len() == 3 { + assert!(fixture.store.is_recovering()); + let journal = std::fs::read_dir(fixture._temp.0.join("encrypted-sync")) + .unwrap() + .filter_map(Result::ok) + .find(|entry| entry.file_name().to_string_lossy().starts_with("restore-")) + .unwrap(); + let bytes = std::fs::read(journal.path()).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("themeMode")); + fixture.store.recover_registered().await.unwrap(); + } + let after = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert_eq!( + after.documents.documents().documents, + before.documents.documents().documents + ); + assert_eq!(after.generation, before.generation); + assert!(!fixture.store.is_recovering()); + } +} + +#[tokio::test] +async fn optimistic_capture_does_not_block_writes_and_discards_a_mixed_snapshot() { + let fixture = Fixture::new(); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + *fixture.credentials.read_barrier.lock().unwrap() = Some(barrier.clone()); + let store = fixture.store.clone(); + let scope = fixture.scope.clone(); + let capture = tokio::spawn(async move { store.export_scope(scope).await }); + barrier.wait().await; + fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = !prefs.show_capsule) + .unwrap(); + barrier.wait().await; + assert_eq!( + capture.await.unwrap().unwrap_err(), + DocumentError::SourceChanged + ); +} + +#[test] +fn every_preference_save_preserves_top_level_and_nested_unknown_values() { + let fixture = Fixture::new(); + let path = fixture._temp.0.join("preferences.json"); + let mut raw: Value = serde_json::to_value(fixture.repo.preferences.get()).unwrap(); + raw["futureSetting"] = json!({"x":9}); + raw["customStylePrompts"]["futureMode"] = json!("keep"); + std::fs::write(&path, serde_json::to_vec(&raw).unwrap()).unwrap(); + fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = !prefs.show_capsule) + .unwrap(); + fixture + .repo + .preferences + .set_preserving_current_style_preferences(fixture.repo.preferences.get()) + .unwrap(); + let saved: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(saved["futureSetting"], raw["futureSetting"]); + assert_eq!(saved["customStylePrompts"]["futureMode"], "keep"); +} + +#[test] +fn unknown_nested_rows_abort_ordinary_mutation_without_writing_or_dirty_generation() { + let fixture = Fixture::new(); + let generation = fixture.store.generation().unwrap(); + let path = fixture._temp.0.join("style-packs.json"); + let mut styles: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + styles[0]["examples"] = json!([{"input":"a","output":"b","future":{"x":1}}]); + let bytes = serde_json::to_vec(&styles).unwrap(); + std::fs::write(&path, &bytes).unwrap(); + let id = styles[0]["id"].as_str().unwrap(); + assert_eq!( + fixture + .repo + .style_packs + .set_enabled(id, false) + .unwrap_err() + .code, + BackendErrorCode::Unsupported + ); + assert_eq!(std::fs::read(path).unwrap(), bytes); + assert_eq!(fixture.store.generation().unwrap(), generation); + let activity = fixture._temp.0.join("activity.json"); + let bytes = + br#"{"2026-09-26":{"count":1,"sourceContributions":{"device":{"count":1,"future":2}}}}"#; + std::fs::write(&activity, bytes).unwrap(); + assert_eq!( + fixture + .repo + .activity + .bump("2026-09-26", 1, 2) + .unwrap_err() + .code, + BackendErrorCode::Unsupported + ); + assert_eq!(std::fs::read(activity).unwrap(), bytes); +} + +#[tokio::test] +async fn baseline_updates_do_not_erase_newer_local_edits_or_tombstones() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = !prefs.show_capsule) + .unwrap(); + let current = fixture.repo.preferences.get().show_capsule; + fixture + .store + .record_baseline( + fixture.scope.clone(), + before.documents.documents().clone(), + Revision::new(3), + ) + .await + .unwrap(); + assert_eq!(fixture.repo.preferences.get().show_capsule, current); + let after = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(after.generation > before.generation); +} + +fn png() -> Vec { + use base64::Engine; + base64::engine::general_purpose::STANDARD.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3c3b0AAAAASUVORK5CYII=").unwrap() +} + +#[test] +fn failed_style_save_never_changes_live_cache() { + let fixture = Fixture::new(); + let before = fixture.repo.style_packs.get("builtin.light").unwrap(); + let mut next = before.clone(); + next.name = "new title".into(); + let generation = fixture.store.generation().unwrap(); + crate::persistence::fail_next_atomic_write(&fixture._temp.0.join("style-packs.json"), false); + assert!(fixture.repo.style_packs.update(next).is_err()); + assert_eq!( + fixture.repo.style_packs.get("builtin.light").unwrap(), + before + ); + assert_eq!(fixture.store.generation().unwrap(), generation); + assert!(!fixture.store.is_recovering()); +} + +#[tokio::test] +async fn uncertain_style_icon_and_import_commits_keep_their_referenced_assets() { + for import in [false, true] { + let fixture = Fixture::new(); + let pack = fixture + .repo + .style_packs + .update_icon("builtin.light", Some(&png())) + .unwrap(); + let original = pack.icon_path.unwrap(); + let archive = fixture + .repo + .style_packs + .export_zip_bytes("builtin.light") + .unwrap(); + crate::persistence::fail_next_atomic_write(&fixture._temp.0.join("style-packs.json"), true); + let error = if import { + fixture + .repo + .style_packs + .import_from_zip_bytes(&archive) + .unwrap_err() + } else { + fixture + .repo + .style_packs + .update_icon("builtin.light", Some(&png())) + .unwrap_err() + }; + assert_eq!(error.code, BackendErrorCode::OutcomeUnknown); + assert!(fixture.store.is_recovering()); + let persisted: Vec = serde_json::from_slice( + &std::fs::read(fixture._temp.0.join("style-packs.json")).unwrap(), + ) + .unwrap(); + for pack in &persisted { + if let Some(path) = &pack.icon_path { + assert!(std::path::Path::new(path).is_file()); + } + } + assert!(std::path::Path::new(&original).is_file()); + fixture.store.recover_registered().await.unwrap(); + assert!(!fixture.store.is_recovering()); + assert_eq!(fixture.repo.style_packs.list().unwrap(), persisted); + } +} + +#[tokio::test] +async fn runtime_probe_rejects_restore_before_any_file_changes() { + let fixture = Fixture::new(); + *fixture.store.inner.runtime_idle.lock().unwrap() = Some(Arc::new(|| false)); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + let result = fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await; + assert_eq!(result.unwrap_err(), DocumentError::RuntimeBusy); + assert!(!fixture.store.is_recovering()); + assert_eq!(fixture.store.generation().unwrap(), before.generation); + assert!(!fixture.extensions.key_requested.load(Ordering::Acquire)); +} + +#[tokio::test] +async fn activity_restore_beyond_rolling_retention_keeps_every_source_record() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + let first = chrono::NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(); + for index in 0..733 { + let date = (first + chrono::Days::new(index)).to_string(); + set_doc( + &mut desired, + DocumentKind::Activity, + &format!("fixture-device:{date}"), + json!({"sourceDeviceId":"fixture-device","date":date,"count":1,"chars":2,"durationMs":3}), + ); + } + fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap(); + assert_eq!(fixture.repo.activity.snapshot().unwrap().len(), 733); +} + +#[tokio::test] +async fn all_eleven_kinds_restore_stable_ids_credentials_icons_presets_and_foreign_profiles() { + use base64::Engine; + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + set_doc( + &mut desired, + DocumentKind::UiPreferences, + "locale", + json!("ja"), + ); + set_doc( + &mut desired, + DocumentKind::Channels, + "llm:stable-llm", + json!({"id":"stable-llm","namespace":"llm","providerType":"requesty","name":"Fixture API","enabled":true,"order":0,"active":true}), + ); + set_doc( + &mut desired, + DocumentKind::ProviderCredentials, + "llm:stable-llm", + json!({"channelId":"stable-llm","namespace":"llm","accounts":{"ark.api_key":"fixture-private-key","ark.endpoint":"https://router.requesty.ai/v1","ark.model_id":"fixture-model"}}), + ); + set_doc( + &mut desired, + DocumentKind::Dictionary, + "stable-word", + json!({"id":"stable-word","phrase":"OpenLess fixture","note":"fixture note","enabled":true,"hits":2,"createdAt":"2026-09-26T00:00:00Z","sortIndex":0}), + ); + set_doc( + &mut desired, + DocumentKind::Corrections, + "stable-rule", + json!({"id":"stable-rule","pattern":"opnless","replacement":"OpenLess","enabled":true,"createdAt":"2026-09-26T00:00:00Z","source":"learned","sortIndex":0}), + ); + set_doc( + &mut desired, + DocumentKind::VocabularyPresets, + "custom:stable-preset", + json!({"id":"stable-preset","origin":"custom","name":"Private words","phrases":["one","two"],"enabled":true,"order":0}), + ); + let builtin = crate::vocabulary::builtin_vocab_presets() + .into_iter() + .next() + .unwrap() + .id; + set_doc( + &mut desired, + DocumentKind::VocabularyPresets, + &format!("override:{builtin}"), + json!({"id":builtin,"origin":"override","name":"Override words","phrases":["three"],"enabled":true,"order":0}), + ); + let icon = base64::engine::general_purpose::STANDARD.encode(png()); + let style = desired + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::StylePacks && doc.id == "builtin.light") + .unwrap(); + style.value["icon"] = json!({"mime":"image/png","base64":icon}); + set_doc( + &mut desired, + DocumentKind::History, + "stable-history", + json!({"id":"stable-history","createdAt":"2026-09-26T00:00:00Z","source":"quick_note","rawTranscript":"Fixture note","finalText":"Fixture note","mode":"raw","insertStatus":"notRequested","hasAudioRecording":false,"sortIndex":0}), + ); + set_doc( + &mut desired, + DocumentKind::Activity, + "foreign-device:2026-09-26", + json!({"sourceDeviceId":"foreign-device","date":"2026-09-26","count":2,"chars":4,"durationMs":8}), + ); + set_doc( + &mut desired, + DocumentKind::DeviceProfile, + "foreign-device", + json!({"device":{"id":"foreign-device","os":"windows","arch":"x86_64","appVersion":"2.0.0-Beta.3"},"preferences":{"microphoneDeviceName":"foreign microphone","codingAgentExe":"C:\\fixture\\do-not-run.exe"},"channels":[{"id":"foreign-local","namespace":"asr","providerType":"foundry-local-whisper","name":"Foreign local model","enabled":true,"order":0,"active":true}],"providerCredentials":[{"channelId":"foreign-local","namespace":"asr","accounts":{"asr.model":"fixture-local-model"}}],"windowPositions":[]}), + ); + let desired = native::canonicalize_native_documents(validate(desired)).unwrap(); + let result = fixture + .store + .restore_scope(desired.clone(), fixture.context(&before)) + .await + .unwrap(); + assert_eq!( + result.documents.documents().documents, + desired.documents().documents + ); + let kinds = result + .documents + .documents() + .documents + .iter() + .map(|doc| doc.kind) + .collect::>(); + assert_eq!(kinds.len(), 11); + let credentials = fixture.credentials.value.lock().unwrap(); + assert_eq!(credentials.channels.len(), 1); + assert_eq!(credentials.channels[0].id, "stable-llm"); + assert_eq!( + credentials.credentials[0].accounts["ark.api_key"], + "fixture-private-key" + ); + drop(credentials); + assert_ne!( + fixture.repo.preferences.get().microphone_device_name, + "foreign microphone" + ); + assert_eq!(fixture.repo.vocabulary.list().unwrap()[0].id, "stable-word"); + assert_eq!( + fixture.repo.correction_rules.list().unwrap()[0].id, + "stable-rule" + ); + let presets = crate::vocabulary::list_vocab_presets(&fixture._temp.0).unwrap(); + assert_eq!(presets.custom[0].id, "stable-preset"); + assert_eq!(presets.overrides[0].phrases, vec!["three"]); + assert!(fixture + .repo + .style_packs + .icon_data_url("builtin.light") + .unwrap() + .is_some()); +} + +#[tokio::test] +async fn deletion_ledger_and_upload_baseline_are_isolated_by_account_vault_scope() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Dictionary, + "deleted-word", + json!({"id":"deleted-word","phrase":"remove me","note":null,"enabled":true,"hits":0,"createdAt":"2026-09-26T00:00:00Z","sortIndex":0}), + ); + let seeded = fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap(); + fixture + .store + .record_baseline( + fixture.scope.clone(), + seeded.documents.documents().clone(), + Revision::new(3), + ) + .await + .unwrap(); + fixture.repo.vocabulary.remove("deleted-word").unwrap(); + let deleted = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(deleted + .documents + .documents() + .tombstones + .iter() + .any(|mark| mark.id == "deleted-word")); + let mut other = fixture.scope.clone(); + other.owner_github_id = "43".into(); + other.vault_id = uuid::Uuid::new_v4().to_string(); + let fresh = fixture.store.export_scope(other).await.unwrap(); + assert!(fresh.documents.documents().tombstones.is_empty()); + fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = !prefs.show_capsule) + .unwrap(); + fixture + .store + .record_baseline( + fixture.scope.clone(), + deleted.documents.documents().clone(), + Revision::new(4), + ) + .await + .unwrap(); + let later = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(later + .documents + .documents() + .tombstones + .iter() + .any(|mark| mark.id == "deleted-word")); + assert!(later.generation > deleted.generation); +} + +#[tokio::test] +async fn ui_compare_and_swap_rejects_an_old_mirror_after_restore_and_preserves_generation() { + let fixture = Fixture::new(); + let old = fixture.extensions.read_ui_revision().await.unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::UiPreferences, + "locale", + json!("ja"), + ); + let restored = fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap(); + let result = fixture + .store + .set_ui_preferences_checked(ui_preferences("en", "large"), old) + .await; + assert_eq!(result.unwrap_err(), DocumentError::StalePreview); + assert_eq!(fixture.store.generation().unwrap(), restored.generation); + assert_eq!( + fixture + .extensions + .read_device(DeviceExtensionKey::UiPreferences) + .await + .unwrap() + .unwrap() + .expose()["locale"], + "ja" + ); + assert_eq!( + fixture + .store + .set_ui_preferences_checked(ui_preferences("de", "small"), None) + .await + .unwrap_err(), + DocumentError::StalePreview + ); + fixture + .store + .set_ui_preferences_checked( + ui_preferences("ja", "large"), + fixture.extensions.read_ui_revision().await.unwrap(), + ) + .await + .unwrap(); + assert_eq!( + fixture.store.generation().unwrap().get(), + restored.generation.get() + 1 + ); +} + +#[test] +fn preference_array_extensions_follow_stable_identity_across_ordinary_updates() { + let fixture = Fixture::new(); + let path = fixture._temp.0.join("preferences.json"); + let mut raw = serde_json::to_value(fixture.repo.preferences.get()).unwrap(); + raw["stylePackHotkeys"] = json!([{"packId":"builtin.light","binding":{"primary":"L","modifiers":["ctrl"],"futureBinding":true},"futureHotkey":{"x":1}}]); + std::fs::write(&path, serde_json::to_vec(&raw).unwrap()).unwrap(); + fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = !prefs.show_capsule) + .unwrap(); + let saved: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(saved["stylePackHotkeys"][0]["futureHotkey"], json!({"x":1})); + assert_eq!( + saved["stylePackHotkeys"][0]["binding"]["futureBinding"], + true + ); +} + +#[test] +fn pending_restore_constructors_never_salvage_or_normalize_original_files() { + let temp = Temp::new(); + let gate = gate::open_for_data_dir(&temp.0).unwrap(); + let permit = gate.try_exclusive().unwrap(); + let scope = SyncScope { + service_origin: "https://sync.example.test".into(), + owner_github_id: "42".into(), + vault_id: uuid::Uuid::new_v4().to_string(), + key_id: uuid::Uuid::new_v4().to_string(), + device_id: "fixture-device".into(), + }; + permit + .mark_restore_pending( + &uuid::Uuid::new_v4().to_string(), + &state::scope_id(&scope).unwrap(), + &scope, + ) + .unwrap(); + let preferences = temp.0.join("preferences.json"); + let styles = temp.0.join("style-packs.json"); + std::fs::write( + &preferences, + br#"{"defaultMode":"unknown-future-mode","streamingInsert":false}"#, + ) + .unwrap(); + std::fs::write(&styles, b"[]").unwrap(); + let before = std::fs::read(&preferences).unwrap(); + let _prefs = crate::preferences::PreferencesStore::open(&preferences).unwrap(); + let style_store = crate::style_pack_store::StylePackStore::at_data_dir(&temp.0).unwrap(); + assert!(style_store.list().unwrap().is_empty()); + assert_eq!(std::fs::read(preferences).unwrap(), before); + assert_eq!(std::fs::read(styles).unwrap(), b"[]"); + assert_eq!(std::fs::read_dir(&temp.0).unwrap().count(), 3); +} + +#[tokio::test] +async fn ui_revision_cas_rejects_a_pre_restore_writer_even_when_values_are_identical() { + let fixture = Fixture::new(); + let old = fixture.extensions.read_ui_revision().await.unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let desired = + validate_sync_documents(before.documents.documents().clone(), Revision::new(3)).unwrap(); + fixture + .store + .restore_scope(desired, fixture.context(&before)) + .await + .unwrap(); + assert_eq!( + fixture + .extensions + .read_device(DeviceExtensionKey::UiPreferences) + .await + .unwrap() + .unwrap(), + ui_preferences("en", "medium") + ); + assert_ne!(fixture.extensions.read_ui_revision().await.unwrap(), old); + assert_eq!( + fixture + .store + .set_ui_preferences_checked(ui_preferences("en", "large"), old) + .await + .unwrap_err(), + DocumentError::StalePreview + ); +} + +#[tokio::test] +async fn restoring_history_never_replaces_existing_local_media_availability_with_remote_flags() { + let fixture = Fixture::new(); + let record:crate::types::DictationSession=serde_json::from_value(json!({"id":"local-recording","createdAt":"2026-09-26T00:00:00Z","source":"quick_note","rawTranscript":"fixture","finalText":"fixture","mode":"raw","insertStatus":"notRequested","hasAudioRecording":true})).unwrap(); + fixture + .repo + .history + .append_with_retention(record, 0, None) + .unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert_eq!( + before + .documents + .documents() + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::History) + .unwrap() + .value["hasAudioRecording"], + false + ); + let desired = + validate_sync_documents(before.documents.documents().clone(), Revision::new(3)).unwrap(); + fixture + .store + .restore_scope(desired, fixture.context(&before)) + .await + .unwrap(); + assert_eq!( + fixture.repo.history.list().unwrap()[0].has_audio_recording, + Some(true) + ); +} + +#[tokio::test] +async fn constructing_store_binds_the_exact_gate_required_by_real_credential_operations() { + let fixture = Fixture::new(); + let bound = fixture + .credentials + .bound_gate + .lock() + .unwrap() + .clone() + .unwrap(); + assert!(Arc::ptr_eq(&bound, &fixture.store.inner.gate)); + *fixture.credentials.bound_gate.lock().unwrap() = None; + assert!(fixture + .credentials + .export_sync_credentials_readonly() + .await + .is_err()); + fixture.credentials.bind_sync_gate(bound).unwrap(); + fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); +} + +#[tokio::test] +async fn failed_device_path_restore_restores_original_local_grant_without_exporting_it() { + for fail_rollback in [false, true] { + let fixture = Fixture::new(); + fixture + .repo + .preferences + .update(|prefs| { + prefs.coding_agent_enabled = true; + prefs.coding_agent_exe = Some("/fixture/original-agent".into()); + }) + .unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(!serde_json::to_string(before.documents.documents()) + .unwrap() + .contains("codingAgentEnabled")); + let mut desired = before.documents.documents().clone(); + desired + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id == "fixture-device") + .unwrap() + .value["preferences"]["codingAgentExe"] = json!("/fixture/never-run-imported-agent"); + fixture + .credentials + .failures + .lock() + .unwrap() + .extend(if fail_rollback { + vec![true, true, false] + } else { + vec![true, false] + }); + assert!(fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .is_err()); + if fail_rollback { + fixture.store.recover_registered().await.unwrap(); + } + let prefs = fixture.repo.preferences.get(); + assert!(prefs.coding_agent_enabled); + assert_eq!( + prefs.coding_agent_exe.as_deref(), + Some("/fixture/original-agent") + ); + } +} + +#[tokio::test] +async fn deleted_history_rollback_restores_all_local_audio_markers_and_keeps_wav_files() { + for restart_recovery in [false, true] { + let mut fixture = Fixture::new(); + let recording_dir = fixture._temp.0.join("recordings"); + std::fs::create_dir_all(&recording_dir).unwrap(); + let mut expected = std::collections::BTreeMap::new(); + let mut recording = None; + for marker in [Some(true), Some(false), None] { + let id = uuid::Uuid::new_v4().to_string(); + if marker == Some(true) { + let path = recording_dir.join(format!("{id}.wav")); + std::fs::write(&path, b"fixture local recording remains untouched").unwrap(); + recording = Some(path); + } + let row:crate::types::DictationSession=serde_json::from_value(json!({"id":id,"createdAt":"2026-09-26T00:00:00Z","rawTranscript":"fixture","finalText":"fixture","mode":"raw","insertStatus":"inserted","hasAudioRecording":marker})).unwrap(); + fixture + .repo + .history + .append_with_retention(row, 0, None) + .unwrap(); + expected.insert(id, marker); + } + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(before + .documents + .documents() + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::History) + .all(|doc| doc.value["hasAudioRecording"] == false)); + let mut desired = before.documents.documents().clone(); + desired + .documents + .retain(|doc| doc.kind != DocumentKind::History); + for id in expected.keys() { + desired + .tombstones + .push(crate::cloud_sync_e2ee_protocol::types::Tombstone { + id: id.clone(), + kind: DocumentKind::History, + deleted_at: "2026-09-26T01:00:00Z".into(), + base_revision: Revision::new(0), + }); + } + fixture + .credentials + .failures + .lock() + .unwrap() + .extend(if restart_recovery { + vec![true, true, false] + } else { + vec![true, false] + }); + let result = fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await; + assert_eq!( + result.unwrap_err(), + if restart_recovery { + DocumentError::RecoveryRequired + } else { + DocumentError::RestoreRolledBack + } + ); + if restart_recovery { + assert!(fixture.store.is_recovering()); + fixture = fixture.reopened(); + fixture.store.recover_registered().await.unwrap(); + } + let actual: std::collections::BTreeMap<_, _> = fixture + .repo + .history + .list() + .unwrap() + .into_iter() + .map(|record| (record.id, record.has_audio_recording)) + .collect(); + assert_eq!(actual,expected,"rollback must preserve true, false and unknown receiver-only markers after rows were removed"); + assert_eq!( + std::fs::read(recording.unwrap()).unwrap(), + b"fixture local recording remains untouched" + ); + let exported = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + assert!(exported + .documents + .documents() + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::History) + .all(|doc| doc.value["hasAudioRecording"] == false)); + assert_eq!(fixture.store.generation().unwrap(), before.generation); + assert!(!fixture.store.is_recovering()); + } +} + +#[tokio::test] +async fn remote_history_audio_true_cannot_create_local_media_availability() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::History, + "foreign-recording", + json!({"id":"foreign-recording","createdAt":"2026-09-26T00:00:00Z","rawTranscript":"fixture","finalText":"fixture","mode":"raw","insertStatus":"inserted","hasAudioRecording":true,"sortIndex":0}), + ); + assert_eq!( + validate_sync_documents(desired, Revision::new(3)).unwrap_err(), + DocumentError::ExcludedField + ); + assert!(fixture + .repo + .history + .read_entry("foreign-recording") + .unwrap() + .is_none()); + assert!(!fixture.store.is_recovering()); +} + +#[tokio::test] +async fn restore_requires_bound_runtime_effects_before_local_writes() { + let fixture = Fixture::new(); + *fixture.store.inner.runtime_effects.lock().unwrap() = None; + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + assert_eq!( + fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap_err(), + DocumentError::Unsupported + ); + assert_eq!(fixture.store.generation().unwrap(), before.generation); + assert!(!fixture.store.is_recovering()); + assert!(!fixture.extensions.key_requested.load(Ordering::Acquire)); + assert!(fixture.effects.targets.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn runtime_effects_are_awaited_under_the_exclusive_and_runtime_fences() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + *fixture.effects.wait.lock().unwrap() = Some(barrier.clone()); + let store = fixture.store.clone(); + let context = fixture.context(&before); + let restore = + tokio::spawn(async move { store.restore_scope(validate(desired), context).await }); + barrier.wait().await; + assert!(!restore.is_finished()); + assert!(fixture.store.is_recovering()); + assert!(fixture.store.ensure_runtime_available().is_err()); + assert!(fixture + .repo + .preferences + .update(|prefs| prefs.show_capsule = false) + .is_err()); + barrier.wait().await; + let result = restore.await.unwrap().unwrap(); + assert_eq!(result.generation.get(), before.generation.get() + 1); + assert!(!fixture.store.is_recovering()); + assert_eq!(fixture.effects.targets.lock().unwrap().len(), 1); +} + +#[tokio::test] +async fn effects_failure_rolls_back_and_final_local_grants_are_applied_again() { + for startup_recovery in [false, true] { + let mut fixture = Fixture::new(); + fixture + .repo + .preferences + .update(|prefs| { + prefs.coding_agent_enabled = true; + prefs.coding_agent_exe = Some("/fixture/original".into()); + }) + .unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + desired + .documents + .iter_mut() + .find(|doc| doc.kind == DocumentKind::DeviceProfile && doc.id == "fixture-device") + .unwrap() + .value["preferences"]["codingAgentExe"] = json!("/fixture/new-program"); + fixture + .effects + .failures + .lock() + .unwrap() + .extend(if startup_recovery { + vec![true, true, false, false] + } else { + vec![true, false, false] + }); + assert_eq!( + fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap_err(), + if startup_recovery { + DocumentError::RecoveryRequired + } else { + DocumentError::RestoreRolledBack + } + ); + if startup_recovery { + assert!(fixture.store.is_recovering()); + fixture = fixture.reopened(); + fixture.store.recover_registered().await.unwrap(); + } + let applied = fixture.effects.targets.lock().unwrap(); + assert!(!applied[0].coding_agent_enabled); + assert_eq!( + applied[0].coding_agent_exe.as_deref(), + Some("/fixture/new-program") + ); + let final_target = applied.last().unwrap(); + assert!(final_target.coding_agent_enabled); + assert_eq!( + final_target.coding_agent_exe.as_deref(), + Some("/fixture/original") + ); + assert_eq!(fixture.store.generation().unwrap(), before.generation); + assert!(!fixture.store.is_recovering()); + } +} + +#[tokio::test] +async fn launch_at_login_is_excluded_and_preserves_the_receivers_local_setting() { + let fixture = Fixture::new(); + fixture + .repo + .preferences + .update(|prefs| prefs.launch_at_login = true) + .unwrap(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let encoded = serde_json::to_string(before.documents.documents()).unwrap(); + assert!(!encoded.contains("launchAtLogin")); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap(); + assert!(fixture.repo.preferences.get().launch_at_login); + assert!( + fixture + .effects + .targets + .lock() + .unwrap() + .last() + .unwrap() + .launch_at_login + ); +} + +#[tokio::test] +async fn pending_recovery_without_effects_never_reads_key_or_changes_its_durable_markers() { + let fixture = Fixture::new(); + let before = fixture + .store + .export_scope(fixture.scope.clone()) + .await + .unwrap(); + let mut desired = before.documents.documents().clone(); + set_doc( + &mut desired, + DocumentKind::Preferences, + "themeMode", + json!("dark"), + ); + fixture + .credentials + .failures + .lock() + .unwrap() + .extend([true, true]); + assert_eq!( + fixture + .store + .restore_scope(validate(desired), fixture.context(&before)) + .await + .unwrap_err(), + DocumentError::RecoveryRequired + ); + let directory = fixture._temp.0.join("encrypted-sync"); + let journal = std::fs::read_dir(&directory) + .unwrap() + .filter_map(Result::ok) + .find(|entry| entry.file_name().to_string_lossy().starts_with("restore-")) + .unwrap() + .path(); + let generation = std::fs::read(directory.join("generation.json")).unwrap(); + let sealed = std::fs::read(&journal).unwrap(); + let effects_calls = fixture.effects.targets.lock().unwrap().len(); + *fixture.store.inner.runtime_effects.lock().unwrap() = None; + fixture + .extensions + .key_requested + .store(false, Ordering::Release); + assert_eq!( + fixture.store.recover_registered().await.unwrap_err(), + DocumentError::Unsupported + ); + assert!(!fixture.extensions.key_requested.load(Ordering::Acquire)); + assert_eq!( + std::fs::read(directory.join("generation.json")).unwrap(), + generation + ); + assert_eq!(std::fs::read(journal).unwrap(), sealed); + assert_eq!(fixture.effects.targets.lock().unwrap().len(), effects_calls); + assert!(fixture.store.is_recovering()); +} + +#[test] +fn setup_prompt_store_probe_requires_idle_runtime_and_no_active_writer() { + let fixture = Fixture::new(); + assert!(fixture.store.setup_prompt_state().unwrap().is_some()); + let writing = fixture.store.inner.gate.begin_mutation().unwrap(); + assert!(fixture.store.setup_prompt_state().unwrap().is_none()); + writing.abort_unmodified().unwrap(); + *fixture.store.inner.runtime_idle.lock().unwrap() = Some(Arc::new(|| false)); + assert!(fixture.store.setup_prompt_state().unwrap().is_none()); + *fixture.store.inner.runtime_idle.lock().unwrap() = Some(Arc::new(|| true)); + fixture.store.inner.restoring.store(true, Ordering::Release); + assert!(fixture.store.setup_prompt_state().unwrap().is_none()); + fixture + .store + .inner + .restoring + .store(false, Ordering::Release); + assert!(fixture.store.setup_prompt_state().unwrap().is_some()); +} diff --git a/openless-all/app/crates/openless-core/src/config.rs b/openless-all/app/crates/openless-core/src/config.rs index adf083ea8..096dc9fd2 100644 --- a/openless-all/app/crates/openless-core/src/config.rs +++ b/openless-all/app/crates/openless-core/src/config.rs @@ -51,6 +51,17 @@ impl Default for BackendConfig { } } +/// Absolute, idempotent Host convergence during a journalled sync restore. +/// Called only after the complete repository target is installed and while +/// the restore fence still blocks runtime admission. A failure must propagate +/// so the journal can reinstall and reconcile its previous target. +pub trait RestoreRuntimeEffects: Send + Sync { + fn apply_target( + &self, + target: crate::shared_types::UserPreferences, + ) -> BoxFuture<'static, Result<(), BackendError>>; +} + pub trait TaskSpawner: Send + Sync { fn spawn(&self, task: BoxFuture<'static, ()>); } diff --git a/openless-all/app/crates/openless-core/src/correction.rs b/openless-all/app/crates/openless-core/src/correction.rs index 910528aa2..1ad5cd142 100644 --- a/openless-all/app/crates/openless-core/src/correction.rs +++ b/openless-all/app/crates/openless-core/src/correction.rs @@ -10,7 +10,7 @@ use std::sync::Mutex; use chrono::Utc; use crate::errors::{BackendError, BackendErrorCode}; -use crate::persistence::{atomic_write, persistence_error, read_or_default}; +use crate::persistence::{atomic_write, persistence_error}; use crate::types::{CorrectionRule, RuleSource}; const NUM_TOKEN: &str = "{num}"; @@ -38,6 +38,27 @@ impl CorrectionRuleStore { self.read_locked() } + pub(crate) fn sync_snapshot( + &self, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result, BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + crate::persistence::read_lossless_rows(&self.path, &[]) + } + + pub(crate) fn sync_replace_all( + &self, + records: &[CorrectionRule], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + let bytes = serde_json::to_vec_pretty(records) + .map_err(|_| persistence_error("encode restored corrections"))?; + crate::persistence::atomic_write_for_sync(&self.path, &bytes, permit) + } + pub(crate) fn cloud_sync_access( &self, ) -> Result<(std::sync::MutexGuard<'_, ()>, &Path), BackendError> { @@ -49,7 +70,11 @@ impl CorrectionRuleStore { pattern: String, replacement: String, ) -> Result { - self.add_with_source(pattern, replacement, RuleSource::Manual) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || self.add_with_source(pattern, replacement, RuleSource::Manual), + ) } pub fn add_with_source( @@ -58,50 +83,68 @@ impl CorrectionRuleStore { replacement: String, source: RuleSource, ) -> Result { - let pattern = pattern.trim().to_string(); - let replacement = replacement.trim().to_string(); - validate_correction_rule_syntax(&pattern, &replacement)?; - let _guard = self.lock_store()?; - let mut rules = self.read_locked()?; - let rule = CorrectionRule { - id: uuid::Uuid::new_v4().to_string(), - pattern, - replacement, - enabled: true, - created_at: Utc::now().to_rfc3339(), - source, - }; - rules.insert(0, rule.clone()); - self.write_locked(&rules)?; - Ok(rule) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let pattern = pattern.trim().to_string(); + let replacement = replacement.trim().to_string(); + validate_correction_rule_syntax(&pattern, &replacement)?; + let _guard = self.lock_store()?; + let mut rules = self.read_locked()?; + let rule = CorrectionRule { + id: uuid::Uuid::new_v4().to_string(), + pattern, + replacement, + enabled: true, + created_at: Utc::now().to_rfc3339(), + source, + }; + rules.insert(0, rule.clone()); + self.write_locked(&rules)?; + Ok(rule) + }, + ) } /// Removing an unknown id is deliberately idempotent. pub fn remove(&self, id: &str) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut rules = self.read_locked()?; - let before = rules.len(); - rules.retain(|rule| rule.id != id); - if rules.len() != before { - self.write_locked(&rules)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut rules = self.read_locked()?; + let before = rules.len(); + rules.retain(|rule| rule.id != id); + if rules.len() != before { + self.write_locked(&rules)?; + } + Ok(()) + }, + ) } pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut rules = self.read_locked()?; - let rule = rules.iter_mut().find(|rule| rule.id == id).ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "correction rule not found", - ) - })?; - if rule.enabled != enabled { - rule.enabled = enabled; - self.write_locked(&rules)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut rules = self.read_locked()?; + let rule = rules.iter_mut().find(|rule| rule.id == id).ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "correction rule not found", + ) + })?; + if rule.enabled != enabled { + rule.enabled = enabled; + self.write_locked(&rules)?; + } + Ok(()) + }, + ) } fn lock_store(&self) -> Result, BackendError> { @@ -114,7 +157,7 @@ impl CorrectionRuleStore { } fn read_locked(&self) -> Result, BackendError> { - read_or_default(&self.path) + crate::persistence::read_lossless_rows(&self.path, &[]) } fn write_locked(&self, rules: &[CorrectionRule]) -> Result<(), BackendError> { diff --git a/openless-all/app/crates/openless-core/src/credentials.rs b/openless-all/app/crates/openless-core/src/credentials.rs index ba1cab461..b74d8c65f 100644 --- a/openless-all/app/crates/openless-core/src/credentials.rs +++ b/openless-all/app/crates/openless-core/src/credentials.rs @@ -3,10 +3,81 @@ use std::fmt; use std::sync::RwLock; use futures_util::future::BoxFuture; +use zeroize::Zeroize; use crate::errors::{BackendError, BackendErrorCode}; use crate::shared_types::{CredentialsStatus, UserPreferences}; +pub use crate::cloud_sync_e2ee_documents::{ + ChannelRecord as SyncChannel, ProviderCredentialRecord as SyncCredentialRecord, SyncNamespace, +}; +pub use crate::cloud_sync_e2ee_store::gate::{ + ChangeOrigin, ExclusivePermit, MutationPermit, SyncWriteGate, +}; + +/// A single coherent native vault capture. It deliberately has no serde support: +/// only the explicit encrypted document exporter may serialize provider secrets. +#[derive(Clone, PartialEq, Eq)] +pub struct SyncCredentials { + pub channels: Vec, + pub credentials: Vec, +} + +impl SyncCredentials { + pub fn validate(&self) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_documents::validate_credential_set(&self.channels, &self.credentials) + .map_err(|_| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "invalid encrypted sync credential set", + ) + }) + } +} + +impl fmt::Debug for SyncCredentials { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SyncCredentials([REDACTED])") + } +} + +/// Only two app-owned E2EE key namespaces may reach the system-secret adapter. +/// The digest binds native service/account/vault/key/device context; callers can +/// never use this interface to enumerate or read arbitrary Keychain accounts. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct SyncSecretAccount(String); + +impl SyncSecretAccount { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + let suffix = value + .strip_prefix("cloud-sync.e2ee.local.") + .or_else(|| value.strip_prefix("cloud-sync.e2ee.key.")); + if !suffix.is_some_and(|digest| { + digest.len() == 64 + && digest + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "invalid local encrypted sync key account", + )); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SyncSecretAccount { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SyncSecretAccount([REDACTED])") + } +} + macro_rules! provider_identifier { ($name:ident, $label:literal) => { #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize)] @@ -255,8 +326,14 @@ impl SecretValue { &self.0 } - pub fn into_exposed(self) -> String { - self.0 + pub fn into_exposed(mut self) -> String { + std::mem::take(&mut self.0) + } +} + +impl Drop for SecretValue { + fn drop(&mut self) { + self.0.zeroize(); } } @@ -267,6 +344,56 @@ impl fmt::Debug for SecretValue { } pub trait CredentialStore: Send + Sync { + fn bind_sync_gate(&self, _gate: std::sync::Arc) -> Result<(), BackendError> { + Ok(()) + } + + /// Capture one coherent vault image without holding the global restore gate. + /// The caller must verify the shared capture epoch before and after reading + /// all repositories; this method only guarantees consistency within the vault. + fn export_sync_credentials_readonly( + &self, + ) -> BoxFuture<'static, Result> { + unsupported_credentials() + } + + fn export_sync_credentials( + &self, + _permit: &ExclusivePermit, + ) -> BoxFuture<'static, Result> { + unsupported_credentials() + } + + fn replace_sync_credentials( + &self, + _snapshot: SyncCredentials, + _permit: &ExclusivePermit, + ) -> BoxFuture<'static, Result<(), BackendError>> { + unsupported_credentials() + } + + fn read_sync_secret( + &self, + _account: SyncSecretAccount, + ) -> BoxFuture<'static, Result, BackendError>> { + unsupported_credentials() + } + + fn write_sync_secret( + &self, + _account: SyncSecretAccount, + _value: SecretValue, + ) -> BoxFuture<'static, Result<(), BackendError>> { + unsupported_credentials() + } + + fn remove_sync_secret( + &self, + _account: SyncSecretAccount, + ) -> BoxFuture<'static, Result<(), BackendError>> { + unsupported_credentials() + } + fn status( &self, preferences: UserPreferences, @@ -1024,6 +1151,66 @@ fn unsupported_credentials() -> BoxFuture<'static, Result> { mod tests { use super::*; + #[test] + fn sync_secret_accounts_cannot_reach_provider_or_other_app_credentials() { + for prefix in ["cloud-sync.e2ee.local.", "cloud-sync.e2ee.key."] { + let accepted = format!("{prefix}{}", "0123456789abcdef".repeat(4)); + let account = SyncSecretAccount::new(&accepted).unwrap(); + assert_eq!(account.as_str(), accepted); + assert!(!format!("{account:?}").contains(&accepted)); + } + for value in [ + "credentials.v2".into(), + "cloud-sync.e2ee.local.".into(), + format!("cloud-sync.e2ee.local.{}", "a".repeat(63)), + format!("cloud-sync.e2ee.local.{}", "a".repeat(65)), + format!("cloud-sync.e2ee.local.{}", "A".repeat(64)), + format!("cloud-sync.e2ee.local.{}", "g".repeat(64)), + format!("cloud-sync.e2ee.local.{}\n", "a".repeat(64)), + format!("cloud-sync.e2ee.local.{}", "../".repeat(22)), + ] { + assert!(SyncSecretAccount::new(value).is_err()); + } + let secret = SecretValue::new("private-canary-secret"); + assert_eq!(format!("{secret:?}"), "SecretValue([REDACTED])"); + assert_eq!(secret.into_exposed(), "private-canary-secret"); + } + + #[tokio::test] + async fn sync_secret_defaults_fail_closed_on_hosts_without_an_adapter() { + let store = UnsupportedCredentialStore; + assert_eq!( + store + .export_sync_credentials_readonly() + .await + .unwrap_err() + .code, + BackendErrorCode::Unsupported + ); + let account = + SyncSecretAccount::new(format!("cloud-sync.e2ee.local.{}", "a".repeat(64))).unwrap(); + assert_eq!( + store + .read_sync_secret(account.clone()) + .await + .unwrap_err() + .code, + BackendErrorCode::Unsupported + ); + assert_eq!( + store + .write_sync_secret(account.clone(), SecretValue::new("private-canary-secret")) + .await + .unwrap_err() + .code, + BackendErrorCode::Unsupported + ); + assert_eq!( + store.remove_sync_secret(account).await.unwrap_err().code, + BackendErrorCode::Unsupported + ); + } + fn summary(id: &str, order: u32, enabled: bool) -> ChannelSummary { ChannelSummary { id: id.to_string(), diff --git a/openless-all/app/crates/openless-core/src/domains.rs b/openless-all/app/crates/openless-core/src/domains.rs index b22c506ed..6fd1a3971 100644 --- a/openless-all/app/crates/openless-core/src/domains.rs +++ b/openless-all/app/crates/openless-core/src/domains.rs @@ -23,6 +23,10 @@ use crate::local_asr_catalog::{ use crate::style_packs::StylePack; use crate::types::{PolishMode, SessionId}; +/// Fast, synchronous restore admission check. Implementations must not do I/O +/// or acquire repository locks; Core calls it while holding a runtime state lock. +pub type RuntimeRestoreGuard = Arc Result<(), BackendError> + Send + Sync>; + fn unsupported(domain: &'static str) -> BoxFuture<'static, Result> { Box::pin(async move { Err(BackendError::new( @@ -398,6 +402,21 @@ pub struct SelectionPolishRequest { } pub trait SelectionApi: Send + Sync { + /// Bind restore admission before the service is exposed to runtime callers. + /// Legacy custom services remain source compatible, but conservatively do + /// not report restore-safe idleness until they implement both methods. + #[doc(hidden)] + fn bind_runtime_restore_guard( + &self, + _guard: RuntimeRestoreGuard, + _spawner: Arc, + ) -> Result<(), BackendError> { + Ok(()) + } + #[doc(hidden)] + fn runtime_restore_idle(&self) -> bool { + false + } fn snapshot(&self) -> BoxFuture<'static, Result>; fn begin_polish( &self, @@ -648,6 +667,21 @@ impl SelectionVoiceApplyOutcome { } pub trait SelectionVoiceApi: Send + Sync { + /// Bind restore admission before the service is exposed to runtime callers. + /// Legacy custom services remain source compatible, but conservatively do + /// not report restore-safe idleness until they implement both methods. + #[doc(hidden)] + fn bind_runtime_restore_guard( + &self, + _guard: RuntimeRestoreGuard, + _spawner: Arc, + ) -> Result<(), BackendError> { + Ok(()) + } + #[doc(hidden)] + fn runtime_restore_idle(&self) -> bool { + false + } #[doc(hidden)] fn bind_qa(&self, _qa: std::sync::Weak) {} /// Register the Host's capture/target cleanup before asynchronous startup. @@ -944,6 +978,21 @@ pub trait QaRuntimeAdapter: Send + Sync { } pub trait QaApi: Send + Sync { + /// Bind restore admission before the service is exposed to runtime callers. + /// Legacy custom services remain source compatible, but conservatively do + /// not report restore-safe idleness until they implement both methods. + #[doc(hidden)] + fn bind_runtime_restore_guard( + &self, + _guard: RuntimeRestoreGuard, + _spawner: Arc, + ) -> Result<(), BackendError> { + Ok(()) + } + #[doc(hidden)] + fn runtime_restore_idle(&self) -> bool { + false + } #[doc(hidden)] fn bind_event_publisher(&self, _publisher: crate::events::BackendEventPublisher) {} /// Show the QA surface without implicitly starting a recording or creating @@ -967,6 +1016,10 @@ pub trait QaApi: Send + Sync { unsupported("QA") } fn submit_text(&self, text: String) -> BoxFuture<'static, Result<(), BackendError>>; + /// Check the displayed conversation and claim the new turn atomically. + fn submit_text_in_context(&self, _text: String, _expected_session: Option) -> BoxFuture<'static, Result<(), BackendError>> { + Box::pin(async { Err(BackendError::new(BackendErrorCode::Unsupported, "scoped QA submission is unavailable")) }) + } fn submit_captured_text( &self, _input: QaInput, @@ -1641,6 +1694,9 @@ impl LocalAsrApi for UnsupportedDomainServices { } impl SelectionApi for UnsupportedDomainServices { + fn runtime_restore_idle(&self) -> bool { + true + } fn snapshot(&self) -> BoxFuture<'static, Result> { unsupported("selection") } @@ -1666,6 +1722,9 @@ impl SelectionApi for UnsupportedDomainServices { } impl SelectionVoiceApi for UnsupportedDomainServices { + fn runtime_restore_idle(&self) -> bool { + true + } fn snapshot(&self) -> BoxFuture<'static, Result> { unsupported("selection voice") } @@ -1760,6 +1819,9 @@ impl SelectionVoiceApi for UnsupportedDomainServices { } impl QaApi for UnsupportedDomainServices { + fn runtime_restore_idle(&self) -> bool { + true + } fn show(&self) -> BoxFuture<'static, Result<(), BackendError>> { unsupported("QA") } diff --git a/openless-all/app/crates/openless-core/src/events.rs b/openless-all/app/crates/openless-core/src/events.rs index 2d2e27c87..ac568b8a4 100644 --- a/openless-all/app/crates/openless-core/src/events.rs +++ b/openless-all/app/crates/openless-core/src/events.rs @@ -129,6 +129,16 @@ pub enum LessComputerEventKind { phase: LessComputerVoicePhase, level: f32, elapsed_ms: u64, + /// `dictate` sessions deliver the transcript to the host composer + /// instead of starting an Agent turn. + #[serde(default)] + mode: LessComputerVoiceMode, + /// Full transcript observed so far, including live partial results. + #[serde(default)] + transcript: String, + /// Present only on the terminal `idle` snapshot of a capture. + #[serde(default, skip_serializing_if = "Option::is_none")] + outcome: Option, }, User { text: String, @@ -167,6 +177,29 @@ pub enum LessComputerVoicePhase { Idle, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LessComputerVoiceMode { + /// Hotkey semantics: the final transcript becomes an Agent turn. + #[default] + Submit, + /// Composer dictation: the final transcript is returned for editing only. + Dictate, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LessComputerVoiceOutcome { + /// The transcript was handed to the Agent. + Submitted, + /// A dictation transcript is ready for the composer. + Committed, + /// Recognition finished without any text. + Empty, + Failed, + Cancelled, +} + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub struct LessComputerEvent { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -377,6 +410,9 @@ pub enum BackendEventKind { HistoryChanged(HistoryChange), VocabularyChanged(VocabularyChange), StylePacksChanged(StylePackChange), + CloudSyncStateChanged(crate::cloud_sync_e2ee::EncryptedSyncEvent), + CloudSyncConflictDetected(crate::cloud_sync_e2ee::EncryptedSyncConflictEvent), + CloudSyncRestoreCompleted(crate::cloud_sync_e2ee::EncryptedSyncRestoreEvent), DownloadProgress(DownloadProgress), PermissionChanged(PermissionSnapshot), HotkeyStatusChanged(HotkeyStatus), @@ -763,6 +799,9 @@ mod tests { phase, level: 0.0, elapsed_ms: 120, + mode: LessComputerVoiceMode::Submit, + transcript: String::new(), + outcome: None, }, }), ); diff --git a/openless-all/app/crates/openless-core/src/history.rs b/openless-all/app/crates/openless-core/src/history.rs index 37da6dddd..31f8efa76 100644 --- a/openless-all/app/crates/openless-core/src/history.rs +++ b/openless-all/app/crates/openless-core/src/history.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use crate::errors::{BackendError, BackendErrorCode}; -use crate::persistence::{atomic_write, persistence_error, read_or_default}; +use crate::persistence::{atomic_write, persistence_error}; use crate::types::{DictationSession, HistorySource}; pub const HISTORY_CAP: usize = 200; @@ -31,17 +31,45 @@ impl HistoryStore { self.read_locked() } + pub(crate) fn sync_snapshot( + &self, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result, BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + crate::persistence::read_lossless_rows(&self.path, &[]) + } + + /// Whole logical replacement deliberately bypasses both age and entry-count retention. + pub(crate) fn sync_replace_all( + &self, + records: &[DictationSession], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + let bytes = serde_json::to_vec_pretty(records) + .map_err(|_| persistence_error("encode restored history"))?; + crate::persistence::atomic_write_for_sync(&self.path, &bytes, permit) + } + pub fn append_with_retention( &self, session: DictationSession, retention_days: u32, max_entries: Option, ) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut sessions = self.read_locked()?; - sessions.insert(0, session); - retain_with_policy(&mut sessions, retention_days, max_entries); - self.write_locked(&sessions) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut sessions = self.read_locked()?; + sessions.insert(0, session); + retain_with_policy(&mut sessions, retention_days, max_entries); + self.write_locked(&sessions) + }, + ) } /// Replace an in-progress record when its terminal result arrives, or @@ -52,19 +80,25 @@ impl HistoryStore { retention_days: u32, max_entries: Option, ) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut sessions = self.read_locked()?; - if let Some(existing) = sessions.iter_mut().find(|item| item.id == session.id) { - let mut replacement = session; - if replacement.has_audio_recording.is_none() { - replacement.has_audio_recording = existing.has_audio_recording; - } - *existing = replacement; - } else { - sessions.insert(0, session); - } - retain_with_policy(&mut sessions, retention_days, max_entries); - self.write_locked(&sessions) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut sessions = self.read_locked()?; + if let Some(existing) = sessions.iter_mut().find(|item| item.id == session.id) { + let mut replacement = session; + if replacement.has_audio_recording.is_none() { + replacement.has_audio_recording = existing.has_audio_recording; + } + *existing = replacement; + } else { + sessions.insert(0, session); + } + retain_with_policy(&mut sessions, retention_days, max_entries); + self.write_locked(&sessions) + }, + ) } pub fn contains(&self, id: &str) -> Result { @@ -101,35 +135,54 @@ impl HistoryStore { } pub fn delete(&self, id: &str) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut sessions = self.read_locked()?; - let before = sessions.len(); - sessions.retain(|session| session.id != id); - if sessions.len() != before { - self.write_locked(&sessions)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut sessions = self.read_locked()?; + let before = sessions.len(); + sessions.retain(|session| session.id != id); + if sessions.len() != before { + self.write_locked(&sessions)?; + } + Ok(()) + }, + ) } pub fn update_entry(&self, updated: DictationSession) -> Result { - let _guard = self.lock_store()?; - let mut sessions = self.read_locked()?; - let Some(slot) = sessions.iter_mut().find(|session| session.id == updated.id) else { - return Ok(false); - }; - *slot = updated; - self.write_locked(&sessions)?; - Ok(true) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut sessions = self.read_locked()?; + let Some(slot) = sessions.iter_mut().find(|session| session.id == updated.id) + else { + return Ok(false); + }; + *slot = updated; + self.write_locked(&sessions)?; + Ok(true) + }, + ) } pub fn clear(&self) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let quick_notes = self - .read_locked()? - .into_iter() - .filter(|session| session.source == HistorySource::QuickNote) - .collect::>(); - self.write_locked(&quick_notes) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let quick_notes = self + .read_locked()? + .into_iter() + .filter(|session| session.source == HistorySource::QuickNote) + .collect::>(); + self.write_locked(&quick_notes) + }, + ) } fn lock_store(&self) -> Result, BackendError> { @@ -139,7 +192,7 @@ impl HistoryStore { } fn read_locked(&self) -> Result, BackendError> { - read_or_default(&self.path) + crate::persistence::read_lossless_rows(&self.path, &[]) } fn write_locked(&self, sessions: &[DictationSession]) -> Result<(), BackendError> { diff --git a/openless-all/app/crates/openless-core/src/less_computer.rs b/openless-all/app/crates/openless-core/src/less_computer.rs index e524aad4c..ffc27dab8 100644 --- a/openless-all/app/crates/openless-core/src/less_computer.rs +++ b/openless-all/app/crates/openless-core/src/less_computer.rs @@ -581,10 +581,12 @@ impl LessComputerApi for LessComputerService { .lock() .expect("Less Computer approval lock poisoned") .remove(&token); - if let Some(sender) = sender { - let _ = sender.send(approved); - } - Ok(()) + let sender = sender.ok_or_else(|| BackendError::new( + BackendErrorCode::InvalidState, "approval request is no longer pending", + ))?; + sender.send(approved).map_err(|_| BackendError::new( + BackendErrorCode::InvalidState, "approval receiver is no longer waiting", + )) }) } @@ -1167,7 +1169,7 @@ mod tests { } #[tokio::test] - async fn approval_tokens_are_instance_local_idempotent_and_event_driven() { + async fn approval_tokens_are_instance_local_confirmed_once_and_event_driven() { let (owner, mut events) = service_with_events(); let (other, _) = service_with_events(); let owner_for_request = owner.clone(); @@ -1193,12 +1195,12 @@ mod tests { assert_eq!(command, "rm file"); assert_eq!(reason, "destructive"); - other.approve(token.clone(), true).await.unwrap(); + assert!(other.approve(token.clone(), true).await.is_err()); tokio::task::yield_now().await; assert!(!waiting.is_finished()); owner.approve(token.clone(), true).await.unwrap(); - owner.approve(token, false).await.unwrap(); + assert!(owner.approve(token, false).await.is_err()); assert!(waiting.await.unwrap().unwrap()); assert_eq!(owner.pending_approval_count(), 0); } diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 10f82c8ca..8fdd4a9a3 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -13,6 +13,10 @@ pub mod auxiliary; pub mod cli; mod cloud_providers; pub mod cloud_sync; +pub mod cloud_sync_e2ee; +pub mod cloud_sync_e2ee_documents; +mod cloud_sync_e2ee_protocol; +pub mod cloud_sync_e2ee_store; mod cloud_sync_transaction; mod cloud_sync_types; mod cloud_sync_validation; @@ -209,8 +213,9 @@ pub mod contract { pub use activity::{ActivityDay, ActivityStore, DayStats}; pub use api::{ BackendRepositories, BackendSnapshot, CliDispatchOutcome, DictationHotkeyDispatchOptions, - DictationHotkeyEdge, LessComputerHotkeyAction, LessComputerVoiceSession, OpenLessBackend, - QaVoiceCaptureResult, QaVoiceCaptureSession, StartupSnapshot, VoiceTranscriptionSession, + DictationHotkeyEdge, LessComputerHotkeyAction, LessComputerVoiceFinish, + LessComputerVoiceOptions, LessComputerVoiceSession, OpenLessBackend, QaVoiceCaptureResult, + QaVoiceCaptureSession, StartupSnapshot, VoiceTranscriptionSession, }; pub use audio::{encode_dictation_wav, NormalizedPcmChunk, PcmNormalizer, DICTATION_SAMPLE_RATE}; pub use auxiliary::{ @@ -252,10 +257,10 @@ pub use errors::{BackendError, BackendErrorCode}; pub use events::{ BackendEvent, BackendEventKind, BackendEventPublisher, CodingAgentStreamEvent, EventRecvError, EventReplay, EventSubscription, LessComputerEvent, LessComputerEventKind, - LessComputerVoicePhase, LocalAsrDownloadPhase, LocalAsrDownloadProgress, LocalAsrPreparePhase, - LocalAsrPrepareProgress, LocalAsrRuntimeKind, QaRecordingLevel, QaStateEvent, QaStateKind, - RecordingControlAction, RecordingControlRequest, RemoteInputErrorEvent, - RemoteInputRuntimeEvent, + LessComputerVoiceMode, LessComputerVoiceOutcome, LessComputerVoicePhase, LocalAsrDownloadPhase, + LocalAsrDownloadProgress, LocalAsrPreparePhase, LocalAsrPrepareProgress, LocalAsrRuntimeKind, + QaRecordingLevel, QaStateEvent, QaStateKind, RecordingControlAction, RecordingControlRequest, + RemoteInputErrorEvent, RemoteInputRuntimeEvent, }; pub use external_audio::{AudioRecorderRouter, ExternalAudioRecorder}; pub use history::{HistoryStore, HISTORY_CAP}; @@ -326,8 +331,8 @@ pub use shared_types::{ }; pub use shortcut_types::{ binding_from_legacy_trigger, binding_requires_side_aware_hook, bindings_overlap, - is_side_specific_modifier_tag, legacy_modifier_trigger, normalize_side_modifier_tag, - reconcile_hotkey_collisions, reject_bare_shift_dictation_shortcut, + is_modifier_chord_binding, is_side_specific_modifier_tag, legacy_modifier_trigger, + normalize_side_modifier_tag, reconcile_hotkey_collisions, reject_bare_shift_dictation_shortcut, reject_dictation_qa_hotkey_overlap, reject_dictation_translation_hotkey_overlap, reject_hotkey_collisions, reject_modifier_only_action_shortcut, reject_non_dictation_side_specific_shortcuts, reject_qa_less_computer_hotkey_overlap, diff --git a/openless-all/app/crates/openless-core/src/llm_protocol.rs b/openless-all/app/crates/openless-core/src/llm_protocol.rs index 3ba509524..be525f133 100644 --- a/openless-all/app/crates/openless-core/src/llm_protocol.rs +++ b/openless-all/app/crates/openless-core/src/llm_protocol.rs @@ -385,12 +385,7 @@ impl TextEventStream { } pub fn push(&mut self, chunk: &[u8]) -> Result<(), LLMError> { - crate::polish::append_utf8_sse_chunk(&mut self.buffer, &mut self.pending, chunk)?; - // 在完整字符串上替换,兼容 CR 与 LF 分属不同网络块。 - if self.buffer.contains("\r\n") { - self.buffer = self.buffer.replace("\r\n", "\n"); - } - Ok(()) + crate::polish::append_utf8_sse_chunk(&mut self.buffer, &mut self.pending, chunk) } pub fn next(&mut self) -> Result, LLMError> { @@ -769,6 +764,39 @@ mod tests { .is_err()); } + #[test] + fn chat_sse_preserves_utf8_and_done_with_lf_crlf_or_mixed_frames() { + for (text_eol, done_eol) in [ + ("\n", "\n"), + ("\r\n", "\r\n"), + ("\n", "\r\n"), + ("\r\n", "\n"), + ] { + let mut stream = TextEventStream::new(LlmRequestFormat::ChatCompletions); + let text_event = format!("data: {{\"choices\":[{{\"delta\":{{\"content\":\"你\\r\\n🙂好\"}}}}]}}{text_eol}{text_eol}"); + let mut text = String::new(); + for byte in text_event.as_bytes() { + stream.push(&[*byte]).unwrap(); + while let Some(event) = stream.next().unwrap() { + if let StreamEvent::Text(delta) = event { + text.push_str(&delta); + } + } + } + assert_eq!(text, "你\r\n🙂好", "JSON escapes must remain user text"); + assert!(!stream.done); + let done_event = format!("data: [DONE]{done_eol}{done_eol}"); + for byte in done_event.as_bytes() { + stream.push(&[*byte]).unwrap(); + while let Some(event) = stream.next().unwrap() { + assert!(matches!(event, StreamEvent::Done)); + } + } + assert!(stream.done, "recognize the terminal marker before EOF"); + stream.finish().unwrap(); + } + } + #[test] fn sse_handles_every_byte_boundary_and_requires_successful_termination() { for (format, fixture) in [ diff --git a/openless-all/app/crates/openless-core/src/marketplace.rs b/openless-all/app/crates/openless-core/src/marketplace.rs index 0b25e9f07..8a99c7100 100644 --- a/openless-all/app/crates/openless-core/src/marketplace.rs +++ b/openless-all/app/crates/openless-core/src/marketplace.rs @@ -26,6 +26,7 @@ pub const CLOUD_SYNC_BASE_URL: &str = "https://apic.openless.top:9443"; #[derive(Debug, Clone)] pub struct MarketplaceConfig { + pub(crate) encrypted_sync_config: Option, pub base_url: reqwest::Url, pub cloud_sync_base_url: reqwest::Url, pub github_client_id: String, @@ -59,6 +60,7 @@ impl MarketplaceConfig { .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "Ov23liyv3nEucG7oMHNE".into()); Ok(Self { + encrypted_sync_config: None, base_url: parse(base_url.as_ref(), "marketplace")?, cloud_sync_base_url: match cloud_sync_base_url { Some(value) => parse(value, "cloud sync")?, @@ -81,6 +83,12 @@ impl MarketplaceConfig { Self::with_cloud_sync_base_url(MARKETPLACE_BASE_URL, Some(CLOUD_SYNC_BASE_URL)) .expect("built-in Marketplace URLs are valid") } + + /// Opt in after the host provides the controlled credential and UI bridges. + pub fn with_encrypted_sync(mut self, config: crate::cloud_sync_e2ee::EncryptedSyncConfig) -> Self { + self.encrypted_sync_config = Some(config); + self + } } #[derive(Clone)] @@ -449,6 +457,43 @@ impl MarketplaceService { .ok_or_else(Self::authentication_required) } + /// Establish the numeric identity used to bind encrypted snapshots. The + /// stored display login is never an authorization or account-isolation key. + pub(crate) async fn sync_identity( + &self, + ) -> Result<(SecretValue, crate::cloud_sync_e2ee_protocol::types::Account), BackendError> { + use futures_util::StreamExt; + use crate::cloud_sync_e2ee_protocol::types::{Account, GithubId}; + let token = self.read_access_token().await?; + let response = crate::net::credential_http_for_url(self.config.github_user_url.as_str()) + .get(self.config.github_user_url.clone()) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "OpenLess-encrypted-sync") + .timeout(Duration::from_secs(15)) + .bearer_auth(token.expose_secret()) + .send().await.map_err(|_| Self::authentication_required())?; + if !response.status().is_success() { + return Err(Self::authentication_required()); + } + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| Self::authentication_required())?; + if bytes.len().saturating_add(chunk.len()) > 16 * 1024 { + return Err(Self::authentication_required()); + } + bytes.extend_from_slice(&chunk); + } + #[derive(serde::Deserialize)] + struct User { id: u64, login: String } + let user: User = serde_json::from_slice(&bytes).map_err(|_| Self::authentication_required())?; + if user.login.is_empty() || user.login.len() > 128 || self.read_access_token().await? != token { + return Err(Self::authentication_required()); + } + let github_id = GithubId::parse(&user.id.to_string()).map_err(|_| Self::authentication_required())?; + Ok((token, Account { github_id, login: user.login })) + } + async fn clear_authentication(&self) -> Result<(), BackendError> { self.auth_tombstoned.store(true, Ordering::Release); let remove_result = self.credential_store.remove(Self::token_key()?).await; @@ -458,6 +503,10 @@ impl MarketplaceService { remove_result.and(preferences_result) } + pub(crate) fn invalidate_authentication(&self) { + self.auth_tombstoned.store(true, Ordering::Release); + } + async fn authenticated_response( &self, method: reqwest::Method, diff --git a/openless-all/app/crates/openless-core/src/omni.rs b/openless-all/app/crates/openless-core/src/omni.rs index 1f39c07a3..171daff84 100644 --- a/openless-all/app/crates/openless-core/src/omni.rs +++ b/openless-all/app/crates/openless-core/src/omni.rs @@ -40,6 +40,17 @@ impl OmniConfig { self.provider_id.trim() == OMNI_GEMINI_PROVIDER_ID || self.base_url.contains("generativelanguage.googleapis.com") } + + /// 百炼/DashScope 兼容端点把 `input_audio.data` 按 URL/data-URL 解析,裸 Base64 + /// 会被 400 拒绝("The provided URL does not appear to be valid")。与 + /// `asr::dashscope_multimodal` 转写通道同款,Base64 须带 data-URL 前缀; + /// 沿用 polish 的主机名关键词,但只检查 URL 解析后的真实 host。 + fn audio_requires_data_url(&self) -> bool { + reqwest::Url::parse(self.base_url.trim()) + .ok() + .and_then(|url| url.host_str().map(str::to_ascii_lowercase)) + .is_some_and(|host| host.contains("dashscope") || host.contains("aliyuncs")) + } } /// 一次 Omni 调用的构建时快照(provider id + model),落历史归因用。 @@ -103,7 +114,13 @@ impl OpenAICompatibleOmni { ) -> Vec { let user_content = match wav_bytes { Some(wav) => { - let data = base64::engine::general_purpose::STANDARD.encode(wav); + let encoded = base64::engine::general_purpose::STANDARD.encode(wav); + // 百炼系端点要求 data-URL 前缀;OpenAI 官方等其他兼容端点保持裸 Base64。 + let data = if self.config.audio_requires_data_url() { + format!("data:audio/wav;base64,{encoded}") + } else { + encoded + }; let mut parts = vec![json!({ "type": "input_audio", "input_audio": { "data": data, "format": "wav" }, @@ -190,7 +207,7 @@ impl OpenAICompatibleOmni { }); } - // SSE 流解析与 polish 路径同款:一帧 = 若干行,`\n\n` 分隔, + // 共用 UTF-8 解码会把 CRLF 归一为 LF;一帧 = 若干行,`\n\n` 分隔, // 每行 `data: {...}` / `data: [DONE]`。 let mut response = response; let mut buffer = String::new(); @@ -458,6 +475,74 @@ mod tests { assert_eq!(parts[1]["text"], "翻译成中文"); } + #[test] + fn build_messages_wraps_audio_as_data_url_for_dashscope() { + let mut dashscope = config(); + dashscope.base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1".into(); + let provider = OpenAICompatibleOmni::new(dashscope); + let messages = provider.build_messages("system-prompt", "", Some(&[1u8, 2, 3, 4])); + let parts = messages[1]["content"].as_array().expect("audio parts"); + let data = parts[0]["input_audio"]["data"] + .as_str() + .expect("audio data"); + let payload = data + .strip_prefix("data:audio/wav;base64,") + .expect("data-url prefix for DashScope"); + let decoded = base64::engine::general_purpose::STANDARD + .decode(payload) + .expect("valid base64"); + assert_eq!(decoded, vec![1u8, 2, 3, 4]); + } + + #[test] + fn build_messages_wraps_audio_as_data_url_for_bailian_dedicated_endpoint() { + let mut maas = config(); + maas.base_url = + "https://llm-example.cn-beijing.maas.aliyuncs.com/compatible-mode/v1".into(); + let provider = OpenAICompatibleOmni::new(maas); + let messages = provider.build_messages("system-prompt", "", Some(&[1u8, 2, 3, 4])); + let parts = messages[1]["content"].as_array().expect("audio parts"); + assert!(parts[0]["input_audio"]["data"] + .as_str() + .expect("audio data") + .starts_with("data:audio/wav;base64,")); + } + + #[test] + fn omni_audio_data_url_matches_dashscope_hosts_only() { + assert!(!config().audio_requires_data_url()); + let mut dashscope = config(); + dashscope.base_url = "https://dashscope.aliyuncs.com/compatible-mode/v1".into(); + assert!(dashscope.audio_requires_data_url()); + let mut maas = config(); + maas.base_url = "https://LLM-EXAMPLE.cn-beijing.maas.aliyuncs.com/v1".into(); + assert!(maas.audio_requires_data_url()); + // host 之外的关键字(路径里碰巧含 dashscope)不触发。 + let mut path_only = config(); + path_only.base_url = "https://example.com/proxy/dashscope/v1".into(); + assert!(!path_only.audio_requires_data_url()); + let mut empty = config(); + empty.base_url = String::new(); + assert!(!empty.audio_requires_data_url()); + } + + #[test] + fn omni_audio_data_url_ignores_non_host_url_components() { + for base_url in [ + "https://dashscope@api.example.com/v1", + "https://user:aliyuncs@api.example.com/v1", + "https://api.example.com?provider=dashscope", + "https://api.example.com#aliyuncs", + ] { + let mut other = config(); + other.base_url = base_url.into(); + assert!(!other.audio_requires_data_url(), "{base_url}"); + let provider = OpenAICompatibleOmni::new(other); + let messages = provider.build_messages("system", "", Some(&[1u8, 2, 3, 4])); + assert_eq!(messages[1]["content"][0]["input_audio"]["data"], "AQIDBA=="); + } + } + #[test] fn omni_body_has_stream_model_and_temperature() { let provider = OpenAICompatibleOmni::new(config()); diff --git a/openless-all/app/crates/openless-core/src/persistence.rs b/openless-all/app/crates/openless-core/src/persistence.rs index f0f67482d..b86d8abac 100644 --- a/openless-all/app/crates/openless-core/src/persistence.rs +++ b/openless-all/app/crates/openless-core/src/persistence.rs @@ -1,6 +1,7 @@ //! Small framework-independent JSON persistence primitives. use std::fs; +use std::io::{Read, Write}; use std::path::Path; use serde::de::DeserializeOwned; @@ -21,9 +22,55 @@ pub(crate) fn read_or_default( } pub(crate) fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), BackendError> { + use crate::cloud_sync_e2ee_store::gate::{begin_unobserved_atomic, ChangeOrigin}; + let permit = begin_unobserved_atomic(path)?; + let outcome = atomic_replace(path, contents); + match outcome { + Ok(changed) => { + if changed { + crate::cloud_sync_e2ee_store::gate::note_successful_write(path); + } + if let Some(permit) = permit { + let result = if changed { + permit.commit(ChangeOrigin::User).map(|_| ()) + } else { + permit.abort_unmodified() + }; + result.map_err(|_| persistence_error("record saved data generation"))?; + } + Ok(()) + } + Err(error) => { + if error.code != BackendErrorCode::OutcomeUnknown { + if let Some(permit) = permit { + permit + .abort_unmodified() + .map_err(|_| persistence_error("clear unused mutation intent"))?; + } + } + Err(error) + } + } +} + +/// Restore hooks require proof of the same exclusive gate; they emit one final batch change. +pub(crate) fn atomic_write_for_sync( + path: &Path, + contents: &[u8], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, +) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(path, permit)?; + atomic_replace(path, contents).map(|_| ()) +} + +fn atomic_replace(path: &Path, contents: &[u8]) -> Result { if path.as_os_str().is_empty() { return Err(persistence_error("empty JSON store path")); } + if fs::read(path).is_ok_and(|existing| existing == contents) { + ensure_durable_file(path)?; + return Ok(false); + } if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|_| persistence_error("create JSON store directory"))?; } @@ -33,15 +80,202 @@ pub(crate) fn atomic_write(path: &Path, contents: &[u8]) -> Result<(), BackendEr .unwrap_or_default(); let temporary = path.with_file_name(format!("{file_name}.tmp-{}", uuid::Uuid::new_v4().simple())); - fs::write(&temporary, contents) - .map_err(|_| persistence_error("write JSON store temporary file"))?; + let write = (|| { + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|_| persistence_error("create JSON store temporary file"))?; + file.write_all(contents) + .map_err(|_| persistence_error("write JSON store temporary file"))?; + file.sync_all() + .map_err(|_| persistence_error("flush JSON store temporary file")) + })(); + if let Err(error) = write { + let _ = fs::remove_file(&temporary); + return Err(error); + } + #[cfg(test)] + if take_test_fault(path, false) { + let _ = fs::remove_file(&temporary); + return Err(persistence_error("fixture pre-rename failure")); + } if fs::rename(&temporary, path).is_err() { let _ = fs::remove_file(&temporary); return Err(persistence_error("replace JSON store file")); } + #[cfg(test)] + if take_test_fault(path, true) { + return Err(BackendError::new( + BackendErrorCode::OutcomeUnknown, + "fixture directory flush failure", + )); + } + #[cfg(unix)] + if let Some(parent) = path.parent() { + fs::File::open(parent) + .and_then(|file| file.sync_all()) + .map_err(|_| { + BackendError::new( + BackendErrorCode::OutcomeUnknown, + "JSON replacement outcome needs verification", + ) + })?; + } + Ok(true) +} + +/// A read-back of an uncertain rename is complete only after its data and directory flush. +pub(crate) fn ensure_durable_file(path: &Path) -> Result<(), BackendError> { + let mut options = fs::OpenOptions::new(); + options.read(true); + #[cfg(windows)] + options.write(true); + options + .open(path) + .and_then(|file| file.sync_all()) + .map_err(|_| { + BackendError::new( + BackendErrorCode::OutcomeUnknown, + "stored data durability needs verification", + ) + })?; + #[cfg(unix)] + if let Some(parent) = path.parent() { + fs::File::open(parent) + .and_then(|file| file.sync_all()) + .map_err(|_| { + BackendError::new( + BackendErrorCode::OutcomeUnknown, + "stored directory durability needs verification", + ) + })?; + } Ok(()) } pub(crate) fn persistence_error(operation: &'static str) -> BackendError { BackendError::new(BackendErrorCode::Persistence, operation) } + +/// Sync must either preserve every stored field or report an unsupported schema. +pub(crate) fn read_lossless_rows( + path: &Path, + aliases: &[&str], +) -> Result, BackendError> { + let file = match fs::File::open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(_) => return Err(persistence_error("read sync rows")), + }; + let limit = crate::cloud_sync_e2ee_documents::MAX_JSON_BYTES; + let mut bytes = Vec::new(); + file.take((limit + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| persistence_error("read sync rows"))?; + if bytes.len() > limit { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "payload_too_large", + )); + } + if bytes.is_empty() { + return Ok(Vec::new()); + } + let raw: Vec = + serde_json::from_slice(&bytes).map_err(|_| persistence_error("decode sync rows"))?; + raw.into_iter() + .map(|value| { + let row: T = serde_json::from_value(value.clone()) + .map_err(|_| persistence_error("decode sync row"))?; + let canonical = + serde_json::to_value(&row).map_err(|_| persistence_error("encode sync row"))?; + ensure_lossless_value(&value, &canonical, aliases)?; + Ok(row) + }) + .collect() +} + +/// Every explicit source value must remain representable. Canonical defaults may be added; +/// aliases are allowed only at the root and must be explicitly registered by the caller. +pub(crate) fn ensure_lossless_value( + raw: &serde_json::Value, + canonical: &serde_json::Value, + aliases: &[&str], +) -> Result<(), BackendError> { + use serde_json::Value; + match (raw, canonical) { + (Value::Object(raw), Value::Object(canonical)) => { + for (key, value) in raw { + if aliases.contains(&key.as_str()) { + continue; + } + let next = canonical.get(key).ok_or_else(|| { + BackendError::new(BackendErrorCode::Unsupported, "unsupported stored fields") + })?; + ensure_lossless_value(value, next, &[])?; + } + } + (Value::Array(raw), Value::Array(canonical)) if raw.len() == canonical.len() => { + for (raw, canonical) in raw.iter().zip(canonical) { + ensure_lossless_value(raw, canonical, &[])?; + } + } + _ if raw == canonical => {} + _ => { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "stored value cannot be preserved", + )) + } + } + Ok(()) +} + +pub(crate) fn read_lossless_object( + path: &Path, +) -> Result { + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(T::default()), + Err(_) => return Err(persistence_error("read JSON store")), + }; + if bytes.is_empty() { + return Ok(T::default()); + } + let raw: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|_| persistence_error("decode JSON store"))?; + let value: T = + serde_json::from_value(raw.clone()).map_err(|_| persistence_error("decode JSON store"))?; + let canonical = + serde_json::to_value(&value).map_err(|_| persistence_error("encode JSON store"))?; + ensure_lossless_value(&raw, &canonical, &[])?; + Ok(value) +} + +#[cfg(test)] +static TEST_FAULTS: std::sync::LazyLock< + std::sync::Mutex>, +> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::BTreeMap::new())); +#[cfg(test)] +pub(crate) fn fail_next_atomic_write(path: &Path, after_rename: bool) { + TEST_FAULTS + .lock() + .unwrap() + .insert(path.to_path_buf(), after_rename); +} +#[cfg(test)] +fn take_test_fault(path: &Path, after_rename: bool) -> bool { + let mut faults = TEST_FAULTS.lock().unwrap(); + if faults.get(path) == Some(&after_rename) { + faults.remove(path); + true + } else { + false + } +} diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 968a3b217..7b8e37198 100644 --- a/openless-all/app/crates/openless-core/src/polish.rs +++ b/openless-all/app/crates/openless-core/src/polish.rs @@ -190,6 +190,7 @@ fn is_builtin_llm_provider(provider_id: &str) -> bool { | "mimo" | "cometapi" | "openrouterFree" + | "requesty" | "orcarouter" | "alibabaCoding" | "codingPlanX" @@ -1450,7 +1451,13 @@ pub(crate) fn append_utf8_sse_chunk( chunk: &[u8], ) -> Result<(), LLMError> { pending.extend_from_slice(chunk); - drain_complete_utf8(buffer, pending) + drain_complete_utf8(buffer, pending)?; + // Normalize only after reassembling UTF-8, including CR/LF split across chunks. + // Omni, Codex and TextEventStream share this framing boundary. + if buffer.contains("\r\n") { + *buffer = buffer.replace("\r\n", "\n"); + } + Ok(()) } pub(crate) fn finish_utf8_sse_chunks( @@ -2169,6 +2176,96 @@ mod tests { format!("{}.{}.sig", header, payload) } + #[test] + fn utf8_sse_decoder_emits_each_crlf_or_lf_frame_before_the_next_one() { + let frames = [ + "data: {\"delta\":\"你好🙂\"}\r\n\r\n", + "data: {\"delta\":\"second\"}\n\n", + "data: [DONE]\r\n\r\n", + ]; + let mut buffer = String::new(); + let mut pending = Vec::new(); + let mut emitted = Vec::new(); + for (index, frame) in frames.iter().enumerate() { + // One byte per HTTP chunk splits both CRLF pairs and UTF-8 codepoints. + for byte in frame.as_bytes() { + append_utf8_sse_chunk(&mut buffer, &mut pending, &[*byte]).unwrap(); + while let Some(end) = buffer.find("\n\n") { + emitted.push(buffer[..end].to_string()); + buffer.drain(..end + 2); + } + } + assert_eq!( + emitted.len(), + index + 1, + "must emit before another frame or EOF" + ); + } + finish_utf8_sse_chunks(&mut buffer, &mut pending).unwrap(); + assert_eq!( + emitted, + [ + "data: {\"delta\":\"你好🙂\"}", + "data: {\"delta\":\"second\"}", + "data: [DONE]" + ] + ); + assert!(buffer.is_empty()); + assert!(pending.is_empty()); + } + + #[test] + fn utf8_sse_decoder_normalizes_crlf_at_every_network_split() { + let frame = "data: {\"delta\":\"你好🙂\"}\r\n\r\n"; + for split in 0..=frame.len() { + let mut buffer = String::new(); + let mut pending = Vec::new(); + append_utf8_sse_chunk(&mut buffer, &mut pending, &frame.as_bytes()[..split]).unwrap(); + append_utf8_sse_chunk(&mut buffer, &mut pending, &frame.as_bytes()[split..]).unwrap(); + assert_eq!(buffer, "data: {\"delta\":\"你好🙂\"}\n\n", "split={split}"); + assert!(pending.is_empty()); + } + } + + #[test] + fn codex_crlf_deltas_and_completion_do_not_wait_for_eof() { + for terminal in ["response.done", "response.completed"] { + let frames = [ + "data: {\"type\":\"response.output_text.delta\",\"delta\":\"你🙂\"}\r\n\r\n".to_string(), + "data: {\"type\":\"response.text.delta\",\"text\":\"好\"}\n\n".to_string(), + format!("data: {{\"type\":\"{terminal}\",\"response\":{{\"output_text\":\"最终文本\"}}}}\r\n\r\n"), + "data: [DONE]\r\n\r\n".to_string(), + ]; + let mut buffer = String::new(); + let mut pending = Vec::new(); + let mut full_text = String::new(); + let mut final_text = String::new(); + let callbacks = StdMutex::new(Vec::new()); + for (index, frame) in frames.iter().enumerate() { + for byte in frame.as_bytes() { + append_utf8_sse_chunk(&mut buffer, &mut pending, &[*byte]).unwrap(); + while let Some(end) = buffer.find("\n\n") { + let event = buffer[..end].to_string(); + buffer.drain(..end + 2); + handle_codex_sse_event(&event, &mut full_text, &mut final_text, &|text| { + callbacks.lock().unwrap().push(text.to_string()); + }); + } + } + assert_eq!(full_text, if index == 0 { "你🙂" } else { "你🙂好" }); + assert_eq!( + callbacks.lock().unwrap().len(), + if index == 0 { 1 } else { 2 } + ); + if index >= 2 { + assert_eq!(final_text, "最终文本", "retain Codex completion fallback"); + } + } + finish_utf8_sse_chunks(&mut buffer, &mut pending).unwrap(); + assert!(buffer.is_empty()); + } + } + #[test] fn utf8_sse_decoder_preserves_multibyte_split_across_chunks() { let mut buffer = String::new(); diff --git a/openless-all/app/crates/openless-core/src/preferences.rs b/openless-all/app/crates/openless-core/src/preferences.rs index 35a4b8156..113cea668 100644 --- a/openless-all/app/crates/openless-core/src/preferences.rs +++ b/openless-all/app/crates/openless-core/src/preferences.rs @@ -14,6 +14,7 @@ fn persistence_error(operation: impl Into) -> BackendError { } fn read_preferences(path: &Path) -> Result { + let read_only = crate::cloud_sync_e2ee_store::gate::recovery_pending_for_path(path); if !path.exists() { return Ok(UserPreferences::default()); } @@ -24,9 +25,14 @@ fn read_preferences(path: &Path) -> Result { let preferences = match serde_json::from_slice::(&bytes) { Ok(preferences) => preferences, - Err(error) => { + Err(_error) => { + if read_only { + // Recovery owns the authoritative before/after images. Do not back up, + // migrate, or overwrite a partially restored file during construction. + return Ok(UserPreferences::salvage_from_json_bytes(&bytes)); + } log::error!( - "[prefs] strict decode of {} failed: {error}; backing up and salvaging", + "[prefs] strict decode of {} failed; backing up and salvaging", path.display() ); let backup = backup_unparseable_preferences(path, &bytes)?; @@ -35,8 +41,11 @@ fn read_preferences(path: &Path) -> Result { backup.display() ); let salvaged = UserPreferences::salvage_from_json_bytes(&bytes); - match serde_json::to_vec_pretty(&salvaged) - .map_err(|_| persistence_error("encode salvaged preferences")) + match preferences_json_preserving_unknown(path, &salvaged) + .and_then(|value| { + serde_json::to_vec_pretty(&value) + .map_err(|_| persistence_error("encode salvaged preferences")) + }) .and_then(|json| atomic_write(path, &json)) { Ok(()) => log::info!( @@ -60,9 +69,12 @@ fn read_preferences(path: &Path) -> Result { .and_then(|flag| flag.as_bool()) }) .unwrap_or(false); - if !streaming_default_migrated { - match serde_json::to_vec_pretty(&preferences) - .map_err(|_| persistence_error("encode migrated preferences")) + if !streaming_default_migrated && !read_only { + match preferences_json_preserving_unknown(path, &preferences) + .and_then(|value| { + serde_json::to_vec_pretty(&value) + .map_err(|_| persistence_error("encode migrated preferences")) + }) .and_then(|json| atomic_write(path, &json)) { Ok(()) => log::info!("[prefs] migrated streamingInsert default marker"), @@ -76,6 +88,87 @@ fn read_preferences(path: &Path) -> Result { Ok(preferences) } +fn preferences_json_preserving_unknown( + path: &Path, + preferences: &UserPreferences, +) -> Result { + let mut value = + serde_json::to_value(preferences).map_err(|_| persistence_error("encode preferences"))?; + let existing = match fs::read(path) { + Ok(bytes) => Some(bytes), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => None, + Err(_) => return Err(persistence_error("read unknown preference fields")), + }; + if let Some(bytes) = existing { + if let Ok(serde_json::Value::Object(old)) = serde_json::from_slice(&bytes) { + let object = value + .as_object_mut() + .ok_or_else(|| persistence_error("preferences object"))?; + let mut old = serde_json::Value::Object(old); + if let Some(map) = old.as_object_mut() { + map.remove("windowsSendinputInsertionOnly"); + map.remove("windowsSendinputNewlineMode"); + } + let mut canonical = serde_json::to_value(preferences) + .map_err(|_| persistence_error("encode preferences"))?; + preserve_unknown_fields(&mut canonical, &old)?; + *object = canonical + .as_object_mut() + .ok_or_else(|| persistence_error("preferences object"))? + .clone(); + } + } + Ok(value) +} + +fn preserve_unknown_fields( + canonical: &mut serde_json::Value, + old: &serde_json::Value, +) -> Result<(), BackendError> { + use serde_json::Value; + match (canonical, old) { + (Value::Object(current), Value::Object(old)) => { + for (key, value) in old { + if let Some(known) = current.get_mut(key) { + preserve_unknown_fields(known, value)?; + } else { + current.insert(key.clone(), value.clone()); + } + } + } + (Value::Array(current), Value::Array(old)) => { + // Existing object arrays have stable identities. Preserve extensions across + // reordering/editing, and let explicit entity deletion remove its extensions. + for item in current.iter_mut().filter(|item| item.is_object()) { + let key = ["packId", "code", "id"] + .into_iter() + .find(|key| item.get(*key).is_some()); + let Some(key) = key else { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "unsupported preference array extension", + )); + }; + let matches: Vec<_> = old + .iter() + .filter(|candidate| candidate.get(key) == item.get(key)) + .collect(); + if matches.len() > 1 { + return Err(BackendError::new( + BackendErrorCode::Unsupported, + "ambiguous preference array identity", + )); + } + if let Some(previous) = matches.first() { + preserve_unknown_fields(item, previous)?; + } + } + } + _ => {} + } + Ok(()) +} + fn backup_unparseable_preferences(path: &Path, bytes: &[u8]) -> Result { let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -109,7 +202,15 @@ impl PreferencesStore { if path.as_os_str().is_empty() { return Err(persistence_error("preferences path is empty")); } - let preferences = read_preferences(&path)?; + let preferences = if crate::cloud_sync_e2ee_store::gate::recovery_pending_for_path(&path) { + read_preferences(&path)? + } else { + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || read_preferences(&path), + )? + }; Ok(Self { path, state: Mutex::new(preferences), @@ -141,6 +242,11 @@ impl PreferencesStore { .clone() } + /// Immutable Host-selected path, used to check a write barrier before taking store locks. + pub(crate) fn persistence_path(&self) -> &Path { + &self.path + } + /// Keep the live value locked until a coordinated restore has committed all of its files. pub(crate) fn cloud_sync_access(&self) -> (std::sync::MutexGuard<'_, UserPreferences>, &Path) { ( @@ -152,14 +258,47 @@ impl PreferencesStore { } pub fn set(&self, preferences: UserPreferences) -> Result<(), BackendError> { - let json = serde_json::to_vec_pretty(&preferences) - .map_err(|_| persistence_error("encode preferences"))?; + self.update(|current| *current = preferences) + } + + pub(crate) fn sync_snapshot( + &self, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + self.sync_snapshot_readonly() + } + + pub(crate) fn sync_snapshot_readonly(&self) -> Result { let mut state = self .state .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - atomic_write(&self.path, &json)?; - *state = preferences; + if self.path.exists() { + let bytes = + fs::read(&self.path).map_err(|_| persistence_error("read sync preferences"))?; + *state = serde_json::from_slice(&bytes) + .map_err(|_| persistence_error("decode sync preferences"))?; + } + preferences_json_preserving_unknown(&self.path, &state) + } + + pub(crate) fn sync_replace_raw( + &self, + value: serde_json::Value, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let parsed: UserPreferences = serde_json::from_value(value.clone()) + .map_err(|_| persistence_error("validate restored preferences"))?; + let bytes = serde_json::to_vec_pretty(&value) + .map_err(|_| persistence_error("encode restored preferences"))?; + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + crate::persistence::atomic_write_for_sync(&self.path, &bytes, permit)?; + *state = parsed; Ok(()) } @@ -171,33 +310,50 @@ impl PreferencesStore { &self, update: impl FnOnce(&mut UserPreferences) -> R, ) -> Result { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut next = state.clone(); - let result = update(&mut next); - let json = serde_json::to_vec_pretty(&next) - .map_err(|_| persistence_error("encode preferences"))?; - atomic_write(&self.path, &json)?; - *state = next; - Ok(result) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation_deciding(&self.path, || { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut next = match fs::read(&self.path) { + Ok(bytes) if !bytes.is_empty() => serde_json::from_slice::(&bytes) + .map_err(|_| persistence_error("decode preferences before save"))?, + Ok(_) => state.clone(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => state.clone(), + Err(_) => return Err(persistence_error("read preferences before save")), + }; + let previous = + serde_json::to_value(&next).map_err(|_| persistence_error("encode preferences"))?; + let result = update(&mut next); + let value = preferences_json_preserving_unknown(&self.path, &next)?; + let changed = crate::cloud_sync_e2ee_documents::registry::PREFERENCE_FIELDS + .iter() + .any(|field| { + field.class + != crate::cloud_sync_e2ee_documents::registry::PreferenceClass::Excluded + && previous.get(field.key) != value.get(field.key) + }); + let origin = if changed { + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User + } else { + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::LocalOnly + }; + let json = serde_json::to_vec_pretty(&value) + .map_err(|_| persistence_error("encode preferences"))?; + atomic_write(&self.path, &json)?; + *state = next; + Ok((result, origin)) + }) } pub fn set_preserving_current_style_preferences( &self, mut preferences: UserPreferences, ) -> Result<(), BackendError> { - let mut state = self - .state - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - preferences.preserve_style_preferences_from(&state); - let json = serde_json::to_vec_pretty(&preferences) - .map_err(|_| persistence_error("encode preferences"))?; - atomic_write(&self.path, &json)?; - *state = preferences; - Ok(()) + self.update(|current| { + preferences.preserve_style_preferences_from(current); + *current = preferences; + }) } } diff --git a/openless-all/app/crates/openless-core/src/provider_rules.rs b/openless-all/app/crates/openless-core/src/provider_rules.rs index ce6a51154..089c1e168 100644 --- a/openless-all/app/crates/openless-core/src/provider_rules.rs +++ b/openless-all/app/crates/openless-core/src/provider_rules.rs @@ -62,6 +62,7 @@ const LLM_PROVIDER_TYPES: &[(&str, &str)] = &[ ("mimo", "mimo"), ("cometapi", "cometapi"), ("openrouterFree", "openrouterFree"), + ("requesty", "requesty"), ("orcarouter", "orcarouter"), ("alibabaCoding", "alibabaCoding"), ("codingPlanX", "codingPlanX"), @@ -601,6 +602,7 @@ pub fn default_llm_endpoint(provider_type: &str) -> Option<&'static str> { "mimo" => Some("https://api.xiaomimimo.com/v1"), "cometapi" => Some("https://api.cometapi.com/v1"), "openrouterFree" => Some("https://openrouter.ai/api/v1"), + "requesty" => Some("https://router.requesty.ai/v1"), "orcarouter" => Some(crate::asr::mimo::ORCAROUTER_DEFAULT_ENDPOINT), "alibabaCoding" => Some("https://coding-intl.dashscope.aliyuncs.com/v1"), "codingPlanX" => Some("https://api.codingplanx.ai/v1"), @@ -623,6 +625,7 @@ pub fn default_llm_model(provider_type: &str) -> Option<&'static str> { crate::polish::CODEX_OAUTH_PROVIDER_ID => Some(crate::polish::CODEX_DEFAULT_MODEL), "mimo" => Some("xiaomi/mimo-v2-flash"), "openrouterFree" => Some("qwen/qwen3-coder:free"), + "requesty" => Some("openai/gpt-4o-mini"), "orcarouter" => Some("orcarouter/fusion-flash"), "alibabaCoding" => Some("qwen3-coder-plus"), "codingPlanX" => Some("gpt-5-mini"), @@ -1341,6 +1344,42 @@ mod tests { assert!(llm_configured("opencode", &configuration)); } + #[test] + fn requesty_descriptor_supplies_defaults_formats_and_credentials() { + use crate::llm_protocol::LlmRequestFormat; + assert!(crate::cloud_providers::SHARED_CLOUD_LLM_PROVIDER_TYPES.contains(&"requesty")); + let descriptor = provider_descriptor(ProviderKind::Llm, "requesty").unwrap(); + assert_eq!(descriptor.label_key, "requesty"); + assert_eq!( + descriptor.default_endpoint.as_deref(), + Some("https://router.requesty.ai/v1") + ); + assert_eq!( + descriptor.default_model.as_deref(), + Some("openai/gpt-4o-mini") + ); + assert_eq!( + descriptor.default_request_format, + Some(LlmRequestFormat::ChatCompletions) + ); + assert_eq!(descriptor.supported_request_formats, LlmRequestFormat::ALL); + assert_eq!(descriptor.validation_probe, ValidationProbe::LlmText); + assert!(api_key_required( + ProviderKind::Llm, + "requesty", + Some("https://router.requesty.ai/v1/chat/completions") + )); + let mut configuration = CredentialConfiguration { + llm_endpoint: true, + llm_api_key_required: true, + llm_model: true, + ..CredentialConfiguration::default() + }; + assert!(!llm_configured("requesty", &configuration)); + configuration.llm_api_key = true; + assert!(llm_configured("requesty", &configuration)); + } + #[test] fn tencent_cloud_asr_and_tokenhub_supply_defaults_and_credentials() { assert!(crate::cloud_providers::SHARED_CLOUD_ASR_PROVIDER_TYPES.contains(&"tencent-cloud")); diff --git a/openless-all/app/crates/openless-core/src/qa_service.rs b/openless-all/app/crates/openless-core/src/qa_service.rs index ab230df6f..c7f5c733c 100644 --- a/openless-all/app/crates/openless-core/src/qa_service.rs +++ b/openless-all/app/crates/openless-core/src/qa_service.rs @@ -24,6 +24,7 @@ struct QaState { enum QaSubmission { Text(String), + ScopedText { text: String, expected_session: Option }, Captured(QaInput), SelectionEdit { selection_voice_session_id: SessionId, @@ -45,6 +46,7 @@ pub struct QaService { persistence: Option>, selection_voice: Option>, voice_sessions: Arc, + runtime_work: Arc, } pub(crate) struct QaPersistence { @@ -77,6 +79,7 @@ impl QaService { host_actions, events: Arc::new(Mutex::new(None)), state: Arc::new(Mutex::new(QaState::default())), + runtime_work: Arc::new(crate::voice_session::RuntimeActivityGate::default()), presentation: Arc::new(Mutex::new(())), persistence: None, selection_voice: None, @@ -96,6 +99,7 @@ impl QaService { host_actions, events: Arc::new(Mutex::new(None)), state: Arc::new(Mutex::new(QaState::default())), + runtime_work: Arc::new(crate::voice_session::RuntimeActivityGate::default()), presentation: Arc::new(Mutex::new(())), persistence: Some(Arc::new(persistence)), selection_voice: Some(selection_voice), @@ -103,6 +107,20 @@ impl QaService { } } + fn begin_runtime_work( + &self, + ) -> Result { + let state = self.state.lock().expect("QA state lock poisoned"); + if matches!( + state.snapshot.phase, + QaPhase::Recording | QaPhase::Thinking | QaPhase::AwaitingApproval + ) { + Ok(self.runtime_work.existing_work()) + } else { + self.runtime_work.acquire() + } + } + fn progress_sink(&self) -> Arc { Arc::new(QaServiceProgress { state: Arc::clone(&self.state), @@ -166,6 +184,7 @@ impl QaService { } async fn begin_recording(&self) -> Result<(), BackendError> { + let _runtime = self.begin_runtime_work()?; let session_id = SessionId::new(); { let _presentation = self @@ -218,14 +237,15 @@ impl QaService { } async fn finish_recording(&self, session_id: SessionId) -> Result<(), BackendError> { - { + let _runtime = { let mut state = self.state.lock().expect("QA state lock poisoned"); // Validate the callback's generation and claim the finish under // one lock. A delayed silence event cannot toggle a completed turn // back on, stop its successor, or compete with a manual stop. ensure_current_phase(&state.snapshot, session_id, QaPhase::Recording)?; state.snapshot.phase = QaPhase::Thinking; - } + self.runtime_work.existing_work() + }; self.publish_snapshot(QaStateKind::Loading, Some((session_id, QaPhase::Thinking))); let input = match self.runtime.finish_recording(session_id).await { @@ -261,8 +281,14 @@ impl QaService { } async fn submit_inner(&self, submission: QaSubmission) -> Result<(), BackendError> { + let _runtime = self.begin_runtime_work()?; + let expected_session = match &submission { + QaSubmission::ScopedText { expected_session, .. } => Some(*expected_session), + _ => None, + }; let text = match &submission { QaSubmission::Text(text) => text, + QaSubmission::ScopedText { text, .. } => text, QaSubmission::Captured(input) => &input.text, QaSubmission::SelectionEdit { instruction, .. } => instruction, } @@ -280,6 +306,9 @@ impl QaService { let previous = { let mut state = self.state.lock().expect("QA state lock poisoned"); ensure_qa_idle(&state.snapshot)?; + if expected_session.is_some_and(|expected| expected != state.snapshot.session_id) { + return Err(BackendError::new(BackendErrorCode::InvalidState, "qa_context_changed")); + } let previous = state.snapshot.clone(); let conversation_id = state.snapshot.conversation_id.unwrap_or(session_id); state.snapshot.phase = QaPhase::Thinking; @@ -305,7 +334,7 @@ impl QaService { } let prepared = match submission { - QaSubmission::Text(_) => self.runtime.prepare_text(session_id, text).await, + QaSubmission::Text(_) | QaSubmission::ScopedText { .. } => self.runtime.prepare_text(session_id, text).await, QaSubmission::Captured(mut input) => { input.text = text; self.runtime.prepare_captured_text(session_id, input).await @@ -556,6 +585,21 @@ impl QaService { &self, requested_session_id: Option, clear: bool, + ) -> Result<(), BackendError> { + let service = self.clone(); + self.runtime_work + .cleanup(Box::pin(async move { + service + .cancel_inner_owned(requested_session_id, clear) + .await + })) + .await + } + + async fn cancel_inner_owned( + &self, + requested_session_id: Option, + clear: bool, ) -> Result<(), BackendError> { let (runtime_session_id, conversation_id, host_result) = { let _presentation = self @@ -631,7 +675,11 @@ impl QaService { } async fn cancel_runtime_best_effort(&self, session_id: SessionId) { - if let Err(error) = self.runtime.cancel(session_id).await { + if let Err(error) = self + .runtime_work + .cleanup(self.runtime.cancel(session_id)) + .await + { log::warn!("failed to release QA runtime session after an error: {error}"); } } @@ -659,6 +707,23 @@ impl QaService { } impl QaApi for QaService { + fn bind_runtime_restore_guard( + &self, + guard: crate::domains::RuntimeRestoreGuard, + spawner: Arc, + ) -> Result<(), BackendError> { + self.runtime_work.bind(guard, spawner) + } + + fn runtime_restore_idle(&self) -> bool { + self.state.lock().is_ok_and(|state| { + matches!( + state.snapshot.phase, + QaPhase::Idle | QaPhase::Completed | QaPhase::Cancelled | QaPhase::Failed + ) && self.runtime_work.runtime_restore_idle() + }) + } + fn bind_event_publisher(&self, publisher: BackendEventPublisher) { *self .events @@ -720,7 +785,7 @@ impl QaApi for QaService { error: BackendError, ) -> BoxFuture<'static, Result<(), BackendError>> { let service = self.clone(); - Box::pin(async move { + self.runtime_work.cleanup(Box::pin(async move { { let state = service.state.lock().expect("QA state lock poisoned"); ensure_current_phase(&state.snapshot, session_id, QaPhase::Recording)?; @@ -728,7 +793,7 @@ impl QaApi for QaService { service.fail_if_current(session_id, &error); service.voice_sessions.release(session_id); service.runtime.cancel(session_id).await - }) + })) } fn stop_recording( @@ -744,6 +809,11 @@ impl QaApi for QaService { Box::pin(async move { service.submit_text_inner(text).await }) } + fn submit_text_in_context(&self, text: String, expected_session: Option) -> BoxFuture<'static, Result<(), BackendError>> { + let service = self.clone(); + Box::pin(async move { service.submit_inner(QaSubmission::ScopedText { text, expected_session }).await }) + } + fn submit_captured_text(&self, input: QaInput) -> BoxFuture<'static, Result<(), BackendError>> { let service = self.clone(); Box::pin(async move { service.submit_inner(QaSubmission::Captured(input)).await }) @@ -1067,3 +1137,281 @@ fn publish_qa_snapshot_impl( )), ); } + +#[cfg(test)] +mod runtime_restore_tests { + use super::*; + use crate::domains::QaTurnResult; + use std::sync::atomic::{AtomicBool, AtomicUsize}; + + #[derive(Clone, Default)] + struct Runtime { + calls: Arc, + block_prepare: Arc, + fail_prepare: Arc, + prepare_entered: Arc, + prepare_release: Arc, + block_cancel: Arc, + cancel_entered: Arc, + cancel_release: Arc, + cancel_done: Arc, + } + impl QaRuntimeAdapter for Runtime { + fn prepare_text( + &self, + _: SessionId, + text: String, + ) -> BoxFuture<'static, Result> { + let this = self.clone(); + Box::pin(async move { + this.calls.fetch_add(1, Ordering::SeqCst); + if this.fail_prepare.load(Ordering::SeqCst) { + return Err(BackendError::new( + BackendErrorCode::Provider, + "fixture prepare failure", + )); + } + if this.block_prepare.load(Ordering::SeqCst) { + this.prepare_entered.notify_one(); + this.prepare_release.notified().await; + } + Ok(QaInput { + text, + selection_text: None, + selection_source_app: None, + }) + }) + } + fn start_recording( + &self, + _: SessionId, + _: Arc, + ) -> BoxFuture<'static, Result<(), BackendError>> { + self.calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async { Ok(()) }) + } + fn finish_recording( + &self, + _: SessionId, + ) -> BoxFuture<'static, Result> { + Box::pin(async { + Ok(QaInput { + text: "question".into(), + selection_text: None, + selection_source_app: None, + }) + }) + } + fn answer( + &self, + _: QaTurnRequest, + _: Arc, + ) -> BoxFuture<'static, Result> { + Box::pin(async { + Ok(QaTurnResult { + answer: "answer".into(), + }) + }) + } + fn cancel(&self, _: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { + let this = self.clone(); + Box::pin(async move { + if this.block_cancel.load(Ordering::SeqCst) { + this.cancel_entered.notify_one(); + this.cancel_release.notified().await; + } + this.cancel_done.notify_one(); + Ok(()) + }) + } + } + fn service(runtime: Runtime, restoring: Arc) -> QaService { + let service = QaService::new(Arc::new(runtime), Arc::new(crate::ports::NoopHostActions)); + service.bind_event_publisher(BackendEventPublisher::new(Arc::new( + crate::events::EventBus::new(32), + ))); + service + .bind_runtime_restore_guard( + Arc::new(move || { + if restoring.load(Ordering::Acquire) { + Err(BackendError::new(BackendErrorCode::Busy, "restoring")) + } else { + Ok(()) + } + }), + Arc::new(crate::config::TokioTaskSpawner), + ) + .unwrap(); + service + } + + #[tokio::test] + async fn runtime_restore_rejects_qa_text_and_voice_before_host_work_but_keeps_snapshot_readable( + ) { + let runtime = Runtime::default(); + let flag = Arc::new(AtomicBool::new(true)); + let service = service(runtime.clone(), flag.clone()); + assert_eq!( + service + .submit_text("question".into()) + .await + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + assert_eq!( + service.toggle_recording().await.unwrap_err().code, + BackendErrorCode::Busy + ); + assert_eq!(runtime.calls.load(Ordering::SeqCst), 0); + assert_eq!(service.snapshot().await.unwrap().phase, QaPhase::Idle); + assert!(service.runtime_restore_idle()); + flag.store(false, Ordering::Release); + service.submit_text("question".into()).await.unwrap(); + assert_eq!(service.snapshot().await.unwrap().phase, QaPhase::Completed); + assert!(service.runtime_restore_idle()); + } + + #[tokio::test] + async fn runtime_restore_qa_text_lease_does_not_take_the_exclusive_voice_slot() { + let runtime = Runtime::default(); + runtime.block_prepare.store(true, Ordering::SeqCst); + let flag = Arc::new(AtomicBool::new(false)); + let service = service(runtime.clone(), flag.clone()); + let text = tokio::spawn(service.submit_text("question".into())); + runtime.prepare_entered.notified().await; + assert!(!service.runtime_restore_idle()); + let voice = SessionId::new(); + service + .voice_sessions + .acquire(voice, crate::voice_session::VoiceSessionKind::Dictation) + .unwrap(); + service.voice_sessions.release(voice); + flag.store(true, Ordering::Release); + assert_eq!( + service + .submit_text("another".into()) + .await + .unwrap_err() + .code, + BackendErrorCode::Busy + ); + runtime.prepare_release.notify_one(); + text.await.unwrap().unwrap(); + assert!( + service.runtime_restore_idle(), + "an attempted restore must not break the accepted turn" + ); + } + + #[tokio::test] + async fn runtime_restore_qa_cancel_waiter_drop_keeps_cleanup_busy_until_drained() { + let runtime = Runtime::default(); + runtime.block_cancel.store(true, Ordering::SeqCst); + let service = service(runtime.clone(), Arc::new(AtomicBool::new(false))); + service.toggle_recording().await.unwrap(); + let cancel = tokio::spawn(service.cancel(None)); + runtime.cancel_entered.notified().await; + assert_eq!(service.snapshot().await.unwrap().phase, QaPhase::Cancelled); + assert!(!service.runtime_restore_idle()); + cancel.abort(); + assert!(cancel.await.unwrap_err().is_cancelled()); + assert!(!service.runtime_restore_idle()); + runtime.cancel_release.notify_one(); + runtime.cancel_done.notified().await; + for _ in 0..4 { + tokio::task::yield_now().await; + } + assert!(service.runtime_restore_idle()); + } + + #[test] + fn runtime_restore_qa_probe_cannot_pass_between_check_and_claim() { + let flag = Arc::new(AtomicBool::new(false)); + let service = QaService::new( + Arc::new(Runtime::default()), + Arc::new(crate::ports::NoopHostActions), + ); + let (checked, receive_checked) = std::sync::mpsc::channel(); + let (resume, receive_resume) = std::sync::mpsc::channel(); + let receive_resume = Mutex::new(receive_resume); + let check_flag = flag.clone(); + service.bind_event_publisher(BackendEventPublisher::new(Arc::new( + crate::events::EventBus::new(32), + ))); + service + .bind_runtime_restore_guard( + Arc::new(move || { + assert!(!check_flag.load(Ordering::Acquire)); + checked.send(()).unwrap(); + receive_resume.lock().unwrap().recv().unwrap(); + Ok(()) + }), + Arc::new(crate::config::TokioTaskSpawner), + ) + .unwrap(); + let start = std::thread::spawn({ + let service = service.clone(); + move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(service.toggle_recording()) + } + }); + receive_checked + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + flag.store(true, Ordering::Release); + let probe = std::thread::spawn({ + let service = service.clone(); + move || service.runtime_restore_idle() + }); + resume.send(()).unwrap(); + start.join().unwrap().unwrap(); + assert!(!probe.join().unwrap()); + } + #[tokio::test] + async fn runtime_restore_error_cleanup_survives_dropping_the_failed_submit_waiter() { + let runtime = Runtime::default(); + runtime.fail_prepare.store(true, Ordering::SeqCst); + runtime.block_cancel.store(true, Ordering::SeqCst); + let service = service(runtime.clone(), Arc::new(AtomicBool::new(false))); + let submitting = tokio::spawn(service.submit_text("fixture".into())); + runtime.cancel_entered.notified().await; + assert_eq!(service.snapshot().await.unwrap().phase, QaPhase::Failed); + submitting.abort(); + assert!(submitting.await.unwrap_err().is_cancelled()); + assert!( + !service.runtime_restore_idle(), + "error cleanup still owns native runtime work" + ); + runtime.cancel_release.notify_one(); + runtime.cancel_done.notified().await; + for _ in 0..4 { + tokio::task::yield_now().await; + } + assert!(service.runtime_restore_idle()); + } + #[tokio::test] + async fn runtime_restore_cancel_return_does_not_release_an_inflight_text_reader() { + let runtime = Runtime::default(); + runtime.block_prepare.store(true, Ordering::SeqCst); + let service = service(runtime.clone(), Arc::new(AtomicBool::new(false))); + let question = tokio::spawn(service.submit_text("fixture".into())); + runtime.prepare_entered.notified().await; + service.cancel(None).await.unwrap(); + assert_eq!(service.snapshot().await.unwrap().phase, QaPhase::Cancelled); + assert!( + !service.runtime_restore_idle(), + "old context preparation has not drained" + ); + runtime.prepare_release.notify_one(); + assert_eq!( + question.await.unwrap().unwrap_err().code, + BackendErrorCode::Cancelled + ); + assert!(service.runtime_restore_idle()); + } +} diff --git a/openless-all/app/crates/openless-core/src/selection_service.rs b/openless-all/app/crates/openless-core/src/selection_service.rs index 9bee92249..e205d0378 100644 --- a/openless-all/app/crates/openless-core/src/selection_service.rs +++ b/openless-all/app/crates/openless-core/src/selection_service.rs @@ -55,6 +55,7 @@ struct SelectionServiceInner { activity: Arc, credential_store: Arc, state: RwLock, + runtime_work: Arc, } pub(crate) struct SelectionService { @@ -97,12 +98,41 @@ impl SelectionService { activity: dependencies.activity, credential_store: dependencies.credential_store, state: RwLock::new(SelectionState::default()), + runtime_work: Arc::new(crate::voice_session::RuntimeActivityGate::default()), }), } } } impl SelectionServiceInner { + fn begin_runtime_work( + &self, + ) -> Result { + let state = self.state.write().expect("selection state lock poisoned"); + if matches!( + state.snapshot.phase, + SelectionPhase::Capturing | SelectionPhase::Preview | SelectionPhase::Applying + ) || state.reverting + { + Ok(self.runtime_work.existing_work()) + } else { + self.runtime_work.acquire() + } + } + + async fn cancel_runtime_best_effort(&self, session_id: SessionId) { + let polisher = Arc::clone(&self.polisher); + let runtime = Arc::clone(&self.runtime); + let _ = self + .runtime_work + .cleanup(Box::pin(async move { + let _ = polisher.cancel(session_id).await; + let _ = runtime.cancel(session_id).await; + Ok(()) + })) + .await; + } + fn hide_preview(&self) { if let Err(error) = self.host_actions.request(HostAction::HideSelectionPreview) { log::warn!("failed to hide selection preview: {error}"); @@ -581,6 +611,27 @@ impl SelectionServiceInner { } impl SelectionApi for SelectionService { + fn bind_runtime_restore_guard( + &self, + guard: crate::domains::RuntimeRestoreGuard, + spawner: Arc, + ) -> Result<(), BackendError> { + self.inner.runtime_work.bind(guard, spawner) + } + + fn runtime_restore_idle(&self) -> bool { + self.inner.state.read().is_ok_and(|state| { + matches!( + state.snapshot.phase, + SelectionPhase::Idle + | SelectionPhase::Completed + | SelectionPhase::Cancelled + | SelectionPhase::Failed + ) && !state.reverting + && self.inner.runtime_work.runtime_restore_idle() + }) + } + fn snapshot(&self) -> BoxFuture<'static, Result> { let inner = Arc::clone(&self.inner); Box::pin(async move { @@ -599,6 +650,7 @@ impl SelectionApi for SelectionService { ) -> BoxFuture<'static, Result> { let inner = Arc::clone(&self.inner); Box::pin(async move { + let _runtime = inner.begin_runtime_work()?; let session_id = inner.begin(&request)?; let result = async { let capture = inner @@ -695,8 +747,7 @@ impl SelectionApi for SelectionService { } .await; if result.is_err() && inner.fail_if_active(session_id) { - let _ = inner.polisher.cancel(session_id).await; - let _ = inner.runtime.cancel(session_id).await; + inner.cancel_runtime_best_effort(session_id).await; inner.hide_preview(); } result @@ -710,6 +761,7 @@ impl SelectionApi for SelectionService { ) -> BoxFuture<'static, Result<(), BackendError>> { let inner = Arc::clone(&self.inner); Box::pin(async move { + let _runtime = inner.begin_runtime_work()?; let (source_text, replacement_text) = inner.applying_text(session_id, text)?; let replacement_text = inner.corrected_replacement(session_id, replacement_text)?; let result = inner @@ -727,8 +779,7 @@ impl SelectionApi for SelectionService { // 平台错误(焦点恢复 / 目标复核抖动)保持 preview 可重试—— // 否则窗口被隐藏、busy 卡死,表现为「点确认没反应」。 if inner.settle_confirm_failure(session_id, &error) { - let _ = inner.polisher.cancel(session_id).await; - let _ = inner.runtime.cancel(session_id).await; + inner.cancel_runtime_best_effort(session_id).await; inner.hide_preview(); } Err(error) @@ -742,7 +793,7 @@ impl SelectionApi for SelectionService { session_id: Option, ) -> BoxFuture<'static, Result<(), BackendError>> { let inner = Arc::clone(&self.inner); - Box::pin(async move { + self.inner.runtime_work.cleanup(Box::pin(async move { let active_session = { let mut state = inner.state.write().expect("selection state lock poisoned"); let Some(active_session) = state.snapshot.session_id else { @@ -771,12 +822,13 @@ impl SelectionApi for SelectionService { let runtime_result = inner.runtime.cancel(active_session).await; polish_result?; runtime_result - }) + })) } fn revert(&self, session_id: SessionId) -> BoxFuture<'static, Result<(), BackendError>> { let inner = Arc::clone(&self.inner); Box::pin(async move { + let _runtime = inner.begin_runtime_work()?; inner.begin_revert(session_id)?; match inner.runtime.revert(session_id).await { Ok(outcome) => inner.finish_revert(session_id, outcome), diff --git a/openless-all/app/crates/openless-core/src/selection_voice_service.rs b/openless-all/app/crates/openless-core/src/selection_voice_service.rs index ebe2d4274..c7faea4f8 100644 --- a/openless-all/app/crates/openless-core/src/selection_voice_service.rs +++ b/openless-all/app/crates/openless-core/src/selection_voice_service.rs @@ -1,5 +1,6 @@ +use std::collections::HashMap; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Arc, RwLock, Weak}; +use std::sync::{Arc, Mutex, RwLock, Weak}; use futures_util::future::BoxFuture; @@ -134,6 +135,8 @@ pub(crate) struct SelectionVoiceService { voice_sessions: Arc, qa: Arc>>>, auto_press_at: Arc>>, + runtime_work: Arc, + applying_work: Arc>>, } struct SelectionVoiceWorkflow { @@ -197,6 +200,29 @@ impl SelectionVoiceService { voice_sessions, qa: Arc::new(RwLock::new(None)), auto_press_at: Arc::new(RwLock::new(None)), + runtime_work: Arc::new(crate::voice_session::RuntimeActivityGate::default()), + applying_work: Arc::new(Mutex::new(HashMap::new())), + } + } + + fn begin_runtime_work( + &self, + ) -> Result { + let state = self + .state + .write() + .expect("selection voice state lock poisoned"); + if matches!( + state.phase, + SelectionVoicePhase::Recording + | SelectionVoicePhase::Processing + | SelectionVoicePhase::AwaitingIntent + | SelectionVoicePhase::Preview + | SelectionVoicePhase::Applying + ) { + Ok(self.runtime_work.existing_work()) + } else { + self.runtime_work.acquire() } } @@ -219,6 +245,7 @@ impl SelectionVoiceService { capture: SelectionCapture, phase: SelectionVoicePhase, ) -> Result { + let _runtime = self.begin_runtime_work()?; if capture.text.trim().is_empty() { return Err(BackendError::new( BackendErrorCode::InvalidArgument, @@ -618,6 +645,26 @@ impl SelectionVoicePersistence { } impl SelectionVoiceApi for SelectionVoiceService { + fn bind_runtime_restore_guard( + &self, + guard: crate::domains::RuntimeRestoreGuard, + spawner: Arc, + ) -> Result<(), BackendError> { + self.runtime_work.bind(guard, spawner) + } + + fn runtime_restore_idle(&self) -> bool { + self.state.read().is_ok_and(|state| { + matches!( + state.phase, + SelectionVoicePhase::Idle + | SelectionVoicePhase::Completed + | SelectionVoicePhase::Cancelled + | SelectionVoicePhase::Failed + ) && self.runtime_work.runtime_restore_idle() + }) + } + fn bind_qa(&self, qa: Weak) { *self .qa @@ -803,6 +850,7 @@ impl SelectionVoiceApi for SelectionVoiceService { ) -> BoxFuture<'static, Result> { let service = self.clone(); Box::pin(async move { + let _runtime = service.begin_runtime_work()?; { let state = service .state @@ -948,6 +996,7 @@ impl SelectionVoiceApi for SelectionVoiceService { ) -> BoxFuture<'static, Result> { let service = self.clone(); Box::pin(async move { + let _runtime = service.begin_runtime_work()?; let session_id = match &disposition { SelectionVoiceDisposition::AwaitingIntent { prompt } => prompt.session_id, SelectionVoiceDisposition::Question { session_id, .. } @@ -1006,6 +1055,7 @@ impl SelectionVoiceApi for SelectionVoiceService { ) -> BoxFuture<'static, Result> { let service = self.clone(); Box::pin(async move { + let _runtime = service.begin_runtime_work()?; let (selection, instruction) = { let state = service .state @@ -1067,6 +1117,7 @@ impl SelectionVoiceApi for SelectionVoiceService { ) -> BoxFuture<'static, Result> { let service = self.clone(); Box::pin(async move { + let _runtime = service.begin_runtime_work()?; let instruction = request.instruction.trim().to_string(); if instruction.is_empty() { return Err(BackendError::new( @@ -1279,6 +1330,7 @@ impl SelectionVoiceApi for SelectionVoiceService { owner_session_id: Option, text: String, ) -> Result { + let _runtime = self.begin_runtime_work()?; let replacement_text = self.persistence.corrected_text(text.trim().to_string()); if replacement_text.is_empty() { return Err(BackendError::new( @@ -1314,6 +1366,13 @@ impl SelectionVoiceApi for SelectionVoiceService { summary, source_app: selection.source_app.clone(), }; + // The native apply can outlive logical cancellation. Its ticket owns + // this lease until the Host reports completion, even if cancel clears + // the visible preview and the old ticket becomes stale. + self.applying_work + .lock() + .expect("selection voice apply work lock poisoned") + .insert(ticket.ticket_id, _runtime); state.applying_ticket = Some(ticket.clone()); state.apply_outcome = None; state.phase = SelectionVoicePhase::Applying; @@ -1335,8 +1394,15 @@ impl SelectionVoiceApi for SelectionVoiceService { let events = self.events.clone(); let persistence = Arc::clone(&self.persistence); let voice_sessions = Arc::clone(&self.voice_sessions); + let runtime_work = Arc::clone(&self.runtime_work); + let applying_work = Arc::clone(&self.applying_work); Box::pin(async move { + let _apply = applying_work + .lock() + .expect("selection voice apply work lock poisoned") + .remove(&ticket_id); let mut state = state.write().expect("selection voice state lock poisoned"); + let _runtime = runtime_work.existing_work(); let ticket = state .applying_ticket .as_ref() @@ -1414,7 +1480,7 @@ impl SelectionVoiceApi for SelectionVoiceService { let events = self.events.clone(); let polisher = self.workflow.polisher.clone(); let voice_sessions = Arc::clone(&self.voice_sessions); - Box::pin(async move { + self.runtime_work.cleanup(Box::pin(async move { let (active_session, snapshot, control) = { let mut state = state.write().expect("selection voice state lock poisoned"); let Some(active_session) = state.session_id else { @@ -1451,7 +1517,7 @@ impl SelectionVoiceApi for SelectionVoiceService { } } host_result - }) + })) } } diff --git a/openless-all/app/crates/openless-core/src/shortcut_types.rs b/openless-all/app/crates/openless-core/src/shortcut_types.rs index c62c7b88e..f1f2d61aa 100644 --- a/openless-all/app/crates/openless-core/src/shortcut_types.rs +++ b/openless-all/app/crates/openless-core/src/shortcut_types.rs @@ -27,6 +27,15 @@ const SIDE_MODIFIER_TAGS: &[&str] = &[ "super-right", ]; +const MODIFIER_CHORD_PRIMARY: &str = "ModifierChord"; + +pub fn is_modifier_chord_binding(binding: &ShortcutBinding) -> bool { + binding + .primary + .trim() + .eq_ignore_ascii_case(MODIFIER_CHORD_PRIMARY) +} + pub fn normalize_side_modifier_tag(raw: &str) -> String { match raw.trim().to_ascii_lowercase().as_str() { "super-left" => "cmd-left".into(), @@ -206,6 +215,24 @@ pub fn validate_shortcut_binding(binding: &ShortcutBinding) -> Result<(), Shortc if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { return Ok(()); } + if is_modifier_chord_binding(binding) { + if binding.modifiers.len() < 2 { + return Err(ShortcutBindingError::UnsupportedKey( + binding.primary.trim().to_string(), + )); + } + let mut unique = BTreeSet::new(); + for raw in &binding.modifiers { + if !is_side_specific_modifier_tag(raw) { + return Err(ShortcutBindingError::UnsupportedModifier(raw.clone())); + } + let normalized = normalize_side_modifier_tag(raw); + if !unique.insert(normalized) { + return Err(ShortcutBindingError::UnsupportedModifier(raw.clone())); + } + } + return Ok(()); + } validate_primary(&binding.primary)?; for raw in &binding.modifiers { @@ -871,6 +898,21 @@ mod tests { assert!(validate_shortcut_binding(&combo("D", &["cmd-left", "shift"])).is_err()); } + #[test] + fn modifier_chords_require_multiple_unique_side_specific_modifiers() { + let chord = combo("ModifierChord", &["ctrl-left", "super-left"]); + assert!(validate_shortcut_binding(&chord).is_ok()); + assert!(binding_requires_side_aware_hook(&chord)); + assert!(is_modifier_chord_binding(&chord)); + + assert!(validate_shortcut_binding(&combo("ModifierChord", &["ctrl-left"])).is_err()); + assert!(validate_shortcut_binding(&combo("ModifierChord", &["ctrl", "super-left"])).is_err()); + assert!( + validate_shortcut_binding(&combo("ModifierChord", &["cmd-left", "super-left"])) + .is_err() + ); + } + #[test] fn overlap_and_legacy_conversion_have_one_shared_implementation() { assert!(bindings_overlap( diff --git a/openless-all/app/crates/openless-core/src/style_pack_store.rs b/openless-all/app/crates/openless-core/src/style_pack_store.rs index 767cfdb16..c75f7b87a 100644 --- a/openless-all/app/crates/openless-core/src/style_pack_store.rs +++ b/openless-all/app/crates/openless-core/src/style_pack_store.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use std::sync::Mutex; use crate::errors::{BackendError, BackendErrorCode}; -use crate::persistence::{atomic_write, persistence_error, read_or_default}; +use crate::persistence::{atomic_write, persistence_error}; use crate::shared_types::UserPreferences; use crate::style_pack_archive::{ cleanup_style_pack_asset_dir, persist_style_pack_icon, read_style_pack_archive, @@ -26,6 +26,18 @@ pub struct StylePackStore { state: Mutex>, } +/// New asset paths have no live references until the index commits. Keep them only +/// on a confirmed or uncertain index commit; all earlier errors clean them up. +#[derive(Default)] +struct UncommittedSyncIcons(Vec); +impl Drop for UncommittedSyncIcons { + fn drop(&mut self) { + for path in &self.0 { + let _ = fs::remove_file(path); + } + } +} + impl StylePackStore { pub fn at_data_dir(data_dir: impl AsRef) -> Result { let data_dir = data_dir.as_ref(); @@ -65,20 +77,45 @@ impl StylePackStore { asset_root: PathBuf, preferences: Option<&UserPreferences>, ) -> Result { - let mut packs: Vec = read_or_default(&path)?; - let mut changed = preferences - .map(|preferences| migrate_style_packs_from_preferences(&mut packs, preferences)) - .unwrap_or(false); - changed |= reconcile_builtin_packs(&mut packs) | ensure_at_least_one_enabled(&mut packs); - sort_packs(&mut packs); - if changed { - write_packs(&path, &packs)?; + let operation_path = path.clone(); + if crate::cloud_sync_e2ee_store::gate::recovery_pending_for_path(&path) { + let packs = crate::persistence::read_lossless_rows(&path, &[])?; + return Ok(Self { + path: Some(path), + asset_root: Some(asset_root), + state: Mutex::new(packs), + }); } - Ok(Self { - path: Some(path), - asset_root: Some(asset_root), - state: Mutex::new(packs), - }) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &operation_path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut packs: Vec = crate::persistence::read_lossless_rows(&path, &[])?; + if crate::cloud_sync_e2ee_store::gate::recovery_pending_for_path(&path) { + return Ok(Self { + path: Some(path), + asset_root: Some(asset_root), + state: Mutex::new(packs), + }); + } + let mut changed = preferences + .map(|preferences| { + migrate_style_packs_from_preferences(&mut packs, preferences) + }) + .unwrap_or(false); + changed |= + reconcile_builtin_packs(&mut packs) | ensure_at_least_one_enabled(&mut packs); + sort_packs(&mut packs); + if changed { + write_packs(&path, &packs)?; + } + Ok(Self { + path: Some(path), + asset_root: Some(asset_root), + state: Mutex::new(packs), + }) + }, + ) } pub fn in_memory() -> Self { @@ -95,6 +132,136 @@ impl StylePackStore { Ok(self.lock()?.clone()) } + pub(crate) fn sync_snapshot( + &self, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result, BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("style pack persistence unavailable"))?; + crate::cloud_sync_e2ee_store::gate::require_exclusive(path, permit)?; + self.sync_snapshot_readonly() + } + + pub(crate) fn sync_snapshot_readonly( + &self, + ) -> Result, BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("style pack persistence unavailable"))?; + let mut state = self + .state + .lock() + .map_err(|_| persistence_error("style pack state poisoned"))?; + let packs: Vec = crate::persistence::read_lossless_rows(path, &[])?; + *state = packs.clone(); + packs + .iter() + .map(|pack| { + let icon = match self.icon_data_url_for_pack(pack)? { + Some(data) => { + let (mime, base64) = data + .strip_prefix("data:") + .and_then(|value| value.split_once(";base64,")) + .ok_or_else(|| persistence_error("invalid controlled icon response"))?; + Some(crate::cloud_sync_e2ee_documents::IconAsset { + mime: mime.into(), + base64: base64.into(), + }) + } + None if pack.icon_path.is_some() => { + return Err(persistence_error("registered style icon is missing")) + } + None => None, + }; + Ok(crate::cloud_sync_e2ee_documents::StylePackRecord { + pack: crate::cloud_sync_e2ee_documents::SecretJson::from_serializable(pack) + .map_err(|_| persistence_error("encode style pack snapshot"))?, + icon, + }) + }) + .collect() + } + + pub(crate) fn sync_replace_all( + &self, + records: &[crate::cloud_sync_e2ee_documents::StylePackRecord], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + let path = self + .path + .as_deref() + .ok_or_else(|| persistence_error("style pack persistence unavailable"))?; + let asset_root = self + .asset_root + .as_deref() + .ok_or_else(|| persistence_error("style pack assets unavailable"))?; + crate::cloud_sync_e2ee_store::gate::require_exclusive(path, permit)?; + let mut state = self + .state + .lock() + .map_err(|_| persistence_error("style pack state poisoned"))?; + let mut prepared = Vec::with_capacity(records.len()); + // Validate all resource bytes before allocating paths or mutating the index. + for record in records { + let mut pack: StylePack = serde_json::from_value(record.pack.expose().clone()) + .map_err(|_| persistence_error("decode restored style pack"))?; + pack.icon_path = None; + pack.active = false; + if let Some(icon) = &record.icon { + crate::cloud_sync_e2ee_documents::validate_icon(icon) + .map_err(|_| persistence_error("validate restored icon"))?; + } + prepared.push((pack, record.icon.as_ref())); + } + let mut restored = Vec::with_capacity(prepared.len()); + let mut created = UncommittedSyncIcons::default(); + for (mut pack, icon) in prepared { + if let Some(icon) = icon { + let expected = format!("data:{};base64,{}", icon.mime, icon.base64); + if let Some(existing) = state.iter().find(|old| old.id == pack.id) { + if self.icon_data_url_for_pack(existing)?.as_deref() == Some(expected.as_str()) + { + pack.icon_path = existing.icon_path.clone(); + } + } + if pack.icon_path.is_none() { + let extension = match icon.mime.as_str() { + "image/png" => "png", + "image/jpeg" => "jpg", + "image/webp" => "webp", + _ => return Err(persistence_error("unsupported restored icon")), + }; + let destination = asset_root.join(format!( + "sync-{}.{}", + uuid::Uuid::new_v4().simple(), + extension + )); + let bytes = base64::engine::general_purpose::STANDARD + .decode(&icon.base64) + .map_err(|_| persistence_error("decode restored icon"))?; + created.0.push(destination.clone()); + crate::persistence::atomic_write_for_sync(&destination, &bytes, permit)?; + pack.icon_path = Some(destination.to_string_lossy().into_owned()); + } + } + restored.push(pack); + } + let bytes = serde_json::to_vec_pretty(&restored) + .map_err(|_| persistence_error("encode restored styles"))?; + if let Err(error) = crate::persistence::atomic_write_for_sync(path, &bytes, permit) { + if error.code == BackendErrorCode::OutcomeUnknown { + created.0.clear(); + } + return Err(error); + } + created.0.clear(); + *state = restored; + Ok(()) + } + pub(crate) fn cloud_sync_access( &self, ) -> Result<(std::sync::MutexGuard<'_, Vec>, &Path, &Path), BackendError> { @@ -143,49 +310,65 @@ impl StylePackStore { } pub fn create(&self, mut pack: StylePack) -> Result { - let mut packs = self.lock()?; - let requested = if pack.id.trim().is_empty() { - format!("imported-{}", uuid::Uuid::new_v4().simple()) - } else { - pack.id.clone() - }; - pack.id = unique_imported_id(&packs, &requested); - pack.name = required_text(&pack.name, "style pack name")?; - pack.kind = StylePackKind::Imported; - pack.active = false; - pack.enabled = true; - let now = chrono::Utc::now().to_rfc3339(); - pack.created_at = Some(now.clone()); - pack.updated_at = Some(now); - pack.version = normalized_version(&pack.version); - pack.examples = normalized_examples(pack.examples); - pack.tags = normalized_tags(&pack.tags); - packs.push(pack.clone()); - self.persist_locked(&packs)?; - Ok(pack) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let requested = if pack.id.trim().is_empty() { + format!("imported-{}", uuid::Uuid::new_v4().simple()) + } else { + pack.id.clone() + }; + pack.id = unique_imported_id(&packs, &requested); + pack.name = required_text(&pack.name, "style pack name")?; + pack.kind = StylePackKind::Imported; + pack.active = false; + pack.enabled = true; + let now = chrono::Utc::now().to_rfc3339(); + pack.created_at = Some(now.clone()); + pack.updated_at = Some(now); + pack.version = normalized_version(&pack.version); + pack.examples = normalized_examples(pack.examples); + pack.tags = normalized_tags(&pack.tags); + packs.push(pack.clone()); + self.persist_locked(&packs)?; + *live = packs; + Ok(pack) + }, + ) } pub fn update(&self, incoming: StylePack) -> Result { - let mut packs = self.lock()?; - let slot = packs - .iter_mut() - .find(|pack| pack.id == incoming.id) - .ok_or_else(|| not_found(&incoming.id))?; - slot.name = required_text(&incoming.name, "style pack name")?; - slot.description = incoming.description.trim().to_string(); - slot.author = normalized_optional(incoming.author); - slot.version = normalized_version(&incoming.version); - slot.selection_prompt = incoming.selection_prompt; - slot.voice_edit_prompt = incoming.voice_edit_prompt; - slot.prompt = incoming.prompt; - slot.examples = normalized_examples(incoming.examples); - slot.tags = normalized_tags(&incoming.tags); - slot.recommended_model = normalized_optional(incoming.recommended_model); - slot.compatible_app_version = normalized_optional(incoming.compatible_app_version); - slot.updated_at = Some(chrono::Utc::now().to_rfc3339()); - let updated = slot.clone(); - self.persist_locked(&packs)?; - Ok(updated) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let slot = packs + .iter_mut() + .find(|pack| pack.id == incoming.id) + .ok_or_else(|| not_found(&incoming.id))?; + slot.name = required_text(&incoming.name, "style pack name")?; + slot.description = incoming.description.trim().to_string(); + slot.author = normalized_optional(incoming.author); + slot.version = normalized_version(&incoming.version); + slot.selection_prompt = incoming.selection_prompt; + slot.voice_edit_prompt = incoming.voice_edit_prompt; + slot.prompt = incoming.prompt; + slot.examples = normalized_examples(incoming.examples); + slot.tags = normalized_tags(&incoming.tags); + slot.recommended_model = normalized_optional(incoming.recommended_model); + slot.compatible_app_version = normalized_optional(incoming.compatible_app_version); + slot.updated_at = Some(chrono::Utc::now().to_rfc3339()); + let updated = slot.clone(); + self.persist_locked(&packs)?; + *live = packs; + Ok(updated) + }, + ) } /// Save a PNG icon in this pack's owned asset directory, or clear it with `None`. @@ -197,76 +380,86 @@ impl StylePackStore { /// Returns an error for an unknown/invalid pack ID, an invalid PNG, an image /// over 64 KiB, an unavailable asset directory, or a failed filesystem write. pub fn update_icon(&self, id: &str, png: Option<&[u8]>) -> Result { - if id.is_empty() - || id == "." - || id == ".." - || !id - .bytes() - .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) - { - return Err(invalid_icon("invalid style pack id")); - } - let mut packs = self.lock()?; - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| not_found(id))?; - let old_path = packs[index].icon_path.clone(); - let new_path = if let Some(bytes) = png { - if bytes.len() > MAX_ICON_BYTES { - return Err(invalid_icon("style pack icon exceeds 64 KiB")); - } - validate_icon_content("png", bytes).map_err(archive_error)?; - let root = self - .asset_root - .as_ref() - .filter(|root| !root.as_os_str().is_empty()) - .ok_or_else(|| invalid_icon("style pack asset root unavailable"))?; - fs::create_dir_all(root) - .map_err(|_| persistence_error("create style pack asset root"))?; - let root = root - .canonicalize() - .map_err(|_| persistence_error("resolve style pack asset root"))?; - let directory = root.join(id); - fs::create_dir_all(&directory) - .map_err(|_| persistence_error("create style pack icon directory"))?; - let directory = directory - .canonicalize() - .map_err(|_| persistence_error("resolve style pack icon directory"))?; - if !directory.starts_with(&root) { - return Err(invalid_icon( - "style pack icon directory is outside its asset root", - )); - } - // A new filename keeps the previous image valid until metadata commits. - let target = directory.join(format!("icon-{}.png", uuid::Uuid::new_v4().simple())); - atomic_write(&target, bytes)?; - Some(target) - } else { - None - }; - let mut next = packs.clone(); - next[index].icon_path = new_path - .as_ref() - .map(|path| path.to_string_lossy().into_owned()); - next[index].updated_at = Some(chrono::Utc::now().to_rfc3339()); - if let Err(error) = self.persist_locked(&next) { - if let Some(path) = new_path { - let _ = fs::remove_file(path); - } - return Err(error); - } - let saved = next[index].clone(); - *packs = next; - if let (Some(root), Some(old)) = (&self.asset_root, old_path) { - let old = Path::new(&old); - if let (Ok(owned), Some(parent)) = (root.join(id).canonicalize(), old.parent()) { - if parent.canonicalize().ok().as_ref() == Some(&owned) { - let _ = fs::remove_file(old); + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + if id.is_empty() + || id == "." + || id == ".." + || !id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._-".contains(&c)) + { + return Err(invalid_icon("invalid style pack id")); } - } - } - Ok(saved) + let mut packs = self.lock_for_mutation()?; + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + let old_path = packs[index].icon_path.clone(); + let new_path = if let Some(bytes) = png { + if bytes.len() > MAX_ICON_BYTES { + return Err(invalid_icon("style pack icon exceeds 64 KiB")); + } + validate_icon_content("png", bytes).map_err(archive_error)?; + let root = self + .asset_root + .as_ref() + .filter(|root| !root.as_os_str().is_empty()) + .ok_or_else(|| invalid_icon("style pack asset root unavailable"))?; + fs::create_dir_all(root) + .map_err(|_| persistence_error("create style pack asset root"))?; + let root = root + .canonicalize() + .map_err(|_| persistence_error("resolve style pack asset root"))?; + let directory = root.join(id); + fs::create_dir_all(&directory) + .map_err(|_| persistence_error("create style pack icon directory"))?; + let directory = directory + .canonicalize() + .map_err(|_| persistence_error("resolve style pack icon directory"))?; + if !directory.starts_with(&root) { + return Err(invalid_icon( + "style pack icon directory is outside its asset root", + )); + } + // A new filename keeps the previous image valid until metadata commits. + let target = + directory.join(format!("icon-{}.png", uuid::Uuid::new_v4().simple())); + atomic_write(&target, bytes)?; + Some(target) + } else { + None + }; + let mut next = packs.clone(); + next[index].icon_path = new_path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()); + next[index].updated_at = Some(chrono::Utc::now().to_rfc3339()); + if let Err(error) = self.persist_locked(&next) { + if error.code != BackendErrorCode::OutcomeUnknown { + if let Some(path) = new_path { + let _ = fs::remove_file(path); + } + } + return Err(error); + } + let saved = next[index].clone(); + *packs = next; + if let (Some(root), Some(old)) = (&self.asset_root, old_path) { + let old = Path::new(&old); + if let (Ok(owned), Some(parent)) = (root.join(id).canonicalize(), old.parent()) + { + if parent.canonicalize().ok().as_ref() == Some(&owned) { + let _ = fs::remove_file(old); + } + } + } + Ok(saved) + }, + ) } /// Read a pack icon as a PNG/JPEG/WebP data URL without exposing filesystem access. @@ -336,84 +529,128 @@ impl StylePackStore { origin_pack_id: Option, origin_author_login: Option, ) -> Result { - let mut packs = self.lock()?; - let slot = packs - .iter_mut() - .find(|pack| pack.id == id) - .ok_or_else(|| not_found(id))?; - slot.origin_pack_id = normalized_optional(origin_pack_id); - slot.origin_author_login = normalized_optional(origin_author_login); - slot.updated_at = Some(chrono::Utc::now().to_rfc3339()); - let updated = slot.clone(); - self.persist_locked(&packs)?; - Ok(updated) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let slot = packs + .iter_mut() + .find(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + slot.origin_pack_id = normalized_optional(origin_pack_id); + slot.origin_author_login = normalized_optional(origin_author_login); + slot.updated_at = Some(chrono::Utc::now().to_rfc3339()); + let updated = slot.clone(); + self.persist_locked(&packs)?; + *live = packs; + Ok(updated) + }, + ) } pub fn set_enabled(&self, id: &str, enabled: bool) -> Result { - let mut packs = self.lock()?; - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| not_found(id))?; - packs[index].enabled = enabled; - packs[index].updated_at = Some(chrono::Utc::now().to_rfc3339()); - ensure_at_least_one_enabled(&mut packs); - let updated = packs[index].clone(); - self.persist_locked(&packs)?; - Ok(updated) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + packs[index].enabled = enabled; + packs[index].updated_at = Some(chrono::Utc::now().to_rfc3339()); + ensure_at_least_one_enabled(&mut packs); + let updated = packs[index].clone(); + self.persist_locked(&packs)?; + *live = packs; + Ok(updated) + }, + ) } pub fn reset_builtin(&self, id: &str) -> Result { - let mode = builtin_mode(id).ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "style pack is not builtin", - ) - })?; - let mut packs = self.lock()?; - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| not_found(id))?; - let existing = &packs[index]; - let mut reset = builtin_style_pack_for_mode(mode); - reset.enabled = existing.enabled; - reset.created_at = existing.created_at.clone(); - reset.updated_at = Some(chrono::Utc::now().to_rfc3339()); - packs[index] = reset.clone(); - self.persist_locked(&packs)?; - Ok(reset) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mode = builtin_mode(id).ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "style pack is not builtin", + ) + })?; + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + let existing = &packs[index]; + let mut reset = builtin_style_pack_for_mode(mode); + reset.enabled = existing.enabled; + reset.created_at = existing.created_at.clone(); + reset.updated_at = Some(chrono::Utc::now().to_rfc3339()); + packs[index] = reset.clone(); + self.persist_locked(&packs)?; + *live = packs; + Ok(reset) + }, + ) } pub fn remove_imported(&self, id: &str) -> Result<(), BackendError> { - let mut packs = self.lock()?; - let index = packs - .iter() - .position(|pack| pack.id == id) - .ok_or_else(|| not_found(id))?; - if packs[index].kind == StylePackKind::Builtin { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "builtin style pack cannot be deleted", - )); - } - let removed = packs.remove(index); - ensure_at_least_one_enabled(&mut packs); - self.persist_locked(&packs)?; - if let Some(asset_root) = &self.asset_root { - cleanup_style_pack_asset_dir(asset_root, &removed.id); - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut live = self.lock_for_mutation()?; + let mut packs = live.clone(); + let index = packs + .iter() + .position(|pack| pack.id == id) + .ok_or_else(|| not_found(id))?; + if packs[index].kind == StylePackKind::Builtin { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "builtin style pack cannot be deleted", + )); + } + let removed = packs.remove(index); + ensure_at_least_one_enabled(&mut packs); + self.persist_locked(&packs)?; + *live = packs; + if let Some(asset_root) = &self.asset_root { + cleanup_style_pack_asset_dir(asset_root, &removed.id); + } + Ok(()) + }, + ) } pub fn import_from_zip(&self, path: &Path) -> Result { - let parsed = read_style_pack_archive(path).map_err(archive_error)?; - self.import_parsed_archive(parsed) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let parsed = read_style_pack_archive(path).map_err(archive_error)?; + self.import_parsed_archive(parsed) + }, + ) } pub fn import_from_zip_bytes(&self, bytes: &[u8]) -> Result { - let parsed = read_style_pack_archive_bytes(bytes).map_err(archive_error)?; - self.import_parsed_archive(parsed) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let parsed = read_style_pack_archive_bytes(bytes).map_err(archive_error)?; + self.import_parsed_archive(parsed) + }, + ) } /// Imports a validated Marketplace archive while committing its remote @@ -426,10 +663,16 @@ impl StylePackStore { origin_pack_id: String, origin_author_login: Option, ) -> Result { - let mut parsed = read_style_pack_archive_bytes(bytes).map_err(archive_error)?; - parsed.manifest.origin_pack_id = Some(origin_pack_id); - parsed.manifest.origin_author_login = origin_author_login; - self.import_parsed_archive(parsed) + crate::cloud_sync_e2ee_store::gate::with_optional_mutation( + self.path.as_deref(), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let mut parsed = read_style_pack_archive_bytes(bytes).map_err(archive_error)?; + parsed.manifest.origin_pack_id = Some(origin_pack_id); + parsed.manifest.origin_author_login = origin_author_login; + self.import_parsed_archive(parsed) + }, + ) } fn import_parsed_archive( @@ -437,7 +680,7 @@ impl StylePackStore { parsed: ParsedStylePackArchive, ) -> Result { let manifest = parsed.manifest; - let mut packs = self.lock()?; + let mut packs = self.lock_for_mutation()?; let pack_id = unique_imported_id(&packs, &manifest.id); let icon_path = match (parsed.icon, self.asset_root.as_deref()) { (Some(icon), Some(asset_root)) => { @@ -478,7 +721,7 @@ impl StylePackStore { let mut next = packs.clone(); next.insert(0, pack.clone()); if let Err(error) = self.persist_locked(&next) { - if pack.icon_path.is_some() { + if error.code != BackendErrorCode::OutcomeUnknown && pack.icon_path.is_some() { if let Some(asset_root) = &self.asset_root { cleanup_style_pack_asset_dir(asset_root, &pack.id); } @@ -568,6 +811,16 @@ impl StylePackStore { }) } + fn lock_for_mutation(&self) -> Result>, BackendError> { + let mut state = self.lock()?; + if let Some(path) = &self.path { + if path.exists() { + *state = crate::persistence::read_lossless_rows(path, &[])?; + } + } + Ok(state) + } + fn persist_locked(&self, packs: &[StylePack]) -> Result<(), BackendError> { match &self.path { Some(path) => write_packs(path, packs), diff --git a/openless-all/app/crates/openless-core/src/vocabulary.rs b/openless-all/app/crates/openless-core/src/vocabulary.rs index 6d8229830..09a030161 100644 --- a/openless-all/app/crates/openless-core/src/vocabulary.rs +++ b/openless-all/app/crates/openless-core/src/vocabulary.rs @@ -6,7 +6,7 @@ use std::sync::Mutex; use chrono::Utc; use crate::errors::{BackendError, BackendErrorCode}; -use crate::persistence::{atomic_write, persistence_error, read_or_default}; +use crate::persistence::{atomic_write, persistence_error}; use crate::shared_types::LEARNED_VOCAB_NOTE; use crate::types::{DictionaryEntry, VocabPreset, VocabPresetStore}; @@ -81,7 +81,28 @@ impl DictionaryStore { pub fn list(&self) -> Result, BackendError> { let _guard = self.lock_store()?; - read_or_default(&self.path) + crate::persistence::read_lossless_rows(&self.path, &["notes", "hitCount"]) + } + + pub(crate) fn sync_snapshot( + &self, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result, BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + crate::persistence::read_lossless_rows(&self.path, &["notes", "hitCount"]) + } + + pub(crate) fn sync_replace_all( + &self, + records: &[DictionaryEntry], + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, + ) -> Result<(), BackendError> { + crate::cloud_sync_e2ee_store::gate::require_exclusive(&self.path, permit)?; + let _guard = self.lock_store()?; + let bytes = serde_json::to_vec_pretty(records) + .map_err(|_| persistence_error("encode restored dictionary"))?; + crate::persistence::atomic_write_for_sync(&self.path, &bytes, permit) } /// Cloud restore locks every participating store before preparing or replacing any file. @@ -97,12 +118,18 @@ impl DictionaryStore { phrase: String, note: Option, ) -> Result { - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - let entry = new_entry(phrase, note); - entries.insert(0, entry.clone()); - self.write_locked(&entries)?; - Ok(entry) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + let entry = new_entry(phrase, note); + entries.insert(0, entry.clone()); + self.write_locked(&entries)?; + Ok(entry) + }, + ) } /// Learned entries are deduplicated and appended behind manual entries. @@ -111,111 +138,141 @@ impl DictionaryStore { phrase: String, note: Option, ) -> Result, BackendError> { - let phrase = phrase.trim().to_string(); - if phrase.is_empty() { - return Ok(None); - } - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - if entries.iter().any(|entry| entry.phrase == phrase) { - return Ok(None); - } - let entry = new_entry(phrase, note); - entries.push(entry.clone()); - self.write_locked(&entries)?; - Ok(Some(entry)) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let phrase = phrase.trim().to_string(); + if phrase.is_empty() { + return Ok(None); + } + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + if entries.iter().any(|entry| entry.phrase == phrase) { + return Ok(None); + } + let entry = new_entry(phrase, note); + entries.push(entry.clone()); + self.write_locked(&entries)?; + Ok(Some(entry)) + }, + ) } pub fn remove(&self, id: &str) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - let before = entries.len(); - entries.retain(|entry| entry.id != id); - if entries.len() != before { - self.write_locked(&entries)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + let before = entries.len(); + entries.retain(|entry| entry.id != id); + if entries.len() != before { + self.write_locked(&entries)?; + } + Ok(()) + }, + ) } pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), BackendError> { - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - let entry = entries - .iter_mut() - .find(|entry| entry.id == id) - .ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "dictionary entry not found", - ) - })?; - if entry.enabled != enabled { - entry.enabled = enabled; - self.write_locked(&entries)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + let entry = entries + .iter_mut() + .find(|entry| entry.id == id) + .ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary entry not found", + ) + })?; + if entry.enabled != enabled { + entry.enabled = enabled; + self.write_locked(&entries)?; + } + Ok(()) + }, + ) } /// Rename an entry in place so its id / hits / enabled state survive the edit. /// Empty phrases and phrases colliding with another entry are rejected. pub fn update_phrase(&self, id: &str, phrase: String) -> Result<(), BackendError> { - let phrase = phrase.trim().to_string(); - if phrase.is_empty() { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "dictionary phrase is empty", - )); - } - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - if entries - .iter() - .any(|entry| entry.id != id && entry.phrase == phrase) - { - return Err(BackendError::new( - BackendErrorCode::InvalidArgument, - "dictionary phrase already exists", - )); - } - let entry = entries - .iter_mut() - .find(|entry| entry.id == id) - .ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidArgument, - "dictionary entry not found", - ) - })?; - if entry.phrase != phrase { - entry.phrase = phrase; - self.write_locked(&entries)?; - } - Ok(()) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + let phrase = phrase.trim().to_string(); + if phrase.is_empty() { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary phrase is empty", + )); + } + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + if entries + .iter() + .any(|entry| entry.id != id && entry.phrase == phrase) + { + return Err(BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary phrase already exists", + )); + } + let entry = entries + .iter_mut() + .find(|entry| entry.id == id) + .ok_or_else(|| { + BackendError::new( + BackendErrorCode::InvalidArgument, + "dictionary entry not found", + ) + })?; + if entry.phrase != phrase { + entry.phrase = phrase; + self.write_locked(&entries)?; + } + Ok(()) + }, + ) } /// Count case-insensitive, non-overlapping occurrences in final output. pub fn record_hits(&self, text: &str) -> Result { - if text.is_empty() { - return Ok(0); - } - let _guard = self.lock_store()?; - let mut entries = self.read_locked()?; - let haystack = text.to_lowercase(); - let mut total = 0_u64; - let mut changed = false; - for entry in entries.iter_mut().filter(|entry| entry.enabled) { - let needle = entry.phrase.trim().to_lowercase(); - let count = count_occurrences(&haystack, &needle); - if count > 0 { - entry.hits = entry.hits.saturating_add(count); - total = total.saturating_add(count); - changed = true; - } - } - if changed { - self.write_locked(&entries)?; - } - Ok(total) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &self.path, + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + if text.is_empty() { + return Ok(0); + } + let _guard = self.lock_store()?; + let mut entries = self.read_locked()?; + let haystack = text.to_lowercase(); + let mut total = 0_u64; + let mut changed = false; + for entry in entries.iter_mut().filter(|entry| entry.enabled) { + let needle = entry.phrase.trim().to_lowercase(); + let count = count_occurrences(&haystack, &needle); + if count > 0 { + entry.hits = entry.hits.saturating_add(count); + total = total.saturating_add(count); + changed = true; + } + } + if changed { + self.write_locked(&entries)?; + } + Ok(total) + }, + ) } fn lock_store(&self) -> Result, BackendError> { @@ -225,7 +282,7 @@ impl DictionaryStore { } fn read_locked(&self) -> Result, BackendError> { - read_or_default(&self.path) + crate::persistence::read_lossless_rows(&self.path, &["notes", "hitCount"]) } fn write_locked(&self, entries: &[DictionaryEntry]) -> Result<(), BackendError> { @@ -263,7 +320,7 @@ fn count_occurrences(haystack: &str, needle: &str) -> u64 { } pub fn list_vocab_presets(data_dir: &Path) -> Result { - read_or_default(&data_dir.join("vocab-presets.json")) + crate::persistence::read_lossless_object(&data_dir.join("vocab-presets.json")) } pub fn builtin_vocab_presets() -> Vec { @@ -295,9 +352,29 @@ pub fn resolve_vocab_presets(store: &VocabPresetStore) -> Vec { } pub fn save_vocab_presets(data_dir: &Path, store: &VocabPresetStore) -> Result<(), BackendError> { - let json = serde_json::to_vec_pretty(store) - .map_err(|_| persistence_error("encode vocabulary presets"))?; - atomic_write(&data_dir.join("vocab-presets.json"), &json) + crate::cloud_sync_e2ee_store::gate::with_registered_mutation( + &data_dir.join("vocab-presets.json"), + crate::cloud_sync_e2ee_store::gate::ChangeOrigin::User, + || { + // A typed replacement may not erase fields introduced by a newer app. + let _: VocabPresetStore = + crate::persistence::read_lossless_object(&data_dir.join("vocab-presets.json"))?; + let json = serde_json::to_vec_pretty(store) + .map_err(|_| persistence_error("encode vocabulary presets"))?; + atomic_write(&data_dir.join("vocab-presets.json"), &json) + }, + ) +} + +pub(crate) fn save_vocab_presets_for_sync( + data_dir: &Path, + store: &VocabPresetStore, + permit: &crate::cloud_sync_e2ee_store::gate::ExclusivePermit, +) -> Result<(), BackendError> { + let path = data_dir.join("vocab-presets.json"); + let bytes = serde_json::to_vec_pretty(store) + .map_err(|_| persistence_error("encode restored vocabulary presets"))?; + crate::persistence::atomic_write_for_sync(&path, &bytes, permit) } #[cfg(test)] diff --git a/openless-all/app/crates/openless-core/src/voice_session.rs b/openless-all/app/crates/openless-core/src/voice_session.rs index 83a7f4344..1a223d19f 100644 --- a/openless-all/app/crates/openless-core/src/voice_session.rs +++ b/openless-all/app/crates/openless-core/src/voice_session.rs @@ -1,4 +1,4 @@ -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, OnceLock}; use crate::{BackendError, BackendErrorCode, SessionId}; @@ -19,12 +19,30 @@ struct ActiveVoiceSession { cancel: crate::CancellationToken, } -#[derive(Debug, Default)] +#[derive(Default)] pub(crate) struct VoiceSessionGate { active: Mutex>, + restore_guard: OnceLock, } impl VoiceSessionGate { + pub(crate) fn bind_restore_guard( + &self, + guard: crate::domains::RuntimeRestoreGuard, + ) -> Result<(), BackendError> { + self.restore_guard.set(guard).map_err(|_| { + BackendError::new( + BackendErrorCode::InvalidState, + "voice restore guard is already bound", + ) + }) + } + + pub(crate) fn runtime_restore_idle(&self) -> bool { + // A released owner with native cleanup holds is deliberately not idle. + self.active.lock().is_ok_and(|active| active.is_none()) + } + pub(crate) fn acquire( &self, session_id: SessionId, @@ -44,6 +62,11 @@ impl VoiceSessionGate { format!("another voice session is active: {:?}", current.kind), )), None => { + // Same mutex as the restore idle probe: the restoring flag is + // checked before claiming the owner, without a check/claim gap. + if let Some(guard) = self.restore_guard.get() { + guard()?; + } *active = Some(ActiveVoiceSession { session_id, kind, @@ -120,6 +143,153 @@ impl Drop for VoiceResourceHold { } } +/// Counts non-audio runtime work without taking the exclusive voice slot. +/// Domain callers acquire a work lease while holding their own state lock; +/// probes read that same state lock before this short counter mutex. +#[derive(Default)] +pub(crate) struct RuntimeActivityGate { + state: Mutex, + binding: OnceLock, +} + +#[derive(Default)] +struct RuntimeActivityState { + active: usize, + abandoned_cleanup: bool, +} +struct RuntimeActivityBinding { + guard: crate::domains::RuntimeRestoreGuard, + spawner: Arc, +} + +impl RuntimeActivityGate { + pub(crate) fn bind( + &self, + guard: crate::domains::RuntimeRestoreGuard, + spawner: Arc, + ) -> Result<(), BackendError> { + self.binding + .set(RuntimeActivityBinding { guard, spawner }) + .map_err(|_| { + BackendError::new( + BackendErrorCode::InvalidState, + "runtime restore guard is already bound", + ) + }) + } + + pub(crate) fn acquire(self: &Arc) -> Result { + let mut state = self.state.lock().expect("runtime activity lock poisoned"); + if let Some(binding) = self.binding.get() { + (binding.guard)()?; + } + state.active = state + .active + .checked_add(1) + .expect("runtime activity count overflow"); + Ok(RuntimeActivityHold { + gate: Arc::clone(self), + clean: true, + }) + } + + /// Continue an owner already validated under the domain state lock. + /// Its active phase makes the restore probe reject admission, even if a + /// restore attempt has raised its flag immediately before that probe. + pub(crate) fn existing_work(self: &Arc) -> RuntimeActivityHold { + let mut state = self.state.lock().expect("runtime activity lock poisoned"); + state.active = state + .active + .checked_add(1) + .expect("runtime activity count overflow"); + RuntimeActivityHold { + gate: Arc::clone(self), + clean: true, + } + } + + fn cleanup_hold(self: &Arc) -> RuntimeActivityHold { + let mut state = self.state.lock().expect("runtime activity lock poisoned"); + state.active = state + .active + .checked_add(1) + .expect("runtime activity count overflow"); + RuntimeActivityHold { + gate: Arc::clone(self), + clean: false, + } + } + + pub(crate) fn runtime_restore_idle(&self) -> bool { + self.state + .lock() + .is_ok_and(|state| state.active == 0 && !state.abandoned_cleanup) + } + + /// A restore-enabled Host owns cleanup through its existing TaskSpawner. + /// Dropping the IPC waiter does not release the lease or cancel cleanup. + /// Hosts without restore binding retain their original executor behavior. + pub(crate) fn cleanup( + self: &Arc, + work: futures_util::future::BoxFuture<'static, Result>, + ) -> futures_util::future::BoxFuture<'static, Result> { + let gate = Arc::clone(self); + let spawner = self + .binding + .get() + .map(|binding| Arc::clone(&binding.spawner)); + Box::pin(async move { + // Claim before handing the task to the scheduler: the caller may + // drop its main lease as soon as this future first yields. + let hold = gate.cleanup_hold(); + let task = async move { + let result = work.await; + hold.complete(); + result + }; + if let Some(spawner) = spawner { + let (send, receive) = tokio::sync::oneshot::channel(); + spawner.spawn(Box::pin(async move { + let _ = send.send(task.await); + })); + receive.await.map_err(|_| { + BackendError::new( + BackendErrorCode::Cancelled, + "runtime cleanup task was interrupted", + ) + })? + } else { + task.await + } + }) + } +} + +pub(crate) struct RuntimeActivityHold { + gate: Arc, + clean: bool, +} +impl RuntimeActivityHold { + fn complete(mut self) { + // Consume the whole guard so async field capture cannot leave its + // Drop on the caller while moving only the Copy completion flag. + self.clean = true; + } +} +impl Drop for RuntimeActivityHold { + fn drop(&mut self) { + let mut state = self + .gate + .state + .lock() + .expect("runtime activity lock poisoned"); + state.active -= 1; + if !self.clean { + state.abandoned_cleanup = true; + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -149,4 +319,179 @@ mod tests { gate.acquire(SessionId::new(), VoiceSessionKind::Qa) .unwrap(); } + fn restore_guard( + flag: Arc, + ) -> crate::domains::RuntimeRestoreGuard { + Arc::new(move || { + if flag.load(std::sync::atomic::Ordering::Acquire) { + Err(BackendError::new( + BackendErrorCode::Busy, + "restore in progress", + )) + } else { + Ok(()) + } + }) + } + + #[test] + fn runtime_restore_blocks_new_voice_but_preserves_an_existing_owner() { + let flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let gate = Arc::new(VoiceSessionGate::default()); + gate.bind_restore_guard(restore_guard(flag.clone())) + .unwrap(); + let owner = SessionId::new(); + gate.acquire(owner, VoiceSessionKind::Dictation).unwrap(); + let resources = gate.hold_resources(owner).unwrap(); + flag.store(true, std::sync::atomic::Ordering::Release); + gate.acquire(owner, VoiceSessionKind::Dictation).unwrap(); + gate.release(owner); + assert!( + !gate.runtime_restore_idle(), + "native cleanup still owns resources" + ); + drop(resources); + assert!(gate.runtime_restore_idle()); + for kind in [ + VoiceSessionKind::Dictation, + VoiceSessionKind::LessComputer, + VoiceSessionKind::Qa, + VoiceSessionKind::SelectionVoice, + ] { + assert_eq!( + gate.acquire(SessionId::new(), kind).unwrap_err().code, + BackendErrorCode::Busy + ); + } + flag.store(false, std::sync::atomic::Ordering::Release); + gate.acquire(SessionId::new(), VoiceSessionKind::Qa) + .unwrap(); + } + + #[test] + fn runtime_restore_probe_cannot_cross_a_voice_check_claim_race() { + let flag = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let gate = Arc::new(VoiceSessionGate::default()); + let (checked, checked_rx) = std::sync::mpsc::channel(); + let (resume, resume_rx) = std::sync::mpsc::channel(); + let resume_rx = Mutex::new(resume_rx); + let check_flag = flag.clone(); + gate.bind_restore_guard(Arc::new(move || { + assert!(!check_flag.load(std::sync::atomic::Ordering::Acquire)); + checked.send(()).unwrap(); + resume_rx.lock().unwrap().recv().unwrap(); + Ok(()) + })) + .unwrap(); + let owner = SessionId::new(); + let starting = std::thread::spawn({ + let gate = gate.clone(); + move || gate.acquire(owner, VoiceSessionKind::Dictation) + }); + checked_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + flag.store(true, std::sync::atomic::Ordering::Release); + let probing = std::thread::spawn({ + let gate = gate.clone(); + move || gate.runtime_restore_idle() + }); + resume.send(()).unwrap(); + starting.join().unwrap().unwrap(); + assert!( + !probing.join().unwrap(), + "restore must reject the newly admitted voice owner" + ); + gate.release(owner); + } + + #[derive(Default)] + struct CapturingSpawner { + tasks: Mutex>>, + } + impl crate::config::TaskSpawner for CapturingSpawner { + fn spawn(&self, task: futures_util::future::BoxFuture<'static, ()>) { + self.tasks.lock().unwrap().push(tokio::spawn(task)); + } + } + + #[tokio::test] + async fn runtime_restore_waits_for_owned_cleanup_after_waiter_is_dropped() { + let gate = Arc::new(RuntimeActivityGate::default()); + let spawner = Arc::new(CapturingSpawner::default()); + gate.bind(Arc::new(|| Ok(())), spawner.clone()).unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let waiter = tokio::spawn(gate.cleanup(Box::pin({ + let entered = entered.clone(); + let release = release.clone(); + async move { + entered.notify_one(); + release.notified().await; + Ok(()) + } + }))); + entered.notified().await; + waiter.abort(); + assert!(waiter.await.unwrap_err().is_cancelled()); + assert!(!gate.runtime_restore_idle()); + let owned = spawner.tasks.lock().unwrap().pop().unwrap(); + release.notify_one(); + owned.await.unwrap(); + assert!(gate.runtime_restore_idle()); + } + + #[tokio::test] + async fn runtime_restore_fails_closed_if_the_cleanup_owner_itself_is_aborted() { + let gate = Arc::new(RuntimeActivityGate::default()); + let spawner = Arc::new(CapturingSpawner::default()); + gate.bind(Arc::new(|| Ok(())), spawner.clone()).unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let waiter = tokio::spawn(gate.cleanup(Box::pin({ + let entered = entered.clone(); + async move { + entered.notify_one(); + std::future::pending::<()>().await; + Ok(()) + } + }))); + entered.notified().await; + let owned = spawner.tasks.lock().unwrap().pop().unwrap(); + owned.abort(); + assert!(owned.await.unwrap_err().is_cancelled()); + assert_eq!( + waiter.await.unwrap().unwrap_err().code, + BackendErrorCode::Cancelled + ); + assert!( + !gate.runtime_restore_idle(), + "uncertain cleanup cannot make restore safe" + ); + } + #[derive(Default)] + struct QueuedSpawner { + tasks: Mutex>>, + } + impl crate::config::TaskSpawner for QueuedSpawner { + fn spawn(&self, task: futures_util::future::BoxFuture<'static, ()>) { + self.tasks.lock().unwrap().push(task); + } + } + + #[tokio::test] + async fn runtime_restore_error_cleanup_claims_before_the_owned_task_is_scheduled() { + let gate = Arc::new(RuntimeActivityGate::default()); + let spawner = Arc::new(QueuedSpawner::default()); + gate.bind(Arc::new(|| Ok(())), spawner.clone()).unwrap(); + let mut cleanup = gate.cleanup(Box::pin(async { Ok(()) })); + assert!(futures_util::poll!(cleanup.as_mut()).is_pending()); + assert!( + !gate.runtime_restore_idle(), + "queued cleanup must already own its restore lease" + ); + let task = spawner.tasks.lock().unwrap().pop().unwrap(); + task.await; + cleanup.await.unwrap(); + assert!(gate.runtime_restore_idle()); + } } diff --git a/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs b/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs index b60957fe2..222fb1377 100644 --- a/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs +++ b/openless-all/app/crates/openless-core/tests/auxiliary_voice_lifecycle.rs @@ -732,22 +732,30 @@ async fn cancellation_during_cold_asr_stops_the_already_started_microphone() { assert!(starting.await.unwrap().is_err()); assert_eq!(starts.load(Ordering::SeqCst), 1); if less { - let phases = std::iter::from_fn(|| events.try_recv().ok()) + let mut snapshots = std::iter::from_fn(|| events.try_recv().ok()) .filter_map(|event| match event.kind { BackendEventKind::LessComputerEvent(LessComputerEvent { - kind: LessComputerEventKind::VoiceState { phase, .. }, + kind: + LessComputerEventKind::VoiceState { + phase, transcript, .. + }, .. - }) => Some(phase), + }) => Some((phase, transcript)), _ => None, }) .collect::>(); + let terminal = snapshots.pop().unwrap(); assert_eq!( - phases, - [ - LessComputerVoicePhase::Starting, - LessComputerVoicePhase::Idle - ] + terminal, + (LessComputerVoicePhase::Idle, String::new()), + "a cancelled cold start must not leave live transcript text behind" ); + // The ASR fixture streams a live partial while starting; it is + // mirrored into the same Starting phase, never a later phase. + assert!(!snapshots.is_empty()); + assert!(snapshots + .iter() + .all(|(phase, _)| *phase == LessComputerVoicePhase::Starting)); } std::fs::remove_dir_all(path).unwrap(); } @@ -1158,26 +1166,30 @@ async fn less_voice_feedback_preserves_phases_and_rejects_late_levels() { session_id, phase, level, + transcript, .. }, .. }) = event.kind { assert_eq!(session_id, id); - Some((phase, level)) + Some((phase, level, transcript)) } else { None } }) .collect::>(); + let live = || "instruction".to_string(); assert_eq!( phases, vec![ - (LessComputerVoicePhase::Starting, 0.0), - (LessComputerVoicePhase::Recording, 0.0), - (LessComputerVoicePhase::Recording, 0.7), - (LessComputerVoicePhase::Transcribing, 0.0), - (LessComputerVoicePhase::Idle, 0.0) + (LessComputerVoicePhase::Starting, 0.0, String::new()), + // The fixture ASR streams its text as a live partial at start. + (LessComputerVoicePhase::Starting, 0.0, live()), + (LessComputerVoicePhase::Recording, 0.0, live()), + (LessComputerVoicePhase::Recording, 0.7, live()), + (LessComputerVoicePhase::Transcribing, 0.0, live()), + (LessComputerVoicePhase::Idle, 0.0, live()) ] ); std::fs::remove_dir_all(path).unwrap(); diff --git a/openless-all/app/crates/openless-core/tests/contract_2.rs b/openless-all/app/crates/openless-core/tests/contract_2.rs index 2c31475f7..18ef2976a 100644 --- a/openless-all/app/crates/openless-core/tests/contract_2.rs +++ b/openless-all/app/crates/openless-core/tests/contract_2.rs @@ -71,23 +71,73 @@ fn runtime_wire_rejects_non_2_contracts() { #[test] fn less_computer_voice_feedback_matches_the_shared_wire_contract() { - use openless_core::{LessComputerEvent, LessComputerEventKind, LessComputerVoicePhase}; + use openless_core::{ + LessComputerEvent, LessComputerEventKind, LessComputerVoiceMode, LessComputerVoiceOutcome, + LessComputerVoicePhase, + }; let fixture = fixture(); let event: LessComputerEvent = serde_json::from_value(fixture["lessComputerVoice"]["sample"].clone()).unwrap(); assert!(matches!( - event.kind, + &event.kind, LessComputerEventKind::VoiceState { phase: LessComputerVoicePhase::Recording, - level: 0.5, + level, elapsed_ms: 120, + mode: LessComputerVoiceMode::Dictate, + transcript, + outcome: None, .. - } + } if *level == 0.5 && transcript == "打开" )); assert_eq!( serde_json::to_value(event).unwrap(), fixture["lessComputerVoice"]["sample"] ); + let idle: LessComputerEvent = + serde_json::from_value(fixture["lessComputerVoice"]["idleSample"].clone()).unwrap(); + assert!(matches!( + &idle.kind, + LessComputerEventKind::VoiceState { + phase: LessComputerVoicePhase::Idle, + outcome: Some(LessComputerVoiceOutcome::Committed), + .. + } + )); + assert_eq!( + serde_json::to_value(idle).unwrap(), + fixture["lessComputerVoice"]["idleSample"] + ); + let legacy: LessComputerEvent = + serde_json::from_value(fixture["lessComputerVoice"]["legacySample"].clone()).unwrap(); + assert!(matches!( + &legacy.kind, + LessComputerEventKind::VoiceState { + mode: LessComputerVoiceMode::Submit, + transcript, + outcome: None, + .. + } if transcript.is_empty() + )); + assert_eq!( + serde_json::to_value([ + LessComputerVoiceMode::Submit, + LessComputerVoiceMode::Dictate + ]) + .unwrap(), + fixture["lessComputerVoice"]["modes"] + ); + assert_eq!( + serde_json::to_value([ + LessComputerVoiceOutcome::Submitted, + LessComputerVoiceOutcome::Committed, + LessComputerVoiceOutcome::Empty, + LessComputerVoiceOutcome::Failed, + LessComputerVoiceOutcome::Cancelled, + ]) + .unwrap(), + fixture["lessComputerVoice"]["outcomes"] + ); assert_eq!( serde_json::to_value([ LessComputerVoicePhase::Starting, diff --git a/openless-all/app/package-lock.json b/openless-all/app/package-lock.json index 91a5252f5..e3867db7e 100644 --- a/openless-all/app/package-lock.json +++ b/openless-all/app/package-lock.json @@ -1,12 +1,12 @@ { "name": "openless-app", - "version": "2.0.0-Beta.2+build.20260924", + "version": "2.0.0-Beta.3+build.20260925", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "openless-app", - "version": "2.0.0-Beta.2+build.20260924", + "version": "2.0.0-Beta.3+build.20260925", "dependencies": { "@base-ui/react": "^1.6.0", "@shadcn/react": "^0.2.1", diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 34c2f4e17..6f8017770 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -1,7 +1,7 @@ { "name": "openless-app", "private": true, - "version": "2.0.0-Beta.2+build.20260924", + "version": "2.0.0-Beta.3+build.20260925", "type": "module", "scripts": { "pretest": "npm run build", @@ -14,9 +14,10 @@ "tauri": "tauri", "tauri:android:init": "tauri android init", "tauri:android:dev": "tauri android dev", - "tauri:android:build": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi", - "tauri:android:build:debug": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi", - "tauri:android:build:release": "tauri android build --apk --target aarch64 armv7 i686 x86_64 --split-per-abi", + "tauri:android:build": "node scripts/run-android-tauri-build.mjs --debug", + "tauri:android:build:debug": "node scripts/run-android-tauri-build.mjs --debug", + "tauri:android:build:release": "node scripts/run-android-tauri-build.mjs --release", + "check:android-apk-workflow": "node scripts/android-apk-workflow-contract.test.mjs", "merge:android-v1-manifest": "node scripts/merge-android-v1-manifest.mjs", "merge:android-overlay-manifest": "node scripts/merge-android-overlay-manifest.mjs", "merge:android-shizuku-manifest": "node scripts/merge-android-shizuku-manifest.mjs", diff --git a/openless-all/app/scripts/android-abi-matrix.mjs b/openless-all/app/scripts/android-abi-matrix.mjs new file mode 100644 index 000000000..e4c0b30cf --- /dev/null +++ b/openless-all/app/scripts/android-abi-matrix.mjs @@ -0,0 +1,123 @@ +/** + * Android ABI matrix helpers for CI (#1103). + * + * CLI target names match `tauri android build --target`. + * Gradle ABI folder names match APK lib/ layout. + */ + +export const ANDROID_ABI_MATRIX = [ + { + abi: 'aarch64', + rustTarget: 'aarch64-linux-android', + gradleAbi: 'arm64-v8a', + outputKey: 'arm64_v8a', + }, + { + abi: 'armv7', + rustTarget: 'armv7-linux-androideabi', + gradleAbi: 'armeabi-v7a', + outputKey: 'armeabi_v7a', + }, + { + abi: 'i686', + rustTarget: 'i686-linux-android', + gradleAbi: 'x86', + outputKey: 'x86', + }, + { + abi: 'x86_64', + rustTarget: 'x86_64-linux-android', + gradleAbi: 'x86_64', + outputKey: 'x86_64', + }, +]; + +const BY_ABI = new Map(ANDROID_ABI_MATRIX.map((entry) => [entry.abi, entry])); +const BY_GRADLE = new Map(ANDROID_ABI_MATRIX.map((entry) => [entry.gradleAbi, entry])); + +export function entryForAbi(abi) { + const entry = BY_ABI.get(abi); + if (!entry) { + throw new Error( + `Unknown Android ABI "${abi}". Expected one of: ${ANDROID_ABI_MATRIX.map((e) => e.abi).join(', ')}`, + ); + } + return entry; +} + +export function entryForGradleAbi(gradleAbi) { + const entry = BY_GRADLE.get(gradleAbi); + if (!entry) { + throw new Error(`Unknown Gradle ABI "${gradleAbi}"`); + } + return entry; +} + +/** + * Parse a comma/space-separated ABI list or the keyword "all". + * Empty / whitespace → defaultAbis. + */ +export function parseAndroidAbis(raw, { defaultAbis = ['aarch64'] } = {}) { + const text = (raw ?? '').trim(); + if (!text) { + return defaultAbis.map(entryForAbi); + } + if (text.toLowerCase() === 'all') { + return [...ANDROID_ABI_MATRIX]; + } + const tokens = text + .split(/[,\s]+/) + .map((t) => t.trim()) + .filter(Boolean); + if (tokens.length === 0) { + return defaultAbis.map(entryForAbi); + } + const seen = new Set(); + const out = []; + for (const token of tokens) { + const entry = entryForAbi(token); + if (seen.has(entry.abi)) continue; + seen.add(entry.abi); + out.push(entry); + } + return out; +} + +/** GitHub Actions matrix.include payload for selected ABIs. */ +export function toGithubMatrixInclude(entries) { + return entries.map((entry) => ({ + abi: entry.abi, + rust_target: entry.rustTarget, + gradle_abi: entry.gradleAbi, + output_key: entry.outputKey, + })); +} + +function main() { + const mode = process.argv[2] || 'parse'; + if (mode === 'parse') { + const raw = process.argv[3] ?? process.env.OPENLESS_ANDROID_ABIS ?? ''; + const defaultRaw = process.env.OPENLESS_ANDROID_ABIS_DEFAULT ?? 'aarch64'; + const entries = parseAndroidAbis(raw, { + defaultAbis: parseAndroidAbis(defaultRaw, { defaultAbis: ['aarch64'] }).map((e) => e.abi), + }); + process.stdout.write(JSON.stringify(toGithubMatrixInclude(entries))); + return; + } + if (mode === 'list-all') { + process.stdout.write(JSON.stringify(toGithubMatrixInclude(ANDROID_ABI_MATRIX))); + return; + } + if (mode === 'rust-targets') { + const raw = process.argv[3] ?? process.env.OPENLESS_ANDROID_ABIS ?? 'all'; + const entries = parseAndroidAbis(raw, { defaultAbis: ANDROID_ABI_MATRIX.map((e) => e.abi) }); + process.stdout.write(entries.map((e) => e.rustTarget).join(',')); + return; + } + console.error(`Usage: node android-abi-matrix.mjs [abis]`); + process.exit(1); +} + +if (process.argv[1]?.replace(/\\/g, '/').endsWith('android-abi-matrix.mjs')) { + main(); +} diff --git a/openless-all/app/scripts/android-apk-workflow-contract.test.mjs b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs new file mode 100644 index 000000000..f66a63dac --- /dev/null +++ b/openless-all/app/scripts/android-apk-workflow-contract.test.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { + ANDROID_ABI_MATRIX, + parseAndroidAbis, + toGithubMatrixInclude, + entryForAbi, +} from './android-abi-matrix.mjs'; +import { collectSplitApks } from './collect-android-split-apks.mjs'; + +const scriptDir = fileURLToPath(new URL('.', import.meta.url)); +const workflowPath = fileURLToPath( + new URL('../../../.github/workflows/android-apk.yml', import.meta.url), +); + +// --- ABI matrix --- +assert.equal( + parseAndroidAbis('') + .map((e) => e.abi) + .join(','), + 'aarch64', +); +assert.equal(parseAndroidAbis('all').length, 4); +assert.equal( + parseAndroidAbis('aarch64,armv7') + .map((e) => e.abi) + .join(','), + 'aarch64,armv7', +); +assert.equal( + parseAndroidAbis('aarch64 aarch64,armv7') + .map((e) => e.abi) + .join(','), + 'aarch64,armv7', +); +assert.throws(() => parseAndroidAbis('riscv'), /Unknown Android ABI/); +assert.equal(entryForAbi('x86_64').gradleAbi, 'x86_64'); +assert.equal(toGithubMatrixInclude(ANDROID_ABI_MATRIX)[0].rust_target, 'aarch64-linux-android'); + +const cli = spawnSync( + process.execPath, + [join(scriptDir, 'android-abi-matrix.mjs'), 'parse', 'aarch64'], + { encoding: 'utf8' }, +); +assert.equal(cli.status, 0); +assert.equal(JSON.parse(cli.stdout)[0].gradle_abi, 'arm64-v8a'); + +// --- collectSplitApks with a minimal zip APK --- +function writeMinimalApk(path, ...abis) { + const python = process.platform === 'win32' ? 'python' : 'python3'; + const result = spawnSync( + python, + [ + '-c', + ` +import sys, zipfile +with zipfile.ZipFile(sys.argv[1], 'w') as apk: + for abi in sys.argv[2:]: + apk.writestr('lib/' + abi + '/libdummy.so', b'so') +`, + path, + ...abis, + ], + { encoding: 'utf8' }, + ); + assert.equal(result.status, 0, result.stderr); +} + +const tmp = mkdtempSync(join(tmpdir(), 'openless-apk-collect-')); +try { + const androidRoot = join(tmp, 'android'); + const outDir = join(tmp, 'out'); + const apkDir = join(androidRoot, 'app', 'build', 'outputs', 'apk', 'release'); + mkdirSync(apkDir, { recursive: true }); + const apk = join(apkDir, 'app-arm64-v8a-release.apk'); + writeMinimalApk(apk, 'arm64-v8a'); + const options = { + mode: 'release', + label: 'test', + expectedGradleAbis: ['arm64-v8a'], + androidRoot, + outDir, + version: '2.0.0-Beta.3+build.20260925', + }; + const { outputs } = collectSplitApks(options); + assert.match( + outputs.arm64_v8a_path.replace(/\\/g, '/'), + /OpenLess_2\.0\.0-Beta\.3\+build\.20260925_arm64-v8a\.apk$/, + ); + assert.equal(outputs.arm64_v8a_arch, 'aarch64'); + writeMinimalApk(apk, 'arm64-v8a', 'unexpected-abi'); + assert.throws(() => collectSplitApks(options), /Unknown ABI/); + writeMinimalApk(apk, 'arm64-v8a', 'x86'); + assert.throws(() => collectSplitApks(options), /expected exactly one ABI/); + writeMinimalApk(apk, 'arm64-v8a'); + writeFileSync(apk, readFileSync(apk).subarray(0, -22)); + assert.throws(() => collectSplitApks(options), /Invalid APK ZIP/); + writeMinimalApk(apk, 'arm64-v8a'); + const corrupt = readFileSync(apk); + corrupt[30 + Buffer.byteLength('lib/arm64-v8a/libdummy.so')] ^= 0xff; + writeFileSync(apk, corrupt); + assert.throws(() => collectSplitApks(options), /Invalid APK ZIP/); + writeMinimalApk(apk, 'x86'); + assert.throws(() => collectSplitApks(options), /Missing split APKs/); + writeMinimalApk(apk, 'arm64-v8a'); + writeMinimalApk(join(apkDir, 'duplicate.apk'), 'arm64-v8a'); + assert.throws(() => collectSplitApks(options), /Duplicate APKs/); +} finally { + rmSync(tmp, { recursive: true, force: true }); +} + +// --- workflow contract (#1103) --- +const workflow = readFileSync(workflowPath, 'utf8'); +assert.match(workflow, /prefix-key:\s*v1-rust-android-1103/); +assert.doesNotMatch(workflow, /Free disk before artifact upload/); +assert.doesNotMatch(workflow, /rm -rf src-tauri\/target/); +assert.doesNotMatch(workflow, /rm -rf ~\/\.cargo\/registry/); +assert.doesNotMatch(workflow, /rm -rf ~\/\.gradle\/caches/); +assert.match(workflow, /abis:/); +assert.match(workflow, /default:\s*['"]aarch64['"]/); +assert.match(workflow, /fast_profile:/); +assert.match(workflow, /strategy:[\s\S]*matrix:/); +assert.match(workflow, /OPENLESS_ANDROID_TARGETS/); +assert.match(workflow, /CARGO_PROFILE_RELEASE_LTO/); +assert.match(workflow, /first ABI may compile twice|android-studio-script/); +assert.match(workflow, /publish-android-release/); +assert.match(workflow, /download-artifact/); +assert.match(workflow, /Rust cache/); + +console.log('android-apk-workflow-contract checks passed'); diff --git a/openless-all/app/scripts/build-mac.sh b/openless-all/app/scripts/build-mac.sh index cb2d0de79..93e17a205 100755 --- a/openless-all/app/scripts/build-mac.sh +++ b/openless-all/app/scripts/build-mac.sh @@ -27,25 +27,28 @@ fi echo "▶ 检查 Apple Silicon MLX 构建依赖" npm run check:macos-metal-toolchain -# Homebrew rustc 在 macOS 上对 `strip=symbols` 生成的 proc-macro dylib -# 可能报 "mis-aligned LINKEDIT string pool"。仅官方 macOS 发布脚本降级 -# 为 debuginfo;Cargo.toml 的全局 profile 仍让 Linux/Windows/Android 使用 symbols。 -export CARGO_PROFILE_RELEASE_STRIP=debuginfo -export RUSTC_WRAPPER="$PWD/scripts/rustc-macos-proc-macro-wrapper.sh" +source scripts/macos-build-env.sh +echo "▶ Cargo release codegen units: ${CARGO_PROFILE_RELEASE_CODEGEN_UNITS} (macOS only)" echo "▶ Cargo release strip: ${CARGO_PROFILE_RELEASE_STRIP} (macOS only)" echo "▶ Rust proc-macro host wrapper: ${RUSTC_WRAPPER}" -# 只保留最新一份 qwen3-asr-rs 构建目录:本地混跑 cargo check/test/build 会按不同 -# feature 上下文生成多份,各自带一份 metallib,会让暂存脚本拒绝猜测。 -KEEP_QWEN_DIR="$(ls -dt src-tauri/target/release/build/qwen3-asr-rs-* 2>/dev/null | head -1 || true)" +# Cargo 为 build-script 可执行文件和 OUT_DIR 创建不同目录。只在多个 +# metallib 输出之间清理,保留所有 build-script 缓存,避免每次重新编译。 +KEEP_QWEN_DIR="" +for d in src-tauri/target/release/build/qwen3-asr-rs-*; do + [ -s "$d/out/lib/mlx.metallib" ] || continue + if [ -z "$KEEP_QWEN_DIR" ] || [ "$d/out/lib/mlx.metallib" -nt "$KEEP_QWEN_DIR/out/lib/mlx.metallib" ]; then + KEEP_QWEN_DIR="$d" + fi +done if [ -n "$KEEP_QWEN_DIR" ]; then for d in src-tauri/target/release/build/qwen3-asr-rs-*; do + [ -s "$d/out/lib/mlx.metallib" ] || continue [ "$d" = "$KEEP_QWEN_DIR" ] || rm -rf "$d" done fi echo "▶ tauri build" -BUILD_START_TS="$(date +%s)" TAURI_BUILD_ARGS=(build --ci) case "$(uname -m)" in arm64) @@ -63,16 +66,41 @@ esac if [ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ] || [ -n "${TAURI_SIGNING_PRIVATE_KEY_PATH:-}" ]; then TAURI_BUILD_ARGS+=(--config '{"bundle":{"createUpdaterArtifacts":true}}') fi -# bundle_dmg(AppleScript)在 Xcode beta 上可能退出非零;.app 与 DMG 是否真实 -# 产出交给下方的新鲜度/存在性校验判定,不在这一步盲 abort。 -npm run tauri -- "${TAURI_BUILD_ARGS[@]}" || echo "⚠ tauri build 退出码非零,继续校验产物" - APP_VERSION="$(node -p "require('./package.json').version")" DMG_PATH="$DMG_DIR/OpenLess_${APP_VERSION}_${MAC_BUNDLE_ARCH}.dmg" -# bundle 必须是本次构建产出的(与构建开始时间比;打包后原始二进制还会被签名 -# 触碰,不能拿它当基准)。 -if [ ! -d "$APP" ] || [ "$(stat -f %m "$APP/Contents/MacOS/openless")" -lt "$BUILD_START_TS" ]; then +# 清掉交付产物,保留 Cargo 缓存。热构建可复用旧时间戳的二进制,不能用 +# 编译文件的 mtime 判断 bundle 新鲜度;Tauri 失败时也不能接受上轮安装包。 +rm -rf "$APP" +rm -f "$DMG_PATH" "${APP}.tar.gz" "${APP}.tar.gz.sig" +TAURI_BUILD_ARGS+=(-- --locked --timings) +if [ "$MAC_BUNDLE_ARCH" = "aarch64" ]; then + # Tauri skips Finder AppleScript in CI. Write deterministic Finder metadata + # into its temporary image before Tauri compresses and signs the final DMG. + DMG_LAYOUT_ENV_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openless-dmg-python.XXXXXX")" + cleanup_dmg_environment() { rm -rf "$DMG_LAYOUT_ENV_DIR"; } + trap cleanup_dmg_environment EXIT + python3 -m venv "$DMG_LAYOUT_ENV_DIR" + DMG_LAYOUT_PYTHON="$DMG_LAYOUT_ENV_DIR/bin/python3" + "$DMG_LAYOUT_PYTHON" -m pip install --quiet --disable-pip-version-check \ + --only-binary=:all: --no-deps --require-hashes -r scripts/macos-dmg-requirements.txt + "$DMG_LAYOUT_PYTHON" scripts/macos-dmg-layout.test.py + CI=true TAURI_BUNDLER_DMG_IGNORE_CI=false \ + OPENLESS_DMG_LAYOUT_ROOT="$PWD" OPENLESS_DMG_LAYOUT_PYTHON="$DMG_LAYOUT_PYTHON" \ + OPENLESS_DMG_LAYOUT_STAMP="$DMG_LAYOUT_ENV_DIR/layout-applied" \ + PATH="$PWD/scripts/macos-dmg-bin:$PATH" npm run tauri -- "${TAURI_BUILD_ARGS[@]}" + if [ ! -s "$DMG_LAYOUT_ENV_DIR/layout-applied" ]; then + echo "✗ Tauri 未调用 DMG 布局步骤,中止交付" + exit 1 + fi + "$DMG_LAYOUT_PYTHON" scripts/macos-dmg-layout.py verify "$DMG_PATH" + cleanup_dmg_environment + trap - EXIT +else + npm run tauri -- "${TAURI_BUILD_ARGS[@]}" +fi + +if [ ! -f "$APP/Contents/MacOS/openless" ]; then echo "✗ $APP 缺失或不是本次构建的产物(打包未完成),中止" exit 1 fi diff --git a/openless-all/app/scripts/collect-android-split-apks.mjs b/openless-all/app/scripts/collect-android-split-apks.mjs new file mode 100644 index 000000000..5fa8c2f8a --- /dev/null +++ b/openless-all/app/scripts/collect-android-split-apks.mjs @@ -0,0 +1,202 @@ +/** + * Collect split-per-ABI APKs from gen/android outputs. + * + * Env: + * OPENLESS_APK_MODE debug|release + * OPENLESS_APK_LABEL artifact label suffix + * OPENLESS_EXPECTED_GRADLE_ABIS comma-separated Gradle ABI folders (required) + * RUNNER_TEMP output parent (required in CI) + * GITHUB_OUTPUT optional; writes paths when set + */ +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { join, relative } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { entryForGradleAbi } from './android-abi-matrix.mjs'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const KNOWN_ABIS = new Set(['arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86']); + +function walkApks(root) { + const out = []; + if (!existsSync(root)) return out; + const stack = [root]; + while (stack.length) { + const dir = stack.pop(); + for (const name of readdirSync(dir)) { + const path = join(dir, name); + const st = statSync(path); + if (st.isDirectory()) { + stack.push(path); + } else if (name.endsWith('.apk')) { + out.push(path); + } + } + } + return out.sort(); +} + +function listApkAbis(apkPath) { + // Use the same standard ZIP reader as the original workflow. Scanning for + // central-directory magic accepts truncated archives and bytes inside entries. + const script = ` +import json, sys, zipfile +with zipfile.ZipFile(sys.argv[1]) as apk: + bad = apk.testzip() + if bad is not None: + raise ValueError("CRC failure in " + bad) + print(json.dumps(sorted({name.split('/')[1] for name in apk.namelist() + if name.startswith('lib/') and len(name.split('/')) >= 3}))) +`; + const commands = process.platform === 'win32' ? ['python', 'python3'] : ['python3', 'python']; + for (const command of commands) { + const result = spawnSync(command, ['-c', script, apkPath], { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + if (result.error?.code === 'ENOENT') continue; + if (result.error || result.status !== 0) { + throw new Error(`Invalid APK ZIP ${apkPath}: ${result.error?.message || result.stderr}`); + } + const abis = JSON.parse(result.stdout); + for (const abi of abis) { + if (!KNOWN_ABIS.has(abi)) throw new Error(`Unknown ABI ${abi} in ${apkPath}`); + } + return abis; + } + throw new Error('Python 3 is required to validate APK ZIP contents'); +} + +export function collectSplitApks({ + mode, + label, + expectedGradleAbis, + androidRoot = join(appRoot, 'src-tauri/gen/android'), + outDir, + version, +} = {}) { + if (!mode || !label) { + throw new Error('mode and label are required'); + } + if (!expectedGradleAbis?.length) { + throw new Error('expectedGradleAbis must be a non-empty array'); + } + if (!outDir) { + throw new Error('outDir is required'); + } + if (!version) { + throw new Error('version is required'); + } + + mkdirSync(outDir, { recursive: true }); + const candidates = walkApks(androidRoot).filter((apk) => + apk.replace(/\\/g, '/').includes('/outputs/'), + ); + if (candidates.length === 0) { + const all = walkApks(androidRoot); + const hint = all.length ? all.map((p) => relative(androidRoot, p)).join('\n') : '(none)'; + throw new Error(`No APK found under ${androidRoot}/**/outputs/\nOther APKs:\n${hint}`); + } + + const expected = new Set(expectedGradleAbis); + const found = new Map(); + + for (const apk of candidates) { + const abis = listApkAbis(apk); + if (abis.length !== 1) { + throw new Error( + `${apk} contains ABI directories [${abis.join(', ')}]; expected exactly one ABI per APK`, + ); + } + const abi = abis[0]; + if (!expected.has(abi)) { + // Ignore extras from previous local builds; only enforce expected set. + console.warn(`Skipping unexpected ABI ${abi} from ${apk}`); + continue; + } + if (found.has(abi)) { + throw new Error(`Duplicate APKs for ABI ${abi}: ${found.get(abi)} and ${apk}`); + } + const destName = + mode === 'release' + ? `OpenLess_${version}_${abi}.apk` + : `OpenLess-android-debug-${abi}-${label}.apk`; + const dest = join(outDir, destName); + copyFileSync(apk, dest); + found.set(abi, dest); + console.log(`Collected ${abi}: ${apk} -> ${dest}`); + } + + const missing = [...expected].filter((abi) => !found.has(abi)).sort(); + if (missing.length) { + throw new Error(`Missing split APKs for ABI(s): ${missing.join(', ')}`); + } + + const releaseFiles = [...expected].map((abi) => found.get(abi)); + const outputs = {}; + for (const abi of expected) { + const entry = entryForGradleAbi(abi); + outputs[`${entry.outputKey}_path`] = found.get(abi); + outputs[`${entry.outputKey}_arch`] = entry.abi; + } + outputs.out_dir = outDir; + outputs.release_files = releaseFiles.join('\n'); + // Single-ABI CI jobs consume these stable keys. + if (expected.size === 1) { + const onlyAbi = [...expected][0]; + const entry = entryForGradleAbi(onlyAbi); + outputs.apk_path = found.get(onlyAbi); + outputs.gradle_abi = onlyAbi; + outputs.cli_abi = entry.abi; + } + return { found, outputs, releaseFiles }; +} + +function main() { + const mode = process.env.OPENLESS_APK_MODE; + const label = process.env.OPENLESS_APK_LABEL; + const expectedRaw = process.env.OPENLESS_EXPECTED_GRADLE_ABIS || ''; + const expectedGradleAbis = expectedRaw + .split(/[,\s]+/) + .map((s) => s.trim()) + .filter(Boolean); + const runnerTemp = process.env.RUNNER_TEMP; + if (!runnerTemp) { + throw new Error('RUNNER_TEMP is required'); + } + const version = JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8')).version; + const outDir = join(runnerTemp, `openless-android-${mode}-split`); + const { outputs } = collectSplitApks({ + mode, + label, + expectedGradleAbis, + outDir, + version, + }); + + const githubOutput = process.env.GITHUB_OUTPUT; + if (githubOutput) { + const lines = []; + for (const [key, value] of Object.entries(outputs)) { + if (key === 'release_files') { + lines.push(`${key}< releaseSigningConfig); + } else if (/signingConfigs\s*\{/.test(content)) { + content = content.replace( + /signingConfigs\s*\{/, + `signingConfigs {\n ${releaseSigningConfig}`, + ); + } else { content = content.replace(/android\s*\{/, `android {${signingConfigsBlock}`); } @@ -70,10 +77,8 @@ function main() { `buildTypes {\n getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")\n }`, ); } else { - content = content.replace( - /android\s*\{/, - `android {${signingConfigsBlock}\n buildTypes {\n getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")\n }\n }`, - ); + // Create the signing config before looking it up in this later block. + content += `\nandroid {\n buildTypes {\n getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")\n }\n }\n}\n`; } writeFileSync(gradlePath, content); diff --git a/openless-all/app/scripts/configure-android-release-signing.test.mjs b/openless-all/app/scripts/configure-android-release-signing.test.mjs new file mode 100644 index 000000000..83569d991 --- /dev/null +++ b/openless-all/app/scripts/configure-android-release-signing.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict'; +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +const root = mkdtempSync(join(tmpdir(), 'openless-signing-')); +try { + mkdirSync(join(root, 'scripts')); + mkdirSync(join(root, 'src-tauri/gen/android/app'), { recursive: true }); + const script = join(root, 'scripts/configure-android-release-signing.mjs'); + copyFileSync(new URL('./configure-android-release-signing.mjs', import.meta.url), script); + const gradle = join(root, 'src-tauri/gen/android/app/build.gradle.kts'); + const env = { + ...process.env, + ANDROID_KEYSTORE_BASE64: Buffer.from('fixture-keystore').toString('base64'), + ANDROID_KEYSTORE_PASSWORD: 'fixture-store-$"}\\secret', + ANDROID_KEY_ALIAS: 'fixture-alias', + ANDROID_KEY_PASSWORD: 'fixture-key-secret', + }; + for (const template of [ + 'android {\n buildTypes { getByName("release") { isMinifyEnabled = false } }\n}', + 'android {\n namespace = "test"\n}', + 'android {\n signingConfigs { create("debug") {} }\n buildTypes {}\n}', + 'android {\n signingConfigs { create("openlessRelease") { storePassword = "old-}secret" } }\n buildTypes { getByName("release") {} }\n}', + ]) { + writeFileSync(gradle, template); + const keystore = join(root, 'src-tauri/gen/android/openless-release.keystore'); + writeFileSync(keystore, 'old-keystore'); + chmodSync(keystore, 0o644); + const run = () => spawnSync(process.execPath, [script], { env, encoding: 'utf8' }); + const first = run(); + assert.equal(first.status, 0, first.stderr); + const content = readFileSync(gradle, 'utf8'); + for (const name of ['ANDROID_KEYSTORE_PASSWORD', 'ANDROID_KEY_ALIAS', 'ANDROID_KEY_PASSWORD']) { + assert.ok(content.includes(`System.getenv("${name}")`)); + assert.ok(!content.includes(env[name]), `${name} must not be embedded in cacheable source`); + assert.ok(!first.stdout.includes(env[name])); + } + assert.ok(!content.includes('old-}secret')); + assert.ok( + content.indexOf('create("openlessRelease")') < + content.indexOf('signingConfigs.getByName("openlessRelease")'), + ); + if (process.platform !== 'win32') assert.equal(statSync(keystore).mode & 0o777, 0o600); + assert.equal(content.match(/create\("openlessRelease"\)/g)?.length, 1); + assert.equal( + content.match(/signingConfig = signingConfigs.getByName\("openlessRelease"\)/g)?.length, + 1, + ); + const second = run(); + assert.equal(second.status, 0, second.stderr); + assert.equal(readFileSync(gradle, 'utf8'), content); + assert.equal( + readFileSync(join(root, 'src-tauri/gen/android/openless-release.keystore'), 'utf8'), + 'fixture-keystore', + ); + } +} finally { + rmSync(root, { recursive: true, force: true }); +} +console.log('Android release signing keeps credentials out of Gradle source'); diff --git a/openless-all/app/scripts/encrypted-sync-setup-prompt.test.mjs b/openless-all/app/scripts/encrypted-sync-setup-prompt.test.mjs new file mode 100644 index 000000000..3a77fb328 --- /dev/null +++ b/openless-all/app/scripts/encrypted-sync-setup-prompt.test.mjs @@ -0,0 +1,242 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const source = readFileSync( + new URL('../src/components/CloudSyncSetupPrompt.tsx', import.meta.url), + 'utf8', +); +const code = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + }, +}).outputText; +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const deferred = () => { + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { promise, resolve, reject }; +}; +const jsx = (type, props) => ({ type, props: props ?? {} }); +const all = (tree) => + Array.isArray(tree) ? tree.flatMap(all) : tree?.props ? [tree, ...all(tree.props.children)] : []; +function context({ native = true, blocked = false } = {}) { + const slots = [], + effects = [], + listeners = new Map(), + timers = new Map(); + let cursor = 0, + id = 0, + calls = 0, + response = false, + props = { blocked, onSetup: () => {} }, + last; + const document = Object.assign(new EventTarget(), { + visibilityState: 'visible', + hasFocus: () => true, + activeElement: null, + }); + const window = new EventTarget(); + const react = { + useState(initial) { + const i = cursor++; + slots[i] ??= { value: initial }; + return [ + slots[i].value, + (v) => { + slots[i].value = typeof v === 'function' ? v(slots[i].value) : v; + }, + ]; + }, + useRef(initial) { + return this.useState({ current: initial })[0]; + }, + useEffect(fn, deps) { + const i = cursor++, + previous = slots[i]; + if (previous && deps.every((v, j) => Object.is(v, previous.deps[j]))) return; + slots[i] = { deps }; + effects.push(() => { + previous?.cleanup?.(); + slots[i].cleanup = fn(); + }); + }, + }; + react.useRef = (initial) => react.useState({ current: initial })[0]; + const imports = { + react, + 'react-i18next': { useTranslation: () => ({ t: (k) => k }) }, + 'react/jsx-runtime': { jsx, jsxs: jsx, Fragment: 'fragment' }, + 'lucide-react': { CloudIcon: 'cloud', XIcon: 'x' }, + '../lib/ipc/shared': { isTauri: native }, + '../lib/ipc/cloud-sync-e2ee': { + cloudSyncE2eeClaimSetupPrompt: async () => { + calls++; + return typeof response === 'function' ? response() : response; + }, + }, + '../pages/settings/CloudSyncSection': { CloudSyncSection: 'sync-section' }, + './ui/Modal': { Modal: 'modal' }, + '@tauri-apps/api/event': { + listen: async (name, fn) => { + listeners.set(name, fn); + return () => listeners.delete(name); + }, + }, + }; + const exports = {}; + new Function( + 'require', + 'exports', + 'window', + 'document', + 'HTMLElement', + 'setTimeout', + 'clearTimeout', + code, + )( + (name) => imports[name], + exports, + window, + document, + class {}, + (fn) => { + timers.set(++id, fn); + return id; + }, + (i) => timers.delete(i), + ); + const render = () => { + cursor = 0; + last = exports.CloudSyncSetupPrompt(props); + effects.splice(0).forEach((fn) => fn()); + return last; + }; + return { + render, + document, + window, + setResponse: (v) => { + response = v; + }, + setBlocked: (v) => { + props = { ...props, blocked: v }; + }, + calls: () => calls, + listeners, + timers, + event: (type) => listeners.get('backend:event')?.({ payload: { kind: { type } } }), + async flush() { + await tick(); + const queued = [...timers.values()]; + timers.clear(); + queued.forEach((fn) => fn()); + await tick(); + return render(); + }, + unmount() { + slots.forEach((s) => s?.cleanup?.()); + }, + welcome: exports.CloudSyncWelcome, + }; +} +for (const options of [{ native: false }, { blocked: true }]) { + const c = context(options); + c.setResponse(true); + assert.equal(c.render(), null); + await c.flush(); + assert.equal(c.calls(), 0); + assert.equal(c.listeners.size, 0); + c.unmount(); +} +{ + const c = context(); + c.setResponse(true); + c.render(); + const tree = await c.flush(); + assert.equal(tree.type, 'modal'); + assert.equal(c.calls(), 1); + const later = all(tree).find( + (n) => n.type === 'button' && n.props.children === 'cloudSyncE2ee.setupPromptLater', + ); + assert(later); + later.props.onClick(); + assert.equal(c.render(), null); + c.unmount(); + assert.equal(c.listeners.size, 0); +} +{ + const c = context(); + c.render(); + await c.flush(); + assert.equal(c.calls(), 1); + assert.equal(c.render(), null); + c.event('qa_level'); + assert.equal(c.timers.size, 0); + c.setResponse(true); + c.event('credentials_changed'); + assert.equal((await c.flush()).type, 'modal'); + c.unmount(); +} +{ + const c = context(); + c.setResponse(() => Promise.reject(new Error('vault denied'))); + c.render(); + await c.flush(); + assert.equal(c.render(), null); + assert.equal(c.listeners.size, 1); + c.setResponse(true); + c.window.dispatchEvent(new Event('focus')); + assert.equal((await c.flush()).type, 'modal'); + c.unmount(); +} +{ + const c = context(); + const pending = deferred(); + c.setResponse(() => pending.promise); + c.render(); + await c.flush(); + c.setBlocked(true); + c.render(); + pending.resolve(true); + await tick(); + assert.equal(c.render(), null); + assert.equal(c.listeners.size, 0); + c.unmount(); +} +{ + const c = context(); + c.document.visibilityState = 'hidden'; + c.setResponse(true); + c.render(); + await c.flush(); + assert.equal(c.calls(), 0); + c.document.visibilityState = 'visible'; + c.document.dispatchEvent(new Event('visibilitychange')); + assert.equal((await c.flush()).type, 'modal'); + c.unmount(); +} +for (const change of ['hidden', 'focus', 'recording']) { + const c = context(); + const pending = deferred(); + c.setResponse(() => pending.promise); + c.render(); + await c.flush(); + if (change === 'hidden') { + c.document.visibilityState = 'hidden'; + c.document.dispatchEvent(new Event('visibilitychange')); + } + if (change === 'focus') c.document.hasFocus = () => false; + if (change === 'recording') c.event('dictation_state_changed'); + pending.resolve(true); + await tick(); + assert.equal(c.render(), null, `late claim after ${change} must not open`); + c.unmount(); +} +console.log('encrypted sync setup prompt: 10 lifecycle cases passed'); diff --git a/openless-all/app/scripts/encrypted-sync-ui-bridge.test.mjs b/openless-all/app/scripts/encrypted-sync-ui-bridge.test.mjs new file mode 100644 index 000000000..b1761ae7a --- /dev/null +++ b/openless-all/app/scripts/encrypted-sync-ui-bridge.test.mjs @@ -0,0 +1,312 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +const app = fileURLToPath(new URL('../', import.meta.url)); +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const source = readFileSync(app + '/src/lib/encryptedSyncUiBridge.ts', 'utf8'); +const code = ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }, +}).outputText; +const tick = () => new Promise((r) => setImmediate(r)); +const drain = async () => { + for (let i = 0; i < 8; i++) await tick(); +}; +const defer = () => { + let resolve, reject; + const promise = new Promise((r, e) => { + resolve = r; + reject = e; + }); + return { promise, resolve, reject }; +}; +function context({ fresh = false } = {}) { + const window = new EventTarget(), + handlers = new Map(), + statusQueue = [], + attempts = []; + let errors = 0; + window.addEventListener('openless:sync-ui-persistence-failed', () => errors++); + let locale = 'en', + fontScale = 'medium', + rev = fresh ? 0 : 1; + let preferences = fresh ? null : { locale, fontScale }; + let status = { + sequence: '0', + account: fresh ? null : { githubId: '1' }, + vaultId: fresh ? null : 'v1', + consentVersion: fresh ? null : 'yes', + taskId: null, + }; + const revision = () => (rev ? `00000000-0000-4000-8000-${String(rev).padStart(12, '0')}` : null); + const event = (key, source) => + window.dispatchEvent( + new CustomEvent('openless:ui-preferences-changed', { detail: { key, source } }), + ); + const i18n = { + getLocalePreference: () => locale, + setLocalePreference: async (value, source = 'user') => { + locale = value; + event('locale', source); + }, + SUPPORTED_LOCALES: ['en', 'fr', 'de'], + }; + const fonts = { + readFontScale: () => fontScale, + setFontScale: (value, source = 'user') => { + fontScale = value; + event('fontScale', source); + }, + }; + const invoke = async (cmd, args) => { + if (cmd === 'cloud_sync_e2ee_status') + return statusQueue.length ? statusQueue.shift().promise : { ...status }; + if (cmd === 'cloud_sync_e2ee_get_ui_preferences_snapshot') + return { preferences: preferences && { ...preferences }, revision: revision() }; + if ( + cmd === 'cloud_sync_e2ee_set_ui_preferences_checked' || + cmd === 'cloud_sync_e2ee_set_ui_preferences' + ) { + attempts.push({ cmd, ...args }); + if (cmd.endsWith('_checked') && args.expectedRevision !== revision()) + throw { details: { reason: 'stale_preview' } }; + const next = { locale: args.locale, fontScale: args.fontScale }; + if (JSON.stringify(next) !== JSON.stringify(preferences)) { + preferences = next; + rev++; + } + return; + } + throw new Error(cmd); + }; + const exports = {}, + shared = { isTauri: true, invokeOrMock: invoke }; + const localRequire = (name) => + name === './ipc/shared' + ? shared + : name === '../i18n' + ? i18n + : name === './fontScale' + ? fonts + : name === '@tauri-apps/api/event' + ? { + listen: async (name, fn) => { + handlers.set(name, fn); + return () => handlers.delete(name); + }, + } + : null; + new Function('require', 'exports', 'window', 'location', 'Event', 'CustomEvent', code)( + localRequire, + exports, + window, + { search: '' }, + Event, + CustomEvent, + ); + return { + install: exports.installEncryptedSyncUiBridge, + flush: exports.flushEncryptedSyncUiPreferences, + userLocale: i18n.setLocalePreference, + userFont: fonts.setFontScale, + statusQueue, + attempts, + setStatus: (value) => { + status = { ...status, ...value }; + }, + snapshot: () => ({ locale, fontScale, preferences, revision: revision(), errors }), + externalWrite: (value = {}) => { + preferences = { ...preferences, ...value }; + rev++; + }, + unchecked: () => invoke('cloud_sync_e2ee_set_ui_preferences', { locale, fontScale }), + restore: (sequence = '1', scope = {}) => + handlers.get('cloud-sync-e2ee:restored')({ + payload: { + sequence, + accountId: status.account.githubId, + vaultId: status.vaultId, + taskId: 'restore', + ...scope, + }, + }), + }; +} +let failed = 0; +async function test(name, fn) { + try { + await fn(); + console.log('PASS ' + name); + } catch (e) { + failed++; + console.log('FAIL ' + name + '\n' + e.message); + } +} +await test('old mirror queued before restore cannot overwrite its native value', async () => { + const c = context(); + await c.install(); + const wait = defer(); + c.statusQueue.push(wait); + await c.userLocale('de'); + await tick(); + c.externalWrite({ locale: 'fr' }); + c.restore(); + await tick(); + wait.resolve({ sequence: '2', account: { githubId: '1' }, vaultId: 'v1', consentVersion: 'yes' }); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'fr'); + assert.equal(c.snapshot().locale, 'fr'); +}); +await test('new choice after restore notification wins only its chosen field', async () => { + const c = context(); + await c.install(); + const wait = defer(); + c.statusQueue.push(wait); + c.externalWrite({ locale: 'fr', fontScale: 'large' }); + c.restore(); + await tick(); + await c.userLocale('de'); + wait.resolve({ sequence: '2', account: { githubId: '1' }, vaultId: 'v1', consentVersion: 'yes' }); + await drain(); + assert.deepEqual(c.snapshot().preferences, { locale: 'de', fontScale: 'large' }); + assert.equal(c.snapshot().locale, 'de'); + assert.equal(c.snapshot().fontScale, 'large'); +}); +await test('prepare mirror must not permanently stale the live bridge revision', async () => { + const c = context({ fresh: true }); + await c.install(); + await c.flush(); + c.setStatus({ account: { githubId: '1' }, vaultId: 'v1', consentVersion: 'yes' }); + await c.userLocale('de'); + await drain(); + c.userFont('large'); + await drain(); + assert.deepEqual( + c.snapshot().preferences, + { locale: 'de', fontScale: 'large' }, + JSON.stringify(c.snapshot()), + ); +}); +await test('legitimate account switch must rebase the cached mirror scope', async () => { + const c = context(); + await c.install(); + c.setStatus({ sequence: '3', account: { githubId: '2' }, vaultId: 'v2' }); + await c.userLocale('de'); + await drain(); + c.userFont('large'); + await drain(); + assert.deepEqual( + c.snapshot().preferences, + { locale: 'de', fontScale: 'large' }, + JSON.stringify(c.snapshot()), + ); +}); +await test('rollback-only UUID change must not permanently poison later user saves', async () => { + const c = context(); + await c.install(); + c.externalWrite({ locale: 'en' }); + await c.userLocale('de'); + await drain(); + await c.userLocale('fr'); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'fr', JSON.stringify(c.snapshot())); +}); + +await test('a new vault restore is hydrated after an account switch', async () => { + const c = context(); + await c.install(); + c.setStatus({ sequence: '3', account: { githubId: '2' }, vaultId: 'v2' }); + c.externalWrite({ locale: 'fr', fontScale: 'large' }); + c.restore('4'); + await drain(); + assert.equal(c.snapshot().locale, 'fr'); + assert.equal(c.snapshot().fontScale, 'large'); + await c.userLocale('de'); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'de'); +}); +await test('an equal-value restore invalidates an older unsent user choice', async () => { + const c = context(); + await c.install(); + const wait = defer(); + c.statusQueue.push(wait); + await c.userLocale('de'); + await tick(); + c.externalWrite({ locale: 'en' }); + c.restore(); + wait.resolve({ sequence: '2', account: { githubId: '1' }, vaultId: 'v1', consentVersion: 'yes' }); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'en'); + await c.userLocale('fr'); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'fr'); +}); +await test('prepare and user edits use only the checked writer', async () => { + const c = context({ fresh: true }); + await c.install(); + await c.flush(); + c.setStatus({ consentVersion: 'yes' }); + await c.userLocale('de'); + c.userFont('large'); + await drain(); + assert.deepEqual(c.snapshot().preferences, { locale: 'de', fontScale: 'large' }); + assert(c.attempts.every((a) => a.cmd === 'cloud_sync_e2ee_set_ui_preferences_checked')); +}); +await test('a late old-vault event cannot discard a new-account user choice', async () => { + const c = context(); + await c.install(); + c.setStatus({ account: { githubId: '2' }, vaultId: 'v2' }); + const wait = defer(); + c.statusQueue.push(wait); + await c.userLocale('de'); + await tick(); + c.restore('99', { accountId: '1', vaultId: 'v1' }); + wait.resolve({ account: { githubId: '2' }, vaultId: 'v2', consentVersion: 'yes' }); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'de'); + c.externalWrite({ locale: 'fr' }); + c.restore('5'); + await drain(); + assert.equal(c.snapshot().locale, 'fr'); +}); +await test('a failed old-scope notification read retains the newer local choice', async () => { + const c = context(); + await c.install(); + c.setStatus({ account: { githubId: '2' }, vaultId: 'v2' }); + const userStatus = defer(), + restoreStatus = defer(); + c.statusQueue.push(userStatus, restoreStatus); + await c.userLocale('de'); + await tick(); + c.restore('99', { accountId: '1', vaultId: 'v1' }); + userStatus.resolve({ account: { githubId: '2' }, vaultId: 'v2', consentVersion: 'yes' }); + await tick(); + restoreStatus.reject(new Error('transient native status failure')); + await drain(); + assert.equal(c.snapshot().preferences.locale, 'de', JSON.stringify(c.snapshot())); + assert.equal(c.snapshot().locale, 'de'); +}); +await test('overlapping valid and old-scope notifications preserve a later field choice', async () => { + const c = context(); + await c.install(); + c.setStatus({ account: { githubId: '2' }, vaultId: 'v2' }); + const wait = defer(); + c.statusQueue.push(wait); + c.externalWrite({ locale: 'fr', fontScale: 'large' }); + c.restore('4'); + await tick(); + await c.userLocale('de'); + c.restore('99', { accountId: '1', vaultId: 'v1' }); + wait.resolve({ account: { githubId: '2' }, vaultId: 'v2', consentVersion: 'yes' }); + await drain(); + assert.deepEqual( + c.snapshot().preferences, + { locale: 'de', fontScale: 'large' }, + JSON.stringify(c.snapshot()), + ); + assert.equal(c.snapshot().locale, 'de'); + assert.equal(c.snapshot().fontScale, 'large'); +}); +console.log(`failures=${failed}`); +process.exitCode = failed ? 1 : 0; diff --git a/openless-all/app/scripts/macos-build-cache.test.mjs b/openless-all/app/scripts/macos-build-cache.test.mjs new file mode 100644 index 000000000..05d9f9b4a --- /dev/null +++ b/openless-all/app/scripts/macos-build-cache.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +if (process.platform !== 'darwin') { + console.log('macOS build cache tests skipped on other platforms'); + process.exit(0); +} + +const scripts = dirname(fileURLToPath(import.meta.url)); +const root = mkdtempSync(join(tmpdir(), 'openless-macos-build-')); +const app = join(root, 'src-tauri/target/release/bundle/macos/OpenLess.app'); +const dmg = join(root, 'src-tauri/target/release/bundle/dmg/OpenLess_1.2.3_x64.dmg'); +const builds = join(root, 'src-tauri/target/release/build'); + +function put(path, content) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +function executable(name, content) { + const path = join(root, 'bin', name); + put(path, `#!/usr/bin/env bash\nset -euo pipefail\n${content}\n`); + chmodSync(path, 0o755); +} + +function run(mode = 'success') { + const env = { ...process.env }; + for (const name of Object.keys(env)) { + if (/^(APPLE_|TAURI_SIGNING_|CARGO_PROFILE_RELEASE_|GITHUB_ENV$)/.test(name)) delete env[name]; + } + return spawnSync('bash', ['scripts/build-mac.sh'], { + cwd: root, + encoding: 'utf8', + env: { ...env, INSTALL: '0', BUILD_FIXTURE_MODE: mode, PATH: `${root}/bin:${env.PATH}` }, + }); +} + +try { + mkdirSync(join(root, 'scripts'), { recursive: true }); + for (const name of [ + 'build-mac.sh', + 'macos-build-env.sh', + 'check-macos-speech-usage-description.sh', + ]) { + copyFileSync(join(scripts, name), join(root, 'scripts', name)); + } + put(join(root, 'package.json'), '{"version":"1.2.3"}'); + put( + join(root, 'fixture.plist'), + ` + +NSMicrophoneUsageDescriptionMicrophone +NSSpeechRecognitionUsageDescriptionSpeech +`, + ); + + executable('uname', 'echo x86_64'); + executable('codesign', 'echo com.apple.security.device.audio-input'); + executable('xattr', 'exit 1'); + executable( + 'npm', + ` +if [ "$*" = "run check:macos-metal-toolchain" ]; then exit 0; fi +[[ "$*" == *"-- --locked --timings"* ]] +[[ "$CARGO_PROFILE_RELEASE_CODEGEN_UNITS" == 16 ]] +[[ "$CARGO_PROFILE_RELEASE_STRIP" == debuginfo ]] +app=src-tauri/target/release/bundle/macos/OpenLess.app +dmg=src-tauri/target/release/bundle/dmg/OpenLess_1.2.3_x64.dmg +# Old bundles must be gone before compilation starts, while Cargo stays warm. +[[ ! -e "$app" && ! -e "$dmg" && ! -e "$app.tar.gz" && ! -e "$app.tar.gz.sig" ]] +[[ -f src-tauri/target/release/build/qwen3-asr-rs-helper/build-script-build ]] +[[ -f src-tauri/target/release/build/qwen3-asr-rs-new/out/lib/mlx.metallib ]] +[[ ! -e src-tauri/target/release/build/qwen3-asr-rs-old ]] +if [ "$BUILD_FIXTURE_MODE" = missing ]; then exit 0; fi +mkdir -p "$app/Contents/MacOS" "$(dirname "$dmg")" +cp fixture.plist "$app/Contents/Info.plist" +echo binary > "$app/Contents/MacOS/openless" +# Cargo may reuse a binary compiled before this packaging invocation. +touch -t 200001010000 "$app/Contents/MacOS/openless" +echo dmg > "$dmg" +if [ "$BUILD_FIXTURE_MODE" = failure ]; then exit 23; fi +`, + ); + + put(join(builds, 'qwen3-asr-rs-helper/build-script-build'), 'cached executable'); + put(join(builds, 'qwen3-asr-rs-new/out/lib/mlx.metallib'), 'new shader'); + put(join(builds, 'qwen3-asr-rs-old/out/lib/mlx.metallib'), 'old shader'); + utimesSync(join(builds, 'qwen3-asr-rs-old/out/lib/mlx.metallib'), 1, 1); + put(join(app, 'Contents/MacOS/openless'), 'stale binary'); + put(dmg, 'stale dmg'); + put(`${app}.tar.gz`, 'stale updater'); + put(`${app}.tar.gz.sig`, 'stale signature'); + + for (let repeat = 0; repeat < 2; repeat += 1) { + const result = run(); + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(readFileSync(dmg, 'utf8').trim(), 'dmg'); + assert.equal( + readFileSync(join(builds, 'qwen3-asr-rs-helper/build-script-build'), 'utf8'), + 'cached executable', + ); + } + const failure = run('failure'); + assert.equal(failure.status, 23, failure.stdout + failure.stderr); + const missing = run('missing'); + assert.notEqual(missing.status, 0, missing.stdout + missing.stderr); + assert.equal(existsSync(app), false); + assert.equal(existsSync(dmg), false); +} finally { + rmSync(root, { recursive: true, force: true }); +} + +console.log('macOS warm build and packaging failure tests passed'); diff --git a/openless-all/app/scripts/macos-build-env.sh b/openless-all/app/scripts/macos-build-env.sh new file mode 100644 index 000000000..a89c14676 --- /dev/null +++ b/openless-all/app/scripts/macos-build-env.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Source locally; execute before rust-cache in GitHub Actions. +set -euo pipefail + +MACOS_BUILD_APP_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Keep opt-level=3 and thin LTO, while allowing LLVM to compile large crates +# in parallel. The shared Cargo profile continues to govern other platforms. +export CARGO_PROFILE_RELEASE_CODEGEN_UNITS="${CARGO_PROFILE_RELEASE_CODEGEN_UNITS:-16}" +# Avoid malformed proc-macro dylibs with the macOS strip tool. +export CARGO_PROFILE_RELEASE_STRIP=debuginfo +export RUSTC_WRAPPER="$MACOS_BUILD_APP_ROOT/scripts/rustc-macos-proc-macro-wrapper.sh" + +if [ -n "${GITHUB_ENV:-}" ]; then + { + echo "CARGO_PROFILE_RELEASE_CODEGEN_UNITS=$CARGO_PROFILE_RELEASE_CODEGEN_UNITS" + echo "CARGO_PROFILE_RELEASE_STRIP=$CARGO_PROFILE_RELEASE_STRIP" + echo "RUSTC_WRAPPER=$RUSTC_WRAPPER" + } >> "$GITHUB_ENV" +fi diff --git a/openless-all/app/scripts/macos-dmg-bin/hdiutil b/openless-all/app/scripts/macos-dmg-bin/hdiutil new file mode 100755 index 000000000..190c09929 --- /dev/null +++ b/openless-all/app/scripts/macos-dmg-bin/hdiutil @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +exec "${OPENLESS_DMG_LAYOUT_PYTHON:?}" "${OPENLESS_DMG_LAYOUT_ROOT:?}/scripts/macos-dmg-layout.py" wrap "$@" diff --git a/openless-all/app/scripts/macos-dmg-layout.py b/openless-all/app/scripts/macos-dmg-layout.py new file mode 100644 index 000000000..87dd23e1b --- /dev/null +++ b/openless-all/app/scripts/macos-dmg-layout.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Add Finder metadata to Tauri's temporary DMG before its final conversion/signing.""" + +import contextlib +import json +import os +from pathlib import Path +import plistlib +import re +import shutil +import subprocess +import sys +import tempfile + +from ds_store import DSStore +from mac_alias import Alias + +HDIUTIL = "/usr/bin/hdiutil" +APP_ROOT = Path(__file__).resolve().parent.parent +BACKGROUND_COLOR_KEYS = ("backgroundColorRed", "backgroundColorGreen", "backgroundColorBlue") + + +def settings(): + tauri = APP_ROOT / "src-tauri" + app = json.loads((tauri / "tauri.conf.json").read_text()) + dmg = json.loads((tauri / "tauri.macos-mlx.conf.json").read_text())["bundle"]["macOS"]["dmg"] + style = json.loads((tauri / "dmg/layout.json").read_text()) + return app, dmg, style + + +def conversion_source(args): + """Only the expected native ARM64 bundle can be altered by this scoped wrapper.""" + app, _, _ = settings() + folder = (APP_ROOT / "src-tauri/target/release/bundle/macos").resolve() + expected_name = f'{app["productName"]}_{app["version"]}_aarch64.dmg' + if len(args) < 2 or args[0] != "convert": + raise ValueError("Expected a Tauri DMG conversion") + try: + output = Path(args[args.index("-o") + 1]).resolve() + image_format = args[args.index("-format") + 1] + except (ValueError, IndexError) as error: + raise ValueError("Unrecognized Tauri DMG conversion arguments") from error + source = Path(args[1]).resolve() + if ( + image_format != "UDZO" + or output != folder / expected_name + or source.parent != folder + or not re.fullmatch(r"rw\.\d+\." + re.escape(expected_name), source.name) + or not source.is_file() + ): + raise ValueError("Refusing DMG layout changes outside the current Tauri bundle") + return source + + +@contextlib.contextmanager +def mounted(image, writable=False): + # Do not recursively delete this directory: it may still contain a mounted volume. + mountpoint = Path(tempfile.mkdtemp(prefix="openless-dmg-layout-", dir="/tmp")).resolve() + device = None + try: + result = subprocess.run( + [HDIUTIL, "attach", str(image), "-readwrite" if writable else "-readonly", + "-noautoopen", "-nobrowse", "-mountpoint", str(mountpoint), "-plist"], + check=True, stdout=subprocess.PIPE, + ) + entities = plistlib.loads(result.stdout)["system-entities"] + device = next( + item["dev-entry"] for item in entities + if item.get("mount-point") and Path(item["mount-point"]).resolve() == mountpoint + ) + yield mountpoint + finally: + if device or os.path.ismount(mountpoint): + subprocess.run([HDIUTIL, "detach", device or str(mountpoint)], check=True, stdout=subprocess.PIPE) + mountpoint.rmdir() + + +def ensure_targets(volume, app_name): + if not (volume / app_name).is_dir(): + raise ValueError("Tauri app is missing from the DMG") + applications = volume / "Applications" + if not applications.is_symlink() or os.readlink(applications) != "/Applications": + raise ValueError("Applications must be the real /Applications drag target") + + +def write_layout(volume): + app, dmg, style = settings() + app_name = app["productName"] + ".app" + ensure_targets(volume, app_name) + background_source = APP_ROOT / "src-tauri" / style["retinaBackground"] + if not background_source.is_file(): + raise ValueError("Retina DMG background is missing") + background = volume / ".background/installer-background.tiff" + background.parent.mkdir(exist_ok=True) + shutil.copyfile(background_source, background) + # mac_alias requires a canonical path to avoid /var vs /private/var alias drift. + alias = Alias.for_file(str(background.resolve())).to_bytes() + position, size = dmg["windowPosition"], dmg["windowSize"] + bounds = "{{%d, %d}, {%d, %d}}" % (position["x"], position["y"], size["width"], size["height"]) + with DSStore.open(str(volume / ".DS_Store"), "w+") as store: + store["."]["vSrn"] = ("long", 1) + store["."]["icvl"] = ("type", b"icnv") + store["."]["bwsp"] = { + "WindowBounds": bounds, "ShowToolbar": False, "ShowStatusBar": False, + "ShowPathbar": False, "ShowSidebar": False, "ShowTabView": False, + "ContainerShowSidebar": False, "SidebarWidth": 0, + } + store["."]["icvp"] = { + "viewOptionsVersion": 1, "iconSize": float(style["iconSize"]), + "textSize": float(style["textSize"]), "arrangeBy": "none", + "labelOnBottom": True, "showItemInfo": False, "showIconPreview": False, + "gridOffsetX": 0.0, "gridOffsetY": 0.0, "gridSpacing": 64.0, + "scrollPositionX": 0.0, "scrollPositionY": 0.0, + "backgroundType": 2, "backgroundImageAlias": alias, + # Finder requires the complete RGB tuple even for picture mode. + # Without it macOS 27 ignores this icvp, including iconSize/alias. + "backgroundColorRed": 1.0, "backgroundColorGreen": 1.0, "backgroundColorBlue": 1.0, + } + for name, key in [(app_name, "appPosition"), ("Applications", "applicationFolderPosition")]: + store[name]["Iloc"] = (dmg[key]["x"], dmg[key]["y"]) + + +def verify_retina_background(background, size): + result = subprocess.run( + ["/usr/bin/tiffutil", "-info", str(background)], + check=True, capture_output=True, text=True, + ) + metadata = result.stdout + result.stderr + dimensions = [(int(w), int(h)) for w, h in re.findall(r"Image Width: (\d+) Image Length: (\d+)", metadata)] + resolutions = [(float(x), float(y)) for x, y in re.findall(r"Resolution: ([\d.]+), ([\d.]+)", metadata)] + expected = [(size["width"], size["height"]), (size["width"] * 2, size["height"] * 2)] + if dimensions != expected or resolutions != [(72.0, 72.0), (144.0, 144.0)]: + raise ValueError("DMG background must contain matching 1x and 2x Retina representations") + + +def verify_layout(image): + app, dmg, style = settings() + with mounted(image) as volume: + app_name = app["productName"] + ".app" + ensure_targets(volume, app_name) + with DSStore.open(str(volume / ".DS_Store"), "r") as store: + icon = store["."]["icvp"] + view = store["."]["bwsp"] + if any(type(icon.get(key)) is not float or icon[key] != 1.0 for key in BACKGROUND_COLOR_KEYS): + raise ValueError("DMG Finder icon view requires all three white RGB real components") + position, size = dmg["windowPosition"], dmg["windowSize"] + expected_bounds = "{{%d, %d}, {%d, %d}}" % (position["x"], position["y"], size["width"], size["height"]) + if view["WindowBounds"] != expected_bounds or any(view[key] for key in ["ShowToolbar", "ShowStatusBar", "ShowPathbar", "ShowSidebar", "ShowTabView"]): + raise ValueError("DMG window geometry does not match its design") + if (icon["iconSize"] != style["iconSize"] or icon["textSize"] != style["textSize"] + or icon["backgroundType"] != 2 or icon["arrangeBy"] != "none" + or not icon["labelOnBottom"] or store["."]["icvl"] != (b"type", b"icnv")): + raise ValueError("DMG icon size, label size or background mode is incorrect") + for name, key in [(app_name, "appPosition"), ("Applications", "applicationFolderPosition")]: + if tuple(store[name]["Iloc"]) != (dmg[key]["x"], dmg[key]["y"]): + raise ValueError("DMG drag target positions are incorrect") + alias = Alias.from_bytes(icon["backgroundImageAlias"]) + if alias.target.posix_path != "/.background/installer-background.tiff": + raise ValueError("DMG background alias escapes the installer volume") + background = volume / ".background/installer-background.tiff" + if background.read_bytes() != (APP_ROOT / "src-tauri" / style["retinaBackground"]).read_bytes(): + raise ValueError("DMG background does not match its Retina source") + verify_retina_background(background, dmg["windowSize"]) + bundled_info = plistlib.loads((volume / app_name / "Contents/Info.plist").read_bytes()) + if bundled_info["CFBundleShortVersionString"] != app["version"]: + raise ValueError("DMG contains a different app version") + print("✓ DMG: Retina background, large icons, window layout and Applications target verified") + + +def main(): + if len(sys.argv) < 2: + raise ValueError("Expected wrap or verify") + if sys.argv[1] == "verify": + verify_layout(Path(sys.argv[2]).resolve()) + elif sys.argv[1] == "wrap": + args = sys.argv[2:] + if args and args[0] == "convert": + image = conversion_source(args) + with mounted(image, writable=True) as volume: + write_layout(volume) + with Path(os.environ["OPENLESS_DMG_LAYOUT_STAMP"]).open("x") as stamp: + stamp.write(str(image)) + print("✓ Prepared Finder layout in Tauri's temporary DMG", file=sys.stderr) + os.execv(HDIUTIL, [HDIUTIL, *args]) + else: + raise ValueError("Unknown DMG layout command") + + +if __name__ == "__main__": + main() diff --git a/openless-all/app/scripts/macos-dmg-layout.test.py b/openless-all/app/scripts/macos-dmg-layout.test.py new file mode 100644 index 000000000..7f1cd3d23 --- /dev/null +++ b/openless-all/app/scripts/macos-dmg-layout.test.py @@ -0,0 +1,107 @@ +"""Regression for Finder's complete picture-mode icon-view schema. + +The reference is Obsidian 1.13.7's shipped DS_Store and a macOS 27 Finder A/B: +adding only the three RGB reals made the original background/icon layout render. +These tests use the real DSStore codec; native mounts and image decoding are +covered by that actual UDZO/Finder experiment, not emulated here. +""" + +import contextlib +import importlib.util +import io +from pathlib import Path +import plistlib +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch + +from ds_store import DSStore + + +SPEC = importlib.util.spec_from_file_location( + "macos_dmg_layout", Path(__file__).with_name("macos-dmg-layout.py") +) +layout = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(layout) +RGB = ("backgroundColorRed", "backgroundColorGreen", "backgroundColorBlue") + + +class FinderPictureSchemaTests(unittest.TestCase): + def setUp(self): + temporary = tempfile.TemporaryDirectory(prefix="openless-dmg-schema-") + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + self.volume = self.root / "volume" + contents = self.volume / "OpenLess.app/Contents" + contents.mkdir(parents=True) + (contents / "Info.plist").write_bytes( + plistlib.dumps({"CFBundleShortVersionString": "1.2.3"}) + ) + (self.volume / "Applications").symlink_to("/Applications") + background = self.root / "src-tauri/dmg/background.tiff" + background.parent.mkdir(parents=True) + background.write_bytes(b"controlled-background-fixture") + self.dmg = { + "windowPosition": {"x": 120, "y": 120}, + "windowSize": {"width": 768, "height": 512}, + "appPosition": {"x": 216, "y": 253}, + "applicationFolderPosition": {"x": 552, "y": 253}, + } + self.style = {"iconSize": 128, "textSize": 14, "retinaBackground": "dmg/background.tiff"} + mocks = contextlib.ExitStack() + self.addCleanup(mocks.close) + mocks.enter_context(patch.object(layout, "APP_ROOT", self.root)) + mocks.enter_context(patch.object(layout, "settings", return_value=( + {"productName": "OpenLess", "version": "1.2.3"}, self.dmg, self.style + ))) + mocks.enter_context(patch.object(layout.Alias, "for_file", return_value= + SimpleNamespace(to_bytes=lambda: b"controlled-alias-fixture"))) + mocks.enter_context(patch.object(layout.Alias, "from_bytes", return_value= + SimpleNamespace(target=SimpleNamespace(posix_path="/.background/installer-background.tiff")))) + mocks.enter_context(patch.object(layout, "mounted", side_effect= + lambda _image: contextlib.nullcontext(self.volume))) + mocks.enter_context(patch.object(layout, "verify_retina_background")) + + def verify(self): + with contextlib.redirect_stdout(io.StringIO()): + layout.verify_layout(self.root / "fixture.dmg") + + def replace_component(self, key, value, remove=False): + layout.write_layout(self.volume) + with DSStore.open(str(self.volume / ".DS_Store"), "r+") as store: + icon = store["."]["icvp"] + if remove: + del icon[key] + else: + icon[key] = value + store["."]["icvp"] = icon + + def test_writer_preserves_version_one_and_complete_rgb_real_values(self): + layout.write_layout(self.volume) + with DSStore.open(str(self.volume / ".DS_Store"), "r") as store: + icon = store["."]["icvp"] + self.assertEqual(icon["viewOptionsVersion"], 1) + self.assertEqual(store["."]["icvl"], (b"type", b"icnv")) + for key in RGB: + self.assertIs(type(icon[key]), float) + self.assertEqual(icon[key], 1.0) + self.verify() + + def test_each_missing_rgb_component_is_rejected(self): + for key in RGB: + with self.subTest(key=key): + self.replace_component(key, None, remove=True) + with self.assertRaisesRegex(ValueError, "RGB real components"): + self.verify() + + def test_non_real_or_non_white_component_is_rejected(self): + for value in (1, True, "1.0", 0.0): + with self.subTest(value=value): + self.replace_component("backgroundColorRed", value) + with self.assertRaisesRegex(ValueError, "RGB real components"): + self.verify() + + +if __name__ == "__main__": + unittest.main() diff --git a/openless-all/app/scripts/macos-dmg-requirements.txt b/openless-all/app/scripts/macos-dmg-requirements.txt new file mode 100644 index 000000000..e6f74b5e0 --- /dev/null +++ b/openless-all/app/scripts/macos-dmg-requirements.txt @@ -0,0 +1,3 @@ +# Last versions compatible with the Python 3.9 shipped in Xcode command-line tools. +ds_store==1.3.1 --hash=sha256:fbacbb0bd5193ab3e66e5a47fff63619f15e374ffbec8ae29744251a6c8f05b5 +mac_alias==2.2.2 --hash=sha256:504ab8ac546f35bbd75ad014d6ad977c426660aa721f2cd3acf3dc2f664141bd diff --git a/openless-all/app/scripts/qa-panel-runtime.test.mjs b/openless-all/app/scripts/qa-panel-runtime.test.mjs new file mode 100644 index 000000000..b38591ca5 --- /dev/null +++ b/openless-all/app/scripts/qa-panel-runtime.test.mjs @@ -0,0 +1,673 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +const app = fileURLToPath(new URL('../', import.meta.url)), + require = createRequire(resolve(app, 'package.json')), + ts = require('typescript'); +const helpers = await import(pathToFileURL(resolve(app, 'src/lib/qaMessage.ts'))); +const source = readFileSync(resolve(app, 'src/pages/QaPanel.tsx'), 'utf8'); +const parsed = ts.createSourceFile( + 'QaPanel.tsx', + source, + ts.ScriptTarget.ES2020, + true, + ts.ScriptKind.TSX, +); +const names = parsed.statements + .filter(ts.isImportDeclaration) + .flatMap((n) => + n.importClause?.namedBindings && ts.isNamedImports(n.importClause.namedBindings) + ? n.importClause.namedBindings.elements.map((n) => n.name.text) + : [], + ); +const body = parsed.statements + .filter((n) => !ts.isImportDeclaration(n)) + .map((n) => n.getFullText(parsed)) + .join('\n') + .replaceAll("import('@tauri-apps/api/event')", 'Promise.resolve({listen: __listen})'); +const compiled = ts.transpileModule(body, { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + }, +}).outputText; +const factory = new Function( + ...names, + '__listen', + 'exports', + 'require', + `${compiled};return {QaPanel,Composer,MessageRow,ErrorContent};`, +); +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const deferred = () => { + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { promise, resolve, reject }; +}; +const jsx = (type, props) => ({ type, props: props ?? {} }); +function nodes(tree) { + if (Array.isArray(tree)) return tree.flatMap(nodes); + if (!tree || typeof tree !== 'object' || !tree.props) return []; + return [tree, ...nodes(tree.props.children)]; +} +function find(tree, predicate) { + const node = nodes(tree).find(predicate); + assert(node, 'expected control'); + return node; +} +let active; +class Hooks { + slots = []; + cursor = 0; + pending = []; + state(initial) { + const i = this.cursor++; + this.slots[i] ??= { value: typeof initial === 'function' ? initial() : initial }; + return [ + this.slots[i].value, + (value) => { + this.slots[i].value = typeof value === 'function' ? value(this.slots[i].value) : value; + }, + ]; + } + ref(initial) { + return this.state({ current: initial })[0]; + } + effect(fn, deps) { + const i = this.cursor++, + prev = this.slots[i]; + if ( + prev && + deps && + prev.deps?.length === deps.length && + deps.every((v, i) => Object.is(v, prev.deps[i])) + ) + return; + const slot = { deps }; + this.slots[i] = slot; + this.pending.push(() => { + prev?.cleanup?.(); + slot.cleanup = fn(); + }); + } + render(fn) { + this.cursor = 0; + active = this; + const tree = fn(); + this.pending.splice(0).forEach((fn) => fn()); + return tree; + } + unmount() { + this.slots.forEach((s) => s.cleanup?.()); + } +} +function context(native = true, embedded = false) { + const events = new Map(), + keys = new Set(), + calls = { + resize: [], + submit: [], + submitArgs: [], + snapshot: 0, + mic: 0, + mode: [], + preview: [], + confirm: [], + revert: [], + dismiss: 0, + close: 0, + }; + const pending = {}; + const lifecycle = { enterEpoch: 0, closing: false }; + globalThis.window = { + location: { search: '?window=qa&demo=1' }, + addEventListener: (name, fn) => { + if (name === 'keydown') keys.add(fn); + }, + removeEventListener: (name, fn) => { + if (name === 'keydown') keys.delete(fn); + }, + }; + const imports = Object.fromEntries(names.map((n) => [n, n])); + Object.assign(imports, { + useState: (i) => active.state(i), + useRef: (i) => active.ref(i), + useEffect: (f, d) => active.effect(f, d), + useTranslation: () => ({ t: (k) => k }), + useGithubLogin: () => '', + useChatPanelLifecycle: () => lifecycle, + isTauri: native, + ...helpers, + qaWindowSetExpanded: async (next) => { + calls.resize.push(next); + return pending.resize?.(next); + }, + qaSubmitText: async (text, session) => { + calls.submit.push(text); + calls.submitArgs.push([text, session]); + return pending.submit?.promise; + }, + qaGetSnapshot: async () => { + calls.snapshot++; + return pending.snapshot?.() ?? { kind: 'idle', messages: [] }; + }, + qaToggleRecording: async () => { + calls.mic++; + return pending.mic?.promise; + }, + qaSetEditInstructionMode: async (mode) => { + calls.mode.push(mode); + return pending.mode?.promise; + }, + qaWindowDismiss: async () => { + calls.dismiss++; + return pending.dismiss?.promise; + }, + getSelectionVoicePreview: async (session) => { + calls.preview.push(session); + return pending.preview?.promise ?? { text: ' fixture edit ' }; + }, + confirmSelectionVoicePreview: async (...args) => { + calls.confirm.push(args); + return pending.confirm?.promise; + }, + revertSelectionVoicePreview: async (session) => { + calls.revert.push(session); + return pending.revert?.promise; + }, + chatPanelFocusKeyboard: async () => {}, + }); + const api = factory( + ...names.map((n) => imports[n]), + async (name, fn) => { + const listeners = events.get(name) ?? new Set(); + listeners.add(fn); + events.set(name, listeners); + return () => listeners.delete(fn); + }, + {}, + () => ({ jsx, jsxs: jsx, Fragment: 'fragment' }), + ); + const hooks = new Hooks(), + render = () => + hooks.render(() => api.QaPanel({ embedded, onRequestClose: () => calls.close++ })); + const emit = (name, payload) => { + for (const fn of events.get(name) ?? []) fn({ payload }); + }; + const composer = (tree) => find(tree, (n) => n.type === api.Composer).props; + return { api, hooks, render, emit, calls, pending, lifecycle, events, keys, composer }; +} +let passed = 0, + failed = 0; +async function test(name, fn) { + try { + await fn(); + console.log('PASS ' + name); + passed++; + } catch (error) { + console.log('FAIL ' + name + '\n' + error.stack); + failed++; + } +} +await test('browser compact has no demo conversations and cannot execute native actions', async () => { + const c = context(false); + let tree = c.render(); + assert(!tree.props.className.includes('is-expanded')); + assert(!nodes(tree).some((n) => n.type === c.api.MessageRow)); + let p = c.composer(tree); + p.onChange('fixture'); + p = c.composer(c.render()); + await p.onSubmit(); + await p.onToggleRecording(); + await p.onEditInstructionModeChange(true); + await tick(); + assert.deepEqual(c.calls.resize, []); + assert.deepEqual(c.calls.submit, []); + assert.equal(c.calls.mic, 0); + assert.deepEqual(c.calls.mode, []); + const h = new Hooks(), + ui = h.render(() => c.api.Composer(p)); + for (const button of nodes(ui).filter((n) => n.type === 'button')) + assert.equal(button.props.disabled, true); + c.hooks.unmount(); +}); +await test('native text waits for expansion and excludes repeated clicks; keeps a newer draft', async () => { + const c = context(); + c.render(); + await tick(); + assert.deepEqual(c.calls.resize, [false]); + c.composer(c.render()).onChange('question'); + let p = c.composer(c.render()); + const resize = deferred(), + send = deferred(); + c.pending.resize = (next) => (next ? resize.promise : undefined); + c.pending.submit = send; + const first = p.onSubmit(); + void p.onSubmit(); + c.render(); + await tick(); + assert.deepEqual(c.calls.resize, [false, true]); + assert.deepEqual(c.calls.submit, []); + resize.resolve(); + await tick(); + assert.deepEqual(c.calls.submit, ['question']); + c.composer(c.render()).onChange('next draft'); + send.resolve(); + await first; + assert.equal(c.composer(c.render()).value, 'next draft'); + c.hooks.unmount(); +}); +await test('send failure preserves draft and exposes error without a fake user message', async () => { + const c = context(); + c.render(); + await tick(); + c.composer(c.render()).onChange('keep this'); + const d = deferred(); + c.pending.submit = d; + const result = c.composer(c.render()).onSubmit(); + await tick(); + d.reject(new Error('fixture')); + await result; + const tree = c.render(); + assert.equal(c.composer(tree).value, 'keep this'); + assert.equal(c.composer(tree).status, 'error'); + assert(!nodes(tree).some((n) => n.type === c.api.MessageRow)); + c.hooks.unmount(); +}); +await test('cancel during resize prevents queued question and late layout failures do not overwrite reopened compact state', async () => { + const c = context(); + c.render(); + await tick(); + c.composer(c.render()).onChange('do not send'); + const d = deferred(); + c.pending.resize = (next) => (next ? d.promise : undefined); + const submission = c.composer(c.render()).onSubmit(); + c.render(); + await tick(); + await c.composer(c.render()).onClose(); + c.lifecycle.closing = true; + c.render(); + c.lifecycle.closing = false; + c.lifecycle.enterEpoch++; + c.render(); + assert.deepEqual(c.calls.resize, [false, true]); + d.reject(new Error('old geometry')); + await submission; + await tick(); + c.render(); + await tick(); + assert.deepEqual(c.calls.submit, []); + assert.deepEqual(c.calls.resize, [false, true, false]); + assert.equal(c.composer(c.render()).status, 'idle'); + assert(!nodes(c.render()).some((n) => n.props.className === 'qa-layout-error')); + c.hooks.unmount(); +}); +await test('recording level requires active session; no fake recording before backend phase', async () => { + const c = context(); + c.render(); + await tick(); + const d = deferred(); + c.pending.mic = d; + let p = c.composer(c.render()); + const first = p.onToggleRecording(); + void p.onToggleRecording(); + assert.equal(c.calls.mic, 1); + p = c.composer(c.render()); + assert.equal(p.status, 'idle'); + assert.equal(p.micBusy, true); + assert.equal(p.level, 0); + c.emit('qa:state', { + kind: 'recording', + sessionId: 'a', + selectionPreview: 'selected', + messages: [], + }); + c.emit('qa:level', { sessionId: 'other', level: 0.9 }); + assert.equal(c.composer(c.render()).level, 0); + c.emit('qa:level', { sessionId: 'a', level: 0.6 }); + p = c.composer(c.render()); + assert.equal(p.level, 0.6); + assert.equal(p.status, 'recording'); + assert.equal(p.voiceActive, true); + assert.equal(p.selectionPreview, 'selected'); + d.resolve(); + await first; + c.emit('qa:state', { + kind: 'thinking', + sessionId: 'a', + messages: [{ role: 'user', content: 'actual question' }], + }); + c.emit('qa:level', { sessionId: 'a', level: 0.9 }); + p = c.composer(c.render()); + assert.equal(p.level, 0); + assert.equal(p.voiceActive, false); + c.hooks.unmount(); +}); +await test('stale answer is ignored and final answer replaces streaming buffer exactly once', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { + kind: 'thinking', + sessionId: 'new', + messages: [{ role: 'user', content: 'question' }], + }); + c.emit('qa:state', { kind: 'answer_delta', sessionId: 'old', chunk: 'stale' }); + c.emit('qa:state', { kind: 'answer_delta', sessionId: 'new', chunk: 'real ' }); + c.emit('qa:state', { kind: 'answer_delta', sessionId: 'new', chunk: 'answer' }); + let tree = c.render(); + assert.equal(find(tree, (n) => n.type === 'AssistantMarkdown').props.markdown, 'real answer'); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'new', + messages: [ + { role: 'user', content: 'question' }, + { role: 'assistant', content: 'real answer' }, + ], + }); + tree = c.render(); + assert(!nodes(tree).some((n) => n.type === 'AssistantMarkdown')); + assert.equal(nodes(tree).filter((n) => n.type === c.api.MessageRow).length, 2); + c.hooks.unmount(); +}); +await test('edit preview captured in old session cannot be applied after a new recording starts', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'a', + messages: [{ role: 'assistant', content: 'preview' }], + editApplyAvailable: true, + editRevertAvailable: true, + }); + let tree = c.render(); + const d = deferred(); + c.pending.preview = d; + find(tree, (n) => n.type === 'button' && n.props.className === 'qa-apply').props.onClick(); + assert.deepEqual(c.calls.preview, ['a']); + c.emit('qa:state', { kind: 'recording', sessionId: 'b', messages: [] }); + d.resolve({ text: 'must not apply' }); + await tick(); + assert.deepEqual(c.calls.confirm, []); + assert.equal(c.composer(c.render()).status, 'recording'); + c.hooks.unmount(); +}); +await test('an old rendered edit button cannot approve a newer answer before React presents it', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'a', + messages: [{ role: 'assistant', content: 'old preview' }], + editApplyAvailable: true, + }); + const oldTree = c.render(); + c.emit('qa:state', { kind: 'thinking', sessionId: 'b', messages: [] }); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'b', + messages: [{ role: 'assistant', content: 'new unreviewed preview' }], + editApplyAvailable: true, + }); + find(oldTree, (n) => n.type === 'button' && n.props.className === 'qa-apply').props.onClick(); + await tick(); + assert.deepEqual(c.calls.preview, []); + assert.deepEqual(c.calls.confirm, []); + c.hooks.unmount(); +}); +await test('edit apply/revert are single-flight and send exact text plus captured session', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'a', + messages: [{ role: 'assistant', content: 'preview' }], + editApplyAvailable: true, + editRevertAvailable: true, + }); + let tree = c.render(); + const d = deferred(); + c.pending.confirm = d; + const button = find(tree, (n) => n.type === 'button' && n.props.className === 'qa-apply'); + button.props.onClick(); + button.props.onClick(); + await tick(); + assert.deepEqual(c.calls.confirm, [['fixture edit', 'a']]); + d.resolve(); + await tick(); + assert(!nodes(c.render()).some((n) => n.props.className === 'qa-edit-actions')); + c.emit('qa:state', { + kind: 'answer', + sessionId: 'a', + messages: [{ role: 'assistant', content: 'preview' }], + editApplyAvailable: true, + editRevertAvailable: true, + }); + tree = c.render(); + const r = deferred(); + c.pending.revert = r; + const back = find( + tree, + (n) => n.type === 'button' && n.props.children === 'qa.editRevertPrevious', + ); + back.props.onClick(); + back.props.onClick(); + assert.deepEqual(c.calls.revert, ['a']); + r.resolve(); + await tick(); + tree = c.render(); + assert(nodes(tree).some((n) => n.props.className === 'qa-apply')); + assert(!nodes(tree).some((n) => n.props.children === 'qa.editRevertPrevious')); + c.hooks.unmount(); +}); +await test('mode update waits for backend and repeated click cannot flip twice', async () => { + const c = context(); + c.render(); + await tick(); + const d = deferred(); + c.pending.mode = d; + let p = c.composer(c.render()); + const first = p.onEditInstructionModeChange(true); + void p.onEditInstructionModeChange(false); + p = c.composer(c.render()); + assert.equal(p.editInstructionMode, false); + assert.deepEqual(c.calls.mode, [true]); + d.reject(new Error('fixture')); + await first; + p = c.composer(c.render()); + assert.equal(p.editInstructionMode, false); + assert.equal(p.status, 'error'); + c.hooks.unmount(); +}); +await test('embedded skip native resize, preserve existing parent close path and event cleanup', async () => { + const c = context(true, true); + c.render(); + await tick(); + c.composer(c.render()).onChange('embedded'); + await c.composer(c.render()).onSubmit(); + c.render(); + await tick(); + assert.deepEqual(c.calls.resize, []); + c.emit('qa:dismiss', {}); + assert.equal(c.calls.close, 1); + c.hooks.unmount(); + assert.equal( + [...c.events.values()].reduce((n, set) => n + set.size, 0), + 0, + ); + assert.equal(c.keys.size, 0); +}); +await test('composer IME enter, WebKit 229 and form submission guards; genuine enter sends', async () => { + const c = context(); + const h = new Hooks(); + let sent = 0; + const p = { + value: '中文', + status: 'idle', + level: 0, + voiceActive: false, + selectionPreview: '', + busy: false, + micBusy: false, + embedded: false, + editInstructionMode: false, + onEditInstructionModeChange() {}, + onChange() {}, + onSubmit() { + sent++; + }, + onToggleRecording() {}, + onClose() {}, + t: (k) => k, + }; + const tree = h.render(() => c.api.Composer(p)); + const input = find(tree, (n) => n.type === 'input'); + const key = (extra = {}) => ({ + key: 'Enter', + keyCode: 13, + nativeEvent: { isComposing: false }, + preventDefault() {}, + ...extra, + }); + input.props.onCompositionStart(); + input.props.onKeyDown(key()); + find(tree, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); + input.props.onCompositionEnd(); + input.props.onKeyDown(key({ nativeEvent: { isComposing: true } })); + input.props.onKeyDown(key({ keyCode: 229 })); + assert.equal(sent, 0); + input.props.onKeyDown(key()); + assert.equal(sent, 1); +}); + +await test('session drift during delayed native resize cancels old draft', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { kind: 'idle', sessionId: 'a', messages: [] }); + c.composer(c.render()).onChange('belongs to a'); + const d = deferred(); + c.pending.resize = (next) => (next ? d.promise : undefined); + const result = c.composer(c.render()).onSubmit(); + await tick(); + c.emit('qa:state', { kind: 'idle', sessionId: 'b', messages: [] }); + d.resolve(); + await result; + await tick(); + assert.deepEqual(c.calls.submit, []); + c.hooks.unmount(); +}); +await test('text always carries the exact session captured before resize', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { kind: 'idle', sessionId: 'a', messages: [] }); + c.composer(c.render()).onChange('question a'); + await c.composer(c.render()).onSubmit(); + assert.deepEqual(c.calls.submitArgs, [['question a', 'a']]); + c.hooks.unmount(); +}); +for (const embedded of [false, true]) + await test(`cold ${embedded ? 'embedded' : 'desktop'} snapshot recovers initial recording and accepts its levels`, async () => { + const c = context(true, embedded); + c.pending.snapshot = () => ({ + kind: 'recording', + sessionId: 'cold', + messages: [], + selectionPreview: 'captured text', + }); + c.render(); + await tick(); + c.emit('qa:level', { sessionId: 'cold', level: 0.4 }); + const p = c.composer(c.render()); + assert.equal(p.status, 'recording'); + assert.equal(p.level, 0.4); + assert.equal(p.selectionPreview, 'captured text'); + assert.equal(c.calls.snapshot, 1); + c.hooks.unmount(); + }); +await test('snapshot reply overtaken by a live session event is discarded and reread', async () => { + const c = context(); + const first = deferred(); + c.pending.snapshot = () => + c.calls.snapshot === 1 + ? first.promise + : { kind: 'recording', sessionId: 'new', messages: [], selectionPreview: 'new' }; + c.render(); + await tick(); + c.emit('qa:state', { + kind: 'recording', + sessionId: 'new', + messages: [], + selectionPreview: 'new', + }); + first.resolve({ kind: 'recording', sessionId: 'old', messages: [], selectionPreview: 'old' }); + await tick(); + c.emit('qa:level', { sessionId: 'new', level: 0.7 }); + const p = c.composer(c.render()); + assert.equal(p.selectionPreview, 'new'); + assert.equal(p.level, 0.7); + assert.equal(c.calls.snapshot, 2); + c.hooks.unmount(); +}); +await test('snapshot completion after dismiss cannot reopen old recording state', async () => { + const c = context(); + const first = deferred(); + c.pending.snapshot = () => first.promise; + c.render(); + await tick(); + c.emit('qa:dismiss', {}); + c.lifecycle.closing = true; + c.render(); + first.resolve({ kind: 'recording', sessionId: 'old', messages: [], selectionPreview: 'old' }); + await tick(); + assert.equal(c.composer(c.render()).status, 'idle'); + c.hooks.unmount(); +}); +await test('a snapshot read failure does not silently remove all established live subscriptions', async () => { + const c = context(); + c.pending.snapshot = () => Promise.reject(new Error('transient snapshot unavailable')); + c.render(); + await tick(); + c.emit('qa:state', { kind: 'recording', sessionId: 'live', messages: [] }); + c.emit('qa:level', { sessionId: 'live', level: 0.4 }); + const p = c.composer(c.render()); + assert.equal(p.status, 'recording'); + assert.equal(p.level, 0.4); + c.hooks.unmount(); +}); +await test('old microphone RPC failure cannot overwrite a newer native recording session', async () => { + const c = context(); + c.render(); + await tick(); + c.emit('qa:state', { kind: 'idle', sessionId: 'before', messages: [] }); + const old = deferred(); + c.pending.mic = old; + const request = c.composer(c.render()).onToggleRecording(); + c.emit('qa:state', { kind: 'recording', sessionId: 'a', messages: [] }); + c.emit('qa:state', { + kind: 'error', + sessionId: 'a', + messages: [], + error: 'old recording failed', + }); + c.emit('qa:state', { kind: 'recording', sessionId: 'b', messages: [] }); + old.reject(new Error('old RPC completed late')); + await request; + const p = c.composer(c.render()); + assert.equal(p.status, 'recording'); + c.emit('qa:level', { sessionId: 'b', level: 0.8 }); + assert.equal(c.composer(c.render()).level, 0.8); + c.hooks.unmount(); +}); +console.log(JSON.stringify({ passed, failed })); +process.exitCode = failed ? 1 : 0; diff --git a/openless-all/app/scripts/remote-input-audio-generation.test.mjs b/openless-all/app/scripts/remote-input-audio-generation.test.mjs new file mode 100644 index 000000000..66991dbf6 --- /dev/null +++ b/openless-all/app/scripts/remote-input-audio-generation.test.mjs @@ -0,0 +1,331 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +// Exercise the production lifecycle functions, with only browser/transport effects +// replaced. Deferred promises and explicit timers make late callbacks deterministic. +const source = readFileSync(new URL('../assets/remote-input/app.js', import.meta.url), 'utf8'); +const names = [ + 'withTimeout', + 'startRecording', + 'stopRecording', + 'cancelRecording', + 'ensureAudio', + 'buildCaptureGraph', + 'clearPendingPcm', + 'resetRemoteStreamState', + 'teardownAudioCapture', + 'teardownAudio', + 'resetAudioContext', +]; +const lifecycle = names + .map((name) => { + const start = source.indexOf(` function ${name}(`); + const end = source.indexOf('\n }', start); + assert.ok(start >= 0 && end > start, `production function ${name} must exist`); + return source.slice(start, end + '\n }'.length); + }) + .join('\n'); +const settle = () => new Promise((resolve) => setImmediate(resolve)); +const deferred = () => { + let resolve; + let reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; +const makeStream = () => { + const track = { + stopped: false, + stop() { + this.stopped = true; + }, + }; + return { track, getTracks: () => [track] }; +}; +const makeNode = () => ({ + disconnected: false, + port: {}, + connect() {}, + disconnect() { + this.disconnected = true; + }, +}); + +function harness({ worklet = false, suspended = false } = {}) { + const calls = { mic: [], resume: [], worklet: [], nodes: [], sent: [], statuses: [], pcm: [] }; + const timers = new Map(); + let timerId = 0; + let scriptBuilds = 0; + class AudioContext { + constructor() { + this.state = suspended ? 'suspended' : 'running'; + this.sampleRate = 48000; + this.audioWorklet = worklet ? {} : undefined; + } + resume() { + const d = deferred(); + calls.resume.push(d); + return d.promise; + } + suspend() { + this.state = 'suspended'; + return Promise.resolve(); + } + close() { + this.state = 'closed'; + return Promise.resolve(); + } + createMediaStreamSource() { + return makeNode(); + } + } + const state = { + recording: false, + startSent: false, + awaitingResult: false, + ws: { readyState: 1 }, + audioGen: 0, + audioCtx: null, + mediaStream: null, + sourceNode: null, + workletNode: null, + scriptNode: null, + usingWorklet: false, + remoteSessionId: '', + remoteSequence: 0, + finishAfterStarted: '', + pendingPcm: [], + pendingPcmBytes: 0, + resampleState: { phase: 0, last: 0, hasLast: false }, + wakeLockHint: null, + TARGET_SR: 16000, + MIC_PREP_TIMEOUT_MS: 10000, + L: { preparingMic: 'mic', preparingBackend: 'backend', micTimeout: 'timeout', ready: 'ready' }, + window: { AudioContext }, + navigator: { + mediaDevices: { + getUserMedia() { + const d = deferred(); + calls.mic.push(d); + return d.promise; + }, + }, + }, + AudioWorkletNode: class { + constructor() { + const n = makeNode(); + calls.nodes.push(n); + return n; + } + }, + setTimeout(fn) { + const id = ++timerId; + timers.set(id, fn); + return id; + }, + clearTimeout(id) { + timers.delete(id); + }, + loadWorklet() { + const d = deferred(); + calls.worklet.push(d); + return d.promise; + }, + buildScriptProcessor() { + scriptBuilds++; + state.scriptNode = makeNode(); + }, + wsSendJSON(message) { + calls.sent.push(message.type); + }, + sendAudio(pcm) { + calls.pcm.push(pcm); + }, + setStatus(message) { + calls.statuses.push(message); + }, + micErrorText(error) { + return error.name; + }, + interruptRecording() { + throw new Error('unexpected interruption'); + }, + }; + for (const name of [ + 'clearRecoveryTimer', + 'acquireWakeLock', + 'releaseWakeLock', + 'clearReadyTimer', + 'clearWorkTimeout', + 'updateRecordBtnUI', + 'clearResult', + 'detachHoldEnd', + 'setLevel', + 'enterTranscribing', + 'armWorkTimeout', + 'saveRecoverySession', + ]) + state[name] = () => {}; + vm.runInNewContext(lifecycle, state); + return { + state, + calls, + timers, + get scriptBuilds() { + return scriptBuilds; + }, + }; +} + +async function startPending(h) { + h.state.startRecording(); + await settle(); +} +async function resolveMic(h, index) { + const stream = makeStream(); + h.calls.mic[index].resolve(stream); + await settle(); + return stream; +} +function assertActive(h, stream, node) { + assert.equal(h.state.recording, true, 'old callback must not stop the new recording'); + assert.equal(h.state.startSent, true); + assert.equal(h.state.mediaStream, stream, 'old stream must not replace the new microphone'); + assert.equal(h.state.sourceNode, node); + assert.equal(node.disconnected, false); + assert.equal(stream.track.stopped, false); + assert.deepEqual(h.calls.sent, ['start'], 'only the current attempt may send start'); +} + +for (const end of ['stopRecording', 'cancelRecording']) { + test(`late microphone success after ${end} cannot replace a retry`, async () => { + const h = harness(); + await startPending(h); + h.state[end](); + await startPending(h); + const current = await resolveMic(h, 1); + const node = h.state.sourceNode; + const stale = await resolveMic(h, 0); + assert.equal(stale.track.stopped, true, 'cancelled microphone request must release its tracks'); + assertActive(h, current, node); + }); +} + +test('late microphone rejection cannot stop a retry', async () => { + const h = harness(); + await startPending(h); + h.state.stopRecording(); + await startPending(h); + const current = await resolveMic(h, 1); + const node = h.state.sourceNode; + h.calls.mic[0].reject(Object.assign(new Error('old request'), { name: 'NotAllowedError' })); + await settle(); + assertActive(h, current, node); + assert.equal(h.calls.statuses.at(-1), 'backend'); +}); + +test('old preparation timeout cannot close the current AudioContext', async () => { + const h = harness(); + await startPending(h); + const oldTimeout = [...h.timers.values()][0]; + h.state.stopRecording(); + await startPending(h); + const current = await resolveMic(h, 1); + const node = h.state.sourceNode; + const context = h.state.audioCtx; + oldTimeout(); + await settle(); + assertActive(h, current, node); + assert.equal(h.state.audioCtx, context); + assert.equal(context.state, 'running'); +}); + +test('late resume cannot start another microphone request after cancellation', async () => { + const h = harness({ suspended: true }); + await startPending(h); + h.state.stopRecording(); + h.calls.resume[0].resolve(); + await settle(); + assert.equal(h.calls.mic.length, 0, 'cancelled resume must not request microphone permission'); + assert.deepEqual(h.calls.sent, []); +}); + +test('late resume cannot borrow a new recording and send a second start', async () => { + const h = harness({ suspended: true }); + await startPending(h); + h.state.stopRecording(); + await startPending(h); + h.state.audioCtx.state = 'running'; + h.calls.resume[1].resolve(); + await settle(); + const current = await resolveMic(h, 0); + const node = h.state.sourceNode; + h.calls.resume[0].resolve(); + await settle(); + assertActive(h, current, node); +}); + +for (const outcome of ['resolve', 'reject']) { + test(`late worklet ${outcome} cannot rebuild the current capture graph`, async () => { + const h = harness({ worklet: true }); + await startPending(h); + const current = await resolveMic(h, 0); + h.state.stopRecording(); + await startPending(h); + h.calls.worklet[1].resolve(); + await settle(); + const node = h.state.sourceNode; + const currentWorklet = h.state.workletNode; + h.calls.worklet[0][outcome](new Error('old module')); + await settle(); + assertActive(h, current, node); + assert.equal(h.state.workletNode, currentWorklet); + assert.equal(h.calls.nodes.length, 1); + assert.equal(h.scriptBuilds, 0, 'stale worklet failure must not start fallback capture'); + assert.equal(h.state.usingWorklet, true); + }); +} + +test('current worklet failure still falls back to ScriptProcessor', async () => { + const h = harness({ worklet: true }); + await startPending(h); + await resolveMic(h, 0); + h.calls.worklet[0].reject(new Error('worklet unavailable')); + await settle(); + assert.equal(h.scriptBuilds, 1); + assert.equal(h.state.recording, true); + assert.deepEqual(h.calls.sent, ['start']); +}); + +test('current timeout still resets the context and releases a late microphone', async () => { + const h = harness(); + await startPending(h); + [...h.timers.values()][0](); + await settle(); + assert.equal(h.state.recording, false); + assert.equal(h.state.audioCtx, null); + assert.equal(h.calls.statuses.at(-1), 'timeout'); + const stale = await resolveMic(h, 0); + assert.equal(stale.track.stopped, true); + assert.deepEqual(h.calls.sent, []); +}); + +test('normal stop still pairs start/stop and reuses the microphone on the next attempt', async () => { + const h = harness(); + await startPending(h); + const stream = await resolveMic(h, 0); + h.state.remoteSessionId = 'active-session'; + h.state.stopRecording(); + assert.deepEqual(h.calls.sent, ['start', 'stop']); + assert.equal(h.state.awaitingResult, true); + assert.equal(stream.track.stopped, false); + h.state.awaitingResult = false; // Backend completed the preceding recording. + await startPending(h); + assert.equal(h.calls.mic.length, 1); + assert.equal(h.state.mediaStream, stream); + assert.deepEqual(h.calls.sent, ['start', 'stop', 'start']); +}); diff --git a/openless-all/app/scripts/run-android-tauri-build.mjs b/openless-all/app/scripts/run-android-tauri-build.mjs new file mode 100644 index 000000000..d3c918dde --- /dev/null +++ b/openless-all/app/scripts/run-android-tauri-build.mjs @@ -0,0 +1,56 @@ +/** + * Run `tauri android build` with ABI targets from OPENLESS_ANDROID_TARGETS + * (space/comma-separated CLI names: aarch64 armv7 i686 x86_64). + * + * Usage: + * node scripts/run-android-tauri-build.mjs --debug + * node scripts/run-android-tauri-build.mjs --release + */ +import { spawnSync } from 'node:child_process'; +import process from 'node:process'; +import { parseAndroidAbis, ANDROID_ABI_MATRIX } from './android-abi-matrix.mjs'; + +function resolveTargets() { + const raw = process.env.OPENLESS_ANDROID_TARGETS ?? ''; + if (!raw.trim()) { + return ANDROID_ABI_MATRIX.map((e) => e.abi); + } + return parseAndroidAbis(raw, { + defaultAbis: ANDROID_ABI_MATRIX.map((e) => e.abi), + }).map((e) => e.abi); +} + +function main() { + const mode = process.argv[2]; + if (mode !== '--debug' && mode !== '--release') { + console.error('Usage: node scripts/run-android-tauri-build.mjs --debug|--release'); + process.exit(1); + } + const targets = resolveTargets(); + const args = ['tauri', 'android', 'build', '--apk', '--split-per-abi', '--target', ...targets]; + if (mode === '--debug') { + args.push('--debug'); + } + + console.log(`[android-build] targets=${targets.join(',')} mode=${mode.slice(2)}`); + // #1103: Tauri CLI runs an initial cargo build for the first target to + // "initialize plugins", then Gradle invokes `android-studio-script` per ABI + // (including the first). Expect two cargo passes for the first ABI; do not + // bypass the Gradle/native path. + console.log( + '[android-build] note: first ABI may compile twice (Tauri plugin init + android-studio-script)', + ); + + const bin = process.platform === 'win32' ? 'npx.cmd' : 'npx'; + const result = spawnSync(bin, args, { + stdio: 'inherit', + env: process.env, + shell: process.platform === 'win32', + }); + if (result.error) { + throw result.error; + } + process.exit(result.status ?? 1); +} + +main(); diff --git a/openless-all/app/scripts/selection-intent-reopen.test.mjs b/openless-all/app/scripts/selection-intent-reopen.test.mjs new file mode 100644 index 000000000..6112470f9 --- /dev/null +++ b/openless-all/app/scripts/selection-intent-reopen.test.mjs @@ -0,0 +1,76 @@ +// Exercise a reused native intent picker; only React/IPC/event boundaries are replaced. +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +const app = fileURLToPath(new URL('../', import.meta.url)); +const require = createRequire(import.meta.url); +const ts = require('typescript'); +const source = readFileSync(app + '/src/pages/SelectionVoiceIntentPicker.tsx', 'utf8'); +const start = source.indexOf('export function SelectionVoiceIntentPicker()'); +const end = source.indexOf('\n return (', start); +assert(start >= 0 && end >= 0); +const body = + source + .slice(start, end) + .replace('export function', 'function') + .replace("import('@tauri-apps/api/event')", 'Promise.resolve({listen})') + + 'return {choose,cancel};\n}'; +const compiled = ts.transpileModule(body, { + compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }, +}).outputText; +const factory = new Function( + 'useState', + 'useEffect', + 'useTranslation', + 'getSelectionVoiceIntentPrompt', + 'confirmSelectionVoiceIntentPrompt', + 'cancelSelectionVoiceIntentPrompt', + 'listen', + 'isTauri', + compiled + ';return SelectionVoiceIntentPicker();', +); +const settle = () => new Promise((resolve) => setImmediate(resolve)); +let failures = 0; +for (const action of ['choose', 'cancel']) { + const state = []; + const callbacks = new Map(); + const useState = (initial) => { + const index = state.length; + state.push(initial); + return [ + initial, + (value) => { + state[index] = value; + }, + ]; + }; + const component = factory( + useState, + (effect) => effect(), + () => ({ t: (key) => key }), + async () => ({ instruction: 'Question', sourceText: 'Selected text' }), + async () => {}, + async () => {}, + async (name, callback) => { + callbacks.set(name, callback); + return () => {}; + }, + true, + ); + await settle(); + if (action === 'choose') await component.choose('question'); + else await component.cancel(); + assert.equal(state[2], true); + callbacks.get('selection-voice-intent:shown')(); + await settle(); + try { + assert.equal(state[2], false, action + ' then hide/show must restore button usability'); + console.log(action + ': PASS'); + } catch (error) { + console.log(action + ': FAIL - ' + error.message); + failures++; + } +} +console.log(`${failures} lifecycle regression(s) reproduced`); +process.exitCode = failures ? 1 : 0; diff --git a/openless-all/app/scripts/shared-backend-wire-contract.test.mjs b/openless-all/app/scripts/shared-backend-wire-contract.test.mjs index 0cab6c6ec..c8b402cc8 100644 --- a/openless-all/app/scripts/shared-backend-wire-contract.test.mjs +++ b/openless-all/app/scripts/shared-backend-wire-contract.test.mjs @@ -178,7 +178,7 @@ assert.match( ); assert.match( lessComputerPanel, - /reconciled\.reset\) \{\s*setTurns\(\[\]\);\s*setVoice\(null\)/, + /reconciled\.reset\) \{[^}]*?setTurns\(\[\]\);\s*setVoice\(null\)/, 'a truncated replay must reset both the conversation and voice presentation', ); @@ -508,6 +508,29 @@ assert.doesNotMatch( /pub fn less_computer_(?:window_dismiss|window_open|submit_text)\(/, 'Coordinator must not own Less Computer command business or window wrappers', ); +for (const [command, args] of [ + ['less_computer_voice_start', '{ mode }'], + ['less_computer_voice_stop', '{ sessionId }'], + ['less_computer_voice_cancel', '{ sessionId }'], + ['less_computer_task_cancel', 'undefined'], +]) { + assert(lessComputerIpc.includes(`'${command}', ${args}`), `${command} wire drifted`); + assert.match( + qaCommand, + new RegExp(`pub (?:async )?fn ${command}\\([^]*?require_less_computer_window\\(&window\\)\\?`), + `${command} must only accept the Less Computer window`, + ); +} +assert.match( + qaCommand, + /less_computer_voice_start\([^]*?mode: openless_core::LessComputerVoiceMode[^]*?start_less_computer_voice_from_panel\(mode\)/, + 'panel voice must pass its delivery mode to the shared Host capture', +); +assert.match( + coordinator, + /start_less_computer_voice_from_panel\([^]*?publish_start_error: false/, + 'panel voice start errors are returned to the composer, not posted into the conversation', +); assert.match( stylePacksCommand, /core\.preview_style_pack_runtime\(&style_pack\)/, diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index abe7e2a2b..89f711f6c 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -174,6 +184,19 @@ dependencies = [ "x11rb", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", + "zeroize", +] + [[package]] name = "arrayvec" version = "0.7.8" @@ -486,6 +509,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bindgen" version = "0.71.1" @@ -554,6 +583,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block" version = "0.1.6" @@ -869,6 +907,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[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.1" @@ -880,6 +929,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -902,6 +964,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -1266,6 +1329,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -4243,6 +4307,12 @@ dependencies = [ "pkg-config", ] +[[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" @@ -4257,7 +4327,7 @@ dependencies = [ [[package]] name = "openless" -version = "2.0.0-Beta.2+build.20260924" +version = "2.0.0-Beta.3+build.20260925" dependencies = [ "anyhow", "arboard", @@ -4337,6 +4407,7 @@ dependencies = [ "windows 0.58.0", "winreg 0.52.0", "x509-parser", + "zeroize", "zip 2.4.2", ] @@ -4345,11 +4416,14 @@ name = "openless-core" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "base64 0.22.1", "bzip2 0.4.4", + "chacha20poly1305", "chrono", "ferrous-opencc", "futures-util", + "getrandom 0.3.4", "hmac", "log", "md-5", @@ -4365,8 +4439,10 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-tungstenite", + "unicode-normalization", "url", "uuid", + "zeroize", "zip 2.4.2", ] @@ -4507,6 +4583,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -4691,6 +4778,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -4994,7 +5092,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.2", "rand_core 0.10.1", ] @@ -7502,6 +7600,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-normalization-alignments" version = "0.1.12" @@ -7535,6 +7642,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index cb0a768ac..fd27caab4 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "openless" -version = "2.0.0-Beta.2+build.20260924" +version = "2.0.0-Beta.3+build.20260925" license = "AGPL-3.0-only" description = "OpenLess — local voice input that types where your cursor is" authors = ["OpenLess"] @@ -17,6 +17,7 @@ tauri-build = { version = "2", features = [] } cc = "1.1" [dependencies] +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" } diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.lock b/openless-all/app/src-tauri/backend-tests/Cargo.lock index 6ba0b85c3..4d416b212 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.lock +++ b/openless-all/app/src-tauri/backend-tests/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aes" version = "0.8.4" @@ -52,6 +62,19 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", + "zeroize", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -109,12 +132,27 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -218,6 +256,17 @@ 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" @@ -229,6 +278,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.45" @@ -248,6 +310,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -327,6 +390,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1097,6 +1161,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" @@ -1121,11 +1191,14 @@ name = "openless-core" version = "0.1.0" dependencies = [ "anyhow", + "argon2", "base64", "bzip2 0.4.4", + "chacha20poly1305", "chrono", "ferrous-opencc", "futures-util", + "getrandom 0.3.4", "hmac", "log", "md-5", @@ -1141,8 +1214,10 @@ dependencies = [ "thiserror 1.0.69", "tokio", "tokio-tungstenite", + "unicode-normalization", "url", "uuid", + "zeroize", "zip", ] @@ -1169,6 +1244,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -1260,6 +1346,17 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -1416,7 +1513,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -2173,6 +2270,25 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" diff --git a/openless-all/app/src-tauri/dmg/README.md b/openless-all/app/src-tauri/dmg/README.md new file mode 100644 index 000000000..77719933e --- /dev/null +++ b/openless-all/app/src-tauri/dmg/README.md @@ -0,0 +1,11 @@ +# Apple Silicon 安装界面 + +背景为本次 OpenLess 安装界面生成的摄影风格图像,不包含模拟的应用或文件夹图标。构图是浅色海岸、清晨日光及中央留白,标题、双语安装说明和箭头位于背景中;真正的 `OpenLess.app` 和 `Applications` 别名由 Finder 展示,支持原生拖动安装。 + +- `installer-background@2x.png`:1536 × 1024,生成图像的交付资源。 +- `installer-background.tiff`:包含 768 × 512(72 dpi)和 1536 × 1024(144 dpi)两种表示,供 Finder Retina 背景使用。 +- `layout.json`:真实图标 128 pt、文字 14 pt;窗口及图标位置以 `../tauri.macos-mlx.conf.json` 为准。 + +通过 `INSTALL=0 ./scripts/build-mac.sh` 打包。ARM 构建使用 `scripts/macos-dmg-layout.py` 在 Tauri 压缩和签名之前写入 Finder 布局,不依赖 CI 上的 Finder 或 AppleScript。最终镜像必须通过 helper 的 `verify` 检查;图像只用于安装盘,不进入应用运行时。 + +设计来源:2026-09-26 用户给出的双图标安装示例及新照片背景要求,由 OpenAI 图像生成工具制作。后续生成可沿用“浅色石灰岩海岸、清晨柔光、中央为真实图标保留空间”的方向;这段说明是设计摘要,不是原始逐字提示。 diff --git a/openless-all/app/src-tauri/dmg/installer-background.tiff b/openless-all/app/src-tauri/dmg/installer-background.tiff new file mode 100644 index 000000000..e8494e20f Binary files /dev/null and b/openless-all/app/src-tauri/dmg/installer-background.tiff differ diff --git a/openless-all/app/src-tauri/dmg/installer-background@2x.png b/openless-all/app/src-tauri/dmg/installer-background@2x.png new file mode 100644 index 000000000..60fe5c3b8 Binary files /dev/null and b/openless-all/app/src-tauri/dmg/installer-background@2x.png differ diff --git a/openless-all/app/src-tauri/dmg/layout.json b/openless-all/app/src-tauri/dmg/layout.json new file mode 100644 index 000000000..29111e9e4 --- /dev/null +++ b/openless-all/app/src-tauri/dmg/layout.json @@ -0,0 +1,5 @@ +{ + "iconSize": 128, + "textSize": 14, + "retinaBackground": "dmg/installer-background.tiff" +} diff --git a/openless-all/app/src-tauri/src/commands/cloud_sync_e2ee.rs b/openless-all/app/src-tauri/src/commands/cloud_sync_e2ee.rs new file mode 100644 index 000000000..075acfd2b --- /dev/null +++ b/openless-all/app/src-tauri/src/commands/cloud_sync_e2ee.rs @@ -0,0 +1,246 @@ +//! Main-window-only encrypted sync IPC. Secrets never leave native Core. +use super::CoreState; +use openless_core::cloud_sync_e2ee::*; +use openless_core::{BackendError, BackendErrorCode}; + +fn require_settings_window(window: &tauri::WebviewWindow) -> Result<(), BackendError> { + let allowed = window.label() == "main" + && window.url().is_ok_and(|url| { + let bundled = (url.scheme() == "tauri" && url.host_str() == Some("localhost")) + || (matches!(url.scheme(), "https" | "http") + && url.host_str() == Some("tauri.localhost") + && url.port().is_none()); + let development = cfg!(debug_assertions) + && url.scheme() == "http" + && matches!(url.host_str(), Some("localhost" | "127.0.0.1")); + bundled || development + }); + if allowed { + Ok(()) + } else { + Err(BackendError::new( + BackendErrorCode::PermissionDenied, + "encrypted_sync_settings_window_required", + )) + } +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_claim_setup_prompt( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_claim_setup_prompt().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_status( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_status() +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_prepare_enable( + window: tauri::WebviewWindow, + core: CoreState<'_>, + consent_version: String, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_prepare_enable(consent_version).await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_create( + window: tauri::WebviewWindow, + core: CoreState<'_>, + password: String, + password_confirmation: String, + remember_key: bool, + consent_version: String, + observed_revision: String, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_create( + password, + password_confirmation, + remember_key, + consent_version, + observed_revision, + ) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_unlock( + window: tauri::WebviewWindow, + core: CoreState<'_>, + password: String, + remember_key: bool, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_unlock(password, remember_key).await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_lock( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_lock().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_set_enabled( + window: tauri::WebviewWindow, + core: CoreState<'_>, + enabled: bool, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_set_enabled(enabled).await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_sync_now( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_sync_now().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_cancel( + window: tauri::WebviewWindow, + core: CoreState<'_>, + task_id: String, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_cancel(task_id) +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_preview_restore( + window: tauri::WebviewWindow, + core: CoreState<'_>, + observed_revision: String, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_preview_restore(observed_revision) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_apply_restore( + window: tauri::WebviewWindow, + core: CoreState<'_>, + preview_id: String, + mode: RestoreMode, + conflict_choices: Vec, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_apply_restore(preview_id, mode, conflict_choices) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_change_password( + window: tauri::WebviewWindow, + core: CoreState<'_>, + current_password: String, + new_password: String, + confirmation: String, + remember_key: bool, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_change_password(current_password, new_password, confirmation, remember_key) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_delete_remote( + window: tauri::WebviewWindow, + core: CoreState<'_>, + expected_vault_id: String, + observed_revision: String, + confirmed: bool, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_delete_remote(expected_vault_id, observed_revision, confirmed) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_sign_out( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_sign_out().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_begin_sign_in( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_begin_sign_in().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_poll_sign_in( + window: tauri::WebviewWindow, + core: CoreState<'_>, + authorization_session_id: String, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_poll_sign_in(authorization_session_id) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_cancel_sign_in( + window: tauri::WebviewWindow, + core: CoreState<'_>, + authorization_session_id: String, +) -> Result<(), BackendError> { + require_settings_window(&window)?; + core.cloud_sync_e2ee_cancel_sign_in(authorization_session_id) + .await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_get_ui_preferences( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result, BackendError> { + require_settings_window(&window)?; + core.cloud_sync_e2ee_get_ui_preferences().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_get_ui_preferences_snapshot( + window: tauri::WebviewWindow, + core: CoreState<'_>, +) -> Result { + require_settings_window(&window)?; + core.cloud_sync_e2ee_get_ui_preferences_snapshot().await +} + +#[tauri::command] +pub async fn cloud_sync_e2ee_set_ui_preferences_checked( + window: tauri::WebviewWindow, + core: CoreState<'_>, + locale: String, + font_scale: String, + expected_revision: Option, +) -> Result<(), BackendError> { + require_settings_window(&window)?; + core.cloud_sync_e2ee_set_ui_preferences_checked(locale, font_scale, expected_revision) + .await +} diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 497f672b8..31cf88702 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -104,6 +104,82 @@ pub(crate) fn active_sherpa_asr_is_supported(provider: &str) -> bool { } impl openless_core::CredentialStore for SystemCredentialStore { + fn bind_sync_gate( + &self, + gate: std::sync::Arc, + ) -> Result<(), openless_core::BackendError> { + CredentialsVault::bind_sync_gate(gate).map_err(credential_persistence_error) + } + + fn export_sync_credentials_readonly( + &self, + ) -> futures_util::future::BoxFuture< + 'static, + Result, + > { + run_credential_task(|| { + CredentialsVault::export_sync_credentials_readonly() + .map_err(credential_persistence_error) + }) + } + + fn export_sync_credentials( + &self, + permit: &openless_core::credentials::ExclusivePermit, + ) -> futures_util::future::BoxFuture< + 'static, + Result, + > { + let permit = permit.clone(); + run_credential_task(move || { + CredentialsVault::export_sync_credentials(&permit).map_err(credential_persistence_error) + }) + } + + fn replace_sync_credentials( + &self, + snapshot: openless_core::credentials::SyncCredentials, + permit: &openless_core::credentials::ExclusivePermit, + ) -> futures_util::future::BoxFuture<'static, Result<(), openless_core::BackendError>> { + let permit = permit.clone(); + run_credential_task(move || { + CredentialsVault::replace_sync_credentials(&snapshot, &permit) + .map_err(credential_persistence_error) + }) + } + + fn read_sync_secret( + &self, + account: openless_core::credentials::SyncSecretAccount, + ) -> futures_util::future::BoxFuture< + 'static, + Result, openless_core::BackendError>, + > { + run_credential_task(move || { + CredentialsVault::read_sync_secret(&account).map_err(credential_persistence_error) + }) + } + + fn write_sync_secret( + &self, + account: openless_core::credentials::SyncSecretAccount, + value: openless_core::SecretValue, + ) -> futures_util::future::BoxFuture<'static, Result<(), openless_core::BackendError>> { + run_credential_task(move || { + CredentialsVault::write_sync_secret(&account, &value) + .map_err(credential_persistence_error) + }) + } + + fn remove_sync_secret( + &self, + account: openless_core::credentials::SyncSecretAccount, + ) -> futures_util::future::BoxFuture<'static, Result<(), openless_core::BackendError>> { + run_credential_task(move || { + CredentialsVault::remove_sync_secret(&account).map_err(credential_persistence_error) + }) + } + fn status( &self, preferences: UserPreferences, @@ -192,10 +268,10 @@ fn run_credential_task( Box::pin(async move { tauri::async_runtime::spawn_blocking(task) .await - .map_err(|error| { + .map_err(|_| { openless_core::BackendError::new( openless_core::BackendErrorCode::Internal, - format!("credential worker failed: {error}"), + "credential worker failed", ) })? }) @@ -420,10 +496,18 @@ fn invalid_credential_key(key: &openless_core::CredentialKey) -> openless_core:: } fn credential_persistence_error(error: anyhow::Error) -> openless_core::BackendError { - openless_core::BackendError::new( - openless_core::BackendErrorCode::Persistence, - format!("credential vault operation failed: {error:#}"), - ) + if let Some(error) = error.downcast_ref::() { + return error.clone(); + } + let code = if matches!( + error.downcast_ref::(), + Some(crate::persistence::VaultCommitFailure::Unknown) + ) { + openless_core::BackendErrorCode::OutcomeUnknown + } else { + openless_core::BackendErrorCode::Persistence + }; + openless_core::BackendError::new(code, "credential vault operation failed") } fn require_readable_vault() -> Result<(), openless_core::BackendError> { @@ -889,6 +973,43 @@ fn parse_account(s: &str) -> Result { mod tests { use super::*; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn cancelled_credential_worker_holds_restore_lease_until_it_finishes() { + let dir = std::env::temp_dir().join(format!( + "openless-credential-worker-{}", + uuid::Uuid::new_v4() + )); + let gate = + openless_core::credentials::SyncWriteGate::open(dir.join("generation.json")).unwrap(); + let original = gate.try_exclusive().unwrap(); + let worker_lease = original.clone(); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (finish_tx, finish_rx) = std::sync::mpsc::channel(); + let (done_tx, done_rx) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(run_credential_task(move || { + let _lease = worker_lease; + let _ = started_tx.send(()); + finish_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + drop(_lease); + let _ = done_tx.send(()); + Ok(()) + })); + started_rx.await.unwrap(); + drop(original); + task.abort(); + let _ = task.await; + assert!(gate.begin_mutation().is_err()); + assert!(gate.try_exclusive().is_err()); + finish_tx.send(()).unwrap(); + done_rx.await.unwrap(); + let permit = gate.begin_mutation().unwrap(); + permit.abort_unmodified().unwrap(); + drop(gate); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn omni_credential_keys_preserve_the_explicit_provider_scope() { for account in [ diff --git a/openless-all/app/src-tauri/src/commands/github_oauth.rs b/openless-all/app/src-tauri/src/commands/github_oauth.rs index 406514b60..0f1f20ee3 100644 --- a/openless-all/app/src-tauri/src/commands/github_oauth.rs +++ b/openless-all/app/src-tauri/src/commands/github_oauth.rs @@ -75,11 +75,7 @@ pub async fn marketplace_auth_status( #[tauri::command] pub async fn marketplace_logout(core: CoreState<'_>) -> Result<(), String> { - core.services() - .marketplace - .logout() - .await - .map_err(command_error) + core.sign_out_account().await.map_err(command_error) } #[cfg(test)] diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index fb485e92d..c6ed9b431 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -49,6 +49,7 @@ pub(crate) use crate::types::{ mod channels; mod cloud_sync; +mod cloud_sync_e2ee; mod credentials; mod dictation; mod dictionary; @@ -79,6 +80,7 @@ mod style_packs; pub use channels::*; pub use cloud_sync::*; +pub use cloud_sync_e2ee::*; pub use credentials::*; pub use dictation::*; pub use dictionary::*; diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index ab24c5a5b..9b4248602 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -50,12 +50,65 @@ pub async fn qa_toggle_recording(core: CoreState<'_>) -> Result<(), String> { /// QA 面板键盘输入:复用语音 QA 的 LLM 管线,只替换问题来源。 #[tauri::command] -pub async fn qa_submit_text(core: CoreState<'_>, text: String) -> Result<(), String> { - core.services() +pub async fn qa_submit_text( + core: CoreState<'_>, + text: String, + expected_session_id: Option, + enforce_context: Option, +) -> Result<(), String> { + if enforce_context.unwrap_or(false) { + core.services() + .qa + .submit_text_in_context(text, expected_session_id) + .await + .map_err(|error| error.message) + } else { + core.services() + .qa + .submit_text(text) + .await + .map_err(|error| error.message) + } +} + +#[tauri::command] +pub async fn qa_get_snapshot( + window: Window, + core: CoreState<'_>, +) -> Result { + if !matches!(window.label(), "qa" | "main") { + return Err("qa_window_required".into()); + } + let snapshot = core + .services() .qa - .submit_text(text) + .snapshot() .await - .map_err(|error| error.message) + .map_err(|_| "qa_snapshot_unavailable")?; + let mut event = openless_core::events::QaStateEvent::from_snapshot(&snapshot); + event.edit_instruction_mode = Some(snapshot.edit_instruction_mode); + event.edit_apply_available = Some(snapshot.edit_apply_available); + event.edit_revert_available = Some(snapshot.edit_revert_available); + Ok(event) +} + +#[tauri::command] +pub async fn qa_window_set_expanded(window: Window, expanded: bool) -> Result<(), String> { + use tauri::Manager; + if window.label() != "qa" { + return Err("qa_window_required".into()); + } + let app = window.app_handle().clone(); + let (sender, receiver) = tokio::sync::oneshot::channel(); + window + .app_handle() + .run_on_main_thread(move || { + let _ = sender.send(crate::set_qa_window_expanded(&app, expanded)); + }) + .map_err(|_| "qa_main_thread_unavailable".to_string())?; + receiver + .await + .map_err(|_| "qa_resize_cancelled".to_string())? } /// 划词提问面板「编辑指令」复选框。 @@ -149,6 +202,59 @@ pub fn less_computer_submit_text(core: CoreState<'_>, coord: CoordinatorState<'_ }); } +fn require_less_computer_window(window: &Window) -> Result<(), String> { + if window.label() != "less-computer" { + return Err("voice input can only be controlled from the Less Computer window".to_string()); + } + Ok(()) +} + +/// 输入框麦克风(dictate:转写只回填输入框)/ 语音模式按钮(submit:与快捷键一致)。 +/// 启动失败直接返回给面板内联提示,不写入对话流。 +#[tauri::command] +pub async fn less_computer_voice_start( + window: Window, + coord: CoordinatorState<'_>, + mode: openless_core::LessComputerVoiceMode, +) -> Result<(), String> { + require_less_computer_window(&window)?; + coord.start_less_computer_voice_from_panel(mode).await +} + +/// 结束当前录音,按会话自己的 mode 收尾;收尾在后台执行,不等待 Agent 跑完。 +#[tauri::command] +pub fn less_computer_voice_stop( + window: Window, + coord: CoordinatorState<'_>, + session_id: openless_core::SessionId, +) -> Result<(), String> { + require_less_computer_window(&window)?; + coord.stop_less_computer_voice_from_panel(session_id) +} + +/// 只取消面板指定的那次录音。 +#[tauri::command] +pub async fn less_computer_voice_cancel( + window: Window, + coord: CoordinatorState<'_>, + session_id: openless_core::SessionId, +) -> Result<(), String> { + require_less_computer_window(&window)?; + coord + .cancel_less_computer_voice_from_panel(session_id) + .await +} + +/// 停止正在运行的 Agent 任务,浮窗保持打开。 +#[tauri::command] +pub async fn less_computer_task_cancel( + window: Window, + coord: CoordinatorState<'_>, +) -> Result<(), String> { + require_less_computer_window(&window)?; + coord.cancel_less_computer_task().await +} + /// 主设置页的文字测试入口。浮窗自身无需也不允许反向调用这个命令。 #[tauri::command] pub fn less_computer_window_open( diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index d560501a1..1c2cbf1d3 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -37,6 +37,7 @@ mod hotkey_loops; #[cfg(target_os = "macos")] mod native_dictation_key; mod qa; +mod restore_runtime; #[cfg(all(not(mobile), target_os = "windows"))] pub(crate) mod selection_voice_session; use capsule_focus::*; @@ -355,6 +356,39 @@ pub struct Coordinator { inner: Arc, } +fn startup_storage_error() -> openless_core::BackendError { + openless_core::BackendError::new( + openless_core::BackendErrorCode::Persistence, + "local recovery or secure storage is unavailable; restore access and restart OpenLess", + ) + .retryable(true) +} + +fn startup_sync_gate( +) -> Result, openless_core::BackendError> { + let directory = crate::persistence::data_dir().map_err(|_| startup_storage_error())?; + openless_core::cloud_sync_e2ee_store::gate::open_for_data_dir(&directory) + .map_err(|_| startup_storage_error()) +} + +fn startup_store( + startup_error: &mut Option, + open: impl FnOnce() -> Result, + fallback: impl FnOnce() -> T, +) -> T { + if startup_error.is_some() { + return fallback(); + } + match open() { + Ok(store) => store, + Err(_) => { + log::error!("[core] local store initialization failed; startup is blocked"); + *startup_error = Some(startup_storage_error()); + fallback() + } + } +} + fn shared_backend_from_stores( history: &HistoryStore, activity: &ActivityStore, @@ -366,6 +400,7 @@ fn shared_backend_from_stores( native_asr: crate::core_adapters::TauriNativeAsrDependencies, hotkey_status: Arc>, qa_context: Arc, + startup_error: Option, ) -> Arc { let data_dir = crate::persistence::data_dir().unwrap_or_else(|error| { log::warn!("[core] data directory unavailable, using fallback config path: {error}"); @@ -375,6 +410,21 @@ fn shared_backend_from_stores( .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "en-US".to_string()); + let config = openless_core::BackendConfig { + cache_dir: data_dir.join("cache"), + data_dir, + home_dir: std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(std::path::PathBuf::from), + resource_dir: None, + platform: crate::types::PlatformCapabilities::current(), + locale, + }; + if let Some(error) = startup_error { + return Arc::new(openless_core::OpenLessBackend::blocked_startup( + config, error, + )); + } let repositories = openless_core::BackendRepositories { preferences: prefs.core(), history: history.core(), @@ -392,23 +442,28 @@ fn shared_backend_from_stores( hotkey_status, qa_context, ); - dependencies.marketplace_config = Some(openless_core::MarketplaceConfig::production()); - let backend = Arc::new( - openless_core::OpenLessBackend::new_with_repositories( - openless_core::BackendConfig { - cache_dir: data_dir.join("cache"), - data_dir, - home_dir: std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(std::path::PathBuf::from), - resource_dir: None, - platform: crate::types::PlatformCapabilities::current(), - locale, + dependencies.marketplace_config = Some( + openless_core::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(), }, + ), + ); + let backend = Arc::new( + match openless_core::OpenLessBackend::new_with_repositories( + config.clone(), dependencies, repositories, - ) - .expect("shared backend config always has a non-empty data directory"), + ) { + Ok(backend) => backend, + Err(_) => { + log::error!( + "[core] shared backend initialization failed; exposing blocked startup" + ); + openless_core::OpenLessBackend::blocked_startup(config, startup_storage_error()) + } + }, ); *backend_slot.lock() = Some(Arc::downgrade(&backend)); backend @@ -448,6 +503,7 @@ struct Inner { /// 串行化 Tauri 侧“Core 设置事务 + 宿主 effect”以及风格包删除 effect, /// 防止两个命令把显式 runtime target 乱序安装。 settings_host_gate: Mutex<()>, + hotkey_resume_started: AtomicBool, overlay_qa_handoff: tokio::sync::Mutex<()>, inserter: TextInserter, /// 建议卡片是不是正占着胶囊窗口。 @@ -543,48 +599,48 @@ impl Coordinator { #[cfg(not(target_os = "windows"))] { - #[cfg(target_os = "android")] - const PERSIST_DEGRADE_SUFFIX: &str = " (Android 禁止 /data/local/tmp)"; - #[cfg(not(target_os = "android"))] - const PERSIST_DEGRADE_SUFFIX: &str = ""; - - let history = HistoryStore::new().unwrap_or_else(|e| { - log::error!( - "[coord] HistoryStore init failed: {e}; 降级为空历史记录{PERSIST_DEGRADE_SUFFIX}" - ); - HistoryStore::new_fallback() - }); - let prefs = PreferencesStore::new().unwrap_or_else(|e| { - log::error!( - "[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置{PERSIST_DEGRADE_SUFFIX}" - ); - PreferencesStore::new_fallback() - }); - // 启动即同步系统代理开关(issue #869),让首个请求就按用户设置建客户端。 - crate::net::set_use_system_proxy(prefs.get().use_system_proxy); - let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| { - log::error!( - "[coord] StylePackStore init failed: {e}; 降级为空样式包列表{PERSIST_DEGRADE_SUFFIX}" - ); - StylePackStore::new_fallback() - }); - let vocab = DictionaryStore::new().unwrap_or_else(|e| { - log::error!( - "[coord] DictionaryStore init failed: {e}; 降级为空词库{PERSIST_DEGRADE_SUFFIX}" - ); - DictionaryStore::new_fallback() - }); - let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| { - log::error!( - "[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则{PERSIST_DEGRADE_SUFFIX}" - ); - CorrectionRuleStore::new_fallback() - }); - - let activity = ActivityStore::load().unwrap_or_else(|e| { - log::error!("[coord] ActivityStore init failed: {e}; 活动计数降级为内存态"); - ActivityStore::new_fallback() - }); + let gate = startup_sync_gate(); + let mut startup_error = gate.as_ref().err().cloned(); + // Keep the registered barrier alive until Core adopts it. Once any store + // fails, later constructors use fallbacks and no real repository is touched. + let _sync_gate = gate.ok(); + let history = startup_store( + &mut startup_error, + HistoryStore::new, + HistoryStore::new_fallback, + ); + let prefs = startup_store( + &mut startup_error, + PreferencesStore::new, + PreferencesStore::new_fallback, + ); + if startup_error.is_none() + && _sync_gate + .as_ref() + .is_some_and(|gate| !gate.recovery_required().unwrap_or(true)) + { + crate::net::set_use_system_proxy(prefs.get().use_system_proxy); + } + let style_packs = startup_store( + &mut startup_error, + || StylePackStore::new(&prefs), + StylePackStore::new_fallback, + ); + let vocab = startup_store( + &mut startup_error, + DictionaryStore::new, + DictionaryStore::new_fallback, + ); + let correction_rules = startup_store( + &mut startup_error, + CorrectionRuleStore::new, + CorrectionRuleStore::new_fallback, + ); + let activity = startup_store( + &mut startup_error, + ActivityStore::load, + ActivityStore::new_fallback, + ); let app = crate::core_adapters::app_handle_slot(); let native_asr = crate::core_adapters::TauriNativeAsrDependencies::new(); @@ -601,6 +657,7 @@ impl Coordinator { native_asr.clone(), Arc::clone(&hotkey_status), Arc::clone(&qa_context), + startup_error, ); let host = crate::tauri_coordinator_host::TauriCoordinatorHost::new(Arc::clone(&app)); @@ -611,6 +668,7 @@ impl Coordinator { less_computer_voice: Mutex::new(None), hotkey_runtime_target: Mutex::new(hotkey_runtime_target), settings_host_gate: Mutex::new(()), + hotkey_resume_started: AtomicBool::new(false), overlay_qa_handoff: tokio::sync::Mutex::new(()), inserter: TextInserter::new(), vocab_card_visible: AtomicBool::new(false), @@ -664,33 +722,48 @@ impl Coordinator { foundry_local_runtime: Arc, sherpa_onnx_runtime: Arc, ) -> Self { - let history = HistoryStore::new().unwrap_or_else(|e| { - log::error!("[coord] HistoryStore init failed: {e}; 降级为空历史记录"); - HistoryStore::new_fallback() - }); - let prefs = PreferencesStore::new().unwrap_or_else(|e| { - log::error!("[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置"); - PreferencesStore::new_fallback() - }); - // 启动即同步系统代理开关(issue #869),让首个请求就按用户设置建客户端。 - crate::net::set_use_system_proxy(prefs.get().use_system_proxy); - let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| { - log::error!("[coord] StylePackStore init failed: {e}; 降级为空样式包列表"); - StylePackStore::new_fallback() - }); - let vocab = DictionaryStore::new().unwrap_or_else(|e| { - log::error!("[coord] DictionaryStore init failed: {e}; 降级为空词库"); - DictionaryStore::new_fallback() - }); - let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| { - log::error!("[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则"); - CorrectionRuleStore::new_fallback() - }); - - let activity = ActivityStore::load().unwrap_or_else(|e| { - log::error!("[coord] ActivityStore init failed: {e}; 活动计数降级为内存态"); - ActivityStore::new_fallback() - }); + let gate = startup_sync_gate(); + let mut startup_error = gate.as_ref().err().cloned(); + // Keep the registered barrier alive until Core adopts it. Once any store + // fails, later constructors use fallbacks and no real repository is touched. + let _sync_gate = gate.ok(); + let history = startup_store( + &mut startup_error, + HistoryStore::new, + HistoryStore::new_fallback, + ); + let prefs = startup_store( + &mut startup_error, + PreferencesStore::new, + PreferencesStore::new_fallback, + ); + if startup_error.is_none() + && _sync_gate + .as_ref() + .is_some_and(|gate| !gate.recovery_required().unwrap_or(true)) + { + crate::net::set_use_system_proxy(prefs.get().use_system_proxy); + } + let style_packs = startup_store( + &mut startup_error, + || StylePackStore::new(&prefs), + StylePackStore::new_fallback, + ); + let vocab = startup_store( + &mut startup_error, + DictionaryStore::new, + DictionaryStore::new_fallback, + ); + let correction_rules = startup_store( + &mut startup_error, + CorrectionRuleStore::new, + CorrectionRuleStore::new_fallback, + ); + let activity = startup_store( + &mut startup_error, + ActivityStore::load, + ActivityStore::new_fallback, + ); let app = crate::core_adapters::app_handle_slot(); let hotkey_status = Arc::new(Mutex::new(HotkeyStatus::default())); @@ -712,6 +785,7 @@ impl Coordinator { ), Arc::clone(&hotkey_status), Arc::clone(&qa_context), + startup_error, ); let host = crate::tauri_coordinator_host::TauriCoordinatorHost::new(Arc::clone(&app)); @@ -722,6 +796,7 @@ impl Coordinator { less_computer_voice: Mutex::new(None), hotkey_runtime_target: Mutex::new(hotkey_runtime_target), settings_host_gate: Mutex::new(()), + hotkey_resume_started: AtomicBool::new(false), overlay_qa_handoff: tokio::sync::Mutex::new(()), inserter: TextInserter::new(), vocab_card_visible: AtomicBool::new(false), @@ -758,6 +833,10 @@ impl Coordinator { Self { inner } } + pub fn startup_error(&self) -> Option { + self.inner.backend.startup_error() + } + pub fn backend(&self) -> Arc { Arc::clone(&self.inner.backend) } @@ -906,7 +985,60 @@ impl Coordinator { self.inner.shutdown.store(true, Ordering::SeqCst); } + /// Call once from RunEvent::Ready, even when recovery is still pending. + /// Installation waits for the fence; sleeping never owns the settings gate. + pub fn start_hotkey_supervisors_when_ready(&self) { + if self + .inner + .hotkey_resume_started + .swap(true, Ordering::AcqRel) + { + return; + } + let weak = Arc::downgrade(&self.inner); + let fallback = weak.clone(); + if std::thread::Builder::new() + .name("openless-hotkey-resume".into()) + .spawn(move || loop { + let Some(inner) = weak.upgrade() else { + return; + }; + if inner.shutdown.load(Ordering::SeqCst) || inner.backend.startup_error().is_some() + { + return; + } + if inner.backend.ensure_runtime_ready().is_ok() { + let coord = Coordinator { inner }; + coord.start_hotkey_listener(); + coord.start_qa_hotkey_listener(); + #[cfg(not(mobile))] + coord.start_selection_polish_hotkey_listener(); + coord.start_coding_agent_hotkey_listener(); + coord.start_combo_hotkey_listener(); + coord.start_translation_hotkey_listener(); + coord.start_switch_style_hotkey_listener(); + coord.start_open_app_hotkey_listener(); + coord.start_quick_note_hotkey_listener(); + coord.start_style_pack_hotkey_listeners(); + return; + } + drop(inner); + std::thread::sleep(std::time::Duration::from_secs(1)); + }) + .is_err() + { + if let Some(inner) = fallback.upgrade() { + inner.hotkey_resume_started.store(false, Ordering::Release); + } + log::error!("[coord] hotkey resume supervisor could not start"); + } + } + pub fn start_hotkey_listener(&self) { + if self.inner.backend.ensure_runtime_ready().is_err() { + log::info!("[coord] hotkey startup waits for local recovery"); + return; + } // 起一个守护线程,反复尝试安装 hotkey hook。Accessibility 一被授予就立即生效, // 用户不需要手动重启 OpenLess。 let inner = Arc::clone(&self.inner); @@ -1099,14 +1231,15 @@ impl Coordinator { if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) { take_combo_hotkey_on_main_thread(&self.inner); self.inner.side_aware_combo.lock().take(); - let (tx, rx) = mpsc::channel::(); - match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) { + let (tx, rx) = mpsc::channel::(); + let combo_tx = spawn_combo_abort_bridge(&self.inner, handle_trigger_combined); + match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx, combo_tx) { Ok(monitor) => { *self.inner.side_aware_combo.lock() = Some(monitor); let bridge_inner = Arc::clone(&self.inner); std::thread::Builder::new() .name("openless-side-combo-bridge".into()) - .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) + .spawn(move || hotkey_bridge_loop(bridge_inner, rx)) .ok(); log::info!("[coord] side-aware combo hotkey listener installed (via update)"); } @@ -1383,6 +1516,15 @@ impl Coordinator { } fn ensure_modifier_hotkey_monitor(&self, binding: crate::types::HotkeyBinding) { + if let Err(error) = self.try_ensure_modifier_hotkey_monitor(binding) { + log::warn!("[coord] modifier hotkey update failed: {error}"); + } + } + + fn try_ensure_modifier_hotkey_monitor( + &self, + binding: crate::types::HotkeyBinding, + ) -> Result<(), String> { if let Some(monitor) = self.inner.hotkey.lock().as_ref() { #[cfg(target_os = "linux")] let plugin_binding = binding.clone(); @@ -1393,7 +1535,7 @@ impl Coordinator { } else { crate::linux_fcitx::sync_binding_to_plugin(&plugin_binding); } - return; + return Ok(()); } let (tx, rx) = mpsc::channel::(); #[cfg(target_os = "linux")] @@ -1405,6 +1547,13 @@ impl Coordinator { match HotkeyMonitor::start(binding, tx, cancel_tx, combo_tx) { Ok(monitor) => { let adapter = monitor.kind(); + let inner_clone = Arc::clone(&self.inner); + std::thread::Builder::new() + .name("openless-hotkey-bridge".into()) + .spawn(move || hotkey_bridge_loop(inner_clone, rx)) + .map_err(|error| error.to_string())?; + // Publish only after the bridge exists. A failed thread spawn + // must drop this monitor, not leave a registered dead sender. *self.inner.hotkey.lock() = Some(monitor); *self.inner.hotkey_status.lock() = HotkeyStatus { adapter, @@ -1412,11 +1561,6 @@ impl Coordinator { message: Some(format!("{} 已安装", adapter.display_name())), last_error: None, }; - let inner_clone = Arc::clone(&self.inner); - std::thread::Builder::new() - .name("openless-hotkey-bridge".into()) - .spawn(move || hotkey_bridge_loop(inner_clone, rx)) - .ok(); // Linux: 启动 fcitx5 插件信号监听作为热键源。 #[cfg(target_os = "linux")] { @@ -1444,10 +1588,12 @@ impl Coordinator { adapter: HotkeyMonitor::capability().adapter, state: HotkeyStatusState::Failed, message: Some(e.message.clone()), - last_error: Some(e), + last_error: Some(e.clone()), }; + return Err(e.message); } } + Ok(()) } pub fn update_modifier_shortcut_bindings(&self) { @@ -1565,6 +1711,51 @@ impl Coordinator { .map_err(|error| error.to_string()) } + /// Composer microphone / voice-mode button. Start errors are returned to the + /// panel instead of being posted into the conversation stream. + pub(crate) async fn start_less_computer_voice_from_panel( + &self, + mode: openless_core::LessComputerVoiceMode, + ) -> Result<(), String> { + start_less_computer_capture( + &self.inner, + openless_core::LessComputerVoiceOptions { + mode, + publish_start_error: false, + }, + ) + .await + .map(|_| ()) + .map_err(|error| error.message) + } + + pub(crate) fn stop_less_computer_voice_from_panel( + &self, + session_id: openless_core::SessionId, + ) -> Result<(), String> { + request_less_computer_voice_stop(&self.inner, session_id) + .map(|_| ()) + .map_err(|error| error.message) + } + + pub(crate) async fn cancel_less_computer_voice_from_panel( + &self, + session_id: openless_core::SessionId, + ) -> Result<(), String> { + cancel_less_computer_voice_request(&self.inner, session_id) + .await + .map(|_| ()) + .map_err(|error| error.message) + } + + /// Stop button while an Agent turn runs; the window stays open. + pub(crate) async fn cancel_less_computer_task(&self) -> Result<(), String> { + cancel_active_less_computer(&self.inner) + .await + .map(|_| ()) + .map_err(|error| error.message) + } + pub(crate) async fn cancel_active_voice(&self) { dictation::cancel_active_session(&self.inner).await; } @@ -1770,3 +1961,43 @@ fn schedule_selection_polish_capsule_idle(inner: &Arc, epoch: u64, delay_ hide_selection_polish_capsule_if_current(&inner, epoch); }); } + +#[cfg(test)] +mod startup_restore_tests { + use super::*; + + #[test] + fn restore_host_denied_store_blocks_later_real_constructors() { + let mut failure = None; + let first = startup_store(&mut failure, || Err::("denied"), || 7); + assert_eq!(first, 7); + assert!(failure.is_some()); + let second = startup_store( + &mut failure, + || -> Result { panic!("must not open another real store after failure") }, + || 9, + ); + assert_eq!(second, 9); + let backend = openless_core::OpenLessBackend::blocked_startup( + openless_core::BackendConfig::default(), + failure.unwrap(), + ); + assert!(backend.startup_error().is_some()); + assert!(!backend.snapshot().running); + assert!(backend.ensure_runtime_ready().is_err()); + } + + #[test] + fn restore_host_healthy_store_does_not_use_fallback() { + let mut failure = None; + assert_eq!( + startup_store( + &mut failure, + || Ok::<_, ()>(11), + || panic!("unexpected fallback") + ), + 11 + ); + assert!(failure.is_none()); + } +} diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 7a1464453..b1e239f43 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -86,6 +86,13 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + let target = hotkey_runtime_target(&inner); if inner.hotkey.lock().is_some() { @@ -105,6 +112,7 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { }; log::warn!("[hotkey-supervisor] fcitx5 plugin unavailable, retrying..."); attempts += 1; + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -194,6 +202,7 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { error_message ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -202,23 +211,40 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc) { // ─────────────────────────── QA hotkey supervisor ─────────────────────────── +fn take_qa_hotkey_on_main_thread(inner: &Arc) { + let main = Arc::clone(inner); + if let Err(error) = inner.host.run_on_main_thread(move || { + main.qa_hotkey.lock().take(); + }) { + log::warn!("[qa] cannot dispatch hotkey removal: {error}"); + } +} + pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { let mut attempts: u32 = 0; loop { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + // 用户已经把 QA 关掉就睡着等 runtime target 改动;改动通过显式 settings effect 唤醒。 let binding = match hotkey_runtime_target(&inner).qa { Some(b) => b, None => { - inner.qa_hotkey.lock().take(); + take_qa_hotkey_on_main_thread(&inner); + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } }; if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { - inner.qa_hotkey.lock().take(); + take_qa_hotkey_on_main_thread(&inner); if let Some(monitor) = inner.hotkey.lock().as_ref() { let (qa_trigger, selection_polish_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); @@ -228,12 +254,14 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { translation_trigger, ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } if inner.qa_hotkey.lock().is_some() { // 已注册成功 → 不重复装;睡 5s 复查( binding 变化由 update 路径手动触发 )。 + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } @@ -243,7 +271,7 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { // PR #119 第一版漏掉的关键步骤,导致用户按了 hotkey 完全无反应。这里通过 // run_on_main_thread 把 QaHotkeyMonitor::start 跳到主线程跑,结果再回 channel。 let (tx, rx) = mpsc::channel::(); - let (init_tx, init_rx) = mpsc::sync_channel::>(1); + let (init_tx, init_rx) = mpsc::sync_channel::>(0); let binding_for_main = binding.clone(); if inner .host @@ -253,6 +281,7 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { }) .is_err() { + drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); continue; } @@ -262,12 +291,14 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { + drop(init_rx); attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] QA hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -292,6 +323,7 @@ pub(super) fn qa_hotkey_supervisor_loop(inner: Arc) { if attempts <= 3 || attempts % 10 == 0 { log::warn!("[coord] QA hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -324,6 +356,13 @@ pub(super) fn selection_polish_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + match try_update_selection_polish_hotkey_binding(&inner) { Ok(()) => return, Err(error) => { @@ -333,6 +372,7 @@ pub(super) fn selection_polish_hotkey_supervisor_loop(inner: Arc) { "[selection-polish] hotkey registration attempt #{attempts} failed: {error}; retrying in 3s" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -613,9 +653,17 @@ pub(super) fn coding_agent_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + if let Err(error) = update_coding_agent_hotkey_binding_now(&inner) { log::warn!("[less-computer] hotkey registration failed: {error}"); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); } } @@ -1172,12 +1220,27 @@ pub(super) async fn handle_less_computer_pressed( } return None; } + start_less_computer_capture(inner, openless_core::LessComputerVoiceOptions::default()) + .await + .ok() +} + +/// Open a Core-owned capture in the single Host slot. Hotkey presses and the +/// panel buttons share this path; only hotkeys own a press generation, so a +/// panel capture never feeds the hotkey interpreter. +pub(super) async fn start_less_computer_capture( + inner: &Arc, + options: openless_core::LessComputerVoiceOptions, +) -> Result { let session_id = openless_core::SessionId::new(); let recording_control = Arc::new(LessComputerRecordingControl::new(inner)); { let mut slot = inner.less_computer_voice.lock(); if slot.is_some() { - return None; + return Err(openless_core::BackendError::new( + openless_core::BackendErrorCode::Busy, + "Less Computer voice input is already active", + )); } *slot = Some(LessComputerHostCapture::Starting( session_id, @@ -1186,9 +1249,10 @@ pub(super) async fn handle_less_computer_pressed( } match inner .backend - .start_less_computer_voice( + .start_less_computer_voice_with( session_id, Arc::clone(&recording_control) as Arc, + options, ) .await { @@ -1199,15 +1263,21 @@ pub(super) async fn handle_less_computer_pressed( // 重绑/禁用/Esc已取走Starting。迟到的原生handle只允许释放, // 不得重新挂回slot,更不能覆盖新的录音或继续提交给Agent。 let _ = session.cancel().await; - return None; + return Err(openless_core::BackendError::new( + openless_core::BackendErrorCode::Cancelled, + "Less Computer voice session was cancelled while starting", + )); } // Make the visible capture state observable before flushing an // early Stop/Cancel. Otherwise a queued effect could hide the glow // first and this function would immediately show it again. inner.host.show_less_computer_glow(); recording_control.flush(session_id); - log::info!("[less-computer] voice session started (session={session_id})"); - Some(session_id) + log::info!( + "[less-computer] voice session started (session={session_id}, mode={:?})", + options.mode + ); + Ok(session_id) } Err(error) => { let mut slot = inner.less_computer_voice.lock(); @@ -1216,9 +1286,75 @@ pub(super) async fn handle_less_computer_pressed( slot.take(); } log::warn!("[less-computer] voice session startup failed: {error}"); + Err(error) + } + } +} + +/// Panel cancel for one recording. A capture still in the Host slot is +/// released there; one already finishing has left the slot, so only that Core +/// session is cancelled. Other sessions and later runs are never touched. +pub(super) async fn cancel_less_computer_voice_request( + inner: &Arc, + session_id: openless_core::SessionId, +) -> Result { + let owns_capture = inner + .less_computer_voice + .lock() + .as_ref() + .map(LessComputerHostCapture::session_id) + == Some(session_id); + if owns_capture { + cancel_less_computer_capture(inner, Some(session_id)); + return Ok(true); + } + if inner.backend.less_computer_capture_cancelled(session_id) { + return Ok(false); + } + inner + .backend + .cancel_less_computer(Some(session_id)) + .await + .map(|()| true) +} + +/// Panel stop: queue Stop for a cold start, otherwise finish in the background. +/// `finish` awaits a whole Agent turn in submit mode, so the command must not. +pub(super) fn request_less_computer_voice_stop( + inner: &Arc, + session_id: openless_core::SessionId, +) -> Result { + request_less_computer_voice_stop_with(inner, session_id, |task| { + inner.host.spawn(task); + }) +} + +fn request_less_computer_voice_stop_with( + inner: &Arc, + session_id: openless_core::SessionId, + spawn: impl FnOnce(futures_util::future::BoxFuture<'static, ()>), +) -> Result { + let starting = match inner.less_computer_voice.lock().as_ref() { + Some(LessComputerHostCapture::Starting(id, control)) if *id == session_id => { + Some(control.clone()) + } + Some(LessComputerHostCapture::Recording(session)) if session.session_id() == session_id => { None } + _ => return Ok(false), + }; + if let Some(control) = starting { + return control + .request(session_id, openless_core::RecordingControlAction::Stop) + .map(|()| true); } + let task_inner = Arc::clone(inner); + // Stop belongs to the recording the panel observed, even if the task is + // scheduled after that recording was cancelled and a new one has started. + spawn(Box::pin(async move { + let _ = finish_less_computer_voice_session(&task_inner, Some(session_id)).await; + })); + Ok(true) } pub(super) async fn handle_less_computer_released( @@ -1273,6 +1409,13 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + let target = hotkey_runtime_target(&inner); if crate::shortcut_binding::legacy_modifier_trigger(&target.dictation).is_some() { take_combo_hotkey_on_main_thread(&inner); @@ -1292,14 +1435,15 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { if inner.side_aware_combo.lock().is_some() { return; } - let (tx, rx) = mpsc::channel::(); - match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) { + let (tx, rx) = mpsc::channel::(); + let combo_tx = spawn_combo_abort_bridge(&inner, handle_trigger_combined); + match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx, combo_tx) { Ok(monitor) => { *inner.side_aware_combo.lock() = Some(monitor); let inner_clone = Arc::clone(&inner); std::thread::Builder::new() .name("openless-side-combo-bridge".into()) - .spawn(move || combo_hotkey_bridge_loop(inner_clone, rx)) + .spawn(move || hotkey_bridge_loop(inner_clone, rx)) .ok(); return; } @@ -1310,6 +1454,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { "[coord] side-aware combo 第 {attempts} 次注册失败: {e}; 3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -1324,7 +1469,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { let (tx, rx) = mpsc::channel::(); let (init_tx, init_rx) = - mpsc::sync_channel::>(1); + mpsc::sync_channel::>(0); let binding_for_main = binding.clone(); if inner .host @@ -1334,6 +1479,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { }) .is_err() { + drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); continue; } @@ -1341,12 +1487,14 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { + drop(init_rx); attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -1373,6 +1521,7 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { if attempts <= 3 || attempts % 10 == 0 { log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -1411,6 +1560,13 @@ pub(super) fn translation_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + let binding = hotkey_runtime_target(&inner).translation; if is_builtin_translation_shift(&binding) || crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() @@ -1436,7 +1592,7 @@ pub(super) fn translation_hotkey_supervisor_loop(inner: Arc) { let (tx, rx) = mpsc::channel::(); let (init_tx, init_rx) = - mpsc::sync_channel::>(1); + mpsc::sync_channel::>(0); let binding_for_main = binding.clone(); if inner .host @@ -1446,6 +1602,7 @@ pub(super) fn translation_hotkey_supervisor_loop(inner: Arc) { }) .is_err() { + drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); continue; } @@ -1453,7 +1610,9 @@ pub(super) fn translation_hotkey_supervisor_loop(inner: Arc) { let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { + drop(init_rx); attempts += 1; + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -1476,6 +1635,7 @@ pub(super) fn translation_hotkey_supervisor_loop(inner: Arc) { "[coord] translation hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -1522,6 +1682,13 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + // None = 用户主动停用:反注册后退出守护(由 update_action_hotkey_binding 主动路径重装)。 let Some(binding) = action_hotkey_binding(&inner, kind) else { take_action_hotkey_on_main_thread(&inner, kind); @@ -1541,7 +1708,7 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke let (tx, rx) = mpsc::channel::(); let (init_tx, init_rx) = - mpsc::sync_channel::>(1); + mpsc::sync_channel::>(0); let binding_for_main = binding.clone(); if inner .host @@ -1551,6 +1718,7 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke }) .is_err() { + drop(registration); std::thread::sleep(std::time::Duration::from_secs(1)); continue; } @@ -1558,12 +1726,14 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { Ok(r) => r, Err(_) => { + drop(init_rx); attempts += 1; if attempts <= 3 || attempts % 10 == 0 { log::warn!( "[coord] action hotkey {kind:?} 第 {attempts} 次注册超时;3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); continue; } @@ -1590,6 +1760,7 @@ pub(super) fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotke "[coord] action hotkey {kind:?} 第 {attempts} 次注册失败: {e}; 3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -1639,12 +1810,10 @@ pub(super) fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotke ) => { backend - .stop_dictation_with_options( - openless_core::DictationStopOptions { - quick_note: Some(true), - ..openless_core::DictationStopOptions::default() - }, - ) + .stop_dictation_with_options(openless_core::DictationStopOptions { + quick_note: Some(true), + ..openless_core::DictationStopOptions::default() + }) .await .map(|_| ()) } @@ -1827,6 +1996,12 @@ pub(super) fn style_pack_hotkey_supervisor_loop(inner: Arc) { if inner.shutdown.load(Ordering::SeqCst) { return; } + let registration = inner.settings_host_gate.lock(); + if inner.backend.ensure_runtime_ready().is_err() { + drop(registration); + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } let desired = configured_style_pack_hotkeys(&inner); let registrations_match = { @@ -1837,6 +2012,7 @@ pub(super) fn style_pack_hotkey_supervisor_loop(inner: Arc) { }; if registrations_match { attempts = 0; + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); continue; } @@ -1848,6 +2024,7 @@ pub(super) fn style_pack_hotkey_supervisor_loop(inner: Arc) { attempts + 1 ); attempts = 0; + drop(registration); std::thread::sleep(std::time::Duration::from_secs(5)); } Err(error) => { @@ -1857,6 +2034,7 @@ pub(super) fn style_pack_hotkey_supervisor_loop(inner: Arc) { "[coord] style pack hotkeys 第 {attempts} 次同步失败: {error}; 3s 后重试" ); } + drop(registration); std::thread::sleep(std::time::Duration::from_secs(3)); } } @@ -2296,8 +2474,8 @@ pub(super) fn window_key_matches_trigger( } } -#[cfg(all(test, target_os = "windows"))] -pub(crate) mod windows_less_computer_tests { +#[cfg(test)] +pub(crate) mod less_computer_test_support { use super::*; // 只替换设备/云边界;边沿、取消、Host slot和Core lease都使用生产实现。 @@ -2353,6 +2531,7 @@ pub(crate) mod windows_less_computer_tests { backend, less_computer_voice: Mutex::new(None), settings_host_gate: Mutex::new(()), + hotkey_resume_started: AtomicBool::new(false), overlay_qa_handoff: tokio::sync::Mutex::new(()), inserter: TextInserter::new(), vocab_card_visible: AtomicBool::new(false), @@ -2370,9 +2549,11 @@ pub(crate) mod windows_less_computer_tests { quick_note_hotkey: Mutex::new(None), style_pack_hotkeys: Mutex::new(std::collections::HashMap::new()), selection_polish_hotkey: Mutex::new(None), + #[cfg(target_os = "windows")] selection_voice_host: Arc::new(Mutex::new( selection_voice_session::SelectionVoiceHostState::default(), )), + #[cfg(target_os = "windows")] selection_voice_capture: Mutex::new(None), qa_hotkey: Mutex::new(None), coding_agent_modifier_hotkey: Mutex::new(None), @@ -2387,6 +2568,268 @@ pub(crate) mod windows_less_computer_tests { (Coordinator { inner }, recorder, data_dir) } + pub(crate) struct DelayedFixtureRecorder { + pub(crate) recorder: Arc, + pub(crate) startup_delay: std::time::Duration, + } + + impl openless_core::AudioRecorder for DelayedFixtureRecorder { + fn start( + &self, + session_id: openless_core::SessionId, + context: Arc, + consumer: Arc, + progress: Arc, + ) -> futures_util::future::BoxFuture< + 'static, + Result, openless_core::BackendError>, + > { + let recorder = self.recorder.clone(); + let delay = self.startup_delay; + Box::pin(async move { + tokio::time::sleep(delay).await; + recorder + .start(session_id, context, consumer, progress) + .await + }) + } + } +} + +#[cfg(test)] +mod less_computer_panel_tests { + use super::less_computer_test_support::fixture_coordinator; + use super::*; + + async fn wait_for_released_capture( + coordinator: &Coordinator, + recorder: &openless_core::testing::FixtureAudioRecorder, + stops: usize, + ) { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + while recorder.stop_count() != stops + || coordinator + .backend() + .less_computer_active_session() + .is_some() + { + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + }) + .await + .expect("the capture must stop and release its lease"); + } + + fn voice_states( + events: &mut openless_core::EventSubscription, + ) -> Vec { + std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event.kind { + openless_core::BackendEventKind::LessComputerEvent(event) => Some(event.kind), + _ => None, + }) + .collect() + } + + #[tokio::test] + async fn panel_dictation_bypasses_hotkeys_and_a_toggle_press_only_commits_text() { + use openless_core::{ + LessComputerEventKind, LessComputerVoiceMode, LessComputerVoiceOutcome, + }; + let (coordinator, recorder, data_dir) = + fixture_coordinator(crate::types::HotkeyMode::Toggle, std::time::Duration::ZERO); + let mut events = coordinator.backend().subscribe(); + coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Dictate) + .await + .unwrap(); + let inner = &coordinator.inner; + assert_eq!( + inner.less_computer_press_generation.load(Ordering::SeqCst), + 0 + ); + assert!(matches!( + inner.less_computer_voice.lock().as_ref(), + Some(LessComputerHostCapture::Recording(session)) + if session.mode() == LessComputerVoiceMode::Dictate + )); + assert!(coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Submit) + .await + .is_err()); + + // A toggle press ends the panel capture with the panel's own delivery mode. + assert!( + handle_less_computer_pressed(inner, 7, std::time::Instant::now()) + .await + .is_none() + ); + wait_for_released_capture(&coordinator, &recorder, 1).await; + let kinds = voice_states(&mut events); + assert!(!kinds + .iter() + .any(|kind| matches!(kind, LessComputerEventKind::User { .. }))); + assert!(kinds.iter().any(|kind| matches!( + kind, + LessComputerEventKind::VoiceState { + outcome: Some(LessComputerVoiceOutcome::Committed), + transcript, + .. + } if transcript == "voice" + ))); + drop(coordinator); + std::fs::remove_dir_all(data_dir).unwrap(); + } + + #[tokio::test] + async fn panel_stop_and_cancel_only_touch_the_named_capture() { + use openless_core::LessComputerVoiceMode; + let (coordinator, recorder, data_dir) = + fixture_coordinator(crate::types::HotkeyMode::Hold, std::time::Duration::ZERO); + coordinator + .stop_less_computer_voice_from_panel(openless_core::SessionId::new()) + .unwrap(); + coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Dictate) + .await + .unwrap(); + let owner = coordinator + .backend() + .less_computer_active_session() + .unwrap(); + coordinator + .stop_less_computer_voice_from_panel(openless_core::SessionId::new()) + .unwrap(); + assert_eq!(recorder.stop_count(), 0); + coordinator + .cancel_less_computer_voice_from_panel(openless_core::SessionId::new()) + .await + .unwrap(); + assert_eq!( + coordinator.backend().less_computer_active_session(), + Some(owner), + "a stale session id must not cancel the live capture" + ); + coordinator + .cancel_less_computer_voice_from_panel(owner) + .await + .unwrap(); + wait_for_released_capture(&coordinator, &recorder, 1).await; + + coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Dictate) + .await + .unwrap(); + let next = coordinator + .backend() + .less_computer_active_session() + .unwrap(); + coordinator + .stop_less_computer_voice_from_panel(next) + .unwrap(); + wait_for_released_capture(&coordinator, &recorder, 2).await; + drop(coordinator); + std::fs::remove_dir_all(data_dir).unwrap(); + } + + #[tokio::test] + async fn delayed_panel_stop_cannot_finish_a_new_recording() { + use openless_core::{LessComputerEventKind, LessComputerVoiceMode}; + let (coordinator, recorder, data_dir) = + fixture_coordinator(crate::types::HotkeyMode::Hold, std::time::Duration::ZERO); + let mut events = coordinator.backend().subscribe(); + coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Dictate) + .await + .unwrap(); + let first = coordinator + .backend() + .less_computer_active_session() + .unwrap(); + let mut queued = None; + assert!( + request_less_computer_voice_stop_with(&coordinator.inner, first, |task| { + queued = Some(task); + }) + .unwrap() + ); + coordinator + .cancel_less_computer_voice_from_panel(first) + .await + .unwrap(); + wait_for_released_capture(&coordinator, &recorder, 1).await; + + coordinator + .start_less_computer_voice_from_panel(LessComputerVoiceMode::Submit) + .await + .unwrap(); + let second = coordinator + .backend() + .less_computer_active_session() + .unwrap(); + assert_ne!(first, second); + // Poll exactly the production stop effect only after the successor has + // acquired the Host slot. No timing/sleep assumption schedules this race. + queued.expect("the recording stop must be scheduled").await; + assert_eq!(recorder.stop_count(), 1); + assert_eq!( + coordinator.backend().less_computer_active_session(), + Some(second) + ); + assert!(matches!( + coordinator.inner.less_computer_voice.lock().as_ref(), + Some(LessComputerHostCapture::Recording(session)) if session.session_id() == second + )); + assert!(!voice_states(&mut events).iter().any(|kind| matches!( + kind, + LessComputerEventKind::User { .. } | LessComputerEventKind::Started + ))); + coordinator + .cancel_less_computer_voice_from_panel(second) + .await + .unwrap(); + wait_for_released_capture(&coordinator, &recorder, 2).await; + drop(coordinator); + std::fs::remove_dir_all(data_dir).unwrap(); + } + + #[tokio::test] + async fn panel_stop_rejects_another_session_during_cold_start() { + let (coordinator, _recorder, data_dir) = + fixture_coordinator(crate::types::HotkeyMode::Hold, std::time::Duration::ZERO); + let owner = openless_core::SessionId::new(); + let control = Arc::new(LessComputerRecordingControl::new(&coordinator.inner)); + *coordinator.inner.less_computer_voice.lock() = + Some(LessComputerHostCapture::Starting(owner, control.clone())); + assert!(!request_less_computer_voice_stop_with( + &coordinator.inner, + openless_core::SessionId::new(), + |_| panic!("a stale cold-start stop must not schedule an effect"), + ) + .unwrap()); + assert!(control.pending.lock().is_empty()); + assert!( + request_less_computer_voice_stop_with(&coordinator.inner, owner, |_| { + panic!("cold-start stop belongs in the handoff queue") + }) + .unwrap() + ); + assert!(matches!( + control.pending.lock().as_slice(), + [(id, openless_core::RecordingControlAction::Stop)] if *id == owner + )); + coordinator.inner.less_computer_voice.lock().take(); + drop(control); + drop(coordinator); + std::fs::remove_dir_all(data_dir).unwrap(); + } +} + +#[cfg(all(test, target_os = "windows"))] +pub(crate) mod windows_less_computer_tests { + pub(crate) use super::less_computer_test_support::fixture_coordinator; + use super::*; + #[tokio::test] async fn translation_stopped_outside_the_hotkey_does_not_leak_to_the_next_session() { for stop_entry in ["button", "cli", "silence"] { @@ -2465,33 +2908,6 @@ pub(crate) mod windows_less_computer_tests { std::fs::remove_dir_all(data_dir).unwrap(); } - struct DelayedFixtureRecorder { - recorder: Arc, - startup_delay: std::time::Duration, - } - - impl openless_core::AudioRecorder for DelayedFixtureRecorder { - fn start( - &self, - session_id: openless_core::SessionId, - context: Arc, - consumer: Arc, - progress: Arc, - ) -> futures_util::future::BoxFuture< - 'static, - Result, openless_core::BackendError>, - > { - let recorder = self.recorder.clone(); - let delay = self.startup_delay; - Box::pin(async move { - tokio::time::sleep(delay).await; - recorder - .start(session_id, context, consumer, progress) - .await - }) - } - } - #[tokio::test] async fn escape_after_toggle_release_allows_the_next_less_recording() { let (coordinator, recorder, data_dir) = diff --git a/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs index f9176f218..df8102544 100644 --- a/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs +++ b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs @@ -47,14 +47,17 @@ impl Coordinator { .update_binding(binding) .map_err(|error| error.to_string())?; } else { - let (tx, rx) = mpsc::channel(); - let monitor = - crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) - .map_err(|error| error.to_string())?; + let (tx, rx) = mpsc::channel::(); + let combo_tx = + spawn_combo_abort_bridge(&inner, handle_trigger_combined); + let monitor = crate::side_aware_combo::SideAwareComboMonitor::start( + binding, tx, combo_tx, + ) + .map_err(|error| error.to_string())?; let bridge_inner = Arc::clone(&inner); std::thread::Builder::new() .name("openless-side-combo-bridge".into()) - .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) + .spawn(move || hotkey_bridge_loop(bridge_inner, rx)) .map_err(|error| error.to_string())?; *slot = Some(monitor); } diff --git a/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs b/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs new file mode 100644 index 000000000..a5961b90e --- /dev/null +++ b/openless-all/app/src-tauri/src/coordinator/restore_runtime.rs @@ -0,0 +1,350 @@ +//! Journal-fenced reconciliation of actual Host state. This is deliberately +//! separate from ordinary settings persistence: credentials and preferences +//! have already been restored under the exclusive source permit. +use super::*; +use futures_util::future::BoxFuture; +use openless_core::{BackendError, BackendErrorCode}; +use std::sync::Weak; + +struct RestoreHost { + coordinator: Weak, +} + +fn failure(reason: &'static str) -> BackendError { + BackendError::new(BackendErrorCode::Platform, reason) +} + +impl openless_core::config::RestoreRuntimeEffects for RestoreHost { + fn apply_target( + &self, + target: crate::types::UserPreferences, + ) -> BoxFuture<'static, Result<(), BackendError>> { + let weak = self.coordinator.clone(); + Box::pin(async move { + let inner = weak + .upgrade() + .ok_or_else(|| failure("restore_host_unavailable"))?; + openless_core::reject_hotkey_collisions(&target) + .map_err(|_| failure("restore_hotkey_conflict"))?; + let coord = Coordinator { + inner: Arc::clone(&inner), + }; + let work_target = target.clone(); + let work_inner = Arc::clone(&inner); + // Ordinary settings transactions take this same Host lock and are + // refused by Core's restoring guard before preparing new effects. + inner + .host + .spawn_blocking(move || { + let coord = Coordinator { inner: work_inner }; + let _host_guard = coord.lock_settings_host(); + #[cfg(target_os = "windows")] + crate::windows_ime_profile::apply_windows_openless_keyboard_list( + openless_core::WindowsKeyboardRuntimeTarget::from(&work_target) + .openless_language_profile_enabled, + ) + .map_err(|_| failure("restore_windows_keyboard_failed"))?; + #[cfg(any(target_os = "macos", target_os = "windows"))] + reconcile_hotkeys(&coord, (&work_target).into())?; + #[cfg(not(any( + target_os = "macos", + target_os = "windows", + target_os = "android", + target_os = "ios" + )))] + return Err(failure("restore_host_platform_unsupported")); + crate::net::set_use_system_proxy(work_target.use_system_proxy); + coord + .inner + .host + .cache_capsule_style(work_target.capsule_style); + Ok::<_, BackendError>(()) + }) + .await + .map_err(|_| failure("restore_host_task_interrupted"))??; + + #[cfg(target_os = "android")] + match target.android_overlay_trigger.normalized() { + crate::types::AndroidOverlayTrigger::Always => { + crate::android::replace_android_overlay() + } + crate::types::AndroidOverlayTrigger::Background + | crate::types::AndroidOverlayTrigger::Keyboard => { + crate::android::hide_android_overlay() + } + } + .map_err(|_| failure("restore_android_overlay_failed"))?; + + // Enabled remains receiver-local consent. Its restored port still + // has to reach the actual listener; configure owns stop/start and + // exposes bind failures to the journal's rollback path. + reconcile_remote_input( + inner.backend.services().remote_input.as_ref(), + target.remote_input_enabled, + target.remote_input_port, + ) + .await?; + // The presentation cache enqueues a native refresh. A final main + // thread acknowledgement keeps that refresh inside the fence. + let (send, receive) = tokio::sync::oneshot::channel(); + inner + .host + .run_on_main_thread(move || { + let _ = send.send(()); + }) + .map_err(|_| failure("restore_main_thread_unavailable"))?; + receive + .await + .map_err(|_| failure("restore_main_thread_interrupted"))?; + coord.backend().reset_restored_runtime_preferences(); + Ok(()) + }) + } +} + +async fn reconcile_remote_input( + remote: &dyn openless_core::domains::RemoteInputApi, + enabled: bool, + port: u16, +) -> Result<(), BackendError> { + match remote.status() { + Ok(status) => { + if status.enabled != enabled || status.port != port || (enabled && !status.running) { + remote + .configure(openless_core::RemoteInputConfig { enabled, port }) + .await + .map_err(|_| failure("restore_remote_input_failed"))?; + } + } + Err(error) if error.code == BackendErrorCode::Unsupported && !enabled => {} + Err(_) => return Err(failure("restore_remote_input_unavailable")), + } + Ok(()) +} + +impl Coordinator { + pub fn bind_restore_runtime_effects(&self) -> Result<(), BackendError> { + if self.startup_error().is_some() { + return Ok(()); + } + if !self.inner.host.is_bound() { + return Err(failure("restore_host_not_bound")); + } + self.inner + .backend + .bind_restore_runtime_effects(Arc::new(RestoreHost { + coordinator: Arc::downgrade(&self.inner), + })) + } +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn reconcile_hotkeys( + coord: &Coordinator, + target: openless_core::HotkeyRuntimeTarget, +) -> Result<(), BackendError> { + *coord.inner.hotkey_runtime_target.lock() = target.clone(); + let trigger = crate::shortcut_binding::legacy_modifier_trigger(&target.dictation); + let binding = crate::types::HotkeyBinding { + trigger: trigger.unwrap_or(crate::types::HotkeyTrigger::Custom), + mode: target.dictation_mode, + keys: None, + }; + coord + .try_ensure_modifier_hotkey_monitor(binding) + .map_err(|_| failure("restore_dictation_monitor_failed"))?; + let (send, receive) = mpsc::sync_channel(1); + let inner = Arc::clone(&coord.inner); + // No timeout success and no abandoned queued registration: keep waiting + // for this exact callback before the journal can roll back another target. + coord + .inner + .host + .run_on_main_thread(move || { + let result = reconcile_hotkeys_on_main(&inner, &target); + let _ = send.send(result); + }) + .map_err(|_| failure("restore_main_thread_unavailable"))?; + receive + .recv() + .map_err(|_| failure("restore_main_thread_interrupted"))? + .map_err(|_| failure("restore_hotkeys_failed"))?; + coord + .try_update_selection_polish_hotkey_binding() + .map_err(|_| failure("restore_selection_hotkey_failed"))?; + coord + .update_coding_agent_hotkey_binding() + .map_err(|_| failure("restore_agent_hotkey_failed"))?; + Ok(()) +} + +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn reconcile_hotkeys_on_main( + inner: &Arc, + target: &openless_core::HotkeyRuntimeTarget, +) -> Result<(), String> { + // Release old combinations together before applying an absolute target; + // this also permits swapping two bindings during a restore. + inner.combo_hotkey.lock().take(); + inner.qa_hotkey.lock().take(); + inner.translation_hotkey.lock().take(); + inner.switch_style_hotkey.lock().take(); + inner.open_app_hotkey.lock().take(); + inner.quick_note_hotkey.lock().take(); + inner.style_pack_hotkeys.lock().clear(); + inner.selection_polish_hotkey.lock().take(); + inner.coding_agent_combo_hotkey.lock().take(); + let trigger = crate::shortcut_binding::legacy_modifier_trigger(&target.dictation); + if trigger.is_some() || is_unconfigured_shortcut(&target.dictation) { + inner.side_aware_combo.lock().take(); + } else if crate::shortcut_binding::binding_requires_side_aware_hook(&target.dictation) { + let mut side_slot = inner.side_aware_combo.lock(); + if let Some(monitor) = side_slot.as_ref() { + monitor + .update_binding(target.dictation.clone()) + .map_err(|error| error.to_string())?; + } else { + let (send, receive) = mpsc::channel(); + let combined = spawn_combo_abort_bridge(inner, handle_trigger_combined); + let monitor = crate::side_aware_combo::SideAwareComboMonitor::start( + target.dictation.clone(), + send, + combined, + ) + .map_err(|error| error.to_string())?; + let owned = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-side-combo-bridge".into()) + .spawn(move || hotkey_bridge_loop(owned, receive)) + .map_err(|error| error.to_string())?; + *side_slot = Some(monitor); + } + } else { + inner.side_aware_combo.lock().take(); + let (send, receive) = mpsc::channel(); + let monitor = ComboHotkeyMonitor::start(target.dictation.clone(), send) + .map_err(|error| error.to_string())?; + let owned = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-combo-hotkey-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(owned, receive)) + .map_err(|error| error.to_string())?; + *inner.combo_hotkey.lock() = Some(monitor); + } + if let Some(binding) = target.qa.as_ref().filter(|binding| { + crate::shortcut_binding::legacy_modifier_trigger(binding).is_none() + && !is_unconfigured_shortcut(binding) + }) { + let (send, receive) = mpsc::channel(); + let monitor = + QaHotkeyMonitor::start(binding.clone(), send).map_err(|error| error.to_string())?; + let owned = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-qa-hotkey-bridge".into()) + .spawn(move || qa_hotkey_bridge_loop(owned, receive)) + .map_err(|error| error.to_string())?; + *inner.qa_hotkey.lock() = Some(monitor); + } + if !is_builtin_translation_shift(&target.translation) + && crate::shortcut_binding::legacy_modifier_trigger(&target.translation).is_none() + && !is_unconfigured_shortcut(&target.translation) + { + update_translation_hotkey_on_main_thread(Arc::clone(inner), target.translation.clone()) + .map_err(|error| error.to_string())?; + } + for kind in [ + ActionHotkeyKind::SwitchStyle, + ActionHotkeyKind::OpenApp, + ActionHotkeyKind::QuickNote, + ] { + let Some(binding) = action_hotkey_binding(inner, kind) else { + continue; + }; + if is_unconfigured_shortcut(&binding) { + continue; + } + if is_modifier_only_shortcut(&binding) { + return Err("unsupported action hotkey".into()); + } + let (send, receive) = mpsc::channel(); + let monitor = + ComboHotkeyMonitor::start(binding, send).map_err(|error| error.to_string())?; + let owned = Arc::clone(inner); + std::thread::Builder::new() + .name(action_hotkey_bridge_thread_name(kind).into()) + .spawn(move || action_hotkey_bridge_loop(owned, receive, kind)) + .map_err(|error| error.to_string())?; + *action_hotkey_slot(inner, kind).lock() = Some(monitor); + } + sync_style_pack_hotkeys(inner)?; + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa, selection, translation) = modifier_shortcut_triggers(inner); + monitor.update_modifier_shortcuts(qa, selection, translation); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use openless_core::domains::RemoteInputApi; + + fn remote() -> ( + openless_core::RemoteInputService, + Arc, + ) { + let runtime = Arc::new(openless_core::testing::RecordingRemoteInputRuntime::default()); + let service = openless_core::RemoteInputService::new(runtime.clone(), 8443, "en").unwrap(); + let publisher_owner = openless_core::OpenLessBackend::blocked_startup( + openless_core::BackendConfig::default(), + failure("fixture has no external adapters"), + ); + service.bind_event_publisher(publisher_owner.event_publisher()); + (service, runtime) + } + + #[tokio::test] + async fn restore_host_running_remote_port_and_rollback_reach_the_listener() { + let (remote, runtime) = remote(); + remote + .configure(openless_core::RemoteInputConfig { + enabled: true, + port: 8443, + }) + .await + .unwrap(); + reconcile_remote_input(&remote, true, 9443).await.unwrap(); + assert!(remote.status().unwrap().running); + assert_eq!(remote.status().unwrap().port, 9443); + assert_eq!(runtime.server_start_count(), 2); + assert_eq!(runtime.server_stop_count(), 1); + // The journal invokes the same absolute callback with its old target. + reconcile_remote_input(&remote, true, 8443).await.unwrap(); + assert!(remote.status().unwrap().running); + assert_eq!(remote.status().unwrap().port, 8443); + assert_eq!(runtime.server_start_count(), 3); + assert_eq!(runtime.server_stop_count(), 2); + } + + #[tokio::test] + async fn restore_host_invalid_remote_target_is_an_error_and_disabled_capability_is_explicit() { + let (remote, runtime) = remote(); + remote + .configure(openless_core::RemoteInputConfig { + enabled: true, + port: 8443, + }) + .await + .unwrap(); + assert!(reconcile_remote_input(&remote, true, 0).await.is_err()); + assert_eq!(remote.status().unwrap().port, 8443); + assert_eq!(runtime.server_stop_count(), 0); + let unsupported = openless_core::domains::UnsupportedDomainServices; + reconcile_remote_input(&unsupported, false, 8443) + .await + .unwrap(); + assert!(reconcile_remote_input(&unsupported, true, 8443) + .await + .is_err()); + } +} diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index 4b0a3addc..ecedfc216 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -2826,6 +2826,130 @@ where } } +/// Worker creation follows an already completed TIS switch. Roll back that +/// switch on startup failure, retaining the original error if rollback fails. +#[cfg(target_os = "macos")] +async fn start_worker_restoring_on_error( + previous: P, + start: impl FnOnce() -> Result, + restore: impl FnOnce(P) -> F, +) -> Result<(P, W), BackendError> +where + F: std::future::Future>, +{ + match start() { + Ok(worker) => Ok((previous, worker)), + Err(error) => { + if let Err(restore_error) = restore(previous).await { + log::warn!("[core-adapter] restore input source after worker startup failed: {restore_error}"); + } + Err(error) + } + } +} + +#[cfg(target_os = "macos")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MacInsertionCompletion { + Finished(InsertOutcome), + Cancelled, +} + +/// TIS restoration belongs to the terminal effect, never to an individual +/// caller's future. Repeated finish/cancel callers join the same result. +#[cfg(target_os = "macos")] +#[derive(Default)] +struct MacInsertionTerminal { + started: AtomicBool, + result: std::sync::OnceLock>, + ready: tokio::sync::Notify, +} + +#[cfg(target_os = "macos")] +impl MacInsertionTerminal { + fn settle(&self, result: Result) { + if self.result.set(result).is_ok() { + self.ready.notify_waiters(); + } + } + + async fn join_or_spawn( + self: &Arc, + effect: impl FnOnce() -> F + Send + 'static, + spawn: impl FnOnce(BoxFuture<'static, ()>), + ) -> Result + where + F: std::future::Future> + + Send + + 'static, + { + if self + .started + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_ok() + { + let guard = MacInsertionTerminalGuard(Arc::clone(self)); + spawn(Box::pin(async move { + guard.settle(effect().await); + })); + } + loop { + let notified = self.ready.notified(); + if let Some(result) = self.result.get() { + return result.clone(); + } + notified.await; + } + } +} + +#[cfg(target_os = "macos")] +struct MacInsertionTerminalGuard(Arc); + +#[cfg(target_os = "macos")] +impl MacInsertionTerminalGuard { + fn settle(&self, result: Result) { + self.0.settle(result); + } +} + +#[cfg(target_os = "macos")] +impl Drop for MacInsertionTerminalGuard { + fn drop(&mut self) { + self.0.settle(Err(BackendError::new( + BackendErrorCode::Internal, + "macOS insertion finalization did not complete", + ))); + } +} + +/// Preserve the existing written-text outcome on TIS restoration failure, but +/// always restore after a failed delivery barrier as well. A barrier error must +/// not cause a second insertion of text that may already have been posted. +#[cfg(target_os = "macos")] +async fn finish_mac_insertion_after_barrier( + barrier: B, + effect: impl FnOnce() -> E, + restore: impl FnOnce() -> R, +) -> Result +where + B: std::future::Future>, + E: std::future::Future>, + R: std::future::Future>, +{ + let result = match barrier.await { + Ok(()) => effect().await, + Err(error) => Err(error), + }; + if let Err(error) = restore().await { + log::warn!("[core-adapter] restore input state after insertion failed: {error}"); + if matches!(result, Ok(MacInsertionCompletion::Cancelled)) { + return Err(error); + } + } + result +} + impl CoreTextInserter for TauriTextInserter { fn capture_target(&self) -> Option> { Some(Arc::new(Self { @@ -2869,7 +2993,7 @@ impl CoreTextInserter for TauriTextInserter { None }; #[cfg(target_os = "macos")] - let (app_handle, previous_input_source, streaming_ready) = { + let (app_handle, mut previous_input_source, streaming_ready) = { let app_handle = app.lock().clone().ok_or_else(|| { BackendError::new( BackendErrorCode::InvalidState, @@ -2890,6 +3014,36 @@ impl CoreTextInserter for TauriTextInserter { .await; (app_handle, previous, streaming_ready) }; + #[cfg(target_os = "macos")] + let cancel_requested = Arc::new(AtomicBool::new(false)); + #[cfg(target_os = "macos")] + let streaming_worker = if streaming_ready { + let (previous, worker) = start_worker_restoring_on_error( + previous_input_source, + || { + crate::macos_streaming_input::MacStreamingInput::spawn( + insertion_target.clone(), + context.insertion.macos_newline_mode, + Arc::clone(&cancel_requested), + ) + }, + |previous| { + let app_handle = &app_handle; + async move { + crate::unicode_keystroke::restore_input_source(app_handle, previous) + .await + .map_err(|error| { + BackendError::new(BackendErrorCode::Platform, error.to_string()) + }) + } + }, + ) + .await?; + previous_input_source = previous; + Some(worker) + } else { + None + }; #[cfg(not(target_os = "macos"))] let _ = app; Ok(Arc::new(TauriTextInsertionSession { @@ -2908,7 +3062,11 @@ impl CoreTextInserter for TauriTextInserter { #[cfg(target_os = "macos")] streaming_ready, #[cfg(target_os = "macos")] - confirm_keyboard_delivery: Arc::new(AtomicBool::new(true)), + streaming_worker, + #[cfg(target_os = "macos")] + cancel_requested, + #[cfg(target_os = "macos")] + terminal: Arc::new(MacInsertionTerminal::default()), }) as Arc) }) } @@ -2931,7 +3089,11 @@ struct TauriTextInsertionSession { #[cfg(target_os = "macos")] streaming_ready: bool, #[cfg(target_os = "macos")] - confirm_keyboard_delivery: Arc, + streaming_worker: Option, + #[cfg(target_os = "macos")] + cancel_requested: Arc, + #[cfg(target_os = "macos")] + terminal: Arc, } impl TauriTextInsertionSession { @@ -2947,66 +3109,47 @@ impl TauriTextInsertionSession { } async fn write_chunk(&self, text: String) -> Result { + #[cfg(target_os = "macos")] + { + // A write future may have been created before a terminal caller + // sealed the session but only polled afterwards. + if self.finished.load(Ordering::Acquire) { + return Err(BackendError::new( + BackendErrorCode::Cancelled, + "text insertion session is closed", + )); + } + let worker = self.streaming_worker.as_ref().ok_or_else(|| { + BackendError::new( + BackendErrorCode::Unsupported, + "macOS streaming insertion is unavailable", + ) + })?; + worker.write(text).await + } + #[cfg(not(target_os = "macos"))] self.restore_insertion_target()?; - #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] + #[cfg(any(target_os = "windows", target_os = "linux"))] { let chunk = text.clone(); #[cfg(target_os = "windows")] let newline_mode = self.context.insertion.windows_sendinput_newline_mode; - #[cfg(target_os = "macos")] - let newline_mode = self.context.insertion.macos_newline_mode; - #[cfg(target_os = "macos")] - let confirm_delivery = Arc::clone(&self.confirm_keyboard_delivery); let finished = Arc::clone(&self.finished); let written = tauri::async_runtime::spawn_blocking(move || { if finished.load(Ordering::Acquire) { return 0; } - // CGEventPost returns before the target has consumed its input. - // Retain the original control before posting; inspect only its - // caret, on this blocking thread, before completing the write. - #[cfg(target_os = "macos")] - let delivery = if confirm_delivery.load(Ordering::Acquire) - && !(newline_mode == crate::types::MacosNewlineMode::Return - && chunk.contains('\n')) - { - crate::host_document::KeyboardDelivery::capture() - } else { - None - }; - #[cfg(target_os = "macos")] - if delivery - .as_ref() - .is_some_and(|delivery| !delivery.is_focused()) - { - return 0; - } #[cfg(target_os = "windows")] let result = crate::unicode_keystroke::type_unicode_chunk_with_options( &chunk, crate::unicode_keystroke::WindowsSendInputOptions { newline_mode }, ); - #[cfg(target_os = "macos")] - let result = - crate::unicode_keystroke::type_unicode_chunk_with_options(&chunk, newline_mode); #[cfg(target_os = "linux")] let result = crate::unicode_keystroke::type_unicode_chunk(&chunk); let written = match result { Ok(written) => written, Err(error) => error.typed_chars(), }; - #[cfg(target_os = "macos")] - { - let delivered = delivery.is_some_and(|delivery| { - let posted: String = chunk.chars().take(written).collect(); - delivery.wait(&posted) - }); - // Unsupported/stalled controls are tried once per session, - // so a missing AX caret cannot add a delay to every delta. - if !delivered { - confirm_delivery.store(false, Ordering::Release); - } - } written }) .await @@ -3148,6 +3291,56 @@ impl TauriTextInsertionSession { } Ok(()) } + + #[cfg(target_os = "macos")] + async fn finalize_mac( + self, + final_text: Option, + ) -> Result { + self.finished.store(true, Ordering::Release); + if final_text.is_none() { + // Separate from the normal finished latch: normal finish must not + // cancel Write commands already accepted by the worker's queue. + self.cancel_requested.store(true, Ordering::Release); + } + let terminal = Arc::clone(&self.terminal); + terminal + .join_or_spawn( + move || async move { + let worker = self.streaming_worker.clone(); + let session = &self; + finish_mac_insertion_after_barrier( + async move { + match worker { + Some(worker) => worker.finish().await, + None => Ok(()), + } + }, + move || async move { + if session.cancel_requested.load(Ordering::Acquire) { + return Ok(MacInsertionCompletion::Cancelled); + } + match final_text { + Some(text) if !text.is_empty() => session + .insert_final(text) + .await + .map(MacInsertionCompletion::Finished), + Some(_) => { + Ok(MacInsertionCompletion::Finished(InsertOutcome::Inserted)) + } + None => Ok(MacInsertionCompletion::Cancelled), + } + }, + || session.restore_platform_state(), + ) + .await + }, + |task| { + tauri::async_runtime::spawn(task); + }, + ) + .await + } } impl TextInsertionSession for TauriTextInsertionSession { @@ -3186,37 +3379,254 @@ impl TextInsertionSession for TauriTextInsertionSession { ) -> BoxFuture<'static, Result> { let session = self.clone(); Box::pin(async move { - if session.finished.swap(true, Ordering::AcqRel) { - return Err(BackendError::new( - BackendErrorCode::InvalidState, - "text insertion session is already closed", - )); + #[cfg(target_os = "macos")] + { + match session.finalize_mac(Some(final_text)).await? { + MacInsertionCompletion::Finished(outcome) => Ok(outcome), + MacInsertionCompletion::Cancelled => Err(BackendError::new( + BackendErrorCode::Cancelled, + "text insertion session was cancelled before completion", + )), + } } - let result = if final_text.is_empty() { - Ok(InsertOutcome::Inserted) - } else { - session.insert_final(final_text).await - }; - if let Err(error) = session.restore_platform_state().await { - // 恢复输入源失败并不能撤销已经落下的文字。保留真实交付结果, - // 避免历史误报失败后诱导用户重试造成重复;无论插入成败都记录恢复错误。 - log::warn!("[core-adapter] restore input state after insertion failed: {error}"); + #[cfg(not(target_os = "macos"))] + { + if session.finished.swap(true, Ordering::AcqRel) { + return Err(BackendError::new( + BackendErrorCode::InvalidState, + "text insertion session is already closed", + )); + } + let result = if final_text.is_empty() { + Ok(InsertOutcome::Inserted) + } else { + session.insert_final(final_text).await + }; + if let Err(error) = session.restore_platform_state().await { + // 恢复输入源失败并不能撤销已经落下的文字。保留真实交付结果, + // 避免历史误报失败后诱导用户重试造成重复;无论插入成败都记录恢复错误。 + log::warn!( + "[core-adapter] restore input state after insertion failed: {error}" + ); + } + result } - result }) } fn cancel(&self) -> BoxFuture<'static, Result<(), BackendError>> { let session = self.clone(); Box::pin(async move { - if session.finished.swap(true, Ordering::AcqRel) { - return Ok(()); + #[cfg(target_os = "macos")] + { + session.finalize_mac(None).await.map(|_| ()) + } + #[cfg(not(target_os = "macos"))] + { + if session.finished.swap(true, Ordering::AcqRel) { + return Ok(()); + } + session.restore_platform_state().await } - session.restore_platform_state().await }) } } +#[cfg(all(test, target_os = "macos"))] +mod mac_insertion_lifecycle_tests { + use super::*; + + fn spawn(task: BoxFuture<'static, ()>) { + tokio::spawn(task); + } + + #[tokio::test] + async fn worker_start_failure_restores_the_previous_source_once() { + let restored = Arc::new(Mutex::new(Vec::new())); + let error = BackendError::new(BackendErrorCode::Platform, "worker unavailable"); + let result = start_worker_restoring_on_error( + 7, + || Err::<(), _>(error.clone()), + |token| { + let restored = &restored; + async move { + restored.lock().push(token); + Ok(()) + } + }, + ) + .await; + assert_eq!(result.unwrap_err(), error); + assert_eq!(*restored.lock(), [7]); + assert_eq!( + start_worker_restoring_on_error( + 8, + || Ok(9), + |_| async { + panic!("successful startup must retain the source for terminal cleanup") + } + ) + .await + .unwrap(), + (8, 9) + ); + } + + #[tokio::test] + async fn failed_barrier_skips_insertion_but_still_restores() { + let actions = Mutex::new(Vec::new()); + let error = BackendError::new(BackendErrorCode::Internal, "worker stopped"); + let result = finish_mac_insertion_after_barrier( + async { + actions.lock().push("barrier"); + Err(error.clone()) + }, + || async { panic!("uncertain posted input must not be inserted again") }, + || async { + actions.lock().push("restore"); + Ok(()) + }, + ) + .await; + assert_eq!(result.unwrap_err(), error); + assert_eq!(*actions.lock(), ["barrier", "restore"]); + } + + #[tokio::test] + async fn failed_final_insertion_still_restores_and_preserves_its_error() { + let actions = Mutex::new(Vec::new()); + let error = BackendError::new(BackendErrorCode::Platform, "target unavailable"); + let result = finish_mac_insertion_after_barrier( + async { + actions.lock().push("barrier"); + Ok(()) + }, + || async { + actions.lock().push("insert"); + Err(error.clone()) + }, + || async { + actions.lock().push("restore"); + Err(BackendError::new( + BackendErrorCode::Platform, + "TIS unavailable", + )) + }, + ) + .await; + assert_eq!(result.unwrap_err(), error); + assert_eq!(*actions.lock(), ["barrier", "insert", "restore"]); + } + + #[tokio::test] + async fn restoration_error_keeps_written_outcome_but_is_reported_on_cancel() { + let error = BackendError::new(BackendErrorCode::Platform, "TIS unavailable"); + let completed = MacInsertionCompletion::Finished(InsertOutcome::Inserted); + assert_eq!( + finish_mac_insertion_after_barrier( + async { Ok(()) }, + || async { Ok(completed) }, + || async { Err(error.clone()) }, + ) + .await + .unwrap(), + completed + ); + assert_eq!( + finish_mac_insertion_after_barrier( + async { Ok(()) }, + || async { Ok(MacInsertionCompletion::Cancelled) }, + || async { Err(error.clone()) }, + ) + .await + .unwrap_err(), + error + ); + } + + async fn assert_terminal_join(completion: MacInsertionCompletion, drop_first: bool) { + let terminal = Arc::new(MacInsertionTerminal::default()); + let actions = Arc::new(Mutex::new(Vec::new())); + let entered = Arc::new(tokio::sync::Semaphore::new(0)); + let release = Arc::new(tokio::sync::Semaphore::new(0)); + let observed = Arc::clone(&actions); + let start = Arc::clone(&entered); + let gate = Arc::clone(&release); + let mut first = Box::pin(terminal.join_or_spawn( + move || async move { + let observed = &observed; + finish_mac_insertion_after_barrier( + async { + observed.lock().push("barrier started"); + start.add_permits(1); + gate.acquire().await.unwrap().forget(); + observed.lock().push("barrier drained"); + Ok(()) + }, + || async { + observed.lock().push("effect"); + Ok(completion) + }, + || async { + observed.lock().push("source restored"); + Ok(()) + }, + ) + .await + }, + spawn, + )); + assert!(futures_util::poll!(first.as_mut()).is_pending()); + entered.acquire().await.unwrap().forget(); + assert_eq!(*actions.lock(), ["barrier started"]); + let mut second = std::pin::pin!(terminal.join_or_spawn( + || async { panic!("duplicate terminal call must not repeat effects") }, + spawn, + )); + assert!(futures_util::poll!(second.as_mut()).is_pending()); + let first = if drop_first { + drop(first); + None + } else { + Some(first) + }; + release.add_permits(1); + assert_eq!(second.await.unwrap(), completion); + if let Some(first) = first { + assert_eq!(first.await.unwrap(), completion); + } + assert_eq!( + *actions.lock(), + [ + "barrier started", + "barrier drained", + "effect", + "source restored" + ] + ); + } + + #[tokio::test] + async fn repeated_finish_and_cancel_wait_for_the_same_terminal_barrier() { + for completion in [ + MacInsertionCompletion::Finished(InsertOutcome::Inserted), + MacInsertionCompletion::Cancelled, + ] { + assert_terminal_join(completion, false).await; + } + } + + #[tokio::test] + async fn dropping_the_initial_terminal_future_does_not_skip_restore() { + for completion in [ + MacInsertionCompletion::Finished(InsertOutcome::Inserted), + MacInsertionCompletion::Cancelled, + ] { + assert_terminal_join(completion, true).await; + } + } +} + #[cfg(target_os = "windows")] fn windows_unicode_fallback(context: &DictationContext, text: &str) -> crate::types::InsertStatus { let inserter = crate::insertion::TextInserter::new(); diff --git a/openless-all/app/src-tauri/src/host_document/macos.rs b/openless-all/app/src-tauri/src/host_document/macos.rs index e1b282e8d..acaa75390 100644 --- a/openless-all/app/src-tauri/src/host_document/macos.rs +++ b/openless-all/app/src-tauri/src/host_document/macos.rs @@ -381,12 +381,12 @@ unsafe fn copy_selected_range(focused: AxUiElementRef) -> Option { (ok != 0).then_some(range) } -/// Confirms a posted keyboard chunk against the original text control's caret. +/// Confirms all posted keyboard input against the original text control's caret. /// Only metadata is read; the target's document text is never fetched. /// Create, wait and drop on the same blocking insertion thread. pub(crate) struct KeyboardDelivery { element: AxUiElementRef, - start: usize, + progress: KeyboardDeliveryProgress, } impl KeyboardDelivery { @@ -402,9 +402,10 @@ impl KeyboardDelivery { let GatedElement::Ready(element) = focused_element_passing_the_gate(gate) else { return None; }; - let mut delivery = Self { element, start: 0 }; - delivery.start = copy_caret_offset(element)?; - Some(delivery) + Some(Self { + element, + progress: KeyboardDeliveryProgress::new(copy_caret_offset(element)), + }) } } @@ -426,38 +427,48 @@ impl KeyboardDelivery { } } - /// False means this target cannot be confirmed; stop probing it for this session. - pub(crate) fn wait(self, posted_text: &str) -> bool { + /// Account only for the prefix actually posted by the native typer. This + /// never reads AX or waits, so another streamed chunk can follow immediately. + pub(crate) fn record_posted( + &mut self, + posted_text: &str, + newline_mode: crate::types::MacosNewlineMode, + ) { + self.progress.record_posted(posted_text, newline_mode); + } + + /// One terminal delivery barrier, on the same thread that captured the + /// element. Unreadable/stale controls keep the existing posted-input fallback. + pub(crate) fn finish(self) -> KeyboardDeliveryOutcome { let started = Instant::now(); - let outcome = wait_for_caret_delivery( - self.start, - posted_text, - || { - if crate::unicode_keystroke::is_secure_input_enabled() { - return None; - } - // SAFETY: self retains the same control throughout this wait. - unsafe { copy_selected_range(self.element) }.and_then(|range| { - if range.length < 0 { + let outcome = match self.progress.expected() { + DeliveryExpectation::NothingPosted => KeyboardDeliveryOutcome::Delivered, + DeliveryExpectation::Unavailable => KeyboardDeliveryOutcome::Unavailable, + DeliveryExpectation::Caret { start, expected } => wait_for_caret_delivery( + start, + expected, + || { + if crate::unicode_keystroke::is_secure_input_enabled() { return None; } - caret_offset_from_location(range.location).map(|offset| (offset, range.length)) - }) - }, - || { - if started.elapsed() >= Duration::from_secs(10) { - return false; - } - std::thread::sleep(Duration::from_millis(10)); - true - }, + // SAFETY: self retains the original control on this worker. + unsafe { copy_selected_range(self.element) }.and_then(|range| { + if range.length < 0 { + return None; + } + caret_offset_from_location(range.location) + .map(|offset| (offset, range.length)) + }) + }, + || started.elapsed(), + || std::thread::sleep(Duration::from_millis(10)), + ), + }; + log::info!( + "[insertion] final keyboard delivery outcome={outcome:?} elapsed_ms={}", + started.elapsed().as_millis() ); - if outcome == KeyboardDeliveryOutcome::TimedOut { - log::warn!( - "[insertion] target caret did not acknowledge posted keyboard input within 10s" - ); - } - outcome == KeyboardDeliveryOutcome::Delivered + outcome } } @@ -468,118 +479,250 @@ impl Drop for KeyboardDelivery { } } -#[derive(Debug, PartialEq, Eq)] -enum KeyboardDeliveryOutcome { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeyboardDeliveryOutcome { Delivered, Unavailable, + NoProgress, TimedOut, } +const DELIVERY_NO_PROGRESS_BUDGET: Duration = Duration::from_millis(250); +const DELIVERY_TOTAL_BUDGET: Duration = Duration::from_secs(10); + +#[derive(Debug, PartialEq, Eq)] +enum DeliveryExpectation { + NothingPosted, + Unavailable, + Caret { start: usize, expected: usize }, +} + +/// Pure accounting, independent of AX ownership. A terminal capture cannot +/// substitute for this cumulative offset: previously posted keys may still queue. +#[derive(Debug)] +struct KeyboardDeliveryProgress { + start: Option, + expected: Option, + posted: bool, +} + +impl KeyboardDeliveryProgress { + fn new(start: Option) -> Self { + Self { + start, + expected: start, + posted: false, + } + } + + fn record_posted(&mut self, text: &str, newline_mode: crate::types::MacosNewlineMode) { + let mut units = 0usize; + for ch in text.chars().filter(|ch| *ch != '\r') { + self.posted = true; + units = units.saturating_add(ch.len_utf16()); + if ch == '\n' && newline_mode == crate::types::MacosNewlineMode::Return { + // A submitting Return can clear the field or move focus. Keep + // its keys, but never wait for a monotonically increasing caret. + self.expected = None; + } + } + self.expected = self.expected.and_then(|offset| offset.checked_add(units)); + } + + fn expected(&self) -> DeliveryExpectation { + if !self.posted { + return DeliveryExpectation::NothingPosted; + } + match (self.start, self.expected) { + (Some(start), Some(expected)) => DeliveryExpectation::Caret { start, expected }, + _ => DeliveryExpectation::Unavailable, + } + } +} + fn wait_for_caret_delivery( start: usize, - posted_text: &str, + expected: usize, mut read: impl FnMut() -> Option<(usize, isize)>, - mut wait: impl FnMut() -> bool, + mut elapsed: impl FnMut() -> Duration, + mut pause: impl FnMut(), ) -> KeyboardDeliveryOutcome { - // AX offsets count UTF-16 units. CR is consumed without posting a key. - let units = posted_text - .chars() - .filter(|ch| *ch != '\r') - .map(char::len_utf16) - .sum(); - if units == 0 { - return KeyboardDeliveryOutcome::Delivered; - } - let Some(expected) = start.checked_add(units) else { - return KeyboardDeliveryOutcome::Unavailable; - }; + let mut high_water = start; + let mut last_progress = Duration::ZERO; loop { + let now = elapsed(); + if now >= DELIVERY_TOTAL_BUDGET { + return KeyboardDeliveryOutcome::TimedOut; + } + if now.saturating_sub(last_progress) >= DELIVERY_NO_PROGRESS_BUDGET { + return KeyboardDeliveryOutcome::NoProgress; + } let Some((offset, selected_length)) = read() else { return KeyboardDeliveryOutcome::Unavailable; }; + let now = elapsed(); + if now >= DELIVERY_TOTAL_BUDGET { + return KeyboardDeliveryOutcome::TimedOut; + } if selected_length == 0 && offset >= expected { return KeyboardDeliveryOutcome::Delivered; } - if !wait() { - return KeyboardDeliveryOutcome::TimedOut; + if offset > high_water { + high_water = offset; + last_progress = now; + } + if now.saturating_sub(last_progress) >= DELIVERY_NO_PROGRESS_BUDGET { + return KeyboardDeliveryOutcome::NoProgress; } + pause(); } } #[cfg(test)] mod keyboard_delivery_tests { use super::*; + use crate::types::MacosNewlineMode; + use std::cell::Cell; + + #[test] + fn cumulative_receipt_counts_only_posted_unicode_and_ignores_swallowed_cr() { + let mut progress = KeyboardDeliveryProgress::new(Some(7)); + assert_eq!(progress.expected(), DeliveryExpectation::NothingPosted); + progress.record_posted("A🙂\r", MacosNewlineMode::ShiftReturn); + progress.record_posted("\n界", MacosNewlineMode::ShiftReturn); + assert_eq!( + progress.expected(), + DeliveryExpectation::Caret { + start: 7, + expected: 12 + } + ); + } + + #[test] + fn submitting_return_and_unknown_offsets_keep_posted_input_fallback() { + let mut progress = KeyboardDeliveryProgress::new(Some(5)); + progress.record_posted("first", MacosNewlineMode::Return); + progress.record_posted("\nsecond", MacosNewlineMode::Return); + progress.record_posted("more", MacosNewlineMode::Return); + assert_eq!(progress.expected(), DeliveryExpectation::Unavailable); + let mut unknown = KeyboardDeliveryProgress::new(None); + unknown.record_posted("text", MacosNewlineMode::ShiftReturn); + assert_eq!(unknown.expected(), DeliveryExpectation::Unavailable); + let mut cr = KeyboardDeliveryProgress::new(None); + cr.record_posted("\r", MacosNewlineMode::Return); + assert_eq!(cr.expected(), DeliveryExpectation::NothingPosted); + } #[test] - fn posted_input_waits_until_target_consumes_the_last_character() { - // A selected range / intermediate caret is not an insertion receipt. + fn delivery_offset_overflow_does_not_wrap_into_a_false_receipt() { + let mut progress = KeyboardDeliveryProgress::new(Some(usize::MAX)); + progress.record_posted("x", MacosNewlineMode::ShiftReturn); + assert_eq!(progress.expected(), DeliveryExpectation::Unavailable); + } + + #[test] + fn final_delivery_waits_for_the_cumulative_end_not_a_selected_range() { let mut samples = [(120, 20), (101, 0), (119, 0), (120, 0)].into_iter(); - let mut waits = 0; + let clock = Cell::new(Duration::ZERO); assert_eq!( wait_for_caret_delivery( 100, - &"字".repeat(20), + 120, || samples.next(), - || { - waits += 1; - true - } + || clock.get(), + || clock.set(clock.get() + Duration::from_millis(10)) ), KeyboardDeliveryOutcome::Delivered ); - assert_eq!(waits, 3); + assert_eq!(clock.get(), Duration::from_millis(30)); } #[test] - fn unavailable_or_stalled_targets_do_not_wait_forever() { + fn stale_readable_caret_stops_after_short_no_progress_budget() { + let clock = Cell::new(Duration::ZERO); assert_eq!( wait_for_caret_delivery( - 0, - "input", - || None, - || panic!("unavailable target must stop") + 100, + 120, + || Some((100, 0)), + || clock.get(), + || clock.set(clock.get() + Duration::from_millis(10)) ), - KeyboardDeliveryOutcome::Unavailable + KeyboardDeliveryOutcome::NoProgress ); - let mut waits = 0; + assert_eq!(clock.get(), DELIVERY_NO_PROGRESS_BUDGET); + } + + #[test] + fn progressing_target_can_take_longer_than_one_no_progress_budget() { + let clock = Cell::new(Duration::ZERO); + let offset = Cell::new(0); assert_eq!( wait_for_caret_delivery( 0, - "input", - || Some((3, 0)), + 5, || { - waits += 1; - waits < 3 - } + offset.set(offset.get() + 1); + Some((offset.get(), 0)) + }, + || clock.get(), + || clock.set(clock.get() + Duration::from_millis(200)) ), - KeyboardDeliveryOutcome::TimedOut + KeyboardDeliveryOutcome::Delivered ); - assert_eq!(waits, 3); + assert_eq!(clock.get(), Duration::from_millis(800)); } #[test] - fn unicode_and_crlf_wait_for_the_actual_utf16_end() { - let mut samples = [(11, 0), (12, 0)].into_iter(); - let mut waits = 0; + fn even_progressing_targets_have_a_hard_deadline() { + let clock = Cell::new(Duration::ZERO); + let offset = Cell::new(0); assert_eq!( wait_for_caret_delivery( - 7, - "A🙂\r\n界", - || samples.next(), + 0, + usize::MAX, || { - waits += 1; - true - } + offset.set(offset.get() + 1); + Some((offset.get(), 0)) + }, + || clock.get(), + || clock.set(clock.get() + Duration::from_millis(100)) ), - KeyboardDeliveryOutcome::Delivered + KeyboardDeliveryOutcome::TimedOut ); + assert_eq!(clock.get(), DELIVERY_TOTAL_BUDGET); + } + + #[test] + fn unavailable_range_never_waits() { assert_eq!( - waits, 1, - "must neither stop at a scalar offset nor wait for swallowed CR" + wait_for_caret_delivery( + 0, + 5, + || None, + || Duration::ZERO, + || panic!("unavailable target must not wait") + ), + KeyboardDeliveryOutcome::Unavailable ); + } + + #[test] + fn a_slow_ax_read_cannot_extend_the_total_deadline() { + let clock = Cell::new(Duration::ZERO); assert_eq!( - wait_for_caret_delivery(7, "\r", || panic!("no keys were posted"), || false), - KeyboardDeliveryOutcome::Delivered + wait_for_caret_delivery( + 0, + 5, + || { + clock.set(DELIVERY_TOTAL_BUDGET); + Some((5, 0)) + }, + || clock.get(), + || panic!("deadline already elapsed"), + ), + KeyboardDeliveryOutcome::TimedOut ); } } diff --git a/openless-all/app/src-tauri/src/host_document/mod.rs b/openless-all/app/src-tauri/src/host_document/mod.rs index ab011509a..d6670a2fb 100644 --- a/openless-all/app/src-tauri/src/host_document/mod.rs +++ b/openless-all/app/src-tauri/src/host_document/mod.rs @@ -29,7 +29,7 @@ mod macos; #[cfg(target_os = "macos")] -pub(crate) use macos::KeyboardDelivery; +pub(crate) use macos::{KeyboardDelivery, KeyboardDeliveryOutcome}; // `minimal_edit` 目前只有 macOS 的观察回调在用,非 macOS 构建下没有消费方。 #[allow(unused_imports)] diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 8b57097b1..b10661e90 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -931,6 +931,7 @@ mod platform { fn handle_key_down(ctx: &CallbackContext, event: CgEventRef) { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; + crate::side_aware_combo::handle_companion_key_down(); if keycode == ESC_KEYCODE { note_companion_key_down(ctx); send_cancel_or_log(&ctx.cancel_tx); @@ -1570,6 +1571,7 @@ mod platform { } let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN); if vk_code == VK_ESCAPE && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) { + crate::side_aware_combo::handle_companion_key_down(); note_companion_key_down(ctx); send_cancel_or_log(&ctx.cancel_tx); // 会话激活期间独占消费 Esc(返回 true → LRESULT(1) 吞掉),宿主应用收不到, @@ -1580,6 +1582,7 @@ mod platform { crate::side_aware_combo::platform::dispatch_vk(vk_code, pressed); if pressed && !is_modifier_vk(vk_code) { + crate::side_aware_combo::handle_companion_key_down(); note_companion_key_down(ctx); } @@ -2153,16 +2156,17 @@ mod platform { #[test] fn windows_shift_side_combo_receives_pressed_via_dispatch_keyboard_event() { - use crate::combo_hotkey::ComboHotkeyEvent; use crate::side_aware_combo::SideAwareComboMonitor; use crate::types::ShortcutBinding; let (combo_tx, combo_rx) = mpsc::channel(); + let (abort_tx, _abort_rx) = mpsc::channel(); let binding = ShortcutBinding { primary: "D".into(), modifiers: vec!["shift-left".into()], }; - let monitor = SideAwareComboMonitor::start(binding, combo_tx).expect("start monitor"); + let monitor = + SideAwareComboMonitor::start(binding, combo_tx, abort_tx).expect("start monitor"); let shared = shared(HotkeyTrigger::Custom); let (ctx, hotkey_rx) = callback_context(shared); @@ -2172,7 +2176,7 @@ mod platform { assert!(matches!( combo_rx.recv().unwrap(), - ComboHotkeyEvent::Pressed { .. } + HotkeyEvent::Pressed { .. } )); assert!(hotkey_rx .try_iter() @@ -2180,6 +2184,54 @@ mod platform { drop(monitor); } + + #[test] + fn windows_modifier_chord_uses_existing_companion_abort_semantics() { + use crate::side_aware_combo::SideAwareComboMonitor; + use crate::types::ShortcutBinding; + + let (tx, rx) = mpsc::channel(); + let (abort_tx, abort_rx) = mpsc::channel(); + let binding = ShortcutBinding { + primary: "ModifierChord".into(), + modifiers: vec!["ctrl-left".into(), "cmd-left".into()], + }; + let monitor = + SideAwareComboMonitor::start(binding, tx, abort_tx).expect("start monitor"); + + let shared = shared(HotkeyTrigger::Custom); + let (ctx, _main_rx) = callback_context(shared); + + dispatch_keyboard_event(&ctx, VK_LCONTROL, WM_KEYDOWN); + assert!(rx.try_recv().is_err()); + dispatch_keyboard_event(&ctx, VK_LWIN, WM_KEYDOWN); + let press_id = match rx.recv().unwrap() { + HotkeyEvent::Pressed { press_id, .. } => press_id, + other => panic!("expected modifier chord Pressed, got {other:?}"), + }; + + dispatch_keyboard_event(&ctx, 0x44, WM_KEYDOWN); + assert!(matches!( + abort_rx.recv().unwrap(), + HotkeyCombinedEdge { + press_id: combined_id, + .. + } if combined_id == press_id + )); + dispatch_keyboard_event(&ctx, 0x44, WM_KEYDOWN); + assert!(abort_rx.try_recv().is_err()); + + dispatch_keyboard_event(&ctx, VK_LWIN, WM_KEYUP); + assert!(matches!( + rx.recv().unwrap(), + HotkeyEvent::Released { + press_id: released_id, + .. + } if released_id == press_id + )); + + drop(monitor); + } } } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 534fcf7d8..828315312 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -33,6 +33,8 @@ mod core_adapters; mod correction; #[cfg(target_os = "macos")] mod macos_dictation_key; +#[cfg(target_os = "macos")] +mod macos_streaming_input; mod qa_adapter; mod tauri_coordinator_host; // 托盘麦克风设备变更监听:macOS CoreAudio / Windows MMDevice 原生通知(空闲零唤醒), @@ -121,6 +123,8 @@ const OPENLESS_BUNDLE_ID: &str = "com.openless.app"; /// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition, /// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。 static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); +#[cfg(target_os = "macos")] +static LESS_COMPUTER_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); /// 聊天面板退场动画的世代计数:hide 先发 `chat-panel:closing` 让前端播 220ms /// 退场动画、240ms 后才真正 hide;期间再次 show 会推进世代,作废挂起的 hide。 static QA_PANEL_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -238,6 +242,26 @@ macro_rules! app_invoke_handler_desktop { commands::cloud_sync_upload, commands::cloud_sync_restore, commands::cloud_sync_delete, + commands::cloud_sync_e2ee_status, + commands::cloud_sync_e2ee_claim_setup_prompt, + commands::cloud_sync_e2ee_prepare_enable, + commands::cloud_sync_e2ee_create, + commands::cloud_sync_e2ee_unlock, + commands::cloud_sync_e2ee_lock, + commands::cloud_sync_e2ee_set_enabled, + commands::cloud_sync_e2ee_sync_now, + commands::cloud_sync_e2ee_cancel, + commands::cloud_sync_e2ee_preview_restore, + commands::cloud_sync_e2ee_apply_restore, + commands::cloud_sync_e2ee_change_password, + commands::cloud_sync_e2ee_delete_remote, + commands::cloud_sync_e2ee_sign_out, + commands::cloud_sync_e2ee_begin_sign_in, + commands::cloud_sync_e2ee_poll_sign_in, + commands::cloud_sync_e2ee_cancel_sign_in, + commands::cloud_sync_e2ee_get_ui_preferences, + commands::cloud_sync_e2ee_get_ui_preferences_snapshot, + commands::cloud_sync_e2ee_set_ui_preferences_checked, commands::marketplace_logout, commands::list_vocab, commands::add_vocab, @@ -329,6 +353,8 @@ macro_rules! app_invoke_handler_desktop { commands::set_quick_note_hotkey, commands::set_style_pack_hotkeys, commands::qa_window_dismiss, + commands::qa_window_set_expanded, + commands::qa_get_snapshot, commands::qa_toggle_recording, commands::qa_submit_text, commands::qa_set_edit_instruction_mode, @@ -338,6 +364,10 @@ macro_rules! app_invoke_handler_desktop { commands::less_computer_submit_text, commands::less_computer_sync, commands::less_computer_approve, + commands::less_computer_voice_start, + commands::less_computer_voice_stop, + commands::less_computer_voice_cancel, + commands::less_computer_task_cancel, commands::validate_combo_hotkey, commands::set_combo_hotkey, commands::list_provider_descriptors, @@ -488,6 +518,26 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::cloud_sync_upload, $crate::commands::cloud_sync_restore, $crate::commands::cloud_sync_delete, + $crate::commands::cloud_sync_e2ee_status, + $crate::commands::cloud_sync_e2ee_claim_setup_prompt, + $crate::commands::cloud_sync_e2ee_prepare_enable, + $crate::commands::cloud_sync_e2ee_create, + $crate::commands::cloud_sync_e2ee_unlock, + $crate::commands::cloud_sync_e2ee_lock, + $crate::commands::cloud_sync_e2ee_set_enabled, + $crate::commands::cloud_sync_e2ee_sync_now, + $crate::commands::cloud_sync_e2ee_cancel, + $crate::commands::cloud_sync_e2ee_preview_restore, + $crate::commands::cloud_sync_e2ee_apply_restore, + $crate::commands::cloud_sync_e2ee_change_password, + $crate::commands::cloud_sync_e2ee_delete_remote, + $crate::commands::cloud_sync_e2ee_sign_out, + $crate::commands::cloud_sync_e2ee_begin_sign_in, + $crate::commands::cloud_sync_e2ee_poll_sign_in, + $crate::commands::cloud_sync_e2ee_cancel_sign_in, + $crate::commands::cloud_sync_e2ee_get_ui_preferences, + $crate::commands::cloud_sync_e2ee_get_ui_preferences_snapshot, + $crate::commands::cloud_sync_e2ee_set_ui_preferences_checked, $crate::commands::marketplace_logout, $crate::commands::list_vocab, $crate::commands::add_vocab, @@ -504,6 +554,8 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::stop_dictation, $crate::commands::cancel_dictation, $crate::commands::qa_window_dismiss, + $crate::commands::qa_window_set_expanded, + $crate::commands::qa_get_snapshot, $crate::commands::qa_toggle_recording, $crate::commands::qa_submit_text, $crate::commands::qa_set_edit_instruction_mode, @@ -553,13 +605,8 @@ fn run_desktop() { #[cfg(not(target_os = "windows"))] let coordinator = Arc::new(coordinator::Coordinator::new()); let core_backend = coordinator.backend(); - // 启动时把偏好里的 active ASR 同步进凭据库;get_credentials 按凭据库的 active 渠道取密钥。 - let startup_active_asr = core_backend.get_preferences().active_asr_provider; - if !startup_active_asr.is_empty() { - if let Err(error) = commands::sync_active_asr_provider_to_vault(&startup_active_asr) { - log::warn!("[startup] sync active ASR provider from preferences failed: {error}"); - } - } + // Runtime effects and active-provider mirroring follow Core startup/recovery + // in tauri_events::start; pending restore must not mutate the old vault here. let builder = tauri::Builder::default(); // macOS:胶囊要叠到别的 app 的全屏 Space 之上,必须是「非激活 NSPanel」(普通 // NSWindow 即便设 collectionBehavior 也做不到 —— tauri#9556 / #11488)。下面 setup 里 @@ -589,7 +636,8 @@ fn run_desktop() { .try_state::>() .map(|s| Arc::clone(&*s)) { - if coordinator.backend().get_preferences().start_minimized { + if coordinator.startup_error().is_none() + && coordinator.backend().get_preferences().start_minimized { log::info!( "[single-instance] start_minimized=true → skipping show on relaunch" ); @@ -622,20 +670,6 @@ fn run_desktop() { init_file_logger(); log::info!("=== OpenLess 启动 ==="); - #[cfg(target_os = "windows")] - { - let target = openless_core::WindowsKeyboardRuntimeTarget::from( - &coordinator.backend().get_preferences(), - ); - if let Err(err) = crate::windows_ime_profile::apply_windows_openless_keyboard_list( - target.openless_language_profile_enabled, - ) { - log::warn!( - "[windows-ime] apply keyboard list visibility pref on startup failed: {err}" - ); - } - } - // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 if let Some(capsule) = app.get_webview_window("capsule") { @@ -691,9 +725,9 @@ fn run_desktop() { let _ = capsule.hide(); } - // QA / Less Computer / glow 浮窗改为懒创建(不再在 tauri.conf.json eager 声明): + // QA / Less Computer 浮窗懒创建(不在 tauri.conf.json eager 声明): // 用到时才 build(ensure_qa_window / ensure_less_computer_window / - // ensure_less_computer_glow_window),idle 时根本没有它们的 WebKit 进程 —— + // ensure_less_computer_window),idle 时没有额外的 WebKit 进程 —— // 省 3 个常驻 webview。定位 + QA 拖拽修复在创建/show 路径里补。 // 主窗口磨砂:macOS 用 NSVisualEffectView,Windows 用 Mica。 @@ -739,8 +773,8 @@ fn run_desktop() { // 于 prefs。 let force_show = std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1"); - let suppress_show = - !force_show && coordinator.backend().get_preferences().start_minimized; + let suppress_show = !force_show && coordinator.startup_error().is_none() + && coordinator.backend().get_preferences().start_minimized; if suppress_show { log::info!("[main] start_minimized=true → 跳过初始 show,等用户点托盘"); } else { @@ -849,12 +883,15 @@ fn run_desktop() { log::warn!("[startup] default window icon missing; tray icon disabled"); } - // Spin up hotkey listener; coordinator owns the lifecycle. + // Bind recovery effects before Core startup; the Ready handler + // starts hotkey supervisors once the recovery fence is clear. let app_handle = app.handle().clone(); coordinator.tauri_host().bind(app_handle); - coordinator.sync_capsule_style_from_preferences(); + coordinator.bind_restore_runtime_effects()?; + if core_backend.ensure_runtime_ready().is_ok() { + coordinator.sync_capsule_style_from_preferences(); + } crate::tauri_events::start(app.handle().clone(), Arc::clone(&core_backend)); - coordinator.start_hotkey_listener(); // QA / custom combo hotkeys use `global-hotkey` (Carbon on macOS). // Start those after RunEvent::Ready, when the AppKit event loop is live. if std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1") { @@ -877,19 +914,7 @@ fn run_desktop() { .run(|app, event| match event { RunEvent::Ready => { let coordinator = app.state::>(); - // 同步启动 QA hotkey listener。和 dictation hotkey 平行,互不抢状态。 - coordinator.start_qa_hotkey_listener(); - coordinator.start_selection_polish_hotkey_listener(); - // 选区语音复用选区润色热键,不再单独注册 voice hotkey。 - // 启动「快速 Agent」双热键监听(功能默认关闭,启用后才注册)。 - coordinator.start_coding_agent_hotkey_listener(); - // 启动自定义组合键监听器。当 trigger == Custom 时替代 modifier-only 监听器。 - coordinator.start_combo_hotkey_listener(); - coordinator.start_translation_hotkey_listener(); - coordinator.start_switch_style_hotkey_listener(); - coordinator.start_open_app_hotkey_listener(); - coordinator.start_quick_note_hotkey_listener(); - coordinator.start_style_pack_hotkey_listeners(); + coordinator.start_hotkey_supervisors_when_ready(); } #[cfg(target_os = "macos")] RunEvent::Reopen { .. } => show_main_window(app), @@ -1945,14 +1970,13 @@ fn wait_for_app_activation(app: &AppHandle) { #[cfg(not(target_os = "macos"))] fn wait_for_app_activation(_app: &AppHandle) {} -/// QA 浮窗的目标尺寸(issue #118;统一聊天面板后与 Less Computer 同尺寸)。 -/// 窗口固定大小,内容在面板内部的 MessageScroller 里滚动。 -const QA_WINDOW_WIDTH: f64 = 420.0; -const QA_WINDOW_HEIGHT: f64 = 540.0; +/// QA starts as a small composer and grows downwards after a question. +const QA_WINDOW_WIDTH: f64 = 480.0; +const QA_WINDOW_HEIGHT: f64 = 80.0; +const QA_WINDOW_EXPANDED_HEIGHT: f64 = 560.0; /// 胶囊与 QA 窗口的间距,与设计稿一致。 const QA_WINDOW_GAP_TO_CAPSULE: f64 = 8.0; /// 给 macOS Dock 留的下边距(与 capsule 同源)。 -const DOCK_BOTTOM_PADDING_FOR_QA: f64 = 80.0; #[derive(Clone, Copy, Debug, PartialEq)] struct LogicalMonitorFrame { @@ -2149,6 +2173,23 @@ fn floating_window_monitor_frame( ))) } +/// First presentation may follow the pointer; resizing an existing chat stays +/// on its own monitor. Work areas exclude the Dock/menu bar/taskbar. +fn chat_window_work_area(window: &tauri::WebviewWindow, initial: bool) -> tauri::Result> { + #[cfg(target_os = "macos")] + if initial { + if let Some(target) = capsule_target_monitor(window) { + return Ok(Some((target.logical_work_area(), target.scale.max(0.1)))); + } + } + #[cfg(not(target_os = "macos"))] + let _ = initial; + let Some(monitor) = window.current_monitor()? else { return Ok(None); }; + let area = monitor.work_area(); + let scale = monitor.scale_factor().max(0.1); + Ok(Some((logical_monitor_frame(area.position.x, area.position.y, area.size.width, area.size.height, scale), scale))) +} + #[cfg(target_os = "macos")] fn macos_mouse_cursor_point() -> Option<(f64, f64)> { macos_capsule_ax::mouse_cursor_point() @@ -2429,18 +2470,39 @@ fn clamp_to_monitor( /// 把 QA 浮窗放到屏幕底部居中、紧贴胶囊上方。tauri 启动期 + show 之前都会调一次, /// 防止用户切换显示器后位置错乱。 fn position_qa_window(window: &tauri::WebviewWindow) -> tauri::Result<()> { - let Some(frame) = floating_window_monitor_frame(window)? else { + let Some((frame, scale)) = chat_window_work_area(window, true)? else { return Ok(()); }; let capsule_height = capsule_height_for_qa(); let (x, y) = bottom_center_position( frame, QA_WINDOW_WIDTH, - QA_WINDOW_HEIGHT, - DOCK_BOTTOM_PADDING_FOR_QA + capsule_height + QA_WINDOW_GAP_TO_CAPSULE, + QA_WINDOW_EXPANDED_HEIGHT, + capsule_height + QA_WINDOW_GAP_TO_CAPSULE, ); - window.set_size(tauri::LogicalSize::new(QA_WINDOW_WIDTH, QA_WINDOW_HEIGHT))?; - window.set_position(LogicalPosition::new(x, y))?; + window.set_position(tauri::PhysicalPosition::new((x * scale).round() as i32, (y * scale).round() as i32))?; + window.set_size(tauri::PhysicalSize::new((QA_WINDOW_WIDTH * scale).round() as u32, (QA_WINDOW_HEIGHT * scale).round() as u32))?; + Ok(()) +} + +/// Called on the native main thread by the restricted QA command. The compact +/// state has a genuinely small native frame, so hidden content cannot eat clicks. +pub(crate) fn set_qa_window_expanded(app: &AppHandle, expanded: bool) -> Result<(), String> { + let window = app.get_webview_window("qa").ok_or_else(|| "qa_window_unavailable".to_string())?; + let area = chat_window_work_area(&window, false).map_err(|_| "qa_geometry_unavailable")?; + let scale = area.map(|(_, scale)| scale).unwrap_or(window.scale_factor().map_err(|_| "qa_geometry_unavailable")?); + let position = window.inner_position().map_err(|_| "qa_geometry_unavailable")?.to_logical::(scale); + let mut width = QA_WINDOW_WIDTH; + let mut height = if expanded { QA_WINDOW_EXPANDED_HEIGHT } else { QA_WINDOW_HEIGHT }; + let (mut x, mut y) = (position.x, position.y); + if let Some((frame, _)) = area { + width = width.min((frame.width - 32.0).max(240.0)); + height = height.min((frame.height - 64.0).max(QA_WINDOW_HEIGHT)); + x = x.clamp(frame.x + 16.0, (frame.x + frame.width - width - 16.0).max(frame.x + 16.0)); + y = y.clamp(frame.y + 32.0, (frame.y + frame.height - height - 16.0).max(frame.y + 32.0)); + } + window.set_position(tauri::PhysicalPosition::new((x * scale).round() as i32, (y * scale).round() as i32)).map_err(|_| "qa_position_failed")?; + window.set_size(tauri::PhysicalSize::new((width * scale).round() as u32, (height * scale).round() as u32)).map_err(|_| "qa_resize_failed")?; Ok(()) } @@ -2559,8 +2621,15 @@ fn make_chat_window_panel_macos(window: &tauri::WebviewWindow use tauri_nspanel::WebviewWindowExt; match window.to_panel() { Ok(panel) => { - const NS_NONACTIVATING_PANEL_MASK: i32 = 1 << 7; - panel.set_style_mask(NS_NONACTIVATING_PANEL_MASK); + use objc2::msg_send; + use objc2::runtime::AnyObject; + let raw = &*panel as *const _ as *mut AnyObject; + if !raw.is_null() { + unsafe { + let current: usize = msg_send![raw, styleMask]; + let _: () = msg_send![raw, setStyleMask: current | (1usize << 7)]; + } + } // 浮层级别(NSFloatingWindowLevel):盖普通窗口,不盖菜单栏/胶囊(25)。 panel.set_level(3); panel.set_collection_behaviour( @@ -2690,7 +2759,8 @@ fn ensure_less_computer_window( .shadow(true) .always_on_top(true) .skip_taskbar(true) - .resizable(false) + .resizable(true) + .min_inner_size(760.0, 520.0) .focused(false) .visible(false) .accept_first_mouse(true) @@ -2703,6 +2773,7 @@ fn ensure_less_computer_window( let _ = app.run_on_main_thread(move || { make_chat_window_panel_macos(&w_clone, "less-computer"); make_chat_window_draggable_macos(&w_clone, "less-computer"); + LESS_COMPUTER_WINDOW_POSITIONED.store(false, Ordering::Relaxed); }); Some(w) } @@ -2732,7 +2803,8 @@ fn ensure_less_computer_window( .shadow(true) .always_on_top(true) .skip_taskbar(true) - .resizable(false) + .resizable(true) + .min_inner_size(760.0, 520.0) .focused(false) .visible(false) .build() @@ -2743,41 +2815,6 @@ fn ensure_less_computer_window( }) } -/// 懒创建 Less Computer glow 描边窗(macOS only)。shadow:false、无 acceptFirstMouse。 -/// 它的 level/collectionBehavior/ignore-mouse 在每次 show_less_computer_glow 里幂等设置, -/// 所以创建时不需要额外原生配置。 -#[cfg(target_os = "macos")] -fn ensure_less_computer_glow_window( - app: &AppHandle, -) -> Option> { - if let Some(w) = app.get_webview_window("less-computer-glow") { - return Some(w); - } - match WebviewWindowBuilder::new( - app, - "less-computer-glow", - WebviewUrl::App("index.html?window=less-computer-glow".into()), - ) - .title("OpenLess Less Computer Glow") - .inner_size(800.0, 600.0) - .decorations(false) - .transparent(true) - .shadow(false) - .always_on_top(true) - .skip_taskbar(true) - .resizable(false) - .focused(false) - .visible(false) - .build() - { - Ok(w) => Some(w), - Err(e) => { - log::warn!("[less-computer-glow] lazy window create failed: {e}"); - None - } - } -} - /// 带退场动画地隐藏聊天面板:先发 `chat-panel:closing` 让前端播退场动画, /// 240ms 后真正 hide。期间再次 show(epoch 前进)则作废本次挂起的 hide —— /// 避免「关的动画还没放完用户又唤起 → 窗口被旧定时器藏掉」。 @@ -3291,33 +3328,26 @@ pub(crate) fn hide_selection_voice_intent_prompt(_app: &AppHa // 操作仍按平台分支,macOS 的 NSWindow/AppKit 调整不能流入 Windows 构建。 // Linux 的产品窗口由 egui Host 接入,不在 Tauri 创建;不支持的平台保留 no-op。 -/// Less Computer 浮窗尺寸:与 QA 同款「统一聊天面板」固定大小 —— 窗口出现即 -/// 定死,内容只在面板内部的 MessageScroller 里滚动,不再按内容自适应缩放窗口。 +/// Less Computer defaults to a desktop workspace; users can resize it afterwards. #[cfg(any(target_os = "macos", target_os = "windows"))] -const LESS_COMPUTER_WINDOW_WIDTH: f64 = 420.0; +const LESS_COMPUTER_WINDOW_WIDTH: f64 = 990.0; #[cfg(any(target_os = "macos", target_os = "windows"))] -const LESS_COMPUTER_WINDOW_HEIGHT: f64 = 540.0; +const LESS_COMPUTER_WINDOW_HEIGHT: f64 = 680.0; -/// 把 Less Computer 浮窗(固定尺寸)摆到屏幕底部居中、紧贴胶囊上方。 +/// Position the initial workspace within the monitor; subsequent shows preserve user geometry. #[cfg(target_os = "macos")] fn position_less_computer_window( window: &tauri::WebviewWindow, ) -> tauri::Result<()> { - let Some(frame) = floating_window_monitor_frame(window)? else { + let Some((frame, scale)) = chat_window_work_area(window, true)? else { return Ok(()); }; - let capsule_height = capsule_height_for_qa(); - let (x, y) = bottom_center_position( - frame, - LESS_COMPUTER_WINDOW_WIDTH, - LESS_COMPUTER_WINDOW_HEIGHT, - DOCK_BOTTOM_PADDING_FOR_QA + capsule_height + QA_WINDOW_GAP_TO_CAPSULE, - ); - window.set_size(tauri::LogicalSize::new( - LESS_COMPUTER_WINDOW_WIDTH, - LESS_COMPUTER_WINDOW_HEIGHT, - ))?; - window.set_position(LogicalPosition::new(x, y))?; + let width = LESS_COMPUTER_WINDOW_WIDTH.min((frame.width - 32.0).max(760.0)); + let height = LESS_COMPUTER_WINDOW_HEIGHT.min((frame.height - 32.0).max(520.0)); + let x = frame.x + (frame.width - width).max(0.0) / 2.0; + let y = frame.y + (frame.height - height).max(0.0) / 2.0; + window.set_position(tauri::PhysicalPosition::new((x * scale).round() as i32, (y * scale).round() as i32))?; + window.set_size(tauri::PhysicalSize::new((width * scale).round() as u32, (height * scale).round() as u32))?; Ok(()) } @@ -3336,8 +3366,11 @@ pub(crate) fn show_less_computer_window(app: &AppHandle) { // voice Agent turn. Keep every AppKit-backed window mutation on the main // thread; macOS aborts the process if a converted NSPanel is resized or moved // from that worker while WebKit is servicing its custom URL scheme. - if let Err(e) = position_less_computer_window(&window_clone) { - log::warn!("[less-computer] position before show failed: {e}"); + if !LESS_COMPUTER_WINDOW_POSITIONED.load(Ordering::Relaxed) { + match position_less_computer_window(&window_clone) { + Ok(()) => LESS_COMPUTER_WINDOW_POSITIONED.store(true, Ordering::Relaxed), + Err(e) => log::warn!("[less-computer] position before show failed: {e}"), + } } // A lazily-created window starts with Tauri's visible=false state. Cocoa's // orderFrontRegardless alone does not always clear that state, leaving the first @@ -3393,88 +3426,8 @@ pub(crate) fn hide_less_computer_window(app: &AppHandle) { #[cfg(not(any(target_os = "macos", target_os = "windows")))] pub(crate) fn hide_less_computer_window(_app: &AppHandle) {} -/// 显示全屏彩虹描边浮层:盖满当前显示器、点击穿透、置顶。Agent 工作时点亮整屏边缘。 -#[cfg(target_os = "macos")] -pub(crate) fn show_less_computer_glow(app: &AppHandle) { - let Some(window) = ensure_less_computer_glow_window(app) else { - return; - }; - // 盖满胶囊所在的那块显示器(跟随鼠标光标,见 floating_window_monitor_frame), - // 含菜单栏/Dock 区域。frame 已是「逻辑坐标」—— Retina 上 monitor.size() 是物理像素(2x), - // 直接拿去 set_size 会把窗口铺成两倍、错位、不贴边。 - let frame = floating_window_monitor_frame(&window) - .ok() - .flatten() - .or_else(|| { - let monitor = app.primary_monitor().ok().flatten()?; - let size = monitor.size(); - let pos = monitor.position(); - Some(logical_monitor_frame( - pos.x, - pos.y, - size.width, - size.height, - monitor.scale_factor(), - )) - }); - if let Some(frame) = frame { - let _ = window.set_position(tauri::LogicalPosition::new(frame.x, frame.y)); - let _ = window.set_size(tauri::LogicalSize::new(frame.width, frame.height)); - } - // 点击穿透:纯视觉浮层,绝不拦截鼠标。 - let _ = window.set_ignore_cursor_events(true); - // issue #470:通知 glow 前端「可见」,恢复发光动画(隐藏时会 emit(false) 卸载发光层以释放 GPU)。 - let _ = window.emit("less-computer-glow:active", true); - let window_clone = window.clone(); - let app_for_reassert = app.clone(); - let _ = app.run_on_main_thread(move || { - use objc2::msg_send; - use objc2::runtime::AnyObject; - match window_clone.ns_window() { - Ok(handle) => { - let ns = handle as *mut AnyObject; - if ns.is_null() { - let _ = window_clone.show(); - } else { - unsafe { - // 抬到菜单栏(24)/Dock 之上,让描边能真正贴到屏幕最外缘(含顶部菜单栏区域)。 - let _: () = msg_send![ns, setLevel: 25i64]; - // 所有 Space 都显示、不参与窗口循环、全屏 app 上也叠加(273 = - // CanJoinAllSpaces|Stationary|FullScreenAuxiliary)。macOS 26 会在 - // 运行中把窗口从「全 Space 贴附」剥离且同值写入救不回(详见 - // show_capsule_window_no_activate 的重注册注释);glow show 频率低, - // 每次都走重注册序列:先以去掉 CanJoinAllSpaces 位的 272 上屏, - // 下一个 tick 再写 273 —— 可见状态下的位翻转才触发重新注册。 - let _: () = msg_send![ns, setCollectionBehavior: 272u64]; - let _: () = msg_send![ns, setIgnoresMouseEvents: true]; - let _: () = msg_send![ns, orderFrontRegardless]; - } - let window_for_reassert = window_clone.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(30)); - let _ = app_for_reassert.run_on_main_thread(move || { - let Ok(handle) = window_for_reassert.ns_window() else { - return; - }; - let ns = handle as *mut AnyObject; - if ns.is_null() { - return; - } - unsafe { - let _: () = msg_send![ns, setCollectionBehavior: 273u64]; - } - }); - }); - } - } - Err(_) => { - let _ = window_clone.show(); - } - } - }); -} - -#[cfg(not(target_os = "macos"))] +/// Less Computer presents work and recording feedback inside its panel. +/// Keep the host port callable without creating a full-screen glow WebView. pub(crate) fn show_less_computer_glow(_app: &AppHandle) {} /// 隐藏全屏彩虹描边浮层。 diff --git a/openless-all/app/src-tauri/src/macos_streaming_input.rs b/openless-all/app/src-tauri/src/macos_streaming_input.rs new file mode 100644 index 000000000..90c79696d --- /dev/null +++ b/openless-all/app/src-tauri/src/macos_streaming_input.rs @@ -0,0 +1,626 @@ +//! Serial macOS keyboard posting with one terminal AX delivery barrier. +//! +//! AX references are created, used and released on the worker thread. The +//! cloneable handle contains only channels and completion state, never raw AX +//! pointers. Once a write is queued, dropping its future does not revoke it; +//! callers must await `finish` before restoring TIS, including on cancellation. + +#![cfg(target_os = "macos")] + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, OnceLock}; + +use openless_core::ports::InsertWriteResult; +use openless_core::{BackendError, BackendErrorCode}; +use parking_lot::Mutex; +use tokio::sync::{oneshot, Notify}; + +use crate::host_document::{KeyboardDelivery, KeyboardDeliveryOutcome}; +use crate::selection::SelectionInsertionTarget; +use crate::types::MacosNewlineMode; + +#[derive(Clone)] +pub(crate) struct MacStreamingInput { + inner: Arc, +} + +struct WorkerHandle { + // Taking the sender seals the queue atomically with respect to new writes. + commands: Mutex>>, + completion: Arc, + closed: Arc, +} + +enum Command { + Write { + text: String, + reply: oneshot::Sender>, + }, + Finish, +} + +#[derive(Default)] +struct Completion { + result: OnceLock>, + ready: Notify, +} + +impl Completion { + fn settle(&self, result: Result<(), BackendError>) { + if self.result.set(result).is_ok() { + self.ready.notify_waiters(); + } + } + + async fn wait(&self) -> Result<(), BackendError> { + loop { + let notified = self.ready.notified(); + if let Some(result) = self.result.get() { + return result.clone(); + } + notified.await; + } + } +} + +// A panic or an unexpectedly abandoned channel must wake all terminal waiters. +struct WorkerExit(Arc); + +impl Drop for WorkerExit { + fn drop(&mut self) { + self.0.settle(Err(worker_stopped())); + } +} + +impl Drop for WorkerHandle { + fn drop(&mut self) { + // Best effort cleanup when the last handle goes away. This cannot + // replace awaiting finish before restoring the caller's input source. + if let Some(sender) = self.commands.get_mut().take() { + if sender.send(Command::Finish).is_err() { + self.completion.settle(Err(worker_stopped())); + } + } + } +} + +impl MacStreamingInput { + pub(crate) fn spawn( + target: SelectionInsertionTarget, + newline_mode: MacosNewlineMode, + closed: Arc, + ) -> Result { + Self::spawn_with_backend( + move || NativeInput { + target, + newline_mode, + submitted_return: false, + }, + closed, + ) + } + + // B need not be Send: it is constructed and destroyed inside this thread. + fn spawn_with_backend( + create: impl FnOnce() -> B + Send + 'static, + closed: Arc, + ) -> Result { + let (sender, receiver) = mpsc::channel(); + let completion = Arc::new(Completion::default()); + let worker_completion = Arc::clone(&completion); + let worker_closed = Arc::clone(&closed); + std::thread::Builder::new() + .name("openless-macos-stream-input".into()) + .spawn(move || { + let _exit = WorkerExit(Arc::clone(&worker_completion)); + let mut input = InputState::new(create()); + let mut failed: Option = None; + while let Ok(command) = receiver.recv() { + match command { + Command::Write { text, reply } => { + let result = if let Some(error) = &failed { + Err(error.clone()) + } else { + input.write(&text, &worker_closed) + }; + match &result { + Err(error) => failed = Some(error.clone()), + Ok(result) if result.written_chars < text.chars().count() => { + failed = Some(BackendError::new( + BackendErrorCode::Platform, + "native keyboard insertion stopped after a partial write", + )); + } + _ => {} + } + // A dropped receiver does not abandon a committed + // native write or the terminal delivery barrier. + let _ = reply.send(result); + } + Command::Finish => break, + } + } + // Previous write failures were reported to Core already. Its + // clipboard reconciliation must still be able to finish cleanup. + let result = input.finish(); + worker_completion.settle(result); + }) + .map_err(|error| { + BackendError::new( + BackendErrorCode::Platform, + format!("start macOS streaming input worker: {error}"), + ) + })?; + Ok(Self { + inner: Arc::new(WorkerHandle { + commands: Mutex::new(Some(sender)), + completion, + closed, + }), + }) + } + + pub(crate) async fn write(&self, text: String) -> Result { + let (reply, receiver) = oneshot::channel(); + { + let commands = self.inner.commands.lock(); + if self.inner.closed.load(Ordering::Acquire) { + return Err(input_closed()); + } + let sender = commands.as_ref().ok_or_else(input_closed)?; + sender + .send(Command::Write { text, reply }) + .map_err(|_| worker_stopped())?; + } + receiver.await.map_err(|_| worker_stopped())? + } + + /// Seal writes, drain everything already posted, then confirm the cumulative + /// caret. Concurrent or cancelled finish callers share one terminal result. + /// The caller may set `closed` to cancel not-yet-started writes, but finish + /// itself does not cancel writes that were accepted before this barrier. + pub(crate) async fn finish(&self) -> Result<(), BackendError> { + { + if let Some(sender) = self.inner.commands.lock().take() { + if sender.send(Command::Finish).is_err() { + self.inner.completion.settle(Err(worker_stopped())); + } + } + } + self.inner.completion.wait().await + } +} + +fn input_closed() -> BackendError { + BackendError::new( + BackendErrorCode::Cancelled, + "macOS streaming insertion is closed", + ) +} + +fn worker_stopped() -> BackendError { + BackendError::new( + BackendErrorCode::Internal, + "macOS streaming input worker stopped", + ) +} + +/// Only native side effects are replaceable in tests; queueing, target guards, +/// prefix accounting and terminal ordering exercise the production code. +trait InputBackend { + type Delivery; + fn restore_target(&mut self) -> bool; + fn capture_delivery(&mut self) -> Option; + fn is_focused(&mut self, delivery: &Self::Delivery) -> bool; + fn post(&mut self, text: &str) -> usize; + fn record_posted(&mut self, delivery: &mut Self::Delivery, posted: &str); + fn finish_delivery(&mut self, delivery: Self::Delivery) -> Result<(), BackendError>; +} + +struct InputState { + backend: B, + captured: bool, + delivery: Option, +} + +impl InputState { + fn new(backend: B) -> Self { + Self { + backend, + captured: false, + delivery: None, + } + } + + fn write( + &mut self, + text: &str, + closed: &AtomicBool, + ) -> Result { + if closed.load(Ordering::Acquire) { + return Err(input_closed()); + } + if text.is_empty() { + return Ok(InsertWriteResult { written_chars: 0 }); + } + if !self.backend.restore_target() { + return Err(BackendError::new( + BackendErrorCode::Platform, + "original text insertion target is unavailable", + )); + } + if !self.captured { + self.delivery = self.backend.capture_delivery(); + self.captured = true; + } + if self + .delivery + .as_ref() + .is_some_and(|delivery| !self.backend.is_focused(delivery)) + { + return Err(BackendError::new( + BackendErrorCode::Platform, + "original text insertion control lost focus", + )); + } + // Restoring focus / AX capture can take time. A cancellation that won + // during those operations must not start posting a fresh batch. + if closed.load(Ordering::Acquire) { + return Err(input_closed()); + } + let written_chars = self.backend.post(text); + if let Some(delivery) = self.delivery.as_mut() { + let posted: String = text.chars().take(written_chars).collect(); + self.backend.record_posted(delivery, &posted); + } + Ok(InsertWriteResult { written_chars }) + } + + fn finish(mut self) -> Result<(), BackendError> { + match self.delivery.take() { + Some(delivery) => self.backend.finish_delivery(delivery), + None => Ok(()), + } + } +} + +struct NativeInput { + target: SelectionInsertionTarget, + newline_mode: MacosNewlineMode, + submitted_return: bool, +} + +impl InputBackend for NativeInput { + type Delivery = KeyboardDelivery; + + fn restore_target(&mut self) -> bool { + crate::selection::reactivate_selection_insertion_target(&self.target) + } + + fn capture_delivery(&mut self) -> Option { + KeyboardDelivery::capture() + } + + fn is_focused(&mut self, delivery: &Self::Delivery) -> bool { + // Explicit Return mode can submit/replace the focused control. Preserve + // its existing posted-input fallback after an actual submitting key; + // the original application check still runs before every later batch. + self.submitted_return || delivery.is_focused() + } + + fn post(&mut self, text: &str) -> usize { + match crate::unicode_keystroke::type_unicode_chunk_with_options(text, self.newline_mode) { + Ok(written) => written, + Err(error) => { + let written = error.typed_chars(); + log::warn!("[insertion] keyboard posting failed after {written} chars: {error}"); + written + } + } + } + + fn record_posted(&mut self, delivery: &mut Self::Delivery, posted: &str) { + delivery.record_posted(posted, self.newline_mode); + if self.newline_mode == MacosNewlineMode::Return && posted.contains('\n') { + self.submitted_return = true; + } + } + + fn finish_delivery(&mut self, delivery: Self::Delivery) -> Result<(), BackendError> { + let outcome = delivery.finish(); + if outcome != KeyboardDeliveryOutcome::Delivered { + log::warn!("[insertion] using posted-input completion; caret outcome={outcome:?}"); + } + // Unsupported/stalled AX targets already use posting completion. Never + // turn an unobservable caret into a retry/paste of text already sent. + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::rc::Rc; + use std::time::Duration; + + struct FakeInput { + events: mpsc::Sender, + restore: bool, + focused: bool, + observable: bool, + limit: usize, + post_gate: Option>, + finish_gate: Option>, + cancel_during_restore: Option>, + } + + impl FakeInput { + fn new(events: mpsc::Sender) -> Self { + Self { + events, + restore: true, + focused: true, + observable: true, + limit: usize::MAX, + post_gate: None, + finish_gate: None, + cancel_during_restore: None, + } + } + } + + // Rc deliberately makes the receipt !Send, like the real AX reference. + impl InputBackend for FakeInput { + type Delivery = (Rc<()>, String); + fn restore_target(&mut self) -> bool { + if let Some(closed) = &self.cancel_during_restore { + closed.store(true, Ordering::Release); + } + self.restore + } + fn capture_delivery(&mut self) -> Option { + self.events.send("capture".into()).unwrap(); + self.observable.then(|| (Rc::new(()), String::new())) + } + fn is_focused(&mut self, _: &Self::Delivery) -> bool { + self.focused + } + fn post(&mut self, text: &str) -> usize { + self.events.send(format!("post:{text}")).unwrap(); + if let Some(gate) = self.post_gate.take() { + gate.recv_timeout(Duration::from_secs(2)).unwrap(); + } + text.chars().count().min(self.limit) + } + fn record_posted(&mut self, delivery: &mut Self::Delivery, text: &str) { + delivery.1.push_str(text); + } + fn finish_delivery(&mut self, delivery: Self::Delivery) -> Result<(), BackendError> { + self.events.send(format!("ack:{}", delivery.1)).unwrap(); + if let Some(gate) = self.finish_gate.take() { + gate.recv_timeout(Duration::from_secs(2)).unwrap(); + } + self.events.send("finished".into()).unwrap(); + Ok(()) + } + } + + fn event(events: &mpsc::Receiver) -> String { + events.recv_timeout(Duration::from_secs(2)).unwrap() + } + + #[tokio::test] + async fn writes_do_not_wait_for_ack_and_finish_waits_for_the_combined_receipt() { + let (tx, events) = mpsc::channel(); + let (release, gate) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.finish_gate = Some(gate); + backend + }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + assert_eq!(input.write("A🙂".into()).await.unwrap().written_chars, 2); + assert_eq!(input.write("B".into()).await.unwrap().written_chars, 1); + assert_eq!(event(&events), "capture"); + assert_eq!(event(&events), "post:A🙂"); + assert_eq!(event(&events), "post:B"); + assert!(events.try_recv().is_err()); + let mut finish = std::pin::pin!(input.finish()); + assert!(futures_util::poll!(finish.as_mut()).is_pending()); + assert_eq!(event(&events), "ack:A🙂B"); + assert!(futures_util::poll!(finish.as_mut()).is_pending()); + release.send(()).unwrap(); + finish.await.unwrap(); + input.finish().await.unwrap(); + assert_eq!(event(&events), "finished"); + assert_eq!( + input.write("late".into()).await.unwrap_err().code, + BackendErrorCode::Cancelled + ); + } + + #[tokio::test] + async fn a_dropped_write_and_finish_future_do_not_abandon_queued_input() { + let (tx, events) = mpsc::channel(); + let (release, gate) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.post_gate = Some(gate); + backend + }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + { + let mut write = std::pin::pin!(input.write("committed".into())); + assert!(futures_util::poll!(write.as_mut()).is_pending()); + assert_eq!(event(&events), "capture"); + assert_eq!(event(&events), "post:committed"); + } + { + let mut finish = std::pin::pin!(input.finish()); + assert!(futures_util::poll!(finish.as_mut()).is_pending()); + } + release.send(()).unwrap(); + input.finish().await.unwrap(); + assert_eq!(event(&events), "ack:committed"); + assert_eq!(event(&events), "finished"); + } + + #[tokio::test] + async fn cancellation_rejects_queued_writes_but_drains_the_started_batch() { + let (tx, events) = mpsc::channel(); + let (release, gate) = mpsc::channel(); + let closed = Arc::new(AtomicBool::new(false)); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.post_gate = Some(gate); + backend + }, + Arc::clone(&closed), + ) + .unwrap(); + let mut first = std::pin::pin!(input.write("first".into())); + assert!(futures_util::poll!(first.as_mut()).is_pending()); + assert_eq!(event(&events), "capture"); + assert_eq!(event(&events), "post:first"); + let mut second = std::pin::pin!(input.write("cancelled".into())); + assert!(futures_util::poll!(second.as_mut()).is_pending()); + closed.store(true, Ordering::Release); + let mut finish = std::pin::pin!(input.finish()); + assert!(futures_util::poll!(finish.as_mut()).is_pending()); + release.send(()).unwrap(); + assert_eq!(first.await.unwrap().written_chars, 5); + assert_eq!(second.await.unwrap_err().code, BackendErrorCode::Cancelled); + finish.await.unwrap(); + assert_eq!(event(&events), "ack:first"); + assert_eq!(event(&events), "finished"); + } + + #[tokio::test] + async fn target_failure_prevents_posting_and_does_not_block_cleanup() { + for restore_failure in [true, false] { + let (tx, events) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.restore = !restore_failure; + backend.focused = false; + backend + }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + assert_eq!( + input.write("must not post".into()).await.unwrap_err().code, + BackendErrorCode::Platform + ); + input.finish().await.unwrap(); + assert!(events.try_iter().all(|event| !event.starts_with("post:"))); + } + } + + #[tokio::test] + async fn cancellation_during_target_restore_is_checked_before_posting() { + let (tx, events) = mpsc::channel(); + let closed = Arc::new(AtomicBool::new(false)); + let cancel = Arc::clone(&closed); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.cancel_during_restore = Some(cancel); + backend + }, + closed, + ) + .unwrap(); + assert_eq!( + input.write("must not post".into()).await.unwrap_err().code, + BackendErrorCode::Cancelled + ); + input.finish().await.unwrap(); + assert!(events.try_iter().all(|event| !event.starts_with("post:"))); + } + + #[tokio::test] + async fn unobservable_targets_keep_posting_fallback_without_recapturing() { + let (tx, events) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.observable = false; + backend + }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + input.write("one".into()).await.unwrap(); + input.write("two".into()).await.unwrap(); + input.finish().await.unwrap(); + assert_eq!( + events.try_iter().collect::>(), + ["capture", "post:one", "post:two"] + ); + } + + #[tokio::test] + async fn partial_writes_account_only_for_the_posted_prefix_and_stop_later_input() { + let (tx, events) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || { + let mut backend = FakeInput::new(tx); + backend.limit = 2; + backend + }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + assert_eq!(input.write("A🙂B".into()).await.unwrap().written_chars, 2); + assert!(input.write("duplicate".into()).await.is_err()); + input.finish().await.unwrap(); + assert_eq!( + events.try_iter().collect::>(), + ["capture", "post:A🙂B", "ack:A🙂", "finished"] + ); + } + + #[tokio::test] + async fn last_handle_drop_closes_the_queue_and_drains_its_receipt() { + let (tx, events) = mpsc::channel(); + let input = MacStreamingInput::spawn_with_backend( + move || FakeInput::new(tx), + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + input.write("done".into()).await.unwrap(); + let completion = Arc::clone(&input.inner.completion); + drop(input); + completion.wait().await.unwrap(); + assert_eq!( + events.try_iter().collect::>(), + ["capture", "post:done", "ack:done", "finished"] + ); + } + + #[tokio::test] + async fn worker_start_panic_wakes_write_and_finish_waiters() { + let input = MacStreamingInput::spawn_with_backend( + || -> FakeInput { panic!("test worker initialization failure") }, + Arc::new(AtomicBool::new(false)), + ) + .unwrap(); + assert_eq!( + input.write("not posted".into()).await.unwrap_err().code, + BackendErrorCode::Internal + ); + assert_eq!( + input.finish().await.unwrap_err().code, + BackendErrorCode::Internal + ); + } +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/side_aware_combo.rs b/openless-all/app/src-tauri/src/mobile_stubs/side_aware_combo.rs index 48cafa583..070c19abb 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/side_aware_combo.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/side_aware_combo.rs @@ -2,7 +2,8 @@ use std::sync::mpsc::Sender; -use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent}; +use crate::combo_hotkey::ComboHotkeyError; +use crate::hotkey::{HotkeyCombinedEdge, HotkeyEvent}; use crate::types::ShortcutBinding; #[derive(Debug, Clone, Copy)] @@ -22,7 +23,8 @@ pub struct SideAwareComboMonitor; impl SideAwareComboMonitor { pub fn start( _binding: ShortcutBinding, - _tx: Sender, + _tx: Sender, + _combo_tx: Sender, ) -> Result { Err(ComboHotkeyError::RegisterFailed( "Side-specific combo hotkeys are not available on mobile".into(), @@ -34,6 +36,8 @@ pub fn handle_side_modifier(_side: SideModifier, _pressed: bool) {} pub fn handle_primary_key(_primary: &str, _pressed: bool) {} +pub fn handle_companion_key_down() {} + #[cfg(target_os = "macos")] pub mod platform { pub fn dispatch_keycode(_keycode: i64, _flags_changed: bool, _flags: u64, _pressed: bool) {} diff --git a/openless-all/app/src-tauri/src/persistence/android_credentials.rs b/openless-all/app/src-tauri/src/persistence/android_credentials.rs index 83530f772..c6d17e1f4 100644 --- a/openless-all/app/src-tauri/src/persistence/android_credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/android_credentials.rs @@ -124,6 +124,58 @@ fn open_envelope( .map_err(StoreError::Crypto) } +/// Inspect the last durable candidate without completing migrations or recovery. +/// Startup with an E2EE recovery marker must not rename, chmod, reset a key, +/// mark migration complete, or delete any envelope before its restore lease. +pub(super) fn read_only( + path: &Path, + crypto: &mut impl AndroidCredentialsCrypto, +) -> Result { + match fs::read(verified_v2_temporary_path(path)) { + Ok(bytes) => return open_envelope(&bytes, crypto).map(ReadOutcome::Plaintext), + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => return Err(io_error("read verified recovery candidate", error)), + } + let bytes = match fs::read(path) { + Ok(bytes) if !bytes.is_empty() => Some(bytes), + Ok(_) => Some(Vec::new()), + Err(error) if error.kind() == io::ErrorKind::NotFound => None, + Err(error) => return Err(io_error("read without migration", error)), + }; + let bytes = if bytes.as_ref().is_none_or(Vec::is_empty) { + match fs::read(path.with_extension("legacy.tmp")) { + Ok(candidate) => candidate, + Err(error) if error.kind() == io::ErrorKind::NotFound => match bytes { + Some(bytes) => bytes, + None => return Ok(ReadOutcome::Missing), + }, + Err(error) => return Err(io_error("read legacy recovery candidate", error)), + } + } else { + bytes.ok_or(StoreError::InvalidEnvelope)? + }; + match bytes + .iter() + .copied() + .find(|byte| !byte.is_ascii_whitespace()) + { + Some(b'{') => open_envelope(&bytes, crypto).map(ReadOutcome::Plaintext), + Some(_) => { + if crypto.migration_complete().map_err(StoreError::Crypto)? { + return Err(StoreError::InvalidEnvelope); + } + let plaintext = base64::engine::general_purpose::STANDARD + .decode(&bytes) + .map_err(|_| StoreError::InvalidEnvelope)?; + if plaintext.is_empty() { + return Err(StoreError::InvalidEnvelope); + } + Ok(ReadOutcome::Legacy(plaintext)) + } + None => Err(StoreError::InvalidEnvelope), + } +} + pub(super) fn read( path: &Path, crypto: &mut impl AndroidCredentialsCrypto, @@ -746,6 +798,73 @@ mod tests { } } + #[test] + fn read_only_preserves_verified_candidates_and_invalidated_key_envelopes() { + let path = test_path("android-read-only-pending"); + let pending = verified_v2_temporary_path(&path); + let mut crypto = TestCrypto::default(); + write_verified(&path, b"old committed root", &mut crypto).unwrap(); + let old = fs::read(&path).unwrap(); + let sealed = crypto.seal(b"new verified root", ENVELOPE_AAD).unwrap(); + let candidate = envelope_for(&sealed).unwrap(); + fs::write(&pending, &candidate).unwrap(); + #[cfg(unix)] + { + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + } + crypto.fail_next_mark_migration = Some(CryptoErrorKind::TemporarilyUnavailable); + assert_eq!( + read_only(&path, &mut crypto).unwrap(), + ReadOutcome::Plaintext(b"new verified root".to_vec()) + ); + assert!(crypto.fail_next_mark_migration.is_some()); + assert_eq!(fs::read(&path).unwrap(), old); + assert_eq!(fs::read(&pending).unwrap(), candidate); + #[cfg(unix)] + { + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o644 + ); + } + crypto.fail_next_open = Some(CryptoErrorKind::KeyMissingOrInvalidated); + assert!(matches!( + read_only(&path, &mut crypto), + Err(StoreError::Crypto(CryptoErrorKind::KeyMissingOrInvalidated)) + )); + assert_eq!(crypto.delete_key_calls, 0); + assert_eq!(fs::read(&path).unwrap(), old); + assert_eq!(fs::read(&pending).unwrap(), candidate); + fs::write(&pending, b"invalid candidate").unwrap(); + assert!(read_only(&path, &mut crypto).is_err()); + assert_eq!(fs::read(&path).unwrap(), old); + remove_test_parent(&path); + } + + #[test] + fn read_only_legacy_recovery_never_promotes_or_marks_a_source() { + let path = test_path("android-read-only-legacy"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let candidate = path.with_extension("legacy.tmp"); + let bytes = base64::engine::general_purpose::STANDARD.encode(b"legacy root"); + fs::write(&candidate, &bytes).unwrap(); + let mut crypto = TestCrypto::default(); + assert_eq!( + read_only(&path, &mut crypto).unwrap(), + ReadOutcome::Legacy(b"legacy root".to_vec()) + ); + assert!(!path.exists()); + assert_eq!(fs::read(&candidate).unwrap(), bytes.as_bytes()); + assert!(!crypto.migration_complete().unwrap()); + crypto.mark_migration_complete().unwrap(); + assert!(matches!( + read_only(&path, &mut crypto), + Err(StoreError::InvalidEnvelope) + )); + assert!(candidate.exists()); + remove_test_parent(&path); + } + #[test] fn v2_round_trip_hides_plaintext() { let path = test_path("android-v2-round-trip"); diff --git a/openless-all/app/src-tauri/src/persistence/credentials.rs b/openless-all/app/src-tauri/src/persistence/credentials.rs index 645b61f07..aedc6ce95 100644 --- a/openless-all/app/src-tauri/src/persistence/credentials.rs +++ b/openless-all/app/src-tauri/src/persistence/credentials.rs @@ -60,6 +60,9 @@ const RESERVED_EXTRA_HEADER_NAMES: &[&str] = &[ const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000; static CREDENTIALS_LOCK: OnceLock> = OnceLock::new(); +static SYNC_WRITE_GATE: OnceLock< + Mutex>>, +> = OnceLock::new(); // Keychain 访问节流:每进程只补写/扫描一次,避免重复授权弹窗。 #[cfg(not(target_os = "android"))] @@ -85,6 +88,367 @@ fn credentials_lock() -> &'static Mutex<()> { CREDENTIALS_LOCK.get_or_init(|| Mutex::new(())) } +fn sync_write_gate() -> Option> { + SYNC_WRITE_GATE + .get_or_init(|| Mutex::new(None)) + .lock() + .clone() +} + +fn install_sync_write_gate( + installed: &mut Option>, + gate: std::sync::Arc, +) -> Result<()> { + if let Some(current) = installed.as_ref() { + anyhow::ensure!( + std::sync::Arc::ptr_eq(current, &gate), + "credential vault already has a different encrypted sync gate" + ); + } else { + *installed = Some(gate); + } + Ok(()) +} + +fn sync_access_error( + error: openless_core::cloud_sync_e2ee_documents::DocumentError, +) -> anyhow::Error { + use openless_core::cloud_sync_e2ee_documents::DocumentError; + use openless_core::{BackendError, BackendErrorCode}; + let code = if error == DocumentError::SourceChanged { + BackendErrorCode::Busy + } else { + BackendErrorCode::OutcomeUnknown + }; + BackendError::new( + code, + if code == BackendErrorCode::Busy { + "credential store is busy with encrypted sync" + } else { + "credential store requires encrypted sync recovery" + }, + ) + .into() +} + +/// The lease precedes both the vault lock and the initial read. Cancellation of +/// the async caller cannot release it: this entire function runs in its worker. +fn mutate_credentials( + origin: openless_core::credentials::ChangeOrigin, + update: impl FnOnce(&mut CredsRoot) -> Result, +) -> Result<()> { + mutate_credentials_with( + sync_write_gate(), + origin, + load_credentials_for_update, + update, + save_credentials, + ) +} + +fn mutate_credentials_with( + gate: Option>, + origin: openless_core::credentials::ChangeOrigin, + load: impl FnOnce() -> Result, + update: impl FnOnce(&mut CredsRoot) -> Result, + persist: impl FnOnce(&CredsRoot) -> Result<()>, +) -> Result<()> { + let was_unbound = gate.is_none(); + let permit = gate + .map(|gate| gate.begin_mutation()) + .transpose() + .map_err(sync_access_error)?; + let _guard = credentials_lock().lock(); + // First binding may win the lock after this worker observed no gate. + // Such a worker must retry; it cannot write through a newly installed barrier. + if was_unbound && sync_write_gate().is_some() { + return Err(sync_access_error( + openless_core::cloud_sync_e2ee_documents::DocumentError::SourceChanged, + )); + } + let prepared = (|| -> Result<(CredsRoot, bool)> { + let mut root = load()?; + let changed = update(&mut root)?; + Ok((root, changed)) + })(); + let (root, changed) = match prepared { + Ok(result) => result, + Err(error) => { + if let Some(permit) = permit { + permit.abort_unmodified().map_err(sync_access_error)?; + } + return Err(error); + } + }; + if !changed { + if let Some(permit) = permit { + permit.abort_unmodified().map_err(sync_access_error)?; + } + return Ok(()); + } + if let Err(error) = persist(&root) { + // The chunked writer can prove that the old manifest never changed. + // Other native failures remain uncertain and intentionally retain intent. + if matches!( + error.downcast_ref::(), + Some(VaultCommitFailure::Unchanged) + ) { + if let Some(permit) = permit { + permit.abort_unmodified().map_err(sync_access_error)?; + } + } + return Err(error); + } + if let Some(permit) = permit { + permit.commit(origin).map_err(sync_access_error)?; + } + Ok(()) +} + +fn require_sync_exclusive(permit: &openless_core::credentials::ExclusivePermit) -> Result<()> { + let gate = sync_write_gate() + .ok_or_else(|| anyhow::anyhow!("encrypted sync credential gate is not bound"))?; + if !permit.belongs_to(&gate) { + anyhow::bail!("encrypted sync credential lease belongs to another gate"); + } + Ok(()) +} + +fn validate_sync_key(value: &openless_core::SecretValue) -> Result<()> { + use base64::Engine; + let encoded = value.expose_secret(); + anyhow::ensure!(encoded.len() == 43, "invalid encrypted sync key encoding"); + let decoded = zeroize::Zeroizing::new( + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded) + .map_err(|_| anyhow::anyhow!("invalid encrypted sync key encoding"))?, + ); + anyhow::ensure!( + decoded.len() == 32 + && base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&*decoded) == encoded, + "invalid encrypted sync key encoding" + ); + Ok(()) +} + +#[cfg(not(target_os = "android"))] +fn read_sync_secret_raw( + account: &openless_core::credentials::SyncSecretAccount, +) -> Result> { + get_keyring_password(account.as_str())? + .map(|raw| { + let value = openless_core::SecretValue::new(raw); + validate_sync_key(&value)?; + Ok(value) + }) + .transpose() +} + +#[cfg(not(target_os = "android"))] +fn write_sync_secret_raw( + account: &openless_core::credentials::SyncSecretAccount, + value: &openless_core::SecretValue, +) -> Result<()> { + set_keyring_password(account.as_str(), value.expose_secret()) +} + +#[cfg(not(target_os = "android"))] +fn remove_sync_secret_raw(account: &openless_core::credentials::SyncSecretAccount) -> Result<()> { + match keyring_entry_for(account.as_str())?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => anyhow::bail!("could not remove encrypted sync key from system credential store"), + } +} + +#[cfg(any(target_os = "android", test))] +struct SyncSecretCrypto { + inner: C, + account: String, +} + +#[cfg(any(target_os = "android", test))] +impl SyncSecretCrypto { + fn aad(&self, original: &[u8]) -> Vec { + let mut aad = b"openless-e2ee-local-keystore\0v1\0".to_vec(); + aad.extend_from_slice(self.account.as_bytes()); + aad.push(0); + aad.extend_from_slice(original); + aad + } +} + +#[cfg(any(target_os = "android", test))] +impl + super::android_credentials::AndroidCredentialsCrypto for SyncSecretCrypto +{ + fn seal( + &mut self, + plaintext: &[u8], + aad: &[u8], + ) -> std::result::Result< + super::android_credentials::SealedPayload, + super::android_credentials::CryptoErrorKind, + > { + self.inner.seal(plaintext, &self.aad(aad)) + } + fn open( + &mut self, + sealed: &super::android_credentials::SealedPayload, + aad: &[u8], + ) -> std::result::Result, super::android_credentials::CryptoErrorKind> { + self.inner.open(sealed, &self.aad(aad)) + } + fn delete_key( + &mut self, + ) -> std::result::Result<(), super::android_credentials::CryptoErrorKind> { + // This namespace never owns the provider vault's shared master key. + Ok(()) + } + fn migration_complete( + &mut self, + ) -> std::result::Result { + // Sync keys have never had a plaintext format. Reject downgrade without + // reading or modifying the provider vault's independent migration marker. + Ok(true) + } + fn mark_migration_complete( + &mut self, + ) -> std::result::Result<(), super::android_credentials::CryptoErrorKind> { + Ok(()) + } +} + +#[cfg(any(target_os = "android", test))] +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct AndroidSyncKeyRecord { + version: u32, + account: String, + value: String, +} + +#[cfg(any(target_os = "android", test))] +impl Drop for AndroidSyncKeyRecord { + fn drop(&mut self) { + use zeroize::Zeroize; + self.value.zeroize(); + } +} + +#[cfg(any(target_os = "android", test))] +fn read_android_sync_key_with_crypto( + path: &Path, + account: &openless_core::credentials::SyncSecretAccount, + crypto: &mut impl super::android_credentials::AndroidCredentialsCrypto, +) -> Result> { + use super::android_credentials::ReadOutcome; + match std::fs::metadata(path) { + Ok(meta) => anyhow::ensure!( + meta.len() <= 16_384, + "encrypted sync key envelope exceeds limit" + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => anyhow::bail!("could not inspect encrypted sync key envelope"), + } + let bytes = match super::android_credentials::read(path, crypto) + .map_err(|_| anyhow::anyhow!("could not open Android Keystore sync key"))? + { + ReadOutcome::Missing => return Ok(None), + ReadOutcome::Legacy(mut bytes) => { + use zeroize::Zeroize; + bytes.zeroize(); + anyhow::bail!("legacy plaintext sync keys are not supported"); + } + ReadOutcome::Plaintext(bytes) => zeroize::Zeroizing::new(bytes), + }; + let mut record: AndroidSyncKeyRecord = serde_json::from_slice(&bytes) + .map_err(|_| anyhow::anyhow!("invalid encrypted sync key record"))?; + anyhow::ensure!( + record.version == 1 && record.account == account.as_str(), + "encrypted sync key binding mismatch" + ); + let value = openless_core::SecretValue::new(std::mem::take(&mut record.value)); + validate_sync_key(&value)?; + Ok(Some(value)) +} + +#[cfg(any(target_os = "android", test))] +fn write_android_sync_key_with_crypto( + path: &Path, + account: &openless_core::credentials::SyncSecretAccount, + value: &openless_core::SecretValue, + crypto: &mut impl super::android_credentials::AndroidCredentialsCrypto, +) -> Result<()> { + validate_sync_key(value)?; + let record = AndroidSyncKeyRecord { + version: 1, + account: account.as_str().into(), + value: value.expose_secret().into(), + }; + let bytes = zeroize::Zeroizing::new( + serde_json::to_vec(&record) + .map_err(|_| anyhow::anyhow!("cannot encode encrypted sync key record"))?, + ); + super::android_credentials::write_verified(path, &bytes, crypto) + .map_err(|_| anyhow::anyhow!("could not save Android Keystore sync key")) +} + +#[cfg(target_os = "android")] +fn android_sync_key_path( + account: &openless_core::credentials::SyncSecretAccount, +) -> Result { + let credential_path = android_credentials_path()?; + let parent = credential_path + .parent() + .context("Android credential directory is unavailable")?; + Ok(parent + .join("sync-secrets") + .join(format!("{}.json", account.as_str()))) +} + +#[cfg(target_os = "android")] +fn read_sync_secret_raw( + account: &openless_core::credentials::SyncSecretAccount, +) -> Result> { + let mut crypto = SyncSecretCrypto { + inner: super::android_credentials::AndroidKeystoreCrypto, + account: account.as_str().into(), + }; + read_android_sync_key_with_crypto(&android_sync_key_path(account)?, account, &mut crypto) +} + +#[cfg(target_os = "android")] +fn write_sync_secret_raw( + account: &openless_core::credentials::SyncSecretAccount, + value: &openless_core::SecretValue, +) -> Result<()> { + let mut crypto = SyncSecretCrypto { + inner: super::android_credentials::AndroidKeystoreCrypto, + account: account.as_str().into(), + }; + write_android_sync_key_with_crypto( + &android_sync_key_path(account)?, + account, + value, + &mut crypto, + ) +} + +#[cfg(target_os = "android")] +fn remove_sync_secret_raw(account: &openless_core::credentials::SyncSecretAccount) -> Result<()> { + let path = android_sync_key_path(account)?; + // Remove only this binding, never the shared non-exportable Keystore master key. + for item in [ + path.clone(), + path.with_extension("json.tmp"), + path.with_extension("json.pending"), + ] { + super::android_credentials::secure_remove(&item) + .map_err(|_| anyhow::anyhow!("could not remove Android sync key envelope"))?; + } + Ok(()) +} + #[cfg(target_os = "android")] fn android_marketplace_token() -> &'static Mutex> { ANDROID_MARKETPLACE_TOKEN.get_or_init(|| Mutex::new(None)) @@ -120,7 +484,13 @@ fn store_credentials_cache(root: &CredsRoot) { } fn record_vault_read_failure(error: &anyhow::Error) { - let chain = format!("{error:#}"); + // Serde errors may echo a malformed field's raw string (including a secret). + // Preserve native Keystore error categories while keeping payload diagnostics local. + let chain = if error.downcast_ref::().is_some() { + "credential payload could not be decoded".to_string() + } else { + format!("{error:#}") + }; *last_vault_read_error_slot().lock() = Some(chain.clone()); let mut logged = last_vault_read_error_logged_slot().lock(); if logged.as_deref() != Some(chain.as_str()) { @@ -225,7 +595,7 @@ struct CredsProviders { /// 多模态(Omni)模型配置:一个 active provider + 按 provider 隔离的 entry。 /// entry 字段形状与 LLM 对齐(API Key / Base URL / Model / 温度 / 额外请求头), /// 但存放在独立命名空间,绝不与 `providers.llm` 共享槽位。 -#[derive(Debug, Serialize, Deserialize, Default, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] struct CredsOmni { #[serde(default = "creds_default_omni")] active: String, @@ -233,13 +603,26 @@ struct CredsOmni { providers: HashMap, } +impl Default for CredsOmni { + fn default() -> Self { + Self { + active: creds_default_omni(), + providers: HashMap::new(), + } + } +} + fn creds_default_omni() -> String { "custom".into() } -#[derive(Debug, Serialize, Deserialize, Default, Clone)] +#[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq)] #[allow(non_snake_case)] struct CredsOmniEntry { + #[serde(flatten)] + channel: ChannelMeta, + #[serde(skip_serializing_if = "Option::is_none")] + displayName: Option, #[serde(skip_serializing_if = "Option::is_none")] apiKey: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -254,6 +637,9 @@ struct CredsOmniEntry { impl CredsOmniEntry { fn is_empty(&self) -> bool { + if self.channel.providerType.is_some() { + return false; + } self.apiKey.as_deref().unwrap_or("").is_empty() && self.baseURL.as_deref().unwrap_or("").is_empty() && self.model.as_deref().unwrap_or("").is_empty() @@ -296,7 +682,7 @@ impl std::fmt::Debug for MarketplaceGithubToken { /// `None` = v1 老数据,此时 map key 本身就是 providerType(见 `channel_provider_type`)。 /// - `order` 越小越优先,启用列表的第一个即"当前使用"。 /// - 关闭的渠道会被自动排到末尾(见 `commands::channels::toggle`)。 -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[allow(non_snake_case)] struct ChannelMeta { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -338,7 +724,7 @@ fn is_zero(value: &u64) -> bool { /// 「测试连通」的结果,持久化以便重启后仍能看到上次测试的延迟。 /// `error` 同时承担 P0 的失败标红(测试失败)与 P2 的运行时失败标红。 -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] #[allow(non_snake_case)] struct ChannelTest { ok: bool, @@ -628,6 +1014,49 @@ fn migrate_channel_map(map: &mut HashMap, active: /// 渠道 schema 版本:1 = 一个 preset 一个槽;2 = 渠道卡片。 const CHANNELS_SCHEMA_VERSION: u32 = 2; +/// Early Omni vaults used derive(Default), bypassing the serde "custom" default. +/// Legacy account imports could consequently write a complete provider under "". +/// Normalize the loaded copy only; a later gated save owns persistence. Never +/// combine two different endpoint/key/model configurations or discard either one. +fn migrate_legacy_omni_slot(omni: &mut CredsOmni) -> bool { + let Some(legacy) = omni.providers.get("") else { + return false; + }; + if !matches!( + legacy.channel.providerType.as_deref(), + None | Some("" | "custom") + ) { + return false; + } + let mut recovered = legacy.clone(); + recovered.channel.providerType = Some(creds_default_omni()); + if let Some(current) = omni.providers.get("custom") { + if !matches!( + current.channel.providerType.as_deref(), + None | Some("" | "custom") + ) { + return false; + } + let mut normalized = current.clone(); + normalized.channel.providerType = Some(creds_default_omni()); + if normalized != recovered { + return false; + } + } + omni.providers.remove(""); + omni.providers.insert(creds_default_omni(), recovered); + if omni.active.is_empty() { + omni.active = creds_default_omni(); + } + // Readers normalize a copy of the same cache; emit this value-free evidence + // once per process, without logging the source configuration or identifiers. + static RECOVERY_LOGGED: AtomicBool = AtomicBool::new(false); + if !RECOVERY_LOGGED.swap(true, Ordering::Relaxed) { + log::info!("[e2ee-capture] stage=legacy_omni_identity code=recovered"); + } + true +} + /// 就地把 v1 数据补成渠道卡片。返回是否有实际改动(调用方据此决定要不要落盘)。 fn migrate_channels(root: &mut CredsRoot) -> bool { let active_asr = root.active.asr.clone(); @@ -662,7 +1091,8 @@ fn migrate_channels(root: &mut CredsRoot) -> bool { root.active.llm = current_channel_id(&root.providers.llm).unwrap_or_default(); } } - changed + // Omni normalization must not trigger the separate ASR/LLM active fallback. + migrate_legacy_omni_slot(&mut root.omni) || changed } /// 全新安装的平台预置。 @@ -1180,6 +1610,63 @@ fn mark_marketplace_token_verified() { MARKETPLACE_TOKEN_REJECTED.store(false, Ordering::SeqCst); } +#[cfg(any(not(target_os = "android"), test))] +fn set_marketplace_token_with( + gate: Option>, + value: &str, + load: impl FnOnce() -> Result, + persist: impl FnOnce(&CredsRoot) -> Result<()>, +) -> Result<()> { + mutate_credentials_with( + gate, + openless_core::credentials::ChangeOrigin::LocalOnly, + load, + |root| { + write_marketplace_github_token(root, Some(value.to_string())); + Ok(true) + }, + |root| { + persist(root)?; + // Keep verification in the same vault critical section as publication. + if value.trim().is_empty() { + invalidate_marketplace_token_process_local(); + } else { + mark_marketplace_token_verified(); + } + Ok(()) + }, + ) +} + +#[cfg(any(not(target_os = "android"), test))] +fn remove_marketplace_token_with( + gate: Option>, + load: impl FnOnce() -> Result, + persist: impl FnOnce(&CredsRoot) -> Result<()>, +) -> Result<()> { + // Block new reads immediately, including when the gate is busy with restore. + invalidate_marketplace_token_process_local(); + let result = mutate_credentials_with( + gate, + openless_core::credentials::ChangeOrigin::LocalOnly, + load, + |root| { + // A concurrent login could have finished while this worker waited for the lock. + invalidate_marketplace_token_process_local(); + write_marketplace_github_token(root, None); + Ok(true) + }, + persist, + ); + if result.is_err() { + // Read/gate failures happen before the update closure. Reassert the local + // revocation after any earlier in-flight login has left its vault section. + let _guard = credentials_lock().lock(); + invalidate_marketplace_token_process_local(); + } + result +} + fn read_legacy_credentials_file(path: &Path) -> Option { if !path.exists() { return None; @@ -1221,13 +1708,103 @@ fn remove_legacy_credentials_file_best_effort() { struct CredsChunkManifest { openless_credentials_storage: String, version: u32, - /// Earlier vaults used UUID-prefixed chunks. Keep reading them during migration; - /// current chunked writes use stable names so item authorizations can persist. + /// Every new Windows write uses a private generation; old stable chunks remain + /// readable until one atomic manifest update commits the new complete payload. #[serde(default, skip_serializing_if = "Option::is_none")] generation: Option, chunks: usize, } +#[derive(Debug, thiserror::Error)] +pub(crate) enum VaultCommitFailure { + #[error("credential vault write failed before commit")] + Unchanged, + #[error("credential vault commit result requires reconciliation")] + Unknown, +} + +/// Each candidate lives under a new UUID namespace. The single manifest is the +/// only publication point: readers never guess a generation from orphan chunks. +#[cfg(any(not(any(target_os = "macos", target_os = "android")), test))] +fn save_chunked_credentials_with( + json: &str, + mut read: impl FnMut(&str) -> Result>, + mut write: impl FnMut(&str, &str) -> Result<()>, + mut delete: impl FnMut(&str) -> Result<()>, +) -> Result<()> { + let previous_head = + read(KEYRING_CREDENTIALS_ACCOUNT).map_err(|_| VaultCommitFailure::Unchanged)?; + let previous = match previous_head.as_deref() { + Some(head) => match read_chunk_manifest(head) { + Some(manifest) if manifest.chunks > 0 => Some(manifest), + Some(_) => return Err(VaultCommitFailure::Unchanged.into()), + None => { + decode_single_credentials(head).map_err(|_| VaultCommitFailure::Unchanged)?; + None + } + }, + None => None, + }; + decode_single_credentials(json).map_err(|_| VaultCommitFailure::Unchanged)?; + let generation = uuid::Uuid::new_v4().to_string(); + let chunks = chunk_json_payload(json); + let accounts = (0..chunks.len()) + .map(|index| chunk_account(Some(&generation), index)) + .collect::>(); + let candidate = CredsChunkManifest { + openless_credentials_storage: "chunked".into(), + version: 1, + generation: Some(generation), + chunks: chunks.len(), + }; + let candidate_head = + serde_json::to_string(&candidate).map_err(|_| VaultCommitFailure::Unchanged)?; + let prepared = (|| -> Result<()> { + for (account, chunk) in accounts.iter().zip(&chunks) { + write(account, chunk)?; + } + let mut recovered = zeroize::Zeroizing::new(String::with_capacity(json.len())); + for account in &accounts { + let chunk = + zeroize::Zeroizing::new(read(account)?.ok_or(VaultCommitFailure::Unchanged)?); + if recovered.len().saturating_add(chunk.len()) > json.len() { + return Err(VaultCommitFailure::Unchanged.into()); + } + recovered.push_str(&chunk); + } + if recovered.as_str() != json { + return Err(VaultCommitFailure::Unchanged.into()); + } + Ok(()) + })(); + if prepared.is_err() { + for account in &accounts { + let _ = delete(account); + } + return Err(VaultCommitFailure::Unchanged.into()); + } + + // Even a failed native call may have committed. Confirm the exact head before + // deciding whether a candidate may be discarded, or old chunks may be pruned. + let _publication = write(KEYRING_CREDENTIALS_ACCOUNT, &candidate_head); + match read(KEYRING_CREDENTIALS_ACCOUNT) { + Ok(Some(head)) if head == candidate_head => {} + Ok(head) if head == previous_head => { + for account in &accounts { + let _ = delete(account); + } + return Err(VaultCommitFailure::Unchanged.into()); + } + _ => return Err(VaultCommitFailure::Unknown.into()), + } + if let Some(previous) = previous { + for index in 0..previous.chunks { + let _ = delete(&chunk_account(previous.generation.as_deref(), index)); + } + } + Ok(()) +} + /// Legacy UUID-prefixed and current stable chunk names share one reader. fn chunk_account(generation: Option<&str>, index: usize) -> String { match generation { @@ -1413,7 +1990,6 @@ fn load_keyring_credentials_with( Ok(Some(root)) } -#[cfg(any(not(target_os = "android"), test))] fn decode_single_credentials(json: &str) -> Result { // Serde defaults alone would accept unrelated JSON as an empty configuration. let payload: serde_json::Value = @@ -1543,45 +2119,185 @@ fn load_credentials_for_update() -> Result { Ok(root) } -fn load_credentials_raw() -> CredsRoot { - if let Some(cached) = credentials_cache().lock().as_ref().cloned() { - return cached; - } - - #[cfg(target_os = "android")] - { - return load_credentials_into_cache_with(load_android_credentials); - } - +/// Pending restore startup may inspect existing sources, but the restore lease +/// exclusively owns their eventual migration/replacement. Never synthesize an +/// empty vault from a failed native read or a corrupt legacy source. +fn load_credentials_for_recovery_readonly() -> Result { #[cfg(not(target_os = "android"))] - load_credentials_into_cache_with(|| { - // Legacy accounts are probed only after a definitive NoEntry. Retrying - // them after an authorization error creates more prompts, not a fallback. - match load_keyring_credentials()? { - Some(root) => { - remove_legacy_credentials_file_best_effort(); - Ok(Some(root)) + let mut root = load_desktop_credentials_readonly_with( + get_keyring_password, + || { + let path = credentials_path()?; + match std::fs::read(path) { + Ok(bytes) => { + let bytes = zeroize::Zeroizing::new(bytes); + let json = std::str::from_utf8(&bytes) + .context("invalid legacy credential encoding")?; + decode_single_credentials(json).map(Some) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(_) => anyhow::bail!("could not read legacy credential source"), + } + }, + cfg!(target_os = "macos"), + )?; + #[cfg(target_os = "android")] + let mut root = { + let path = android_credentials_path()?; + let mut crypto = super::android_credentials::AndroidKeystoreCrypto; + let mut loaded = load_android_credentials_readonly_at(&path, &mut crypto)?; + if loaded.is_none() { + for source in android_legacy_credentials_paths(&path) { + loaded = load_android_credentials_readonly_at(&source, &mut crypto)?; + if loaded.is_some() { + break; + } } - None => migrate_legacy_sources_for_update().map(Some), } - }) + loaded.unwrap_or_default() + }; + migrate_channels(&mut root); + Ok(root) } -fn load_credentials_for_update_raw() -> Result { - if let Some(cached) = credentials_cache().lock().as_ref().cloned() { - return Ok(cached); +#[cfg(any(not(target_os = "android"), test))] +fn load_desktop_credentials_readonly_with( + mut read: impl FnMut(&str) -> Result>, + legacy_file: impl FnOnce() -> Result>, + prefer_single: bool, +) -> Result { + if prefer_single { + if let Some(json) = read(KEYRING_SINGLE_CREDENTIALS_ACCOUNT)? { + let json = zeroize::Zeroizing::new(json); + return decode_single_credentials(&json); + } } - - #[cfg(target_os = "android")] - { - return android_credentials_root_for_update(load_android_credentials); + if let Some(root) = load_keyring_credentials_with( + &mut read, + |_, _| anyhow::bail!("read-only credential loading cannot consolidate"), + false, + )? { + return Ok(root); + } + if let Some(root) = legacy_file()? { + return Ok(root); + } + let mut root = CredsRoot::default(); + for account in CredentialAccount::all() { + if let Some(value) = read(account.keyring_account())? { + write_account(&mut root, *account, Some(value)); + } } + Ok(clean_credentials(&root)) +} - #[cfg(not(target_os = "android"))] - match load_keyring_credentials() { - Ok(Some(root)) => { - // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring - // entries,避免反复触发 macOS Keychain ACL 弹窗。 +#[cfg(any(target_os = "android", test))] +fn load_android_credentials_readonly_at( + path: &Path, + crypto: &mut impl super::android_credentials::AndroidCredentialsCrypto, +) -> Result> { + use super::android_credentials::ReadOutcome; + let bytes = + match super::android_credentials::read_only(path, crypto).map_err(anyhow::Error::new)? { + ReadOutcome::Missing => return Ok(None), + ReadOutcome::Plaintext(bytes) | ReadOutcome::Legacy(bytes) => { + zeroize::Zeroizing::new(bytes) + } + }; + let json = + std::str::from_utf8(&bytes).context("invalid read-only Android credential encoding")?; + let root = decode_single_credentials(json)?; + // Legacy OAuth remains excluded even while the encrypted restore barrier + // temporarily prevents rewriting its old source envelope. + Ok(Some(android_persistable_credentials(&root))) +} + +fn load_credentials_for_sync_binding_with( + readonly_required: bool, + readonly: impl FnOnce() -> Result, + ordinary: impl FnOnce() -> Result, +) -> Result { + if readonly_required { + readonly() + } else { + ordinary() + } +} + +/// Bound readers may run before recovery or while an exclusive restore is +/// preparing. Only inspect complete sources; the next gated save can migrate +/// legacy storage. A failed read leaves no default cache and remains retryable. +fn load_credentials_readonly_into_cache_with( + loader: impl FnOnce() -> Result, +) -> Result { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + return Ok(cached); + } + match loader() { + Ok(root) => { + clear_vault_read_error(); + store_credentials_cache(&root); + Ok(root) + } + Err(error) => { + record_vault_read_failure(&error); + Err(error) + } + } +} + +fn load_credentials_raw() -> CredsRoot { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + return cached; + } + + if sync_write_gate().is_some() { + return load_credentials_readonly_into_cache_with(load_credentials_for_recovery_readonly) + .unwrap_or_default(); + } + + #[cfg(target_os = "android")] + { + return load_credentials_into_cache_with(load_android_credentials); + } + + #[cfg(not(target_os = "android"))] + load_credentials_into_cache_with(|| { + // Legacy accounts are probed only after a definitive NoEntry. Retrying + // them after an authorization error creates more prompts, not a fallback. + match load_keyring_credentials()? { + Some(root) => { + remove_legacy_credentials_file_best_effort(); + Ok(Some(root)) + } + None => migrate_legacy_sources_for_update().map(Some), + } + }) +} + +fn load_credentials_for_update_raw() -> Result { + if let Some(cached) = credentials_cache().lock().as_ref().cloned() { + return Ok(cached); + } + + load_credentials_for_sync_binding_with( + sync_write_gate().is_some(), + || load_credentials_readonly_into_cache_with(load_credentials_for_recovery_readonly), + load_credentials_for_update_unbound, + ) +} + +fn load_credentials_for_update_unbound() -> Result { + #[cfg(target_os = "android")] + { + return android_credentials_root_for_update(load_android_credentials); + } + + #[cfg(not(target_os = "android"))] + match load_keyring_credentials() { + Ok(Some(root)) => { + // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring + // entries,避免反复触发 macOS Keychain ACL 弹窗。 remove_legacy_credentials_file_best_effort(); clear_vault_read_error(); store_credentials_cache(&root); @@ -1604,6 +2320,26 @@ fn load_credentials_for_update_raw() -> Result { } } +fn finish_credential_write(root: &CredsRoot, outcome: Result<()>) -> Result<()> { + match outcome { + Ok(()) => { + store_credentials_cache(root); + Ok(()) + } + Err(error) => { + // A native write may have committed even though confirmation failed. + // Reconciliation must read the OS store rather than a stale process cache. + if !matches!( + error.downcast_ref::(), + Some(VaultCommitFailure::Unchanged) + ) { + *credentials_cache().lock() = None; + } + Err(error) + } + } +} + fn save_credentials(root: &CredsRoot) -> Result<()> { let mut cleaned = clean_credentials(root); let current_revision = credentials_cache() @@ -1618,8 +2354,7 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { #[cfg(target_os = "android")] { - save_android_credentials(&cleaned)?; - store_credentials_cache(&cleaned); + finish_credential_write(&cleaned, save_android_credentials(&cleaned))?; return Ok(()); } @@ -1628,8 +2363,10 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; // Updating this stable item keeps the user's authorization attached to it. // Do not recreate entries or rewrite/delete legacy chunks on each save. - set_keyring_password(KEYRING_SINGLE_CREDENTIALS_ACCOUNT, &json)?; - store_credentials_cache(&cleaned); + finish_credential_write( + &cleaned, + set_keyring_password(KEYRING_SINGLE_CREDENTIALS_ACCOUNT, &json), + )?; remove_legacy_credentials_file_best_effort(); return Ok(()); } @@ -1637,55 +2374,17 @@ fn save_credentials(root: &CredsRoot) -> Result<()> { #[cfg(not(any(target_os = "android", target_os = "macos")))] { let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; - let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) - .ok() - .flatten() - .and_then(|value| read_chunk_manifest(&value)); - let chunks = chunk_json_payload(&json); - - // Publish the chunk count only after all writes succeed. Existing chunk - // names remain stable; this format is retained for bounded platform stores. - for (index, chunk) in chunks.iter().enumerate() { - let account = chunk_account(None, index); - keyring_entry_for(&account)? - .set_password(chunk) - .with_context(|| format!("write system credential vault chunk {index}"))?; - } - - let manifest = CredsChunkManifest { - openless_credentials_storage: "chunked".to_string(), - version: 1, - generation: None, - chunks: chunks.len(), - }; - let manifest_json = - serde_json::to_string(&manifest).context("encode credential manifest failed")?; - keyring_entry()? - .set_password(&manifest_json) - .context("write system credential vault manifest")?; - - // 清理旧 chunks: - // 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name) - // 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx - if let Some(previous) = previous_manifest { - match previous.generation.as_deref() { - Some(prev_gen) => { - for index in 0..previous.chunks { - delete_keyring_password(&chunk_account(Some(prev_gen), index)); - } - } - None => { - for index in chunks.len()..previous.chunks { - delete_keyring_password(&chunk_account(None, index)); - } - } - } - } - + let outcome = save_chunked_credentials_with( + &json, + get_keyring_password, + set_keyring_password, + |account| { + delete_keyring_password(account); + Ok(()) + }, + ); + finish_credential_write(&cleaned, outcome)?; remove_legacy_credentials_file_best_effort(); - // 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。 - // 见 CREDENTIALS_CACHE 的 doc。 - store_credentials_cache(&cleaned); Ok(()) } } @@ -2175,6 +2874,423 @@ fn channel_has_secrets(root: &CredsRoot, kind: ChannelKind, id: &str) -> bool { } } +fn canonical_headers(headers: &Option>) -> Result> { + headers + .as_ref() + .map(|values| { + let ordered = values.iter().collect::>(); + serde_json::to_string(&ordered).context("encode sync provider headers") + }) + .transpose() +} + +fn sync_channel( + id: &str, + namespace: openless_core::credentials::SyncNamespace, + meta: &ChannelMeta, + name: &Option, + active: &str, +) -> openless_core::credentials::SyncChannel { + openless_core::credentials::SyncChannel { + id: id.into(), + namespace, + provider_type: meta.providerType.clone().unwrap_or_else(|| id.into()), + name: name.clone().unwrap_or_default(), + enabled: meta.enabled, + order: meta.order.unwrap_or(u32::MAX), + active: id == active, + } +} + +/// Project actual storage fields, not lookup_account's compatibility fallbacks. +/// In particular appKey=None must not become a duplicated apiKey after a restore. +fn export_sync_credentials_root( + root: &CredsRoot, +) -> Result { + use openless_core::credentials::{SyncCredentialRecord, SyncCredentials, SyncNamespace}; + let mut snapshot = SyncCredentials { + channels: Vec::new(), + credentials: Vec::new(), + }; + macro_rules! accounts { + ($entry:expr, $( $name:literal => $field:ident ),+ $(,)?) => {{ + let mut values = BTreeMap::new(); + $(if let Some(value) = &$entry.$field { values.insert($name.into(), value.clone()); })+ + values + }}; + } + for (id, entry) in &root.providers.asr { + snapshot.channels.push(sync_channel( + id, + SyncNamespace::Asr, + &entry.channel, + &entry.displayName, + &root.active.asr, + )); + snapshot.credentials.push(SyncCredentialRecord { channel_id: id.clone(), namespace: SyncNamespace::Asr, + accounts: accounts!(entry, + "asr.api_key"=>apiKey, "asr.endpoint"=>baseURL, "asr.model"=>model, + "asr.vocabulary_id"=>vocabularyId, "asr.advanced_config"=>advancedConfig, + "volcengine.app_key"=>appKey, "volcengine.access_key"=>accessKey, + "volcengine.resource_id"=>resourceId, "volcengine.service"=>volcengineService, + "volcengine.auth_mode"=>authMode, "volcengine.api_key"=>volcengineApiKey, + "xfyun.app_id"=>xfyunAppId, "xfyun.api_key"=>xfyunApiKey, + "tencent_cloud.app_id"=>tencentCloudAppId, "tencent_cloud.secret_id"=>tencentCloudSecretId, + "tencent_cloud.secret_key"=>tencentCloudSecretKey), + }); + } + for (id, entry) in &root.providers.llm { + snapshot.channels.push(sync_channel( + id, + SyncNamespace::Llm, + &entry.channel, + &entry.displayName, + &root.active.llm, + )); + let mut values = accounts!(entry, "ark.api_key"=>apiKey, "ark.endpoint"=>baseURL, "ark.model_id"=>model, + "ark.request_format"=>requestFormat, "ark.messages_thinking"=>messagesThinking, + "ark.max_tokens"=>maxTokens, "ark.thinking_budget"=>thinkingBudget); + if let Some(value) = entry.temperature { + values.insert("ark.temperature".into(), value.to_string()); + } + if let Some(value) = canonical_headers(&entry.extraHeaders)? { + values.insert("ark.extra_headers".into(), value); + } + snapshot.credentials.push(SyncCredentialRecord { + channel_id: id.clone(), + namespace: SyncNamespace::Llm, + accounts: values, + }); + } + let mut omni_ids = root.omni.providers.keys().cloned().collect::>(); + // Omni has a fixed-provider selector, which may be persisted before any key. + if !root.omni.active.is_empty() && !omni_ids.contains(&root.omni.active) { + omni_ids.push(root.omni.active.clone()); + } + omni_ids.sort(); + for (index, id) in omni_ids.iter().enumerate() { + let empty = CredsOmniEntry::default(); + let entry = root.omni.providers.get(id).unwrap_or(&empty); + let mut channel = sync_channel( + id, + SyncNamespace::Omni, + &entry.channel, + &entry.displayName, + &root.omni.active, + ); + if entry.channel.order.is_none() { + channel.order = u32::try_from(index).context("too many Omni providers")?; + } + snapshot.channels.push(channel); + let mut values = + accounts!(entry, "omni.api_key"=>apiKey, "omni.endpoint"=>baseURL, "omni.model"=>model); + if let Some(value) = entry.temperature { + values.insert("omni.temperature".into(), value.to_string()); + } + if let Some(value) = canonical_headers(&entry.extraHeaders)? { + values.insert("omni.extra_headers".into(), value); + } + snapshot.credentials.push(SyncCredentialRecord { + channel_id: id.clone(), + namespace: SyncNamespace::Omni, + accounts: values, + }); + } + snapshot + .channels + .sort_by(|a, b| (a.namespace, a.order, &a.id).cmp(&(b.namespace, b.order, &b.id))); + snapshot + .credentials + .sort_by(|a, b| (a.namespace, &a.channel_id).cmp(&(b.namespace, &b.channel_id))); + snapshot.validate().map_err(|error| { + // Recheck the pure validator only to retain its fixed, value-free cause. + // The original rejection and BackendError remain unchanged. + if let Err(cause) = openless_core::cloud_sync_e2ee_documents::validate_credential_set( + &snapshot.channels, + &snapshot.credentials, + ) { + log::warn!("[e2ee-capture] stage=credential_validation code={cause}"); + } + anyhow::Error::new(error) + })?; + Ok(snapshot) +} + +/// Classify typed causes without formatting an error, credential attribute or blob. +fn sync_capture_read_error_code(error: &anyhow::Error) -> &'static str { + let mut fallback = "native_store_failed"; + for cause in error.chain() { + if let Some(code) = sync_keyring_error_code(cause) { + if code != "keyring_platform_failure" { + return code; + } + fallback = code; + } + if let Some(error) = cause.downcast_ref::() { + use serde_json::error::Category; + return match error.classify() { + Category::Io => error + .io_error_kind() + .map(sync_io_error_code) + .unwrap_or("json_io"), + Category::Syntax => "json_syntax", + Category::Data => "json_data", + Category::Eof => "json_eof", + }; + } + if let Some(error) = cause.downcast_ref::() { + return sync_io_error_code(error.kind()); + } + if cause.is::() || cause.is::() { + return "invalid_utf8"; + } + if let Some(error) = cause.downcast_ref::() { + use openless_core::BackendErrorCode; + return match error.code { + BackendErrorCode::PermissionDenied => "backend_permission_denied", + BackendErrorCode::InvalidArgument => "backend_invalid_argument", + BackendErrorCode::Persistence => "backend_persistence", + BackendErrorCode::OutcomeUnknown => "backend_outcome_unknown", + BackendErrorCode::Unsupported => "backend_unsupported", + _ => "backend_failed", + }; + } + } + fallback +} + +fn sync_io_error_code(kind: std::io::ErrorKind) -> &'static str { + use std::io::ErrorKind; + match kind { + ErrorKind::PermissionDenied => "io_permission_denied", + ErrorKind::NotFound => "io_not_found", + ErrorKind::InvalidData => "io_invalid_data", + ErrorKind::InvalidInput => "io_invalid_input", + ErrorKind::UnexpectedEof => "io_unexpected_eof", + ErrorKind::TimedOut => "io_timed_out", + ErrorKind::WouldBlock => "io_would_block", + ErrorKind::Interrupted => "io_interrupted", + _ => "io_other", + } +} + +fn sync_keyring_error_code(_cause: &(dyn std::error::Error + 'static)) -> Option<&'static str> { + #[cfg(not(target_os = "android"))] + if let Some(error) = _cause.downcast_ref::() { + return Some(match error { + keyring::Error::NoStorageAccess(_) => "keyring_access_denied", + keyring::Error::NoEntry => "keyring_no_entry", + keyring::Error::BadEncoding(_) => "keyring_bad_encoding", + keyring::Error::TooLong(_, _) => "keyring_attribute_too_long", + keyring::Error::Invalid(_, _) => "keyring_invalid_attribute", + keyring::Error::Ambiguous(_) => "keyring_ambiguous", + keyring::Error::PlatformFailure(_) => "keyring_platform_failure", + _ => "keyring_other", + }); + } + None +} + +fn sync_identity_error_code(value: &str) -> Option<&'static str> { + if value.is_empty() { + Some("empty") + } else if value.len() > 512 { + Some("too_long") + } else if matches!(value, "." | "..") { + Some("dot_segment") + } else if value.chars().any(char::is_control) { + Some("control_character") + } else if value.contains(['/', '\\']) { + Some("path_separator") + } else { + None + } +} + +fn capture_sync_credentials_with( + load: impl FnOnce() -> Result, +) -> Result { + let root = load().inspect_err(|error| { + let code = sync_capture_read_error_code(error); + log::warn!("[e2ee-capture] stage=credential_load code={code}"); + })?; + export_sync_credentials_root(&root).inspect_err(|_| { + // Metadata counts identify invalid legacy state without exposing channel names, + // accounts, secret values, provider IDs, endpoints, or underlying error bodies. + let disabled_active = usize::from(root.providers.asr.get(&root.active.asr).is_some_and(|entry| !entry.channel.enabled)) + + usize::from(root.providers.llm.get(&root.active.llm).is_some_and(|entry| !entry.channel.enabled)) + + usize::from(root.omni.providers.get(&root.omni.active).is_some_and(|entry| !entry.channel.enabled)); + let mismatched_omni = root.omni.providers.iter().filter(|(id,entry)| entry.channel.providerType.as_ref().is_some_and(|provider|provider!=*id)).count(); + let identities = root.providers.asr.iter().map(|(id, entry)| ("asr", id, &entry.channel)) + .chain(root.providers.llm.iter().map(|(id, entry)| ("llm", id, &entry.channel))) + .chain(root.omni.providers.iter().map(|(id, entry)| ("omni", id, &entry.channel))); + for (namespace, id, meta) in identities { + for (field, value) in [("channel_id", Some(id.as_str())), ("provider_type", meta.providerType.as_deref())] { + if let Some(code) = value.and_then(sync_identity_error_code) { + log::warn!("[e2ee-capture] stage=credential_identity namespace={namespace} field={field} code={code}"); + } + } + } + let invalid_id = |value: &str| sync_identity_error_code(value).is_some(); + let invalid_identities = root.providers.asr.iter().filter(|(id,entry)|invalid_id(id) || entry.channel.providerType.as_deref().is_some_and(invalid_id)).count() + + root.providers.llm.iter().filter(|(id,entry)|invalid_id(id) || entry.channel.providerType.as_deref().is_some_and(invalid_id)).count() + + root.omni.providers.iter().filter(|(id,entry)|invalid_id(id) || entry.channel.providerType.as_deref().is_some_and(invalid_id)).count(); + log::warn!("[e2ee-capture] stage=credential_projection code=invalid_snapshot disabled_active={disabled_active} mismatched_omni={mismatched_omni} invalid_identities={invalid_identities}"); + }) +} + +fn restored_channel_meta(channel: &openless_core::credentials::SyncChannel) -> ChannelMeta { + ChannelMeta { + providerType: Some(channel.provider_type.clone()), + order: Some(channel.order), + enabled: channel.enabled, + lastTest: None, + } +} + +fn apply_sync_credentials_root( + root: &CredsRoot, + snapshot: &openless_core::credentials::SyncCredentials, +) -> Result { + use openless_core::credentials::SyncNamespace; + snapshot.validate().map_err(anyhow::Error::new)?; + let accounts = snapshot + .credentials + .iter() + .map(|record| { + ( + (record.namespace, record.channel_id.as_str()), + &record.accounts, + ) + }) + .collect::>(); + let mut next = root.clone(); // Preserve Marketplace OAuth and all unrelated local root state. + next.version = CHANNELS_SCHEMA_VERSION; + next.providers = CredsProviders::default(); + next.omni.providers.clear(); + next.active.asr.clear(); + next.active.llm.clear(); + next.omni.active.clear(); + for channel in &snapshot.channels { + let values = accounts + .get(&(channel.namespace, channel.id.as_str())) + .context("missing sync credential channel")?; + let value = |name: &str| values.get(name).cloned(); + let name = (!channel.name.is_empty()).then(|| channel.name.clone()); + match channel.namespace { + SyncNamespace::Asr => { + next.providers.asr.insert( + channel.id.clone(), + CredsAsrEntry { + channel: restored_channel_meta(channel), + displayName: name, + apiKey: value("asr.api_key"), + baseURL: value("asr.endpoint"), + model: value("asr.model"), + vocabularyId: value("asr.vocabulary_id"), + advancedConfig: value("asr.advanced_config"), + appKey: value("volcengine.app_key"), + accessKey: value("volcengine.access_key"), + resourceId: value("volcengine.resource_id"), + volcengineService: value("volcengine.service"), + authMode: value("volcengine.auth_mode"), + volcengineApiKey: value("volcengine.api_key"), + xfyunAppId: value("xfyun.app_id"), + xfyunApiKey: value("xfyun.api_key"), + tencentCloudAppId: value("tencent_cloud.app_id"), + tencentCloudSecretId: value("tencent_cloud.secret_id"), + tencentCloudSecretKey: value("tencent_cloud.secret_key"), + }, + ); + if channel.active { + next.active.asr = channel.id.clone(); + } + } + SyncNamespace::Llm => { + let mut protocol = openless_core::llm_protocol::LlmProtocolConfig { + format: openless_core::llm_protocol::LlmRequestFormat::default_for( + &channel.provider_type, + ), + ..Default::default() + }; + for key in [ + "ark.request_format", + "ark.messages_thinking", + "ark.max_tokens", + "ark.thinking_budget", + ] { + if let Some(value) = values.get(key) { + protocol.apply(key, value)?; + } + } + let temperature = values + .get("ark.temperature") + .map(|value| parse_llm_temperature(value)) + .transpose()? + .flatten(); + let headers = values + .get("ark.extra_headers") + .map(|value| parse_extra_headers_json(value)) + .transpose()?; + protocol.validate()?; + if let Some(headers) = &headers { + protocol.validate_headers(headers)?; + } + next.providers.llm.insert( + channel.id.clone(), + CredsLlmEntry { + channel: restored_channel_meta(channel), + displayName: name, + apiKey: value("ark.api_key"), + baseURL: value("ark.endpoint"), + model: value("ark.model_id"), + temperature, + extraHeaders: headers, + requestFormat: value("ark.request_format"), + messagesThinking: value("ark.messages_thinking"), + maxTokens: value("ark.max_tokens"), + thinkingBudget: value("ark.thinking_budget"), + }, + ); + if channel.active { + next.active.llm = channel.id.clone(); + } + } + SyncNamespace::Omni => { + anyhow::ensure!( + channel.id == channel.provider_type, + "Omni provider identity cannot be remapped" + ); + let temperature = values + .get("omni.temperature") + .map(|value| parse_llm_temperature(value)) + .transpose()? + .flatten(); + let headers = values + .get("omni.extra_headers") + .map(|value| parse_extra_headers_json(value)) + .transpose()?; + next.omni.providers.insert( + channel.id.clone(), + CredsOmniEntry { + channel: restored_channel_meta(channel), + displayName: name, + apiKey: value("omni.api_key"), + baseURL: value("omni.endpoint"), + model: value("omni.model"), + temperature, + extraHeaders: headers, + }, + ); + if channel.active { + next.omni.active = channel.id.clone(); + } + } + } + } + Ok(next) +} + /// 凭据存储——系统凭据库;旧 JSON 文件只作为迁移来源。 pub struct CredentialsVault; @@ -2182,6 +3298,91 @@ impl CredentialsVault { /// 系统凭据库 service name;macOS 下对应 Keychain service。 pub const SERVICE_NAME: &'static str = "com.openless.app"; + pub(crate) fn bind_sync_gate( + gate: std::sync::Arc, + ) -> Result<()> { + let _guard = credentials_lock().lock(); + let mut installed = SYNC_WRITE_GATE.get_or_init(|| Mutex::new(None)).lock(); + // Binding is deliberately independent of OS authorization and migration. + // Status/read/export lazily inspect the native store under this barrier; + // denial must remain retryable rather than disabling sync construction. + install_sync_write_gate(&mut installed, gate) + } + + pub(crate) fn export_sync_credentials_readonly( + ) -> Result { + let _guard = credentials_lock().lock(); + // Cold capture uses the same strict read-only loader as bound getters. + // Legacy sources remain readable without migration, even before startup + // recovery. Failed OS access leaves the next capture free to retry. + anyhow::ensure!( + sync_write_gate().is_some(), + "encrypted sync credential gate is not bound" + ); + capture_sync_credentials_with(|| { + let mut root = + load_credentials_readonly_into_cache_with(load_credentials_for_recovery_readonly)?; + migrate_channels(&mut root); + Ok(root) + }) + } + + pub(crate) fn export_sync_credentials( + permit: &openless_core::credentials::ExclusivePermit, + ) -> Result { + require_sync_exclusive(permit)?; + let _guard = credentials_lock().lock(); + capture_sync_credentials_with(load_credentials_for_update) + } + + pub(crate) fn replace_sync_credentials( + snapshot: &openless_core::credentials::SyncCredentials, + permit: &openless_core::credentials::ExclusivePermit, + ) -> Result<()> { + require_sync_exclusive(permit)?; + let _guard = credentials_lock().lock(); + let current = load_credentials_for_update()?; + let replacement = apply_sync_credentials_root(¤t, snapshot)?; + // The outer restore transaction owns the exclusive generation/receipt. + save_credentials(&replacement) + } + + pub(crate) fn read_sync_secret( + account: &openless_core::credentials::SyncSecretAccount, + ) -> Result> { + let _guard = credentials_lock().lock(); + read_sync_secret_raw(account) + } + + pub(crate) fn write_sync_secret( + account: &openless_core::credentials::SyncSecretAccount, + value: &openless_core::SecretValue, + ) -> Result<()> { + validate_sync_key(value)?; + let _guard = credentials_lock().lock(); + if let Some(existing) = read_sync_secret_raw(account)? { + if existing == *value { + return Ok(()); + } + anyhow::bail!("refusing to replace an existing encrypted sync key binding"); + } + write_sync_secret_raw(account, value)?; + let stored = read_sync_secret_raw(account)? + .context("encrypted sync key write could not be verified")?; + anyhow::ensure!( + stored == *value, + "encrypted sync key write verification failed" + ); + Ok(()) + } + + pub(crate) fn remove_sync_secret( + account: &openless_core::credentials::SyncSecretAccount, + ) -> Result<()> { + let _guard = credentials_lock().lock(); + remove_sync_secret_raw(account) + } + /// Last envelope/keyring read failure, if this process has not successfully /// loaded credentials since. Distinguishes "vault unreadable" from /// "user has not configured a provider" (empty default is volcengine). @@ -2195,10 +3396,13 @@ impl CredentialsVault { } pub fn save_metadata(metadata: openless_core::CredentialMetadata) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - apply_credential_metadata(&mut root, metadata)?; - save_credentials(&root) + mutate_credentials( + openless_core::credentials::ChangeOrigin::User, + move |root| { + apply_credential_metadata(root, metadata)?; + Ok(true) + }, + ) } pub fn channel_has_secrets(kind: ChannelKind, id: &str) -> Result { @@ -2216,15 +3420,14 @@ impl CredentialsVault { } pub fn set(account: CredentialAccount, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - let v = if value.is_empty() { - None - } else { - Some(value.to_string()) - }; - write_account(&mut root, account, v); - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + write_account( + root, + account, + (!value.is_empty()).then(|| value.to_string()), + ); + Ok(true) + }) } pub fn get_for_asr_provider(id: &str, account: CredentialAccount) -> Result> { @@ -2235,21 +3438,24 @@ impl CredentialsVault { } pub fn set_for_asr_provider(id: &str, account: CredentialAccount, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - let active = root.active.asr.clone(); - root.active.asr = id.to_string(); - let value = (!value.is_empty()).then(|| value.to_string()); - write_account(&mut root, account, value); - root.active.asr = active; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + let active = root.active.asr.clone(); + root.active.asr = id.to_string(); + write_account( + root, + account, + (!value.is_empty()).then(|| value.to_string()), + ); + root.active.asr = active; + Ok(true) + }) } pub fn remove(account: CredentialAccount) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - write_account(&mut root, account, None); - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + write_account(root, account, None); + Ok(true) + }) } /// GitHub OAuth token for authenticated marketplace operations. @@ -2277,9 +3483,9 @@ impl CredentialsVault { } pub fn set_marketplace_github_token(value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); #[cfg(target_os = "android")] { + let _guard = credentials_lock().lock(); ensure_android_marketplace_legacy_scrubbed()?; *android_marketplace_token().lock() = (!value.trim().is_empty()).then(|| MarketplaceGithubToken(value.to_string())); @@ -2288,34 +3494,30 @@ impl CredentialsVault { } else { mark_marketplace_token_verified(); } - return Ok(()); - } - #[cfg(not(target_os = "android"))] - { - let mut root = load_credentials_for_update()?; - write_marketplace_github_token(&mut root, Some(value.to_string())); - save_credentials(&root)?; - mark_marketplace_token_verified(); Ok(()) } + #[cfg(not(target_os = "android"))] + set_marketplace_token_with( + sync_write_gate(), + value, + load_credentials_for_update, + save_credentials, + ) } pub fn remove_marketplace_github_token() -> Result<()> { - let _guard = credentials_lock().lock(); - invalidate_marketplace_token_with(|| { - #[cfg(target_os = "android")] - { - // Retry the durable legacy scrub on every logout until it has - // actually completed. Process memory is already invalidated. - return ensure_android_marketplace_legacy_scrubbed(); - } - #[cfg(not(target_os = "android"))] - { - let mut root = load_credentials_for_update()?; - write_marketplace_github_token(&mut root, None); - save_credentials(&root) - } - }) + #[cfg(target_os = "android")] + { + invalidate_marketplace_token_process_local(); + let _guard = credentials_lock().lock(); + invalidate_marketplace_token_with(ensure_android_marketplace_legacy_scrubbed) + } + #[cfg(not(target_os = "android"))] + remove_marketplace_token_with( + sync_write_gate(), + load_credentials_for_update, + save_credentials, + ) } #[cfg(test)] @@ -2408,14 +3610,17 @@ impl CredentialsVault { } pub fn set_for_llm_provider(id: &str, account: CredentialAccount, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - let active = root.active.llm.clone(); - root.active.llm = id.to_string(); - let value = (!value.is_empty()).then(|| value.to_string()); - write_account(&mut root, account, value); - root.active.llm = active; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + let active = root.active.llm.clone(); + root.active.llm = id.to_string(); + write_account( + root, + account, + (!value.is_empty()).then(|| value.to_string()), + ); + root.active.llm = active; + Ok(true) + }) } pub fn get_active_omni() -> String { @@ -2428,18 +3633,18 @@ impl CredentialsVault { } fn select_active_provider(slot: openless_core::ProviderSlot, id: &str) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - let mut metadata = credential_metadata(&root); - let revision = metadata.revision(); - metadata - .select_active_provider(slot, id.to_string()) - .map_err(|error| anyhow::anyhow!(error.message))?; - if metadata.revision() == revision { - return Ok(()); - } - apply_credential_metadata(&mut root, metadata)?; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + let mut metadata = credential_metadata(root); + let revision = metadata.revision(); + metadata + .select_active_provider(slot, id.to_string()) + .map_err(anyhow::Error::new)?; + if metadata.revision() == revision { + return Ok(false); + } + apply_credential_metadata(root, metadata)?; + Ok(true) + }) } pub fn get_for_omni_provider(id: &str, account: CredentialAccount) -> Result> { @@ -2448,15 +3653,15 @@ impl CredentialsVault { } pub fn set_for_omni_provider(id: &str, account: CredentialAccount, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); - let mut root = load_credentials_for_update()?; - write_omni_account( - &mut root, - id, - account, - (!value.is_empty()).then(|| value.to_string()), - )?; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + write_omni_account( + root, + id, + account, + (!value.is_empty()).then(|| value.to_string()), + )?; + Ok(true) + }) } pub fn get_active_omni_extra_headers() -> HashMap { @@ -2490,57 +3695,51 @@ impl CredentialsVault { } pub fn set_active_omni_temperature(value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let temperature = parse_llm_temperature(value)?; - let mut root = load_credentials_for_update()?; - let entry = root - .omni - .providers - .entry(root.omni.active.clone()) - .or_default(); - entry.temperature = temperature; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.omni + .providers + .entry(root.omni.active.clone()) + .or_default() + .temperature = temperature; + Ok(true) + }) } pub fn set_omni_temperature_for_provider(id: &str, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let temperature = parse_llm_temperature(value)?; - let mut root = load_credentials_for_update()?; - root.omni - .providers - .entry(id.to_string()) - .or_default() - .temperature = temperature; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.omni + .providers + .entry(id.to_string()) + .or_default() + .temperature = temperature; + Ok(true) + }) } pub fn set_active_omni_extra_headers_json(value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let headers = parse_extra_headers_json(value)?; - let mut root = load_credentials_for_update()?; - let entry = root - .omni - .providers - .entry(root.omni.active.clone()) - .or_default(); - entry.extraHeaders = if headers.is_empty() { - None - } else { - Some(headers) - }; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.omni + .providers + .entry(root.omni.active.clone()) + .or_default() + .extraHeaders = (!headers.is_empty()).then_some(headers); + Ok(true) + }) } pub fn set_omni_extra_headers_json_for_provider(id: &str, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let headers = parse_extra_headers_json(value)?; - let mut root = load_credentials_for_update()?; - root.omni - .providers - .entry(id.to_string()) - .or_default() - .extraHeaders = (!headers.is_empty()).then_some(headers); - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.omni + .providers + .entry(id.to_string()) + .or_default() + .extraHeaders = (!headers.is_empty()).then_some(headers); + Ok(true) + }) } pub fn get_active_llm_extra_headers() -> HashMap { @@ -2564,16 +3763,15 @@ impl CredentialsVault { } pub fn set_active_llm_temperature(value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let temperature = parse_llm_temperature(value)?; - let mut root = load_credentials_for_update()?; - let entry = root - .providers - .llm - .entry(root.active.llm.clone()) - .or_default(); - entry.temperature = temperature; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.providers + .llm + .entry(root.active.llm.clone()) + .or_default() + .temperature = temperature; + Ok(true) + }) } pub fn get_llm_protocol_option(id: Option<&str>, account: &str) -> Result> { @@ -2587,51 +3785,46 @@ impl CredentialsVault { } pub fn set_llm_protocol_option(id: Option<&str>, account: &str, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let mut config = openless_core::llm_protocol::LlmProtocolConfig::default(); config.apply(account, value)?; - let mut root = load_credentials_for_update()?; - let id = id.unwrap_or(&root.active.llm).to_string(); - let entry = root.providers.llm.entry(id).or_default(); - *entry.protocol_option(account)? = - (!value.trim().is_empty()).then(|| value.trim().to_string()); - entry.channel.lastTest = None; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + let id = id.unwrap_or(&root.active.llm).to_string(); + let entry = root.providers.llm.entry(id).or_default(); + *entry.protocol_option(account)? = + (!value.trim().is_empty()).then(|| value.trim().to_string()); + entry.channel.lastTest = None; + Ok(true) + }) } /// 写入指定 LLM 渠道的采样温度,不改变 active 渠道。 pub fn set_llm_temperature_for_provider(id: &str, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let temperature = parse_llm_temperature(value)?; - let mut root = load_credentials_for_update()?; - set_llm_temperature_for_provider_in_root(&mut root, id, temperature); - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + set_llm_temperature_for_provider_in_root(root, id, temperature); + Ok(true) + }) } pub fn set_active_llm_extra_headers_json(value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let headers = parse_extra_headers_json(value)?; - let mut root = load_credentials_for_update()?; - let entry = root - .providers - .llm - .entry(root.active.llm.clone()) - .or_default(); - entry.extraHeaders = if headers.is_empty() { - None - } else { - Some(headers) - }; - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + root.providers + .llm + .entry(root.active.llm.clone()) + .or_default() + .extraHeaders = (!headers.is_empty()).then_some(headers); + Ok(true) + }) } /// 写入指定 LLM 渠道的额外请求头,不改变 active 渠道。 pub fn set_llm_extra_headers_json_for_provider(id: &str, value: &str) -> Result<()> { - let _guard = credentials_lock().lock(); let headers = parse_extra_headers_json(value)?; - let mut root = load_credentials_for_update()?; - set_llm_extra_headers_for_provider_in_root(&mut root, id, headers); - save_credentials(&root) + mutate_credentials(openless_core::credentials::ChangeOrigin::User, |root| { + set_llm_extra_headers_for_provider_in_root(root, id, headers); + Ok(true) + }) } pub fn snapshot() -> CredentialsSnapshot { @@ -3368,7 +4561,6 @@ mod tests { #[test] fn android_startup_failure_does_not_cache_default_or_suppress_retry() { - use anyhow::Context; reset_credentials_cache_for_tests(); let first = load_credentials_into_cache_with(|| { Err(anyhow!("injected startup scrub failure") @@ -3404,9 +4596,20 @@ mod tests { let _ = std::fs::remove_dir_all(dir); } + #[test] + fn malformed_payload_errors_never_echo_private_field_values() { + reset_credentials_cache_for_tests(); + let error = serde_json::from_str::(r#"{"version":"private-canary-secret"}"#) + .unwrap_err(); + super::record_vault_read_failure(&anyhow::Error::new(error)); + let recorded = CredentialsVault::last_read_error().unwrap(); + assert_eq!(recorded, "credential payload could not be decoded"); + assert!(!recorded.contains("private-canary-secret")); + super::clear_vault_read_error(); + } + #[test] fn android_for_update_path_retries_and_does_not_cache_on_envelope_error() { - use anyhow::Context; reset_credentials_cache_for_tests(); let err = android_credentials_root_for_update(|| { Err(anyhow!("temporarily unavailable") @@ -3815,3 +5018,1046 @@ mod tests { ); } } + +#[cfg(test)] +mod encrypted_sync_tests { + use super::*; + use openless_core::credentials::{ + ChangeOrigin, SyncNamespace, SyncSecretAccount, SyncWriteGate, + }; + use std::cell::{Cell, RefCell}; + + struct Temporary(PathBuf); + impl Temporary { + fn new() -> Self { + let path = + std::env::temp_dir().join(format!("openless-vault-sync-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + } + impl Drop for Temporary { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn root_with_secret(value: &str) -> CredsRoot { + let mut root = CredsRoot::default(); + root.version = 2; + root.active.asr = "stable-channel".into(); + root.providers.asr.insert( + "stable-channel".into(), + CredsAsrEntry { + channel: ChannelMeta { + providerType: Some("openai-compatible".into()), + order: Some(0), + enabled: true, + lastTest: None, + }, + apiKey: Some(value.into()), + ..Default::default() + }, + ); + root + } + fn installed_chunks(json: &str, generation: Option<&str>) -> HashMap { + let chunks = chunk_json_payload(json); + let manifest = CredsChunkManifest { + openless_credentials_storage: "chunked".into(), + version: 1, + generation: generation.map(str::to_string), + chunks: chunks.len(), + }; + let mut values = HashMap::from([( + KEYRING_CREDENTIALS_ACCOUNT.into(), + serde_json::to_string(&manifest).unwrap(), + )]); + for (i, part) in chunks.into_iter().enumerate() { + values.insert(chunk_account(generation, i), part); + } + values + } + fn read_root(values: &RefCell>) -> CredsRoot { + load_keyring_credentials_with( + |key| Ok(values.borrow().get(key).cloned()), + |_, _| panic!("reader must not write"), + false, + ) + .unwrap() + .unwrap() + } + + #[test] + fn sync_gate_binding_is_lazy_and_preserves_pending_recovery() { + let temp = Temporary::new(); + let path = temp.0.join("generation.json"); + let gate = SyncWriteGate::open(path.clone()).unwrap(); + drop(gate.begin_mutation().unwrap()); + let before = std::fs::read(&path).unwrap(); + let mut installed = None; + install_sync_write_gate(&mut installed, gate.clone()).unwrap(); + install_sync_write_gate(&mut installed, gate.clone()).unwrap(); + assert!(std::sync::Arc::ptr_eq(installed.as_ref().unwrap(), &gate)); + assert!(gate.recovery_required().unwrap()); + assert_eq!(std::fs::read(&path).unwrap(), before); + let other = SyncWriteGate::open(temp.0.join("other.json")).unwrap(); + assert!(install_sync_write_gate(&mut installed, other).is_err()); + assert!(std::sync::Arc::ptr_eq(installed.as_ref().unwrap(), &gate)); + } + + #[test] + fn readonly_warm_cache_retries_denial_and_only_real_read_clears_failure() { + reset_credentials_cache_for_tests(); + let failure = load_credentials_readonly_into_cache_with(|| { + Err(anyhow::anyhow!("fixture native access denied")) + }) + .unwrap_err(); + assert!(failure.to_string().contains("fixture native access denied")); + assert!(credentials_cache().lock().is_none()); + let recorded = CredentialsVault::last_read_error().unwrap(); + + // A cache hit itself is not a successful OS read. Seed only the cache + // slot, without the successful-load/save helper's error-latch update. + *credentials_cache().lock() = Some(root_with_secret("fixture-cached")); + let cached = + load_credentials_readonly_into_cache_with(|| panic!("must use cache")).unwrap(); + assert_eq!( + cached.providers.asr["stable-channel"].apiKey.as_deref(), + Some("fixture-cached") + ); + assert_eq!( + CredentialsVault::last_read_error().as_deref(), + Some(recorded.as_str()) + ); + + *credentials_cache().lock() = None; + let loaded = load_credentials_readonly_into_cache_with(|| { + Ok(root_with_secret("fixture-retry-succeeded")) + }) + .unwrap(); + assert_eq!( + loaded.providers.asr["stable-channel"].apiKey.as_deref(), + Some("fixture-retry-succeeded") + ); + assert!(credentials_cache().lock().is_some()); + assert!(CredentialsVault::last_read_error().is_none()); + reset_credentials_cache_for_tests(); + } + + #[test] + fn bound_cold_read_preserves_legacy_file_until_a_real_save() { + let temp = Temporary::new(); + let path = temp.0.join("legacy.json"); + let mut legacy = root_with_secret("fixture-legacy-provider"); + legacy.version = 1; + legacy.marketplace.githubAccessToken = + Some(MarketplaceGithubToken("fixture-local-oauth".into())); + let original = serde_json::to_vec(&legacy).unwrap(); + std::fs::write(&path, &original).unwrap(); + let loaded = load_credentials_for_sync_binding_with( + true, + || { + load_desktop_credentials_readonly_with( + |_| Ok(None), + || decode_single_credentials(&std::fs::read_to_string(&path)?).map(Some), + true, + ) + }, + || panic!("bound read must not call the migration loader"), + ) + .unwrap(); + assert_eq!( + loaded.providers.asr["stable-channel"].apiKey.as_deref(), + Some("fixture-legacy-provider") + ); + assert_eq!( + lookup_marketplace_github_token(&loaded).as_deref(), + Some("fixture-local-oauth") + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + } + + #[test] + fn recovery_binding_uses_only_readers_and_preserves_the_pending_gate() { + let temp = Temporary::new(); + let path = temp.0.join("generation.json"); + let gate = SyncWriteGate::open(path.clone()).unwrap(); + drop(gate.begin_mutation().unwrap()); + let before = std::fs::read(&path).unwrap(); + let json = serde_json::to_string(&root_with_secret("fixture-source")).unwrap(); + let chunks = installed_chunks(&json, Some("prior-generation")); + let root = load_credentials_for_sync_binding_with( + gate.recovery_required().unwrap(), + || { + load_desktop_credentials_readonly_with( + |account| Ok(chunks.get(account).cloned()), + || panic!("complete chunks must not probe a legacy file"), + true, + ) + }, + || panic!("pending startup must not call the migration loader"), + ) + .unwrap(); + assert_eq!( + root.providers.asr["stable-channel"].apiKey.as_deref(), + Some("fixture-source") + ); + assert_eq!(std::fs::read(&path).unwrap(), before); + let error = load_credentials_for_sync_binding_with( + gate.recovery_required().unwrap(), + || { + load_desktop_credentials_readonly_with( + |_| Err(anyhow::anyhow!("fixture native access denied")), + || panic!("native errors must not fall back"), + true, + ) + }, + || panic!("must not migrate after denial"), + ); + assert!(error.is_err()); + assert!(gate.recovery_required().unwrap()); + assert_eq!(std::fs::read(&path).unwrap(), before); + let single = load_desktop_credentials_readonly_with( + |account| { + assert_eq!(account, KEYRING_SINGLE_CREDENTIALS_ACCOUNT); + Ok(Some(json.clone())) + }, + || panic!("v2 has priority"), + true, + ) + .unwrap(); + assert_eq!( + single.providers.asr["stable-channel"].apiKey.as_deref(), + Some("fixture-source") + ); + assert!(load_desktop_credentials_readonly_with( + |_| Ok(Some("{}".into())), + || panic!("corrupt source must not fall back"), + true + ) + .is_err()); + assert!(load_desktop_credentials_readonly_with( + |_| Ok(None), + || Err(anyhow::anyhow!("unreadable legacy source")), + false + ) + .is_err()); + let legacy = load_desktop_credentials_readonly_with( + |account| Ok((account == "asr.api_key").then(|| "legacy-source".into())), + || Ok(None), + false, + ) + .unwrap(); + assert_eq!( + lookup_account(&legacy, CredentialAccount::AsrApiKey).as_deref(), + Some("legacy-source") + ); + let empty = + load_desktop_credentials_readonly_with(|_| Ok(None), || Ok(None), false).unwrap(); + assert!(empty.providers.asr.is_empty()); + } + + #[cfg(not(windows))] + #[test] + fn recovery_android_vault_read_is_strict_and_does_not_scrub_or_migrate() { + use base64::Engine; + let temp = Temporary::new(); + let path = temp.0.join("credentials.enc.json"); + let mut root = root_with_secret("private-provider"); + write_marketplace_github_token(&mut root, Some("legacy-oauth".into())); + let bytes = + base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&root).unwrap()); + std::fs::write(&path, &bytes).unwrap(); + let mut crypto = super::super::android_credentials::TestCrypto::default(); + let loaded = load_android_credentials_readonly_at(&path, &mut crypto) + .unwrap() + .unwrap(); + assert_eq!( + loaded.providers.asr["stable-channel"].apiKey.as_deref(), + Some("private-provider") + ); + assert!(lookup_marketplace_github_token(&loaded).is_none()); + assert_eq!(std::fs::read(&path).unwrap(), bytes.as_bytes()); + assert_eq!(crypto.delete_key_calls, 0); + std::fs::write( + &path, + base64::engine::general_purpose::STANDARD.encode(b"{}"), + ) + .unwrap(); + assert!(load_android_credentials_readonly_at(&path, &mut crypto).is_err()); + assert!(path.exists()); + } + + #[test] + fn generation_writer_preserves_old_data_at_every_precommit_failure() { + let old = serde_json::to_string(&root_with_secret(&"old".repeat(1500))).unwrap(); + let new = serde_json::to_string(&root_with_secret(&"new".repeat(1500))).unwrap(); + let initial = installed_chunks(&old, None); + for failed_piece in 0..chunk_json_payload(&new).len() { + let values = RefCell::new(initial.clone()); + let writes = Cell::new(0); + let result = save_chunked_credentials_with( + &new, + |key| Ok(values.borrow().get(key).cloned()), + |key, value| { + if key != KEYRING_CREDENTIALS_ACCOUNT { + let n = writes.get(); + writes.set(n + 1); + if n == failed_piece { + anyhow::bail!("fixture failure"); + } + } + values.borrow_mut().insert(key.into(), value.into()); + Ok(()) + }, + |key| { + values.borrow_mut().remove(key); + Ok(()) + }, + ); + assert!(result.is_err()); + assert_eq!( + read_root(&values).providers.asr["stable-channel"].apiKey, + root_with_secret(&"old".repeat(1500)).providers.asr["stable-channel"].apiKey + ); + assert_eq!( + values.borrow().get(KEYRING_CREDENTIALS_ACCOUNT), + initial.get(KEYRING_CREDENTIALS_ACCOUNT) + ); + } + } + + #[test] + fn generation_writer_verifies_chunks_and_head_without_claiming_fake_success() { + let old = serde_json::to_string(&root_with_secret("old")).unwrap(); + let new = serde_json::to_string(&root_with_secret(&"new".repeat(1000))).unwrap(); + for mode in [ + "head_read", + "chunk_read", + "corrupt_chunk", + "head_noop", + "head_error", + "head_confirmation", + ] { + let values = RefCell::new(installed_chunks(&old, Some("old-generation"))); + let switched = Cell::new(false); + let result = save_chunked_credentials_with( + &new, + |key| { + if mode == "head_read" && key == KEYRING_CREDENTIALS_ACCOUNT { + anyhow::bail!("fixture"); + } + if mode == "head_confirmation" + && key == KEYRING_CREDENTIALS_ACCOUNT + && switched.get() + { + anyhow::bail!("fixture"); + } + if key.starts_with(KEYRING_CREDENTIALS_CHUNK_PREFIX) + && !key.contains("old-generation") + { + if mode == "chunk_read" { + anyhow::bail!("fixture"); + } + if mode == "corrupt_chunk" { + return Ok(Some("corrupt".into())); + } + } + Ok(values.borrow().get(key).cloned()) + }, + |key, value| { + if key == KEYRING_CREDENTIALS_ACCOUNT { + if mode == "head_error" { + anyhow::bail!("fixture"); + } + if mode == "head_noop" { + return Ok(()); + } + switched.set(true); + } + values.borrow_mut().insert(key.into(), value.into()); + Ok(()) + }, + |key| { + values.borrow_mut().remove(key); + Ok(()) + }, + ); + assert!(result.is_err(), "{mode}"); + let expected = if mode == "head_confirmation" { + "new".repeat(1000) + } else { + "old".into() + }; + assert_eq!( + read_root(&values).providers.asr["stable-channel"] + .apiKey + .as_deref(), + Some(expected.as_str()) + ); + } + } + + #[test] + fn generation_commit_survives_lost_ack_and_cleanup_failure_and_migrates_legacy() { + let old = serde_json::to_string(&root_with_secret("old")).unwrap(); + let new = serde_json::to_string(&root_with_secret("new")).unwrap(); + for initial in [ + installed_chunks(&old, None), + installed_chunks(&old, Some("previous-generation")), + HashMap::from([(KEYRING_CREDENTIALS_ACCOUNT.into(), old.clone())]), + ] { + let values = RefCell::new(initial); + save_chunked_credentials_with( + &new, + |key| Ok(values.borrow().get(key).cloned()), + |key, value| { + values.borrow_mut().insert(key.into(), value.into()); + if key == KEYRING_CREDENTIALS_ACCOUNT { + anyhow::bail!("lost native ack"); + } + Ok(()) + }, + |_| anyhow::bail!("cleanup denied"), + ) + .unwrap(); + assert_eq!( + read_root(&values).providers.asr["stable-channel"] + .apiKey + .as_deref(), + Some("new") + ); + let head = values.borrow()[KEYRING_CREDENTIALS_ACCOUNT].clone(); + assert!(read_chunk_manifest(&head).unwrap().generation.is_some()); + } + } + + #[test] + fn full_provider_capture_roundtrips_fixed_ids_and_keeps_local_oauth() { + let mut root = root_with_secret("asr-primary"); + let asr = root.providers.asr.get_mut("stable-channel").unwrap(); + asr.appKey = None; + asr.accessKey = Some("legacy-access".into()); + asr.resourceId = Some("resource".into()); + asr.volcengineApiKey = Some("new-service-key".into()); + asr.vocabularyId = Some("vocab".into()); + asr.advancedConfig = Some("{\"verboseJson\":true}".into()); + asr.xfyunAppId = Some("xfyun-id".into()); + asr.xfyunApiKey = Some("xfyun-secret".into()); + asr.tencentCloudAppId = Some("tencent-id".into()); + asr.tencentCloudSecretId = Some("tencent-secret-id".into()); + asr.tencentCloudSecretKey = Some("tencent-secret".into()); + asr.channel.lastTest = Some(ChannelTest { + ok: true, + latencyMs: Some(1), + at: 5, + error: None, + }); + root.active.llm = "stable-llm".into(); + root.providers.llm.insert( + "stable-llm".into(), + CredsLlmEntry { + channel: ChannelMeta { + providerType: Some("custom".into()), + order: Some(5), + enabled: true, + lastTest: asr.channel.lastTest.clone(), + }, + displayName: Some("Named provider".into()), + apiKey: Some("llm-secret".into()), + baseURL: Some("https://api.example/v1".into()), + model: Some("model".into()), + temperature: Some(0.7), + extraHeaders: Some(HashMap::from([( + "x-provider-key".into(), + "header-secret".into(), + )])), + requestFormat: Some("chat_completions".into()), + messagesThinking: Some("adaptive".into()), + maxTokens: Some("4096".into()), + thinkingBudget: None, + }, + ); + root.omni.active = "custom".into(); + for id in ["custom", "qwen3-omni", "volcengine-omni"] { + root.omni.providers.insert( + id.into(), + CredsOmniEntry { + apiKey: Some(format!("{id}-secret")), + baseURL: Some("https://omni.example".into()), + model: Some("omni-model".into()), + temperature: Some(0.8), + extraHeaders: Some(HashMap::from([( + "x-extra".into(), + "omni-extra-secret".into(), + )])), + ..Default::default() + }, + ); + } + write_marketplace_github_token(&mut root, Some("local-only-oauth".into())); + let count = Cell::new(0); + let capture = capture_sync_credentials_with(|| { + count.set(count.get() + 1); + Ok(root.clone()) + }) + .unwrap(); + assert_eq!(count.get(), 1); + assert!(!format!("{capture:?}").contains("secret")); + assert!(!capture.credentials.iter().any(|record| record + .accounts + .values() + .any(|value| value == "local-only-oauth"))); + assert!(!capture + .credentials + .iter() + .find(|record| record.namespace == SyncNamespace::Asr) + .unwrap() + .accounts + .contains_key("volcengine.app_key")); + assert_eq!( + capture + .channels + .iter() + .filter(|c| c.namespace == SyncNamespace::Omni) + .count(), + 3 + ); + let restored = apply_sync_credentials_root(&root, &capture).unwrap(); + assert_eq!(export_sync_credentials_root(&restored).unwrap(), capture); + assert_eq!( + lookup_marketplace_github_token(&restored).as_deref(), + Some("local-only-oauth") + ); + assert!(restored + .providers + .asr + .values() + .all(|entry| entry.channel.lastTest.is_none())); + assert!(restored + .providers + .llm + .values() + .all(|entry| entry.channel.lastTest.is_none())); + let mut excluded = capture.clone(); + excluded.credentials[0] + .accounts + .insert("github.oauth_token".into(), "not-allowed".into()); + assert!(apply_sync_credentials_root(&root, &excluded).is_err()); + let mut invalid_protocol = capture.clone(); + let accounts = &mut invalid_protocol + .credentials + .iter_mut() + .find(|record| record.namespace == SyncNamespace::Llm) + .unwrap() + .accounts; + accounts.insert("ark.request_format".into(), "messages".into()); + accounts.insert("ark.messages_thinking".into(), "budget".into()); + accounts.insert("ark.thinking_budget".into(), "4096".into()); + assert!(apply_sync_credentials_root(&root, &invalid_protocol).is_err()); + assert!( + capture_sync_credentials_with(|| Err(anyhow::anyhow!("unreadable fixture vault"))) + .is_err() + ); + } + + #[test] + fn mutation_lease_precedes_read_and_lives_until_actual_save_finishes() { + let temp = Temporary::new(); + let gate = SyncWriteGate::open(temp.0.join("generation.json")).unwrap(); + mutate_credentials_with( + Some(gate.clone()), + ChangeOrigin::User, + || { + assert!(gate.try_exclusive().is_err()); + Ok(root_with_secret("old")) + }, + |root| { + root.active.asr = "changed".into(); + Ok(true) + }, + |_| { + assert!(gate.try_exclusive().is_err()); + Ok(()) + }, + ) + .unwrap(); + assert_eq!(gate.generation().unwrap().get(), 1); + let exclusive = gate.try_exclusive().unwrap(); + assert!(mutate_credentials_with( + Some(gate.clone()), + ChangeOrigin::User, + || panic!("must reject before read"), + |_| Ok(true), + |_| Ok(()) + ) + .is_err()); + drop(exclusive); + assert!(mutate_credentials_with( + Some(gate.clone()), + ChangeOrigin::User, + || Err(anyhow::anyhow!("read failed")), + |_| Ok(true), + |_| Ok(()) + ) + .is_err()); + assert!(!gate.recovery_required().unwrap()); + mutate_credentials_with( + Some(gate.clone()), + ChangeOrigin::LocalOnly, + || Ok(root_with_secret("old")), + |_| Ok(true), + |_| Ok(()), + ) + .unwrap(); + assert_eq!(gate.generation().unwrap().get(), 1); + assert!(mutate_credentials_with( + Some(gate.clone()), + ChangeOrigin::User, + || Ok(root_with_secret("old")), + |_| Ok(true), + |_| Err(VaultCommitFailure::Unknown.into()) + ) + .is_err()); + assert!(gate.recovery_required().unwrap()); + } + + #[test] + fn unknown_publication_drops_cache_before_reconciliation() { + let old = root_with_secret("old"); + let new = root_with_secret("new"); + store_credentials_cache(&old); + assert!(finish_credential_write(&new, Err(VaultCommitFailure::Unchanged.into())).is_err()); + assert_eq!( + credentials_cache().lock().as_ref().unwrap().providers.asr["stable-channel"] + .apiKey + .as_deref(), + Some("old") + ); + assert!(finish_credential_write(&new, Err(VaultCommitFailure::Unknown.into())).is_err()); + assert!(credentials_cache().lock().is_none()); + let count = Cell::new(0); + let loaded = load_credentials_into_cache_with(|| { + count.set(count.get() + 1); + Ok(Some(new)) + }); + assert_eq!(count.get(), 1); + assert_eq!( + loaded.providers.asr["stable-channel"].apiKey.as_deref(), + Some("new") + ); + reset_credentials_cache_for_tests(); + } + + #[test] + fn failed_revocation_after_concurrent_login_keeps_oauth_unusable() { + use std::sync::{mpsc, Arc}; + use std::time::{Duration, Instant}; + let root = Arc::new(Mutex::new(root_with_secret("provider-only"))); + store_credentials_cache(&root.lock()); + mark_marketplace_token_verified(); + let (started_tx, started_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let login_root = root.clone(); + let login = std::thread::spawn(move || { + set_marketplace_token_with( + None, + "new-oauth", + || Ok(login_root.lock().clone()), + |next| { + started_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + *login_root.lock() = next.clone(); + store_credentials_cache(next); + Ok(()) + }, + ) + }); + started_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + let revoke = std::thread::spawn(move || { + remove_marketplace_token_with( + None, + || Ok(root.lock().clone()), + |_| Err(VaultCommitFailure::Unchanged.into()), + ) + }); + let start = Instant::now(); + while !marketplace_token_is_rejected() && start.elapsed() < Duration::from_secs(5) { + std::thread::yield_now(); + } + let saw_revocation = marketplace_token_is_rejected(); + release_tx.send(()).unwrap(); + login.join().unwrap().unwrap(); + assert!(revoke.join().unwrap().is_err()); + assert!(saw_revocation); + assert!(marketplace_token_is_rejected()); + assert!(credentials_cache() + .lock() + .as_ref() + .and_then(lookup_marketplace_github_token) + .is_none()); + reset_credentials_cache_for_tests(); + mark_marketplace_token_verified(); + } + + #[cfg(not(windows))] + #[test] + fn sync_key_write_does_not_complete_or_delete_main_vault_migration() { + use super::super::android_credentials::{AndroidCredentialsCrypto, ReadOutcome}; + use base64::Engine; + let temp = Temporary::new(); + let main_path = temp.0.join("credentials.enc.json"); + let legacy = serde_json::to_vec(&root_with_secret("legacy-provider-secret")).unwrap(); + std::fs::write( + &main_path, + base64::engine::general_purpose::STANDARD.encode(&legacy), + ) + .unwrap(); + let account = + SyncSecretAccount::new(format!("cloud-sync.e2ee.key.{}", "c".repeat(64))).unwrap(); + let key = openless_core::SecretValue::new( + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([8; 32]), + ); + let mut wrapper = SyncSecretCrypto { + inner: super::super::android_credentials::TestCrypto::default(), + account: account.as_str().into(), + }; + write_android_sync_key_with_crypto( + &temp.0.join("sync-key.json"), + &account, + &key, + &mut wrapper, + ) + .unwrap(); + assert!(!wrapper.inner.migration_complete().unwrap()); + assert!(wrapper.migration_complete().unwrap()); + wrapper.delete_key().unwrap(); + assert_eq!(wrapper.inner.delete_key_calls, 0); + assert!(matches!( + super::super::android_credentials::read(&main_path, &mut wrapper.inner).unwrap(), + ReadOutcome::Legacy(_) + )); + let migrated = + load_android_credentials_from_path_with_crypto(&main_path, &mut wrapper.inner) + .unwrap() + .unwrap(); + assert_eq!( + migrated.providers.asr["stable-channel"].apiKey.as_deref(), + Some("legacy-provider-secret") + ); + assert!(wrapper.inner.migration_complete().unwrap()); + } + + #[cfg(not(windows))] + #[test] + fn android_sync_key_envelope_is_account_bound_and_never_plaintext() { + use base64::Engine; + let temp = Temporary::new(); + let path = temp.0.join("sync-key.json"); + let account = + SyncSecretAccount::new(format!("cloud-sync.e2ee.local.{}", "a".repeat(64))).unwrap(); + let other = + SyncSecretAccount::new(format!("cloud-sync.e2ee.local.{}", "b".repeat(64))).unwrap(); + let key = openless_core::SecretValue::new( + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode([7; 32]), + ); + let mut crypto = SyncSecretCrypto { + inner: super::super::android_credentials::TestCrypto::default(), + account: account.as_str().into(), + }; + write_android_sync_key_with_crypto(&path, &account, &key, &mut crypto).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains(key.expose_secret())); + assert_eq!( + read_android_sync_key_with_crypto(&path, &account, &mut crypto).unwrap(), + Some(key.clone()) + ); + crypto.account = other.as_str().into(); + assert!(read_android_sync_key_with_crypto(&path, &other, &mut crypto).is_err()); + assert!(path.exists()); + assert!(super::super::android_credentials::read(&path, &mut crypto.inner).is_err()); + assert!(path.exists()); + assert!(validate_sync_key(&openless_core::SecretValue::new("not-a-key")).is_err()); + } +} + +#[cfg(test)] +mod sync_capture_diagnostic_tests { + use super::*; + #[test] + fn typed_diagnostics_never_expose_error_bodies_or_attributes() { + let private = "fixture-private-value-never-log"; + let cases: Vec<(anyhow::Error, &str)> = vec![ + ( + anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + private, + )) + .context(private), + "io_permission_denied", + ), + ( + anyhow::Error::new( + serde_json::from_str::(&format!("\"{private}\"")).unwrap_err(), + ) + .context(private), + "json_data", + ), + ( + anyhow::Error::new(serde_json::from_str::("]").unwrap_err()), + "json_syntax", + ), + ( + anyhow::Error::new(serde_json::from_str::("{").unwrap_err()), + "json_eof", + ), + ( + anyhow::Error::new(String::from_utf8(vec![255]).unwrap_err()), + "invalid_utf8", + ), + (anyhow::anyhow!(private), "native_store_failed"), + ]; + for (error, expected) in cases { + let code = sync_capture_read_error_code(&error); + assert_eq!(code, expected); + assert!(!code.contains(private)); + } + } + #[cfg(not(target_os = "android"))] + #[test] + fn keyring_causes_are_classified_without_formatting_secret_material() { + let private = "fixture-private-value-never-log"; + let cases: Vec<(keyring::Error, &str)> = vec![ + ( + keyring::Error::NoStorageAccess(Box::new(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + private, + ))), + "keyring_access_denied", + ), + ( + keyring::Error::PlatformFailure(Box::new(std::io::Error::new( + std::io::ErrorKind::TimedOut, + private, + ))), + "io_timed_out", + ), + ( + keyring::Error::BadEncoding(private.as_bytes().to_vec()), + "keyring_bad_encoding", + ), + ( + keyring::Error::Invalid(private.into(), private.into()), + "keyring_invalid_attribute", + ), + (keyring::Error::NoEntry, "keyring_no_entry"), + ]; + for (error, expected) in cases { + assert_eq!( + sync_capture_read_error_code(&anyhow::Error::new(error).context(private)), + expected + ); + } + } + #[test] + fn projection_diagnostic_does_not_relax_omni_identity_validation() { + let mut root = CredsRoot::default(); + root.omni.active = "custom".into(); + root.omni.providers.insert( + "custom".into(), + CredsOmniEntry { + channel: ChannelMeta { + providerType: Some("different".into()), + ..Default::default() + }, + ..Default::default() + }, + ); + let error = export_sync_credentials_root(&root).unwrap_err(); + assert_eq!( + error + .downcast_ref::() + .unwrap() + .code, + openless_core::BackendErrorCode::InvalidArgument + ); + } + + #[test] + fn omni_defaults_and_legacy_account_import_use_the_same_custom_slot() { + let omitted: CredsRoot = + serde_json::from_str(r#"{"version":1,"active":{},"providers":{}}"#).unwrap(); + let explicit: CredsOmni = serde_json::from_str("{}").unwrap(); + assert_eq!(CredsRoot::default().omni.active, explicit.active); + assert_eq!(omitted.omni.active, "custom"); + let imported = load_desktop_credentials_readonly_with( + |account| Ok((account == "omni.api_key").then(|| "legacy-fixture-key".into())), + || Ok(None), + true, + ) + .unwrap(); + assert_eq!(imported.omni.providers.len(), 1); + assert_eq!( + imported.omni.providers["custom"].apiKey.as_deref(), + Some("legacy-fixture-key") + ); + export_sync_credentials_root(&imported).unwrap(); + } + + fn legacy_empty_omni_slot() -> CredsOmniEntry { + CredsOmniEntry { + displayName: Some("Legacy fixture".into()), + apiKey: Some("legacy-fixture-key".into()), + baseURL: Some("https://legacy.example/v1".into()), + model: Some("legacy-model".into()), + temperature: Some(0.7), + extraHeaders: Some(HashMap::from([("x-fixture".into(), "header-value".into())])), + channel: ChannelMeta { + order: Some(3), + lastTest: Some(ChannelTest { + ok: true, + latencyMs: Some(12), + at: 7, + error: None, + }), + ..Default::default() + }, + } + } + + #[test] + fn legacy_empty_omni_slot_migrates_losslessly_and_preserves_selected_provider() { + for active in ["", "qwen3-omni"] { + let mut root = CredsRoot { + version: CHANNELS_SCHEMA_VERSION, + ..Default::default() + }; + root.omni.active = active.into(); + let mut expected = legacy_empty_omni_slot(); + root.omni.providers.insert(String::new(), expected.clone()); + let persisted_before = serde_json::to_value(&root).unwrap(); + + // Match a read-only capture: normalize the loaded copy, never the OS source. + let mut loaded = decode_single_credentials(&persisted_before.to_string()).unwrap(); + assert!(migrate_channels(&mut loaded)); + expected.channel.providerType = Some("custom".into()); + assert_eq!( + serde_json::to_value(&loaded.omni.providers["custom"]).unwrap(), + serde_json::to_value(&expected).unwrap() + ); + assert!(!loaded.omni.providers.contains_key("")); + assert_eq!( + loaded.omni.active, + if active.is_empty() { "custom" } else { active } + ); + assert_eq!(loaded.active.asr, root.active.asr); + assert_eq!(loaded.active.llm, root.active.llm); + let captured = capture_sync_credentials_with(|| Ok(loaded.clone())).unwrap(); + let restored = apply_sync_credentials_root(&loaded, &captured).unwrap(); + assert_eq!(export_sync_credentials_root(&restored).unwrap(), captured); + assert!(!migrate_channels(&mut loaded)); + assert_eq!(serde_json::to_value(&root).unwrap(), persisted_before); + } + } + + #[test] + fn legacy_empty_omni_slot_only_deduplicates_identical_custom_configuration() { + let mut root = CredsRoot { + version: CHANNELS_SCHEMA_VERSION, + ..Default::default() + }; + root.omni.active = "qwen3-omni".into(); + let legacy = legacy_empty_omni_slot(); + let mut custom = legacy.clone(); + custom.channel.providerType = Some("custom".into()); + root.omni.providers.insert(String::new(), legacy); + root.omni.providers.insert("custom".into(), custom); + assert!(migrate_channels(&mut root)); + assert!(!root.omni.providers.contains_key("")); + assert_eq!(root.omni.active, "qwen3-omni"); + export_sync_credentials_root(&root).unwrap(); + + for field in [ + "apiKey", + "baseURL", + "model", + "temperature", + "extraHeaders", + "displayName", + "order", + "enabled", + "lastTest", + ] { + let mut conflict = root.clone(); + conflict + .omni + .providers + .insert(String::new(), legacy_empty_omni_slot()); + let mut changed = serde_json::to_value(&conflict.omni.providers["custom"]).unwrap(); + changed.as_object_mut().unwrap().remove(field); + if field == "enabled" { + changed[field] = serde_json::json!(false); + } + conflict + .omni + .providers + .insert("custom".into(), serde_json::from_value(changed).unwrap()); + let before = serde_json::to_value(&conflict).unwrap(); + assert!( + !migrate_channels(&mut conflict), + "conflicting {field} must stay untouched" + ); + assert_eq!(serde_json::to_value(&conflict).unwrap(), before); + assert!(export_sync_credentials_root(&conflict).is_err()); + } + } + + #[test] + fn legacy_omni_repair_does_not_reinterpret_other_invalid_identities() { + for provider_type in [None, Some(""), Some("custom")] { + let mut root = CredsRoot { + version: CHANNELS_SCHEMA_VERSION, + ..Default::default() + }; + let mut entry = legacy_empty_omni_slot(); + entry.channel.providerType = provider_type.map(str::to_string); + root.omni.providers.insert(String::new(), entry); + assert!(migrate_channels(&mut root)); + export_sync_credentials_root(&root).unwrap(); + } + for (id, provider_type) in [("", "other"), ("unsafe/path", "unsafe/path")] { + let mut root = CredsRoot { + version: CHANNELS_SCHEMA_VERSION, + ..Default::default() + }; + let mut entry = legacy_empty_omni_slot(); + entry.channel.providerType = Some(provider_type.into()); + root.omni.providers.insert(id.into(), entry); + let before = serde_json::to_value(&root).unwrap(); + assert!(!migrate_channels(&mut root)); + assert_eq!(serde_json::to_value(&root).unwrap(), before); + assert!(export_sync_credentials_root(&root).is_err()); + } + } + + #[test] + fn legacy_omni_repair_preserves_restored_empty_and_inactive_snapshots() { + let root = CredsRoot::default(); + let empty = openless_core::credentials::SyncCredentials { + channels: vec![], + credentials: vec![], + }; + let mut inactive = export_sync_credentials_root(&root).unwrap(); + for channel in &mut inactive.channels { + channel.active = false; + channel.enabled = false; + } + for snapshot in [empty, inactive] { + let mut restored = apply_sync_credentials_root(&root, &snapshot).unwrap(); + assert!(restored.omni.active.is_empty()); + assert!(!migrate_channels(&mut restored)); + assert_eq!(export_sync_credentials_root(&restored).unwrap(), snapshot); + } + } +} diff --git a/openless-all/app/src-tauri/src/selection.rs b/openless-all/app/src-tauri/src/selection.rs index d8106db74..a41503e2a 100644 --- a/openless-all/app/src-tauri/src/selection.rs +++ b/openless-all/app/src-tauri/src/selection.rs @@ -506,6 +506,12 @@ pub(crate) fn reactivate_selection_insertion_target(target: &SelectionInsertionT let Some(pid) = captured.front_app_pid else { return false; }; + // Streaming writes usually already own the foreground application. + // Do not reactivate it and sleep for 80ms on every delta; the caller + // still validates the captured text control before posting keys. + if current_front_app_pid() == Some(pid) { + return true; + } // 预览窗是 OpenLess 自己的窗口,确认后需要把焦点交还原应用再粘贴。 // NSRunningApplication activate 是 best-effort,且部分 app(Electron、 // 自绘窗口)恢复 key window 需要 >120ms——固定 sleep 一次就核 pid 会 diff --git a/openless-all/app/src-tauri/src/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs index ab335b454..ba6348d52 100644 --- a/openless-all/app/src-tauri/src/side_aware_combo.rs +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -1,7 +1,7 @@ //! Side-specific combo hotkey matching (e.g. Left Cmd + D). //! //! `global-hotkey` cannot distinguish left/right modifiers. This module maintains -//! physical modifier state and matches combos registered via [`SideAwareComboMonitor`]. +//! physical modifier state and matches side-aware bindings registered via [`SideAwareComboMonitor`]. use std::sync::mpsc::Sender; use std::sync::{OnceLock, RwLock}; @@ -9,14 +9,16 @@ use std::time::Instant; use parking_lot::Mutex; -use crate::combo_hotkey::ComboHotkeyEvent; +use crate::hotkey::{HotkeyCombinedEdge, HotkeyEvent}; use crate::shortcut_binding::{is_side_specific_modifier_tag, normalize_side_modifier_tag}; use crate::types::ShortcutBinding; +use openless_core::is_modifier_chord_binding; static ACTIVE_MONITOR: OnceLock>> = OnceLock::new(); struct ActiveSideCombo { - tx: Sender, + tx: Sender, + combo_tx: Sender, state: Mutex, } @@ -36,7 +38,8 @@ struct ModifierSideState { struct SideAwareComboState { binding: ShortcutBinding, modifiers: ModifierSideState, - combo_active: bool, + active_press_id: Option, + companion_seen: bool, } impl SideAwareComboState { @@ -44,7 +47,8 @@ impl SideAwareComboState { Self { binding, modifiers: ModifierSideState::default(), - combo_active: false, + active_press_id: None, + companion_seen: false, } } @@ -106,51 +110,74 @@ impl SideAwareComboState { self.expected_modifier_tags() == self.pressed_modifier_tags() } - fn on_primary(&mut self, primary: &str, pressed: bool) -> Option { + fn activate(&mut self) -> Option { + if self.active_press_id.is_some() { + return None; + } + let press_id = crate::hotkey::next_press_id(); + self.active_press_id = Some(press_id); + self.companion_seen = false; + Some(HotkeyEvent::Pressed { + at: Instant::now(), + press_id, + }) + } + + fn release_active(&mut self) -> Option { + let press_id = self.active_press_id.take()?; + self.companion_seen = false; + Some(HotkeyEvent::Released { + at: Instant::now(), + press_id, + }) + } + + fn on_modifier_press(&mut self, side: SideModifier) -> Option { + self.set_side(side, true); + if is_modifier_chord_binding(&self.binding) && self.modifiers_match() { + return self.activate(); + } + None + } + + fn on_primary(&mut self, primary: &str, pressed: bool) -> Option { + if is_modifier_chord_binding(&self.binding) { + return None; + } if !primary_eq(&self.binding.primary, primary) { return None; } if pressed { if self.modifiers_match() { - // `modifiers_match()` is the authoritative activation gate. If - // `combo_active` is still true here, the previous `Released` was - // dropped by the OS: we must NOT emit a second `Pressed` (that - // would break the pairing invariant), so treat the flag as - // already reflecting an active combo and swallow this edge. - if !self.combo_active { - self.combo_active = true; - return Some(ComboHotkeyEvent::Pressed { at: Instant::now() }); - } - return None; + return self.activate(); } - // Modifiers no longer match — this is the single authoritative reset - // condition. If `combo_active` is stuck true (a modifier release was - // dropped so no `Released` was ever emitted), self-heal by emitting - // the terminal `Released` now so the recording latch cannot stick. - if self.combo_active { - self.combo_active = false; - return Some(ComboHotkeyEvent::Released { at: Instant::now() }); + if self.active_press_id.is_some() { + return self.release_active(); } return None; } - // Primary key up is the absolute termination signal for the combo. - if self.combo_active { - self.combo_active = false; - return Some(ComboHotkeyEvent::Released { at: Instant::now() }); - } - None + self.release_active() } - fn on_modifier_release(&mut self, side: SideModifier) -> Option { + fn on_modifier_release(&mut self, side: SideModifier) -> Option { self.set_side(side, false); - // Modifiers no longer matching is the authoritative reset condition: - // once the required side-modifier set is broken, the combo is over. - if self.combo_active && !self.modifiers_match() { - self.combo_active = false; - return Some(ComboHotkeyEvent::Released { at: Instant::now() }); + if self.active_press_id.is_some() && !self.modifiers_match() { + return self.release_active(); } None } + + fn on_companion_key_down(&mut self) -> Option { + if !is_modifier_chord_binding(&self.binding) || self.companion_seen { + return None; + } + let press_id = self.active_press_id?; + self.companion_seen = true; + Some(HotkeyCombinedEdge { + at: Instant::now(), + press_id, + }) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -170,7 +197,8 @@ pub struct SideAwareComboMonitor; impl SideAwareComboMonitor { pub fn start( binding: ShortcutBinding, - tx: Sender, + tx: Sender, + combo_tx: Sender, ) -> Result { // Linux has no side-aware platform dispatch (no CGEventTap / WH_KEYBOARD_LL // equivalent wired here). Accepting the binding would leave the user with a @@ -178,7 +206,7 @@ impl SideAwareComboMonitor { // so the caller can surface an actionable error instead. #[cfg(target_os = "linux")] { - let _ = (&binding, &tx); + let _ = (&binding, &tx, &combo_tx); return Err(crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier( "侧向修饰键组合键在 Linux 暂不支持".into(), )); @@ -192,6 +220,7 @@ impl SideAwareComboMonitor { let mut guard = slot.write().expect("side combo monitor lock poisoned"); *guard = Some(ActiveSideCombo { tx, + combo_tx, state: Mutex::new(SideAwareComboState::new(binding)), }); Ok(Self) @@ -213,13 +242,24 @@ fn validate_side_binding( "binding is not side-specific".into(), )); } + if is_modifier_chord_binding(binding) { + return openless_core::validate_shortcut_binding(binding).map_err(|error| match error { + openless_core::ShortcutBindingError::UnsupportedModifier(value) => { + crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier(value) + } + openless_core::ShortcutBindingError::UnsupportedKey(value) => { + crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(value) + } + }); + } + crate::shortcut_binding::parse_primary(&binding.primary) .map_err(|e| crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(e.to_string()))?; Ok(()) } -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "windows"))] impl SideAwareComboMonitor { /// Update the existing route without replacing its handle or event sender. /// Used when a failed native-key switch restores an already-live shortcut. @@ -261,18 +301,23 @@ where guard.as_ref().map(f) } -fn send_event(tx: &Sender, evt: ComboHotkeyEvent) { +fn send_event(tx: &Sender, evt: HotkeyEvent) { if let Err(err) = tx.send(evt) { log::warn!("[side-aware-combo] event send failed: {err}"); } } +fn send_combo_abort(tx: &Sender, edge: HotkeyCombinedEdge) { + if let Err(err) = tx.send(edge) { + log::warn!("[side-aware-combo] abort send failed: {err}"); + } +} + pub fn handle_side_modifier(side: SideModifier, pressed: bool) { if let Some(evt) = with_active(|active| { let mut state = active.state.lock(); if pressed { - state.set_side(side, true); - None + state.on_modifier_press(side) } else { state.on_modifier_release(side) } @@ -294,6 +339,12 @@ pub fn handle_primary_key(primary: &str, pressed: bool) { } } +pub fn handle_companion_key_down() { + if let Some(edge) = with_active(|active| active.state.lock().on_companion_key_down()).flatten() { + with_active(|active| send_combo_abort(&active.combo_tx, edge)); + } +} + fn primary_eq(expected: &str, actual: &str) -> bool { expected.trim().eq_ignore_ascii_case(actual.trim()) } @@ -585,7 +636,7 @@ pub mod platform { mod tests { use super::*; - #[cfg(target_os = "macos")] + #[cfg(any(target_os = "macos", target_os = "windows"))] #[test] fn restoring_live_side_binding_preserves_its_event_route() { use std::sync::mpsc; @@ -594,7 +645,8 @@ mod tests { modifiers: vec!["ctrl-right".into()], }; let (tx, rx) = mpsc::channel(); - let monitor = SideAwareComboMonitor::start(binding.clone(), tx).unwrap(); + let (combo_tx, _combo_rx) = mpsc::channel(); + let monitor = SideAwareComboMonitor::start(binding.clone(), tx, combo_tx).unwrap(); let press_and_release = |primary: &str| { handle_side_modifier(SideModifier::CtrlRight, true); handle_primary_key(primary, true); @@ -602,11 +654,11 @@ mod tests { handle_side_modifier(SideModifier::CtrlRight, false); assert!(matches!( rx.try_recv(), - Ok(ComboHotkeyEvent::Pressed { .. }) + Ok(HotkeyEvent::Pressed { .. }) )); assert!(matches!( rx.try_recv(), - Ok(ComboHotkeyEvent::Released { .. }) + Ok(HotkeyEvent::Released { .. }) )); assert!(rx.try_recv().is_err()); }; @@ -618,7 +670,7 @@ mod tests { press_and_release("D"); assert!(monitor .update_binding(ShortcutBinding { - primary: "F21".into(), + primary: "F25".into(), modifiers: vec!["ctrl-right".into()] }) .is_err()); @@ -686,7 +738,7 @@ mod tests { state.set_side(SideModifier::CmdLeft, true); assert!(state.modifiers_match()); let evt = state.on_primary("D", true); - assert!(matches!(evt, Some(ComboHotkeyEvent::Pressed { .. }))); + assert!(matches!(evt, Some(HotkeyEvent::Pressed { .. }))); } #[test] @@ -702,7 +754,7 @@ mod tests { assert!(state.modifiers_match()); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); } @@ -741,7 +793,7 @@ mod tests { assert!(state.modifiers_match()); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); } @@ -757,6 +809,63 @@ mod tests { assert_eq!(state.on_primary("D", true), None); } + #[test] + fn modifier_chord_activates_on_final_modifier_and_releases_with_same_generation() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "ModifierChord".into(), + modifiers: vec!["ctrl-left".into(), "cmd-left".into()], + }); + + assert_eq!(state.on_modifier_press(SideModifier::CtrlLeft), None); + let press_id = match state.on_modifier_press(SideModifier::CmdLeft) { + Some(HotkeyEvent::Pressed { press_id, .. }) => press_id, + other => panic!("expected modifier chord Pressed, got {other:?}"), + }; + assert_ne!(press_id, 0); + assert_eq!(state.on_modifier_press(SideModifier::CmdLeft), None); + + assert!(matches!( + state.on_modifier_release(SideModifier::CtrlLeft), + Some(HotkeyEvent::Released { press_id: released_id, .. }) if released_id == press_id + )); + assert!(state.active_press_id.is_none()); + } + + #[test] + fn modifier_chord_companion_aborts_once_but_still_pairs_release() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "ModifierChord".into(), + modifiers: vec!["ctrl-left".into(), "cmd-left".into()], + }); + state.on_modifier_press(SideModifier::CtrlLeft); + let press_id = match state.on_modifier_press(SideModifier::CmdLeft) { + Some(HotkeyEvent::Pressed { press_id, .. }) => press_id, + other => panic!("expected modifier chord Pressed, got {other:?}"), + }; + + assert!(matches!( + state.on_companion_key_down(), + Some(HotkeyCombinedEdge { press_id: combined_id, .. }) if combined_id == press_id + )); + assert_eq!(state.on_companion_key_down(), None); + assert!(matches!( + state.on_modifier_release(SideModifier::CmdLeft), + Some(HotkeyEvent::Released { press_id: released_id, .. }) if released_id == press_id + )); + } + + #[test] + fn extra_modifier_does_not_activate_modifier_chord_when_it_is_released() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "ModifierChord".into(), + modifiers: vec!["ctrl-left".into(), "cmd-left".into()], + }); + assert_eq!(state.on_modifier_press(SideModifier::CtrlLeft), None); + assert_eq!(state.on_modifier_press(SideModifier::ShiftLeft), None); + assert_eq!(state.on_modifier_press(SideModifier::CmdLeft), None); + assert_eq!(state.on_modifier_release(SideModifier::ShiftLeft), None); + assert!(state.active_press_id.is_none()); + } #[cfg(target_os = "macos")] #[test] fn macos_side_keycodes_are_distinct() { @@ -782,16 +891,16 @@ mod tests { state.set_side(SideModifier::CmdLeft, true); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); // Primary key up terminates the combo with exactly one Released. assert!(matches!( state.on_primary("D", false), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); // No trailing events; a second key-up must not emit anything. assert_eq!(state.on_primary("D", false), None); - assert!(!state.combo_active); + assert!(state.active_press_id.is_none()); } #[test] @@ -800,50 +909,50 @@ mod tests { state.set_side(SideModifier::CmdLeft, true); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); // Modifier lifts while primary is still down -> combo terminates once. assert!(matches!( state.on_modifier_release(SideModifier::CmdLeft), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); - assert!(!state.combo_active); + assert!(state.active_press_id.is_none()); // A now-orphaned primary key-up must NOT emit a second Released. assert_eq!(state.on_primary("D", false), None); } #[test] fn dropped_modifier_release_is_recovered_on_primary_up() { - // Simulate the OS dropping the modifier-up event: combo_active stays true + // Simulate the OS dropping the modifier-up event: the active press stays latched // and modifiers still "match" from the state's perspective. The primary // key-up (absolute termination) must still emit the paired Released. let mut state = cmd_left_d_state(); state.set_side(SideModifier::CmdLeft, true); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); // Modifier physically released but the release event never arrived, so the // side flag is still set here. Primary up is the fallback terminator. assert!(matches!( state.on_primary("D", false), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); - assert!(!state.combo_active); + assert!(state.active_press_id.is_none()); } #[test] fn stale_combo_active_reset_when_modifiers_stop_matching() { - // Reproduce a stuck latch: a prior Released was lost so combo_active is true, + // Reproduce a stuck latch: a prior Released was lost so an active press is still latched, // yet the required modifier is no longer held (modifiers_match() == false). // The next primary-down must self-heal by emitting the terminal Released // (NOT swallow it, and NOT emit a second Pressed) so recording can't stick. let mut state = cmd_left_d_state(); - state.combo_active = true; // stale flag from a dropped Released + state.active_press_id = Some(crate::hotkey::next_press_id()); // stale flag from a dropped Released assert!(!state.modifiers_match()); // cmd-left is not held let evt = state.on_primary("D", true); - assert!(matches!(evt, Some(ComboHotkeyEvent::Released { .. }))); - assert!(!state.combo_active); + assert!(matches!(evt, Some(HotkeyEvent::Released { .. }))); + assert!(state.active_press_id.is_none()); } #[test] @@ -852,26 +961,26 @@ mod tests { // must be able to fire a fresh Pressed. Guards against the combo becoming // permanently unrepeatable (the historical #545/#468 stuck-latch class). let mut state = cmd_left_d_state(); - state.combo_active = true; // leftover from a dropped Released + state.active_press_id = Some(crate::hotkey::next_press_id()); // leftover from a dropped Released // Releasing the required side-modifier breaks the match, so the stale latch // self-heals by emitting the terminal Released here (pairing the Pressed whose - // Released was dropped). Either way combo_active must end up cleared. + // Released was dropped). Either way the active press must end up cleared. assert!(matches!( state.on_modifier_release(SideModifier::CmdLeft), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); - assert!(!state.combo_active); + assert!(state.active_press_id.is_none()); // Fresh, clean press cycle now behaves normally. state.set_side(SideModifier::CmdLeft, true); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); assert!(matches!( state.on_primary("D", false), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); } @@ -882,13 +991,13 @@ mod tests { // must NOT emit a second Pressed — that would break the pairing invariant. let mut state = cmd_left_d_state(); state.set_side(SideModifier::CmdLeft, true); - state.combo_active = true; // pretend previous Released was dropped + state.active_press_id = Some(crate::hotkey::next_press_id()); // pretend previous Released was dropped assert!(state.modifiers_match()); assert_eq!(state.on_primary("D", true), None); // The real terminator (primary up) still yields exactly one Released. assert!(matches!( state.on_primary("D", false), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); } diff --git a/openless-all/app/src-tauri/src/tauri_events.rs b/openless-all/app/src-tauri/src/tauri_events.rs index 6bb39ff86..a4352035b 100644 --- a/openless-all/app/src-tauri/src/tauri_events.rs +++ b/openless-all/app/src-tauri/src/tauri_events.rs @@ -60,7 +60,26 @@ pub fn start(app: AppHandle, backend: Arc) { log::error!("[core-events] backend start failed: {error}"); return; } + if backend.ensure_runtime_ready().is_err() { + return; + } let preferences = backend.get_preferences(); + if !preferences.active_asr_provider.is_empty() { + if let Err(error) = + crate::commands::sync_active_asr_provider_to_vault(&preferences.active_asr_provider) + { + log::warn!("[startup] active ASR provider mirror failed: {error}"); + } + } + #[cfg(target_os = "windows")] + { + let target = openless_core::WindowsKeyboardRuntimeTarget::from(&preferences); + if let Err(error) = crate::windows_ime_profile::apply_windows_openless_keyboard_list( + target.openless_language_profile_enabled, + ) { + log::warn!("[windows-ime] startup keyboard visibility failed: {error}"); + } + } if let Err(error) = backend .services() .remote_input @@ -136,6 +155,16 @@ async fn forward_legacy_event( } match kind { BackendEventKind::PreferencesChanged(_) => emit_preferences(app, backend), + BackendEventKind::CloudSyncStateChanged(event) => { + let _ = app.emit_to("main", "cloud-sync-e2ee:state", event); + } + BackendEventKind::CloudSyncConflictDetected(event) => { + let _ = app.emit_to("main", "cloud-sync-e2ee:conflict", event); + } + BackendEventKind::CloudSyncRestoreCompleted(event) => { + emit_preferences(app, backend); + let _ = app.emit_to("main", "cloud-sync-e2ee:restored", event); + } BackendEventKind::CredentialsChanged(status) => { let _ = app.emit("credentials:changed", status); } @@ -220,6 +249,7 @@ async fn forward_legacy_event( phase, level, elapsed_ms, + .. } = &event.kind { // 胶囊只展示Core语音快照。已开始的其它会话拥有共享窗口,旧Less终态不得盖掉它。 @@ -304,7 +334,7 @@ async fn forward_legacy_event( let _ = app.emit_to( crate::coordinator::qa_event_target(), "qa:level", - serde_json::json!({ "level": level.level }), + serde_json::json!({ "sessionId": level.session_id, "level": level.level }), ); } BackendEventKind::QaState(state) => { @@ -1237,6 +1267,9 @@ mod tests { phase: openless_core::LessComputerVoicePhase::Transcribing, level: 0.0, elapsed_ms: 456, + mode: openless_core::LessComputerVoiceMode::Submit, + transcript: String::new(), + outcome: None, }, }; let payload = transcription_notice_payload( diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index a4d8652c2..9ae2c098c 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenLess", - "version": "2.0.0-Beta.2+build.20260924", + "version": "2.0.0-Beta.3+build.20260925", "identifier": "com.openless.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/openless-all/app/src-tauri/tauri.macos-mlx.conf.json b/openless-all/app/src-tauri/tauri.macos-mlx.conf.json index 4dcbd2b5d..d1fc4359c 100644 --- a/openless-all/app/src-tauri/tauri.macos-mlx.conf.json +++ b/openless-all/app/src-tauri/tauri.macos-mlx.conf.json @@ -5,6 +5,13 @@ }, "bundle": { "macOS": { + "dmg": { + "background": "dmg/installer-background@2x.png", + "windowPosition": { "x": 120, "y": 120 }, + "windowSize": { "width": 768, "height": 512 }, + "appPosition": { "x": 216, "y": 253 }, + "applicationFolderPosition": { "x": 552, "y": 253 } + }, "files": { "Resources/mlx.metallib": "target/release/openless-mlx/mlx.metallib" } diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index 907d8e969..674a012e4 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -340,7 +340,7 @@ export function UpdateDialog({ style={{ position: 'fixed', inset: 0, - background: 'rgba(0,0,0,0.22)', + background: 'var(--ol-dialog-backdrop)', display: 'grid', placeItems: 'center', zIndex: 40, @@ -350,10 +350,10 @@ export function UpdateDialog({
diff --git a/openless-all/app/src/components/CloudSyncSetupPrompt.tsx b/openless-all/app/src/components/CloudSyncSetupPrompt.tsx new file mode 100644 index 000000000..c634280dd --- /dev/null +++ b/openless-all/app/src/components/CloudSyncSetupPrompt.tsx @@ -0,0 +1,214 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CloudIcon, XIcon } from 'lucide-react'; +import { isTauri } from '../lib/ipc/shared'; +import { cloudSyncE2eeClaimSetupPrompt } from '../lib/ipc/cloud-sync-e2ee'; +import { CloudSyncSection } from '../pages/settings/CloudSyncSection'; +import { Modal } from './ui/Modal'; + +const PROMPT_EVENTS = new Set([ + 'backend_started', + 'preferences_changed', + 'credentials_changed', + 'dictation_completed', + 'dictation_state_changed', + 'qa_state', + 'selection_state_changed', + 'selection_voice_state_changed', + 'less_computer_event', + 'local_asr_engine_changed', + 'permission_changed', +]); + +/** Core owns eligibility and the durable once-per-installation claim. */ +export function CloudSyncSetupPrompt({ + blocked, + onSetup, +}: { + blocked: boolean; + onSetup: () => void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const checkRef = useRef(0); + const focusRef = useRef(null); + + useEffect(() => { + if (!isTauri || blocked) return; + let cancelled = false; + let inFlight = false; + let timer: ReturnType | undefined; + let unlisten: (() => void) | undefined; + const check = async () => { + if (cancelled || inFlight || document.visibilityState !== 'visible' || !document.hasFocus()) + return; + inFlight = true; + const request = ++checkRef.current; + try { + const claimed = await cloudSyncE2eeClaimSetupPrompt(); + if ( + !cancelled && + checkRef.current === request && + claimed && + document.visibilityState === 'visible' && + document.hasFocus() + ) + setOpen(true); + } catch { + // Denied credential reads and in-progress saves never consume consent + // or display a misleading configuration-complete message. + } finally { + inFlight = false; + } + }; + const schedule = () => { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + void check(); + }, 300); + }; + const invalidate = () => { + checkRef.current += 1; + setOpen(false); + }; + const eligibilityChanged = () => { + invalidate(); + schedule(); + }; + void import('@tauri-apps/api/event') + .then(async ({ listen }) => { + const handle = await listen<{ kind: { type: string } }>('backend:event', ({ payload }) => { + if (PROMPT_EVENTS.has(payload.kind.type)) eligibilityChanged(); + }); + if (cancelled) handle(); + else { + unlisten = handle; + schedule(); + } + }) + .catch(() => {}); + window.addEventListener('focus', schedule); + window.addEventListener('blur', invalidate); + document.addEventListener('visibilitychange', eligibilityChanged); + return () => { + cancelled = true; + checkRef.current += 1; + if (timer) clearTimeout(timer); + unlisten?.(); + window.removeEventListener('focus', schedule); + window.removeEventListener('blur', invalidate); + document.removeEventListener('visibilitychange', eligibilityChanged); + }; + }, [blocked]); + + useEffect(() => { + if (!open || blocked) return; + const previous = document.activeElement; + focusRef.current?.focus(); + return () => { + if (previous instanceof HTMLElement) previous.focus(); + }; + }, [open, blocked]); + + if (!open || blocked) return null; + return ( + setOpen(false)} width="min(480px, 100%)" zIndex={75}> +
{ + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + } + if (event.key === 'Tab') { + const buttons = [...event.currentTarget.querySelectorAll('button')]; + const last = buttons[buttons.length - 1]; + const next = event.shiftKey ? last : buttons[0]; + if (document.activeElement === (event.shiftKey ? buttons[0] : last)) { + event.preventDefault(); + next?.focus(); + } + } + }} + > +
+
+ ); +} + +/** Available before API-key setup. Opening this never enables sync or uploads. */ +export function CloudSyncWelcome() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + return ( + <> + + {open && ( + setOpen(false)} width="min(680px, 100%)" zIndex={50}> +
+ +
+ +
+ )} + + ); +} diff --git a/openless-all/app/src/components/FloatingShell.tsx b/openless-all/app/src/components/FloatingShell.tsx index 114462417..a2a96c20e 100644 --- a/openless-all/app/src/components/FloatingShell.tsx +++ b/openless-all/app/src/components/FloatingShell.tsx @@ -8,6 +8,7 @@ import { Tooltip } from './Tooltip'; import { WindowChrome, detectOS, type OS } from './WindowChrome'; import { AudioCueListener } from './AudioCue'; import { SettingsModal } from './SettingsModal'; +import { CloudSyncSetupPrompt } from './CloudSyncSetupPrompt'; import { Overview } from '../pages/Overview'; import { History } from '../pages/History'; import { Vocab } from '../pages/Vocab'; @@ -17,7 +18,7 @@ import { Translation } from '../pages/Translation'; import { SelectionAsk } from '../pages/SelectionAsk'; import { QuickNote } from '../pages/QuickNote'; import { Corrections } from '../pages/Corrections'; -import { APP_VERSION_LABEL, IS_BETA_BUILD } from '../lib/appVersion'; +import { IS_BETA_BUILD } from '../lib/appVersion'; import { HOTKEY_MODE_MIGRATION_ACK_KEY, HOTKEY_MODE_MIGRATION_DEFERRED_KEY, @@ -356,8 +357,6 @@ function FloatingShellBody({ {t('shell.betaTag')} )} - - {t('shell.footer.version', { version: APP_VERSION_LABEL })}
{/* nav — 扁平项 + 可展开分组(用户拍板结构)。扁平项:概览/历史/词汇。 @@ -635,6 +634,10 @@ function FloatingShellBody({ closing={providerPromptMount.closing} onLater={rememberProviderPrompt} onOpenSettings={openProviderSettings} + onRestore={() => { + rememberProviderPrompt(); + openSettings('privacy'); + }} /> ) : hotkeyPromptMount.mounted ? ( ) : null} + openSettings('privacy')} + /> {/* tab 切换 + provider prompt + footer popover 公用的入场关键帧 */} @@ -899,10 +906,12 @@ function ProviderSetupPrompt({ closing = false, onLater, onOpenSettings, + onRestore, }: { closing?: boolean; onLater: () => void; onOpenSettings: () => void; + onRestore: () => void; }) { const { t } = useTranslation(); return ( @@ -915,7 +924,7 @@ function ProviderSetupPrompt({ alignItems: 'center', justifyContent: 'center', padding: 28, - background: 'rgba(15,17,22,0.28)', + background: 'var(--ol-dialog-backdrop)', backdropFilter: 'blur(6px) saturate(140%)', WebkitBackdropFilter: 'blur(6px) saturate(140%)', animation: closing @@ -926,10 +935,10 @@ function ProviderSetupPrompt({
{t('shell.providerPrompt.body')}
+
@@ -298,8 +315,13 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps) {phase.kind === 'success' && (
-
-
✓
+
+
{t('marketplace.oauth.successAs', { login: phase.login })}
@@ -313,9 +335,9 @@ export function GithubLoginModal({ onClose, onSuccess }: GithubLoginModalProps) style={{ padding: 12, borderRadius: 10, - border: '0.5px solid rgba(239,68,68,0.3)', - background: 'rgba(239,68,68,0.06)', - color: '#b91c1c', + border: '0.5px solid color-mix(in srgb, var(--ol-err) 32%, transparent)', + background: 'color-mix(in srgb, var(--ol-err) 8%, transparent)', + color: 'var(--ol-err)', fontSize: 12, lineHeight: 1.6, whiteSpace: 'pre-wrap', diff --git a/openless-all/app/src/components/Onboarding.tsx b/openless-all/app/src/components/Onboarding.tsx index 97d1ea28a..32737d10f 100644 --- a/openless-all/app/src/components/Onboarding.tsx +++ b/openless-all/app/src/components/Onboarding.tsx @@ -20,6 +20,7 @@ import { getHotkeyTriggerLabel } from '../lib/hotkey'; import type { PermissionStatus, PlatformCapabilities } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { ProvidersSection } from '../pages/settings/ChannelList'; +import { CloudSyncWelcome } from './CloudSyncSetupPrompt'; interface OnboardingProps { onComplete: () => void; @@ -44,7 +45,12 @@ export function Onboarding({ onComplete }: OnboardingProps) { return ; } - return ; + return ( + <> + + + + ); } function AndroidOnboarding({ onComplete }: OnboardingProps) { diff --git a/openless-all/app/src/components/SavedToast.tsx b/openless-all/app/src/components/SavedToast.tsx index 33541271c..cb9abd2ec 100644 --- a/openless-all/app/src/components/SavedToast.tsx +++ b/openless-all/app/src/components/SavedToast.tsx @@ -14,6 +14,9 @@ interface SavedToastProps { message: string; offsetStyle?: Pick; slideFrom?: ToastSlideFrom; + actionLabel?: string; + onAction?: () => void; + durationMs?: number; } export function SavedToast({ @@ -21,6 +24,9 @@ export function SavedToast({ message, offsetStyle, slideFrom = 'right', + actionLabel, + onAction, + durationMs, }: SavedToastProps) { // 维护内部状态,使通知可以自己倒计时关闭(即使用户父组件的 timer 长于 0.8s) const [internalVisible, setInternalVisible] = useState(false); @@ -28,12 +34,14 @@ export function SavedToast({ useEffect(() => { if (saveState !== 'idle') { setInternalVisible(true); - // 满足用户要求:弹出后约 0.8 秒自动收回 - const timer = window.setTimeout(() => setInternalVisible(false), 800); + const timer = window.setTimeout( + () => setInternalVisible(false), + durationMs ?? (onAction ? 6000 : 800), + ); return () => window.clearTimeout(timer); } setInternalVisible(false); - }, [saveState, message]); + }, [saveState, message, durationMs, onAction]); const failed = saveState === 'failed'; @@ -59,7 +67,7 @@ export function SavedToast({ : '0 4px 12px -8px rgba(37,99,235,.26)', backdropFilter: 'blur(12px) saturate(160%)', WebkitBackdropFilter: 'blur(12px) saturate(160%)', - pointerEvents: 'none', + pointerEvents: onAction ? 'auto' : 'none', whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', @@ -83,6 +91,24 @@ export function SavedToast({ style={style} > {failed ? '⚠️' : '✓'} {message} + {actionLabel && onAction && ( + + )} )} diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 5bafe5913..2330ac807 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -292,13 +292,14 @@ export function SettingsModal({ return (
event.stopPropagation()} onKeyDown={handleKeyDown} @@ -328,9 +329,9 @@ export function SettingsModal({ maxHeight: mobile ? undefined : 680, minHeight: 0, background: 'var(--ol-settings-content-bg)', - borderRadius: mobile ? 0 : 14, - border: mobile ? 'none' : '0.5px solid var(--ol-line)', - boxShadow: mobile ? 'none' : 'var(--ol-shadow-xl)', + borderRadius: mobile ? 0 : 'var(--ol-dialog-radius)', + border: mobile ? 'none' : '1px solid var(--ol-dialog-border)', + boxShadow: mobile ? 'none' : 'var(--ol-dialog-shadow)', display: 'flex', flexDirection: 'column', overflow: 'hidden', diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index f5f368625..ac7ab4358 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -2,7 +2,12 @@ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } f import { AnimatePresence, motion } from 'framer-motion'; import { ChevronDown } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { formatComboParts, modifiersFromPressedCodes } from '../lib/hotkey'; +import { + chordModifiersFromPressedCodes, + formatComboParts, + MODIFIER_CHORD_PRIMARY, + modifiersFromPressedCodes, +} from '../lib/hotkey'; import { functionKeyPrimaryFromEvent } from '../lib/hotkeyRecorder'; import { KbdGroup } from './Kbd'; import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc'; @@ -194,6 +199,20 @@ export function ShortcutRecorder({ if (comboOnly) { return; } + if (sideSpecificModifiers) { + const modifiers = chordModifiersFromPressedCodes(pressedCodes.current); + if (modifiers.length >= 2) { + clearPendingModifier(); + const binding = { primary: MODIFIER_CHORD_PRIMARY, modifiers }; + pendingModifier.current = binding; + pendingTimer.current = window.setTimeout(() => { + if (pendingModifier.current === binding) { + void finish(binding); + } + }, 650); + return; + } + } const primary = modifierPrimaryFromCode(e.code, e.key); if (!primary || pendingModifier.current?.primary === primary) return; clearPendingModifier(); @@ -222,6 +241,12 @@ export function ShortcutRecorder({ e.stopPropagation(); pressedCodes.current.delete(e.code); if (comboOnly) return; + if (pendingModifier.current?.primary === MODIFIER_CHORD_PRIMARY) { + const binding = pendingModifier.current; + clearPendingModifier(); + void finish(binding); + return; + } const primary = modifierPrimaryFromCode(e.code, e.key); if (primary && pendingModifier.current?.primary === primary) { const binding = pendingModifier.current; diff --git a/openless-all/app/src/components/chat/AgentBuddy.tsx b/openless-all/app/src/components/chat/AgentBuddy.tsx new file mode 100644 index 000000000..4f87549b2 --- /dev/null +++ b/openless-all/app/src/components/chat/AgentBuddy.tsx @@ -0,0 +1,84 @@ +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/LiveWaveform.tsx b/openless-all/app/src/components/chat/LiveWaveform.tsx new file mode 100644 index 000000000..62bdaa6f2 --- /dev/null +++ b/openless-all/app/src/components/chat/LiveWaveform.tsx @@ -0,0 +1,124 @@ +import { useEffect, useRef } from 'react'; + +const BAR_WIDTH = 3; +const BAR_GAP = 3; +const SAMPLE_MS = 70; +const MIN_BAR = 2; + +function clampLevel(level: number): number { + return Number.isFinite(level) ? Math.max(0, Math.min(1, level)) : 0; +} + +/** + * Scrolling history of the real microphone level. Each bar is one sampled + * level (fast attack, slow release); nothing is synthesized while recording. + * `processing` replaces the history with a quiet travelling pulse. + */ +export function LiveWaveform({ + level, + processing = false, + label, +}: { + level: number; + processing?: boolean; + label: string; +}) { + const canvasRef = useRef(null); + const levelRef = useRef(0); + const processingRef = useRef(processing); + levelRef.current = clampLevel(level); + processingRef.current = processing; + + useEffect(() => { + const canvas = canvasRef.current; + const context = canvas?.getContext('2d'); + if (!canvas || !context) return; + const reducedMotion = + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches; + let history: number[] = []; + let smoothed = 0; + let lastSample = performance.now(); + let width = 0; + let height = 0; + let color = ''; + let colorAge = 0; + let frame = 0; + + const resize = () => { + const rect = canvas.getBoundingClientRect(); + const ratio = window.devicePixelRatio || 1; + width = rect.width; + height = rect.height; + canvas.width = Math.max(1, Math.round(width * ratio)); + canvas.height = Math.max(1, Math.round(height * ratio)); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + }; + resize(); + const observer = typeof ResizeObserver === 'function' ? new ResizeObserver(resize) : null; + observer?.observe(canvas); + + const bar = (x: number, amplitude: number, alpha: number) => { + const h = Math.max(MIN_BAR, amplitude * height * 0.92); + const y = (height - h) / 2; + context.globalAlpha = alpha; + context.beginPath(); + if (typeof context.roundRect === 'function') + context.roundRect(x, y, BAR_WIDTH, h, BAR_WIDTH / 2); + else context.rect(x, y, BAR_WIDTH, h); + context.fill(); + }; + + const draw = (now: number) => { + const step = BAR_WIDTH + BAR_GAP; + const capacity = Math.max(1, Math.ceil(width / step) + 1); + if (colorAge-- <= 0) { + color = getComputedStyle(canvas).color; + colorAge = 30; + } + context.clearRect(0, 0, width, height); + context.fillStyle = color; + + if (processingRef.current) { + smoothed = 0; + history = []; + for (let j = 0; j < capacity; j += 1) { + const wave = reducedMotion ? 0.5 : 0.5 + 0.5 * Math.sin(now / 240 - j * 0.45); + bar(width - BAR_WIDTH - j * step, 0.1 + 0.16 * wave, 0.35 + 0.35 * wave); + } + } else { + const target = levelRef.current; + smoothed += (target - smoothed) * (target > smoothed ? 0.6 : 0.2); + if (now - lastSample >= SAMPLE_MS) { + history.push(smoothed); + lastSample = now; + if (history.length > capacity) history = history.slice(-capacity); + } + const drift = reducedMotion ? 0 : Math.min(1, (now - lastSample) / SAMPLE_MS); + for (let j = 0; j < capacity; j += 1) { + const sample = history[history.length - 1 - j] ?? 0; + const amplitude = Math.min(1, Math.sqrt(sample) * 1.08); + const age = j / capacity; + bar(width - BAR_WIDTH - (j + drift) * step, amplitude, 1 - age * 0.65); + } + } + context.globalAlpha = 1; + frame = window.requestAnimationFrame(draw); + }; + frame = window.requestAnimationFrame(draw); + return () => { + window.cancelAnimationFrame(frame); + observer?.disconnect(); + }; + }, []); + + return ( +
+
+ ); +} diff --git a/openless-all/app/src/components/chat/VoiceWaveform.tsx b/openless-all/app/src/components/chat/VoiceWaveform.tsx new file mode 100644 index 000000000..9dfb748d3 --- /dev/null +++ b/openless-all/app/src/components/chat/VoiceWaveform.tsx @@ -0,0 +1,39 @@ +import type { CSSProperties } from 'react'; + +const WEIGHTS = [ + 0.2, 0.4, 0.7, 0.45, 0.9, 0.65, 1, 0.75, 0.5, 0.95, 0.65, 0.4, 0.8, 0.55, 0.35, 0.6, 0.25, +]; + +export function VoiceWaveform({ + level, + processing = false, + label, +}: { + level: number; + processing?: boolean; + label: string; +}) { + const safeLevel = Number.isFinite(level) ? Math.max(0, Math.min(1, level)) : 0; + return ( +
+ + {label} +
+ ); +} diff --git a/openless-all/app/src/components/chat/chat.css b/openless-all/app/src/components/chat/chat.css index c008de616..bdcff399c 100644 --- a/openless-all/app/src/components/chat/chat.css +++ b/openless-all/app/src/components/chat/chat.css @@ -350,10 +350,159 @@ /* Opaque backing prevents desktop text from bleeding through the floating panel. */ .olchat-shell { - background-color: #fbfbfc; - background-image: - linear-gradient(rgba(251, 251, 252, 0.985), rgba(248, 248, 250, 0.985)), var(--ol-frost-grain); - color: #18181b; + --ol-surface: #ffffff; + --ol-ink: #202937; + --ol-ink-2: #455166; + --ol-ink-3: #68758a; + --ol-line: #e7ebf1; + --ol-line-soft: #f0f3f7; + --ol-blue: #3164d6; + --ol-blue-soft: #edf3ff; + --ol-control-muted: #edf1f7; + --ol-dialog-wash: #f6f8fd; + --foreground: #202937; + --muted-foreground: #68758a; + --muted: #f1f4f8; + --primary: #3164d6; + --primary-foreground: #ffffff; + --border: #e3e9f1; + --input: #dce3ed; + padding: 0; + background: #fff; + color: #202937; isolation: isolate; + border: 1px solid #dfe5ee; + border-radius: 22px; + box-shadow: inset 0 1px 0 #fff; +} +.olchat-shell.olchat-embedded { + border: 0; + border-radius: 0; +} +.olchat-shell > [data-slot='card-content'] { + min-height: 0; + background: linear-gradient(180deg, #fafbfd 0, #fff 100px); +} +.olchat-shell [data-slot='message-scroller-content'] { + padding: 20px; +} +.olchat-shell [data-slot='message'] { + gap: 10px; +} +.olchat-shell [data-slot='message-avatar'] { + align-self: flex-start; + margin-top: 3px; + width: 27px; + min-width: 27px; + height: 27px; + box-shadow: 0 0 0 2px #fff; +} +.olchat-shell [data-slot='bubble'] { + max-width: 90%; + background: transparent; + border: 0; + border-radius: 0; +} +.olchat-shell [data-slot='message'][data-align='end'] [data-slot='bubble-content'] { + color: #24416e; + background: #edf3ff; + border: 1px solid #e2eaff; + border-radius: 15px 15px 5px 15px; +} +.olchat-shell [data-slot='bubble'][data-variant='secondary'] [data-slot='bubble-content'] { + background: #f4f6f9; + border-color: #eef1f5; + border-radius: 12px; + color: #68758a; +} +.olchat-shell [data-slot='bubble-content'] { + font-size: 13px; + line-height: 1.65; +} +.olchat-shell .olchat-answer { + font-size: 13px; + line-height: 1.75; + color: #29364a; +} +.olchat-shell .olchat-answer p { + margin-bottom: 9px; +} +.olchat-shell .olchat-answer li { + margin: 4px 0; +} +.olchat-shell > [data-slot='card-footer'] { + flex-shrink: 0; + padding: 14px 16px 16px; + background: linear-gradient(180deg, #fff, #f7f9fd); + border-top: 1px solid #f0f3f7; +} +.olchat-composer[data-slot='input-group'] { + min-height: 83px; + border: 1px solid #dce3ed; + border-radius: 16px; + background: #fff; + box-shadow: 0 3px 12px rgba(25, 46, 80, 0.035); + transition: + border-color 0.18s ease, + box-shadow 0.18s ease; +} +.olchat-composer[data-slot='input-group']:focus-within { + border-color: #8ba9eb; + box-shadow: 0 0 0 3px rgba(49, 100, 214, 0.09); +} +.olchat-composer input:not([type='checkbox']) { + min-height: 42px; + padding: 12px 14px 7px; + font-size: 13px; + color: #26344a; +} +.olchat-composer input::placeholder { + color: #8490a2; +} +.olchat-composer [data-slot='input-group-addon'] { + padding: 0 10px 10px; + gap: 8px; +} +.olchat-composer button { + min-width: 30px; + height: 30px; + border-radius: 10px; +} +.olchat-composer button[type='submit']:not(:disabled) { + box-shadow: 0 3px 7px rgba(49, 100, 214, 0.18); +} +.olchat-composer input[type='checkbox'] { + accent-color: #3164d6; +} +.olchat-composer:has([data-slot='input-group-addon']) { + --radius-2xl: 16px; +} +.olchat-empty { + padding: 30px 24px; +} +.olchat-empty [data-slot='empty-media'] { + width: 56px; + height: 56px; + margin-bottom: 8px; + border: 1px solid #e0e9fb; border-radius: 18px; + color: #5680d7; + background: linear-gradient(145deg, #fff, #edf3ff); + box-shadow: 0 7px 20px rgba(30, 64, 120, 0.04); +} +.olchat-empty [data-slot='empty-title'] { + font-size: 15px; + font-weight: 600; +} +.olchat-empty [data-slot='empty-description'] { + max-width: 245px; + font-size: 12px; + line-height: 1.75; +} +@media (prefers-reduced-motion: reduce) { + .olchat-shell-out, + .olchat-composer { + animation: none; + transition: none; + } } diff --git a/openless-all/app/src/components/ui/Modal.tsx b/openless-all/app/src/components/ui/Modal.tsx index 4ea187fba..5c0c66cea 100644 --- a/openless-all/app/src/components/ui/Modal.tsx +++ b/openless-all/app/src/components/ui/Modal.tsx @@ -1,10 +1,11 @@ // Modal — 居中弹窗:backdrop + 卡片。风格市场详情 / 上传 / 我的发布 / GitHub 登录 // 等共用同一套弹出逻辑,避免每处各写一个。 // -// 动画沿用 global.css 的 ol-modal-backdrop-in / ol-modal-card-in(纯 opacity + -// transform,不碰 blur),与设置弹窗、各市场弹窗保持一致。 +// 动画在 overlays.css 的 .ol-dialog-overlay / .ol-dialog-card 上(纯 opacity + +// transform,不碰 blur)。退场使用独立的 *-out 关键帧:仅反转同名动画的方向不会 +// 重新播放,已结束的入场动画会直接跳到起始帧,表现为「啪」地消失。 -import type { CSSProperties, ReactNode } from 'react'; +import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; interface ModalProps { @@ -16,9 +17,24 @@ interface ModalProps { width?: string; /** 需要固定标题和底栏的弹窗可由内部内容区负责滚动。 */ style?: CSSProperties; - /** true 时反向播放入场动画;调用方用 + /** true 时播放退场动画;调用方用 * useExitMount 门控卸载时机,动画播完再 unmount。 */ closing?: boolean; + /** 宿主窗口定制遮罩与卡片外观(例如圆角浮窗需要圆角遮罩)。 */ + overlayClassName?: string; + labelledBy?: string; +} + +const FOCUSABLE = + 'a[href], button:not([disabled]), textarea:not([disabled]), select:not([disabled]), input:not([disabled]):not([type="hidden"]), [tabindex]:not([tabindex="-1"])'; + +// Only the top-most dialog keeps Tab cycling inside itself. +const openModals: symbol[] = []; + +function focusableWithin(root: HTMLElement): HTMLElement[] { + return Array.from(root.querySelectorAll(FOCUSABLE)).filter( + (element) => element.getClientRects().length > 0, + ); } export function Modal({ @@ -28,7 +44,47 @@ export function Modal({ width = 'min(560px, 100%)', style, closing = false, + overlayClassName, + labelledBy, }: ModalProps) { + const cardRef = useRef(null); + + useEffect(() => { + const id = Symbol('modal'); + openModals.push(id); + const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const card = cardRef.current; + if (card && !card.contains(document.activeElement)) card.focus({ preventScroll: true }); + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || !card || openModals[openModals.length - 1] !== id) return; + const items = focusableWithin(card); + const active = document.activeElement; + if (items.length === 0) { + event.preventDefault(); + card.focus({ preventScroll: true }); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + if (event.shiftKey) { + if (active === first || active === card || !card.contains(active)) { + event.preventDefault(); + last.focus(); + } + } else if (active === last || !card.contains(active)) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', onKeyDown); + return () => { + document.removeEventListener('keydown', onKeyDown); + const index = openModals.indexOf(id); + if (index >= 0) openModals.splice(index, 1); + if (previous?.isConnected) previous.focus({ preventScroll: true }); + }; + }, []); + // Portal 到 document.body:弹窗常从设置 / 市场等面板内部触发,而窗口 chrome // (WindowChrome)和页面容器带常驻 `will-change: transform`,会创建 containing // block —— 直接渲染的话 backdrop 的 `position: fixed` 会相对那个祖先而非视口定位, @@ -36,34 +92,37 @@ export function Modal({ // Portal 出去后 fixed 相对视口,遮罩铺满全局。与 Tooltip / SelectLite 同款做法。 return createPortal(
e.stopPropagation()} style={{ width, maxHeight: '85vh', overflow: 'auto', - borderRadius: 16, + borderRadius: 'var(--ol-dialog-radius)', background: 'var(--ol-surface)', - border: '0.5px solid var(--ol-line-strong)', - boxShadow: '0 18px 42px rgba(0,0,0,0.18)', - padding: 22, - animation: closing - ? 'ol-modal-card-in 0.18s var(--ol-motion-soft) reverse both' - : 'ol-modal-card-in 0.24s var(--ol-motion-spring)', + border: '1px solid var(--ol-dialog-border)', + boxShadow: 'var(--ol-dialog-shadow)', + padding: 24, + outline: 'none', ...style, }} > diff --git a/openless-all/app/src/components/ui/ToolWindowHeader.tsx b/openless-all/app/src/components/ui/ToolWindowHeader.tsx new file mode 100644 index 000000000..ad640d58e --- /dev/null +++ b/openless-all/app/src/components/ui/ToolWindowHeader.tsx @@ -0,0 +1,50 @@ +import type { ReactNode } from 'react'; +import { X } from 'lucide-react'; + +/** Shared chrome for native utility windows; controls never become drag targets. */ +export function ToolWindowHeader({ + icon, + title, + description, + onClose, + closeLabel, + closeDisabled = false, + draggable = true, +}: { + icon: ReactNode; + title: string; + description: ReactNode; + onClose: () => void; + closeLabel: string; + closeDisabled?: boolean; + draggable?: boolean; +}) { + const drag = draggable ? { 'data-tauri-drag-region': true } : {}; + return ( +
+ +
+

{title}

+
+ {description} +
+
+ +
+ ); +} diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 50583f25c..4f591c423 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1,6 +1,193 @@ import type { zhCN } from './zh-CN'; export const de: typeof zhCN = { + cloudSyncE2ee: { + protocolTitle: 'Cloud-Sync: Vereinbarung und Datenschutz', + protocolIntro: + 'OpenLess und seine unabhängigen Entwickler respektieren Ihre Privatsphäre und schützen Ihre Daten. Lesen Sie diese Hinweise, bevor Sie fortfahren.', + protocolPasswordTitle: 'Synchronisierungspasswort sicher aufbewahren', + protocolPassword: + 'Passwort und Entschlüsselungsschlüssel werden lokal verwendet und nicht an den Sync-Dienst gesendet. Geht das Passwort verloren und kann kein Gerät das Backup mehr entsperren, können wir den Inhalt nicht wiederherstellen.', + protocolEncryptionTitle: 'Vor dem Hochladen lokal verschlüsseln', + protocolEncryption: + 'Einstellungen, API-Schlüssel und Textverlauf werden auf diesem Gerät verschlüsselt. Backup-Inhalte werden als Geheimtext übertragen und gespeichert. Der Sync-Dienst kann Ihre API-Schlüssel daraus weder lesen noch verwenden und führt damit keine Modellanfragen aus.', + protocolExcludedTitle: 'Anmeldestatus und sichtbare Metadaten', + protocolExcluded: + 'Das Backup enthält weder Sync-Passwort noch Entschlüsselungsschlüssel, von OpenLess verwaltete GitHub/OAuth-Anmeldedaten, Zugriffstoken oder private Geräteschlüssel. Die Kontoanmeldung verarbeitet notwendige Zugangsdaten separat. Kontokennung, Größe, Versionen und Sync-Zeiten bleiben für den Server sichtbar.', + protocolCheck: + 'Ich habe diese Vereinbarung gelesen, verstehe den Umfang und bewahre mein Sync-Passwort sicher auf.', + protocolBack: 'Zurück zum Umfang', + protocolConfirm: 'Gelesen und bestätigen', + setupPromptTitle: 'Einrichtung verschlüsselt sichern?', + setupPromptBody: + 'Die Dienste sind eingerichtet. Einstellungen, API-Schlüssel und Textverlauf werden auf diesem Gerät verschlüsselt. Erst nach Ihrer Zustimmung und Aktivierung werden Daten hochgeladen.', + setupPromptLater: 'Jetzt nicht', + setupPromptOpen: 'Verschlüsselte Synchronisierung', + title: 'Verschlüsselte Cloud-Synchronisierung', + description: 'Vor der Synchronisierung mit deinem GitHub-Konto auf diesem Gerät verschlüsseln.', + enable: 'Verschlüsselte Synchronisierung aktivieren', + setPassword: 'Sync-Passwort festlegen', + stepEnableTitle: 'Schritt 1 von 3: Synchronisierung einschalten', + stepEnableDetail: 'Den Schalter verwenden. Nach der Bestätigung folgt das Sync-Passwort.', + stepPasswordTitle: 'Schritt 2 von 3: Sync-Passwort festlegen', + stepPasswordDetail: 'Noch ein Schritt. Die Synchronisierung startet erst nach dem Passwort.', + stepUnlockTitle: 'Schritt 2 von 3: Dieses Gerät entsperren', + stepUnlockDetail: 'Noch ein Schritt. Sync-Passwort eingeben, um dieses Gerät zu entsperren.', + stepClosedTitle: 'Synchronisierung ist aus', + stepClosedDetail: 'Die Cloud-Sicherung bleibt. Schalter einschalten, um fortzufahren.', + stepDoneTitle: 'Schritt 3 von 3: Synchronisierung ist bereit', + stepDoneDetail: 'Dieses Gerät ist entsperrt und die Synchronisierung ist an.', + stepPreparingTitle: 'Nächster Schritt wird geöffnet', + stepPreparingDetail: 'Hinweis bestätigt. Als Nächstes legen Sie das Sync-Passwort fest.', + stepFollowDetail: 'Mit der Schaltfläche in dieser Zeile fortfahren.', + refresh: 'Status aktualisieren', + loading: 'Synchronisierungsstatus wird geprüft…', + signIn: 'Mit GitHub anmelden', + signOut: 'Abmelden', + account: 'Synchronisierungskonto', + keyLocked: 'Auf diesem Gerät gesperrt', + keyUnlocked: 'Auf diesem Gerät entsperrt', + hasSnapshot: 'Eine verschlüsselte Cloud-Sicherung ist verfügbar.', + noSnapshot: 'Noch keine verschlüsselte Cloud-Sicherung.', + snapshotUnknown: 'Cloud-Sicherung noch nicht geprüft.', + lastSync: 'Zuletzt synchronisiert: {{time}}', + syncNow: 'Jetzt synchronisieren', + checkPending: 'Ausstehendes Ergebnis prüfen', + unlock: 'Entsperren', + lock: 'Dieses Gerät sperren', + changePassword: 'Sync-Passwort ändern', + restore: 'Cloud-Wiederherstellung prüfen', + delete: 'Cloud-Sicherung löschen', + working: 'Wird verarbeitet…', + cancelTask: 'Aktuellen Vorgang abbrechen', + cancelRequested: 'Abbruch angefordert. Der Vorgang wird beendet.', + done: 'Synchronisierungseinstellungen aktualisiert.', + restored: 'Cloud-Daten wiederhergestellt. Gerätespezifische Einstellungen prüfen.', + retryAfter: 'In {{seconds}} Sekunden erneut versuchen.', + passwordWarning: + 'Bewahre dein Sync-Passwort sicher auf. Es ist nicht wiederherstellbar; bei Verlust können Cloud-Daten unlesbar werden.', + consentTitle: 'Ende-zu-Ende-verschlüsselte Synchronisierung aktivieren', + consentDescription: + 'Die Synchronisierung umfasst Dienstzugangsdaten und private Textverläufe. Alles wird vor dem Upload lokal verschlüsselt. Prüfe zuerst den vollständigen Umfang.', + scopeSummary: 'Vollständigen Umfang anzeigen', + consentCheck: + 'Ich verstehe den Umfang und stimme der verschlüsselten Synchronisierung dieser Daten zu.', + continue: 'Weiter', + createTitle: 'Sync-Passwort erstellen', + unlockTitle: 'Cloud-Sicherung entsperren', + passwordTitle: 'Sync-Passwort ändern', + create: 'Verschlüsselte Sicherung erstellen', + password: 'Sync-Passwort', + newPassword: 'Neues Sync-Passwort', + currentPassword: 'Aktuelles Sync-Passwort', + confirmPassword: 'Neues Passwort bestätigen', + passwordPolicy: + '12–128 Zeichen mit Groß- und Kleinbuchstaben sowie einer Zahl. Keine üblichen Passwörter verwenden.', + rememberKey: 'Entsperrschlüssel im sicheren Speicher dieses Geräts merken', + disableTitle: 'Verschlüsselte Synchronisierung ausschalten?', + disableDescription: + 'Weitere Synchronisierung wird gestoppt. Cloud-Sicherung und lokale Daten bleiben erhalten. Löschen ist eine separate Aktion.', + confirmDisable: 'Synchronisierung ausschalten', + deleteTitle: 'Verschlüsselte Cloud-Sicherung löschen?', + deleteDescription: + 'Nur die Cloud-Sicherung dieses Kontos wird gelöscht. Lokale Daten bleiben. Für ein anderes Gerät wird danach eine neue Sicherung benötigt.', + backupRetention: + 'Nach dem Löschen können Sicherungskopien des Dienstes bis zu {{days}} Tage aufbewahrt werden.', + deleteCheck: 'Ich bestätige die Löschung dieser Cloud-Sicherung.', + confirmDelete: 'Cloud-Sicherung löschen', + restoreTitle: 'Wiederherstellung prüfen', + restoreDescription: + 'Prüfe die Datenkategorien. Vertrauliche Werte bleiben verborgen. Ohne Bestätigung wird nichts wiederhergestellt.', + deviceReview: + 'Nach der Wiederherstellung {{count}} Geräteeinstellungen prüfen, etwa Tastenkürzel, Modellpfade, Mikrofone und Systemrechte.', + restoreMode: 'Wiederherstellungsart', + merge: 'Zusammenführen und Konflikte lösen', + replace: 'Synchronisierte lokale Daten durch Cloud-Daten ersetzen', + replaceWarning: + 'Lokale Daten im Sync-Umfang werden ersetzt. Zum Behalten lokaler Änderungen Zusammenführen wählen.', + restoreCheck: 'Ich habe Wiederherstellungsart und Konfliktentscheidungen geprüft.', + applyRestore: 'Wiederherstellung bestätigen', + conflictProgress: '{{selected}} von {{total}} Konflikten gelöst', + conflictItem: 'Konflikt {{index}} · {{category}}', + conflictLocal: 'Lokal behalten', + conflictCloud: 'Cloud verwenden', + previous: 'Zurück', + next: 'Weiter', + scope: { + preferences: 'App-Einstellungen und Oberfläche.', + credentials: + 'ASR-, LLM- und Omni-Kanäle samt API-Schlüsseln, IDs, Access Keys und Secret Keys.', + personal: 'Wörterbuch, eigene Wortlisten, Korrekturen, Stilpakete und Symbole.', + history: 'Alle Diktat- und Kurznotiz-Texte sowie Aktivitätsstatistiken.', + device: 'Geräteprofile zur Prüfung auf dem Zielgerät.', + excluded: + 'Nicht enthalten: OAuth-Anmeldesitzungen, Sync-Passwörter oder abgeleitete Schlüssel, OS-Zugangsdaten, PINs, Geräteberechtigungen, Originalaufnahmen oder Modellgewichte.', + }, + states: { + disabled: 'Synchronisierung aus', + sign_in_required: 'GitHub-Anmeldung erforderlich', + unlock_required: 'Zum Entsperren Passwort eingeben', + ready: 'Aktuell', + pending: 'Lokale Änderungen warten auf Synchronisierung', + syncing: 'Synchronisierung läuft', + conflict: 'Konflikte prüfen', + failed: 'Synchronisierung benötigt Aufmerksamkeit', + outcome_unknown: 'Cloud-Ergebnis noch unbestätigt', + recovery_required: 'Lokale Wiederherstellung erforderlich', + }, + categories: { + preferences: 'App-Einstellungen', + ui_preferences: 'Oberfläche', + channels: 'Dienstkanäle und Zugangsdaten', + provider_credentials: 'Dienstzugangsdaten', + dictionary: 'Wörterbuch', + vocabulary_presets: 'Eigene Wortlisten', + corrections: 'Korrekturen', + style_packs: 'Stilpakete und Symbole', + history: 'Textverlauf', + activity: 'Aktivitätsstatistiken', + device_profile: 'Geräteprofile', + other: 'Weitere Sync-Daten', + }, + conflictReasons: { + both_modified: 'Lokale und Cloud-Version wurden geändert.', + delete_modify: 'Eine Version wurde gelöscht, die andere geändert.', + no_common_baseline: 'Keine gemeinsame Ausgangsversion. Wähle die zu behaltende Version.', + other: 'Lokale oder Cloud-Version wählen. Werte werden nicht angezeigt.', + }, + errors: { + unknown: 'Vorgang fehlgeschlagen. Status aktualisieren und erneut versuchen.', + signIn: 'Erneut bei GitHub anmelden.', + unlock: 'Dieses Gerät zuerst mit dem Sync-Passwort entsperren.', + unavailable: + 'Verschlüsselte Synchronisierung nicht verfügbar. Native App verwenden und Dienst später prüfen.', + weakPassword: 'Ein stärkeres Passwort mit passender Länge und Zeichenwahl verwenden.', + passwordMismatch: 'Die Passwörter stimmen nicht überein.', + invalidPassword: + 'Passwort entsperrt die Sicherung nicht oder verschlüsselte Daten konnten nicht geprüft werden.', + secureStorage: + 'Zugriff auf sicheren Speicher verweigert. Berechtigungen prüfen oder ohne gespeicherten Schlüssel entsperren.', + changed: 'Daten wurden geändert. Dialog schließen, aktualisieren und neue Vorschau prüfen.', + accountChanged: 'GitHub-Konto geändert. Vor dem Fortfahren aktualisieren.', + busy: 'Ein Sync-Vorgang läuft. Warten oder zuerst abbrechen.', + cancelled: 'Vorgang abgebrochen.', + network: 'Sync-Dienst nicht erreichbar. Verbindung prüfen und erneut versuchen.', + rateLimited: 'Zu viele Anfragen. Vor erneutem Versuch warten.', + outcomeUnknown: + 'Cloud-Ergebnis unbestätigt. Ausstehenden Vorgang prüfen, bevor eine neue Sicherung erstellt wird.', + recovery: + 'Lokale Wiederherstellung nötig. Daten dieses Geräts behalten und Cloud-Sicherung nicht überschreiben.', + rolledBack: + 'Wiederherstellung fehlgeschlagen; lokale Änderungen zurückgesetzt. Vor erneutem Versuch aktualisieren.', + tooLarge: 'Verschlüsselte Sicherung überschreitet das Größenlimit.', + reviewRequired: 'Zuerst Sync-Umfang und Wiederherstellungsvorschau prüfen.', + choicesRequired: 'Für jeden Konflikt lokal oder Cloud wählen.', + invalidData: + 'Verschlüsselte Sicherung nicht verifizierbar. Lokale Daten wurden nicht ersetzt.', + localData: + 'Die Daten auf diesem Gerät konnten nicht vorbereitet werden. Status aktualisieren und erneut versuchen.', + }, + }, cloudSync: { title: 'Cloud-Synchronisierung', description: @@ -110,6 +297,18 @@ export const de: typeof zhCN = { cancel: 'Abbrechen', }, qa: { + compact: { + closeError: 'Schließen fehlgeschlagen. Erneut versuchen.', + sendError: 'Senden fehlgeschlagen. Deine Eingabe bleibt erhalten. Erneut versuchen.', + microphoneError: 'Die Mikrofonaktion ist fehlgeschlagen. Erneut versuchen.', + modeError: 'Der Bearbeitungsmodus wurde nicht geändert. Erneut versuchen.', + conversation: 'Aktuelles Gespräch', + sending: 'Deine Frage wird gesendet…', + addUnavailable: 'Inhalt hinzufügen · noch nicht verfügbar', + browserUnavailable: 'Vorschau führt keine Befehle aus', + answer: 'OpenLess-Antwort', + layoutError: 'Das Fenster konnte nicht vergrößert werden. Schließe und öffne es erneut.', + }, title: 'Nachfragen', headerHint: 'Jederzeit fragen', thinking: 'Denkt nach…', @@ -142,6 +341,87 @@ export const de: typeof zhCN = { editInstructionMode: 'Bearbeitungsanweisung', }, lessComputer: { + activity: { + process: 'Ablauf', + count: '{{count}} Aktivitäten', + count_one: '{{count}} Aktivität', + count_other: '{{count}} Aktivitäten', + finished: 'Abgeschlossen', + stopped: 'Gestoppt', + search: 'Suche', + searchRunning: 'Wird gesucht…', + read: 'Lesen', + readRunning: 'Wird gelesen…', + command: 'Befehle', + commandRunning: 'Befehle werden ausgeführt…', + edit: 'Bearbeitung', + editRunning: 'Wird bearbeitet…', + web: 'Webseiten', + webRunning: 'Webseiten werden gelesen…', + other: 'Werkzeuge', + otherRunning: 'In Arbeit…', + }, + desktop: { + approvedSubmitted: 'Freigabe gesendet', + deniedSubmitted: 'Ablehnung gesendet', + agents: 'Deine Agenten', + configured: 'Aktuell konfiguriert', + agentSettings: 'In Einstellungen wählen', + sessionUnavailable: 'Neue Sitzung · nicht verfügbar', + signedIn: 'Angemeldet', + signIn: 'Anmelden', + currentSession: 'Aktuelles Gespräch', + minimize: 'Minimieren', + maximize: 'Maximieren / Wiederherstellen', + windowError: 'Die Fensteraktion ist fehlgeschlagen. Erneut versuchen.', + idle: 'Wartet auf eine Aufgabe', + waitingApproval: 'Wartet auf Freigabe', + submittingApproval: 'Wird gesendet…', + approvalError: 'Die Freigabe wurde nicht gesendet. Erneut versuchen.', + approvalExpired: 'Dieser Durchlauf ist beendet. Eine Freigabe ist nicht mehr möglich.', + sendError: 'Senden fehlgeschlagen. Deine Eingabe bleibt erhalten. Erneut versuchen.', + browserUnavailable: 'Die Browser-Vorschau führt keine Befehle aus. Nutze die Desktop-App.', + inputHint: 'Enter zum Senden · Umschalt + Enter für eine neue Zeile', + apiCost: 'API-Kosten dieses Durchlaufs', + emptyHint: 'Beschreibe eine Aufgabe, tippe aufs Mikrofon oder nutze dein Sprach-Kürzel.', + showInspector: 'Arbeitsbereich einblenden', + hideInspector: 'Arbeitsbereich ausblenden', + inspectorTitle: 'Arbeitsbereich', + inspectorStatus: 'Status', + inspectorTurn: 'Diese Runde', + toolCalls: 'Tool-Aufrufe', + pendingApprovals: 'Wartet auf dich', + inspectorVoice: 'Spracheingabe', + voiceShortcutOff: + 'Kein Sprach-Kürzel festgelegt. Du kannst es in den Einstellungen aktivieren.', + voiceModesHint: + 'Mikrofon: Der Text landet im Eingabefeld, damit du ihn vor dem Senden bearbeiten kannst. Sprachtaste: wird beim Stoppen direkt an den Agenten gesendet.', + copy: 'Kopieren', + copied: 'Kopiert', + messagePlaceholder: 'Nachricht an {{agent}}', + }, + voice: { + dictate: 'Ins Eingabefeld diktieren', + voiceMode: 'Sprachmodus (sendet beim Stoppen)', + stopTask: 'Aufgabe stoppen', + cancel: 'Aufnahme abbrechen', + confirmDictation: 'Diktat beenden', + stopAndSend: 'Stoppen und senden', + listening: 'Hört zu…', + starting: 'Mikrofon wird vorbereitet…', + transcribing: 'Wird transkribiert…', + empty: 'Nichts erkannt. Bitte noch einmal.', + failed: 'Spracherkennung fehlgeschlagen. Bitte erneut versuchen.', + startFailed: 'Aufnahme konnte nicht starten: {{message}}', + taskCancelFailed: 'Die Aufgabe wurde nicht gestoppt. Bitte erneut versuchen.', + dictateCaption: + 'Tippe auf ✓, wenn du fertig bist – der Text landet zum Bearbeiten im Eingabefeld · Esc bricht ab', + submitCaption: 'Tippe auf ↑, um direkt an den Agenten zu senden · Esc bricht ab', + stopHint: 'Aufgabe läuft – tippe auf ■ zum Stoppen', + holdHint: '{{key}} gedrückt halten zum Sprechen', + toggleHint: '{{key}} drücken zum Starten oder Beenden', + autoHint: '{{key}} halten oder kurz drücken zum Sprechen', + }, title: 'Less Computer', subtitle: 'Was soll dein Computer tun?', you: 'Du', @@ -577,6 +857,9 @@ export const de: typeof zhCN = { }, vocab: { selectAllVisible: 'Aktuelle Ergebnisse auswählen', + selecting: 'Auswählen', + doneSelecting: 'Fertig', + disabledWord: 'Aus', selectedCount: 'Ausgewählte Wörter: {{count}}', selectWord: '„{{phrase}}“ auswählen', deleteSelected: 'Auswahl löschen ({{count}})', @@ -779,6 +1062,7 @@ export const de: typeof zhCN = { deleteImported: 'Löschen', deleteConfirm: '„{{name}}“ löschen? Dies kann nicht rückgängig gemacht werden.', deleteSuccess: '„{{name}}“ gelöscht.', + undoDelete: 'Rückgängig', deleteFailed: 'Paket konnte nicht gelöscht werden: {{err}}', summaryCurrentEmpty: 'Noch kein Paket ausgewählt', editorTitle: 'Paket bearbeiten', @@ -1262,6 +1546,10 @@ export const de: typeof zhCN = { verificationUnavailable: 'Die Prüfung wird für diesen Kanal nicht unterstützt', passed: 'Prüfung bestanden', failed: 'Prüfung fehlgeschlagen · {{reason}}', + failedPlain: 'Prüfung fehlgeschlagen', + failureKeepsEnabled: + 'Eine fehlgeschlagene Prüfung schaltet den Dienst nicht ab. Anfragen nutzen weiter den ersten aktivierten Dienst und wechseln nicht automatisch.', + reverify: 'Erneut prüfen', elapsed: 'Dauer: {{ms}} ms', staleResult: 'Das Ergebnis ist älter als 24 Stunden', connectionTitle: 'Dienstverbindung', @@ -1354,6 +1642,7 @@ export const de: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (kostenlose Modelle)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', @@ -1491,15 +1780,16 @@ export const de: typeof zhCN = { bailianVocabularyIdLabel: 'Wörterbuch-ID für Begriffe (optional)', bailianVocabularyIdNote: 'Wenn du bei DashScope ein Begriffswörterbuch erstellt hast, gib seine vocab-... ID ein. Leer lassen, um keine Begriffe zu übergeben.', - bailianProtocolLabel: "API-Typ", - bailianProtocolNote: "Die manuelle Auswahl ersetzt die Erkennung anhand des Modellnamens und gilt pro Kanal für Prüfung und Aufnahme. Beachten Sie die Modelldokumentation.", + bailianProtocolLabel: 'API-Typ', + bailianProtocolNote: + 'Die manuelle Auswahl ersetzt die Erkennung anhand des Modellnamens und gilt pro Kanal für Prüfung und Aufnahme. Beachten Sie die Modelldokumentation.', bailianProtocolOptions: { - "auto": "Automatisch", - "dashscope-realtime": "Echtzeit (DashScope)", - "qwen-realtime": "Echtzeit (Qwen Realtime)", - "multimodal": "Synchron, nicht in Echtzeit (Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "Synchron, nicht in Echtzeit (Qwen3-ASR)", - "async-transcription": "Asynchron (Dateitranskription)", + auto: 'Automatisch', + 'dashscope-realtime': 'Echtzeit (DashScope)', + 'qwen-realtime': 'Echtzeit (Qwen Realtime)', + multimodal: 'Synchron, nicht in Echtzeit (Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': 'Synchron, nicht in Echtzeit (Qwen3-ASR)', + 'async-transcription': 'Asynchron (Dateitranskription)', }, bailianModelRealtimeHint: 'Echtzeitmodell · transkribiert während des Sprechens.', bailianModelSyncFileHint: @@ -2019,8 +2309,9 @@ export const de: typeof zhCN = { omni: 'Multimodal', models: 'Lokale Modelle', connections: 'Verbindungen', - statusConfigured: 'Eingerichtet', - statusMissing: 'Nicht eingerichtet', + statusConfigured: + 'Grüner Punkt: mindestens ein Dienst ist aktiv. Anfragen nutzen den ersten aktivierten Dienst.', + statusMissing: 'Roter Punkt: noch kein Dienst ist aktiviert.', }, searchPlaceholder: 'Einstellungskategorie suchen…', clearSearch: 'Suche leeren', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index e604bb9e7..08c604b67 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -4,6 +4,192 @@ import type { zhCN } from './zh-CN'; // Type-level guarantee that en mirrors the zh-CN shape. export const en: typeof zhCN = { + cloudSyncE2ee: { + protocolTitle: 'Cloud sync agreement and privacy notice', + protocolIntro: + 'OpenLess and its independent developers respect your privacy and work to protect your data. Read this notice before continuing.', + protocolPasswordTitle: 'Keep your sync password safe', + protocolPassword: + '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.', + protocolEncryptionTitle: 'Encrypt on this device before uploading', + protocolEncryption: + '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.', + protocolExcludedTitle: 'Sign-in state and visible metadata', + protocolExcluded: + '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.', + protocolCheck: + 'I have read the agreement and privacy notice, understand the scope, and will keep my sync password safe.', + protocolBack: 'Back to sync scope', + protocolConfirm: 'I have read and confirm', + setupPromptTitle: 'Back up your setup securely?', + setupPromptBody: + 'Your service setup is complete. Settings, service API keys and text history are encrypted on this device. Uploads start only after you review the scope and enable sync.', + setupPromptLater: 'Not now', + setupPromptOpen: 'Explore encrypted sync', + title: 'Encrypted cloud sync', + description: 'Encrypt on this device before syncing with your GitHub account.', + enable: 'Enable encrypted sync', + setPassword: 'Set sync password', + stepEnableTitle: 'Step 1 of 3: Turn sync on', + stepEnableDetail: + 'Use the switch. After you confirm the notice, the next step is a sync password.', + stepPasswordTitle: 'Step 2 of 3: Set a sync password', + stepPasswordDetail: + 'One step left. Sync starts only after you set a password. This is not done yet.', + stepUnlockTitle: 'Step 2 of 3: Unlock this device', + stepUnlockDetail: 'One step left. Enter the sync password to unlock this device.', + stepClosedTitle: 'Sync is off', + stepClosedDetail: 'The cloud backup is still there. Turn the switch on to continue.', + stepDoneTitle: 'Step 3 of 3: Sync is ready', + stepDoneDetail: 'This device is unlocked and sync is on.', + stepPreparingTitle: 'Opening the next step', + stepPreparingDetail: + 'The notice is confirmed. Next you will set a sync password. That step is not done yet.', + stepFollowDetail: 'Use the button on this row to continue.', + refresh: 'Refresh status', + loading: 'Checking sync status…', + signIn: 'Sign in with GitHub', + signOut: 'Sign out', + account: 'Sync account', + keyLocked: 'Locked on this device', + keyUnlocked: 'Unlocked on this device', + hasSnapshot: 'An encrypted cloud backup is available.', + noSnapshot: 'No encrypted cloud backup yet.', + snapshotUnknown: 'Cloud backup has not been checked yet.', + lastSync: 'Last synced {{time}}', + syncNow: 'Sync now', + checkPending: 'Check pending result', + unlock: 'Unlock', + lock: 'Lock this device', + changePassword: 'Change sync password', + restore: 'Review cloud restore', + delete: 'Delete cloud backup', + working: 'Working securely…', + cancelTask: 'Cancel current task', + cancelRequested: 'Cancellation requested. Waiting for the task to settle.', + done: 'Sync settings updated.', + restored: 'Cloud data restored. Review settings specific to this device.', + retryAfter: 'Try again in {{seconds}} seconds.', + passwordWarning: + 'Keep your sync password safe. It cannot be recovered; losing it can make cloud data unreadable.', + consentTitle: 'Enable end-to-end encrypted sync', + consentDescription: + 'This sync includes service credentials and private text history. Data is encrypted on your device before upload. Review the full scope first.', + scopeSummary: 'View everything included', + consentCheck: 'I understand the full scope and agree to sync this data in encrypted form.', + continue: 'Continue', + createTitle: 'Create a sync password', + unlockTitle: 'Unlock your cloud backup', + passwordTitle: 'Change your sync password', + create: 'Create encrypted backup', + password: 'Sync password', + newPassword: 'New sync password', + currentPassword: 'Current sync password', + confirmPassword: 'Confirm new password', + passwordPolicy: + 'Use 12–128 characters with uppercase and lowercase letters and a number. Avoid common passwords.', + rememberKey: 'Remember the unlock key in this device’s secure storage', + disableTitle: 'Turn off encrypted sync?', + disableDescription: + 'This stops further sync. Your cloud backup and local data are kept. Deleting the cloud backup is a separate action.', + confirmDisable: 'Turn off sync', + deleteTitle: 'Delete the encrypted cloud backup?', + deleteDescription: + 'Only this account’s cloud backup is deleted. Local data is kept. You will need a new backup to restore on another device.', + backupRetention: + 'After deletion, service backup copies may be retained for up to {{days}} days.', + deleteCheck: 'I confirm deletion of this cloud backup.', + confirmDelete: 'Delete cloud backup', + restoreTitle: 'Review before restoring', + restoreDescription: + 'Review the categories below. Sensitive values stay hidden. Nothing is restored until you confirm.', + deviceReview: + 'After restoring, review {{count}} device settings, including shortcuts, model paths, microphones and system permissions.', + restoreMode: 'How to restore', + merge: 'Merge and resolve conflicts', + replace: 'Replace synced local data with cloud data', + replaceWarning: + 'This replaces local data within the sync scope. Choose merge if you need to keep local changes.', + restoreCheck: 'I have reviewed the restore mode and conflict choices.', + applyRestore: 'Confirm restore', + conflictProgress: '{{selected}} of {{total}} conflicts resolved', + conflictItem: 'Conflict {{index}} · {{category}}', + conflictLocal: 'Keep local', + conflictCloud: 'Use cloud', + previous: 'Previous', + next: 'Next', + scope: { + preferences: 'App preferences and interface settings.', + credentials: + 'ASR, LLM and Omni channels, including service API keys, IDs, access keys and secret keys.', + personal: 'Dictionary, custom vocabulary presets, corrections, style packs and icons.', + history: 'All dictation and quick-note text history, plus activity statistics.', + device: 'Device configuration profiles, to review on the receiving device.', + excluded: + 'Not included: OAuth login sessions, sync passwords or derived keys, OS credential files, PINs, device permissions, original audio/video recordings, or model weights.', + }, + states: { + disabled: 'Sync is off', + sign_in_required: 'GitHub sign-in required', + unlock_required: 'Enter your password to unlock', + ready: 'Up to date', + pending: 'Local changes are waiting to sync', + syncing: 'Sync in progress', + conflict: 'Review conflicting changes', + failed: 'Sync needs attention', + outcome_unknown: 'Waiting to confirm the cloud result', + recovery_required: 'Local recovery is required', + }, + categories: { + preferences: 'App preferences', + ui_preferences: 'Interface settings', + channels: 'Service channels and credentials', + provider_credentials: 'Service credentials', + dictionary: 'Dictionary', + vocabulary_presets: 'Custom vocabulary presets', + corrections: 'Corrections', + style_packs: 'Style packs and icons', + history: 'Text history', + activity: 'Activity statistics', + device_profile: 'Device profiles', + other: 'Other synced data', + }, + conflictReasons: { + both_modified: 'Both local and cloud versions changed.', + delete_modify: 'One version was deleted while the other changed.', + no_common_baseline: 'No shared baseline is available. Choose which version to keep.', + other: 'Choose the local or cloud version. Values are not displayed.', + }, + errors: { + unknown: 'The operation could not be completed. Refresh the status and try again.', + signIn: 'Sign in to GitHub again.', + unlock: 'Unlock this device with your sync password first.', + unavailable: + 'Encrypted sync is unavailable here. Use the native app and check the service later.', + weakPassword: 'Choose a stronger password meeting the length and character requirements.', + passwordMismatch: 'The two passwords do not match.', + invalidPassword: + 'The password could not unlock this backup, or the encrypted data could not be verified.', + secureStorage: + 'Secure storage denied access. Check device permissions, or unlock without remembering the key.', + changed: 'Data changed during review. Close this dialog, refresh, and review a new preview.', + accountChanged: 'The GitHub account changed. Refresh before continuing.', + busy: 'Another sync task is running. Wait or cancel it first.', + cancelled: 'The task was cancelled.', + network: 'Could not reach the sync service. Check your connection and try again.', + rateLimited: 'Too many requests. Wait before retrying.', + outcomeUnknown: + 'The cloud result is not confirmed. Check the pending result before creating another backup.', + recovery: + 'Local recovery is needed. Keep this device’s data and do not overwrite the cloud backup.', + rolledBack: 'Restore failed and local changes were rolled back. Refresh before trying again.', + tooLarge: 'The encrypted backup exceeds the service’s size limit.', + reviewRequired: 'Review the full sync scope and the cloud restore preview before continuing.', + choicesRequired: 'Choose local or cloud for every conflict.', + invalidData: 'The encrypted backup could not be verified. Local data was not replaced.', + localData: 'This device could not prepare its data. Choose Refresh status, then try again.', + }, + }, cloudSync: { title: 'Cloud sync', description: @@ -110,6 +296,18 @@ export const en: typeof zhCN = { cancel: 'Cancel', }, qa: { + compact: { + closeError: 'Could not close. Try again.', + sendError: 'Could not send. Your draft is kept here; try again.', + microphoneError: 'The microphone action failed. Try again.', + modeError: 'Edit mode was not changed. Try again.', + conversation: 'Current conversation', + sending: 'Sending your question…', + addUnavailable: 'Add content · not available yet', + browserUnavailable: 'Preview cannot run commands', + answer: 'OpenLess answer', + layoutError: 'Could not expand the window. Close and reopen it.', + }, title: 'Ask', headerHint: 'Ask anytime', thinking: 'Thinking…', @@ -142,6 +340,85 @@ export const en: typeof zhCN = { editInstructionMode: 'Edit instruction', }, lessComputer: { + activity: { + process: 'Activity', + count: '{{count}} activities', + count_one: '{{count}} activity', + count_other: '{{count}} activities', + finished: 'Finished', + stopped: 'Stopped', + search: 'Search', + searchRunning: 'Searching…', + read: 'Read', + readRunning: 'Reading…', + command: 'Commands', + commandRunning: 'Running commands…', + edit: 'Edits', + editRunning: 'Editing…', + web: 'Web', + webRunning: 'Browsing…', + other: 'Tools', + otherRunning: 'Working…', + }, + desktop: { + approvedSubmitted: 'Approval sent', + deniedSubmitted: 'Denial sent', + agents: 'Your agents', + configured: 'Configured agent', + agentSettings: 'Choose in Settings', + sessionUnavailable: 'New session · unavailable', + signedIn: 'Signed in', + signIn: 'Sign in', + currentSession: 'Current conversation', + minimize: 'Minimize', + maximize: 'Maximize / restore', + windowError: 'The window action failed. Try again.', + idle: 'Waiting for a task', + waitingApproval: 'Waiting for approval', + submittingApproval: 'Submitting…', + approvalError: 'Approval was not submitted. Try again.', + approvalExpired: 'This turn has ended. Approval is no longer available.', + sendError: 'Could not send. Your draft is saved here; try again.', + browserUnavailable: 'This browser preview cannot run commands. Use the desktop app.', + inputHint: 'Enter to send · Shift + Enter for a new line', + apiCost: 'API cost this turn', + emptyHint: 'Describe a task, tap the mic, or use your voice shortcut to get started.', + showInspector: 'Show workspace panel', + hideInspector: 'Hide workspace panel', + inspectorTitle: 'Workspace', + inspectorStatus: 'Status', + inspectorTurn: 'This turn', + toolCalls: 'Tool calls', + pendingApprovals: 'Waiting for you', + inspectorVoice: 'Voice input', + voiceShortcutOff: 'No voice shortcut set. You can turn one on in Settings.', + voiceModesHint: + 'Mic: your words go into the input so you can edit before sending. Voice button: sends straight to the agent when you stop.', + copy: 'Copy', + copied: 'Copied', + messagePlaceholder: 'Message {{agent}}', + }, + voice: { + dictate: 'Dictate into the input', + voiceMode: 'Voice mode (sends when you stop)', + stopTask: 'Stop task', + cancel: 'Cancel recording', + confirmDictation: 'Finish dictation', + stopAndSend: 'Stop and send', + listening: 'Listening…', + starting: 'Preparing microphone…', + transcribing: 'Transcribing…', + empty: "Didn't catch anything. Try again.", + failed: 'Speech recognition failed. Try again.', + startFailed: 'Could not start recording: {{message}}', + taskCancelFailed: 'The task did not stop. Try again.', + dictateCaption: 'Tap ✓ when done — the text goes into the input for editing · Esc to cancel', + submitCaption: 'Tap ↑ to send straight to the agent · Esc to cancel', + stopHint: 'Task running — tap ■ to stop', + holdHint: 'Hold {{key}} to talk', + toggleHint: 'Press {{key}} to start or stop talking', + autoHint: 'Hold or tap {{key}} to talk', + }, title: 'Less Computer', subtitle: 'What should your computer do?', you: 'You', @@ -568,6 +845,9 @@ export const en: typeof zhCN = { }, vocab: { selectAllVisible: 'Select current results', + selecting: 'Select', + doneSelecting: 'Done', + disabledWord: 'Off', selectedCount: '{{count}} words selected', selectWord: 'Select “{{phrase}}”', deleteSelected: 'Delete selected ({{count}})', @@ -763,6 +1043,7 @@ export const en: typeof zhCN = { deleteImported: 'Delete', deleteConfirm: 'Delete "{{name}}"? This cannot be undone.', deleteSuccess: 'Deleted "{{name}}".', + undoDelete: 'Undo', deleteFailed: 'Failed to delete pack: {{err}}', summaryCurrentEmpty: 'No pack selected yet', editorTitle: 'Edit Pack', @@ -1233,6 +1514,10 @@ export const en: typeof zhCN = { verificationUnavailable: 'Verification is not supported for this channel', passed: 'Check passed', failed: 'Check failed · {{reason}}', + failedPlain: 'Check failed', + failureKeepsEnabled: + 'A failed check does not turn this service off. Requests still use the first enabled service and do not switch to the next one.', + reverify: 'Check again', elapsed: 'Took {{ms}} ms', staleResult: 'Result is over 24 hours old', connectionTitle: 'Service connection', @@ -1315,6 +1600,7 @@ export const en: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (free models)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', @@ -1461,15 +1747,16 @@ export const en: typeof zhCN = { bailianVocabularyIdLabel: 'Hotword Vocabulary ID (optional)', bailianVocabularyIdNote: 'If you have created a DashScope hotword vocabulary, enter its vocab-... ID. Leave blank to skip hotwords.', - bailianProtocolLabel: "API type", - bailianProtocolNote: "Manual selection overrides model-name detection and is saved per channel for both validation and recording. Choose the API specified by the model documentation.", + bailianProtocolLabel: 'API type', + bailianProtocolNote: + 'Manual selection overrides model-name detection and is saved per channel for both validation and recording. Choose the API specified by the model documentation.', bailianProtocolOptions: { - "auto": "Automatic", - "dashscope-realtime": "Realtime (DashScope)", - "qwen-realtime": "Realtime (Qwen Realtime)", - "multimodal": "Non-realtime synchronous (Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "Non-realtime synchronous (Qwen3-ASR)", - "async-transcription": "Non-realtime asynchronous (file transcription)", + auto: 'Automatic', + 'dashscope-realtime': 'Realtime (DashScope)', + 'qwen-realtime': 'Realtime (Qwen Realtime)', + multimodal: 'Non-realtime synchronous (Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': 'Non-realtime synchronous (Qwen3-ASR)', + 'async-transcription': 'Non-realtime asynchronous (file transcription)', }, bailianModelRealtimeHint: 'Realtime model · transcribes as you speak.', bailianModelSyncFileHint: @@ -1960,8 +2247,9 @@ export const en: typeof zhCN = { omni: 'Multimodal', models: 'Local models', connections: 'Connections', - statusConfigured: 'Configured', - statusMissing: 'Not configured', + statusConfigured: + 'Green dot: at least one service is on. Requests use the first enabled service.', + statusMissing: 'Red dot: no service is turned on yet.', }, searchPlaceholder: 'Find a settings category…', clearSearch: 'Clear search', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 6275308e2..5999b71bf 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1,6 +1,192 @@ import type { zhCN } from './zh-CN'; export const es: typeof zhCN = { + cloudSyncE2ee: { + protocolTitle: 'Acuerdo de sincronización y privacidad', + protocolIntro: + 'OpenLess y sus desarrolladores independientes respetan tu privacidad y protegen tus datos. Lee este aviso antes de continuar.', + protocolPasswordTitle: 'Guarda tu contraseña de sincronización', + protocolPassword: + 'La contraseña y la clave de descifrado se usan localmente y no se envían al servicio. Si pierdes la contraseña y ningún dispositivo puede desbloquear la copia, no podremos recuperar su contenido.', + protocolEncryptionTitle: 'Cifrado local antes de subir los datos', + protocolEncryption: + 'Los ajustes, claves API e historial de texto se cifran en este dispositivo. La copia se envía y almacena cifrada. El servicio no puede leer ni utilizar tus claves API y no usa estos datos para ejecutar solicitudes a modelos.', + protocolExcludedTitle: 'Inicio de sesión y metadatos visibles', + protocolExcluded: + 'La copia excluye la contraseña, claves de descifrado, estado de GitHub/OAuth gestionado por OpenLess, tokens de acceso y claves privadas del dispositivo. La autenticación de la cuenta trata sus credenciales por separado. El servidor aún puede ver la cuenta, el tamaño del cifrado, las versiones y las fechas de sincronización.', + protocolCheck: + 'He leído el acuerdo y el aviso, entiendo el alcance y guardaré mi contraseña de forma segura.', + protocolBack: 'Volver al alcance', + protocolConfirm: 'Leído y confirmado', + setupPromptTitle: '¿Guardar una copia cifrada?', + setupPromptBody: + 'Los servicios están configurados. Los ajustes, claves de API e historial de texto se cifran en este dispositivo. Solo se suben tras revisar el alcance y activar la sincronización.', + setupPromptLater: 'Ahora no', + setupPromptOpen: 'Ver sincronización cifrada', + title: 'Sincronización cifrada en la nube', + description: 'Cifra en este dispositivo antes de sincronizar con tu cuenta de GitHub.', + enable: 'Activar sincronización cifrada', + setPassword: 'Crear contraseña de sincronización', + stepEnableTitle: 'Paso 1 de 3: activar la sincronización', + stepEnableDetail: 'Usa el interruptor. Tras confirmar, el siguiente paso es la contraseña.', + stepPasswordTitle: 'Paso 2 de 3: crear la contraseña', + stepPasswordDetail: 'Falta un paso. La sincronización empieza solo después de la contraseña.', + stepUnlockTitle: 'Paso 2 de 3: desbloquear este dispositivo', + stepUnlockDetail: 'Falta un paso. Introduce la contraseña para desbloquear este dispositivo.', + stepClosedTitle: 'Sincronización desactivada', + stepClosedDetail: 'La copia en la nube sigue ahí. Activa el interruptor para continuar.', + stepDoneTitle: 'Paso 3 de 3: sincronización lista', + stepDoneDetail: 'Este dispositivo está desbloqueado y la sincronización está activa.', + stepPreparingTitle: 'Abriendo el siguiente paso', + stepPreparingDetail: + 'El aviso está confirmado. Ahora vas a crear la contraseña de sincronización.', + stepFollowDetail: 'Usa el botón de esta fila para continuar.', + refresh: 'Actualizar estado', + loading: 'Comprobando la sincronización…', + signIn: 'Iniciar sesión con GitHub', + signOut: 'Cerrar sesión', + account: 'Cuenta de sincronización', + keyLocked: 'Bloqueado en este dispositivo', + keyUnlocked: 'Desbloqueado en este dispositivo', + hasSnapshot: 'Hay una copia cifrada en la nube.', + noSnapshot: 'Todavía no hay una copia cifrada en la nube.', + snapshotUnknown: 'La copia en la nube aún no se ha comprobado.', + lastSync: 'Última sincronización: {{time}}', + syncNow: 'Sincronizar ahora', + checkPending: 'Comprobar resultado pendiente', + unlock: 'Desbloquear', + lock: 'Bloquear este dispositivo', + changePassword: 'Cambiar contraseña de sincronización', + restore: 'Revisar restauración desde la nube', + delete: 'Eliminar copia en la nube', + working: 'Procesando…', + cancelTask: 'Cancelar tarea actual', + cancelRequested: 'Cancelación solicitada. Esperando a que termine la tarea.', + done: 'Configuración de sincronización actualizada.', + restored: 'Datos restaurados. Revisa los ajustes específicos de este dispositivo.', + retryAfter: 'Inténtalo de nuevo en {{seconds}} segundos.', + passwordWarning: + 'Guarda tu contraseña. No se puede recuperar; perderla puede impedir leer los datos de la nube.', + consentTitle: 'Activar sincronización cifrada de extremo a extremo', + consentDescription: + 'Incluye credenciales de servicios e historial de textos privados. Los datos se cifran en el dispositivo antes de subirlos. Revisa todo el alcance.', + scopeSummary: 'Ver el alcance completo', + consentCheck: 'Entiendo el alcance y acepto sincronizar estos datos cifrados.', + continue: 'Continuar', + createTitle: 'Crear contraseña de sincronización', + unlockTitle: 'Desbloquear copia en la nube', + passwordTitle: 'Cambiar contraseña de sincronización', + create: 'Crear copia cifrada', + password: 'Contraseña de sincronización', + newPassword: 'Nueva contraseña de sincronización', + currentPassword: 'Contraseña actual', + confirmPassword: 'Confirmar nueva contraseña', + passwordPolicy: + 'Usa 12–128 caracteres con mayúsculas, minúsculas y un número. Evita contraseñas comunes.', + rememberKey: 'Recordar la clave en el almacenamiento seguro del dispositivo', + disableTitle: '¿Desactivar la sincronización cifrada?', + disableDescription: + 'Se detiene la sincronización. Se conservan la copia en la nube y los datos locales. Eliminar la copia es una acción separada.', + confirmDisable: 'Desactivar sincronización', + deleteTitle: '¿Eliminar la copia cifrada en la nube?', + deleteDescription: + 'Solo se elimina la copia en la nube de esta cuenta. Los datos locales permanecen. Necesitarás una nueva copia para restaurar en otro dispositivo.', + backupRetention: + 'Tras la eliminación, el servicio puede conservar copias de seguridad durante un máximo de {{days}} días.', + deleteCheck: 'Confirmo que quiero eliminar esta copia en la nube.', + confirmDelete: 'Eliminar copia en la nube', + restoreTitle: 'Revisar antes de restaurar', + restoreDescription: + 'Revisa las categorías. Los valores sensibles permanecen ocultos. No se restaura nada hasta que confirmes.', + deviceReview: + 'Después de restaurar, revisa {{count}} ajustes del dispositivo: atajos, rutas de modelos, micrófonos y permisos del sistema.', + restoreMode: 'Modo de restauración', + merge: 'Combinar y resolver conflictos', + replace: 'Reemplazar datos locales sincronizados por los de la nube', + replaceWarning: + 'Se reemplazarán los datos locales incluidos. Elige combinar para conservar cambios locales.', + restoreCheck: 'He revisado el modo y las decisiones sobre conflictos.', + applyRestore: 'Confirmar restauración', + conflictProgress: '{{selected}} de {{total}} conflictos resueltos', + conflictItem: 'Conflicto {{index}} · {{category}}', + conflictLocal: 'Conservar local', + conflictCloud: 'Usar nube', + previous: 'Anterior', + next: 'Siguiente', + scope: { + preferences: 'Preferencias de la aplicación y de la interfaz.', + credentials: + 'Canales ASR, LLM y Omni con claves API, IDs, claves de acceso y claves secretas.', + personal: 'Diccionario, vocabularios personalizados, correcciones, estilos e iconos.', + history: 'Todo el historial de textos dictados y notas rápidas, y estadísticas de actividad.', + device: 'Perfiles de configuración para revisar en el dispositivo de destino.', + excluded: + 'No incluye sesiones OAuth, contraseñas de sincronización ni claves derivadas, archivos de credenciales del sistema, PIN, permisos, grabaciones originales de audio/vídeo ni pesos de modelos.', + }, + states: { + disabled: 'Sincronización desactivada', + sign_in_required: 'Se requiere iniciar sesión con GitHub', + unlock_required: 'Introduce tu contraseña para desbloquear', + ready: 'Actualizado', + pending: 'Cambios locales pendientes de sincronizar', + syncing: 'Sincronizando', + conflict: 'Revisar cambios en conflicto', + failed: 'La sincronización requiere atención', + outcome_unknown: 'Resultado en la nube sin confirmar', + recovery_required: 'Se requiere recuperación local', + }, + categories: { + preferences: 'Preferencias de la aplicación', + ui_preferences: 'Interfaz', + channels: 'Canales y credenciales de servicios', + provider_credentials: 'Credenciales de servicios', + dictionary: 'Diccionario', + vocabulary_presets: 'Vocabularios personalizados', + corrections: 'Correcciones', + style_packs: 'Estilos e iconos', + history: 'Historial de textos', + activity: 'Estadísticas de actividad', + device_profile: 'Perfiles de dispositivos', + other: 'Otros datos sincronizados', + }, + conflictReasons: { + both_modified: 'Se modificaron las versiones local y de la nube.', + delete_modify: 'Se eliminó una versión y se modificó la otra.', + no_common_baseline: 'No hay una base común. Elige la versión que quieres conservar.', + other: 'Elige la versión local o de la nube. No se muestran los valores.', + }, + errors: { + unknown: 'No se pudo completar la operación. Actualiza el estado y vuelve a intentarlo.', + signIn: 'Vuelve a iniciar sesión con GitHub.', + unlock: 'Primero desbloquea el dispositivo con la contraseña de sincronización.', + unavailable: + 'La sincronización cifrada no está disponible. Usa la aplicación nativa y comprueba el servicio más tarde.', + weakPassword: 'Elige una contraseña más fuerte que cumpla los requisitos.', + passwordMismatch: 'Las contraseñas no coinciden.', + invalidPassword: + 'La contraseña no desbloqueó la copia o no se pudieron verificar los datos cifrados.', + secureStorage: + 'Acceso al almacenamiento seguro denegado. Revisa permisos o desbloquea sin recordar la clave.', + changed: 'Los datos cambiaron. Cierra el diálogo, actualiza y revisa una nueva vista previa.', + accountChanged: 'La cuenta GitHub cambió. Actualiza antes de continuar.', + busy: 'Hay otra tarea de sincronización en curso. Espera o cancélala.', + cancelled: 'Tarea cancelada.', + network: 'No se pudo conectar con el servicio. Comprueba la conexión y vuelve a intentarlo.', + rateLimited: 'Demasiadas solicitudes. Espera antes de reintentar.', + outcomeUnknown: + 'Resultado sin confirmar. Comprueba la operación pendiente antes de crear otra copia.', + recovery: + 'Se necesita recuperación local. Conserva los datos de este dispositivo y no sobrescribas la copia en la nube.', + rolledBack: + 'La restauración falló y se revirtieron los cambios locales. Actualiza antes de reintentar.', + tooLarge: 'La copia cifrada supera el límite de tamaño del servicio.', + reviewRequired: 'Revisa el alcance y la vista previa de restauración antes de continuar.', + choicesRequired: 'Elige local o nube para cada conflicto.', + invalidData: 'No se pudo verificar la copia cifrada. No se reemplazaron datos locales.', + localData: + 'No se pudieron preparar los datos de este dispositivo. Actualiza el estado y vuelve a intentarlo.', + }, + }, cloudSync: { title: 'Sincronización en la nube', description: @@ -110,6 +296,18 @@ export const es: typeof zhCN = { cancel: 'Cancelar', }, qa: { + compact: { + closeError: 'No se pudo cerrar. Inténtalo de nuevo.', + sendError: 'No se pudo enviar. Tu texto se conserva. Inténtalo de nuevo.', + microphoneError: 'La acción del micrófono ha fallado. Inténtalo de nuevo.', + modeError: 'No se cambió el modo de edición. Inténtalo de nuevo.', + conversation: 'Conversación actual', + sending: 'Enviando tu pregunta…', + addUnavailable: 'Añadir contenido · aún no disponible', + browserUnavailable: 'La vista previa no ejecuta comandos', + answer: 'Respuesta de OpenLess', + layoutError: 'No se pudo ampliar la ventana. Ciérrala y vuelve a abrirla.', + }, title: 'Preguntar', headerHint: 'Pregunta cuando quieras', thinking: 'Pensando…', @@ -142,6 +340,86 @@ export const es: typeof zhCN = { editInstructionMode: 'Instrucción de edición', }, lessComputer: { + activity: { + process: 'Actividad', + count: '{{count}} actividades', + count_one: '{{count}} actividad', + count_other: '{{count}} actividades', + finished: 'Completado', + stopped: 'Detenido', + search: 'Búsqueda', + searchRunning: 'Buscando…', + read: 'Lectura', + readRunning: 'Leyendo…', + command: 'Comandos', + commandRunning: 'Ejecutando comandos…', + edit: 'Edición', + editRunning: 'Editando…', + web: 'Web', + webRunning: 'Consultando páginas…', + other: 'Herramientas', + otherRunning: 'Procesando…', + }, + desktop: { + approvedSubmitted: 'Aprobación enviada', + deniedSubmitted: 'Rechazo enviado', + agents: 'Tus agentes', + configured: 'Agente configurado', + agentSettings: 'Elegir en Ajustes', + sessionUnavailable: 'Nueva sesión · no disponible', + signedIn: 'Sesión iniciada', + signIn: 'Iniciar sesión', + currentSession: 'Conversación actual', + minimize: 'Minimizar', + maximize: 'Maximizar / restaurar', + windowError: 'La acción de la ventana ha fallado. Inténtalo de nuevo.', + idle: 'Esperando una tarea', + waitingApproval: 'Esperando aprobación', + submittingApproval: 'Enviando…', + approvalError: 'No se ha enviado la aprobación. Inténtalo de nuevo.', + approvalExpired: 'Este turno ha terminado. Ya no se puede aprobar.', + sendError: 'No se pudo enviar. Tu texto sigue aquí. Inténtalo de nuevo.', + browserUnavailable: 'Esta vista previa no ejecuta comandos. Usa la aplicación de escritorio.', + inputHint: 'Intro para enviar · Mayús + Intro para una nueva línea', + apiCost: 'Coste de API de este turno', + emptyHint: 'Describe una tarea, toca el micrófono o usa tu atajo de voz para empezar.', + showInspector: 'Mostrar panel de trabajo', + hideInspector: 'Ocultar panel de trabajo', + inspectorTitle: 'Espacio de trabajo', + inspectorStatus: 'Estado', + inspectorTurn: 'Este turno', + toolCalls: 'Llamadas a herramientas', + pendingApprovals: 'Esperando tu respuesta', + inspectorVoice: 'Entrada de voz', + voiceShortcutOff: 'No hay atajo de voz. Puedes activar uno en Ajustes.', + voiceModesHint: + 'Micrófono: el texto va al campo de entrada para que lo edites antes de enviar. Botón de voz: se envía directamente al agente al detenerte.', + copy: 'Copiar', + copied: 'Copiado', + messagePlaceholder: 'Mensaje para {{agent}}', + }, + voice: { + dictate: 'Dictar en el campo de entrada', + voiceMode: 'Modo de voz (envía al detenerte)', + stopTask: 'Detener tarea', + cancel: 'Cancelar grabación', + confirmDictation: 'Terminar dictado', + stopAndSend: 'Detener y enviar', + listening: 'Escuchando…', + starting: 'Preparando el micrófono…', + transcribing: 'Transcribiendo…', + empty: 'No se oyó nada. Inténtalo de nuevo.', + failed: 'Falló el reconocimiento de voz. Inténtalo de nuevo.', + startFailed: 'No se pudo empezar a grabar: {{message}}', + taskCancelFailed: 'La tarea no se detuvo. Inténtalo de nuevo.', + dictateCaption: + 'Toca ✓ al terminar: el texto irá al campo de entrada para editarlo · Esc para cancelar', + submitCaption: 'Toca ↑ para enviarlo directamente al agente · Esc para cancelar', + stopHint: 'Tarea en curso: toca ■ para detenerla', + holdHint: 'Mantén {{key}} para hablar', + toggleHint: 'Pulsa {{key}} para empezar o terminar', + autoHint: 'Mantén o toca {{key}} para hablar', + }, title: 'Less Computer', subtitle: '¿Qué quieres que haga tu ordenador?', you: 'Tú', @@ -576,6 +854,9 @@ export const es: typeof zhCN = { }, vocab: { selectAllVisible: 'Seleccionar resultados actuales', + selecting: 'Seleccionar', + doneSelecting: 'Listo', + disabledWord: 'Desactivado', selectedCount: '{{count}} palabras seleccionadas', selectWord: 'Seleccionar «{{phrase}}»', deleteSelected: 'Eliminar seleccionadas ({{count}})', @@ -777,6 +1058,7 @@ export const es: typeof zhCN = { deleteImported: 'Eliminar', deleteConfirm: '¿Eliminar «{{name}}»? Esta acción no se puede deshacer.', deleteSuccess: 'Se ha eliminado «{{name}}».', + undoDelete: 'Deshacer', deleteFailed: 'No se pudo eliminar el paquete: {{err}}', summaryCurrentEmpty: 'Todavía no hay ningún paquete seleccionado', editorTitle: 'Editar paquete', @@ -1257,6 +1539,10 @@ export const es: typeof zhCN = { verificationUnavailable: 'La comprobación no está disponible para este canal', passed: 'Comprobación correcta', failed: 'Comprobación fallida · {{reason}}', + failedPlain: 'Comprobación fallida', + failureKeepsEnabled: + 'Un fallo no desactiva este servicio. Las solicitudes siguen usando el primer servicio activado y no cambian solas al siguiente.', + reverify: 'Comprobar de nuevo', elapsed: 'Duración: {{ms}} ms', staleResult: 'El resultado tiene más de 24 horas', connectionTitle: 'Conexión al servicio', @@ -1346,6 +1632,7 @@ export const es: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (modelos gratuitos)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', @@ -1482,15 +1769,16 @@ export const es: typeof zhCN = { bailianVocabularyIdLabel: 'ID del vocabulario de palabras clave (opcional)', bailianVocabularyIdNote: 'Si creaste un vocabulario en DashScope, introduce su ID vocab-... Déjalo vacío para no usar palabras clave.', - bailianProtocolLabel: "Tipo de API", - bailianProtocolNote: "La selección manual prevalece sobre el nombre del modelo y se guarda por canal para validar y grabar. Consulte la documentación del modelo.", + bailianProtocolLabel: 'Tipo de API', + bailianProtocolNote: + 'La selección manual prevalece sobre el nombre del modelo y se guarda por canal para validar y grabar. Consulte la documentación del modelo.', bailianProtocolOptions: { - "auto": "Automático", - "dashscope-realtime": "Tiempo real (DashScope)", - "qwen-realtime": "Tiempo real (Qwen Realtime)", - "multimodal": "Síncrono no en tiempo real (Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "Síncrono no en tiempo real (Qwen3-ASR)", - "async-transcription": "Asíncrono (transcripción de archivos)", + auto: 'Automático', + 'dashscope-realtime': 'Tiempo real (DashScope)', + 'qwen-realtime': 'Tiempo real (Qwen Realtime)', + multimodal: 'Síncrono no en tiempo real (Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': 'Síncrono no en tiempo real (Qwen3-ASR)', + 'async-transcription': 'Asíncrono (transcripción de archivos)', }, bailianModelRealtimeHint: 'Modelo en tiempo real: transcribe mientras hablas.', bailianModelSyncFileHint: @@ -2003,8 +2291,9 @@ export const es: typeof zhCN = { omni: 'Multimodal', models: 'Modelos locales', connections: 'Conexiones', - statusConfigured: 'Configurado', - statusMissing: 'Sin configurar', + statusConfigured: + 'Punto verde: hay al menos un servicio activado. Las solicitudes usan el primero.', + statusMissing: 'Punto rojo: todavía no hay ningún servicio activado.', }, searchPlaceholder: 'Buscar una categoría de ajustes…', clearSearch: 'Borrar búsqueda', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 3e831549e..0a0a571b8 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1,6 +1,198 @@ import type { zhCN } from './zh-CN'; export const fr: typeof zhCN = { + cloudSyncE2ee: { + protocolTitle: 'Accord de synchronisation et confidentialité', + protocolIntro: + 'OpenLess et ses développeurs indépendants respectent votre vie privée et protègent vos données. Lisez cette notice avant de continuer.', + protocolPasswordTitle: 'Conservez votre mot de passe', + protocolPassword: + 'Le mot de passe et la clé de déchiffrement restent utilisés localement et ne sont jamais envoyés au service. Si vous perdez le mot de passe et qu’aucun appareil ne peut déverrouiller la sauvegarde, nous ne pouvons pas récupérer son contenu.', + protocolEncryptionTitle: 'Chiffrement local avant l’envoi', + protocolEncryption: + 'Réglages, clés API et historique texte sont chiffrés sur cet appareil. La sauvegarde est transmise et stockée chiffrée. Le service ne peut ni lire ni utiliser vos clés API et n’emploie pas ces contenus pour appeler des modèles.', + protocolExcludedTitle: 'Connexion et métadonnées visibles', + protocolExcluded: + 'La sauvegarde exclut le mot de passe, les clés de déchiffrement, l’état GitHub/OAuth géré par OpenLess, les jetons d’accès et les clés privées de l’appareil. L’authentification du compte traite séparément les identifiants nécessaires. Le serveur voit encore le compte, la taille chiffrée, les versions et les dates de synchronisation.', + protocolCheck: + 'J’ai lu l’accord et la notice, je comprends le périmètre et conserverai mon mot de passe en sécurité.', + protocolBack: 'Revenir au périmètre', + protocolConfirm: 'Lu et confirmé', + setupPromptTitle: 'Sauvegarder votre configuration ?', + setupPromptBody: + 'Les services sont configurés. Réglages, clés API et historique texte sont chiffrés sur cet appareil. L’envoi commence uniquement après examen du périmètre et activation.', + setupPromptLater: 'Pas maintenant', + setupPromptOpen: 'Découvrir la synchronisation', + title: 'Synchronisation cloud chiffrée', + description: 'Chiffrez sur cet appareil avant de synchroniser avec votre compte GitHub.', + enable: 'Activer la synchronisation chiffrée', + setPassword: 'Définir le mot de passe de sync', + stepEnableTitle: 'Étape 1 sur 3 : activer la synchronisation', + stepEnableDetail: + 'Utilisez l’interrupteur. Après confirmation, l’étape suivante est le mot de passe.', + stepPasswordTitle: 'Étape 2 sur 3 : définir le mot de passe', + stepPasswordDetail: + 'Il reste une étape. La synchronisation ne démarre qu’après le mot de passe.', + stepUnlockTitle: 'Étape 2 sur 3 : déverrouiller cet appareil', + stepUnlockDetail: + 'Il reste une étape. Saisissez le mot de passe pour déverrouiller cet appareil.', + stepClosedTitle: 'Synchronisation désactivée', + stepClosedDetail: 'La sauvegarde cloud est conservée. Activez l’interrupteur pour continuer.', + stepDoneTitle: 'Étape 3 sur 3 : synchronisation prête', + stepDoneDetail: 'Cet appareil est déverrouillé et la synchronisation est activée.', + stepPreparingTitle: 'Ouverture de l’étape suivante', + stepPreparingDetail: 'L’avis est confirmé. Vous allez ensuite définir le mot de passe de sync.', + stepFollowDetail: 'Utilisez le bouton sur cette ligne pour continuer.', + refresh: 'Actualiser l’état', + loading: 'Vérification de la synchronisation…', + signIn: 'Se connecter avec GitHub', + signOut: 'Se déconnecter', + account: 'Compte de synchronisation', + keyLocked: 'Verrouillé sur cet appareil', + keyUnlocked: 'Déverrouillé sur cet appareil', + hasSnapshot: 'Une sauvegarde cloud chiffrée est disponible.', + noSnapshot: 'Aucune sauvegarde cloud chiffrée pour le moment.', + snapshotUnknown: 'La sauvegarde cloud n’a pas encore été vérifiée.', + lastSync: 'Dernière synchronisation : {{time}}', + syncNow: 'Synchroniser maintenant', + checkPending: 'Vérifier le résultat en attente', + unlock: 'Déverrouiller', + lock: 'Verrouiller cet appareil', + changePassword: 'Modifier le mot de passe', + restore: 'Examiner la restauration cloud', + delete: 'Supprimer la sauvegarde cloud', + working: 'Traitement en cours…', + cancelTask: 'Annuler la tâche en cours', + cancelRequested: 'Annulation demandée. Fin de la tâche en cours.', + done: 'Paramètres de synchronisation mis à jour.', + restored: 'Données cloud restaurées. Vérifiez les réglages propres à cet appareil.', + retryAfter: 'Réessayez dans {{seconds}} secondes.', + passwordWarning: + 'Conservez votre mot de passe. Il est irrécupérable ; sa perte peut rendre les données cloud illisibles.', + consentTitle: 'Activer la synchronisation chiffrée de bout en bout', + consentDescription: + 'La synchronisation inclut les identifiants de services et l’historique de textes privés. Les données sont chiffrées sur l’appareil avant envoi. Consultez le périmètre complet.', + scopeSummary: 'Voir le périmètre complet', + consentCheck: + 'Je comprends le périmètre et j’accepte la synchronisation chiffrée de ces données.', + continue: 'Continuer', + createTitle: 'Créer un mot de passe de synchronisation', + unlockTitle: 'Déverrouiller la sauvegarde cloud', + passwordTitle: 'Modifier le mot de passe de synchronisation', + create: 'Créer une sauvegarde chiffrée', + password: 'Mot de passe de synchronisation', + newPassword: 'Nouveau mot de passe', + currentPassword: 'Mot de passe actuel', + confirmPassword: 'Confirmer le nouveau mot de passe', + passwordPolicy: + '12 à 128 caractères avec majuscule, minuscule et chiffre. Évitez les mots de passe courants.', + rememberKey: 'Mémoriser la clé dans le stockage sécurisé de cet appareil', + disableTitle: 'Désactiver la synchronisation chiffrée ?', + disableDescription: + 'Les synchronisations cessent. La sauvegarde cloud et les données locales sont conservées. La suppression est une action distincte.', + confirmDisable: 'Désactiver la synchronisation', + deleteTitle: 'Supprimer la sauvegarde cloud chiffrée ?', + deleteDescription: + 'Seule la sauvegarde cloud de ce compte sera supprimée. Les données locales restent. Une nouvelle sauvegarde sera nécessaire pour un autre appareil.', + backupRetention: + 'Après suppression, les copies de sauvegarde du service peuvent être conservées jusqu’à {{days}} jours.', + deleteCheck: 'Je confirme la suppression de cette sauvegarde cloud.', + confirmDelete: 'Supprimer la sauvegarde cloud', + restoreTitle: 'Examiner avant de restaurer', + restoreDescription: + 'Vérifiez les catégories. Les valeurs sensibles restent masquées. Rien n’est restauré avant confirmation.', + deviceReview: + 'Après restauration, vérifiez {{count}} réglages de l’appareil : raccourcis, chemins des modèles, microphones et autorisations système.', + restoreMode: 'Mode de restauration', + merge: 'Fusionner et résoudre les conflits', + replace: 'Remplacer les données locales synchronisées par le cloud', + replaceWarning: + 'Les données locales du périmètre seront remplacées. Choisissez la fusion pour conserver vos modifications locales.', + restoreCheck: 'J’ai vérifié le mode et mes choix pour les conflits.', + applyRestore: 'Confirmer la restauration', + conflictProgress: '{{selected}} conflits résolus sur {{total}}', + conflictItem: 'Conflit {{index}} · {{category}}', + conflictLocal: 'Garder la version locale', + conflictCloud: 'Utiliser le cloud', + previous: 'Précédent', + next: 'Suivant', + scope: { + preferences: 'Préférences de l’application et de l’interface.', + credentials: + 'Canaux ASR, LLM et Omni, avec clés API, identifiants, clés d’accès et clés secrètes.', + personal: + 'Dictionnaire, listes de vocabulaire personnalisées, corrections, styles et icônes.', + history: + 'Tout l’historique textuel de dictée et de notes rapides, ainsi que les statistiques d’activité.', + device: 'Profils de configuration à vérifier sur l’appareil de destination.', + excluded: + 'Exclus : sessions OAuth, mots de passe de synchronisation ou clés dérivées, fichiers d’identifiants système, PIN, autorisations, enregistrements audio/vidéo originaux et poids des modèles.', + }, + states: { + disabled: 'Synchronisation désactivée', + sign_in_required: 'Connexion GitHub requise', + unlock_required: 'Saisissez le mot de passe pour déverrouiller', + ready: 'À jour', + pending: 'Modifications locales en attente', + syncing: 'Synchronisation en cours', + conflict: 'Examiner les conflits', + failed: 'La synchronisation nécessite votre attention', + outcome_unknown: 'Résultat cloud à confirmer', + recovery_required: 'Récupération locale requise', + }, + categories: { + preferences: 'Préférences de l’application', + ui_preferences: 'Interface', + channels: 'Canaux et identifiants de services', + provider_credentials: 'Identifiants de services', + dictionary: 'Dictionnaire', + vocabulary_presets: 'Vocabulaire personnalisé', + corrections: 'Corrections', + style_packs: 'Styles et icônes', + history: 'Historique textuel', + activity: 'Statistiques d’activité', + device_profile: 'Profils des appareils', + other: 'Autres données synchronisées', + }, + conflictReasons: { + both_modified: 'Les versions locale et cloud ont été modifiées.', + delete_modify: 'Une version a été supprimée, l’autre modifiée.', + no_common_baseline: 'Aucune base commune. Choisissez la version à conserver.', + other: 'Choisissez la version locale ou cloud. Les valeurs restent masquées.', + }, + errors: { + unknown: 'L’opération n’a pas abouti. Actualisez l’état et réessayez.', + signIn: 'Reconnectez-vous à GitHub.', + unlock: 'Déverrouillez d’abord cet appareil avec votre mot de passe.', + unavailable: + 'Synchronisation chiffrée indisponible. Utilisez l’application native et vérifiez le service plus tard.', + weakPassword: 'Choisissez un mot de passe plus fort respectant les critères.', + passwordMismatch: 'Les mots de passe ne correspondent pas.', + invalidPassword: + 'Le mot de passe n’a pas déverrouillé la sauvegarde, ou les données chiffrées sont invérifiables.', + secureStorage: + 'Accès au stockage sécurisé refusé. Vérifiez les autorisations ou déverrouillez sans mémoriser la clé.', + changed: 'Les données ont changé. Fermez, actualisez et examinez un nouvel aperçu.', + accountChanged: 'Le compte GitHub a changé. Actualisez avant de continuer.', + busy: 'Une tâche de synchronisation est en cours. Attendez ou annulez-la.', + cancelled: 'Tâche annulée.', + network: 'Service de synchronisation inaccessible. Vérifiez votre connexion et réessayez.', + rateLimited: 'Trop de demandes. Attendez avant de réessayer.', + outcomeUnknown: + 'Résultat cloud non confirmé. Vérifiez l’opération en attente avant une nouvelle sauvegarde.', + recovery: + 'Une récupération locale est nécessaire. Conservez les données de cet appareil sans écraser la sauvegarde cloud.', + rolledBack: + 'La restauration a échoué ; les modifications locales ont été annulées. Actualisez avant de réessayer.', + tooLarge: 'La sauvegarde chiffrée dépasse la limite de taille du service.', + reviewRequired: 'Examinez le périmètre et l’aperçu de restauration avant de continuer.', + choicesRequired: 'Choisissez local ou cloud pour chaque conflit.', + invalidData: + 'Sauvegarde chiffrée invérifiable. Les données locales n’ont pas été remplacées.', + localData: + 'Impossible de préparer les données de cet appareil. Actualisez l’état, puis réessayez.', + }, + }, cloudSync: { title: 'Synchronisation cloud', description: @@ -110,6 +302,18 @@ export const fr: typeof zhCN = { cancel: 'Annuler', }, qa: { + compact: { + closeError: 'Impossible de fermer. Réessayez.', + sendError: 'Échec de l’envoi. Votre saisie est conservée. Réessayez.', + microphoneError: 'L’action du microphone a échoué. Réessayez.', + modeError: 'Le mode d’édition n’a pas été modifié. Réessayez.', + conversation: 'Conversation actuelle', + sending: 'Envoi de votre question…', + addUnavailable: 'Ajouter du contenu · pas encore disponible', + browserUnavailable: 'L’aperçu n’exécute aucune commande', + answer: 'Réponse d’OpenLess', + layoutError: 'Impossible d’agrandir la fenêtre. Fermez-la, puis rouvrez-la.', + }, title: 'Questions', headerHint: 'Posez une question à tout moment', thinking: 'Réflexion…', @@ -142,6 +346,89 @@ export const fr: typeof zhCN = { editInstructionMode: 'Instruction de modification', }, lessComputer: { + activity: { + process: 'Activité', + count: '{{count}} activités', + count_one: '{{count}} activité', + count_other: '{{count}} activités', + finished: 'Terminé', + stopped: 'Arrêté', + search: 'Recherche', + searchRunning: 'Recherche en cours…', + read: 'Lecture', + readRunning: 'Lecture en cours…', + command: 'Commandes', + commandRunning: 'Exécution des commandes…', + edit: 'Modifications', + editRunning: 'Modification en cours…', + web: 'Web', + webRunning: 'Navigation en cours…', + other: 'Outils', + otherRunning: 'Traitement en cours…', + }, + desktop: { + approvedSubmitted: 'Approbation envoyée', + deniedSubmitted: 'Refus envoyé', + agents: 'Vos agents', + configured: 'Agent configuré', + agentSettings: 'Choisir dans Réglages', + sessionUnavailable: 'Nouvelle session · indisponible', + signedIn: 'Connecté', + signIn: 'Se connecter', + currentSession: 'Conversation actuelle', + minimize: 'Réduire', + maximize: 'Agrandir / restaurer', + windowError: 'L’action sur la fenêtre a échoué. Réessayez.', + idle: 'En attente d’une tâche', + waitingApproval: 'En attente d’approbation', + submittingApproval: 'Envoi en cours…', + approvalError: 'L’approbation n’a pas été envoyée. Réessayez.', + approvalExpired: 'Ce tour est terminé. L’approbation n’est plus disponible.', + sendError: 'Échec de l’envoi. Votre saisie est conservée. Réessayez.', + browserUnavailable: + 'Cet aperçu dans le navigateur n’exécute aucune commande. Utilisez l’application de bureau.', + inputHint: 'Entrée pour envoyer · Maj + Entrée pour une nouvelle ligne', + apiCost: 'Coût API de ce tour', + emptyHint: + 'Décrivez une tâche, touchez le micro ou utilisez votre raccourci vocal pour commencer.', + showInspector: 'Afficher le panneau de travail', + hideInspector: 'Masquer le panneau de travail', + inspectorTitle: 'Espace de travail', + inspectorStatus: 'État', + inspectorTurn: 'Ce tour', + toolCalls: 'Appels d’outils', + pendingApprovals: 'En attente de vous', + inspectorVoice: 'Saisie vocale', + voiceShortcutOff: + 'Aucun raccourci vocal défini. Vous pouvez en activer un dans les réglages.', + voiceModesHint: + 'Micro : le texte arrive dans la zone de saisie pour être modifié avant l’envoi. Bouton vocal : envoie directement à l’agent quand vous arrêtez.', + copy: 'Copier', + copied: 'Copié', + messagePlaceholder: 'Message à {{agent}}', + }, + voice: { + dictate: 'Dicter dans la zone de saisie', + voiceMode: 'Mode vocal (envoi à l’arrêt)', + stopTask: 'Arrêter la tâche', + cancel: 'Annuler l’enregistrement', + confirmDictation: 'Terminer la dictée', + stopAndSend: 'Arrêter et envoyer', + listening: 'À l’écoute…', + starting: 'Préparation du micro…', + transcribing: 'Transcription…', + empty: 'Rien n’a été entendu. Réessayez.', + failed: 'La reconnaissance vocale a échoué. Réessayez.', + startFailed: 'Impossible de démarrer l’enregistrement : {{message}}', + taskCancelFailed: 'La tâche ne s’est pas arrêtée. Réessayez.', + dictateCaption: + 'Touchez ✓ une fois terminé : le texte arrive dans la zone de saisie · Échap pour annuler', + submitCaption: 'Touchez ↑ pour envoyer directement à l’agent · Échap pour annuler', + stopHint: 'Tâche en cours : touchez ■ pour l’arrêter', + holdHint: 'Maintenez {{key}} pour parler', + toggleHint: 'Appuyez sur {{key}} pour commencer ou arrêter', + autoHint: 'Maintenez ou touchez {{key}} pour parler', + }, title: 'Less Computer', subtitle: 'Que doit faire votre ordinateur ?', you: 'Vous', @@ -582,6 +869,9 @@ export const fr: typeof zhCN = { }, vocab: { selectAllVisible: 'Sélectionner les résultats actuels', + selecting: 'Sélectionner', + doneSelecting: 'Terminé', + disabledWord: 'Désactivé', selectedCount: '{{count}} mots sélectionnés', selectWord: 'Sélectionner « {{phrase}} »', deleteSelected: 'Supprimer la sélection ({{count}})', @@ -784,6 +1074,7 @@ export const fr: typeof zhCN = { deleteImported: 'Supprimer', deleteConfirm: 'Supprimer « {{name}} » ? Cette action est irréversible.', deleteSuccess: '« {{name}} » supprimé.', + undoDelete: 'Annuler', deleteFailed: 'Impossible de supprimer le pack : {{err}}', summaryCurrentEmpty: 'Aucun pack sélectionné', editorTitle: 'Modifier le pack', @@ -1270,6 +1561,10 @@ export const fr: typeof zhCN = { verificationUnavailable: 'La vérification n’est pas disponible pour ce canal', passed: 'Vérification réussie', failed: 'Échec de la vérification · {{reason}}', + failedPlain: 'Échec de la vérification', + failureKeepsEnabled: + 'Un échec ne désactive pas ce service. Les requêtes utilisent le premier service activé et ne passent pas automatiquement au suivant.', + reverify: 'Vérifier à nouveau', elapsed: 'Durée : {{ms}} ms', staleResult: 'Le résultat date de plus de 24 heures', connectionTitle: 'Connexion au service', @@ -1363,6 +1658,7 @@ export const fr: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter (modèles gratuits)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', @@ -1502,15 +1798,16 @@ export const fr: typeof zhCN = { bailianVocabularyIdLabel: 'ID du vocabulaire de mots-clés (facultatif)', bailianVocabularyIdNote: 'Si vous avez créé un vocabulaire dans DashScope, saisissez son ID vocab-... Laissez vide pour ne pas utiliser de mots-clés.', - bailianProtocolLabel: "Type d’API", - bailianProtocolNote: "Le choix manuel remplace la détection par nom et est enregistré par canal pour la validation et l’enregistrement. Consultez la documentation du modèle.", + bailianProtocolLabel: 'Type d’API', + bailianProtocolNote: + 'Le choix manuel remplace la détection par nom et est enregistré par canal pour la validation et l’enregistrement. Consultez la documentation du modèle.', bailianProtocolOptions: { - "auto": "Automatique", - "dashscope-realtime": "Temps réel (DashScope)", - "qwen-realtime": "Temps réel (Qwen Realtime)", - "multimodal": "Synchrone hors temps réel (Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "Synchrone hors temps réel (Qwen3-ASR)", - "async-transcription": "Asynchrone (transcription de fichier)", + auto: 'Automatique', + 'dashscope-realtime': 'Temps réel (DashScope)', + 'qwen-realtime': 'Temps réel (Qwen Realtime)', + multimodal: 'Synchrone hors temps réel (Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': 'Synchrone hors temps réel (Qwen3-ASR)', + 'async-transcription': 'Asynchrone (transcription de fichier)', }, bailianModelRealtimeHint: 'Modèle en temps réel : transcrit pendant que vous parlez.', bailianModelSyncFileHint: @@ -2027,8 +2324,9 @@ export const fr: typeof zhCN = { omni: 'Multimodal', models: 'Modèles locaux', connections: 'Connexions', - statusConfigured: 'Configuré', - statusMissing: 'Non configuré', + statusConfigured: + 'Point vert : au moins un service est activé. Les requêtes utilisent le premier service activé.', + statusMissing: 'Point rouge : aucun service n’est activé.', }, searchPlaceholder: 'Rechercher une catégorie de réglages…', clearSearch: 'Effacer la recherche', diff --git a/openless-all/app/src/i18n/index.ts b/openless-all/app/src/i18n/index.ts index f9f72963a..078247f47 100644 --- a/openless-all/app/src/i18n/index.ts +++ b/openless-all/app/src/i18n/index.ts @@ -117,6 +117,7 @@ export function getLocalePreference(): SupportedLocale | typeof FOLLOW_SYSTEM_VA */ export async function setLocalePreference( pref: SupportedLocale | typeof FOLLOW_SYSTEM_VALUE, + source: 'user' | 'sync-restore' = 'user', ): Promise { const resolved = resolveLocalePreference(pref); if (pref === FOLLOW_SYSTEM_VALUE) { @@ -124,6 +125,7 @@ export async function setLocalePreference( } else { window.localStorage.setItem(LOCALE_STORAGE_KEY, pref); } + window.dispatchEvent(new CustomEvent('openless:ui-preferences-changed', { detail: { source, key: 'locale' } })); await applyLocale(resolved); return resolved; } diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 480cefcf2..a5fb44762 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -6,6 +6,192 @@ import { en } from './en'; // 更新してください(更新されない key は ...en で英語にフォールバックします)。 export const ja: typeof zhCN = { ...en, + cloudSyncE2ee: { + protocolTitle: 'クラウド同期の同意事項とプライバシー', + protocolIntro: + 'OpenLess と開発者はプライバシーを尊重し、データの保護に取り組んでいます。続行する前に、以下をご確認ください。', + protocolPasswordTitle: '同期パスワードを大切に保管してください', + protocolPassword: + '同期パスワードと復号キーは端末内で使用され、同期サービスには送信されません。パスワードを紛失し、解除できる端末もない場合、内容を復元することはできません。', + protocolEncryptionTitle: '端末で暗号化してから送信', + protocolEncryption: + '設定、API キー、テキスト履歴はこの端末で暗号化されます。バックアップは暗号文として送信・保存されます。同期サービスはその内容から API キーを読み取ったり使用したりせず、モデルへのリクエストにも利用しません。', + protocolExcludedTitle: 'ログイン状態と確認可能な情報', + protocolExcluded: + '同期パスワード、復号キー、OpenLess が管理する GitHub/OAuth ログイン状態、アクセストークン、端末の秘密鍵はバックアップに含まれません。アカウント認証には必要な認証情報を別途使用します。サーバーにはアカウント識別子、暗号文のサイズ、版、同期日時が見えます。', + protocolCheck: + '同意事項とプライバシーの説明を読み、対象を理解しました。同期パスワードを適切に保管します。', + protocolBack: '同期対象に戻る', + protocolConfirm: '内容を確認して同意', + setupPromptTitle: '設定を暗号化してバックアップしますか?', + setupPromptBody: + 'サービスの設定が完了しました。設定、API キー、テキスト履歴はこの端末で暗号化されます。対象を確認して同期を有効にした後にのみアップロードされます。', + setupPromptLater: '今はしない', + setupPromptOpen: '暗号化同期について', + title: '暗号化クラウド同期', + description: 'この端末で暗号化してから、GitHub アカウントで端末間を同期します。', + enable: '暗号化同期を有効にする', + setPassword: '同期パスワードを設定', + stepEnableTitle: '手順 1/3:同期をオンにする', + stepEnableDetail: 'スイッチを使います。確認のあと、次は同期パスワードの設定です。', + stepPasswordTitle: '手順 2/3:同期パスワードを設定', + stepPasswordDetail: 'あと 1 ステップです。パスワードを設定するまで同期は始まりません。', + stepUnlockTitle: '手順 2/3:このデバイスのロックを解除', + stepUnlockDetail: + 'あと 1 ステップです。同期パスワードを入力して、このデバイスのロックを解除します。', + stepClosedTitle: '同期はオフです', + stepClosedDetail: + 'クラウドのバックアップは残っています。続けるには上のスイッチをオンにしてください。', + stepDoneTitle: '手順 3/3:同期の準備ができました', + stepDoneDetail: 'このデバイスはロック解除済みで、同期はオンです。', + stepPreparingTitle: '次の手順を開いています', + stepPreparingDetail: '確認は済みました。次に同期パスワードを設定します。まだ完了していません。', + stepFollowDetail: 'この行のボタンで続けてください。', + refresh: '状態を更新', + loading: '同期状態を確認中…', + signIn: 'GitHub でログイン', + signOut: 'ログアウト', + account: '同期アカウント', + keyLocked: 'この端末ではロック中', + keyUnlocked: 'この端末でロック解除済み', + hasSnapshot: '暗号化されたクラウドバックアップがあります。', + noSnapshot: '暗号化クラウドバックアップはまだありません。', + snapshotUnknown: 'クラウドバックアップは未確認です。', + lastSync: '最終同期:{{time}}', + syncNow: '今すぐ同期', + checkPending: '未確定の結果を確認', + unlock: 'ロック解除', + lock: 'この端末をロック', + changePassword: '同期パスワードを変更', + restore: 'クラウドからの復元を確認', + delete: 'クラウドバックアップを削除', + working: '処理中…', + cancelTask: '現在の処理をキャンセル', + cancelRequested: 'キャンセルを要求しました。処理の終了を待っています。', + done: '同期設定を更新しました。', + restored: 'クラウドのデータを復元しました。この端末固有の設定を確認してください。', + retryAfter: '{{seconds}} 秒後に再試行してください。', + passwordWarning: + '同期パスワードは安全に保管してください。再発行できず、紛失するとクラウドのデータを読めなくなる場合があります。', + consentTitle: 'エンドツーエンド暗号化同期を有効にする', + consentDescription: + 'サービスの認証情報と非公開のテキスト履歴も同期します。アップロード前に端末で暗号化します。対象範囲を確認してください。', + scopeSummary: '同期する全データを確認', + consentCheck: '対象範囲を理解し、これらのデータの暗号化同期に同意します。', + continue: '続ける', + createTitle: '同期パスワードを設定', + unlockTitle: 'クラウドバックアップを解除', + passwordTitle: '同期パスワードを変更', + create: '暗号化バックアップを作成', + password: '同期パスワード', + newPassword: '新しい同期パスワード', + currentPassword: '現在の同期パスワード', + confirmPassword: '新しいパスワードを再入力', + passwordPolicy: + '12~128 文字で英大文字・英小文字・数字を含めてください。よくあるパスワードは避けてください。', + rememberKey: 'この端末の安全なストレージに解除キーを記憶する', + disableTitle: '暗号化同期をオフにしますか?', + disableDescription: + '以後の同期を停止します。クラウドのバックアップとローカルのデータは残ります。削除は別の操作です。', + confirmDisable: '同期をオフにする', + deleteTitle: '暗号化クラウドバックアップを削除しますか?', + deleteDescription: + 'このアカウントのクラウドバックアップのみ削除します。ローカルデータは残ります。別の端末で復元するには新しいバックアップが必要です。', + backupRetention: + '削除後も、サービスのバックアップコピーは最長 {{days}} 日間保持される場合があります。', + deleteCheck: 'このクラウドバックアップの削除を確認します。', + confirmDelete: 'クラウドバックアップを削除', + restoreTitle: '復元前に確認', + restoreDescription: + 'データの種類を確認してください。機密の値は表示しません。確認するまで復元しません。', + deviceReview: + '復元後は、ショートカット、モデルの場所、マイク、システム権限など {{count}} 件の端末設定を確認してください。', + restoreMode: '復元方法', + merge: 'マージして競合を解決', + replace: '同期対象のローカルデータをクラウドの内容で置換', + replaceWarning: + '同期範囲のローカルデータを置き換えます。ローカルの変更を残すにはマージを選んでください。', + restoreCheck: '復元方法と競合の選択を確認しました。', + applyRestore: '復元を確認', + conflictProgress: '{{total}} 件中 {{selected}} 件を選択済み', + conflictItem: '競合 {{index}} · {{category}}', + conflictLocal: 'ローカルを使用', + conflictCloud: 'クラウドを使用', + previous: '前へ', + next: '次へ', + scope: { + preferences: 'アプリの環境設定と表示設定。', + credentials: + 'ASR・LLM・Omni の接続設定と、API キー、ID、アクセスキー、シークレットキーなどの認証情報。', + personal: '辞書、カスタム語彙プリセット、修正ルール、スタイルパックとアイコン。', + history: 'すべての音声入力・クイックメモのテキスト履歴と利用統計。', + device: '復元先の端末で確認するデバイス設定プロファイル。', + excluded: + 'OAuth ログイン状態、同期パスワード・派生キー、OS の認証情報ファイル、PIN、端末の権限、元の録音・動画、モデルの重みは含みません。', + }, + states: { + disabled: '同期はオフです', + sign_in_required: 'GitHub ログインが必要です', + unlock_required: 'パスワードを入力して解除してください', + ready: '同期済み', + pending: 'ローカルの変更は同期待ちです', + syncing: '同期中', + conflict: '競合する変更を確認してください', + failed: '同期の確認が必要です', + outcome_unknown: 'クラウドの結果は未確定です', + recovery_required: 'ローカル状態の復旧が必要です', + }, + categories: { + preferences: 'アプリ設定', + ui_preferences: '表示設定', + channels: 'サービス接続と認証情報', + provider_credentials: 'サービス認証情報', + dictionary: '辞書', + vocabulary_presets: 'カスタム語彙プリセット', + corrections: '修正ルール', + style_packs: 'スタイルとアイコン', + history: 'テキスト履歴', + activity: '利用統計', + device_profile: '端末設定プロファイル', + other: 'その他の同期データ', + }, + conflictReasons: { + both_modified: 'ローカルとクラウドの両方で変更されています。', + delete_modify: '一方で削除され、もう一方で変更されています。', + no_common_baseline: '共通の同期履歴がありません。残す側を選んでください。', + other: 'ローカルかクラウドを選択してください。値は表示しません。', + }, + errors: { + unknown: '操作を完了できませんでした。状態を更新して再試行してください。', + signIn: 'GitHub に再ログインしてください。', + unlock: '同期パスワードでこの端末を解除してください。', + unavailable: + '暗号化同期は利用できません。ネイティブアプリで後ほどサービスを確認してください。', + weakPassword: '長さと文字種の条件を満たす、推測されにくいパスワードにしてください。', + passwordMismatch: 'パスワードが一致しません。', + invalidPassword: 'このパスワードで解除できないか、暗号化データを検証できませんでした。', + secureStorage: + '安全なストレージへのアクセスが拒否されました。権限を確認するか、キーを記憶せずに解除してください。', + changed: + '確認中にデータが変わりました。閉じて状態を更新し、再度プレビューを取得してください。', + accountChanged: 'GitHub アカウントが変わりました。更新してから続けてください。', + busy: '別の同期処理が実行中です。待つか先にキャンセルしてください。', + cancelled: '処理をキャンセルしました。', + network: '同期サービスに接続できません。ネットワークを確認して再試行してください。', + rateLimited: 'リクエストが多すぎます。しばらく待ってから再試行してください。', + outcomeUnknown: + 'クラウドの結果が未確定です。新しいバックアップを作る前に未確定の結果を確認してください。', + recovery: + 'ローカル状態の復旧が必要です。この端末のデータを保持し、クラウドを上書きしないでください。', + rolledBack: '復元に失敗し、ローカルの変更を元に戻しました。更新してから再試行してください。', + tooLarge: '暗号化バックアップがサービスのサイズ上限を超えています。', + reviewRequired: '続ける前に同期対象と復元プレビューを確認してください。', + choicesRequired: 'すべての競合でローカルかクラウドを選択してください。', + invalidData: '暗号化バックアップを検証できませんでした。ローカルデータは置き換えていません。', + localData: + 'このデバイスのデータを準備できませんでした。「状態を更新」を押して、もう一度試してください。', + }, + }, cloudSync: { title: 'クラウド同期', description: 'GitHub アカウントで辞書、スタイル、個人設定をデバイス間で同期します。', @@ -101,6 +287,18 @@ export const ja: typeof zhCN = { cancel: 'キャンセル', }, qa: { + compact: { + closeError: '閉じられませんでした。再試行してください。', + sendError: '送信できませんでした。入力は保持されています。再試行してください。', + microphoneError: 'マイク操作に失敗しました。再試行してください。', + modeError: '編集モードを変更できませんでした。再試行してください。', + conversation: '現在の会話', + sending: '質問を送信中…', + addUnavailable: 'コンテンツを追加 · 未対応', + browserUnavailable: 'プレビューでは実行できません', + answer: 'OpenLess の回答', + layoutError: 'ウィンドウを展開できませんでした。閉じてから開き直してください。', + }, title: '質問', headerHint: 'いつでも質問', thinking: '思考中…', @@ -133,6 +331,87 @@ export const ja: typeof zhCN = { editInstructionMode: '編集指示', }, lessComputer: { + activity: { + process: '処理の流れ', + count: '{{count}} 件の処理', + count_one: '{{count}} 件の処理', + count_other: '{{count}} 件の処理', + finished: '完了', + stopped: '停止', + search: '検索', + searchRunning: '検索中…', + read: '読み取り', + readRunning: '読み取り中…', + command: 'コマンド', + commandRunning: 'コマンドを実行中…', + edit: '編集', + editRunning: '編集中…', + web: 'ウェブ', + webRunning: 'ウェブページを確認中…', + other: 'ツール', + otherRunning: '処理中…', + }, + desktop: { + approvedSubmitted: '許可を送信しました', + deniedSubmitted: '拒否を送信しました', + agents: 'エージェント', + configured: '現在の設定', + agentSettings: '設定で選択', + sessionUnavailable: '新しい会話 · 未対応', + signedIn: 'ログイン済み', + signIn: 'ログイン', + currentSession: '現在の会話', + minimize: '最小化', + maximize: '最大化 / 元に戻す', + windowError: 'ウィンドウ操作に失敗しました。再試行してください。', + idle: '指示を待っています', + waitingApproval: '承認待ち', + submittingApproval: '送信中…', + approvalError: '承認を送信できませんでした。再試行してください。', + approvalExpired: 'このターンは終了しました。承認はできません。', + sendError: '送信できませんでした。入力は保持されています。再試行してください。', + browserUnavailable: + 'ブラウザのプレビューでは実行できません。デスクトップアプリをご利用ください。', + inputHint: 'Enter で送信 · Shift + Enter で改行', + apiCost: '今回の API 料金', + emptyHint: 'タスクを入力するか、マイクをタップするか、音声ショートカットで始めましょう。', + showInspector: 'ワークスペースを表示', + hideInspector: 'ワークスペースを隠す', + inspectorTitle: 'ワークスペース', + inspectorStatus: 'ステータス', + inspectorTurn: 'このターン', + toolCalls: 'ツール呼び出し', + pendingApprovals: '確認待ち', + inspectorVoice: '音声入力', + voiceShortcutOff: '音声ショートカットが未設定です。設定で有効にできます。', + voiceModesHint: + 'マイク:話した内容が入力欄に入り、編集してから送信できます。音声ボタン:話し終えるとそのままエージェントに送信します。', + copy: 'コピー', + copied: 'コピーしました', + messagePlaceholder: '{{agent}} にメッセージ', + }, + voice: { + dictate: '入力欄に音声入力', + voiceMode: '音声モード(停止で送信)', + stopTask: 'タスクを停止', + cancel: '録音をキャンセル', + confirmDictation: '音声入力を完了', + stopAndSend: '停止して送信', + listening: '聞き取り中…', + starting: 'マイクを準備中…', + transcribing: '文字起こし中…', + empty: '聞き取れませんでした。もう一度お話しください。', + failed: '音声認識に失敗しました。もう一度お試しください。', + startFailed: '録音を開始できません:{{message}}', + taskCancelFailed: 'タスクを停止できませんでした。もう一度お試しください。', + dictateCaption: + '話し終えたら ✓ をタップ。テキストは入力欄に入り編集できます · Esc でキャンセル', + submitCaption: '↑ をタップするとそのままエージェントに送信 · Esc でキャンセル', + stopHint: 'タスク実行中:■ で停止できます', + holdHint: '{{key}} を押している間話す', + toggleHint: '{{key}} を押して開始・終了', + autoHint: '{{key}} を長押しまたはタップして話す', + }, title: 'Less Computer', subtitle: 'コンピュータに何をさせますか?', you: 'あなた', @@ -556,6 +835,9 @@ export const ja: typeof zhCN = { }, vocab: { selectAllVisible: '現在の結果を選択', + selecting: '選択', + doneSelecting: '完了', + disabledWord: '停止中', selectedCount: '{{count}} 語を選択中', selectWord: '「{{phrase}}」を選択', deleteSelected: '選択項目を削除({{count}})', @@ -752,6 +1034,7 @@ export const ja: typeof zhCN = { deleteImported: '削除', deleteConfirm: '"{{name}}" を削除しますか?この操作は取り消せません。', deleteSuccess: '"{{name}}" を削除しました', + undoDelete: '元に戻す', deleteFailed: 'パック削除失敗:{{err}}', summaryCurrentEmpty: 'まだパックが選択されていません', editorTitle: 'パック編集', @@ -1221,6 +1504,10 @@ export const ja: typeof zhCN = { verificationUnavailable: 'このチャンネルは確認に対応していません', passed: '確認に成功', failed: '確認に失敗 · {{reason}}', + failedPlain: '確認に失敗', + failureKeepsEnabled: + '確認に失敗しても、このサービスは自動では停止しません。リクエストは有効な一覧の先頭を使い、次へは自動で切り替わりません。', + reverify: '再確認', elapsed: '所要時間 {{ms}} ms', staleResult: '24 時間以上前の結果', connectionTitle: 'サービス接続', @@ -1303,6 +1590,7 @@ export const ja: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(無料モデル)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', @@ -1447,15 +1735,16 @@ export const ja: typeof zhCN = { bailianVocabularyIdLabel: 'ホットワード Vocabulary ID(任意)', bailianVocabularyIdNote: 'DashScope でホットワード辞書を作成済みの場合は vocab-... ID を入力します。空欄なら送信しません。', - bailianProtocolLabel: "API の種類", - bailianProtocolNote: "手動選択はモデル名による判定より優先され、検証と録音用にチャネルごとに保存されます。モデルのドキュメントに従って選択してください。", + bailianProtocolLabel: 'API の種類', + bailianProtocolNote: + '手動選択はモデル名による判定より優先され、検証と録音用にチャネルごとに保存されます。モデルのドキュメントに従って選択してください。', bailianProtocolOptions: { - "auto": "自動判定", - "dashscope-realtime": "リアルタイム(DashScope)", - "qwen-realtime": "リアルタイム(Qwen Realtime)", - "multimodal": "非リアルタイム同期(Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "非リアルタイム同期(Qwen3-ASR)", - "async-transcription": "非リアルタイム非同期(ファイル文字起こし)", + auto: '自動判定', + 'dashscope-realtime': 'リアルタイム(DashScope)', + 'qwen-realtime': 'リアルタイム(Qwen Realtime)', + multimodal: '非リアルタイム同期(Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': '非リアルタイム同期(Qwen3-ASR)', + 'async-transcription': '非リアルタイム非同期(ファイル文字起こし)', }, bailianModelRealtimeHint: 'リアルタイムモデル · 話しながら文字起こし。', bailianModelSyncFileHint: '同期録音モデル · 話し終えてから一括で文字起こし(1 本 ≤ 5 分)。', @@ -1930,8 +2219,9 @@ export const ja: typeof zhCN = { omni: 'マルチモーダル', models: 'ローカルモデル', connections: '接続と拡張', - statusConfigured: '設定済み', - statusMissing: '未設定', + statusConfigured: + '緑の点:有効なサービスがあります。リクエストは有効な一覧の先頭を使います。', + statusMissing: '赤の点:まだ有効なサービスがありません。', }, searchPlaceholder: '設定カテゴリを検索…', clearSearch: '検索をクリア', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index a80e3da3d..12f885799 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -6,6 +6,188 @@ import { en } from './en'; // 갱신해 주세요(갱신되지 않은 key 는 ...en 으로 영어로 fallback 됩니다). export const ko: typeof zhCN = { ...en, + cloudSyncE2ee: { + protocolTitle: '클라우드 동기화 약관 및 개인정보 안내', + protocolIntro: + 'OpenLess와 독립 개발자는 개인정보를 존중하고 데이터를 보호하기 위해 노력합니다. 계속하기 전에 아래 안내를 읽어 주세요.', + protocolPasswordTitle: '동기화 비밀번호를 안전하게 보관하세요', + protocolPassword: + '동기화 비밀번호와 복호화 키는 기기에서만 사용되며 동기화 서비스로 전송되지 않습니다. 비밀번호를 잃고 백업을 잠금 해제할 기기도 없다면 내용을 복구할 수 없습니다.', + protocolEncryptionTitle: '기기에서 암호화한 후 업로드', + protocolEncryption: + '설정, API 키, 텍스트 기록은 이 기기에서 암호화됩니다. 백업은 암호문으로 전송되고 저장됩니다. 동기화 서비스는 이를 통해 API 키를 읽거나 사용하지 못하며 모델 요청에도 이용하지 않습니다.', + protocolExcludedTitle: '로그인 상태와 확인 가능한 정보', + protocolExcluded: + '동기화 비밀번호, 복호화 키, OpenLess가 관리하는 GitHub/OAuth 로그인 상태, 액세스 토큰, 기기 개인 키는 백업에 포함되지 않습니다. 계정 인증에는 필요한 자격 증명을 별도로 사용합니다. 서버는 계정 식별자, 암호문 크기, 버전 및 동기화 시간을 볼 수 있습니다.', + protocolCheck: + '약관과 개인정보 안내를 읽고 범위를 이해했으며 동기화 비밀번호를 안전하게 보관하겠습니다.', + protocolBack: '동기화 범위로 돌아가기', + protocolConfirm: '읽었으며 동의합니다', + setupPromptTitle: '설정을 암호화해 백업할까요?', + setupPromptBody: + '서비스 설정이 완료되었습니다. 설정, API 키, 텍스트 기록은 이 기기에서 암호화됩니다. 범위를 확인하고 동기화를 켠 후에만 업로드됩니다.', + setupPromptLater: '나중에', + setupPromptOpen: '암호화 동기화 알아보기', + title: '암호화 클라우드 동기화', + description: '이 기기에서 암호화한 후 GitHub 계정으로 기기 간에 동기화합니다.', + enable: '암호화 동기화 사용', + setPassword: '동기화 비밀번호 설정', + stepEnableTitle: '1/3단계: 동기화 켜기', + stepEnableDetail: '스위치를 사용하세요. 확인 후 다음 단계는 동기화 비밀번호입니다.', + stepPasswordTitle: '2/3단계: 동기화 비밀번호 설정', + stepPasswordDetail: '한 단계 남았습니다. 비밀번호를 설정해야 동기화가 시작됩니다.', + stepUnlockTitle: '2/3단계: 이 기기 잠금 해제', + stepUnlockDetail: '한 단계 남았습니다. 동기화 비밀번호로 이 기기의 잠금을 해제하세요.', + stepClosedTitle: '동기화가 꺼져 있음', + stepClosedDetail: '클라우드 백업은 그대로입니다. 계속하려면 위 스위치를 켜세요.', + stepDoneTitle: '3/3단계: 동기화 준비됨', + stepDoneDetail: '이 기기는 잠금 해제되었고 동기화가 켜져 있습니다.', + stepPreparingTitle: '다음 단계를 여는 중', + stepPreparingDetail: + '확인을 마쳤습니다. 이제 동기화 비밀번호를 설정합니다. 아직 끝나지 않았습니다.', + stepFollowDetail: '이 줄의 버튼으로 계속하세요.', + refresh: '상태 새로고침', + loading: '동기화 상태 확인 중…', + signIn: 'GitHub로 로그인', + signOut: '로그아웃', + account: '동기화 계정', + keyLocked: '이 기기에서 잠김', + keyUnlocked: '이 기기에서 잠금 해제됨', + hasSnapshot: '암호화된 클라우드 백업이 있습니다.', + noSnapshot: '아직 암호화된 클라우드 백업이 없습니다.', + snapshotUnknown: '클라우드 백업을 아직 확인하지 않았습니다.', + lastSync: '마지막 동기화: {{time}}', + syncNow: '지금 동기화', + checkPending: '확인 대기 결과 조회', + unlock: '잠금 해제', + lock: '이 기기 잠그기', + changePassword: '동기화 비밀번호 변경', + restore: '클라우드 복원 검토', + delete: '클라우드 백업 삭제', + working: '처리 중…', + cancelTask: '현재 작업 취소', + cancelRequested: '취소를 요청했습니다. 작업이 종료되기를 기다립니다.', + done: '동기화 설정을 업데이트했습니다.', + restored: '클라우드 데이터를 복원했습니다. 이 기기의 전용 설정을 확인하세요.', + retryAfter: '{{seconds}}초 후 다시 시도하세요.', + passwordWarning: + '동기화 비밀번호를 안전하게 보관하세요. 복구할 수 없으며, 분실하면 클라우드 데이터를 읽지 못할 수 있습니다.', + consentTitle: '종단 간 암호화 동기화 사용', + consentDescription: + '서비스 자격 증명과 비공개 텍스트 기록도 동기화됩니다. 업로드 전에 기기에서 암호화합니다. 전체 범위를 확인하세요.', + scopeSummary: '전체 동기화 범위 보기', + consentCheck: '전체 범위를 이해했으며 이 데이터를 암호화해 동기화하는 데 동의합니다.', + continue: '계속', + createTitle: '동기화 비밀번호 설정', + unlockTitle: '클라우드 백업 잠금 해제', + passwordTitle: '동기화 비밀번호 변경', + create: '암호화 백업 만들기', + password: '동기화 비밀번호', + newPassword: '새 동기화 비밀번호', + currentPassword: '현재 동기화 비밀번호', + confirmPassword: '새 비밀번호 확인', + passwordPolicy: '12~128자로 영문 대문자, 소문자, 숫자를 포함하세요. 흔한 비밀번호는 피하세요.', + rememberKey: '이 기기의 보안 저장소에 잠금 해제 키 기억', + disableTitle: '암호화 동기화를 끌까요?', + disableDescription: + '이후 동기화를 중지합니다. 클라우드 백업과 로컬 데이터는 유지됩니다. 백업 삭제는 별도 작업입니다.', + confirmDisable: '동기화 끄기', + deleteTitle: '암호화된 클라우드 백업을 삭제할까요?', + deleteDescription: + '이 계정의 클라우드 백업만 삭제합니다. 로컬 데이터는 유지됩니다. 다른 기기에서 복원하려면 새 백업이 필요합니다.', + backupRetention: '삭제 후에도 서비스의 백업 사본이 최대 {{days}}일 동안 보관될 수 있습니다.', + deleteCheck: '이 클라우드 백업 삭제에 동의합니다.', + confirmDelete: '클라우드 백업 삭제', + restoreTitle: '복원 전 검토', + restoreDescription: + '데이터 종류를 확인하세요. 민감한 값은 표시하지 않습니다. 확인 전에는 복원하지 않습니다.', + deviceReview: + '복원 후 단축키, 모델 경로, 마이크, 시스템 권한 등 기기 설정 {{count}}개를 확인하세요.', + restoreMode: '복원 방식', + merge: '병합하고 충돌 해결', + replace: '동기화 대상 로컬 데이터를 클라우드 데이터로 교체', + replaceWarning: + '동기화 범위의 로컬 데이터를 교체합니다. 로컬 변경을 유지하려면 병합을 선택하세요.', + restoreCheck: '복원 방식과 충돌 선택을 확인했습니다.', + applyRestore: '복원 확인', + conflictProgress: '충돌 {{total}}개 중 {{selected}}개 해결', + conflictItem: '충돌 {{index}} · {{category}}', + conflictLocal: '로컬 유지', + conflictCloud: '클라우드 사용', + previous: '이전', + next: '다음', + scope: { + preferences: '앱 환경설정과 인터페이스 설정.', + credentials: 'ASR·LLM·Omni 채널과 API 키, ID, 액세스 키, 시크릿 키 등의 자격 증명.', + personal: '사전, 사용자 어휘 프리셋, 교정, 스타일 팩과 아이콘.', + history: '전체 받아쓰기·빠른 메모 텍스트 기록과 활동 통계.', + device: '복원 대상 기기에서 확인할 기기 설정 프로필.', + excluded: + 'OAuth 로그인 상태, 동기화 비밀번호·파생 키, OS 자격 증명 파일, PIN, 기기 권한, 원본 녹음·동영상, 모델 가중치는 포함하지 않습니다.', + }, + states: { + disabled: '동기화 꺼짐', + sign_in_required: 'GitHub 로그인 필요', + unlock_required: '비밀번호를 입력해 잠금을 해제하세요', + ready: '최신 상태', + pending: '로컬 변경 동기화 대기 중', + syncing: '동기화 중', + conflict: '충돌하는 변경 검토 필요', + failed: '동기화 확인 필요', + outcome_unknown: '클라우드 결과 확인 대기 중', + recovery_required: '로컬 상태 복구 필요', + }, + categories: { + preferences: '앱 환경설정', + ui_preferences: '인터페이스 설정', + channels: '서비스 채널 및 자격 증명', + provider_credentials: '서비스 자격 증명', + dictionary: '사전', + vocabulary_presets: '사용자 어휘 프리셋', + corrections: '교정 규칙', + style_packs: '스타일 팩과 아이콘', + history: '텍스트 기록', + activity: '활동 통계', + device_profile: '기기 설정 프로필', + other: '기타 동기화 데이터', + }, + conflictReasons: { + both_modified: '로컬과 클라우드 양쪽에서 변경되었습니다.', + delete_modify: '한쪽에서 삭제하고 다른 쪽에서 변경했습니다.', + no_common_baseline: '공통 동기화 기준이 없습니다. 유지할 버전을 선택하세요.', + other: '로컬 또는 클라우드를 선택하세요. 실제 값은 표시하지 않습니다.', + }, + errors: { + unknown: '작업을 완료하지 못했습니다. 상태를 새로고침한 후 다시 시도하세요.', + signIn: 'GitHub에 다시 로그인하세요.', + unlock: '먼저 동기화 비밀번호로 이 기기를 잠금 해제하세요.', + unavailable: + '암호화 동기화를 사용할 수 없습니다. 네이티브 앱에서 나중에 서비스를 확인하세요.', + weakPassword: '길이와 문자 조건을 충족하는 더 강력한 비밀번호를 사용하세요.', + passwordMismatch: '두 비밀번호가 일치하지 않습니다.', + invalidPassword: + '비밀번호로 백업을 잠금 해제할 수 없거나 암호화 데이터를 검증하지 못했습니다.', + secureStorage: + '보안 저장소 접근이 거부되었습니다. 권한을 확인하거나 키를 기억하지 않고 잠금 해제하세요.', + changed: + '검토 중 데이터가 변경되었습니다. 창을 닫고 새로고침한 후 미리보기를 다시 확인하세요.', + accountChanged: 'GitHub 계정이 변경되었습니다. 새로고침한 후 계속하세요.', + busy: '다른 동기화 작업이 실행 중입니다. 기다리거나 먼저 취소하세요.', + cancelled: '작업이 취소되었습니다.', + network: '동기화 서비스에 연결할 수 없습니다. 네트워크를 확인한 후 다시 시도하세요.', + rateLimited: '요청이 너무 많습니다. 잠시 후 다시 시도하세요.', + outcomeUnknown: + '클라우드 결과가 확인되지 않았습니다. 새 백업을 만들기 전에 대기 중인 결과를 확인하세요.', + recovery: + '로컬 상태 복구가 필요합니다. 이 기기의 데이터를 보관하고 클라우드 백업을 덮어쓰지 마세요.', + rolledBack: '복원에 실패해 로컬 변경을 되돌렸습니다. 새로고침한 후 다시 시도하세요.', + tooLarge: '암호화 백업이 서비스 크기 제한을 초과합니다.', + reviewRequired: '계속하기 전에 동기화 범위와 복원 미리보기를 확인하세요.', + choicesRequired: '각 충돌에 대해 로컬 또는 클라우드를 선택하세요.', + invalidData: '암호화 백업을 검증하지 못했습니다. 로컬 데이터는 교체되지 않았습니다.', + localData: '이 기기의 데이터를 준비하지 못했습니다. 상태 새로고침을 누른 뒤 다시 시도하세요.', + }, + }, cloudSync: { title: '클라우드 동기화', description: 'GitHub 계정으로 사전, 스타일, 개인 설정을 기기 간에 동기화합니다.', @@ -99,6 +281,18 @@ export const ko: typeof zhCN = { cancel: '취소', }, qa: { + compact: { + closeError: '닫지 못했습니다. 다시 시도하세요.', + sendError: '전송하지 못했습니다. 입력 내용은 유지됩니다. 다시 시도하세요.', + microphoneError: '마이크 작업에 실패했습니다. 다시 시도하세요.', + modeError: '편집 모드를 변경하지 못했습니다. 다시 시도하세요.', + conversation: '현재 대화', + sending: '질문 전송 중…', + addUnavailable: '콘텐츠 추가 · 아직 지원 안 함', + browserUnavailable: '미리보기에서는 실행할 수 없음', + answer: 'OpenLess 답변', + layoutError: '창을 확장하지 못했습니다. 닫고 다시 열어 주세요.', + }, title: '질문', headerHint: '언제든 질문하세요', thinking: '생각 중…', @@ -131,6 +325,87 @@ export const ko: typeof zhCN = { editInstructionMode: '편집 지시', }, lessComputer: { + activity: { + process: '처리 과정', + count: '활동 {{count}}개', + count_one: '활동 {{count}}개', + count_other: '활동 {{count}}개', + finished: '완료', + stopped: '중지됨', + search: '검색', + searchRunning: '검색 중…', + read: '읽기', + readRunning: '읽는 중…', + command: '명령', + commandRunning: '명령 실행 중…', + edit: '편집', + editRunning: '편집 중…', + web: '웹', + webRunning: '웹페이지 확인 중…', + other: '도구', + otherRunning: '처리 중…', + }, + desktop: { + approvedSubmitted: '허용을 제출했습니다', + deniedSubmitted: '거부를 제출했습니다', + agents: '내 에이전트', + configured: '현재 설정', + agentSettings: '설정에서 선택', + sessionUnavailable: '새 대화 · 아직 지원 안 함', + signedIn: '로그인됨', + signIn: '로그인', + currentSession: '현재 대화', + minimize: '최소화', + maximize: '최대화 / 복원', + windowError: '창 작업에 실패했습니다. 다시 시도하세요.', + idle: '지시 대기 중', + waitingApproval: '승인 대기 중', + submittingApproval: '제출 중…', + approvalError: '승인이 제출되지 않았습니다. 다시 시도하세요.', + approvalExpired: '이 차례는 종료되어 더 이상 승인할 수 없습니다.', + sendError: '전송하지 못했습니다. 입력 내용은 유지됩니다. 다시 시도하세요.', + browserUnavailable: + '브라우저 미리보기에서는 명령을 실행할 수 없습니다. 데스크톱 앱을 사용하세요.', + inputHint: 'Enter로 전송 · Shift + Enter로 줄 바꿈', + apiCost: '이번 API 비용', + emptyHint: '작업을 입력하거나, 마이크를 누르거나, 음성 단축키로 시작하세요.', + showInspector: '작업 패널 보기', + hideInspector: '작업 패널 숨기기', + inspectorTitle: '작업 공간', + inspectorStatus: '상태', + inspectorTurn: '이번 턴', + toolCalls: '도구 호출', + pendingApprovals: '확인 대기', + inspectorVoice: '음성 입력', + voiceShortcutOff: '음성 단축키가 설정되지 않았습니다. 설정에서 켤 수 있습니다.', + voiceModesHint: + '마이크: 말한 내용이 입력창에 들어가 수정한 뒤 보낼 수 있습니다. 음성 버튼: 말을 멈추면 바로 에이전트에 보냅니다.', + copy: '복사', + copied: '복사됨', + messagePlaceholder: '{{agent}}에게 메시지', + }, + voice: { + dictate: '입력창에 받아쓰기', + voiceMode: '음성 모드(멈추면 전송)', + stopTask: '작업 중지', + cancel: '녹음 취소', + confirmDictation: '받아쓰기 완료', + stopAndSend: '멈추고 보내기', + listening: '듣는 중…', + starting: '마이크 준비 중…', + transcribing: '받아쓰는 중…', + empty: '들린 내용이 없습니다. 다시 말씀해 주세요.', + failed: '음성 인식에 실패했습니다. 다시 시도해 주세요.', + startFailed: '녹음을 시작할 수 없습니다: {{message}}', + taskCancelFailed: '작업이 중지되지 않았습니다. 다시 시도해 주세요.', + dictateCaption: + '다 말했으면 ✓를 누르세요. 텍스트가 입력창에 들어가 수정할 수 있습니다 · Esc로 취소', + submitCaption: '↑를 누르면 바로 에이전트에 보냅니다 · Esc로 취소', + stopHint: '작업 실행 중: ■를 눌러 중지', + holdHint: '{{key}}를 누른 채 말하기', + toggleHint: '{{key}}를 눌러 시작 또는 종료', + autoHint: '{{key}}를 길게 또는 짧게 눌러 말하기', + }, title: 'Less Computer', subtitle: '컴퓨터로 무엇을 할까요?', you: '나', @@ -554,6 +829,9 @@ export const ko: typeof zhCN = { }, vocab: { selectAllVisible: '현재 결과 선택', + selecting: '선택', + doneSelecting: '완료', + disabledWord: '꺼짐', selectedCount: '단어 {{count}}개 선택됨', selectWord: '“{{phrase}}” 선택', deleteSelected: '선택 항목 삭제({{count}})', @@ -750,6 +1028,7 @@ export const ko: typeof zhCN = { deleteImported: '삭제', deleteConfirm: '"{{name}}"을(를) 삭제할까요? 되돌릴 수 없습니다.', deleteSuccess: '"{{name}}"을(를) 삭제했습니다', + undoDelete: '실행 취소', deleteFailed: '팩 삭제 실패: {{err}}', summaryCurrentEmpty: '아직 팩이 선택되지 않았습니다', editorTitle: '팩 편집', @@ -1213,6 +1492,10 @@ export const ko: typeof zhCN = { verificationUnavailable: '이 채널은 확인을 지원하지 않습니다', passed: '확인 성공', failed: '확인 실패 · {{reason}}', + failedPlain: '확인 실패', + failureKeepsEnabled: + '확인에 실패해도 이 서비스는 자동으로 꺼지지 않습니다. 요청은 켜진 목록의 첫 항목을 쓰며, 다음 항목으로 자동 전환되지 않습니다.', + reverify: '다시 확인', elapsed: '소요 시간 {{ms}} ms', staleResult: '24시간이 지난 결과', connectionTitle: '서비스 연결', @@ -1295,6 +1578,7 @@ export const ko: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(무료 모델)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', @@ -1439,15 +1723,16 @@ export const ko: typeof zhCN = { bailianVocabularyIdLabel: '핫워드 Vocabulary ID(선택)', bailianVocabularyIdNote: 'DashScope에서 핫워드 사전을 만들었다면 vocab-... ID를 입력하세요. 비워 두면 핫워드를 전송하지 않습니다.', - bailianProtocolLabel: "API 유형", - bailianProtocolNote: "수동 선택은 모델 이름 감지보다 우선하며 검증과 녹음에 사용할 채널별 설정으로 저장됩니다. 모델 문서에 따라 선택하세요.", + bailianProtocolLabel: 'API 유형', + bailianProtocolNote: + '수동 선택은 모델 이름 감지보다 우선하며 검증과 녹음에 사용할 채널별 설정으로 저장됩니다. 모델 문서에 따라 선택하세요.', bailianProtocolOptions: { - "auto": "자동 감지", - "dashscope-realtime": "실시간 (DashScope)", - "qwen-realtime": "실시간 (Qwen Realtime)", - "multimodal": "비실시간 동기 (Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "비실시간 동기 (Qwen3-ASR)", - "async-transcription": "비실시간 비동기 (파일 전사)", + auto: '자동 감지', + 'dashscope-realtime': '실시간 (DashScope)', + 'qwen-realtime': '실시간 (Qwen Realtime)', + multimodal: '비실시간 동기 (Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': '비실시간 동기 (Qwen3-ASR)', + 'async-transcription': '비실시간 비동기 (파일 전사)', }, bailianModelRealtimeHint: '실시간 모델 · 말하는 동안 바로 전사.', bailianModelSyncFileHint: '동기 녹음 모델 · 말을 마친 뒤 전체 전사(한 클립 ≤ 5분).', @@ -1916,8 +2201,8 @@ export const ko: typeof zhCN = { omni: '멀티모달', models: '로컬 모델', connections: '연결 및 확장', - statusConfigured: '설정됨', - statusMissing: '미설정', + statusConfigured: '초록 점: 켜진 서비스가 있습니다. 요청은 켜진 목록의 첫 항목을 사용합니다.', + statusMissing: '빨간 점: 아직 켜진 서비스가 없습니다.', }, searchPlaceholder: '설정 카테고리 찾기…', clearSearch: '검색 지우기', @@ -2151,8 +2436,7 @@ export const ko: typeof zhCN = { groupDownload: '다운로드 및 관리', groupOther: '기타', mirrorLabel: '다운로드 미러', - mirrorDesc: - 'HuggingFace, 커뮤니티 미러 또는 지원 모델의 공식 ModelScope 저장소를 선택합니다.', + mirrorDesc: 'HuggingFace, 커뮤니티 미러 또는 지원 모델의 공식 ModelScope 저장소를 선택합니다.', mirrorHuggingface: 'HuggingFace 공식 (huggingface.co)', mirrorHfMirror: '중국 미러 (hf-mirror.com)', mirrorModelscope: 'ModelScope 공식 (modelscope.cn)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index f90dff385..af66036d0 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -2,6 +2,176 @@ // 八个语言文件共享同一结构;新增或删除 key 时同步更新全部语言。 export const zhCN = { + cloudSyncE2ee: { + protocolTitle: '云同步协议与隐私提醒', + protocolIntro: + 'OpenLess 及个人开发者尊重您的隐私,并致力于保护您的资料。请阅读以下说明,再决定是否继续。', + protocolPasswordTitle: '请妥善保存同步密码', + protocolPassword: + '同步密码和解密密钥仅用于本机加密与解锁,不会发送给云同步服务。若密码遗失且没有仍可解锁的设备,我们无法找回加密内容。', + protocolEncryptionTitle: '先在本机加密,再上传', + protocolEncryption: + '配置、服务 API 密钥和历史文本会先在本机加密,发往云端和保存在云端的备份内容均为密文。云同步服务无法据此读取或调用您的服务 API 密钥,也不会使用这些内容执行模型请求。', + protocolExcludedTitle: '登录状态与可见信息', + protocolExcluded: + '同步密码、解密密钥、OpenLess 管理的 GitHub/OAuth 登录状态、访问令牌及设备私钥不进入备份。账号认证会单独处理必要凭据;服务器仍可见账号标识、密文大小、版本及同步时间。', + protocolCheck: '我已阅读协议与隐私提醒,了解同步范围,并会妥善保管同步密码。', + protocolBack: '返回同步范围', + protocolConfirm: '我已阅读并确认', + setupPromptTitle: '为配置开启加密备份?', + setupPromptBody: + '服务配置已完成。配置、服务 API 密钥和历史文本会先在此设备加密。查看同步范围并主动开启后,才会上传。', + setupPromptLater: '暂不开启', + setupPromptOpen: '了解加密同步', + title: '加密云同步', + description: '在本机加密后,通过 GitHub 账号跨设备同步。', + enable: '启用加密同步', + setPassword: '设置同步密码', + stepEnableTitle: '第 1 步,共 3 步:开启同步', + stepEnableDetail: '打开右侧开关。确认协议之后,下一步是设置同步密码。', + stepPasswordTitle: '第 2 步,共 3 步:设置同步密码', + stepPasswordDetail: '还差一步。设好同步密码之后,同步才会开始。现在还没有完成。', + stepUnlockTitle: '第 2 步,共 3 步:解锁这台设备', + stepUnlockDetail: '还差一步。输入同步密码,解锁之后才能同步。', + stepClosedTitle: '同步已关闭', + stepClosedDetail: '云端备份还在。要继续同步,打开上方的开关。', + stepDoneTitle: '第 3 步,共 3 步:同步已就绪', + stepDoneDetail: '这台设备已解锁,同步已经开启。', + stepPreparingTitle: '正在进入下一步', + stepPreparingDetail: '协议已经确认。接下来会请你设置同步密码,这一步还没完成。', + stepFollowDetail: '按这一行旁边的按钮继续。', + refresh: '刷新状态', + loading: '正在读取同步状态…', + signIn: '使用 GitHub 登录', + signOut: '退出登录', + account: '同步账号', + keyLocked: '此设备尚未解锁', + keyUnlocked: '此设备已解锁', + hasSnapshot: '云端已有加密备份。', + noSnapshot: '云端尚无加密备份。', + snapshotUnknown: '尚未检查云端备份。', + lastSync: '上次同步:{{time}}', + syncNow: '立即同步', + checkPending: '核对待确认结果', + unlock: '解锁', + lock: '锁定此设备', + changePassword: '更改同步密码', + restore: '审阅云端恢复', + delete: '删除云端备份', + working: '正在处理…', + cancelTask: '取消当前任务', + cancelRequested: '已请求取消,正在等待当前任务结束。', + done: '同步设置已更新。', + restored: '已恢复云端资料,请核对本设备的专属设置。', + retryAfter: '请在 {{seconds}} 秒后重试。', + passwordWarning: '请妥善保管同步密码。密码无法找回,遗失后可能无法读取云端资料。', + consentTitle: '启用端到端加密同步', + consentDescription: + '同步内容包含服务凭据和私密文本历史,资料会在本机加密后上传。请先了解完整范围。', + scopeSummary: '查看完整同步范围', + consentCheck: '我已了解完整范围,并同意加密同步这些资料。', + continue: '继续', + createTitle: '设置同步密码', + unlockTitle: '解锁云端备份', + passwordTitle: '更改同步密码', + create: '创建加密备份', + password: '同步密码', + newPassword: '新同步密码', + currentPassword: '当前同步密码', + confirmPassword: '再次输入新密码', + passwordPolicy: '使用 12–128 个字符,包含大写字母、小写字母和数字,避免常见弱密码。', + rememberKey: '在此设备的安全存储中记住解锁密钥', + disableTitle: '关闭加密同步?', + disableDescription: + '关闭后停止后续同步,云端备份和本机资料都会保留。删除云端备份是另一项独立操作。', + confirmDisable: '关闭同步', + deleteTitle: '删除云端加密备份?', + deleteDescription: + '仅删除当前账号的云端备份,本机资料保留。以后在其他设备恢复前,需要重新创建备份。', + backupRetention: '删除后,服务备份副本最多保留 {{days}} 天。', + deleteCheck: '我确认删除这份云端备份。', + confirmDelete: '删除云端备份', + restoreTitle: '恢复前审阅', + restoreDescription: '请检查下列资料类别,敏感值不会展示。确认之前不会恢复任何内容。', + deviceReview: '恢复后请核对 {{count}} 项设备配置,包括快捷键、模型目录、麦克风及系统授权。', + restoreMode: '恢复方式', + merge: '合并,并逐项处理冲突', + replace: '使用云端资料替换本机同步资料', + replaceWarning: '此操作会替换同步范围内的本机资料。若要保留本机修改,请选择合并。', + restoreCheck: '我已检查恢复方式和冲突处理选择。', + applyRestore: '确认恢复', + conflictProgress: '已处理 {{selected}} / {{total}} 项冲突', + conflictItem: '冲突 {{index}} · {{category}}', + conflictLocal: '保留本机', + conflictCloud: '使用云端', + previous: '上一页', + next: '下一页', + scope: { + preferences: '应用偏好与界面设置。', + credentials: 'ASR、LLM、Omni 渠道及服务 API keys、ID、access key、secret key 等凭据。', + personal: '词典、自定义词汇预设、纠错、风格包及图标。', + history: '全部听写与速记文本历史,以及活动统计。', + device: '设备配置档案,恢复后需在目标设备核对。', + excluded: + '不包含 OAuth 登录态、同步密码或派生密钥、操作系统凭据文件、PIN、设备权限、原始录音与视频,以及模型权重。', + }, + states: { + disabled: '同步已关闭', + sign_in_required: '需要登录 GitHub', + unlock_required: '请输入密码解锁', + ready: '已同步', + pending: '本机更改待同步', + syncing: '正在同步', + conflict: '请审阅冲突更改', + failed: '同步需要处理', + outcome_unknown: '云端结果待确认', + recovery_required: '需要恢复本机同步状态', + }, + categories: { + preferences: '应用偏好', + ui_preferences: '界面设置', + channels: '服务渠道与凭据', + provider_credentials: '服务凭据', + dictionary: '词典', + vocabulary_presets: '自定义词汇预设', + corrections: '纠错规则', + style_packs: '风格包与图标', + history: '文本历史', + activity: '活动统计', + device_profile: '设备配置档案', + other: '其他同步资料', + }, + conflictReasons: { + both_modified: '本机和云端都修改了这项资料。', + delete_modify: '一端删除了这项资料,另一端进行了修改。', + no_common_baseline: '没有共同的同步基线,请选择要保留的一端。', + other: '请选择本机或云端版本,具体值不会展示。', + }, + errors: { + unknown: '操作未能完成,请刷新状态后重试。', + signIn: '请重新登录 GitHub。', + unlock: '请先使用同步密码解锁此设备。', + unavailable: '加密同步暂不可用,请使用原生客户端并稍后检查服务。', + weakPassword: '请使用满足长度和字符要求、且不常见的强密码。', + passwordMismatch: '两次输入的密码不一致。', + invalidPassword: '密码无法解锁这份备份,或加密资料校验未通过。', + secureStorage: '安全存储拒绝访问。请检查设备授权,或取消记住密钥后解锁。', + changed: '审阅期间资料已改变。请关闭弹窗、刷新状态,并重新获取预览。', + accountChanged: 'GitHub 账号已改变,请刷新后再继续。', + busy: '已有同步任务在运行,请等待或先取消。', + cancelled: '任务已取消。', + network: '暂时无法连接同步服务,请检查网络后重试。', + rateLimited: '请求过于频繁,请稍后重试。', + outcomeUnknown: '云端结果尚未确认。请先核对待确认结果,不要重复创建备份。', + recovery: '本机同步状态需要恢复。请保留本机资料,不要覆盖云端备份。', + rolledBack: '恢复失败,本机更改已回滚。请刷新状态后重试。', + tooLarge: '加密备份超出服务的大小限制。', + reviewRequired: '请先确认完整同步范围,并审阅云端恢复预览。', + choicesRequired: '请为每项冲突选择本机或云端版本。', + invalidData: '加密备份未通过校验,本机资料未被替换。', + localData: '这台设备还没准备好本机资料。点「刷新状态」,然后再试一次。', + }, + }, cloudSync: { title: '云同步', description: '使用 GitHub 账号,在设备之间同步词典、风格与个人偏好。', @@ -102,6 +272,18 @@ export const zhCN = { cancel: '取消', }, qa: { + compact: { + closeError: '关闭失败,请重试。', + sendError: '发送失败,输入已保留,请重试。', + microphoneError: '麦克风操作失败,请重试。', + modeError: '编辑模式未更改,请重试。', + conversation: '当前会话', + sending: '正在发送提问…', + addUnavailable: '添加内容 · 暂不可用', + browserUnavailable: '预览不执行指令', + answer: 'OpenLess 回答', + layoutError: '窗口展开失败,请关闭后重新打开。', + }, title: '划词追问', headerHint: '随时提问', thinking: '思考中…', @@ -133,6 +315,84 @@ export const zhCN = { editInstructionMode: '编辑指令', }, lessComputer: { + activity: { + process: '处理过程', + count: '{{count}} 项活动', + count_one: '{{count}} 项活动', + count_other: '{{count}} 项活动', + finished: '已完成', + stopped: '已停止', + search: '搜索', + searchRunning: '正在搜索…', + read: '读取', + readRunning: '正在读取…', + command: '运行命令', + commandRunning: '正在运行命令…', + edit: '编辑', + editRunning: '正在编辑…', + web: '查看网页', + webRunning: '正在查看网页…', + other: '工具处理', + otherRunning: '正在处理…', + }, + desktop: { + approvedSubmitted: '已提交允许', + deniedSubmitted: '已提交拒绝', + agents: '你的 Agents', + configured: '当前配置', + agentSettings: '在设置中选择', + sessionUnavailable: '新会话 · 暂不可用', + signedIn: '已登录', + signIn: '登录账号', + currentSession: '当前会话', + minimize: '最小化', + maximize: '最大化 / 还原', + windowError: '窗口操作未完成,请重试。', + idle: '等待指令', + waitingApproval: '等待你的确认', + submittingApproval: '正在提交…', + approvalError: '审批未提交成功,请重试。', + approvalExpired: '此轮已结束,审批已不可用。', + sendError: '发送失败,输入已保留。请重试。', + browserUnavailable: '浏览器预览不会执行指令,请在桌面应用中使用。', + inputHint: 'Enter 发送 · Shift + Enter 换行', + apiCost: '本轮 API 费用', + emptyHint: '写下具体任务,点麦克风说出来,或用说话快捷键让想法开始行动。', + showInspector: '显示工作台', + hideInspector: '收起工作台', + inspectorTitle: '工作台', + inspectorStatus: '运行状态', + inspectorTurn: '本轮', + toolCalls: '工具调用', + pendingApprovals: '待确认', + inspectorVoice: '语音输入', + voiceShortcutOff: '未设置说话快捷键,可在设置中开启。', + voiceModesHint: '麦克风:说完先填进输入框,确认后再发送。语音按钮:说完直接交给 Agent。', + copy: '复制', + copied: '已复制', + messagePlaceholder: '给 {{agent}} 发消息', + }, + voice: { + dictate: '听写到输入框', + voiceMode: '语音模式(说完直接发送)', + stopTask: '停止任务', + cancel: '取消录音', + confirmDictation: '完成听写', + stopAndSend: '停止并发送', + listening: '正在聆听…', + starting: '正在准备麦克风…', + transcribing: '正在转写…', + empty: '没有听到内容,请再说一次。', + failed: '语音识别失败,请重试。', + startFailed: '无法开始录音:{{message}}', + taskCancelFailed: '任务未能停止,请重试。', + dictateCaption: '说完点 ✓,文字会填进输入框供你修改 · Esc 取消', + submitCaption: '说完点 ↑ 直接发送给 Agent · Esc 取消', + stopHint: '任务运行中,可点击 ■ 停止', + holdHint: '按住 {{key}} 说话', + toggleHint: '按 {{key}} 开始或结束说话', + autoHint: '按住或轻按 {{key}} 说话', + }, title: 'Less Computer', subtitle: '想让电脑做什么?', you: '你', @@ -547,6 +807,9 @@ export const zhCN = { }, vocab: { selectAllVisible: '选择当前结果', + selecting: '选择', + doneSelecting: '完成', + disabledWord: '已停用', selectedCount: '已选择 {{count}} 个词', selectWord: '选择「{{phrase}}」', deleteSelected: '删除已选({{count}})', @@ -736,6 +999,7 @@ export const zhCN = { deleteImported: '删除', deleteConfirm: '确定删除"{{name}}"吗?删除后无法恢复。', deleteSuccess: '已删除"{{name}}"', + undoDelete: '撤销', deleteFailed: '删除风格包失败:{{err}}', summaryCurrentEmpty: '还没有选中风格包', editorTitle: '编辑风格', @@ -1178,6 +1442,10 @@ export const zhCN = { verificationUnavailable: '此渠道暂不支持验证', passed: '验证通过', failed: '验证失败 · {{reason}}', + failedPlain: '验证失败', + failureKeepsEnabled: + '验证失败不会自动停用。请求仍使用列表中第一个已启用的渠道,不会自动改用下一个。', + reverify: '重新验证', elapsed: '耗时 {{ms}} ms', staleResult: '结果已超过 24 小时', connectionTitle: '服务连接', @@ -1253,6 +1521,7 @@ export const zhCN = { mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免费模型)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: '阿里云 Coding Plan', codingPlanX: 'CodingPlanX', @@ -1390,15 +1659,16 @@ export const zhCN = { '按所选请求格式和模型支持的参数启用、关闭或降低思考,不向提示词注入控制指令。', bailianVocabularyIdLabel: '热词 Vocabulary ID(可选)', bailianVocabularyIdNote: '如已在百炼创建热词表,可填写 vocab-...;留空则不下发热词。', - bailianProtocolLabel: "接口类型", - bailianProtocolNote: "手动选择优先于模型名称,按渠道保存,同时用于验证和录音。请根据模型文档选择接口。", + bailianProtocolLabel: '接口类型', + bailianProtocolNote: + '手动选择优先于模型名称,按渠道保存,同时用于验证和录音。请根据模型文档选择接口。', bailianProtocolOptions: { - "auto": "自动识别", - "dashscope-realtime": "实时(DashScope)", - "qwen-realtime": "实时(Qwen Realtime)", - "multimodal": "非实时同步(Fun-ASR / Qwen-Audio)", - "qwen-multimodal": "非实时同步(Qwen3-ASR)", - "async-transcription": "非实时异步(文件转写)", + auto: '自动识别', + 'dashscope-realtime': '实时(DashScope)', + 'qwen-realtime': '实时(Qwen Realtime)', + multimodal: '非实时同步(Fun-ASR / Qwen-Audio)', + 'qwen-multimodal': '非实时同步(Qwen3-ASR)', + 'async-transcription': '非实时异步(文件转写)', }, bailianModelRealtimeHint: '实时模型 · 边说边出字。', bailianModelSyncFileHint: '同步录音模型 · 说完后整段转写(单条 ≤ 5 分钟)。', @@ -1855,8 +2125,8 @@ export const zhCN = { omni: '多模态模型', models: '本地模型', connections: '连接与扩展', - statusConfigured: '已配置', - statusMissing: '未配置', + statusConfigured: '绿点:已有启用的渠道。请求使用列表中第一个启用的渠道。', + statusMissing: '红点:还没有启用的渠道。', }, searchPlaceholder: '查找设置分类…', clearSearch: '清除搜索', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 023ef50ef..133222566 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -2,6 +2,176 @@ import type { zhCN } from './zh-CN'; // 繁體中文資源,與其餘七種語言共用同一組文案 key。 export const zhTW: typeof zhCN = { + cloudSyncE2ee: { + protocolTitle: '雲端同步協議與隱私提醒', + protocolIntro: + 'OpenLess 及個人開發者尊重您的隱私,並致力於保護您的資料。請閱讀以下說明,再決定是否繼續。', + protocolPasswordTitle: '請妥善保存同步密碼', + protocolPassword: + '同步密碼及解密金鑰僅用於本機加密與解鎖,不會傳送給雲端同步服務。若密碼遺失且沒有仍可解鎖的裝置,我們無法找回加密內容。', + protocolEncryptionTitle: '先在本機加密,再上傳', + protocolEncryption: + '設定、服務 API 金鑰及歷史文字會先在本機加密,傳送與儲存在雲端的備份內容均為密文。雲端同步服務無法據此讀取或使用您的 API 金鑰,也不會使用這些內容執行模型請求。', + protocolExcludedTitle: '登入狀態與可見資訊', + protocolExcluded: + '同步密碼、解密金鑰、OpenLess 管理的 GitHub/OAuth 登入狀態、存取權杖及裝置私鑰不進入備份。帳號驗證會另外處理必要憑證;伺服器仍可見帳號識別碼、密文大小、版本及同步時間。', + protocolCheck: '我已閱讀協議與隱私提醒,了解同步範圍,並會妥善保管同步密碼。', + protocolBack: '返回同步範圍', + protocolConfirm: '我已閱讀並確認', + setupPromptTitle: '為設定開啟加密備份?', + setupPromptBody: + '服務設定已完成。設定、服務 API 金鑰及歷史文字會先在此裝置加密。檢視同步範圍並主動開啟後,才會上傳。', + setupPromptLater: '暫不開啟', + setupPromptOpen: '了解加密同步', + title: '加密雲端同步', + description: '在本機加密後,透過 GitHub 帳號跨裝置同步。', + enable: '啟用加密同步', + setPassword: '設定同步密碼', + stepEnableTitle: '第 1 步,共 3 步:開啟同步', + stepEnableDetail: '打開右側開關。確認協議之後,下一步是設定同步密碼。', + stepPasswordTitle: '第 2 步,共 3 步:設定同步密碼', + stepPasswordDetail: '還差一步。設好同步密碼之後,同步才會開始。現在還沒有完成。', + stepUnlockTitle: '第 2 步,共 3 步:解鎖這台裝置', + stepUnlockDetail: '還差一步。輸入同步密碼,解鎖之後才能同步。', + stepClosedTitle: '同步已關閉', + stepClosedDetail: '雲端備份還在。要繼續同步,打開上方的開關。', + stepDoneTitle: '第 3 步,共 3 步:同步已就緒', + stepDoneDetail: '這台裝置已解鎖,同步已經開啟。', + stepPreparingTitle: '正在進入下一步', + stepPreparingDetail: '協議已經確認。接下來會請你設定同步密碼,這一步還沒完成。', + stepFollowDetail: '按這一列旁邊的按鈕繼續。', + refresh: '重新整理狀態', + loading: '正在讀取同步狀態…', + signIn: '使用 GitHub 登入', + signOut: '登出', + account: '同步帳號', + keyLocked: '此裝置尚未解鎖', + keyUnlocked: '此裝置已解鎖', + hasSnapshot: '雲端已有加密備份。', + noSnapshot: '雲端尚無加密備份。', + snapshotUnknown: '尚未檢查雲端備份。', + lastSync: '上次同步:{{time}}', + syncNow: '立即同步', + checkPending: '核對待確認結果', + unlock: '解鎖', + lock: '鎖定此裝置', + changePassword: '變更同步密碼', + restore: '檢閱雲端還原', + delete: '刪除雲端備份', + working: '正在處理…', + cancelTask: '取消目前工作', + cancelRequested: '已請求取消,正在等待目前工作結束。', + done: '同步設定已更新。', + restored: '已還原雲端資料,請核對此裝置的專屬設定。', + retryAfter: '請在 {{seconds}} 秒後重試。', + passwordWarning: '請妥善保管同步密碼。密碼無法找回,遺失後可能無法讀取雲端資料。', + consentTitle: '啟用端對端加密同步', + consentDescription: + '同步內容包含服務憑證和私人文字歷史,資料會在本機加密後上傳。請先了解完整範圍。', + scopeSummary: '查看完整同步範圍', + consentCheck: '我已了解完整範圍,並同意加密同步這些資料。', + continue: '繼續', + createTitle: '設定同步密碼', + unlockTitle: '解鎖雲端備份', + passwordTitle: '變更同步密碼', + create: '建立加密備份', + password: '同步密碼', + newPassword: '新同步密碼', + currentPassword: '目前同步密碼', + confirmPassword: '再次輸入新密碼', + passwordPolicy: '使用 12–128 個字元,包含大寫字母、小寫字母和數字,避免常見弱密碼。', + rememberKey: '在此裝置的安全儲存空間記住解鎖金鑰', + disableTitle: '關閉加密同步?', + disableDescription: + '關閉後停止後續同步,雲端備份和本機資料都會保留。刪除雲端備份是另一項獨立操作。', + confirmDisable: '關閉同步', + deleteTitle: '刪除雲端加密備份?', + deleteDescription: + '僅刪除目前帳號的雲端備份,本機資料保留。之後在其他裝置還原前,需要重新建立備份。', + backupRetention: '刪除後,服務備份副本最多保留 {{days}} 天。', + deleteCheck: '我確認刪除這份雲端備份。', + confirmDelete: '刪除雲端備份', + restoreTitle: '還原前檢閱', + restoreDescription: '請檢查下列資料類別,敏感值不會顯示。確認之前不會還原任何內容。', + deviceReview: '還原後請核對 {{count}} 項裝置設定,包括快捷鍵、模型目錄、麥克風及系統授權。', + restoreMode: '還原方式', + merge: '合併,並逐項處理衝突', + replace: '使用雲端資料取代本機同步資料', + replaceWarning: '此操作會取代同步範圍內的本機資料。若要保留本機修改,請選擇合併。', + restoreCheck: '我已檢查還原方式和衝突處理選擇。', + applyRestore: '確認還原', + conflictProgress: '已處理 {{selected}} / {{total}} 項衝突', + conflictItem: '衝突 {{index}} · {{category}}', + conflictLocal: '保留本機', + conflictCloud: '使用雲端', + previous: '上一頁', + next: '下一頁', + scope: { + preferences: '應用程式偏好與介面設定。', + credentials: 'ASR、LLM、Omni 渠道及服務 API keys、ID、access key、secret key 等憑證。', + personal: '詞典、自訂詞彙預設、修正、風格包及圖示。', + history: '全部聽寫與速記文字歷史,以及活動統計。', + device: '裝置設定檔,還原後需在目標裝置核對。', + excluded: + '不包含 OAuth 登入狀態、同步密碼或衍生金鑰、作業系統憑證檔案、PIN、裝置權限、原始錄音與影片,以及模型權重。', + }, + states: { + disabled: '同步已關閉', + sign_in_required: '需要登入 GitHub', + unlock_required: '請輸入密碼解鎖', + ready: '已同步', + pending: '本機變更待同步', + syncing: '正在同步', + conflict: '請檢閱衝突變更', + failed: '同步需要處理', + outcome_unknown: '雲端結果待確認', + recovery_required: '需要復原本機同步狀態', + }, + categories: { + preferences: '應用程式偏好', + ui_preferences: '介面設定', + channels: '服務渠道與憑證', + provider_credentials: '服務憑證', + dictionary: '詞典', + vocabulary_presets: '自訂詞彙預設', + corrections: '修正規則', + style_packs: '風格包與圖示', + history: '文字歷史', + activity: '活動統計', + device_profile: '裝置設定檔', + other: '其他同步資料', + }, + conflictReasons: { + both_modified: '本機和雲端都修改了這項資料。', + delete_modify: '一端刪除了這項資料,另一端進行了修改。', + no_common_baseline: '沒有共同的同步基準,請選擇要保留的一端。', + other: '請選擇本機或雲端版本,具體值不會顯示。', + }, + errors: { + unknown: '操作未能完成,請重新整理狀態後重試。', + signIn: '請重新登入 GitHub。', + unlock: '請先使用同步密碼解鎖此裝置。', + unavailable: '加密同步暫不可用,請使用原生用戶端並稍後檢查服務。', + weakPassword: '請使用符合長度和字元要求、且不常見的強密碼。', + passwordMismatch: '兩次輸入的密碼不一致。', + invalidPassword: '密碼無法解鎖這份備份,或加密資料驗證未通過。', + secureStorage: '安全儲存空間拒絕存取。請檢查裝置授權,或取消記住金鑰後解鎖。', + changed: '檢閱期間資料已變更。請關閉對話框、重新整理狀態,並重新取得預覽。', + accountChanged: 'GitHub 帳號已變更,請重新整理後再繼續。', + busy: '已有同步工作在執行,請等待或先取消。', + cancelled: '工作已取消。', + network: '暫時無法連線同步服務,請檢查網路後重試。', + rateLimited: '請求過於頻繁,請稍後重試。', + outcomeUnknown: '雲端結果尚未確認。請先核對待確認結果,不要重複建立備份。', + recovery: '本機同步狀態需要復原。請保留本機資料,不要覆寫雲端備份。', + rolledBack: '還原失敗,本機變更已回復。請重新整理狀態後重試。', + tooLarge: '加密備份超出服務的大小限制。', + reviewRequired: '請先確認完整同步範圍,並檢閱雲端還原預覽。', + choicesRequired: '請為每項衝突選擇本機或雲端版本。', + invalidData: '加密備份未通過驗證,本機資料未被取代。', + localData: '這台裝置還沒準備好本機資料。點「重新整理狀態」,然後再試一次。', + }, + }, cloudSync: { title: '雲端同步', description: '使用 GitHub 帳號,在裝置之間同步詞典、風格與個人偏好。', @@ -102,6 +272,18 @@ export const zhTW: typeof zhCN = { cancel: '取消', }, qa: { + compact: { + closeError: '關閉失敗,請重試。', + sendError: '傳送失敗,輸入已保留,請重試。', + microphoneError: '麥克風操作失敗,請重試。', + modeError: '編輯模式未變更,請重試。', + conversation: '目前對話', + sending: '正在傳送提問…', + addUnavailable: '新增內容 · 暫不可用', + browserUnavailable: '預覽不執行指令', + answer: 'OpenLess 回答', + layoutError: '視窗展開失敗,請關閉後重新開啟。', + }, title: '劃詞追問', headerHint: '隨時提問', thinking: '思考中…', @@ -133,6 +315,84 @@ export const zhTW: typeof zhCN = { editInstructionMode: '編輯指令', }, lessComputer: { + activity: { + process: '處理過程', + count: '{{count}} 項活動', + count_one: '{{count}} 項活動', + count_other: '{{count}} 項活動', + finished: '已完成', + stopped: '已停止', + search: '搜尋', + searchRunning: '正在搜尋…', + read: '讀取', + readRunning: '正在讀取…', + command: '執行指令', + commandRunning: '正在執行指令…', + edit: '編輯', + editRunning: '正在編輯…', + web: '檢視網頁', + webRunning: '正在檢視網頁…', + other: '工具處理', + otherRunning: '正在處理…', + }, + desktop: { + approvedSubmitted: '已提交允許', + deniedSubmitted: '已提交拒絕', + agents: '你的 Agents', + configured: '目前設定', + agentSettings: '在設定中選擇', + sessionUnavailable: '新對話 · 暫不可用', + signedIn: '已登入', + signIn: '登入帳號', + currentSession: '目前對話', + minimize: '最小化', + maximize: '最大化 / 還原', + windowError: '視窗操作未完成,請重試。', + idle: '等待指令', + waitingApproval: '等待你的確認', + submittingApproval: '正在提交…', + approvalError: '審批未提交成功,請重試。', + approvalExpired: '此輪已結束,審批已不可用。', + sendError: '傳送失敗,輸入已保留。請重試。', + browserUnavailable: '瀏覽器預覽不會執行指令,請在桌面應用程式中使用。', + inputHint: 'Enter 傳送 · Shift + Enter 換行', + apiCost: '本輪 API 費用', + emptyHint: '寫下具體任務,點麥克風說出來,或用說話快捷鍵讓想法開始行動。', + showInspector: '顯示工作台', + hideInspector: '收起工作台', + inspectorTitle: '工作台', + inspectorStatus: '執行狀態', + inspectorTurn: '本輪', + toolCalls: '工具呼叫', + pendingApprovals: '待確認', + inspectorVoice: '語音輸入', + voiceShortcutOff: '尚未設定說話快捷鍵,可在設定中開啟。', + voiceModesHint: '麥克風:說完先填入輸入框,確認後再傳送。語音按鈕:說完直接交給 Agent。', + copy: '複製', + copied: '已複製', + messagePlaceholder: '傳訊息給 {{agent}}', + }, + voice: { + dictate: '聽寫到輸入框', + voiceMode: '語音模式(說完直接傳送)', + stopTask: '停止任務', + cancel: '取消錄音', + confirmDictation: '完成聽寫', + stopAndSend: '停止並傳送', + listening: '正在聆聽…', + starting: '正在準備麥克風…', + transcribing: '正在轉寫…', + empty: '沒有聽到內容,請再說一次。', + failed: '語音辨識失敗,請重試。', + startFailed: '無法開始錄音:{{message}}', + taskCancelFailed: '任務未能停止,請重試。', + dictateCaption: '說完點 ✓,文字會填入輸入框供你修改 · Esc 取消', + submitCaption: '說完點 ↑ 直接傳送給 Agent · Esc 取消', + stopHint: '任務執行中,可點擊 ■ 停止', + holdHint: '按住 {{key}} 說話', + toggleHint: '按 {{key}} 開始或結束說話', + autoHint: '按住或輕按 {{key}} 說話', + }, title: 'Less Computer', subtitle: '想讓電腦做什麼?', you: '你', @@ -547,6 +807,9 @@ export const zhTW: typeof zhCN = { }, vocab: { selectAllVisible: '選取目前結果', + selecting: '選取', + doneSelecting: '完成', + disabledWord: '已停用', selectedCount: '已選取 {{count}} 個詞', selectWord: '選取「{{phrase}}」', deleteSelected: '刪除已選({{count}})', @@ -738,6 +1001,7 @@ export const zhTW: typeof zhCN = { deleteImported: '刪除', deleteConfirm: '確定刪除"{{name}}"嗎?刪除後無法復原。', deleteSuccess: '已刪除"{{name}}"', + undoDelete: '復原', deleteFailed: '刪除風格包失敗:{{err}}', summaryCurrentEmpty: '還沒有選中風格包', editorTitle: '編輯風格', @@ -1179,6 +1443,10 @@ export const zhTW: typeof zhCN = { verificationUnavailable: '此渠道暫不支援驗證', passed: '驗證通過', failed: '驗證失敗 · {{reason}}', + failedPlain: '驗證失敗', + failureKeepsEnabled: + '驗證失敗不會自動停用。請求仍使用列表中第一個已啟用的渠道,不會自動改用下一個。', + reverify: '重新驗證', elapsed: '耗時 {{ms}} ms', staleResult: '結果已超過 24 小時', connectionTitle: '服務連線', @@ -1254,6 +1522,7 @@ export const zhTW: typeof zhCN = { mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免費模型)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: '阿里雲 Coding Plan', codingPlanX: 'CodingPlanX', @@ -1856,8 +2125,8 @@ export const zhTW: typeof zhCN = { omni: '多模態模型', models: '本機模型', connections: '連線與擴充', - statusConfigured: '已設定', - statusMissing: '未設定', + statusConfigured: '綠點:已有啟用的渠道。請求使用列表中第一個啟用的渠道。', + statusMissing: '紅點:還沒有啟用的渠道。', }, searchPlaceholder: '尋找設定分類…', clearSearch: '清除搜尋', diff --git a/openless-all/app/src/lib/encryptedSyncUiBridge.ts b/openless-all/app/src/lib/encryptedSyncUiBridge.ts new file mode 100644 index 000000000..03f036b9e --- /dev/null +++ b/openless-all/app/src/lib/encryptedSyncUiBridge.ts @@ -0,0 +1,204 @@ +// The main native window owns the device-local UI mirror. Every write shares +// this queue and a native revision, including the initial consent preparation. +import { invokeOrMock, isTauri } from './ipc/shared'; +import { + getLocalePreference, + setLocalePreference, + SUPPORTED_LOCALES, + type SupportedLocale, +} from '../i18n'; +import { readFontScale, setFontScale, type FontScaleId } from './fontScale'; + +type UiPreferences = { locale?: string; fontScale?: string }; +type UiSnapshot = { preferences: UiPreferences | null; revision: string | null }; +type UiKey = 'locale' | 'fontScale'; +type Status = { + sequence: string; + account: { githubId: string } | null; + vaultId: string | null; + consentVersion: string | null; +}; +type Restored = { sequence: string; accountId: string; vaultId: string; taskId: string | null }; +let installation: Promise | null = null; +let flushBridge: (() => Promise) | null = null; + +export async function flushEncryptedSyncUiPreferences(): Promise { + await installEncryptedSyncUiBridge(); + if (!flushBridge) throw new Error('cloud_sync_unavailable'); + await flushBridge(); +} + +export function installEncryptedSyncUiBridge(): Promise { + if (!isTauri || new URLSearchParams(location.search).has('window')) return Promise.resolve(); + return (installation ??= install()); +} + +async function install(): Promise { + let ready = false; + let choiceEpoch = 0; + let obsoleteThrough = 0; + let restoredSequence = 0n; + let restorationEpoch = 0; + let settledRestorationEpoch = 0; + let known: UiSnapshot = { preferences: null, revision: null }; + const choices = new Map(); + let queue: Promise = Promise.resolve(); + const unavailable = (): never => { + throw new Error('cloud_sync_unavailable'); + }; + const readStatus = () => invokeOrMock('cloud_sync_e2ee_status', undefined, unavailable); + const readUi = () => + invokeOrMock('cloud_sync_e2ee_get_ui_preferences_snapshot', undefined, unavailable); + const sameScope = (a: Status, b: Status) => + a.account?.githubId === b.account?.githubId && a.vaultId === b.vaultId; + const belongs = (state: Status, event: Restored) => + state.account?.githubId === event.accountId && state.vaultId === event.vaultId; + const failed = (error: unknown) => + window.dispatchEvent(new CustomEvent('openless:sync-ui-persistence-failed', { detail: error })); + const enqueue = (work: () => Promise): Promise => { + const next = queue.catch(() => {}).then(work); + queue = next.catch(failed); + return next; + }; + const invalidate = (through: number) => { + obsoleteThrough = Math.max(obsoleteThrough, through); + for (const [key, choice] of choices) if (choice.epoch <= through) choices.delete(key); + }; + const apply = async (preferences: UiPreferences, expectedEpoch: number) => { + if ( + (choices.get('locale')?.epoch ?? 0) <= expectedEpoch && + (preferences.locale === 'system' || + SUPPORTED_LOCALES.some((locale) => locale === preferences.locale)) + ) { + await setLocalePreference(preferences.locale as SupportedLocale | 'system', 'sync-restore'); + } + if ( + (choices.get('fontScale')?.epoch ?? 0) <= expectedEpoch && + ['small', 'medium', 'large'].includes(preferences.fontScale ?? '') + ) { + setFontScale(preferences.fontScale as FontScaleId, 'sync-restore'); + } + }; + const reconcile = async (through: number) => { + const current = await readUi(); + known = current; + invalidate(through); + if (current.preferences) await apply(current.preferences, through); + }; + const persist = async (explicit: boolean, epoch: number) => { + if (!explicit && (epoch !== choiceEpoch || epoch <= obsoleteThrough)) return; + const status = await readStatus(); + if (!explicit && (epoch !== choiceEpoch || epoch <= obsoleteThrough || !status.consentVersion)) + return; + const current = await readUi(); + if (restorationEpoch !== settledRestorationEpoch) { + if (explicit) throw new Error('cloud_sync_ui_restore_pending'); + return; + } + // A restore or rollback may have happened without its notification reaching + // this WebView. Rebase from native, but never replay the old user's intent + // over that revision. A subsequent edit uses the refreshed revision. + if (current.revision !== known.revision) { + known = current; + invalidate(epoch); + if (current.preferences) await apply(current.preferences, epoch); + throw new Error('cloud_sync_ui_revision_changed'); + } + if (!explicit && epoch <= obsoleteThrough) return; + const pending = [...choices].filter( + ([, value]) => value.epoch > obsoleteThrough && value.epoch <= epoch, + ); + if (!pending.length && current.preferences) return; + const desired = { + locale: getLocalePreference(), + fontScale: readFontScale(), + ...current.preferences, + }; + for (const [key, choice] of pending) desired[key] = choice.value; + try { + await invokeOrMock( + 'cloud_sync_e2ee_set_ui_preferences_checked', + { + ...desired, + expectedRevision: current.revision, + }, + unavailable, + ); + await reconcile(epoch); + } catch (error) { + // Refresh even after CAS failure or an uncertain reply. Keeping the old + // revision would make future edits fail until the WebView restarts. + await reconcile(epoch); + throw error; + } + }; + const changed = (event: Event) => { + if (!ready || (event as CustomEvent<{ source?: string }>).detail?.source === 'sync-restore') + return; + const epoch = ++choiceEpoch; + const values = { locale: getLocalePreference(), fontScale: readFontScale() }; + const detailKey = (event as CustomEvent<{ key?: UiKey }>).detail?.key; + const storageKey = (event as StorageEvent).key; + const keys: UiKey[] = detailKey + ? [detailKey] + : storageKey === 'ol-font-scale' + ? ['fontScale'] + : storageKey + ? ['locale'] + : ['locale', 'fontScale']; + for (const key of keys) choices.set(key, { epoch, value: values[key] }); + void enqueue(() => persist(false, epoch)).catch(() => {}); + }; + const { listen } = await import('@tauri-apps/api/event'); + await listen('cloud-sync-e2ee:restored', ({ payload }) => { + if ( + !/^(0|[1-9]\d{0,19})$/.test(payload.sequence) || + BigInt(payload.sequence) <= restoredSequence + ) + return; + const eventChoiceEpoch = choiceEpoch; + const notificationEpoch = ++restorationEpoch; + // Pause old writes immediately, but retain their choices until native has + // verified this event's scope. A late event from another vault must not + // discard the current user's pending changes. + void enqueue(async () => { + try { + if (BigInt(payload.sequence) <= restoredSequence) return; + const before = await readStatus(); + if (!belongs(before, payload)) return; + const current = await readUi(); + const after = await readStatus(); + if ( + !belongs(after, payload) || + !sameScope(before, after) || + BigInt(payload.sequence) <= restoredSequence + ) + return; + invalidate(eventChoiceEpoch); + known = current; + restoredSequence = BigInt(payload.sequence); + if (current.preferences) await apply(current.preferences, eventChoiceEpoch); + } finally { + settledRestorationEpoch = notificationEpoch; + if (notificationEpoch === restorationEpoch && choices.size > 0) { + void enqueue(() => persist(false, choiceEpoch)).catch(() => {}); + } + } + }).catch(() => {}); + }); + window.addEventListener('openless:ui-preferences-changed', changed); + window.addEventListener('storage', (event) => { + if (event.key === 'ol-font-scale' || event.key?.includes('locale')) changed(event); + }); + flushBridge = () => enqueue(() => persist(true, choiceEpoch)); + try { + await enqueue(async () => { + known = await readUi(); + if (known.preferences) await apply(known.preferences, choiceEpoch); + }); + } catch { + // Settings remain available to retry a denied keychain read. + } finally { + ready = true; + } +} diff --git a/openless-all/app/src/lib/fontScale.ts b/openless-all/app/src/lib/fontScale.ts index 6ca8de617..bfc7c5b29 100644 --- a/openless-all/app/src/lib/fontScale.ts +++ b/openless-all/app/src/lib/fontScale.ts @@ -32,10 +32,11 @@ export function applyFontScale(id: FontScaleId): void { (document.documentElement.style as CSSStyleDeclaration & { zoom?: string }).zoom = String(scale); } -export function setFontScale(id: FontScaleId): void { +export function setFontScale(id: FontScaleId, source: 'user' | 'sync-restore' = 'user'): void { applyFontScale(id); try { window.localStorage.setItem(FONT_SCALE_KEY, id); + window.dispatchEvent(new CustomEvent('openless:ui-preferences-changed', { detail: { source, key: 'fontScale' } })); } catch { /* 忽略 */ } diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 94b8780fb..dedc5744f 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -8,6 +8,8 @@ import type { ShortcutBinding, } from './types'; +export const MODIFIER_CHORD_PRIMARY = 'ModifierChord'; + export function defaultQaShortcut(): ShortcutBinding { return { primary: ';', @@ -269,7 +271,9 @@ export function formatComboParts( } } - parts.push(formatPrimary(binding.primary)); + if (binding.primary !== MODIFIER_CHORD_PRIMARY) { + parts.push(formatPrimary(binding.primary)); + } return parts; } @@ -304,6 +308,22 @@ export function sideModifiersFromPressedCodes(codes: Iterable): string[] return modifiers; } +/** A modifier-only chord preserves every physical side, including Ctrl+Ctrl. */ +export function chordModifiersFromPressedCodes(codes: Iterable): string[] { + const set = codes instanceof Set ? codes : new Set(codes); + const pairs = [ + ['MetaLeft', 'cmd-left'], + ['MetaRight', 'cmd-right'], + ['ControlLeft', 'ctrl-left'], + ['ControlRight', 'ctrl-right'], + ['AltLeft', 'alt-left'], + ['AltRight', 'alt-right'], + ['ShiftLeft', 'shift-left'], + ['ShiftRight', 'shift-right'], + ]; + return pairs.filter(([code]) => set.has(code)).map(([, modifier]) => modifier); +} + /** Build generic modifier tags (cmd/super/ctrl/alt/shift) from pressed key codes. */ export function genericModifiersFromPressedCodes(codes: Iterable): string[] { const set = codes instanceof Set ? codes : new Set(codes); diff --git a/openless-all/app/src/lib/hotkeySideModifiers.test.ts b/openless-all/app/src/lib/hotkeySideModifiers.test.ts index 9cd898938..5749a2339 100644 --- a/openless-all/app/src/lib/hotkeySideModifiers.test.ts +++ b/openless-all/app/src/lib/hotkeySideModifiers.test.ts @@ -1,5 +1,8 @@ import { + chordModifiersFromPressedCodes, + formatComboParts, genericModifiersFromPressedCodes, + MODIFIER_CHORD_PRIMARY, modifiersFromPressedCodes, shortcutFromLegacyTrigger, sideModifiersFromPressedCodes, @@ -62,6 +65,15 @@ assertDeepEqual( 'default recording on non-mac uses super', ); +assertDeepEqual( + formatComboParts({ + primary: MODIFIER_CHORD_PRIMARY, + modifiers: ['ctrl-left', 'cmd-left'], + }), + ['左 Win', '左 Ctrl'], + 'modifier chord display contains only its physical modifiers', +); + assertDeepEqual( sideModifiersFromPressedCodes(new Set(['MetaRight', 'KeyD'])), ['cmd-right'], @@ -74,4 +86,22 @@ assertDeepEqual( 'left cmd wins when both meta keys are tracked', ); +for (const [left, right, modifier] of [ + ['MetaLeft', 'MetaRight', 'cmd'], + ['ControlLeft', 'ControlRight', 'ctrl'], + ['AltLeft', 'AltRight', 'alt'], + ['ShiftLeft', 'ShiftRight', 'shift'], +]) { + assertDeepEqual( + chordModifiersFromPressedCodes(new Set([right, left])), + [`${modifier}-left`, `${modifier}-right`], + 'modifier-only recording keeps both physical sides independent of press order', + ); +} +assertDeepEqual( + chordModifiersFromPressedCodes(new Set(['MetaLeft', 'ControlLeft', 'KeyD'])), + ['cmd-left', 'ctrl-left'], + 'modifier chord ignores ordinary key codes', +); + console.log('hotkeySideModifiers.test.ts passed'); diff --git a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.browser.test.ts b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.browser.test.ts new file mode 100644 index 000000000..8f32a0cab --- /dev/null +++ b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.browser.test.ts @@ -0,0 +1,48 @@ +// @ts-nocheck — Node-only runtime harness; production UI and IPC remain strictly typed. +import assert from 'node:assert/strict'; +import * as api from './cloud-sync-e2ee'; + +const operations: Array<() => Promise> = [ + api.cloudSyncE2eeStatus, + api.cloudSyncE2eeClaimSetupPrompt, + () => api.cloudSyncE2eePrepareEnable(api.CLOUD_SYNC_E2EE_CONSENT_VERSION), + () => + api.cloudSyncE2eeCreate({ + password: 'SyntheticOnly1A', + passwordConfirmation: 'SyntheticOnly1A', + rememberKey: true, + consentVersion: api.CLOUD_SYNC_E2EE_CONSENT_VERSION, + observedRevision: '0', + }), + () => api.cloudSyncE2eeUnlock({ password: 'SyntheticOnly1A', rememberKey: true }), + api.cloudSyncE2eeLock, + () => api.cloudSyncE2eeSetEnabled(true), + api.cloudSyncE2eeSyncNow, + () => api.cloudSyncE2eeCancel('task'), + () => api.cloudSyncE2eePreviewRestore('1'), + () => + api.cloudSyncE2eeApplyRestore({ previewId: 'preview', mode: 'replace', conflictChoices: [] }), + () => + api.cloudSyncE2eeChangePassword({ + currentPassword: 'SyntheticOnly1A', + newPassword: 'SyntheticOnly2A', + confirmation: 'SyntheticOnly2A', + rememberKey: true, + }), + () => + api.cloudSyncE2eeDeleteRemote({ + expectedVaultId: 'vault', + observedRevision: '1', + confirmed: true, + }), + api.cloudSyncE2eeSignOut, + api.cloudSyncE2eeGetUiPreferences, + api.mirrorEncryptedSyncUiPreferences, +]; +for (const invoke of operations) { + await assert.rejects( + invoke, + (error: unknown) => api.encryptedSyncErrorKey(error) === 'unavailable', + ); +} +console.log('cloud-sync-e2ee.browser.test.ts passed'); diff --git a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.test.ts b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.test.ts new file mode 100644 index 000000000..1e661457f --- /dev/null +++ b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.test.ts @@ -0,0 +1,142 @@ +// @ts-nocheck — Node-only runtime harness; production UI and IPC remain strictly typed. +import assert from 'node:assert/strict'; +import type { EncryptedSyncStatus } from './cloud-sync-e2ee'; + +const calls: Array<{ command: string; args: unknown }> = []; +const response = { marker: 'native-result' }; +let failure: unknown = null; +const stored = new Map([ + ['ol.locale', 'fr'], + ['ol-font-scale', 'large'], +]); +Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + localStorage: { getItem: (key: string) => stored.get(key) ?? null }, + addEventListener() {}, + __TAURI_INTERNALS__: { + invoke: async (command: string, args: unknown) => { + if (command === 'get_startup_snapshot') + return { contractVersion: '2.0.0', backend: { running: true } }; + if (command === 'set_remote_locale') return; + calls.push({ command, args }); + if (failure) throw failure; + return response; + }, + }, + }, +}); + +try { + const api = await import('./cloud-sync-e2ee'); + const secret = 'OnlyA1SyntheticFixture'; + const create = { + password: secret, + passwordConfirmation: secret, + rememberKey: true, + consentVersion: api.CLOUD_SYNC_E2EE_CONSENT_VERSION, + observedRevision: '9007199254740993', + }; + const change = { + currentPassword: secret, + newPassword: `${secret}2`, + confirmation: `${secret}2`, + rememberKey: false, + }; + const apply = { + previewId: 'preview', + mode: 'merge' as const, + conflictChoices: [{ id: 'opaque', side: 'cloud' as const }], + }; + const deletion = { + expectedVaultId: 'vault', + observedRevision: '9007199254740993', + confirmed: true, + }; + const cases: Array<[string, unknown, () => Promise]> = [ + ['status', undefined, api.cloudSyncE2eeStatus], + ['claim_setup_prompt', undefined, api.cloudSyncE2eeClaimSetupPrompt], + [ + 'prepare_enable', + { consentVersion: api.CLOUD_SYNC_E2EE_CONSENT_VERSION }, + () => api.cloudSyncE2eePrepareEnable(api.CLOUD_SYNC_E2EE_CONSENT_VERSION), + ], + ['create', create, () => api.cloudSyncE2eeCreate(create)], + [ + 'unlock', + { password: secret, rememberKey: false }, + () => api.cloudSyncE2eeUnlock({ password: secret, rememberKey: false }), + ], + ['lock', undefined, api.cloudSyncE2eeLock], + ['set_enabled', { enabled: false }, () => api.cloudSyncE2eeSetEnabled(false)], + ['sync_now', undefined, api.cloudSyncE2eeSyncNow], + ['cancel', { taskId: 'task' }, () => api.cloudSyncE2eeCancel('task')], + [ + 'preview_restore', + { observedRevision: '9007199254740993' }, + () => api.cloudSyncE2eePreviewRestore('9007199254740993'), + ], + ['apply_restore', apply, () => api.cloudSyncE2eeApplyRestore(apply)], + ['change_password', change, () => api.cloudSyncE2eeChangePassword(change)], + ['delete_remote', deletion, () => api.cloudSyncE2eeDeleteRemote(deletion)], + ['sign_out', undefined, api.cloudSyncE2eeSignOut], + ['get_ui_preferences', undefined, api.cloudSyncE2eeGetUiPreferences], + ]; + for (const [command, args, invoke] of cases) { + assert.equal(await invoke(), response); + assert.deepEqual(calls.pop(), { command: `cloud_sync_e2ee_${command}`, args: args ?? {} }); + } + // The mirror uses the same revisioned queue as live changes; exercised by + // scripts/encrypted-sync-ui-bridge.test.mjs, including consent preparation. + assert.equal(stored.get('ol.locale'), 'fr'); + assert.equal(stored.get('ol-font-scale'), 'large'); + failure = { + code: 'provider', + details: { reason: 'revision_conflict' }, + message: 'DO_NOT_RENDER_PRIVATE_RESPONSE', + }; + assert.equal(await api.cloudSyncE2eeSyncNow().catch((error: unknown) => error), failure); + assert.equal(api.encryptedSyncErrorKey(failure), 'changed'); + assert.equal(api.encryptedSyncErrorKey({ message: 'DO_NOT_RENDER_PRIVATE_RESPONSE' }), 'unknown'); + assert.equal(api.encryptedSyncErrorKey({ details: { reason: '__proto__' } }), 'unknown'); + + const status = { + sequence: '9007199254740993', + account: { githubId: 'owner', login: 'name' }, + vaultId: 'vault', + taskId: 'task', + serviceOrigin: 'https://sync.example', + } as EncryptedSyncStatus; + const event = { + sequence: '9007199254740994', + accountId: 'owner', + vaultId: 'vault', + taskId: 'task', + }; + assert(api.matchesEncryptedSyncEvent(status, event, status.sequence)); + for (const invalid of [ + { ...event, sequence: status.sequence }, + { ...event, sequence: '01' }, + { ...event, sequence: '18446744073709551616' }, + { ...event, accountId: 'other' }, + { ...event, vaultId: 'other' }, + { ...event, taskId: 'old-task' }, + ]) + assert(!api.matchesEncryptedSyncEvent(status, invalid, status.sequence)); + assert( + api.matchesEncryptedSyncEvent(status, { ...event, taskId: 'new-task' }, status.sequence, false), + ); + assert.notEqual( + api.encryptedSyncScope(status), + api.encryptedSyncScope({ ...status, serviceOrigin: 'https://other.example' }), + ); + assert.equal(api.syncSequence('18446744073709551615'), 18446744073709551615n); + assert.equal(api.syncSequence('not-a-sequence'), null); + assert(api.syncPasswordMeetsBasicRequirements('LongFixture1Ab')); + assert(api.syncPasswordMeetsBasicRequirements('A1' + '界'.repeat(10)) === false); + assert(!api.syncPasswordMeetsBasicRequirements('shortA1')); + assert(!api.syncPasswordMeetsBasicRequirements('A1a' + '界'.repeat(126))); +} finally { + Reflect.deleteProperty(globalThis, 'window'); +} +console.log('cloud-sync-e2ee.test.ts passed'); diff --git a/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts new file mode 100644 index 000000000..2a47bb1a5 --- /dev/null +++ b/openless-all/app/src/lib/ipc/cloud-sync-e2ee.ts @@ -0,0 +1,243 @@ +import { invokeOrMock, isTauri } from './shared'; + +export const CLOUD_SYNC_E2EE_CONSENT_VERSION = 'encrypted-full-snapshot-v1'; + +export type EncryptedSyncState = + | 'disabled' + | 'sign_in_required' + | 'unlock_required' + | 'ready' + | 'pending' + | 'syncing' + | 'conflict' + | 'failed' + | 'outcome_unknown' + | 'recovery_required'; + +export interface EncryptedSyncStatus { + sequence: string; + enabled: boolean; + authState: 'signed_out' | 'signed_in' | 'expired'; + keyState: 'locked' | 'unlocked'; + syncState: EncryptedSyncState; + account: { githubId: string; login: string } | null; + vaultId: string | null; + keyId: string | null; + localGeneration: string; + lastSyncedLocalGeneration: string | null; + remoteRevision: string | null; + lastSuccessfulSyncAt: string | null; + pendingOperationId: string | null; + lastError: { code: string; retryAfterSeconds: number | null } | null; + recoveryRequired: boolean; + hasCloudSnapshot: boolean | null; + taskId: string | null; + serviceOrigin: string; + backupRetentionDays: number | null; + consentVersion: string | null; +} + +export interface EnablePreparation { + nextStep: 'create' | 'unlock' | 'restore_review' | 'ready'; + status: EncryptedSyncStatus; +} + +export interface SyncConflictChoice { + id: string; + side: 'local' | 'cloud'; +} +export interface RestorePreview { + previewId: string; + unconfirmedOperationId?: string | null; + observedRevision: string; + localGeneration: string; + counts: Record; + deviceSettingsToReview: string[]; + conflicts: Array<{ id: string; kind: string; reason: string }>; +} + +export interface EncryptedSyncEventScope { + sequence: string; + accountId: string | null; + vaultId: string | null; + taskId: string | null; +} +export interface EncryptedSyncEvent extends EncryptedSyncEventScope { + status: EncryptedSyncStatus; +} +export interface EncryptedSyncConflictEvent extends EncryptedSyncEventScope { + preview: RestorePreview; +} +export interface EncryptedSyncRestoreEvent extends EncryptedSyncEventScope { + localGeneration: string; + uiPreferences: Record; +} + +// Browser previews have neither a native vault nor a verified GitHub session. +// Never invent an enabled/unlocked/successful state in the preview adapter. +function unavailable(): never { + throw Object.assign(new Error('cloud_sync_e2ee_unavailable'), { + code: 'unsupported', + details: { reason: 'service_unavailable' }, + }); +} + +export const cloudSyncE2eeStatus = (): Promise => + invokeOrMock('cloud_sync_e2ee_status', undefined, unavailable); +export const cloudSyncE2eeClaimSetupPrompt = (): Promise => + invokeOrMock('cloud_sync_e2ee_claim_setup_prompt', undefined, unavailable); +export const cloudSyncE2eePrepareEnable = (consentVersion: string): Promise => + invokeOrMock('cloud_sync_e2ee_prepare_enable', { consentVersion }, unavailable); +export const cloudSyncE2eeCreate = (input: { + password: string; + passwordConfirmation: string; + rememberKey: boolean; + consentVersion: string; + observedRevision: string; +}): Promise => invokeOrMock('cloud_sync_e2ee_create', input, unavailable); +export const cloudSyncE2eeUnlock = (input: { + password: string; + rememberKey: boolean; +}): Promise => invokeOrMock('cloud_sync_e2ee_unlock', input, unavailable); +export const cloudSyncE2eeLock = (): Promise => + invokeOrMock('cloud_sync_e2ee_lock', undefined, unavailable); +export const cloudSyncE2eeSetEnabled = (enabled: boolean): Promise => + invokeOrMock('cloud_sync_e2ee_set_enabled', { enabled }, unavailable); +export const cloudSyncE2eeSyncNow = (): Promise => + invokeOrMock('cloud_sync_e2ee_sync_now', undefined, unavailable); +export const cloudSyncE2eeCancel = (taskId: string): Promise => + invokeOrMock('cloud_sync_e2ee_cancel', { taskId }, unavailable); +export const cloudSyncE2eePreviewRestore = (observedRevision: string): Promise => + invokeOrMock('cloud_sync_e2ee_preview_restore', { observedRevision }, unavailable); +export const cloudSyncE2eeApplyRestore = (input: { + previewId: string; + mode: 'replace' | 'merge'; + conflictChoices: SyncConflictChoice[]; +}): Promise => + invokeOrMock('cloud_sync_e2ee_apply_restore', input, unavailable); +export const cloudSyncE2eeChangePassword = (input: { + currentPassword: string; + newPassword: string; + confirmation: string; + rememberKey: boolean; +}): Promise => + invokeOrMock('cloud_sync_e2ee_change_password', input, unavailable); +export const cloudSyncE2eeDeleteRemote = (input: { + expectedVaultId: string; + observedRevision: string; + confirmed: boolean; +}): Promise => + invokeOrMock('cloud_sync_e2ee_delete_remote', input, unavailable); +export const cloudSyncE2eeSignOut = (): Promise => + invokeOrMock('cloud_sync_e2ee_sign_out', undefined, unavailable); +export const cloudSyncE2eeGetUiPreferences = (): Promise<{ + locale?: string; + fontScale?: string; +} | null> => invokeOrMock('cloud_sync_e2ee_get_ui_preferences', undefined, unavailable); + +export async function mirrorEncryptedSyncUiPreferences(): Promise { + if (!isTauri) unavailable(); + const { flushEncryptedSyncUiPreferences } = await import('../encryptedSyncUiBridge'); + await flushEncryptedSyncUiPreferences(); +} + +export function syncSequence(value: unknown): bigint | null { + if (typeof value !== 'string' || !/^(0|[1-9]\d{0,19})$/.test(value)) return null; + const sequence = BigInt(value); + return sequence <= 18446744073709551615n ? sequence : null; +} + +export function encryptedSyncScope(status: EncryptedSyncStatus): string { + return JSON.stringify([status.serviceOrigin, status.account?.githubId ?? null, status.vaultId]); +} + +/** State events establish task transitions; conflict/restore events must match it. */ +export function matchesEncryptedSyncEvent( + status: EncryptedSyncStatus, + event: EncryptedSyncEventScope, + watermark: string, + requireTask = true, +): boolean { + const incoming = syncSequence(event.sequence); + const previous = syncSequence(watermark); + return ( + incoming !== null && + previous !== null && + incoming > previous && + event.accountId === (status.account?.githubId ?? null) && + event.vaultId === status.vaultId && + (!requireTask || event.taskId === status.taskId) + ); +} + +// Only these value-free codes may influence UI text. Raw server messages, +// unknown detail fields and credential-bearing strings are never displayed. +const errorKeys = { + sign_in_required: 'signIn', + unlock_required: 'unlock', + sync_documents_locked: 'unlock', + service_unavailable: 'unavailable', + unsupported_protocol: 'unavailable', + weak_password: 'weakPassword', + password_confirmation_mismatch: 'passwordMismatch', + invalid_password_or_ciphertext: 'invalidPassword', + secure_storage_denied: 'secureStorage', + revision_conflict: 'changed', + cloud_deleted: 'changed', + conflict: 'reviewRequired', + stale_preview: 'changed', + sync_documents_source_changed: 'changed', + account_changed: 'accountChanged', + busy: 'busy', + runtime_busy: 'busy', + revision_rollback: 'invalidData', + cancelled: 'cancelled', + stale_task: 'changed', + transport_failed: 'network', + rate_limited: 'rateLimited', + service_failed: 'network', + outcome_unknown: 'outcomeUnknown', + recovery_required: 'recovery', + local_storage_unavailable: 'recovery', + sync_journal_unavailable: 'recovery', + sync_restore_rolled_back: 'rolledBack', + payload_too_large: 'tooLarge', + consent_required: 'reviewRequired', + restore_review_required: 'reviewRequired', + sync_conflict_choice_required: 'choicesRequired', + invalid_response: 'invalidData', + sync_documents_invalid: 'invalidData', + sync_documents_invalid_reference: 'invalidData', + sync_documents_duplicate_id: 'invalidData', + sync_documents_excluded_field: 'invalidData', + sync_documents_missing_tombstone: 'invalidData', + sync_documents_unsupported: 'unavailable', + sync_documents_capture_failed: 'localData', + secure_random_unavailable: 'localData', + crypto_worker_failed: 'localData', +} as const; +export type EncryptedSyncErrorKey = (typeof errorKeys)[keyof typeof errorKeys] | 'unknown'; + +export function encryptedSyncErrorKey(error: unknown): EncryptedSyncErrorKey { + if (!error || typeof error !== 'object') return 'unknown'; + const value = error as { message?: unknown; code?: unknown; details?: { reason?: unknown } }; + for (const reason of [value.details?.reason, value.code, value.message]) { + if (typeof reason === 'string' && Object.prototype.hasOwnProperty.call(errorKeys, reason)) { + return errorKeys[reason as keyof typeof errorKeys]; + } + } + return 'unknown'; +} + +/** Advisory form check only; Core owns normalization and the complete policy. */ +export function syncPasswordMeetsBasicRequirements(password: string): boolean { + const normalized = password.normalize('NFC'); + const length = Array.from(normalized).length; + return ( + length >= 12 && + length <= 128 && + /[a-z]/.test(normalized) && + /[A-Z]/.test(normalized) && + /[0-9]/.test(normalized) + ); +} diff --git a/openless-all/app/src/lib/ipc/index.ts b/openless-all/app/src/lib/ipc/index.ts index 971f257c8..00ccae5be 100644 --- a/openless-all/app/src/lib/ipc/index.ts +++ b/openless-all/app/src/lib/ipc/index.ts @@ -161,6 +161,8 @@ export { getQaHotkeyLabel, setQaHotkey, qaWindowDismiss, + qaWindowSetExpanded, + qaGetSnapshot, qaToggleRecording, qaSubmitText, qaSetEditInstructionMode, @@ -188,6 +190,10 @@ export { lessComputerApprove, lessComputerSubmitText, lessComputerSync, + lessComputerVoiceStart, + lessComputerVoiceStop, + lessComputerVoiceCancel, + lessComputerTaskCancel, } from './less-computer'; // chat-panel(QA / Less Computer 共用) diff --git a/openless-all/app/src/lib/ipc/less-computer.ts b/openless-all/app/src/lib/ipc/less-computer.ts index c28746896..edca507b8 100644 --- a/openless-all/app/src/lib/ipc/less-computer.ts +++ b/openless-all/app/src/lib/ipc/less-computer.ts @@ -1,5 +1,5 @@ import { invokeOrMock } from './shared'; -import type { LessComputerSyncResult } from '../types'; +import type { LessComputerSyncResult, LessComputerVoiceMode } from '../types'; /** 用户点 ✕ / 按 Esc 关闭 Less Computer 浮窗(隐藏窗口)。 */ export function lessComputerWindowDismiss(): Promise { @@ -21,6 +21,27 @@ export function lessComputerSubmitText(text: string): Promise { return invokeOrMock('less_computer_submit_text', { text }, () => undefined); } +/** 面板内开麦。dictate:转写只填进输入框;submit:说完直接交给 Agent(与快捷键一致)。 + * 启动失败(麦克风权限、其它语音会话占用)以 reject 返回,面板内联提示。 */ +export function lessComputerVoiceStart(mode: LessComputerVoiceMode): Promise { + return invokeOrMock('less_computer_voice_start', { mode }, () => undefined); +} + +/** 只结束指定录音并按其 mode 收尾;迟到请求不会停止后来开始的会话。 */ +export function lessComputerVoiceStop(sessionId: string): Promise { + return invokeOrMock('less_computer_voice_stop', { sessionId }, () => undefined); +} + +/** 只取消指定录音会话,不会波及其它会话或已在运行的任务。 */ +export function lessComputerVoiceCancel(sessionId: string): Promise { + return invokeOrMock('less_computer_voice_cancel', { sessionId }, () => undefined); +} + +/** 停止正在运行的 Agent 任务。 */ +export function lessComputerTaskCancel(): Promise { + return invokeOrMock('less_computer_task_cancel', undefined, () => undefined); +} + /** 浮窗 mount 时拉取当前会话的事件缓冲(seq 升序),重放 webview 冷加载期间 * 丢掉的事件(尤其首条 user —— 用户说的话)。 */ export function lessComputerSync(afterSequence: number): Promise { diff --git a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index cbc02a6ee..c63c7bf6e 100644 --- a/openless-all/app/src/lib/ipc/mock-provider-descriptors.json +++ b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json @@ -168,6 +168,22 @@ ], "validationProbe": "llm_text" }, + { + "authRequirement": "api_key_unless_custom_endpoint", + "defaultEndpoint": "https://router.requesty.ai/v1", + "defaultModel": "openai/gpt-4o-mini", + "defaultRequestFormat": "chat_completions", + "kind": "llm", + "labelKey": "requesty", + "providerType": "requesty", + "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], + "validationProbe": "llm_text" + }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://api.orcarouter.ai/v1", diff --git a/openless-all/app/src/lib/ipc/provider-descriptors.generated.json b/openless-all/app/src/lib/ipc/provider-descriptors.generated.json index 8cef02b82..cab5adbbe 100644 --- a/openless-all/app/src/lib/ipc/provider-descriptors.generated.json +++ b/openless-all/app/src/lib/ipc/provider-descriptors.generated.json @@ -500,6 +500,22 @@ ], "validationProbe": "llm_text" }, + { + "authRequirement": "api_key_unless_custom_endpoint", + "defaultEndpoint": "https://router.requesty.ai/v1", + "defaultModel": "openai/gpt-4o-mini", + "defaultRequestFormat": "chat_completions", + "kind": "llm", + "labelKey": "requesty", + "providerType": "requesty", + "staticModels": [], + "supportedRequestFormats": [ + "chat_completions", + "responses", + "messages" + ], + "validationProbe": "llm_text" + }, { "authRequirement": "api_key_unless_custom_endpoint", "defaultEndpoint": "https://api.orcarouter.ai/v1", diff --git a/openless-all/app/src/lib/ipc/qa.ts b/openless-all/app/src/lib/ipc/qa.ts index b560f166e..850e8b755 100644 --- a/openless-all/app/src/lib/ipc/qa.ts +++ b/openless-all/app/src/lib/ipc/qa.ts @@ -1,4 +1,4 @@ -import type { QaHotkeyBinding } from '../types'; +import type { QaHotkeyBinding, QaStatePayload } from '../types'; import { invokeOrMock } from './shared'; import { formatComboLabel, defaultQaShortcut } from '../hotkey'; @@ -16,12 +16,24 @@ export function qaWindowDismiss(): Promise { return invokeOrMock('qa_window_dismiss', undefined, () => undefined); } +export function qaWindowSetExpanded(expanded: boolean): Promise { + return invokeOrMock('qa_window_set_expanded', { expanded }, () => undefined); +} + export function qaToggleRecording(): Promise { return invokeOrMock('qa_toggle_recording', undefined, () => undefined); } -export function qaSubmitText(text: string): Promise { - return invokeOrMock('qa_submit_text', { text }, () => undefined); +export function qaSubmitText(text: string, expectedSessionId?: string | null): Promise { + return invokeOrMock( + 'qa_submit_text', + expectedSessionId === undefined ? { text } : { text, expectedSessionId, enforceContext: true }, + () => undefined, + ); +} + +export function qaGetSnapshot(): Promise { + return invokeOrMock('qa_get_snapshot', undefined, () => ({ kind: 'idle', messages: [] })); } export function qaSetEditInstructionMode(enabled: boolean): Promise { diff --git a/openless-all/app/src/lib/lessComputerComposer.test.ts b/openless-all/app/src/lib/lessComputerComposer.test.ts new file mode 100644 index 000000000..f9bd494d8 --- /dev/null +++ b/openless-all/app/src/lib/lessComputerComposer.test.ts @@ -0,0 +1,44 @@ +import { + claimDictationResult, + mergeDictation, + transcriptTail, + voiceHintKey, +} from './lessComputerComposer'; + +function assert(condition: unknown, name: string): asserts condition { + if (!condition) throw new Error(name); +} +assert.equal = (actual: unknown, expected: unknown, name = 'value') => { + if (actual !== expected) + throw new Error(`${name}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); +}; + +assert.equal(mergeDictation('', ' 打开设置 '), '打开设置', 'an empty draft takes the transcript'); +assert.equal( + mergeDictation('请帮我', '打开设置'), + '请帮我打开设置', + 'CJK text joins without a space', +); +assert.equal(mergeDictation('open', 'settings'), 'open settings', 'Latin words get one space'); +assert.equal(mergeDictation('open ', 'settings'), 'open settings', 'existing whitespace is kept'); +assert.equal(mergeDictation('first line\n', 'second'), 'first line\nsecond'); +assert.equal(mergeDictation('done', ', thanks'), 'done, thanks', 'punctuation attaches directly'); +assert.equal(mergeDictation('draft', ' '), 'draft', 'silence leaves the draft untouched'); + +assert.equal(transcriptTail(' hello world '), 'hello world'); +const long = '一二三四五六七八九十'.repeat(12); +const tail = transcriptTail(long, 20); +assert.equal(Array.from(tail).length, 21, 'tail keeps the requested characters plus an ellipsis'); +assert(tail.startsWith('…') && long.endsWith(tail.slice(1)), 'the newest words stay visible'); + +assert.equal(voiceHintKey('hold'), 'holdHint'); +assert.equal(voiceHintKey('toggle'), 'toggleHint'); +assert.equal(voiceHintKey('doubleClick'), 'toggleHint', 'double click behaves like one toggle'); +assert.equal(voiceHintKey('auto'), 'autoHint'); + +const session = `dictation-${Date.now()}`; +assert.equal(claimDictationResult(session), true, 'the first observer applies the result'); +assert.equal(claimDictationResult(session), false, 'replays and remounts never apply it twice'); +assert.equal(claimDictationResult(`${session}-next`), true, 'a new dictation is independent'); + +console.log('lessComputerComposer.test.ts passed'); diff --git a/openless-all/app/src/lib/lessComputerComposer.ts b/openless-all/app/src/lib/lessComputerComposer.ts new file mode 100644 index 000000000..694507a70 --- /dev/null +++ b/openless-all/app/src/lib/lessComputerComposer.ts @@ -0,0 +1,70 @@ +import type { HotkeyMode } from './types'; + +const CJK = /[\u2e80-\u303f\u3040-\u9fff\uac00-\ud7af\uf900-\ufaff\uff00-\uffef]/; +const CLOSING_PUNCTUATION = /^[,.!?;:%)\]},。!?;:、)」』》]/; + +/** Append a finished dictation to the draft; CJK text and punctuation never get an extra space. */ +export function mergeDictation(draft: string, transcript: string): string { + const addition = transcript.trim(); + if (!addition) return draft; + if (!draft.trim()) return addition; + const last = draft[draft.length - 1]; + if ( + /\s/.test(last) || + CJK.test(last) || + CJK.test(addition[0]) || + CLOSING_PUNCTUATION.test(addition) + ) + return draft + addition; + return `${draft} ${addition}`; +} + +/** Live transcript line: keep the most recent words visible inside a single composer row. */ +export function transcriptTail(text: string, maxChars = 96): string { + const normalized = text.replace(/\s+/g, ' ').trim(); + const chars = Array.from(normalized); + return chars.length <= maxChars ? normalized : `…${chars.slice(-maxChars).join('')}`; +} + +export type VoiceHintKey = 'holdHint' | 'toggleHint' | 'autoHint'; + +/** Less Computer shares the dictation hotkey mode; double-click behaves like a single toggle press. */ +export function voiceHintKey(mode: HotkeyMode): VoiceHintKey { + if (mode === 'hold') return 'holdHint'; + if (mode === 'auto') return 'autoHint'; + return 'toggleHint'; +} + +const CLAIM_KEY = 'ol.lc.dictation-claims'; +const MAX_CLAIMS = 32; +let claims: string[] | null = null; + +function loadClaims(): string[] { + if (claims) return claims; + try { + const parsed: unknown = JSON.parse(globalThis.sessionStorage?.getItem(CLAIM_KEY) ?? '[]'); + claims = Array.isArray(parsed) + ? parsed.filter((id): id is string => typeof id === 'string') + : []; + } catch { + claims = []; + } + return claims; +} + +/** + * The idle projection of a finished dictation is replayed on remount and after + * a WebView reload. Only the first observer may apply it to the draft. + */ +export function claimDictationResult(sessionId: string): boolean { + const list = loadClaims(); + if (list.includes(sessionId)) return false; + list.push(sessionId); + if (list.length > MAX_CLAIMS) list.splice(0, list.length - MAX_CLAIMS); + try { + globalThis.sessionStorage?.setItem(CLAIM_KEY, JSON.stringify(list)); + } catch { + /* in-memory claims still prevent duplicates for this WebView */ + } + return true; +} diff --git a/openless-all/app/src/lib/lessComputerReplay.test.ts b/openless-all/app/src/lib/lessComputerReplay.test.ts index 866558e62..f424a9744 100644 --- a/openless-all/app/src/lib/lessComputerReplay.test.ts +++ b/openless-all/app/src/lib/lessComputerReplay.test.ts @@ -1,5 +1,5 @@ import { reconcileLessComputerReplay, reduceLessComputerVoice } from './lessComputerReplay'; -import type { LessComputerEvent, LessComputerSyncResult } from './types'; +import type { LessComputerEvent, LessComputerSyncResult, LessComputerVoiceEvent } from './types'; import contract from '../../contract/backend-2.0.json'; function assertDeepEqual(actual: unknown, expected: unknown, name: string) { @@ -96,9 +96,27 @@ assertDeepEqual( phase: 'recording', level: 0.5, elapsedMs: 120, + mode: 'dictate', + transcript: '打开', }, 'React consumes the same serialized Core feedback fixture', ); +const liveDictation = contract.lessComputerVoice.sample as LessComputerVoiceEvent; +const committedDictation = contract.lessComputerVoice.idleSample as LessComputerVoiceEvent; +const settled = reduceLessComputerVoice( + reduceLessComputerVoice(null, liveDictation), + committedDictation, +); +assertDeepEqual( + settled?.kind === 'voice_state' ? [settled.phase, settled.outcome, settled.transcript] : null, + ['idle', 'committed', '打开设置'], + 'the terminal dictation snapshot carries the final transcript for the composer', +); +assertDeepEqual( + reduceLessComputerVoice(settled, { ...liveDictation, seq: 5, transcript: 'late partial' }), + settled, + 'a late partial cannot reopen a committed dictation', +); const truncated: LessComputerSyncResult = { events: [], diff --git a/openless-all/app/src/lib/lessComputerToolActivity.test.ts b/openless-all/app/src/lib/lessComputerToolActivity.test.ts new file mode 100644 index 000000000..b102301c7 --- /dev/null +++ b/openless-all/app/src/lib/lessComputerToolActivity.test.ts @@ -0,0 +1,63 @@ +import { groupToolActivities, toolActivityCategory } from './lessComputerToolActivity'; + +function equal(actual: unknown, expected: unknown, name: string) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) + throw new Error(`${name}: ${JSON.stringify(actual)} != ${JSON.stringify(expected)}`); +} + +for (const [name, category] of [ + ['Grep', 'search'], + ['mcp__files__read_file', 'read'], + ['Bash', 'command'], + ['apply_patch', 'edit'], + ['WebFetch', 'web'], + ['provider.some_unknown_tool', 'other'], +]) + equal(toolActivityCategory(name), category, name); + +const calls = [ + { name: 'Read', running: false }, + { name: 'Bash', running: false }, + { name: 'Bash', running: false }, + { name: 'Bash', running: true }, +]; +const grouped = groupToolActivities(calls, true); +equal( + grouped.map(({ category, state }) => [category, state]), + [ + ['read', 'finished'], + ['command', 'active'], + ], + 'consecutive phase grouping', +); +equal(grouped[1].names, [{ name: 'Bash', count: 3 }], 'dense actual calls retain their count'); +equal( + grouped.flatMap((group) => group.tools), + calls, + 'every real tool event remains available', +); +equal( + groupToolActivities(calls, false).map((group) => group.state), + ['finished', 'finished'], + 'completion removes all active states', +); +equal( + groupToolActivities(calls, false, true).map((group) => group.state), + ['finished', 'stopped'], + 'cancel or error cannot claim final step completed', +); +equal( + groupToolActivities( + [ + { name: 'Read', running: false }, + { name: 'Bash', running: false }, + { name: 'Read', running: true }, + ], + true, + ).length, + 3, + 'do not merge across different stages', +); +equal(groupToolActivities([], true), [], 'no invented activity before a real tool event'); +equal(calls[3].running, true, 'projection does not mutate replay state'); +console.log('lessComputerToolActivity.test.ts passed'); diff --git a/openless-all/app/src/lib/lessComputerToolActivity.ts b/openless-all/app/src/lib/lessComputerToolActivity.ts new file mode 100644 index 000000000..004513ceb --- /dev/null +++ b/openless-all/app/src/lib/lessComputerToolActivity.ts @@ -0,0 +1,86 @@ +/** Presentation categories inferred only from actual tool names, never arguments or filenames. */ +export type ToolActivityCategory = 'search' | 'read' | 'command' | 'edit' | 'web' | 'other'; +export type ToolActivityState = 'active' | 'finished' | 'stopped'; + +export interface ToolActivityEvent { + name: string; + running: boolean; +} + +export interface ToolActivityGroup { + category: ToolActivityCategory; + state: ToolActivityState; + tools: ToolActivityEvent[]; + names: { name: string; count: number }[]; +} + +const CATEGORIES: Record = { + grep: 'search', + glob: 'search', + search: 'search', + websearch: 'search', + searchquery: 'search', + findfiles: 'search', + searchfiles: 'search', + listdirectory: 'search', + listfiles: 'search', + ls: 'search', + read: 'read', + readfile: 'read', + readtextfile: 'read', + readmultiplefiles: 'read', + cat: 'read', + bash: 'command', + shell: 'command', + terminal: 'command', + execcommand: 'command', + runcommand: 'command', + executeshell: 'command', + run: 'command', + edit: 'edit', + write: 'edit', + writefile: 'edit', + applypatch: 'edit', + multiedit: 'edit', + strreplace: 'edit', + strreplaceeditor: 'edit', + webfetch: 'web', + fetch: 'web', + browse: 'web', + openurl: 'web', + browsernavigate: 'web', +}; + +export function toolActivityCategory(name: string): ToolActivityCategory { + const parts = name.trim().split(/__|[.:/]/); + const operation = (parts[parts.length - 1] ?? '').replace(/[\s_-]/g, '').toLowerCase(); + return CATEGORIES[operation] ?? 'other'; +} + +/** + * Consecutive calls in one category form a display step. "finished" means the + * activity stream advanced, not that a tool returned success; no result payload + * exists in this protocol. Terminal errors/cancellation stop the trailing step. + */ +export function groupToolActivities( + tools: readonly ToolActivityEvent[], + working: boolean, + interrupted = false, +): ToolActivityGroup[] { + const groups: ToolActivityGroup[] = []; + for (const tool of tools) { + const category = toolActivityCategory(tool.name); + let group = groups[groups.length - 1]; + if (!group || group.category !== category) { + group = { category, state: 'finished', tools: [], names: [] }; + groups.push(group); + } + group.tools.push(tool); + const previous = group.names[group.names.length - 1]; + if (previous?.name === tool.name) previous.count += 1; + else group.names.push({ name: tool.name, count: 1 }); + if (working && tool.running) group.state = 'active'; + } + if (!working && interrupted && groups.length > 0) groups[groups.length - 1].state = 'stopped'; + return groups; +} diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 880779cb3..56fa25a93 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -33,12 +33,7 @@ export type PolishMode = 'raw' | 'light' | 'structured' | 'formal'; * 两套配置在凭据库中完全隔离,运行时只读当前模式。 */ export type PipelineMode = 'traditional' | 'multimodal'; -export type InsertStatus = - | 'inserted' - | 'pasteSent' - | 'copiedFallback' - | 'failed' - | 'notRequested'; +export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed' | 'notRequested'; export type HistorySource = 'voice' | 'quick_note' | 'selection_polish' | 'selection_voice_edit'; @@ -655,6 +650,12 @@ export type LessComputerEvent = phase: 'starting' | 'recording' | 'transcribing' | 'idle'; level: number; elapsedMs: number; + /** 缺省按 submit 处理(旧事件与快捷键路径)。 */ + mode?: LessComputerVoiceMode; + /** 本会话迄今的完整转写,录音中随实时识别更新。 */ + transcript?: string; + /** 仅 idle 时出现,描述本次录音如何收尾。 */ + outcome?: LessComputerVoiceOutcome; } /** 一轮用户气泡(语音指令转写)。fresh=true 表示新会话(清空历史);否则追加为后续轮次。 */ | { kind: 'user'; text: string; fresh?: boolean } @@ -682,6 +683,11 @@ export type LessComputerEvent = export type LessComputerVoiceEvent = Extract; +/** submit:说完直接交给 Agent;dictate:转写填进输入框,由用户编辑后发送。 */ +export type LessComputerVoiceMode = 'submit' | 'dictate'; + +export type LessComputerVoiceOutcome = 'submitted' | 'committed' | 'empty' | 'failed' | 'cancelled'; + /** `less_computer_sync` 的有界 replay 结果。`truncated=true` 表示调用方的水位 * 已早于后端仍保留的最老事件,前端必须清空派生视图后再应用 `events`。 */ export interface LessComputerSyncResult { diff --git a/openless-all/app/src/main.tsx b/openless-all/app/src/main.tsx index 702ba5d0e..057a5fe64 100644 --- a/openless-all/app/src/main.tsx +++ b/openless-all/app/src/main.tsx @@ -5,8 +5,10 @@ import { SplashVideo } from './components/SplashVideo'; import { detectOS } from './components/WindowChrome'; import { i18nReady } from './i18n'; import { initThemeMode } from './lib/themeMode'; +import { installEncryptedSyncUiBridge } from './lib/encryptedSyncUiBridge'; import './styles/tokens.css'; import './styles/global.css'; +import './styles/overlays.css'; import type { OS } from './components/WindowChrome'; @@ -46,4 +48,7 @@ const renderApp = () => { }; // Mount only after the selected local language chunk is ready; avoid mixed-language startup. -void i18nReady.then(renderApp); +void i18nReady.then(async () => { + if (isMainWindow) await installEncryptedSyncUiBridge().catch(() => {}); + renderApp(); +}); diff --git a/openless-all/app/src/pages/LessComputerChrome.tsx b/openless-all/app/src/pages/LessComputerChrome.tsx new file mode 100644 index 000000000..461d7b385 --- /dev/null +++ b/openless-all/app/src/pages/LessComputerChrome.tsx @@ -0,0 +1,168 @@ +// Less Computer leaf components: avatars, per-bubble actions and the summary +// inspector. The inspector only aggregates the current turn; tool details stay +// in the conversation so no second activity feed is created. +import { useEffect, useRef, useState } from 'react'; +import type { useTranslation } from 'react-i18next'; +import { CheckIcon, ChevronsRightIcon, CopyIcon } from 'lucide-react'; +import { Tooltip } from '../components/Tooltip'; +import type { CodingAgentProviderId } from '../lib/types'; + +type Translate = ReturnType['t']; + +const AGENT_MARKS: Record = { + 'claude-code-cli': 'CC', + 'opencode-cli': 'OC', + 'codex-cli': 'Cx', + 'dsh-cli': 'ds', +}; + +export type RunTone = 'idle' | 'working' | 'waiting' | 'done' | 'error' | 'cancelled'; + +export function AgentAvatar({ + agentId, + size = 'md', + active = false, +}: { + agentId: CodingAgentProviderId | null; + size?: 'sm' | 'md' | 'lg'; + active?: boolean; +}) { + return ( + + ); +} + +export function CopyAction({ text, t }: { text: string; t: Translate }) { + const [copied, setCopied] = useState(false); + const timer = useRef(null); + useEffect( + () => () => { + if (timer.current != null) window.clearTimeout(timer.current); + }, + [], + ); + const copy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + if (timer.current != null) window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setCopied(false), 1400); + } catch { + /* clipboard unavailable: nothing was copied, so no confirmation */ + } + }; + const label = copied ? t('lessComputer.desktop.copied') : t('lessComputer.desktop.copy'); + return ( +
+ + + +
+ ); +} + +export interface InspectorSummary { + agentId: CodingAgentProviderId | null; + agentName: string | null; + statusLabel: string; + tone: RunTone; + toolCount: number; + pendingApprovals: number; + costUsd: number | null; + voiceHint: string | null; +} + +export function LessComputerInspector({ + open, + summary, + onClose, + t, +}: { + open: boolean; + summary: InspectorSummary; + onClose: () => void; + t: Translate; +}) { + const closeLabel = t('lessComputer.desktop.hideInspector'); + return ( + + ); +} diff --git a/openless-all/app/src/pages/LessComputerPanel.test.ts b/openless-all/app/src/pages/LessComputerPanel.test.ts new file mode 100644 index 000000000..a2502e135 --- /dev/null +++ b/openless-all/app/src/pages/LessComputerPanel.test.ts @@ -0,0 +1,913 @@ +// @ts-nocheck — Node-only handler replay; production UI remains strictly typed. +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +const app = process.argv[2] || process.cwd(); +const require = createRequire(resolve(app, 'package.json')); +const ts = require('typescript'); +const replayModule = await import( + new URL('file://' + resolve(app, 'src/lib/lessComputerReplay.ts')) +); +const activityModule = await import( + new URL('file://' + resolve(app, 'src/lib/lessComputerToolActivity.ts')) +); +const composerModule = await import( + new URL('file://' + resolve(app, 'src/lib/lessComputerComposer.ts')) +); +const source = readFileSync(resolve(app, 'src/pages/LessComputerPanel.tsx'), 'utf8'); +const parsed = ts.createSourceFile( + 'LessComputerPanel.tsx', + source, + ts.ScriptTarget.ES2020, + true, + ts.ScriptKind.TSX, +); +const names = parsed.statements + .filter(ts.isImportDeclaration) + .flatMap((node) => + node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings) + ? node.importClause.namedBindings.elements.map((item) => item.name.text) + : [], + ); +const body = parsed.statements + .filter((node) => !ts.isImportDeclaration(node)) + .map((node) => node.getFullText(parsed)) + .join('\n') + .replaceAll("import('@tauri-apps/api/event')", 'Promise.resolve({listen: __listen})') + .replaceAll( + "import('@tauri-apps/api/window')", + 'Promise.resolve({getCurrentWindow: __getWindow})', + ); +const compiled = ts.transpileModule(body, { + compilerOptions: { + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.CommonJS, + jsx: ts.JsxEmit.ReactJSX, + }, +}).outputText; +const factory = new Function( + ...names, + '__listen', + '__getWindow', + 'exports', + 'require', + `${compiled};return {LessComputerPanel,Composer,ApprovalCard,TurnView,ToolProcess,voiceTime};`, +); +const tick = () => new Promise((resolve) => setImmediate(resolve)); +const deferred = () => { + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + return { promise, resolve, reject }; +}; +const jsx = (type, props) => ({ type, props: props ?? {} }); +function nodes(value) { + if (Array.isArray(value)) return value.flatMap(nodes); + if (!value || typeof value !== 'object' || !value.props) return []; + return [value, ...nodes(value.props.children)]; +} +function find(tree, predicate) { + const result = nodes(tree).find(predicate); + assert(result, 'control exists'); + return result; +} +let active; +class Hooks { + slots = []; + cursor = 0; + pending = []; + state(initial) { + const index = this.cursor++; + this.slots[index] ??= { value: typeof initial === 'function' ? initial() : initial }; + return [ + this.slots[index].value, + (value) => { + this.slots[index].value = + typeof value === 'function' ? value(this.slots[index].value) : value; + }, + ]; + } + ref(initial) { + return this.state({ current: initial })[0]; + } + effect(fn, deps) { + const index = this.cursor++; + const prev = this.slots[index]; + if ( + prev && + deps && + prev.deps?.length === deps.length && + deps.every((v, i) => Object.is(v, prev.deps[i])) + ) + return; + const slot = { deps }; + this.slots[index] = slot; + this.pending.push(() => { + prev?.cleanup?.(); + slot.cleanup = fn(); + }); + } + render(fn) { + this.cursor = 0; + active = this; + const tree = fn(); + this.pending.splice(0).forEach((fn) => fn()); + return tree; + } + unmount() { + this.slots.forEach((slot) => slot.cleanup?.()); + } +} +function context(native, replay) { + const events = new Map(); + const keys = new Set(); + const calls = { + approve: [], + submit: [], + windows: [], + voiceStart: [], + voiceStop: [], + voiceCancel: [], + taskCancel: 0, + focus: 0, + }; + let approvalResult, submitResult, voiceStartResult; + globalThis.window = { + location: { search: '?window=less-computer&demo=1' }, + addEventListener: (name, fn) => { + if (name === 'keydown') keys.add(fn); + }, + removeEventListener: (name, fn) => { + if (name === 'keydown') keys.delete(fn); + }, + }; + const imports = Object.fromEntries(names.map((name) => [name, name])); + Object.assign(imports, { + useState: (initial) => active.state(initial), + useRef: (initial) => active.ref(initial), + useEffect: (fn, deps) => active.effect(fn, deps), + useTranslation: () => ({ t: (key) => key }), + useChatPanelLifecycle: () => ({ enterEpoch: 0, closing: false }), + useExitMount: (open) => ({ mounted: open, closing: false }), + applyThemeFromPreference: () => {}, + formatComboLabel: (binding) => binding.primary, + isTauri: native, + getSettings: async () => ({ codingAgentProvider: 'codex-cli' }), + marketplaceAuthStatus: async () => ({ signedIn: false }), + lessComputerSync: () => + replay?.promise ?? Promise.resolve({ events: [], latestSequence: 0, truncated: false }), + lessComputerApprove: async (...args) => { + calls.approve.push(args); + return approvalResult?.promise; + }, + lessComputerSubmitText: async (text) => { + calls.submit.push(text); + return submitResult?.promise; + }, + lessComputerWindowDismiss: async () => { + calls.windows.push('hide'); + }, + lessComputerVoiceStart: async (mode) => { + calls.voiceStart.push(mode); + return voiceStartResult?.promise; + }, + lessComputerVoiceStop: async (sessionId) => { + calls.voiceStop.push(sessionId); + }, + lessComputerVoiceCancel: async (sessionId) => { + calls.voiceCancel.push(sessionId); + }, + lessComputerTaskCancel: async () => { + calls.taskCancel += 1; + }, + chatPanelFocusKeyboard: async () => { + calls.focus += 1; + }, + ...replayModule, + ...activityModule, + ...composerModule, + }); + const api = factory( + ...names.map((n) => imports[n]), + async (name, fn) => { + const set = events.get(name) ?? new Set(); + set.add(fn); + events.set(name, set); + return () => set.delete(fn); + }, + () => ({ + minimize: async () => calls.windows.push('minimize'), + toggleMaximize: async () => calls.windows.push('maximize'), + }), + {}, + () => ({ jsx, jsxs: jsx, Fragment: 'fragment' }), + ); + const hooks = new Hooks(); + const render = () => hooks.render(api.LessComputerPanel); + const emit = (ev) => { + for (const fn of events.get('less-computer:event') ?? []) fn({ payload: ev }); + }; + return { + api, + hooks, + render, + emit, + calls, + events, + keys, + imports, + approval: (d) => { + approvalResult = d; + }, + submission: (d) => { + submitResult = d; + }, + voiceStartResult: (d) => { + voiceStartResult = d; + }, + turns: (tree) => + nodes(tree) + .filter((node) => node.type === api.TurnView) + .map((node) => node.props), + voice: (tree) => find(tree, (node) => node.type === api.Composer).props.voice, + }; +} +let passed = 0; +async function test(name, fn) { + await fn(); + console.log(`PASS ${name}`); + passed++; +} +await test('browser has no demo state and cannot submit, approve, open OAuth or control native windows', async () => { + const c = context(false); + let tree = c.render(); + assert.equal(c.turns(tree).length, 0); + const windowButtons = nodes(tree).filter( + (n) => n.type === 'button' && n.props.className?.startsWith('lc-window-'), + ); + for (const button of windowButtons) { + assert.equal(button.props.disabled, true); + await button.props.onClick(); + } + assert.deepEqual(c.calls.windows, []); + const hooks = new Hooks(); + let composer = hooks.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); + find(composer, (n) => n.type === 'textarea').props.onChange({ + currentTarget: { value: 'never execute' }, + }); + composer = hooks.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); + find(composer, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); + await tick(); + assert.deepEqual(c.calls.submit, []); + for (const button of nodes(composer).filter( + (n) => n.type === 'button' && n.props.className?.startsWith('lc-round'), + )) { + assert.equal(button.props.disabled, true, 'browser previews cannot open the microphone'); + await button.props.onClick?.(); + } + assert.deepEqual(c.calls.voiceStart, []); + find(tree, (n) => n.type === 'button' && n.props.className === 'lc-github').props.onClick(); + tree = c.render(); + assert(!nodes(tree).some((n) => n.type === 'GithubLoginModal')); + c.hooks.unmount(); + assert.equal(c.keys.size, 0); +}); +await test('replay/live dedup, ordered deltas and fresh session reset', async () => { + const replay = deferred(), + c = context(true, replay); + c.render(); + await tick(); + c.emit({ kind: 'tool', name: 'Read', seq: 2 }); + c.emit({ kind: 'delta', text: 'A', seq: 3 }); + replay.resolve({ + events: [ + { kind: 'user', text: 'task', fresh: true, seq: 1 }, + { kind: 'tool', name: 'Read', seq: 2 }, + ], + latestSequence: 2, + truncated: false, + }); + await tick(); + let turns = c.turns(c.render()); + assert.equal(turns.length, 1); + assert.equal(turns[0].turn.user, 'task'); + assert.deepEqual(turns[0].turn.segments, [ + { kind: 'tool', name: 'Read', running: false }, + { kind: 'text', content: 'A' }, + ]); + c.emit({ kind: 'delta', text: 'duplicate', seq: 3 }); + c.emit({ kind: 'delta', text: 'B', seq: 4 }); + turns = c.turns(c.render()); + assert.equal(turns[0].turn.segments[1].content, 'AB'); + c.emit({ kind: 'user', text: 'new', fresh: true, seq: 5 }); + turns = c.turns(c.render()); + assert.equal(turns.length, 1); + assert.equal(turns[0].turn.user, 'new'); + c.hooks.unmount(); + assert.equal( + [...c.events.values()].reduce((a, s) => a + s.size, 0), + 0, + ); +}); +await test('cold missing-user stream self-heals; completed text fallback preserves tools and real cost', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'tool', name: 'Read', seq: 1 }); + c.emit({ kind: 'completed', text: 'result', costUsd: 0.005, seq: 2 }); + const turn = c.turns(c.render())[0].turn; + assert.equal(turn.user, ''); + assert.deepEqual(turn.segments, [ + { kind: 'tool', name: 'Read', running: false }, + { kind: 'text', content: 'result' }, + ]); + assert.equal(turn.costUsd, 0.005); + assert.equal(turn.status, 'done'); + c.hooks.unmount(); +}); +await test('approval waits for IPC, rejects duplicate clicks, retains retry on failure, confirms only success', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); + c.emit({ kind: 'approval', token: 'one', command: 'fixture', reason: 'fixture', seq: 2 }); + let turn = c.turns(c.render())[0]; + let d = deferred(); + c.approval(d); + const first = turn.onApproval('one', true); + void turn.onApproval('one', false); + turn = c.turns(c.render())[0]; + assert.equal(c.calls.approve.length, 1); + assert.equal(turn.turn.segments[0].pending, true); + assert.equal(turn.turn.segments[0].decision, undefined); + d.reject(new Error('fixture rejection')); + await first; + turn = c.turns(c.render())[0]; + assert.equal(turn.turn.segments[0].pending, false); + assert.equal(turn.turn.segments[0].failed, true); + assert.equal(turn.turn.segments[0].decision, undefined); + d = deferred(); + c.approval(d); + const second = turn.onApproval('one', true); + d.resolve(); + await second; + turn = c.turns(c.render())[0]; + assert.equal(turn.turn.segments[0].decision, 'approved'); + assert.equal(turn.turn.segments[0].failed, false); + c.hooks.unmount(); +}); +await test('late approval from old turn cannot mutate a fresh session or remove its pending request', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'user', text: 'old', fresh: true, seq: 1 }); + c.emit({ kind: 'approval', token: 'same', command: 'old', reason: 'x', seq: 2 }); + let d = deferred(); + c.approval(d); + const old = c.turns(c.render())[0].onApproval('same', true); + c.emit({ kind: 'user', text: 'new', fresh: true, seq: 3 }); + c.emit({ kind: 'approval', token: 'same', command: 'new', reason: 'x', seq: 4 }); + const newer = deferred(); + c.approval(newer); + let turn = c.turns(c.render())[0]; + const current = turn.onApproval('same', false); + d.resolve(); + await old; + turn = c.turns(c.render())[0]; + assert.equal(turn.turn.segments[0].decision, undefined); + assert.equal(turn.turn.segments[0].pending, true); + void turn.onApproval('same', true); + assert.equal(c.calls.approve.length, 2); + newer.resolve(); + await current; + turn = c.turns(c.render())[0]; + assert.equal(turn.turn.segments[0].decision, 'denied'); + c.hooks.unmount(); +}); +await test('terminal cancellation blocks approvals and removes tool running indicator', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); + c.emit({ kind: 'tool', name: 'Read', seq: 2 }); + c.emit({ kind: 'approval', token: 'old', command: 'fixture', reason: 'x', seq: 3 }); + c.emit({ kind: 'cancelled', seq: 4 }); + let turn = c.turns(c.render())[0]; + await turn.onApproval('old', true); + assert.equal(c.calls.approve.length, 0); + assert.equal(turn.turn.segments[0].running, false); + assert.equal(turn.turn.status, 'cancelled'); + c.hooks.unmount(); +}); +await test('voice projection preserves session ownership and exposes only actual level/phase/elapsed', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ + kind: 'voice_state', + sessionId: 'a', + phase: 'recording', + level: 0.5, + elapsedMs: 1000, + seq: 1, + }); + c.emit({ + kind: 'voice_state', + sessionId: 'b', + phase: 'starting', + level: 0, + elapsedMs: 0, + seq: 2, + }); + c.emit({ kind: 'voice_state', sessionId: 'a', phase: 'idle', level: 0, elapsedMs: 5000, seq: 3 }); + assert.equal(c.voice(c.render()).sessionId, 'b'); + c.emit({ + kind: 'voice_state', + sessionId: 'b', + phase: 'recording', + level: 0.7, + elapsedMs: 65000, + seq: 4, + }); + const props = find(c.render(), (n) => n.type === c.api.Composer).props; + const hooks = new Hooks(); + const tree = hooks.render(() => c.api.Composer(props)); + const waveform = find(tree, (n) => n.type === 'LiveWaveform'); + assert.equal(waveform.props.level, 0.7); + assert.equal(waveform.props.processing, false); + assert.equal(find(tree, (n) => n.type === 'time').props.children, '1:05'); + assert(find(tree, (n) => n.type === 'textarea').props.disabled); + assert.equal(c.api.voiceTime(NaN), '0:00'); + assert.equal(c.api.voiceTime(-8), '0:00'); + c.hooks.unmount(); +}); +await test('composer IME/229/Shift+Enter guards; one pending send; RPC failure retains draft', async () => { + const c = context(true); + const h = new Hooks(); + const render = () => h.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); + let tree = render(); + find(tree, (n) => n.type === 'textarea').props.onChange({ currentTarget: { value: '你好' } }); + tree = render(); + let input = find(tree, (n) => n.type === 'textarea'); + const key = (overrides = {}) => ({ + key: 'Enter', + shiftKey: false, + keyCode: 13, + nativeEvent: { isComposing: false }, + preventDefault() { + this.prevented = true; + }, + ...overrides, + }); + input.props.onCompositionStart(); + input.props.onKeyDown(key()); + find(tree, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); + input.props.onCompositionEnd(); + input.props.onKeyDown(key({ nativeEvent: { isComposing: true } })); + input.props.onKeyDown(key({ keyCode: 229 })); + let shift = key({ shiftKey: true }); + input.props.onKeyDown(shift); + assert.equal(shift.prevented, undefined); + assert.equal(c.calls.submit.length, 0); + let d = deferred(); + c.submission(d); + input.props.onKeyDown(key()); + input.props.onKeyDown(key()); + assert.equal(c.calls.submit.length, 1); + tree = render(); + assert(find(tree, (n) => n.type === 'button' && n.props.type === 'submit').props.disabled); + d.reject(new Error('fixture')); + await tick(); + tree = render(); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, '你好'); + assert(nodes(tree).some((n) => n.props.role === 'alert')); + d = deferred(); + c.submission(d); + find(tree, (n) => n.type === 'textarea').props.onKeyDown(key()); + tree = render(); + find(tree, (n) => n.type === 'textarea').props.onChange({ + currentTarget: { value: 'next draft' }, + }); + d.resolve(); + await tick(); + tree = render(); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'next draft'); +}); +await test('native window actions use hide/minimize/toggleMaximize; Escape closes OAuth without hiding panel', async () => { + const c = context(true); + let tree = c.render(); + await tick(); + for (const button of nodes(tree).filter( + (n) => n.type === 'button' && n.props.className?.startsWith('lc-window-'), + )) + await button.props.onClick(); + await tick(); + assert.deepEqual(c.calls.windows, ['hide', 'minimize', 'maximize']); + find(tree, (n) => n.type === 'button' && n.props.className === 'lc-github').props.onClick(); + tree = c.render(); + assert(nodes(tree).some((n) => n.type === 'GithubLoginModal')); + let prevented = false, + stopped = false; + for (const key of c.keys) + key({ + key: 'Escape', + isComposing: false, + keyCode: 27, + preventDefault() { + prevented = true; + }, + stopPropagation() { + stopped = true; + }, + }); + tree = c.render(); + assert.equal(prevented, true); + assert.equal(stopped, true); + assert(!nodes(tree).some((n) => n.type === 'GithubLoginModal')); + assert.equal(c.calls.windows.length, 3); + for (const key of c.keys) + key({ + key: 'Escape', + isComposing: true, + keyCode: 229, + preventDefault() { + throw new Error('IME Escape must be left alone'); + }, + }); + assert.equal(c.calls.windows.length, 3); + c.hooks.unmount(); +}); +await test('tool activity is collapsed, grouped and loses shimmer on every terminal state', async () => { + const c = context(false); + const tools = [ + { kind: 'tool', name: 'Read', running: false }, + { kind: 'tool', name: 'Bash', running: false }, + { kind: 'tool', name: 'Bash', running: true }, + ]; + const hooks = new Hooks(); + const renderActive = () => + hooks.render(() => + c.api.ToolProcess({ tools, working: true, interrupted: false, t: (k) => k }), + ); + let active = renderActive(); + const summary = () => find(active, (n) => n.props.className === 'lc-tool-summary'); + assert.equal(summary().props['aria-expanded'], false, 'tool activity starts collapsed'); + assert.equal( + find(active, (n) => n.props.className === 'lc-tool-body').props['aria-hidden'], + true, + ); + summary().props.onClick(); + active = renderActive(); + assert.equal(summary().props['aria-expanded'], true); + assert(active.props.className.includes('is-open'), 'expansion animates via the open class'); + assert.equal( + nodes(active).filter((n) => n.props.className?.startsWith('lc-process-step is-')).length, + 2, + ); + assert(nodes(active).some((n) => n.props.children === 'lessComputer.activity.commandRunning')); + assert(nodes(active).some((n) => n.props.className === 'lc-process-label is-running')); + assert(nodes(active).some((n) => n.props.className === 'lc-process-phase is-running')); + assert(nodes(active).some((n) => n.props.children === 'Read')); + assert(nodes(active).some((n) => n.props.children === 'Bash')); + for (const interrupted of [false, true]) { + const ended = new Hooks().render(() => + c.api.ToolProcess({ tools, working: false, interrupted, t: (k) => k }), + ); + assert(!nodes(ended).some((n) => n.props.className?.includes('is-running'))); + assert.equal( + nodes(ended).some((n) => n.props.className === 'lc-process-step is-stopped'), + interrupted, + ); + } +}); +await test('approval stays directly visible between separate folded tool blocks', async () => { + const c = context(false); + const tree = c.api.TurnView({ + index: 0, + actionable: true, + onApproval() {}, + t: (k) => k, + turn: { + user: 'fixture', + status: 'working', + errorMsg: '', + costUsd: null, + segments: [ + { kind: 'tool', name: 'Read', running: false }, + { + kind: 'approval', + token: 'real-token', + command: 'fixture command', + reason: 'fixture reason', + }, + { kind: 'tool', name: 'Bash', running: true }, + ], + }, + }); + assert.equal(nodes(tree).filter((n) => n.type === c.api.ToolProcess).length, 2); + assert.equal(nodes(tree).filter((n) => n.type === c.api.ApprovalCard).length, 1); +}); +await test('window controls lead the sidebar and the inspector only summarizes the turn', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); + c.emit({ kind: 'tool', name: 'Read', seq: 2 }); + c.emit({ kind: 'tool', name: 'Bash', seq: 3 }); + c.emit({ kind: 'approval', token: 'one', command: 'rm -rf build', reason: 'x', seq: 4 }); + const tree = c.render(); + const shell = find(tree, (n) => n.props.className?.startsWith('lc-desktop')); + const sidebar = shell.props.children.find(Boolean); + assert.equal(sidebar.type, 'aside'); + const head = sidebar.props.children.find(Boolean); + assert.equal(head.props.className, 'lc-sidebar-head'); + assert.equal(head.props.children[0].props.className, 'lc-window-controls'); + assert(!nodes(tree).some((n) => n.type === 'AgentBuddy')); + assert(!nodes(tree).some((n) => n.props.className === 'lc-activity')); + const inspector = find(tree, (n) => n.type === 'LessComputerInspector'); + assert.deepEqual( + { + tone: inspector.props.summary.tone, + toolCount: inspector.props.summary.toolCount, + pendingApprovals: inspector.props.summary.pendingApprovals, + }, + { tone: 'waiting', toolCount: 2, pendingApprovals: 1 }, + ); + const serialized = JSON.stringify(inspector.props.summary); + assert(!serialized.includes('Read') && !serialized.includes('rm -rf'), 'no second activity feed'); + const current = find(tree, (n) => n.type === 'li' && n.props['aria-current'] === 'true'); + assert(nodes(current).some((n) => n.props.children === 'Codex')); + c.hooks.unmount(); +}); +await test('finished dictation lands in the draft exactly once; silence shows a notice', async () => { + const c = context(true); + const committed = { + kind: 'voice_state', + sessionId: `dictation-${Date.now()}`, + phase: 'idle', + level: 0, + elapsedMs: 900, + mode: 'dictate', + transcript: 'open settings', + outcome: 'committed', + seq: 9, + }; + const h = new Hooks(); + const render = (voice) => h.render(() => c.api.Composer({ working: false, voice, t: (k) => k })); + let tree = render(null); + find(tree, (n) => n.type === 'textarea').props.onChange({ currentTarget: { value: 'please' } }); + render(committed); + tree = render(committed); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'please open settings'); + tree = render({ ...committed }); + assert.equal( + find(tree, (n) => n.type === 'textarea').props.value, + 'please open settings', + 'a replayed idle snapshot is not inserted twice', + ); + const remount = new Hooks(); + tree = remount.render(() => c.api.Composer({ working: false, voice: committed, t: (k) => k })); + tree = remount.render(() => c.api.Composer({ working: false, voice: committed, t: (k) => k })); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, ''); + const silent = { + ...committed, + sessionId: `${committed.sessionId}-silent`, + transcript: '', + outcome: 'empty', + }; + const quiet = new Hooks(); + quiet.render(() => c.api.Composer({ working: false, voice: silent, t: (k) => k })); + tree = quiet.render(() => c.api.Composer({ working: false, voice: silent, t: (k) => k })); + assert( + nodes(tree).some( + (n) => n.props.role === 'alert' && n.props.children === 'lessComputer.voice.empty', + ), + ); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, ''); +}); +await test('composer voice controls call their own commands and report start failures inline', async () => { + const c = context(true); + const h = new Hooks(); + const props = { working: false, voice: null, t: (k) => k }; + const render = (overrides = {}) => h.render(() => c.api.Composer({ ...props, ...overrides })); + let tree = render(); + const byClass = (cls) => + find(tree, (n) => n.type === 'button' && n.props.className?.split(' ').includes(cls)); + byClass('lc-mic').props.onClick(); + await tick(); + tree = render(); + byClass('lc-voice').props.onClick(); + await tick(); + assert.deepEqual(c.calls.voiceStart, ['dictate', 'submit']); + const failure = deferred(); + c.voiceStartResult(failure); + tree = render(); + byClass('lc-mic').props.onClick(); + tree = render(); + assert.equal(byClass('lc-mic').props.disabled, true, 'one start request at a time'); + byClass('lc-mic').props.onClick(); + assert.equal(c.calls.voiceStart.length, 3, 'a second click while starting is ignored'); + failure.reject(new Error('mic denied')); + await tick(); + tree = render(); + assert( + nodes(tree).some( + (n) => n.props.role === 'alert' && n.props.children === 'lessComputer.voice.startFailed', + ), + ); + c.voiceStartResult(undefined); + + tree = render({ working: true }); + await byClass('lc-stop').props.onClick(); + assert.equal(c.calls.taskCancel, 1); + + const recording = { + kind: 'voice_state', + sessionId: 'live', + phase: 'recording', + level: 0.4, + elapsedMs: 1200, + mode: 'dictate', + transcript: 'hello wor', + }; + tree = render({ voice: recording }); + assert( + nodes(tree).some((n) => n.props.children === 'hello wor'), + 'live transcript is visible', + ); + const stage = find(tree, (n) => n.props.className === 'lc-voice-stage'); + const [cancel, confirm] = nodes(stage).filter((n) => n.type === 'button'); + cancel.props.onClick(); + confirm.props.onClick(); + assert.deepEqual(c.calls.voiceCancel, ['live']); + assert.deepEqual(c.calls.voiceStop, ['live'], 'confirm stops only the displayed recording'); + tree = render({ voice: { ...recording, phase: 'transcribing' } }); + const [, finishing] = nodes(find(tree, (n) => n.props.className === 'lc-voice-stage')).filter( + (n) => n.type === 'button', + ); + assert.equal(finishing.props.disabled, true, 'a finishing capture cannot be stopped twice'); + tree = render({ voice: { ...recording, sessionId: 'next-recording' } }); + confirm.props.onClick(); + const nextConfirm = nodes(find(tree, (n) => n.props.className === 'lc-voice-stage')).filter( + (n) => n.type === 'button', + )[1]; + nextConfirm.props.onClick(); + assert.deepEqual( + c.calls.voiceStop, + ['live', 'live', 'next-recording'], + 'a delayed control keeps its original session ID instead of targeting the next capture', + ); +}); +await test('voice startup and text submission exclude each other before React rerenders', async () => { + const c = context(true); + const h = new Hooks(); + const render = () => h.render(() => c.api.Composer({ working: false, voice: null, t: (k) => k })); + let tree = render(); + find(tree, (n) => n.type === 'textarea').props.onChange({ + currentTarget: { value: 'keep this draft' }, + }); + tree = render(); + const mic = find(tree, (n) => n.props.className === 'lc-round lc-mic'); + const input = find(tree, (n) => n.type === 'textarea'); + const form = find(tree, (n) => n.type === 'form'); + const pending = deferred(); + c.voiceStartResult(pending); + mic.props.onClick(); + input.props.onKeyDown({ + key: 'Enter', + shiftKey: false, + keyCode: 13, + nativeEvent: { isComposing: false }, + preventDefault() {}, + }); + form.props.onSubmit({ preventDefault() {} }); + await tick(); + assert.deepEqual(c.calls.submit, [], 'same-render send cannot race an accepted microphone start'); + tree = render(); + assert.equal( + find(tree, (n) => n.type === 'button' && n.props.type === 'submit').props.disabled, + true, + ); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'keep this draft'); + pending.reject(new Error('fixture microphone failed')); + await tick(); + tree = render(); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'keep this draft'); + + const sending = deferred(); + c.submission(sending); + const currentMic = find(tree, (n) => n.props.className === 'lc-round lc-mic'); + find(tree, (n) => n.type === 'form').props.onSubmit({ preventDefault() {} }); + currentMic.props.onClick(); + assert.deepEqual( + c.calls.voiceStart, + ['dictate'], + 'pending text send also blocks same-render microphone start', + ); + sending.resolve(); + await tick(); + h.unmount(); +}); +await test('dictation updates a blocked composer without stealing DOM or native focus', async () => { + const c = context(true); + const h = new Hooks(); + let domFocus = 0; + const render = (voice, focusAllowed) => + h.render(() => c.api.Composer({ working: false, voice, focusAllowed, t: (k) => k })); + let tree = render(null, false); + const input = find(tree, (n) => n.type === 'textarea'); + input.props.ref.current = { + style: {}, + scrollHeight: 36, + value: 'fixture dictation', + focus() { + domFocus += 1; + }, + setSelectionRange() {}, + }; + input.props.onFocus(); + input.props.onPointerDown(); + assert.equal(c.calls.focus, 0, 'blocked input handlers cannot request native focus either'); + const terminal = { + kind: 'voice_state', + sessionId: `focus-blocked-${Date.now()}`, + phase: 'idle', + mode: 'dictate', + outcome: 'committed', + transcript: 'fixture dictation', + level: 0, + elapsedMs: 500, + }; + render(terminal, false); + tree = render(terminal, false); + assert.equal(find(tree, (n) => n.type === 'textarea').props.value, 'fixture dictation'); + assert.equal(domFocus, 0); + assert.equal(c.calls.focus, 0); + render(terminal, true); + assert.equal(domFocus, 0, 'closing a dialog does not replay an old focus request'); + assert.equal(c.calls.focus, 0); + const next = { ...terminal, sessionId: `${terminal.sessionId}-next`, transcript: 'next' }; + render(next, true); + render(next, true); + assert.equal(domFocus, 1, 'a subsequent foreground dictation still focuses the input'); + assert.equal(c.calls.focus, 1); + h.unmount(); +}); +await test('an open login dialog disables composer focus', async () => { + const c = context(true); + let tree = c.render(); + assert.equal(find(tree, (n) => n.type === c.api.Composer).props.focusAllowed, true); + find(tree, (n) => n.props.className === 'lc-github').props.onClick(); + tree = c.render(); + assert.equal(find(tree, (n) => n.type === c.api.Composer).props.focusAllowed, false); + c.hooks.unmount(); +}); +await test('abandoned captures never relabel a finished turn; capture errors get their own row', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ kind: 'user', text: 'task', fresh: true, seq: 1 }); + c.emit({ kind: 'completed', text: 'answer', costUsd: null, seq: 2 }); + c.emit({ kind: 'cancelled', seq: 3 }); + let turns = c.turns(c.render()); + assert.equal(turns.length, 1); + assert.equal(turns[0].turn.status, 'done'); + c.emit({ kind: 'error', message: 'Less Computer voice input failed. Please try again.', seq: 4 }); + turns = c.turns(c.render()); + assert.equal(turns.length, 2); + assert.equal(turns[0].turn.status, 'done'); + assert.equal(turns[1].turn.status, 'error'); + assert.equal(turns[1].turn.user, ''); + c.hooks.unmount(); +}); +await test('Escape during a recording cancels only that recording', async () => { + const c = context(true); + c.render(); + await tick(); + c.emit({ + kind: 'voice_state', + sessionId: 'rec', + phase: 'recording', + level: 0.2, + elapsedMs: 300, + mode: 'submit', + seq: 1, + }); + c.render(); + for (const key of c.keys) + key({ + key: 'Escape', + isComposing: false, + keyCode: 27, + preventDefault() {}, + stopPropagation() {}, + }); + await tick(); + assert.deepEqual(c.calls.voiceCancel, ['rec']); + assert.deepEqual(c.calls.windows, []); + c.hooks.unmount(); +}); +console.log(`${passed} actual-component behavior tests passed`); diff --git a/openless-all/app/src/pages/LessComputerPanel.tsx b/openless-all/app/src/pages/LessComputerPanel.tsx index 77ac390f6..9eeb1d3ed 100644 --- a/openless-all/app/src/pages/LessComputerPanel.tsx +++ b/openless-all/app/src/pages/LessComputerPanel.tsx @@ -1,26 +1,29 @@ -// LessComputerPanel.tsx — Less Computer 语音 Agent 浮窗(窗口 label = "less-computer")。 -// -// 结构 = 官方 shadcn base 组件文档 message-scroller-demo **同款骨架**: -// MessageScrollerProvider → Card(CardHeader 标题/副行/CardAction ✕ → -// CardContent(p-0) 内 MessageScroller / Empty 空状态 → CardFooter 内 -// InputGroup 输入组)。组件源码 1:1 来自官方 registry(components/chat/ui/, -// 仅按 CLI 规则改 import 路径),行为来自 @shadcn/react 官方 primitive。 -// -// 「电脑操控」形态:不带头像 —— 用户指令 = Bubble align="end"(官方 bubble-demo -// 同款);工具调用 = Marker + Spinner/✓ + shimmer(官方 marker-demo 同款); -// 上下文压缩 = Marker separator;思考 = 与转译胶囊一模一样的 SiriGL 流体圆点。 -// -// 事件流:`user`(fresh=true 清空重开)→ delta/tool/compaction/approval 交错 → -// completed(落成本)/ error / cancelled。浮窗首次创建时后端事件可能先于 -// listener 注册到达(webview 冷加载),丢掉 user 事件后其余事件必须自愈补轮, -// 不能对空轮次静默丢弃(真机「后端在跑、前端一片空白」的根因)。 -// -// 窗口固定尺寸(Rust 侧创建即定死 420×540),内容只在滚动框内滚动。 -// 关闭:Esc / ✕ → less_computer_window_dismiss → 后端隐藏窗口。 - +// Less Computer desktop workspace. The event replay and voice projection remain +// authoritative; history/multi-session placeholders never invent executable state. import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { ArrowUpIcon, CheckIcon, MessageCircleDashedIcon, XIcon } from 'lucide-react'; +import { + ArrowUpIcon, + AudioLinesIcon, + CheckIcon, + ChevronRightIcon, + CircleAlertIcon, + FileTextIcon, + GlobeIcon, + LayersIcon, + Maximize2Icon, + MicIcon, + MinusIcon, + PanelRightOpenIcon, + PencilLineIcon, + SearchIcon, + ShieldCheckIcon, + SquareIcon, + SquarePenIcon, + TerminalIcon, + WrenchIcon, + XIcon, +} from 'lucide-react'; import { MessageScroller, MessageScrollerButton, @@ -29,47 +32,68 @@ import { MessageScrollerProvider, MessageScrollerViewport, } from '../components/chat/ui/message-scroller'; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from '../components/chat/ui/card'; -import { - Empty, - EmptyDescription, - EmptyHeader, - EmptyMedia, - EmptyTitle, -} from '../components/chat/ui/empty'; -import { - InputGroup, - InputGroupAddon, - InputGroupButton, - InputGroupInput, -} from '../components/chat/ui/input-group'; -import { Marker, MarkerContent, MarkerIcon } from '../components/chat/ui/marker'; -import { Bubble, BubbleContent } from '../components/chat/ui/bubble'; -import { Button } from '../components/chat/ui/button'; -import { Spinner } from '../components/chat/ui/spinner'; -import { ThinkingOrb } from '../components/chat/avatars'; +import { LiveWaveform } from '../components/chat/LiveWaveform'; import { AssistantMarkdown } from '../components/chat/markdown'; import { useChatPanelLifecycle } from '../components/chat/lifecycle'; -import { cn } from '../components/chat/lib/utils'; +import { GithubLoginModal } from '../components/GithubLoginModal'; +import { Tooltip } from '../components/Tooltip'; import { chatPanelFocusKeyboard, + getSettings, isTauri, lessComputerApprove, lessComputerSubmitText, lessComputerSync, + lessComputerTaskCancel, + lessComputerVoiceCancel, + lessComputerVoiceStart, + lessComputerVoiceStop, lessComputerWindowDismiss, + marketplaceAuthStatus, } from '../lib/ipc'; import { reconcileLessComputerReplay, reduceLessComputerVoice } from '../lib/lessComputerReplay'; -import type { LessComputerEvent, LessComputerVoiceEvent } from '../lib/types'; -import '../components/chat/chat.css'; +import { + claimDictationResult, + mergeDictation, + transcriptTail, + voiceHintKey, +} from '../lib/lessComputerComposer'; +import { formatComboLabel } from '../lib/hotkey'; +import { applyThemeFromPreference } from '../lib/themeMode'; +import { useExitMount } from '../lib/useExitMount'; +import type { + CodingAgentProviderId, + HotkeyMode, + LessComputerEvent, + LessComputerVoiceEvent, + LessComputerVoiceMode, + UserPreferences, +} from '../lib/types'; +import { groupToolActivities, toolActivityCategory } from '../lib/lessComputerToolActivity'; +import { AgentAvatar, CopyAction, LessComputerInspector, type RunTone } from './LessComputerChrome'; +import './less-computer-panel.css'; + +type Translate = ReturnType['t']; +const AGENTS: { id: CodingAgentProviderId; name: string }[] = [ + { id: 'claude-code-cli', name: 'Claude Code' }, + { id: 'opencode-cli', name: 'OpenCode' }, + { id: 'codex-cli', name: 'Codex' }, + { id: 'dsh-cli', name: 'dsh' }, +]; + +const CATEGORY_ICONS = { + search: SearchIcon, + read: FileTextIcon, + command: TerminalIcon, + edit: PencilLineIcon, + web: GlobeIcon, + other: WrenchIcon, +}; + +const INSPECTOR_KEY = 'ol.lc.inspector'; +const NARROW_QUERY = '(max-width: 899px)'; +const LOGIN_EXIT_MS = 180; +const MAX_INPUT_HEIGHT = 168; type RunStatus = 'idle' | 'working' | 'done' | 'error' | 'cancelled'; @@ -81,7 +105,7 @@ interface TextSegment { interface ToolSegment { kind: 'tool'; name: string; - /** 后端没有工具结束事件:下一个事件(delta/tool/approval/收尾)到达即视为结束。 */ + /** 后端没有工具结束事件:下一个事件到达时仅停止活动指示,不推断工具成功。 */ running: boolean; } @@ -90,7 +114,9 @@ interface ApprovalSegment { token: string; command: string; reason: string; - /** 用户已点过的结果,决定按钮禁用态。undefined = 待处理。 */ + /** Only confirmed IPC results become decisions. Pending requests remain undecided. */ + pending?: boolean; + failed?: boolean; decision?: 'approved' | 'denied'; } @@ -110,6 +136,11 @@ interface Turn { costUsd: number | null; } +interface VoiceShortcut { + label: string; + mode: HotkeyMode; +} + function emptyTurn(user: string): Turn { return { user, segments: [], status: 'working', errorMsg: '', costUsd: null }; } @@ -129,74 +160,155 @@ function updateLastTurn(turns: Turn[], fn: (t: Turn) => Turn): Turn[] { return [...list.slice(0, -1), fn(list[list.length - 1])]; } -/** 把流里还在扫光的工具行停下来(下一个事件到达 = 上一个工具已结束)。 */ +function hasFinishedLastTurn(turns: Turn[]): boolean { + const last = turns[turns.length - 1]; + return last !== undefined && last.status !== 'working'; +} + +/** 把流里还在扫光的工具行停下来(下一个事件到达仅停止工具活动指示)。 */ function settleRunningTools(segments: Segment[]): Segment[] { if (!segments.some((s) => s.kind === 'tool' && s.running)) return segments; return segments.map((s) => (s.kind === 'tool' && s.running ? { ...s, running: false } : s)); } -// 浏览器预览(vite dev,非 Tauri):?window=less-computer&demo=1 注入两轮演示对话 -// (第一轮完成态含成本行;第二轮进行中,覆盖「文本 → 工具行 → 压缩行 → 进行中 -// 工具行 → 审批卡」交错流与新轮次锚定),方便调样式。 -function getPreviewTurns(): Turn[] { - if (isTauri || typeof window === 'undefined') return []; - if (new URLSearchParams(window.location.search).get('demo') !== '1') return []; - return [ - { - user: '看一下下载文件夹里最大的三个文件是什么', - segments: [ - { kind: 'tool', name: 'Bash', running: false }, - { - kind: 'text', - content: - '最大的三个文件:\n1. `Xcode_26.5.xip` — 12.4 GB\n2. `ubuntu-24.04.iso` — 5.8 GB\n3. `设计素材包.zip` — 2.1 GB', - }, - ], - status: 'done', - errorMsg: '', - costUsd: 0.012, - }, - { - user: '帮我把桌面上的截图整理到「本周素材」文件夹', - segments: [ - { kind: 'text', content: '好的,我先看一下桌面上有哪些截图。' }, - { kind: 'tool', name: 'Bash', running: false }, - { kind: 'compaction' }, - { kind: 'text', content: '找到 6 张截图,正在移动并按日期重命名…' }, - { kind: 'tool', name: 'Bash', running: true }, - { - kind: 'approval', - token: 'demo', - command: 'mv ~/Desktop/Screenshot*.png ~/Documents/本周素材/', - reason: 'Moving files outside the working directory.', - }, - ], - status: 'working', - errorMsg: '', - costUsd: null, - }, - ]; +function readInspectorPreference(): boolean { + try { + return window.localStorage?.getItem(INSPECTOR_KEY) !== '0'; + } catch { + return true; + } +} + +function writeInspectorPreference(open: boolean) { + try { + window.localStorage?.setItem(INSPECTOR_KEY, open ? '1' : '0'); + } catch { + /* the in-memory preference still applies for this window */ + } } -/** macOS movableByWindowBackground 拖动把手(header 区域整条可拖,普通箭头指针)。 */ -const drag = { 'data-tauri-drag-region': true } as const; +function matchesNarrow(): boolean { + return typeof window.matchMedia === 'function' && window.matchMedia(NARROW_QUERY).matches; +} -/** 已应用事件的最大 seq。放模块级而不是 effect 闭包:StrictMode/HMR 重挂载时 - * 组件 state 保留,若水位归零会把同一批积压重放两遍、轮次翻倍。后端 seq 全局 - * 单调不回卷(新会话只清缓冲),webview 整页重载时本变量归零、恰好与「需要 - * 完整重放」对齐。 */ +function prefersReducedMotion(): boolean { + return ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-reduced-motion: reduce)').matches + ); +} + +function voiceShortcutFrom(preferences: UserPreferences): VoiceShortcut | null { + const binding = preferences.codingAgentVoiceHotkey; + if (!binding) return null; + return { label: formatComboLabel(binding), mode: preferences.hotkey?.mode ?? 'hold' }; +} + +// Keep the replay watermark across StrictMode/HMR effect remounts. A whole +// WebView reload resets both state and watermark so the backend can replay it. let lcAppliedSeq = 0; export function LessComputerPanel() { const { t } = useTranslation(); - // 连续对话:每按一次说话键追加一轮(除非后端标记 fresh=新会话则清空重开)。 - const [turns, setTurns] = useState(getPreviewTurns); + const [turns, setTurns] = useState([]); const [voice, setVoice] = useState(null); - // 新会话计数:fresh 时 +1,作为壳 key 重放入场动画 —— 浮窗是常驻 webview - // (hide/show 复用),没有这个的话再次唤起时内容直接闪现,很突兀。 const [sessionSeq, setSessionSeq] = useState(0); - // 出现/消失动画:后端 show/hide 发 chat-panel:shown / chat-panel:closing。 + const [provider, setProvider] = useState(null); + const [voiceShortcut, setVoiceShortcut] = useState(null); + const [signedIn, setSignedIn] = useState(null); + const [loginOpen, setLoginOpen] = useState(false); + const [windowError, setWindowError] = useState(false); + const [inspectorPreference, setInspectorPreference] = useState(readInspectorPreference); + const [narrow, setNarrow] = useState(matchesNarrow); + const [inspectorOverlay, setInspectorOverlay] = useState(false); + const turnEpoch = useRef(0); + const approvalRequests = useRef(new Map()); + const shellRef = useRef(null); + const closedRef = useRef(false); const { enterEpoch, closing } = useChatPanelLifecycle(); + const login = useExitMount(loginOpen, LOGIN_EXIT_MS); + + // Read actual configuration and account status; browser previews stay unavailable. + useEffect(() => { + if (!isTauri) return; + let cancelled = false; + let revision = 0; + let unlisten: (() => void) | undefined; + const applyPreferences = (preferences: UserPreferences) => { + setProvider(preferences.codingAgentProvider); + setVoiceShortcut(voiceShortcutFrom(preferences)); + if (preferences.themeMode) applyThemeFromPreference(preferences.themeMode); + }; + const refresh = async () => { + const current = ++revision; + const results = await Promise.allSettled([getSettings(), marketplaceAuthStatus()]); + if (cancelled || current !== revision) return; + const [settings, account] = results; + if (settings.status === 'fulfilled') applyPreferences(settings.value); + else { + setProvider(null); + setVoiceShortcut(null); + } + setSignedIn(account.status === 'fulfilled' ? account.value.signedIn : null); + }; + void (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen('prefs:changed', (event) => { + revision += 1; + applyPreferences(event.payload); + }); + if (cancelled) { + handle(); + return; + } + unlisten = handle; + } catch { + /* focus refresh still works when the optional subscription fails */ + } + if (!cancelled) void refresh(); + })(); + window.addEventListener('focus', refresh); + return () => { + cancelled = true; + unlisten?.(); + window.removeEventListener('focus', refresh); + }; + }, [loginOpen]); + + // Narrow windows show the inspector as an overlay that starts closed. + useEffect(() => { + if (typeof window.matchMedia !== 'function') return; + const media = window.matchMedia(NARROW_QUERY); + const onChange = () => { + setNarrow(media.matches); + setInspectorOverlay(false); + }; + media.addEventListener('change', onChange); + return () => media.removeEventListener('change', onChange); + }, []); + + // The WebView is reused across show/hide. Replay only the entrance motion so + // drafts, scroll position and inspector state survive reopening. Text and + // voice turns also call the native show path while the panel is visible; + // those must not flash the window, so only a preceding close replays it. + useEffect(() => { + if (closing) closedRef.current = true; + }, [closing]); + useEffect(() => { + if (enterEpoch === 0 || !closedRef.current) return; + closedRef.current = false; + if (prefersReducedMotion()) return; + const shell = shellRef.current; + if (!shell || typeof shell.animate !== 'function') return; + shell.animate( + [ + { opacity: 0, transform: 'translateY(6px) scale(0.985)' }, + { opacity: 1, transform: 'none' }, + ], + { duration: 240, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }, + ); + }, [enterEpoch]); // ── 后端事件订阅(mount 一次)──────────────────────────────────────── // @@ -243,6 +355,8 @@ export function LessComputerPanel() { if (cancelled) return; const reconciled = reconcileLessComputerReplay(lcAppliedSeq, replay, pending); if (reconciled.reset) { + turnEpoch.current += 1; + approvalRequests.current.clear(); setTurns([]); setVoice(null); } @@ -271,6 +385,8 @@ export function LessComputerPanel() { setVoice((previous) => reduceLessComputerVoice(previous, ev)); break; case 'user': { + turnEpoch.current += 1; + approvalRequests.current.clear(); // 一轮新对话。fresh=true(后端无可续会话→新会话)则清空历史重开;否则追加为后续轮次。 setTurns((prev) => (ev.fresh ? [emptyTurn(ev.text)] : [...prev, emptyTurn(ev.text)])); if (ev.fresh) setSessionSeq((seq) => seq + 1); @@ -346,189 +462,582 @@ export function LessComputerPanel() { break; case 'error': setTurns((prev) => - updateLastTurn(prev, (tn) => ({ - ...tn, - segments: settleRunningTools(tn.segments), - errorMsg: ev.message, - status: 'error', - })), + // A capture that fails before any turn starts must not relabel the + // previous finished answer; it gets its own error row instead. + hasFinishedLastTurn(prev) + ? [...prev, { ...emptyTurn(''), status: 'error', errorMsg: ev.message }] + : updateLastTurn(prev, (tn) => ({ + ...tn, + segments: settleRunningTools(tn.segments), + errorMsg: ev.message, + status: 'error', + })), ); break; case 'cancelled': setTurns((prev) => - updateLastTurn(prev, (tn) => ({ - ...tn, - segments: settleRunningTools(tn.segments), - status: 'cancelled', - })), + // Abandoning a recording cancels only the capture, not a finished turn. + hasFinishedLastTurn(prev) + ? prev + : updateLastTurn(prev, (tn) => ({ + ...tn, + segments: settleRunningTools(tn.segments), + status: 'cancelled', + })), ); break; } }; - const onApproval = (token: string, approved: boolean) => { - setTurns((prev) => - prev.map((tn) => ({ - ...tn, - segments: tn.segments.map((s) => - s.kind === 'approval' && s.token === token - ? { ...s, decision: approved ? 'approved' : ('denied' as const) } - : s, - ), - })), + const onApproval = async (token: string, approved: boolean) => { + const currentTurn = turns[turns.length - 1]; + if (!isTauri || currentTurn?.status !== 'working' || approvalRequests.current.has(token)) + return; + const card = currentTurn.segments.find( + (segment) => segment.kind === 'approval' && segment.token === token, ); - void lessComputerApprove(token, approved); + if (!card || card.kind !== 'approval' || card.decision) return; + const request = Symbol(token); + const epoch = turnEpoch.current; + approvalRequests.current.set(token, request); + const update = (patch: Partial) => + setTurns((previous) => + previous.map((turn) => ({ + ...turn, + segments: turn.segments.map((segment) => + segment.kind === 'approval' && segment.token === token + ? { ...segment, ...patch } + : segment, + ), + })), + ); + update({ pending: true, failed: false }); + try { + await lessComputerApprove(token, approved); + if (turnEpoch.current === epoch) + update({ pending: false, decision: approved ? 'approved' : 'denied' }); + } catch { + if (turnEpoch.current === epoch) update({ pending: false, failed: true }); + } finally { + if (approvalRequests.current.get(token) === request) approvalRequests.current.delete(token); + } }; - const onClose = () => void lessComputerWindowDismiss(); + const windowAction = async (action: 'hide' | 'minimize' | 'maximize') => { + if (!isTauri) return; + setWindowError(false); + try { + if (action === 'hide') await lessComputerWindowDismiss(); + else { + const { getCurrentWindow } = await import('@tauri-apps/api/window'); + const nativeWindow = getCurrentWindow(); + if (action === 'minimize') await nativeWindow.minimize(); + else await nativeWindow.toggleMaximize(); + } + } catch { + setWindowError(true); + } + }; - // ── Esc 关闭 ──────────────────────────────────────────────────────── + const activeVoiceSession = voice && voice.phase !== 'idle' ? voice.sessionId : null; useEffect(() => { const onKey = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - void lessComputerWindowDismiss(); - } + if (event.key !== 'Escape' || event.isComposing || event.keyCode === 229) return; + event.preventDefault(); + if (loginOpen) { + event.stopPropagation(); + setLoginOpen(false); + } else if (activeVoiceSession && isTauri) { + // Esc during a recording only abandons that recording, never the window or task. + event.stopPropagation(); + void lessComputerVoiceCancel(activeVoiceSession).catch(() => undefined); + } else void windowAction('hide'); }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); - }, []); + }, [loginOpen, activeVoiceSession]); - const working = turns.some((tn) => tn.status === 'working'); + const working = turns.some((turn) => turn.status === 'working'); + const latestTurn = turns[turns.length - 1]; + const status = runStatus(latestTurn, t); + const agentName = AGENTS.find((agent) => agent.id === provider)?.name ?? null; + const voiceHint = voiceShortcut + ? t(`lessComputer.voice.${voiceHintKey(voiceShortcut.mode)}`, { key: voiceShortcut.label }) + : null; + const inspectorOpen = narrow ? inspectorOverlay : inspectorPreference; + const toggleInspector = () => { + if (narrow) { + setInspectorOverlay((open) => !open); + return; + } + const next = !inspectorPreference; + setInspectorPreference(next); + writeInspectorPreference(next); + }; + const showPillStatus = + status.tone === 'working' || status.tone === 'waiting' || status.tone === 'error'; + const openInspectorLabel = t('lessComputer.desktop.showInspector'); - // ── 官方 message-scroller-demo 同款骨架 ───────────────────────────── return ( - - +
- - {t('lessComputer.title')} - - {working ? ( - - {t('lessComputer.working')} - - ) : ( - t('lessComputer.subtitle') - )} - - - + + +
+ + + +
+
+
{t('lessComputer.desktop.agents')}
+
    + {AGENTS.map((agent) => { + const current = provider === agent.id; + return ( +
  • + +
    + {agent.name} + + {current + ? sessionPreview(turns, status.label, t) + : t('lessComputer.desktop.agentSettings')} + +
    +
  • + ); + })} +
+
+
+ - - - - {turns.length === 0 ? ( - - - - - - {t('lessComputer.title')} - {t('lessComputer.subtitle')} - - - ) : ( - - - - {turns.map((turn, ti) => ( - - ))} - - - - + + + + + GitHub + + {signedIn === true + ? t('lessComputer.desktop.signedIn') + : t('lessComputer.desktop.signIn')} + + + {signedIn === true ? : } + +
+ + +
+
+
+ + {agentName ?? t('lessComputer.title')} + + +
+
+ {!inspectorOpen && ( + + + + )} +
+
+ {windowError && ( +

+ {t('lessComputer.desktop.windowError')} +

)} - - - - - - +
+ + {turns.length === 0 ? ( +
+ +

{t('lessComputer.subtitle')}

+

{t('lessComputer.desktop.emptyHint')}

+
+ ) : ( + + + + {turns.map((turn, index) => ( + + ))} + + + + + )} +
+
+ +
+ + segment.kind === 'tool').length ?? 0, + pendingApprovals: + latestTurn?.status === 'working' + ? latestTurn.segments.filter( + (segment) => segment.kind === 'approval' && !segment.decision, + ).length + : 0, + costUsd: latestTurn?.costUsd ?? null, + voiceHint, + }} + /> +
+ {login.mounted && isTauri && ( + setLoginOpen(false)} + onSuccess={() => { + setSignedIn(true); + setLoginOpen(false); + }} + /> + )} + ); } -// ── 底部输入区:官方 demo 同款 InputGroup,打字 + 语音两种形式完整 ──── -// -// · 打字:单行输入 + 右下发送(官方 demo 的 block-end addon 布局);Enter 走 -// 表单提交;IME 组合中的 Enter(选字确认)不触发(isComposing/keyCode 229 -// 守卫)。点进输入框时 chat_panel_focus_keyboard 让非激活面板成为 key window -// (不激活 app,主窗口不动)。 -// · 语音:录音红光、转译思考黑光绕输入组一圈圈跑(olchat-ring),输入框本体 -// 保持可见;只显示Core提供的voice_state,与聊天事件共用seq重放和session归属。 +function runStatus(turn: Turn | undefined, t: Translate): { label: string; tone: RunTone } { + if (!turn) return { label: t('lessComputer.desktop.idle'), tone: 'idle' }; + if ( + turn.status === 'working' && + turn.segments.some((segment) => segment.kind === 'approval' && !segment.decision) + ) + return { label: t('lessComputer.desktop.waitingApproval'), tone: 'waiting' }; + if (turn.status === 'working') { + const active = turn.segments.find((segment) => segment.kind === 'tool' && segment.running); + return { + label: + active?.kind === 'tool' + ? t(`lessComputer.activity.${toolActivityCategory(active.name)}Running`) + : t('lessComputer.working'), + tone: 'working', + }; + } + if (turn.status === 'done') return { label: t('lessComputer.done'), tone: 'done' }; + if (turn.status === 'cancelled') return { label: t('common.cancelled'), tone: 'cancelled' }; + if (turn.status === 'error') return { label: t('lessComputer.error'), tone: 'error' }; + return { label: t('lessComputer.desktop.idle'), tone: 'idle' }; +} + +function plainPreview(markdown: string): string { + return markdown + .replace(/```[\s\S]*?```/g, ' ') + .replace(/[`*_>#~[\]()|]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +/** Sidebar preview of the one real session: live status while working, else its latest words. */ +function sessionPreview(turns: Turn[], statusLabel: string, t: Translate): string { + const turn = turns[turns.length - 1]; + if (!turn) return t('lessComputer.desktop.idle'); + if (turn.status === 'working') return statusLabel; + for (let i = turn.segments.length - 1; i >= 0; i -= 1) { + const segment = turn.segments[i]; + if (segment.kind === 'text') { + const preview = plainPreview(segment.content); + if (preview) return preview; + } + } + if (turn.status === 'error') return turn.errorMsg || statusLabel; + return turn.user.trim() || statusLabel; +} + +function voiceTime(elapsedMs: number): string { + const seconds = Math.floor(Math.max(0, Number.isFinite(elapsedMs) ? elapsedMs : 0) / 1000); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; +} + +function errorText(error: unknown): string { + if (error instanceof Error) return error.message; + return typeof error === 'string' ? error : String(error); +} + function Composer({ working, voice, t, + agentName = null, + voiceHint = null, + focusAllowed = true, }: { working: boolean; voice: LessComputerVoiceEvent | null; - t: ReturnType['t']; + t: Translate; + agentName?: string | null; + voiceHint?: string | null; + focusAllowed?: boolean; }) { const [text, setText] = useState(''); - const busy = working || (voice !== null && voice.phase !== 'idle'); - // 输入组环形光:录音红光 → 转译黑光 → 指令落定(agent 已在跑)即停。 - const ring = - voice?.phase === 'recording' - ? 'recording' - : (voice?.phase === 'starting' || voice?.phase === 'transcribing') && !working - ? 'thinking' - : undefined; - // IME 组合期间的 Enter 是「选字确认」不是「发送」。keydown 里 isComposing - // 已覆盖大部分场景,keyCode 229 兜底 WebKit 老行为。 + const [submitting, setSubmitting] = useState(false); + const [failed, setFailed] = useState(false); + const [voiceNotice, setVoiceNotice] = useState(null); + const [voicePending, setVoicePending] = useState(false); + const [stoppingTask, setStoppingTask] = useState(false); + const submittingRef = useRef(false); const composingRef = useRef(false); + const voiceRequestRef = useRef(false); + const focusPendingRef = useRef(false); + const inputRef = useRef(null); + const phase = voice?.phase ?? 'idle'; + const speaking = voice !== null && phase !== 'idle'; + const dictating = speaking && voice?.mode === 'dictate'; + const busy = working || speaking || submitting || voicePending; + const hasText = text.trim().length > 0; + const voiceLabel = + phase === 'recording' + ? t('lessComputer.voice.listening') + : phase === 'starting' + ? t('lessComputer.voice.starting') + : t('lessComputer.voice.transcribing'); + + // A finished dictation lands in the draft exactly once, even when replayed. + useEffect(() => { + if (!voice || voice.phase !== 'idle' || voice.mode !== 'dictate' || !voice.outcome) return; + if (!claimDictationResult(voice.sessionId)) return; + const transcript = voice.transcript?.trim() ?? ''; + if (voice.outcome === 'committed' && transcript) { + setText((current) => mergeDictation(current, transcript)); + setVoiceNotice(null); + focusPendingRef.current = true; + } else if (voice.outcome === 'empty') setVoiceNotice(t('lessComputer.voice.empty')); + else if (voice.outcome === 'failed') setVoiceNotice(t('lessComputer.voice.failed')); + }, [voice]); + + useEffect(() => { + const input = inputRef.current; + if (!input) return; + input.style.height = 'auto'; + input.style.height = `${Math.min(input.scrollHeight, MAX_INPUT_HEIGHT)}px`; + if (!focusPendingRef.current) return; + focusPendingRef.current = false; + // A dialog or closing window owns focus. Consume this request rather than + // unexpectedly replaying it when that surface is dismissed later. + if (!focusAllowed) return; + if (isTauri) void chatPanelFocusKeyboard().catch(() => undefined); + input.focus({ preventScroll: true }); + input.setSelectionRange(input.value.length, input.value.length); + }, [text, focusAllowed]); - const send = () => { + const send = async () => { const trimmed = text.trim(); - if (!trimmed || busy) return; - setText(''); - void lessComputerSubmitText(trimmed); + if ( + !isTauri || + !trimmed || + busy || + submittingRef.current || + voiceRequestRef.current || + composingRef.current + ) + return; + submittingRef.current = true; + setSubmitting(true); + setFailed(false); + setVoiceNotice(null); + try { + await lessComputerSubmitText(trimmed); + // Keep a draft typed while the IPC was pending. The actual user event owns the chat. + setText((current) => (current === text ? '' : current)); + } catch { + setFailed(true); + } finally { + submittingRef.current = false; + setSubmitting(false); + } }; - - const onKeyDown = (event: ReactKeyboardEvent) => { - if (event.key !== 'Enter') return; - if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) { - event.preventDefault(); + const startVoice = async (mode: LessComputerVoiceMode) => { + if (!isTauri || busy || voiceRequestRef.current || submittingRef.current) return; + voiceRequestRef.current = true; + setVoicePending(true); + setVoiceNotice(null); + setFailed(false); + try { + await lessComputerVoiceStart(mode); + } catch (error) { + setVoiceNotice(t('lessComputer.voice.startFailed', { message: errorText(error) })); + } finally { + voiceRequestRef.current = false; + setVoicePending(false); } }; - + const stopVoice = () => { + if (!isTauri || !voice || !speaking || phase === 'transcribing') return; + void lessComputerVoiceStop(voice.sessionId).catch(() => + setVoiceNotice(t('lessComputer.voice.failed')), + ); + }; + const cancelVoice = () => { + if (!isTauri || !voice || !speaking) return; + void lessComputerVoiceCancel(voice.sessionId).catch(() => undefined); + }; + const stopTask = async () => { + if (!isTauri || !working || stoppingTask) return; + setStoppingTask(true); + setVoiceNotice(null); + try { + await lessComputerTaskCancel(); + } catch { + setVoiceNotice(t('lessComputer.voice.taskCancelFailed')); + } finally { + setStoppingTask(false); + } + }; + const onKeyDown = (event: ReactKeyboardEvent) => { + if (event.key !== 'Enter' || event.shiftKey) return; + event.preventDefault(); + if (composingRef.current || event.nativeEvent.isComposing || event.keyCode === 229) return; + void send(); + }; + const liveTranscript = voice?.transcript ? transcriptTail(voice.transcript) : ''; + const placeholder = agentName + ? t('lessComputer.desktop.messagePlaceholder', { agent: agentName }) + : t('lessComputer.inputPlaceholder'); + const primary = working ? 'stop' : hasText ? 'send' : 'voice'; + const primaryLabel = + primary === 'stop' + ? t('lessComputer.voice.stopTask') + : primary === 'send' + ? t('lessComputer.send') + : t('lessComputer.voice.voiceMode'); + const confirmLabel = dictating + ? t('lessComputer.voice.confirmDictation') + : t('lessComputer.voice.stopAndSend'); + const caption = failed ? ( + {t('lessComputer.desktop.sendError')} + ) : voiceNotice ? ( + {voiceNotice} + ) : !isTauri ? ( + t('lessComputer.desktop.browserUnavailable') + ) : speaking ? ( + dictating ? ( + t('lessComputer.voice.dictateCaption') + ) : ( + t('lessComputer.voice.submitCaption') + ) + ) : working ? ( + t('lessComputer.voice.stopHint') + ) : ( + [t('lessComputer.desktop.inputHint'), voiceHint].filter(Boolean).join(' · ') + ); return ( -
{ - event.preventDefault(); - send(); - }} - className="w-full" - > - - + { + event.preventDefault(); + void send(); + }} + > +