diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index 007564c8..014e4c3f 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/README.md b/README.md index 2666efcd..74f5f506 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 c3933918..2e06ad3e 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 00000000..7ba32ae6 --- /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/index.md b/docs/index.md index d59ff8e7..8838ee99 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,7 @@ ## 平台与运营 - [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) @@ -40,6 +41,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 00000000..c01335b4 --- /dev/null +++ b/docs/local-builds/2026-09-25-beta3.md @@ -0,0 +1,27 @@ +# 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 | 远程输入停止/取消后隔离过期音频准备回调 | 保留麦克风复用,验证迟到回调和超时 | + +所有新增 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/` 中的运行记录与发布验收结果。 diff --git a/openless-all/app/assets/remote-input/app.js b/openless-all/app/assets/remote-input/app.js index d3ed74b0..a07ac65a 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/crates/openless-core/src/api.rs b/openless-all/app/crates/openless-core/src/api.rs index 0207a0ed..6236bb46 100644 --- a/openless-all/app/crates/openless-core/src/api.rs +++ b/openless-all/app/crates/openless-core/src/api.rs @@ -3399,7 +3399,9 @@ impl OpenLessBackend { }; let preferences = self.get_preferences(); let mode = preferences.hotkey.mode; - let modifier_only = crate::hotkey_interpreter::modifier_arbitration_required( + 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, ); @@ -9447,7 +9449,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 +12749,62 @@ 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()); 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 dec6ac09..5a52a88a 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/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index 10f82c8c..cacf8e67 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -326,7 +326,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, + 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, diff --git a/openless-all/app/crates/openless-core/src/polish.rs b/openless-all/app/crates/openless-core/src/polish.rs index 968a3b21..0d2e089e 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" 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 ce6a5115..089c1e16 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/shortcut_types.rs b/openless-all/app/crates/openless-core/src/shortcut_types.rs index c62c7b88..f1f2d61a 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/package-lock.json b/openless-all/app/package-lock.json index 91a5252f..e3867db7 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 34c2f4e1..6f801777 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 00000000..e4c0b30c --- /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 00000000..f66a63da --- /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/collect-android-split-apks.mjs b/openless-all/app/scripts/collect-android-split-apks.mjs new file mode 100644 index 00000000..5fa8c2f8 --- /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 00000000..83569d99 --- /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/remote-input-audio-generation.test.mjs b/openless-all/app/scripts/remote-input-audio-generation.test.mjs new file mode 100644 index 00000000..66991dbf --- /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 00000000..d3c918dd --- /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/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index abe7e2a2..af8588ee 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -4257,7 +4257,7 @@ dependencies = [ [[package]] name = "openless" -version = "2.0.0-Beta.2+build.20260924" +version = "2.0.0-Beta.3+build.20260925" dependencies = [ "anyhow", "arboard", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index cb0a768a..8e20bc80 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"] diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index d560501a..42246a5a 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1099,14 +1099,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)"); } 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 7a146445..d2d0b39f 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1292,14 +1292,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; } 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 f9176f21..df810254 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/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 8b57097b..b10661e9 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/mobile_stubs/side_aware_combo.rs b/openless-all/app/src-tauri/src/mobile_stubs/side_aware_combo.rs index 48cafa58..070c19ab 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/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs index ab335b45..b4790e26 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,50 +110,73 @@ 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() }); + self.release_active() + } + + fn on_modifier_release(&mut self, side: SideModifier) -> Option { + self.set_side(side, false); + if self.active_press_id.is_some() && !self.modifiers_match() { + return self.release_active(); } None } - 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() }); + fn on_companion_key_down(&mut self) -> Option { + if !is_modifier_chord_binding(&self.binding) || self.companion_seen { + return None; } - None + let press_id = self.active_press_id?; + self.companion_seen = true; + Some(HotkeyCombinedEdge { + at: Instant::now(), + press_id, + }) } } @@ -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,6 +242,17 @@ 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()))?; @@ -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()) } @@ -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()); }; @@ -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/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index a4d8652c..9ae2c098 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/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index f5f36862..ac7ab435 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/i18n/de.ts b/openless-all/app/src/i18n/de.ts index 50583f25..f095de62 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1354,6 +1354,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', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index e604bb9e..2a9919e8 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1315,6 +1315,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', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index 6275308e..eea8bd8b 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1346,6 +1346,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', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index 3e831549..0030543a 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1363,6 +1363,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', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 480cefcf..7dece6e8 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1303,6 +1303,7 @@ export const ja: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(無料モデル)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index a80e3da3..f91eab8b 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1295,6 +1295,7 @@ export const ko: typeof zhCN = { mimo: 'Xiaomi MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(무료 모델)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: 'Alibaba Cloud Coding Plan', codingPlanX: 'CodingPlanX', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index f90dff38..bd06ec81 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1253,6 +1253,7 @@ export const zhCN = { mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免费模型)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: '阿里云 Coding Plan', codingPlanX: 'CodingPlanX', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 023ef50e..b9019074 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1254,6 +1254,7 @@ export const zhTW: typeof zhCN = { mimo: '小米 MiMo', cometapi: 'CometAPI', openrouterFree: 'OpenRouter(免費模型)', + requesty: 'Requesty', orcarouter: 'OrcaRouter', alibabaCoding: '阿里雲 Coding Plan', codingPlanX: 'CodingPlanX', diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 94b8780f..dedc5744 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 9cd89893..5749a233 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/mock-provider-descriptors.json b/openless-all/app/src/lib/ipc/mock-provider-descriptors.json index cbc02a6e..c63c7bf6 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 8cef02b8..cab5adbb 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/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index b43c244e..cca505ca 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -59,6 +59,7 @@ const LLM_NAME_KEY_BY_ID: Record = { mimo: 'mimo', cometapi: 'cometapi', openrouterFree: 'openrouterFree', + requesty: 'requesty', orcarouter: 'orcarouter', alibabaCoding: 'alibabaCoding', codingPlanX: 'codingPlanX', diff --git a/openless-all/app/src/pages/settings/ProvidersSection.tsx b/openless-all/app/src/pages/settings/ProvidersSection.tsx index 77319b71..14ac7ad8 100644 --- a/openless-all/app/src/pages/settings/ProvidersSection.tsx +++ b/openless-all/app/src/pages/settings/ProvidersSection.tsx @@ -139,6 +139,7 @@ export const LLM_LABELS = [ ['mimo', 'mimo'], ['cometapi', 'cometapi'], ['openrouterFree', 'openrouterFree'], + ['requesty', 'requesty'], ['orcarouter', 'orcarouter'], ['alibabaCoding', 'alibabaCoding'], ['codingPlanX', 'codingPlanX'],