diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml new file mode 100644 index 00000000..a4156250 --- /dev/null +++ b/.github/workflows/android-apk.yml @@ -0,0 +1,241 @@ +name: Android APK (debug) + +# Triggers: +# - push v*-tauri tag → build debug APK, upload artifact + attach to GitHub Release +# - workflow_dispatch → build debug APK, upload artifact only (no release) +# +# Scope: full overlay/accessibility APK for ADB testing (v1 RECORD_AUDIO + overlay manifest merge). + +on: + push: + tags: + - 'v*-tauri' + workflow_dispatch: + +jobs: + build-android-apk: + permissions: + contents: write + runs-on: ubuntu-latest + env: + CI: true + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Setup Java 17 (Zulu) + uses: actions/setup-java@v4 + with: + distribution: zulu + java-version: '17' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + packages: platform-tools + + - name: Install NDK and accept SDK licenses + shell: bash + run: | + set -euo pipefail + sdkmanager "ndk;26.1.10909125" + ndk_dir="$ANDROID_HOME/ndk/26.1.10909125" + if [ ! -d "$ndk_dir" ]; then + echo "::error::NDK not found at $ndk_dir" + exit 1 + fi + echo "ANDROID_NDK_HOME=$ndk_dir" >> "$GITHUB_ENV" + echo "NDK_HOME=$ndk_dir" >> "$GITHUB_ENV" + set +o pipefail + yes | sdkmanager --licenses + sdkmanager_exit=${PIPESTATUS[1]} + set -o pipefail + if [ "$sdkmanager_exit" -ne 0 ]; then + echo "::error::sdkmanager --licenses failed with exit code $sdkmanager_exit" + exit "$sdkmanager_exit" + fi + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: openless-all/app/package-lock.json + + - uses: dtolnay/rust-toolchain@stable + with: + targets: >- + aarch64-linux-android, + armv7-linux-androideabi, + i686-linux-android, + x86_64-linux-android + + - name: Cache Cargo + uses: swatinem/rust-cache@v2 + with: + workspaces: openless-all/app/src-tauri -> target + + - uses: gradle/actions/setup-gradle@v4 + + - name: Install npm deps + working-directory: openless-all/app + run: npm ci + + - name: Build frontend + working-directory: openless-all/app + run: npm run build + + - name: Initialize Android project + working-directory: openless-all/app + run: npm run tauri -- android init --ci + + - name: Copy Android scaffolding (Kotlin + XML) + working-directory: openless-all/app + run: node scripts/copy-android-scaffolding.mjs + + - name: Merge APK v1 manifest permissions + working-directory: openless-all/app + run: node scripts/merge-android-v1-manifest.mjs + + - name: Merge overlay / accessibility manifest + working-directory: openless-all/app + run: node scripts/merge-android-overlay-manifest.mjs + + - name: Prime Gradle wrapper + working-directory: openless-all/app + shell: bash + run: | + set -euo pipefail + for attempt in 1 2 3; do + if src-tauri/gen/android/gradlew --project-dir src-tauri/gen/android --version --no-daemon; then + exit 0 + fi + if [ "$attempt" -lt 3 ]; then + sleep $([ "$attempt" -eq 1 ] && echo 15 || echo 30) + fi + done + exit 1 + + - name: Build Android debug APK + working-directory: openless-all/app + run: npm run tauri:android:build + + - name: Free disk before artifact upload + 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 + + - name: Collect split debug APKs + id: apk + shell: bash + working-directory: openless-all/app + run: | + set -euo pipefail + if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then + label="${{ github.ref_name }}" + else + label="run-${{ github.run_number }}" + fi + export OPENLESS_APK_LABEL="$label" + python - <<'PY' + 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", + } + root = Path("src-tauri/gen/android") + label = os.environ["OPENLESS_APK_LABEL"] + out_dir = Path(os.environ["RUNNER_TEMP"]) / "openless-android-debug-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) + 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("release_files<_x64-setup.exe` — run the installer. +- **Android debug APK**: choose exactly one split APK for your device: + - `OpenLess-android-debug-arm64-v8a-*.apk` for most modern Android phones. + - `OpenLess-android-debug-armeabi-v7a-*.apk` for older 32-bit ARM phones. + - `OpenLess-android-debug-x86_64-*.apk` for most 64-bit Android emulator images. + - `OpenLess-android-debug-x86-*.apk` for older 32-bit x86 emulator images. + - If unsure, run `adb shell getprop ro.product.cpu.abi` and download the APK matching that ABI. Do not install the x86 builds on a normal ARM phone. - **macOS (Homebrew)**: ```bash brew tap appergb/openless https://github.com/appergb/openless diff --git a/docs/android-mobile-apk-overlay-plan.md b/docs/android-mobile-apk-overlay-plan.md new file mode 100644 index 00000000..28fcfd9b --- /dev/null +++ b/docs/android-mobile-apk-overlay-plan.md @@ -0,0 +1,315 @@ +# OpenLess Android APK 与悬浮窗实施计划 + +> 状态:实施中 / 后端分层已落地 +> 日期:2026-06-07 +> 范围:Android APK v1(应用内录音)→ IME v2(跨 App 输入)→ 悬浮窗 v3;不改桌面语义 + +目标是把现有 OpenLess 桌面应用扩展为 Android APK,并在手机端提供可用的语音输入体验。当前项目是 React/Vite + Tauri v2 + Rust 后端,Tauri 官方支持 Android 构建;现仓库已增加 Android 分层与脚手架,桌面核心能力(全局热键、托盘、桌面浮窗、TSF IME 等)通过 `#[cfg(not(mobile))]` 收口。 + +核心策略:先产出可安装 APK,再做 Android 输入法服务,最后做跨 App 悬浮窗。桌面功能保持不受影响。 + +参考依据: +- Tauri Android 依赖:Android Studio、SDK/NDK、`ANDROID_HOME`、`NDK_HOME`、Android Rust targets。 +- Tauri Android 构建命令:`tauri android init`,`tauri android build --apk`。 +- Android 悬浮窗:Android 8.0+ 使用 `SYSTEM_ALERT_WINDOW` + `TYPE_APPLICATION_OVERLAY`。 + +--- + +## 1. 目标与非目标 + +### 目标 + +- **APK v1**:应用内主窗口、设置、历史、云端 ASR/LLM、基础录音、复制结果 +- **IME v2**:`OpenLessImeService` 作为跨 App 输入主路径 +- **悬浮窗 v3**:前台服务 + `TYPE_APPLICATION_OVERLAY`,仅作录音控制入口 +- **平台能力查询**:前端通过 `get_platform_capabilities` 隐藏桌面专属设置项 +- **桌面零破坏**:所有适配经 `#[cfg(not(mobile))]` / `#[cfg(mobile)]` 分层 + +### 非目标 + +- APK 首版不纳入本地 ASR(Foundry、Sherpa、Qwen 桌面假设) +- 首版不承诺直接写入其他 App(无 IME 时走复制兜底) +- Accessibility 跨 App 输入不作为默认路径 +- 悬浮窗不承担最终文本插入职责 +- 不改桌面现有功能语义 + +### 明确边界 + +- **不动** macOS / Windows / Linux 热键、托盘、胶囊、QA 浮窗逻辑 +- **Android 命令名与桌面一致**;不支持的命令返回明确 unavailable 状态 +- **Coordinator 听写主链路复用**;`end_session` 在 Android 增加 IME commit 分支 + +--- + +## 2. 架构定位 + +``` +openless-all/app/ + package.json # tauri:android:* scripts + vite.config.ts # TAURI_ENV_PLATFORM → 0.0.0.0 + HMR + src-tauri/ + tauri.conf.json # bundle.android.minSdkVersion ≥ 26 + tauri.android.conf.json # 单 main 窗口(无 capsule/qa/tray) + capabilities/ + default.json # 桌面 + mobile.json # Android 主窗口 + android-scaffolding/ # Kotlin 模板(init 后复制到 gen/android) + src/ + lib.rs # desktop/mobile run() 分层 + android_ime.rs # IME 状态 + commit(JNI 桩) + android_overlay.rs # 悬浮窗权限/状态(JNI 桩) + permissions.rs # Android 麦克风 runtime permission 分支 + persistence.rs # Android 凭据加密文件桩 + types.rs # PlatformCapabilities + commands.rs # get_platform_capabilities + 桌面命令 stub + coordinator/dictation.rs # end_session Android IME 分支 +``` + +平台分层示意: + +``` +┌─────────────────────────────────────────────────────────┐ +│ React UI(能力查询 → 隐藏桌面专属设置) │ +├─────────────────────────────────────────────────────────┤ +│ Tauri commands(同名 IPC;mobile 返回 unavailable stub) │ +├──────────────┬──────────────────────────────────────────┤ +│ desktop │ mobile (Android) │ +│ hotkey/tray │ in-app dictation + cloud ASR │ +│ capsule/qa │ android_ime (v2) / android_overlay(v3) │ +│ TSF/AX/粘贴 │ IME commit (v1); clipboard TBD │ +└──────────────┴──────────────────────────────────────────┘ +``` + +> **v1 剪贴板**:APK v1 不使用 Android 剪贴板兜底(未接 arboard);跨 App 文本输入依赖后续 IME/JNI 接线。 + +--- + +## 3. 模块设计 + +### 3.1 `PlatformCapabilities`(`types.rs`) + +```rust +pub struct PlatformCapabilities { + pub platform: String, + pub supports_ime_input: bool, + pub supports_overlay: bool, + pub supports_desktop_hotkey: bool, + pub supports_tray: bool, + pub supports_local_asr: bool, + pub supports_capsule_overlay: bool, +} +``` + +- Android:`supportsImeInput=true`(v2 起)、`supportsOverlay=true`(v3 起)、`supportsDesktopHotkey=false`、`supportsTray=false` +- 桌面:按 OS 填真实能力 + +### 3.2 `android_ime.rs` + +- `get_android_ime_status()` → 是否已启用 OpenLess 输入法 +- `commit_text(text)` → 通过 JNI 提交到 `InputConnection`(桩 → 日志 + 返回未连接) +- Kotlin:`OpenLessImeService` 继承 `InputMethodService` + +### 3.3 `android_overlay.rs` + +- `get_android_overlay_status()` → `SYSTEM_ALERT_WINDOW` 授权状态 +- `request_android_overlay_permission()` → 跳转 `OverlayPermissionActivity` +- Kotlin:`OpenLessOverlayService`(前台服务 + overlay window) + +### 3.4 `permissions.rs` / `persistence.rs` + +- 麦克风:Android runtime permission 分支(JNI 桩;未接线时 `NotDetermined`) +- 凭据:Android 加密 JSON 文件桩(`credentials.enc.json`),不沿用桌面 keyring + +### 3.5 `coordinator/dictation.rs` + +`end_session` 插入阶段: + +``` +#[cfg(target_os = "android")] + android_ime::commit_text → Inserted + 失败 → copy_fallback → CopiedFallback +#[cfg(not(mobile))] + 现有 Windows TSF / AX / paste 路径 +``` + +--- + +## 4. 原生层接口 + +| 组件 | 职责 | +|---|---| +| `MainActivity` | Tauri WebView + 权限状态桥接 | +| `OpenLessImeService` | 接收识别结果,`commitText` 到当前输入框 | +| `OpenLessOverlayService` | 悬浮窗开始/停止录音、显示状态 | +| `OverlayPermissionActivity` | 引导用户授权 `SYSTEM_ALERT_WINDOW` | + +Rust ↔ Kotlin 通信:Tauri mobile plugin / `jni`(脚手架阶段为桩,init 后接线)。 + +--- + +## 5. 实施里程碑 + +### M0 环境与文档(本期) + +- 扩展本计划文档(元数据、架构、文件表、风险) +- `package.json`:`tauri:android:init|dev|build` +- `vite.config.ts`:mobile dev host `0.0.0.0` + HMR +- `tauri.conf.json`:`bundle.android.minSdkVersion: 26` +- `tauri.android.conf.json` + `capabilities/mobile.json` + +### M1 Rust 分层 + APK 骨架(本期) + +- `lib.rs` desktop/mobile `run()` 分层 +- `Cargo.toml` gate 桌面专属依赖 +- `PlatformCapabilities` + 命令 stub +- `permissions.rs` / `persistence.rs` Android 分支 +- `cargo check` 桌面通过;Android target 尽力验证 + +### M2 IME v2 + +- 接线 `OpenLessImeService` JNI +- 设置页显示输入法启用状态 +- 跨 App 提交验收 + +### M3 悬浮窗 v3 + +- 接线 `OpenLessOverlayService` +- 授权引导 + 前台服务稳定性 + +--- + +## 6. 文件触达表 + +| 文件 | 变更 | +|---|---| +| `docs/android-mobile-apk-overlay-plan.md` | 扩展为完整实施规划(本文档) | +| `openless-all/app/package.json` | `tauri:android:*` scripts | +| `.github/workflows/android-apk.yml` | Android debug APK CI | +| `openless-all/app/scripts/merge-android-v1-manifest.mjs` | v1 manifest merge (RECORD_AUDIO) | +| `openless-all/app/vite.config.ts` | mobile dev server / HMR | +| `openless-all/app/src-tauri/tauri.conf.json` | `bundle.android` | +| `openless-all/app/src-tauri/tauri.android.conf.json` | 单 main 窗口 | +| `openless-all/app/src-tauri/capabilities/mobile.json` | Android 权限集 | +| `openless-all/app/src-tauri/Cargo.toml` | gate desktop deps | +| `openless-all/app/src-tauri/src/lib.rs` | mobile/desktop 分层 | +| `openless-all/app/src-tauri/src/types.rs` | `PlatformCapabilities` 等 | +| `openless-all/app/src-tauri/src/commands.rs` | 能力查询 + mobile stub | +| `openless-all/app/src-tauri/src/permissions.rs` | Android 麦克风 | +| `openless-all/app/src-tauri/src/persistence.rs` | Android 凭据 | +| `openless-all/app/src-tauri/src/android_ime.rs` | 新增 | +| `openless-all/app/src-tauri/src/android_overlay.rs` | 新增 | +| `openless-all/app/src-tauri/src/coordinator/dictation.rs` | `end_session` Android 分支 | +| `openless-all/app/src-tauri/android-scaffolding/*.kt` | Kotlin 模板 | + +--- + +## 7. 风险与对策 + +| 风险 | 对策 | +|---|---| +| 本机无 Android SDK / NDK | 文档记录手动脚手架;`android-scaffolding/` 供 init 后复制 | +| `global-hotkey` / `enigo` / `arboard` 无法编 Android | `Cargo.toml` `cfg(not(mobile))` gate | +| `keyring` 在 Android 不可用 | 加密文件桩 + 后续 Keystore 接线 | +| 桌面构建被 mobile 分层破坏 | 桌面 `cargo check` 为 CI 门禁;mobile 代码 `#[cfg(mobile)]` 隔离 | +| JNI 未接线时 IME/overlay 假成功 | 状态查询返回 `enabled=false`;commit 走 copy fallback | +| 多窗口配置污染移动端 | `tauri.android.conf.json` 仅声明 `main` | +| 本地 ASR 拖慢 Android 首版 | 明确排除;`supportsLocalAsr=false` | +| `SYSTEM_ALERT_WINDOW` 用户拒绝 | 设置页显示状态;悬浮窗功能降级为应用内入口 | + +--- + +## Android APK CI Workflow + +GitHub Actions workflow: [`.github/workflows/android-apk.yml`](../.github/workflows/android-apk.yml) + +### Capability platform isolation + +Desktop permissions live in `capabilities/default.json` with `"platforms": ["macOS", "windows", "linux"]`, so updater, autostart, and multi-window permissions do not apply on Android. Android uses `capabilities/mobile.json` with `"platforms": ["android"]` for the main-window permission set only (no updater/autostart). + +### Triggers + +| Trigger | Behavior | +|---|---| +| `workflow_dispatch` | Build debug APK → upload Actions artifact only | +| Push tag `v*-tauri` | Build debug APK → upload artifact **and** attach APK to the existing GitHub Release for that tag | + +Tag-triggered runs share the same `v*-tauri` convention as [`.github/workflows/release-tauri.yml`](../.github/workflows/release-tauri.yml). The Android job is independent and does not modify the desktop release workflow. + +### Debug APK policy + +- CI builds **debug** APKs (`tauri android build --apk --debug`) for faster iteration and to avoid release-signing requirements in v1. +- Actions artifact name: `openless-android-debug`. +- On-disk APK filename: + - Tag runs (`v*-tauri`): `OpenLess-android-debug-.apk` (e.g. `OpenLess-android-debug-v1.0.0-tauri.apk`) + - Manual dispatch: `OpenLess-android-debug-run-.apk` (not branch name) + +### Command chain (CI) + +```bash +cd openless-all/app +npm ci && npm run build +CI=true npm run tauri -- android init --ci +node scripts/merge-android-v1-manifest.mjs +CI=true npm run tauri:android:build +``` + +Local equivalent (after Android SDK/NDK setup): + +```bash +cd openless-all/app +npm run build +npm run tauri:android:init +npm run merge:android-v1-manifest +npm run tauri:android:build +``` + +### Manifest merge (v1 only) + +`scripts/merge-android-v1-manifest.mjs` merges **only** `RECORD_AUDIO` from `android-scaffolding/AndroidManifest.v1.snippet.xml` into the generated `src-tauri/gen/android/app/src/main/AndroidManifest.xml`. The script is idempotent (skips if permission already present). v2 (IME) and v3 (overlay) manifest snippets are **not** merged in this workflow. + +--- + +## 8. 验收标准 + +### 构建验证 + +```bash +cd openless-all/app +npm run build +cargo check --manifest-path src-tauri/Cargo.toml +# 需 Android SDK / NDK(与 CI 一致:debug APK): +npm run tauri:android:init +npm run merge:android-v1-manifest +npm run tauri:android:build +# 等价于 CI 的 debug 构建: +# CI=true npm run tauri -- android init --ci && node scripts/merge-android-v1-manifest.mjs && CI=true npm run tauri:android:build +``` + +### APK v1 + +- 首次启动进入主界面 +- 麦克风授权流程可触发 +- 应用内录音 → 云端转写 → 历史 + 复制 +- 桌面专属命令不导致前端白屏(返回 unavailable) +- 桌面 `cargo check` 仍通过 + +### IME v2 / 悬浮窗 v3 + +见原 Summary 中 Test Plan 章节(启用输入法后跨 App 提交;悬浮窗授权与前台服务稳定性)。 + +--- + +## Compatibility fixes(2026-06-07) + +- **`app_invoke_handler_mobile`**:仅保留 dictation / settings / credentials / history / cloud ASR / platform capabilities / Android IME·overlay / permissions / marketplace / style packs / mic devices;已移除 `get_hotkey_*`、`set_shortcut_recording_active` 及全部 desktop-only 命令(热键 setter、updater、local ASR、coding agent、tray 等)。前端 `ipc.ts` 在 `supportsDesktopHotkey === false` 时本地返回 stub,不再 invoke 这些命令。 +- **`mobile_stubs`**:`unicode_keystroke` 补齐 `typed_chars()` / `Partial`(与 coordinator 流式插入一致);`shortcut_binding::binding_from_legacy_trigger` 与桌面实现对齐。 +- **`Cargo.toml`**:`enigo` / `global-hotkey` / updater / single-instance / autostart 仅在 `cfg(not(mobile))`;Android 侧 `jni` + `ndk-context` 已声明。 + +--- + +## 9. 相关参考 + +- Tauri Android:https://v2.tauri.app/develop/mobile/ +- 桌面 Windows ASR 规划风格:`docs/windows-sherpa-onnx-asr-plan.md` +- 主听写链路:`openless-all/app/src-tauri/src/coordinator/dictation.rs` +- Windows IME unavailable 模式:`openless-all/app/src-tauri/src/windows_ime_profile.rs` diff --git a/openless-all/app/android/README.md b/openless-all/app/android/README.md new file mode 100644 index 00000000..30b04abc --- /dev/null +++ b/openless-all/app/android/README.md @@ -0,0 +1,79 @@ +# OpenLess Android 平台代码 + +Android 相关 Rust、Kotlin 与前端代码的统一入口。桌面端通过 `#[cfg(not(mobile))]` 分层,不受影响。 + +## 目录结构 + +```text +android/ +├── kotlin/ # Kotlin 模板(CI 复制到 gen/android/) +├── manifests/ # AndroidManifest snippet + res/xml +└── frontend/ # React 模块(Vite 别名 @android) + +src-tauri/src/android/ # Rust 运行时模块(crate::android) +``` + +## Rust(`src-tauri/src/android/`) + +| 模块 | 职责 | +|------|------| +| `jni.rs` | JNI 工具(clipboard、overlay service、accessibility) | +| `native_bridge.rs` | Kotlin ↔ Coordinator JNI 入口 | +| `overlay.rs` | 悬浮窗权限与 show/hide | +| `accessibility.rs` | 无障碍服务状态与 paste | +| `insert.rs` | 跨 App 文本插入策略 | +| `types.rs` | Android 偏好与状态类型 | + +主 crate 通过 `mod android;` 引入,常用 API 经 `crate::android::` 扁平 re-export。 + +## Kotlin(`android/kotlin/`) + +`tauri android init` 后由 [`scripts/copy-android-scaffolding.mjs`](../scripts/copy-android-scaffolding.mjs) 复制到 `src-tauri/gen/android/app/src/main/java/com/openless/app/`。 + +Manifest 合并脚本: + +- [`scripts/merge-android-v1-manifest.mjs`](../scripts/merge-android-v1-manifest.mjs) — 麦克风权限(`android/manifests/AndroidManifest.v1.snippet.xml`) +- [`scripts/merge-android-overlay-manifest.mjs`](../scripts/merge-android-overlay-manifest.mjs) — 悬浮窗 / 无障碍 + +## 前端(`android/frontend/`,别名 `@android`) + +| 路径 | 职责 | +|------|------| +| `lib/androidTypes.ts` | Android 偏好与状态 TS 类型 | +| `lib/androidIpc.ts` | overlay / accessibility Tauri invoke | +| `lib/androidMicrophonePermission.ts` | WebView 麦克风权限辅助 | +| `components/AndroidPermissionsPanel.tsx` | 设置页 Android 权限与 overlay 配置 | + +`src/lib/types.ts` 与 `src/lib/ipc.ts` 保留 re-export,现有 import 路径仍可用。 + +## 构建与 CI + +**CI(overlay / 无障碍 ADB 测试 APK)** — 合并 v1 麦克风权限 + overlay / 无障碍 manifest,用于真机 ADB 测试完整悬浮窗与无障碍能力(非仅应用内听写): + +```bash +cd openless-all/app +npm ci && npm run build +CI=true npm run tauri -- android init --ci +node scripts/copy-android-scaffolding.mjs +node scripts/merge-android-v1-manifest.mjs +node scripts/merge-android-overlay-manifest.mjs +CI=true npm run tauri:android:build +``` + +Workflow: [`.github/workflows/android-apk.yml`](../../.github/workflows/android-apk.yml) + +**本地 overlay / 无障碍开发(v3)** — 与 CI 相同的 manifest 合并链,使用本地 init / copy 脚本: + +```bash +cd openless-all/app +npm run tauri:android:init +npm run copy:android-scaffolding +node scripts/merge-android-v1-manifest.mjs +node scripts/merge-android-overlay-manifest.mjs +npm run tauri:android:build +``` + +## 相关文档 + +- [AGENTS.md](../../AGENTS.md) — 真机闪退排查 +- [docs/android-mobile-apk-overlay-plan.md](../../docs/android-mobile-apk-overlay-plan.md) — 分阶段产品计划 diff --git a/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx new file mode 100644 index 00000000..959a5565 --- /dev/null +++ b/openless-all/app/android/frontend/components/AndroidPermissionsPanel.tsx @@ -0,0 +1,386 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icon } from '../../../src/components/Icon'; +import { getSettings, setSettings } from '../../../src/lib/ipc'; +import type { UserPreferences } from '../../../src/lib/types'; +import { Btn, Pill } from '../../../src/pages/_atoms'; +import { SettingRow } from '../../../src/pages/settings/shared'; +import { + getAndroidAccessibilityStatus, + getAndroidOverlayStatus, + requestAndroidAccessibilityPermission, + requestAndroidOverlayPermission, +} from '../lib/androidIpc'; +import type { + AndroidAccessibilityStatus, + AndroidInsertStrategy, + AndroidOverlayActivationMode, + AndroidOverlayCancelSwipeDirection, + AndroidOverlayLeftSwipeAction, + AndroidOverlayStatus, + AndroidOverlayTrigger, + AndroidPreferenceKey, +} from '../lib/androidTypes'; +import { + clampAndroidOverlaySize, + normalizeAndroidOverlayTrigger, +} from '../lib/androidTypes'; + +type AndroidPrefsSlice = Pick; + +function pickAndroidPrefs(settings: UserPreferences): AndroidPrefsSlice { + return { + androidInsertStrategy: settings.androidInsertStrategy, + androidOverlayTrigger: settings.androidOverlayTrigger, + androidOverlayActivationMode: settings.androidOverlayActivationMode, + androidOverlayLeftSwipeAction: settings.androidOverlayLeftSwipeAction, + androidOverlayCancelSwipeDirection: settings.androidOverlayCancelSwipeDirection, + androidOverlaySizeDp: settings.androidOverlaySizeDp, + }; +} + +let androidOverlaySaveQueue = Promise.resolve(); + +function enqueueAndroidOverlaySave(task: () => Promise): Promise { + const run = androidOverlaySaveQueue.then(task); + androidOverlaySaveQueue = run.then(() => undefined, () => undefined); + return run; +} + +function persistAndroidOverlayPrefs(patch: Partial): Promise { + return enqueueAndroidOverlaySave(async () => { + const settings = await getSettings(); + const next = { + ...settings, + ...patch, + }; + await setSettings(next); + return pickAndroidPrefs(next); + }); +} + +type AndroidPermissionsPanelMode = 'all' | 'accessibility' | 'overlayPermission' | 'overlayConfig'; + +interface AndroidPermissionsPanelProps { + mode?: AndroidPermissionsPanelMode; +} + +export function AndroidPermissionsPanel({ mode = 'all' }: AndroidPermissionsPanelProps) { + const { t } = useTranslation(); + const [androidOverlay, setAndroidOverlay] = useState(null); + const [androidAccessibility, setAndroidAccessibility] = useState(null); + const [androidPrefs, setAndroidPrefs] = useState | null>(null); + const [sizeDraft, setSizeDraft] = useState(null); + const sizeDebounceRef = useRef(null); + const sizePendingRef = useRef(false); + + const refreshAndroid = async () => { + const [overlay, accessibility, settings] = await Promise.all([ + getAndroidOverlayStatus(), + getAndroidAccessibilityStatus(), + getSettings(), + ]); + let migratedSettings = settings; + if (settings.androidOverlayTrigger === 'keyboard') { + const migratedPrefs = await persistAndroidOverlayPrefs({ + androidOverlayTrigger: normalizeAndroidOverlayTrigger(settings.androidOverlayTrigger), + }); + migratedSettings = { ...settings, ...migratedPrefs }; + } + setAndroidOverlay(overlay); + setAndroidAccessibility(accessibility); + setAndroidPrefs(pickAndroidPrefs(migratedSettings)); + }; + + useEffect(() => { + void refreshAndroid(); + const androidId = window.setInterval(refreshAndroid, 3000); + const onFocus = () => { void refreshAndroid(); }; + window.addEventListener('focus', onFocus); + return () => { + window.clearInterval(androidId); + window.removeEventListener('focus', onFocus); + }; + }, []); + + useEffect(() => { + if (sizePendingRef.current) return; + if (androidPrefs?.androidOverlaySizeDp != null) { + setSizeDraft(androidPrefs.androidOverlaySizeDp); + } + }, [androidPrefs?.androidOverlaySizeDp]); + + useEffect(() => { + return () => { + if (sizeDebounceRef.current) clearTimeout(sizeDebounceRef.current); + }; + }, []); + + const saveAndroidOverlaySize = async (value: number) => { + const clamped = clampAndroidOverlaySize(value); + try { + const nextPrefs = await persistAndroidOverlayPrefs({ androidOverlaySizeDp: clamped }); + setAndroidPrefs((prev) => (prev ? { ...prev, ...nextPrefs } : nextPrefs)); + setSizeDraft(clamped); + } catch (error) { + console.error('[android] failed to save overlay size', error); + const settings = await getSettings(); + const rolledBack = settings.androidOverlaySizeDp; + const safeDraft = rolledBack != null ? clampAndroidOverlaySize(rolledBack) : null; + setAndroidPrefs((prev) => (prev ? { ...prev, androidOverlaySizeDp: rolledBack } : null)); + setSizeDraft(safeDraft); + } finally { + sizePendingRef.current = false; + } + }; + + const scheduleAndroidOverlaySizeSave = (value: number) => { + if (sizeDebounceRef.current) clearTimeout(sizeDebounceRef.current); + sizeDebounceRef.current = window.setTimeout(() => { + sizeDebounceRef.current = null; + void saveAndroidOverlaySize(value); + }, 200); + }; + + const flushAndroidOverlaySizeSave = (value: number) => { + const hasDebounce = sizeDebounceRef.current != null; + const hasPending = sizePendingRef.current; + if (!hasDebounce && !hasPending) return; + + if (sizeDebounceRef.current) { + clearTimeout(sizeDebounceRef.current); + sizeDebounceRef.current = null; + } + + const clamped = clampAndroidOverlaySize(value); + const committed = androidPrefs?.androidOverlaySizeDp; + if (committed != null && clamped === committed) { + sizePendingRef.current = false; + return; + } + + void saveAndroidOverlaySize(clamped); + }; + + const handleAndroidOverlaySizeChange = (value: number) => { + const clamped = clampAndroidOverlaySize(value); + sizePendingRef.current = true; + setSizeDraft(clamped); + scheduleAndroidOverlaySizeSave(clamped); + }; + + const updateAndroidPref = async (key: K, value: UserPreferences[K]) => { + if (key !== 'androidOverlaySizeDp' && sizeDebounceRef.current) { + clearTimeout(sizeDebounceRef.current); + sizeDebounceRef.current = null; + } + + const patch: Partial = { + [key]: key === 'androidOverlayTrigger' + ? normalizeAndroidOverlayTrigger(value as AndroidOverlayTrigger) + : value, + }; + + if (key !== 'androidOverlaySizeDp') { + const committedSize = androidPrefs?.androidOverlaySizeDp; + const draftSize = sizeDraft; + if (sizePendingRef.current || (draftSize != null && draftSize !== committedSize)) { + patch.androidOverlaySizeDp = clampAndroidOverlaySize(draftSize ?? committedSize ?? 72); + } + sizePendingRef.current = false; + } + + try { + const nextPrefs = await persistAndroidOverlayPrefs(patch); + setAndroidPrefs(nextPrefs); + if (patch.androidOverlaySizeDp != null) { + setSizeDraft(nextPrefs.androidOverlaySizeDp); + } + await refreshAndroid(); + } catch (error) { + console.error('[android] failed to save overlay pref', error); + await refreshAndroid(); + } + }; + + const showOverlayPermission = mode === 'all' || mode === 'overlayPermission'; + const showAccessibility = mode === 'all' || mode === 'accessibility'; + const showOverlayConfig = mode === 'all' || mode === 'overlayConfig'; + + return ( + <> + {showOverlayPermission && ( + +
+ {androidOverlay?.message && ( + + {androidOverlay.message} + + )} + + {androidOverlay?.permission !== 'granted' && ( + { void requestAndroidOverlayPermission().then(refreshAndroid); }}> + {t('settings.permissions.grant')} + + )} +
+
+ )} + {showAccessibility && ( + +
+
+ {androidAccessibility?.message && ( + + {androidAccessibility.message} + + )} + + {!androidAccessibility?.enabled && ( + { void requestAndroidAccessibilityPermission().then(refreshAndroid); }}> + {t('settings.permissions.openSystem')} + + )} +
+ + {t('settings.permissions.androidAccessibilityImpact')} + +
+
+ )} + {showOverlayConfig && ( + <> + +
+ + + {t(`settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'accessibility'}`)} + +
+
+ +
+ + + {t(`settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`)} + + + {t('settings.permissions.androidOverlayTriggerDisabled.keyboard')} + +
+
+ +
+ + + {t(`settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`)} + +
+
+ +
+ + + {t(`settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`)} + +
+
+ +
+ + + {t(`settings.permissions.androidOverlayCancelSwipeDirectionHint.${androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}`)} + +
+
+ +
+
+ { + handleAndroidOverlaySizeChange(Number(event.target.value)); + }} + onPointerUp={(event) => { + flushAndroidOverlaySizeSave(Number(event.currentTarget.value)); + }} + onTouchEnd={(event) => { + flushAndroidOverlaySizeSave(Number(event.currentTarget.value)); + }} + onBlur={(event) => { + flushAndroidOverlaySizeSave(Number(event.currentTarget.value)); + }} + style={{ width: 132 }} + /> + + {sizeDraft ?? androidPrefs?.androidOverlaySizeDp ?? 72} dp + +
+ + {t('settings.permissions.androidOverlaySizeHint')} + +
+
+ + )} + + ); +} + +function AndroidOverlayStatusPill({ status }: { status: AndroidOverlayStatus | null }) { + const { t } = useTranslation(); + if (!status) return {t('settings.permissions.checking')}; + if (status.permission === 'granted') { + return {t('settings.permissions.granted')}; + } + return {t('settings.permissions.denied')}; +} + +function AndroidAccessibilityStatusPill({ status }: { status: AndroidAccessibilityStatus | null }) { + const { t } = useTranslation(); + if (!status) return {t('settings.permissions.checking')}; + if (status.enabled) { + return {t('settings.permissions.granted')}; + } + return {t('settings.permissions.denied')}; +} diff --git a/openless-all/app/android/frontend/lib/androidIpc.ts b/openless-all/app/android/frontend/lib/androidIpc.ts new file mode 100644 index 00000000..062407ab --- /dev/null +++ b/openless-all/app/android/frontend/lib/androidIpc.ts @@ -0,0 +1,43 @@ +import { invokeOrMock } from '../../../src/lib/ipc'; +import type { + AndroidAccessibilityStatus, + AndroidOverlayStatus, +} from './androidTypes'; + +export function getAndroidOverlayStatus(): Promise { + return invokeOrMock('get_android_overlay_status', undefined, () => ({ + permission: 'notAndroid', + overlayVisible: false, + message: 'Android overlay is only available on Android', + })); +} + +export function requestAndroidOverlayPermission(): Promise<{ launched: boolean; message: string }> { + return invokeOrMock('request_android_overlay_permission', undefined, () => ({ + launched: false, + message: 'Mock: overlay permission unavailable in browser preview', + })); +} + +export function showAndroidOverlay(): Promise { + return invokeOrMock('show_android_overlay', undefined, () => undefined); +} + +export function hideAndroidOverlay(): Promise { + return invokeOrMock('hide_android_overlay', undefined, () => undefined); +} + +export function getAndroidAccessibilityStatus(): Promise { + return invokeOrMock('get_android_accessibility_status', undefined, () => ({ + state: 'notAndroid', + enabled: false, + message: 'Android accessibility is only available on Android', + })); +} + +export function requestAndroidAccessibilityPermission(): Promise<{ launched: boolean; message: string }> { + return invokeOrMock('request_android_accessibility_permission', undefined, () => ({ + launched: false, + message: 'Mock: accessibility settings unavailable in browser preview', + })); +} diff --git a/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts b/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts new file mode 100644 index 00000000..d5fee524 --- /dev/null +++ b/openless-all/app/android/frontend/lib/androidMicrophonePermission.ts @@ -0,0 +1,104 @@ +import type { PermissionStatus as AppPermissionStatus } from '../../../src/lib/types'; +import { checkMicrophonePermission, requestMicrophonePermission } from '../../../src/lib/ipc'; + +const ANDROID_MIC_GRANTED_KEY = 'openless.androidMicrophoneGranted'; +const ANDROID_MIC_REQUESTED_KEY = 'openless.androidMicrophoneRequested'; + +export async function checkAndroidMicrophoneAccess(): Promise { + try { + const nativeStatus = await checkMicrophonePermission(); + if (nativeStatus === 'granted' || nativeStatus === 'notApplicable') { + localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); + return 'granted'; + } + if (nativeStatus === 'denied' || nativeStatus === 'restricted') { + localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); + return localStorage.getItem(ANDROID_MIC_REQUESTED_KEY) === '1' ? 'denied' : 'notDetermined'; + } + } catch { + // Fall through to WebView-local checks below. + } + + if (localStorage.getItem(ANDROID_MIC_GRANTED_KEY) === '1') { + return 'granted'; + } + + try { + const permissions = navigator.permissions; + if (permissions?.query) { + const status = await permissions.query({ name: 'microphone' as PermissionName }); + if (status.state === 'granted') return 'granted'; + if (status.state === 'denied') { + localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); + return 'denied'; + } + } + } catch { + // Android WebView versions differ on navigator.permissions support. + } + + return 'notDetermined'; +} + +export async function requestAndroidMicrophoneAccess(): Promise { + try { + localStorage.setItem(ANDROID_MIC_REQUESTED_KEY, '1'); + const nativeStatus = await requestMicrophonePermission(); + if (nativeStatus === 'granted' || nativeStatus === 'notApplicable') { + localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); + return 'granted'; + } + if (nativeStatus === 'denied' || nativeStatus === 'restricted') { + localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); + } + if (nativeStatus === 'notDetermined') { + return 'notDetermined'; + } + } catch { + // Fall through to WebView-local checks below. + } + + if (localStorage.getItem(ANDROID_MIC_GRANTED_KEY) === '1') { + return 'granted'; + } + + try { + const permissions = navigator.permissions; + if (permissions?.query) { + const status = await permissions.query({ name: 'microphone' as PermissionName }); + if (status.state === 'granted') { + localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); + return 'granted'; + } + if (status.state === 'denied') { + localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); + localStorage.setItem(ANDROID_MIC_REQUESTED_KEY, '1'); + return 'denied'; + } + } + } catch { + // Android WebView versions differ on navigator.permissions support. + } + + const mediaDevices = navigator.mediaDevices; + if (!mediaDevices?.getUserMedia) { + return 'notDetermined'; + } + + let stream: MediaStream | null = null; + try { + localStorage.setItem(ANDROID_MIC_REQUESTED_KEY, '1'); + stream = await mediaDevices.getUserMedia({ audio: true }); + localStorage.setItem(ANDROID_MIC_GRANTED_KEY, '1'); + return 'granted'; + } catch (error) { + console.warn('[android-mic] WebView microphone permission request failed', error); + localStorage.removeItem(ANDROID_MIC_GRANTED_KEY); + if (error instanceof DOMException && error.name === 'NotAllowedError') { + return 'denied'; + } + return 'notDetermined'; + } finally { + stream?.getTracks().forEach(track => track.stop()); + } +} diff --git a/openless-all/app/android/frontend/lib/androidTypes.ts b/openless-all/app/android/frontend/lib/androidTypes.ts new file mode 100644 index 00000000..387d5419 --- /dev/null +++ b/openless-all/app/android/frontend/lib/androidTypes.ts @@ -0,0 +1,38 @@ +/** Android-specific preference and status types (mirrors Rust IPC payloads). */ + +export type AndroidInsertStrategy = 'accessibility' | 'clipboard'; +export type AndroidOverlayTrigger = 'background' | 'keyboard' | 'always'; +export type AndroidOverlayActivationMode = 'tap' | 'long_press'; +export type AndroidOverlayLeftSwipeAction = 'translation' | 'style_pack'; +export type AndroidOverlayCancelSwipeDirection = 'up' | 'down'; + +export interface AndroidOverlayStatus { + permission: 'granted' | 'notGranted' | 'notAndroid'; + overlayVisible: boolean; + message: string; +} + +export interface AndroidAccessibilityStatus { + state: 'enabled' | 'notEnabled' | 'notAndroid'; + enabled: boolean; + message: string; +} + +export type AndroidPreferenceKey = + | 'androidInsertStrategy' + | 'androidOverlayTrigger' + | 'androidOverlayActivationMode' + | 'androidOverlayLeftSwipeAction' + | 'androidOverlayCancelSwipeDirection' + | 'androidOverlaySizeDp'; + +export function normalizeAndroidOverlayTrigger( + trigger: AndroidOverlayTrigger, +): AndroidOverlayTrigger { + return trigger === 'keyboard' ? 'background' : trigger; +} + +export function clampAndroidOverlaySize(size: number): number { + if (!Number.isFinite(size)) return 72; + return Math.min(120, Math.max(48, Math.round(size / 4) * 4)); +} diff --git a/openless-all/app/android/kotlin/MicrophonePermissionActivity.kt b/openless-all/app/android/kotlin/MicrophonePermissionActivity.kt new file mode 100644 index 00000000..746941f2 --- /dev/null +++ b/openless-all/app/android/kotlin/MicrophonePermissionActivity.kt @@ -0,0 +1,49 @@ +package com.openless.app + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.os.Bundle +import android.util.Log + +class MicrophonePermissionActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + OpenLessPermissionBridge.resolveRecordAudioPermission(true) + finish() + return + } + requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_RECORD_AUDIO) + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode != REQUEST_RECORD_AUDIO) { + return + } + val granted = grantResults.isNotEmpty() && + grantResults[0] == PackageManager.PERMISSION_GRANTED + Log.i(TAG, "RECORD_AUDIO permission result granted=$granted") + OpenLessPermissionBridge.resolveRecordAudioPermission(granted) + finish() + } + + override fun onDestroy() { + if (isFinishing) { + OpenLessPermissionBridge.resolveRecordAudioPermission( + checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED, + ) + } + super.onDestroy() + } + + companion object { + private const val TAG = "OpenLessMicPermission" + private const val REQUEST_RECORD_AUDIO = 9101 + } +} diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt new file mode 100644 index 00000000..a3d69ad4 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityCommandReceiver.kt @@ -0,0 +1,36 @@ +package com.openless.app + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.os.ResultReceiver +import android.util.Log + +class OpenLessAccessibilityCommandReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + if (intent?.action != ACTION_PASTE) return + val pasted = OpenLessAccessibilityService.performPasteFromCommand() + resultReceiver(intent)?.send( + if (pasted) RESULT_PASTE_SUCCESS else RESULT_PASTE_FAILED, + Bundle().apply { putBoolean(EXTRA_PASTE_RESULT, pasted) }, + ) + if (!pasted) { + Log.w(TAG, "paste command did not find an editable focused field") + } + } + + @Suppress("DEPRECATION") + private fun resultReceiver(intent: Intent): ResultReceiver? { + return intent.getParcelableExtra(EXTRA_RESULT_RECEIVER) as? ResultReceiver + } + + companion object { + const val ACTION_PASTE = "com.openless.app.accessibility.PASTE" + const val EXTRA_RESULT_RECEIVER = "result_receiver" + const val EXTRA_PASTE_RESULT = "paste_result" + const val RESULT_PASTE_FAILED = 0 + const val RESULT_PASTE_SUCCESS = 1 + private const val TAG = "OpenLessA11yCommand" + } +} diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt new file mode 100644 index 00000000..8a843049 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt @@ -0,0 +1,373 @@ +package com.openless.app + +import android.accessibilityservice.AccessibilityService +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.graphics.Rect +import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.os.ResultReceiver +import android.provider.Settings +import android.util.Log +import android.view.accessibility.AccessibilityEvent +import android.view.accessibility.AccessibilityNodeInfo +import android.view.accessibility.AccessibilityWindowInfo +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Detects IME windows for overlay keyboard trigger mode and performs paste insertion. + */ +class OpenLessAccessibilityService : AccessibilityService() { + private val mainHandler = Handler(Looper.getMainLooper()) + private val heartbeatRunnable = object : Runnable { + override fun run() { + markServiceAlive() + mainHandler.postDelayed(this, HEARTBEAT_INTERVAL_MS) + } + } + private val keyboardRefreshRunnable = Runnable { updateKeyboardOverlayState() } + private var lastEditableFocus: AccessibilityNodeInfo? = null + + override fun onServiceConnected() { + super.onServiceConnected() + instance = this + startHeartbeat() + updateKeyboardOverlayState() + scheduleKeyboardOverlayRefresh() + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + if (event == null) return + markServiceAlive() + when (event.eventType) { + AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, + AccessibilityEvent.TYPE_WINDOWS_CHANGED, + AccessibilityEvent.TYPE_VIEW_FOCUSED -> { + rememberFocusedEditable(event) + updateKeyboardOverlayState() + scheduleKeyboardOverlayRefresh() + } + } + } + + override fun onInterrupt() = Unit + + override fun onDestroy() { + mainHandler.removeCallbacks(heartbeatRunnable) + mainHandler.removeCallbacks(keyboardRefreshRunnable) + lastEditableFocus?.recycle() + lastEditableFocus = null + if (instance === this) { + instance = null + } + super.onDestroy() + } + + private fun scheduleKeyboardOverlayRefresh() { + mainHandler.removeCallbacks(keyboardRefreshRunnable) + for (delayMs in KEYBOARD_REFRESH_DELAYS_MS) { + mainHandler.postDelayed(keyboardRefreshRunnable, delayMs) + } + } + + private fun startHeartbeat() { + mainHandler.removeCallbacks(heartbeatRunnable) + heartbeatRunnable.run() + } + + private fun updateKeyboardOverlayState() { + if (!shouldTrackKeyboard()) { + return + } + if (!canDrawOverlays()) { + return + } + val imeBounds = findInputMethodBounds() + val intent = Intent(this, OpenLessOverlayService::class.java).apply { + action = OpenLessOverlayService.ACTION_KEYBOARD_CHANGED + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_VISIBLE, imeBounds != null) + imeBounds?.let { + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_TOP, it.top) + putExtra(OpenLessOverlayService.EXTRA_KEYBOARD_BOTTOM, it.bottom) + } + } + try { + Log.i(TAG, "keyboard overlay event visible=${imeBounds != null} bounds=$imeBounds") + startService(intent) + } catch (error: Throwable) { + Log.w(TAG, "send keyboard overlay event failed", error) + } + } + + private fun findInputMethodBounds(): Rect? { + for (window in windows) { + if (window.type != AccessibilityWindowInfo.TYPE_INPUT_METHOD) { + continue + } + val bounds = Rect() + window.getBoundsInScreen(bounds) + if (!bounds.isEmpty) { + return bounds + } + } + return null + } + + private fun shouldTrackKeyboard(): Boolean { + return OpenLessAndroidPreferences.isKeyboardOverlayTrigger(this) + } + + private fun canDrawOverlays(): Boolean { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + Settings.canDrawOverlays(this) + } else { + true + } + } + + private fun performPasteToFocusedField(): Boolean { + val target = findEditableTarget() ?: return false + return try { + target.performAction(AccessibilityNodeInfo.ACTION_FOCUS) + pasteWithRetryOrSetText(target) + } finally { + target.recycle() + } + } + + private fun rememberFocusedEditable(event: AccessibilityEvent) { + val source = event.source ?: return + try { + if (!source.isEditable) return + lastEditableFocus?.recycle() + lastEditableFocus = AccessibilityNodeInfo.obtain(source) + } finally { + source.recycle() + } + } + + private fun findEditableTarget(): AccessibilityNodeInfo? { + lastEditableFocus?.let { cached -> + if (cached.refresh() && cached.isEditable) { + return AccessibilityNodeInfo.obtain(cached) + } + } + val root = rootInActiveWindow ?: return null + editableFocusedNode(root, AccessibilityNodeInfo.FOCUS_INPUT)?.let { return it } + editableFocusedNode(root, AccessibilityNodeInfo.FOCUS_ACCESSIBILITY)?.let { return it } + return findEditableInTree(root, 0) + } + + private fun editableFocusedNode(root: AccessibilityNodeInfo, focusType: Int): AccessibilityNodeInfo? { + val focused = root.findFocus(focusType) ?: return null + if (focused.isEditable) { + return focused + } + focused.recycle() + return null + } + + private fun findEditableInTree(node: AccessibilityNodeInfo, depth: Int): AccessibilityNodeInfo? { + if (depth > MAX_EDITABLE_SEARCH_DEPTH) return null + var firstEditable: AccessibilityNodeInfo? = null + if (node.isEditable) { + if (node.isFocused) { + return AccessibilityNodeInfo.obtain(node) + } + firstEditable = AccessibilityNodeInfo.obtain(node) + } + for (index in 0 until node.childCount) { + val child = node.getChild(index) ?: continue + try { + findEditableInTree(child, depth + 1)?.let { found -> + firstEditable?.recycle() + return found + } + } finally { + child.recycle() + } + } + return firstEditable + } + + private fun pasteWithRetryOrSetText(target: AccessibilityNodeInfo): Boolean { + sleepQuietly(PASTE_INITIAL_DELAY_MS) + repeat(PASTE_RETRY_COUNT) { attempt -> + if (target.performAction(AccessibilityNodeInfo.ACTION_PASTE)) { + Log.i(TAG, "paste=true attempt=${attempt + 1} package=${target.packageName}") + return true + } + sleepQuietly(PASTE_RETRY_DELAY_MS) + } + val setText = appendClipboardTextWithSetText(target) + Log.i(TAG, "paste=false setText=$setText package=${target.packageName}") + return setText + } + + private fun appendClipboardTextWithSetText(target: AccessibilityNodeInfo): Boolean { + if (target.isPassword) return false + val clipboardText = clipboardText().takeIf { it.isNotEmpty() } ?: return false + val existingText = target.text?.toString().orEmpty() + val args = Bundle().apply { + putCharSequence( + AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE, + existingText + clipboardText, + ) + } + return target.performAction(AccessibilityNodeInfo.ACTION_SET_TEXT, args) + } + + private fun clipboardText(): String { + val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return "" + val clip = clipboard.primaryClip ?: return "" + if (clip.itemCount <= 0) return "" + return clip.getItemAt(0)?.coerceToText(this)?.toString().orEmpty() + } + + private fun sleepQuietly(delayMs: Long) { + try { + Thread.sleep(delayMs) + } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + } + } + + private fun captureSelectedTextFromFocusedNode(): String { + val root = rootInActiveWindow ?: return "" + val focused = root.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) + ?: root.findFocus(AccessibilityNodeInfo.FOCUS_ACCESSIBILITY) + focused?.let { + return try { + selectedTextFromNode(it) + } finally { + it.recycle() + } + } + return selectedTextFromTree(root) + } + + private fun selectedTextFromTree(node: AccessibilityNodeInfo?): String { + if (node == null) return "" + selectedTextFromNode(node).takeIf { it.isNotBlank() }?.let { return it } + for (index in 0 until node.childCount) { + val child = node.getChild(index) ?: continue + try { + selectedTextFromTree(child).takeIf { it.isNotBlank() }?.let { return it } + } finally { + child.recycle() + } + } + return "" + } + + private fun selectedTextFromNode(node: AccessibilityNodeInfo): String { + val text = node.text?.toString() ?: return "" + val start = node.textSelectionStart + val end = node.textSelectionEnd + if (start < 0 || end < 0 || start == end) return "" + val from = minOf(start, end).coerceIn(0, text.length) + val to = maxOf(start, end).coerceIn(0, text.length) + if (from >= to) return "" + return text.substring(from, to) + } + + private fun markServiceAlive() { + getSharedPreferences(PREFS_NAME, prefsMode()) + .edit() + .putLong(PREF_KEY_LAST_HEARTBEAT, System.currentTimeMillis()) + .apply() + } + + companion object { + @Volatile + var instance: OpenLessAccessibilityService? = null + private set + + @JvmStatic + fun pasteToFocusedField(): Boolean { + instance?.let { return it.performPasteToFocusedField() } + return sendPasteRequestToAccessibilityProcess() + } + + @JvmStatic + fun captureSelectedText(): String { + return instance?.captureSelectedTextFromFocusedNode().orEmpty() + } + + @JvmStatic + fun isEnabled(context: Context): Boolean { + val enabled = Settings.Secure.getInt( + context.contentResolver, + Settings.Secure.ACCESSIBILITY_ENABLED, + 0, + ) == 1 + if (!enabled) return false + val services = Settings.Secure.getString( + context.contentResolver, + Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES, + ) ?: return false + return services.contains("${context.packageName}/${OpenLessAccessibilityService::class.java.name}") + } + + @JvmStatic + fun isOperational(context: Context): Boolean { + if (!isEnabled(context)) return false + val lastHeartbeat = context + .getSharedPreferences(PREFS_NAME, prefsMode()) + .getLong(PREF_KEY_LAST_HEARTBEAT, 0L) + if (lastHeartbeat <= 0L) return false + return System.currentTimeMillis() - lastHeartbeat <= HEARTBEAT_STALE_MS + } + + internal fun performPasteFromCommand(): Boolean { + return instance?.performPasteToFocusedField() == true + } + + private fun sendPasteRequestToAccessibilityProcess(): Boolean { + val context = OpenLessAppContext.context ?: return false + if (!isOperational(context)) return false + val latch = CountDownLatch(1) + val success = AtomicBoolean(false) + val receiver = object : ResultReceiver(null) { + override fun onReceiveResult(resultCode: Int, resultData: Bundle?) { + success.set(resultCode == OpenLessAccessibilityCommandReceiver.RESULT_PASTE_SUCCESS) + latch.countDown() + } + } + return try { + val intent = Intent(context, OpenLessAccessibilityCommandReceiver::class.java).apply { + action = OpenLessAccessibilityCommandReceiver.ACTION_PASTE + putExtra(OpenLessAccessibilityCommandReceiver.EXTRA_RESULT_RECEIVER, receiver) + } + context.sendBroadcast(intent) + if (!latch.await(PASTE_COMMAND_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + Log.w(TAG, "accessibility paste result timed out") + return false + } + success.get() + } catch (error: Throwable) { + Log.w(TAG, "send accessibility paste request failed", error) + false + } + } + + @Suppress("DEPRECATION") + private fun prefsMode(): Int = Context.MODE_PRIVATE or Context.MODE_MULTI_PROCESS + + private val KEYBOARD_REFRESH_DELAYS_MS = longArrayOf(120L, 360L, 900L, 1600L) + private const val MAX_EDITABLE_SEARCH_DEPTH = 4 + private const val PASTE_INITIAL_DELAY_MS = 50L + private const val PASTE_RETRY_COUNT = 3 + private const val PASTE_RETRY_DELAY_MS = 80L + private const val PASTE_COMMAND_TIMEOUT_MS = 800L + private const val TAG = "OpenLessAccessibility" + private const val PREFS_NAME = "openless_accessibility" + private const val PREF_KEY_LAST_HEARTBEAT = "last_heartbeat" + private const val HEARTBEAT_INTERVAL_MS = 5_000L + private const val HEARTBEAT_STALE_MS = 15_000L + } +} diff --git a/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt new file mode 100644 index 00000000..1b06e9c8 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessAndroidPreferences.kt @@ -0,0 +1,112 @@ +package com.openless.app + +import android.content.Context +import android.util.Log +import java.io.File +import org.json.JSONObject + +/** + * Reads Android-visible preferences without depending on the Rust coordinator. + */ +object OpenLessAndroidPreferences { + private const val TAG = "OpenLessAndroidPrefs" + private const val APP_DIR = "OpenLess" + private const val PREFERENCES_FILE = "preferences.json" + private const val KEY_OVERLAY_TRIGGER = "androidOverlayTrigger" + private const val KEY_OVERLAY_ACTIVATION_MODE = "androidOverlayActivationMode" + private const val KEY_OVERLAY_LEFT_SWIPE_ACTION = "androidOverlayLeftSwipeAction" + private const val KEY_OVERLAY_CANCEL_SWIPE_DIRECTION = "androidOverlayCancelSwipeDirection" + private const val KEY_OVERLAY_SIZE_DP = "androidOverlaySizeDp" + private const val DEFAULT_OVERLAY_SIZE_DP = 72 + private const val MIN_OVERLAY_SIZE_DP = 48 + private const val MAX_OVERLAY_SIZE_DP = 120 + private val VALID_OVERLAY_TRIGGERS = setOf("background", "always") + private val VALID_OVERLAY_ACTIVATION_MODES = setOf("tap", "long_press") + private val VALID_OVERLAY_LEFT_SWIPE_ACTIONS = setOf("translation", "style_pack") + private val VALID_OVERLAY_CANCEL_SWIPE_DIRECTIONS = setOf("up", "down") + + fun overlayTriggerMode(context: Context): String? { + val value = readPreferenceString(context, KEY_OVERLAY_TRIGGER) ?: return null + if (value == "keyboard") { + return "background" + } + return value.takeIf { it in VALID_OVERLAY_TRIGGERS } + } + + /** True when preferences still store legacy `"keyboard"` (before migration). */ + fun isKeyboardOverlayTrigger(context: Context): Boolean { + return readPreferenceString(context, KEY_OVERLAY_TRIGGER) == "keyboard" + } + + fun overlayActivationMode(context: Context): String { + return readPreferenceString(context, KEY_OVERLAY_ACTIVATION_MODE) + ?.takeIf { it in VALID_OVERLAY_ACTIVATION_MODES } + ?: "tap" + } + + fun overlayLeftSwipeAction(context: Context): String { + return readPreferenceString(context, KEY_OVERLAY_LEFT_SWIPE_ACTION) + ?.takeIf { it in VALID_OVERLAY_LEFT_SWIPE_ACTIONS } + ?: "translation" + } + + fun overlayCancelSwipeDirection(context: Context): String { + return readPreferenceString(context, KEY_OVERLAY_CANCEL_SWIPE_DIRECTION) + ?.takeIf { it in VALID_OVERLAY_CANCEL_SWIPE_DIRECTIONS } + ?: "up" + } + + fun overlaySizeDp(context: Context): Int { + return readPreferenceInt(context, KEY_OVERLAY_SIZE_DP) + ?.coerceIn(MIN_OVERLAY_SIZE_DP, MAX_OVERLAY_SIZE_DP) + ?: DEFAULT_OVERLAY_SIZE_DP + } + + private fun readPreferenceString(context: Context, key: String): String? { + for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { + if (!file.isFile) { + continue + } + val value = try { + JSONObject(file.readText()).optString(key, "") + } catch (error: Throwable) { + Log.w(TAG, "read ${file.absolutePath} failed", error) + "" + } + if (value.isNotBlank()) { + return value + } + } + return null + } + + private fun readPreferenceInt(context: Context, key: String): Int? { + for (file in preferenceFiles(context).distinctBy { it.absolutePath }) { + if (!file.isFile) { + continue + } + val value = try { + val json = JSONObject(file.readText()) + if (json.has(key)) json.optInt(key) else null + } catch (error: Throwable) { + Log.w(TAG, "read ${file.absolutePath} failed", error) + null + } + if (value != null) { + return value + } + } + return null + } + + private fun preferenceFiles(context: Context): List { + val files = mutableListOf() + val envDir = System.getenv("TAURI_ANDROID_APP_DATA_DIR") + if (!envDir.isNullOrBlank()) { + files += File(File(envDir), APP_DIR).resolve(PREFERENCES_FILE) + } + files += File(File(context.cacheDir, APP_DIR), PREFERENCES_FILE) + files += File(File(context.filesDir, APP_DIR), PREFERENCES_FILE) + return files + } +} diff --git a/openless-all/app/android/kotlin/OpenLessAppContext.kt b/openless-all/app/android/kotlin/OpenLessAppContext.kt new file mode 100644 index 00000000..e1c627ae --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessAppContext.kt @@ -0,0 +1,13 @@ +package com.openless.app + +import android.content.Context + +object OpenLessAppContext { + @Volatile + var context: Context? = null + private set + + fun initialize(context: Context) { + this.context = context.applicationContext + } +} diff --git a/openless-all/app/android/kotlin/OpenLessApplication.kt b/openless-all/app/android/kotlin/OpenLessApplication.kt new file mode 100644 index 00000000..8e25fd93 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessApplication.kt @@ -0,0 +1,84 @@ +package com.openless.app + +import android.app.Activity +import android.app.Application +import android.content.Intent +import android.os.Bundle +import android.provider.Settings +import android.util.Log + +/** + * Registers activity lifecycle hooks for overlay background trigger mode. + */ +class OpenLessApplication : Application() { + override fun onCreate() { + super.onCreate() + OpenLessAppContext.initialize(this) + registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + override fun onActivityStarted(activity: Activity) { + if (activity.javaClass.name.endsWith("MainActivity")) { + maybeHideOverlayOnForeground() + } + } + override fun onActivityResumed(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) { + if (activity.javaClass.name.endsWith("MainActivity")) { + maybeShowOverlayOnBackground() + } + } + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + }) + } + + private fun maybeShowOverlayOnBackground() { + val configured = configuredOverlayTriggerMode() + val shouldShow = configured == "background" || + configured == "always" + if (!shouldShow) { + return + } + if (!canDrawOverlays()) { + return + } + sendOverlayAction(OpenLessOverlayService.ACTION_SHOW) + } + + private fun maybeHideOverlayOnForeground() { + if (configuredOverlayTriggerMode() == "always") { + if (canDrawOverlays()) { + sendOverlayAction(OpenLessOverlayService.ACTION_SHOW) + } + return + } + sendOverlayAction(OpenLessOverlayService.ACTION_HIDE) + } + + private fun configuredOverlayTriggerMode(): String { + return OpenLessAndroidPreferences.overlayTriggerMode(this) ?: "background" + } + + private fun canDrawOverlays(): Boolean { + return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + Settings.canDrawOverlays(this) + } else { + true + } + } + + private fun sendOverlayAction(action: String) { + try { + startService(Intent(this, OpenLessOverlayService::class.java).apply { + this.action = action + }) + } catch (error: Throwable) { + Log.w(TAG, "overlay action failed: $action", error) + } + } + + companion object { + private const val TAG = "OpenLessApplication" + } +} diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt new file mode 100644 index 00000000..3c6f297a --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -0,0 +1,42 @@ +package com.openless.app + +/** + * JNI bridge from Kotlin overlay / lifecycle code into Rust Coordinator. + */ +object OpenLessNative { + init { + try { + System.loadLibrary("openless_lib") + } catch (error: UnsatisfiedLinkError) { + android.util.Log.e("OpenLessNative", "failed to load openless_lib", error) + } + } + + @JvmStatic external fun nativeStartDictation() + + @JvmStatic external fun nativeStartDictationWithTranslation(translation: Boolean) + + @JvmStatic external fun nativeStopDictation() + + @JvmStatic external fun nativeStopDictationWithTranslation(translation: Boolean) + + @JvmStatic external fun nativeCancelDictation() + + @JvmStatic external fun nativeSwitchStylePack() + + @JvmStatic external fun nativeOpenQaFromOverlay() + + @JvmStatic external fun nativeFinalizeQaFromOverlay() + + @JvmStatic external fun nativeGetOverlayTriggerMode(): String + + @JvmStatic external fun nativeCanDrawOverlays(context: android.content.Context): Boolean + + @JvmStatic external fun nativeShowOverlay(context: android.content.Context) + + @JvmStatic external fun nativeHideOverlay(context: android.content.Context) + + @JvmStatic external fun nativeIsOverlayVisible(): Boolean + + @JvmStatic external fun nativeNotifyOverlayPermissionChanged(context: android.content.Context) +} diff --git a/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt b/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt new file mode 100644 index 00000000..92406730 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt @@ -0,0 +1,33 @@ +package com.openless.app + +import android.os.Handler +import android.os.Looper + +/** + * Rust calls back into this object to refresh overlay UI state. + */ +object OpenLessOverlayBridge { + private val mainHandler = Handler(Looper.getMainLooper()) + + @Volatile + var listener: OverlayStateListener? = null + + interface OverlayStateListener { + fun onCapsuleStateChanged(state: String, message: String?) + } + + @JvmStatic + fun onCapsuleStateChanged(state: String, message: String?) { + mainHandler.post { + listener?.onCapsuleStateChanged(state, message) + } + } + + @JvmStatic + fun showToast(message: String) { + mainHandler.post { + val service = OpenLessOverlayService.instance ?: return@post + android.widget.Toast.makeText(service.applicationContext, message, android.widget.Toast.LENGTH_SHORT).show() + } + } +} diff --git a/openless-all/app/android/kotlin/OpenLessOverlayService.kt b/openless-all/app/android/kotlin/OpenLessOverlayService.kt new file mode 100644 index 00000000..658d22eb --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessOverlayService.kt @@ -0,0 +1,831 @@ +package com.openless.app + +import android.Manifest +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.graphics.Color +import android.graphics.PixelFormat +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.IBinder +import android.provider.Settings +import android.util.Log +import android.view.Gravity +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.Toast +import kotlin.math.abs + +/** + * Foreground service + TYPE_APPLICATION_OVERLAY floating dictation control. + */ +class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateListener { + + private var windowManager: WindowManager? = null + private var rootView: FrameLayout? = null + private var layoutParams: WindowManager.LayoutParams? = null + private var recording = false + private var processing = false + private var keyboardVisible = false + private var armed = false + private var dragStartX = 0 + private var dragStartY = 0 + private var paramStartX = 0 + private var paramStartY = 0 + private var dragging = false + private var longPressRecording = false + private var pendingSwipe: SwipeDirection? = null + private var swipeConsumed = false + + private lateinit var iconContainer: FrameLayout + private lateinit var iconButton: ImageView + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + instance = this + OpenLessOverlayBridge.listener = this + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.i( + TAG, + "onStartCommand action=${intent?.action} startId=$startId rootAttached=${rootView?.isAttachedToWindow}", + ) + when (intent?.action) { + ACTION_SHOW -> showOverlay() + ACTION_START_RECORDING -> { + showOverlay() + startRecordingFromOverlay() + } + ACTION_HIDE -> { + hideOverlay() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + stopSelf(startId) + } + ACTION_REPLACE_OVERLAY -> replaceOverlay() + ACTION_TOGGLE_EXPAND -> handleIconClick() + ACTION_KEYBOARD_CHANGED -> handleKeyboardChanged(intent) + ACTION_REFRESH_LAYOUT -> refreshOverlayLayout() + } + return START_STICKY + } + + override fun onDestroy() { + if (OpenLessOverlayBridge.listener === this) { + OpenLessOverlayBridge.listener = null + } + hideOverlay() + if (instance === this) { + instance = null + } + super.onDestroy() + } + + override fun onCapsuleStateChanged(state: String, message: String?) { + when (state) { + "recording" -> { + recording = true + processing = false + if (!tryPromoteRecordingForeground()) { + try { + OpenLessNative.nativeCancelDictation() + } catch (error: Throwable) { + Log.w(TAG, "cancel dictation bridge unavailable", error) + } + return + } + applyVisualState(OverlayVisualState.Recording) + } + "transcribing", "polishing" -> { + recording = false + processing = true + applyVisualState(OverlayVisualState.Processing) + } + "done" -> { + recording = false + processing = false + setArmed(false) + } + "error" -> { + recording = false + processing = false + setArmed(false) + applyVisualState(OverlayVisualState.Error) + message?.takeIf { it.isNotBlank() }?.let { showToast(it) } + } + "cancelled", "idle" -> { + recording = false + processing = false + setArmed(false) + } + } + } + + private fun showOverlay() = withOverlayLock { + windowManager = getSystemService(WINDOW_SERVICE) as WindowManager + reconcileOverlayRoots() + overlayRoots.lastOrNull()?.let { existing -> + val params = existing.layoutParams as? WindowManager.LayoutParams + if (params != null) { + refreshExistingOverlayLayout(existing, params) + Log.i(TAG, "overlay already shown roots=${overlayRoots.size}") + return@withOverlayLock + } + removeOverlayRoot(existing) + synchronized(overlayRoots) { + overlayRoots.remove(existing) + } + if (rootView === existing) { + rootView = null + layoutParams = null + } + } + attachNewOverlayRoot() + } + + private fun replaceOverlay() = withOverlayLock { + windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager + reconcileOverlayRoots() + val removed = clearAllOverlayRoots() + if (removed > 0) { + Log.i(TAG, "overlay replace cleared removed=$removed") + } + if (!canDrawOverlays()) { + Log.i(TAG, "overlay replace skipped no overlay permission") + return@withOverlayLock + } + if (!attachNewOverlayRoot()) { + return@withOverlayLock + } + if (recording) { + tryPromoteRecordingForeground() + } + Log.i(TAG, "overlay replaced roots=1") + } + + private fun hideOverlay() = withOverlayLock { + val removed = clearAllOverlayRoots() + if (removed > 0) { + Log.i(TAG, "overlay hidden removed=$removed") + } + } + + private fun clearAllOverlayRoots(): Int { + windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager + val views = synchronized(overlayRoots) { + (overlayRoots + listOfNotNull(rootView)).distinct().also { + overlayRoots.clear() + } + } + views.forEach { view -> + removeOverlayRoot(view) + } + rootView = null + layoutParams = null + return views.size + } + + private fun attachNewOverlayRoot(): Boolean { + val savedPosition = loadSavedPosition() + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.WRAP_CONTENT, + WindowManager.LayoutParams.WRAP_CONTENT, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + } else { + @Suppress("DEPRECATION") + WindowManager.LayoutParams.TYPE_PHONE + }, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or + WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or + WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN, + PixelFormat.TRANSLUCENT, + ).apply { + gravity = Gravity.TOP or Gravity.START + x = savedPosition.first + y = savedPosition.second + } + layoutParams = params + + val root = FrameLayout(this).apply { + contentDescription = "OpenLess" + isClickable = true + isFocusable = false + setOnClickListener { handleIconClick() } + } + iconContainer = root + iconButton = buildIconButton() + root.addView( + iconButton, + FrameLayout.LayoutParams(1, 1, Gravity.CENTER), + ) + applyOverlaySize(root) + attachDragHandler(root, params) + return try { + windowManager?.addView(root, params) + rootView = root + synchronized(overlayRoots) { + overlayRoots.add(root) + } + Log.i(TAG, "overlay shown x=${params.x} y=${params.y} roots=${overlayRoots.size}") + applyVisualState( + when { + recording -> OverlayVisualState.Recording + processing -> OverlayVisualState.Processing + armed -> OverlayVisualState.Armed + else -> OverlayVisualState.Idle + }, + ) + true + } catch (error: Throwable) { + Log.w(TAG, "show overlay failed", error) + layoutParams = null + false + } + } + + private fun canDrawOverlays(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Settings.canDrawOverlays(this) + } else { + true + } + } + + private fun refreshExistingOverlayLayout( + root: FrameLayout, + params: WindowManager.LayoutParams, + ) { + rootView = root + layoutParams = params + iconContainer = root + (root.getChildAt(0) as? ImageView)?.let { iconButton = it } + applyOverlaySize(root) + attachDragHandler(root, params) + clampToScreen(params) + windowManager?.updateViewLayout(root, params) + } + + private fun refreshOverlayLayout() = withOverlayLock { + windowManager = windowManager ?: getSystemService(WINDOW_SERVICE) as WindowManager + reconcileOverlayRoots() + val root = overlayRoots.lastOrNull() ?: rootView + if (root == null || !root.isAttachedToWindow) { + Log.i(TAG, "overlay refresh skipped roots=0") + return@withOverlayLock + } + val params = root.layoutParams as? WindowManager.LayoutParams + if (params == null) { + Log.i(TAG, "overlay refresh skipped roots=0") + return@withOverlayLock + } + refreshExistingOverlayLayout(root, params) + val sizeDp = OpenLessAndroidPreferences.overlaySizeDp(this) + Log.i(TAG, "overlay layout refreshed sizeDp=$sizeDp roots=1") + } + + private fun reconcileOverlayRoots() { + val roots = synchronized(overlayRoots) { + (overlayRoots + listOfNotNull(rootView)).distinct().filter { it.isAttachedToWindow }.also { + overlayRoots.clear() + overlayRoots.addAll(it) + } + } + if (roots.isEmpty()) { + rootView = null + layoutParams = null + return + } + roots.dropLast(1).forEach { staleRoot -> + removeOverlayRoot(staleRoot) + synchronized(overlayRoots) { + overlayRoots.remove(staleRoot) + } + } + val activeRoot = roots.last() + synchronized(overlayRoots) { + overlayRoots.clear() + overlayRoots.add(activeRoot) + } + rootView = activeRoot + layoutParams = activeRoot.layoutParams as? WindowManager.LayoutParams + Log.i(TAG, "reconciled overlay roots kept=1 removed=${roots.size - 1}") + } + + private fun removeOverlayRoot(view: FrameLayout) { + try { + if (view.isAttachedToWindow) { + windowManager?.removeViewImmediate(view) + } + } catch (error: Throwable) { + Log.w(TAG, "remove overlay root failed", error) + } + } + + private fun buildIconButton(): ImageView { + return ImageView(this).apply { + setImageResource(R.drawable.ic_overlay_logo) + scaleType = ImageView.ScaleType.CENTER_INSIDE + setPadding(0, 0, 0, 0) + contentDescription = "OpenLess" + isClickable = false + isFocusable = false + } + } + + private fun applyOverlaySize(container: FrameLayout) { + if (!::iconButton.isInitialized) return + val sizeDp = OpenLessAndroidPreferences.overlaySizeDp(this) + val paddingDp = overlayPaddingDp(sizeDp) + val imageSizePx = dp((sizeDp - paddingDp * 2).coerceAtLeast(MIN_ICON_IMAGE_SIZE_DP)) + val paddingPx = dp(paddingDp) + container.setPadding(paddingPx, paddingPx, paddingPx, paddingPx) + (iconButton.layoutParams as? FrameLayout.LayoutParams)?.let { childParams -> + childParams.width = imageSizePx + childParams.height = imageSizePx + childParams.gravity = Gravity.CENTER + iconButton.layoutParams = childParams + } + container.requestLayout() + Log.i(TAG, "overlay size applied sizeDp=$sizeDp imagePx=$imageSizePx") + } + + private fun overlayPaddingDp(sizeDp: Int): Int { + return (sizeDp * ICON_PADDING_DP / DEFAULT_ICON_SIZE_DP).coerceIn(8, 20) + } + + private fun handleIconClick() { + if (processing) return + if (recording) { + stopRecordingFromOverlay() + return + } + if (!isTapActivationMode()) { + return + } + startRecordingFromOverlay() + } + + private fun handleKeyboardChanged(intent: Intent) { + val visible = intent.getBooleanExtra(EXTRA_KEYBOARD_VISIBLE, false) + keyboardVisible = visible + Log.i(TAG, "keyboard changed visible=$visible") + if (visible) { + showOverlay() + return + } + if (!recording && !processing) { + hideOverlay() + } + } + + private fun attachDragHandler(view: View, params: WindowManager.LayoutParams) { + view.setOnTouchListener { touchedView, event -> + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + dragging = false + swipeConsumed = false + longPressRecording = false + pendingSwipe = null + if (processing) { + return@setOnTouchListener true + } + dragStartX = event.rawX.toInt() + dragStartY = event.rawY.toInt() + paramStartX = params.x + paramStartY = params.y + if (!isTapActivationMode() && !recording && !processing) { + longPressRecording = true + startRecordingFromOverlay() + } + true + } + MotionEvent.ACTION_MOVE -> { + val dx = event.rawX.toInt() - dragStartX + val dy = event.rawY.toInt() - dragStartY + val verticalSwipe = detectVerticalSwipe(dx, dy) + if (recording && verticalSwipe != null && matchesConfiguredCancelSwipe(verticalSwipe) && !swipeConsumed) { + pendingSwipe = verticalSwipe + swipeConsumed = true + applySwipePreview(verticalSwipe) + return@setOnTouchListener true + } + val swipe = detectHorizontalSwipe(dx, dy) + if ((recording || armed || longPressRecording) && swipe != null && !swipeConsumed) { + pendingSwipe = swipe + swipeConsumed = true + applySwipePreview(swipe) + return@setOnTouchListener true + } + if (!processing && !armed && !recording && !longPressRecording && (abs(dx) > DRAG_SLOP_PX || abs(dy) > DRAG_SLOP_PX)) { + dragging = true + params.x = paramStartX + dx + params.y = paramStartY + dy + clampToScreen(params) + rootView?.let { windowManager?.updateViewLayout(it, params) } + } + true + } + MotionEvent.ACTION_UP -> { + if (!dragging) { + val swipe = pendingSwipe + if (swipe != null) { + commitSwipe(swipe) + } else if (longPressRecording || (!isTapActivationMode() && recording)) { + stopRecordingFromOverlay() + } else if (!isTapActivationMode()) { + setArmed(false) + } else if (!swipeConsumed) { + touchedView.performClick() + } + } else { + savePosition(params.x, params.y) + } + longPressRecording = false + pendingSwipe = null + swipeConsumed = false + true + } + MotionEvent.ACTION_CANCEL -> { + if (longPressRecording || (!isTapActivationMode() && recording)) { + stopRecordingFromOverlay() + } + longPressRecording = false + pendingSwipe = null + swipeConsumed = false + true + } + else -> false + } + } + } + + private fun applyVisualState(state: OverlayVisualState) { + if (!::iconContainer.isInitialized || !::iconButton.isInitialized) return + val (alpha, fill, stroke, strokeWidth, enabled) = when (state) { + OverlayVisualState.Idle -> VisualStyle( + alpha = 0.58f, + fill = Color.parseColor("#66202A36"), + stroke = Color.parseColor("#66FFFFFF"), + strokeWidth = 1, + enabled = true, + ) + OverlayVisualState.Armed -> VisualStyle( + alpha = 1f, + fill = Color.parseColor("#E6111827"), + stroke = Color.parseColor("#38BDF8"), + strokeWidth = 3, + enabled = true, + ) + OverlayVisualState.Recording -> VisualStyle( + alpha = 1f, + fill = Color.parseColor("#E6111827"), + stroke = Color.parseColor("#F43F5E"), + strokeWidth = 3, + enabled = true, + ) + OverlayVisualState.Processing -> VisualStyle( + alpha = 0.86f, + fill = Color.parseColor("#D1111827"), + stroke = Color.parseColor("#38BDF8"), + strokeWidth = 2, + enabled = true, + ) + OverlayVisualState.Error -> VisualStyle( + alpha = 0.95f, + fill = Color.parseColor("#E67F1D1D"), + stroke = Color.parseColor("#EF4444"), + strokeWidth = 2, + enabled = true, + ) + } + iconContainer.alpha = alpha + iconContainer.isEnabled = enabled + iconContainer.background = circleDrawable(fill, stroke, dp(strokeWidth)) + iconButton.isEnabled = enabled + } + + private fun setArmed(value: Boolean) { + armed = value + if (!recording && !processing) { + applyVisualState(if (value) OverlayVisualState.Armed else OverlayVisualState.Idle) + } + } + + private fun detectHorizontalSwipe(dx: Int, dy: Int): SwipeDirection? { + if (abs(dx) < dp(SWIPE_THRESHOLD_DP)) return null + if (abs(dy) > abs(dx) * SWIPE_VERTICAL_RATIO) return null + return if (dx < 0) SwipeDirection.Left else SwipeDirection.Right + } + + private fun detectVerticalSwipe(dx: Int, dy: Int): SwipeDirection? { + if (abs(dy) < dp(SWIPE_THRESHOLD_DP)) return null + if (abs(dx) > abs(dy) * SWIPE_VERTICAL_RATIO) return null + return if (dy < 0) SwipeDirection.Up else SwipeDirection.Down + } + + private fun matchesConfiguredCancelSwipe(direction: SwipeDirection): Boolean { + val configured = OpenLessAndroidPreferences.overlayCancelSwipeDirection(this) + return (direction == SwipeDirection.Up && configured == "up") || + (direction == SwipeDirection.Down && configured == "down") + } + + private fun applySwipePreview(direction: SwipeDirection) { + when (direction) { + SwipeDirection.Left -> applyVisualState(OverlayVisualState.Armed) + SwipeDirection.Right -> applyVisualState(OverlayVisualState.Processing) + SwipeDirection.Up, + SwipeDirection.Down -> applyVisualState(OverlayVisualState.Error) + } + } + + private fun commitSwipe(direction: SwipeDirection) { + Log.i(TAG, "commit swipe direction=$direction recording=$recording processing=$processing") + when (direction) { + SwipeDirection.Left -> handleLeftSwipe() + SwipeDirection.Right -> finalizeQaFromOverlay() + SwipeDirection.Up, + SwipeDirection.Down -> cancelRecordingFromOverlay(direction) + } + } + + private fun cancelRecordingFromOverlay(direction: SwipeDirection) { + if (!recording || !matchesConfiguredCancelSwipe(direction)) { + return + } + try { + OpenLessNative.nativeCancelDictation() + recording = false + processing = false + longPressRecording = false + setArmed(false) + applyVisualState(OverlayVisualState.Idle) + Log.i(TAG, "recording cancelled from overlay direction=$direction") + } catch (error: Throwable) { + Log.w(TAG, "cancel dictation bridge unavailable", error) + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun handleLeftSwipe() { + when (OpenLessAndroidPreferences.overlayLeftSwipeAction(this)) { + "style_pack" -> { + switchStylePackFromOverlay() + if (recording) { + stopRecordingFromOverlay() + } + } + else -> stopRecordingFromOverlay(translation = true) + } + } + + private fun switchStylePackFromOverlay() { + try { + OpenLessNative.nativeSwitchStylePack() + setArmed(false) + } catch (error: Throwable) { + Log.w(TAG, "switch style pack bridge unavailable", error) + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun openQaFromOverlay() { + try { + Log.i(TAG, "open QA from overlay") + OpenLessNative.nativeOpenQaFromOverlay() + setArmed(false) + } catch (error: Throwable) { + Log.w(TAG, "open QA bridge unavailable", error) + applyVisualState(OverlayVisualState.Error) + showToast("问答服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun finalizeQaFromOverlay() { + try { + Log.i(TAG, "finalize QA from overlay") + OpenLessNative.nativeFinalizeQaFromOverlay() + recording = false + processing = true + setArmed(false) + applyVisualState(OverlayVisualState.Processing) + } catch (error: Throwable) { + Log.w(TAG, "finalize QA bridge unavailable", error) + processing = false + applyVisualState(OverlayVisualState.Error) + showToast("问答服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun startRecordingFromOverlay(translation: Boolean = false) { + showOverlay() + if (tryPromoteRecordingForeground()) { + try { + if (translation) { + OpenLessNative.nativeStartDictationWithTranslation(true) + } else { + OpenLessNative.nativeStartDictation() + } + recording = true + processing = false + setArmed(false) + applyVisualState(OverlayVisualState.Recording) + } catch (error: Throwable) { + Log.w(TAG, "start dictation bridge unavailable", error) + recording = false + processing = false + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } + return + } + applyVisualState(OverlayVisualState.Error) + } + + private fun stopRecordingFromOverlay(translation: Boolean = false) { + try { + recording = false + processing = true + applyVisualState(OverlayVisualState.Processing) + if (translation) { + OpenLessNative.nativeStopDictationWithTranslation(true) + } else { + OpenLessNative.nativeStopDictation() + } + } catch (error: Throwable) { + Log.w(TAG, "stop dictation bridge unavailable", error) + recording = false + processing = false + applyVisualState(OverlayVisualState.Error) + showToast("语音服务未就绪,请打开 OpenLess 后重试") + } + } + + private fun tryPromoteRecordingForeground(): Boolean { + if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) { + showToast("请先授予麦克风权限") + return false + } + val notification = buildNotification("录音中") + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE, + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + true + } catch (error: SecurityException) { + Log.w(TAG, "microphone foreground service not allowed from current state", error) + showToast("系统限制后台录音,请在 OpenLess 内开始") + false + } + } + + private fun buildNotification(contentText: String): Notification { + val channelId = "openless_overlay" + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val nm = getSystemService(NotificationManager::class.java) + nm.createNotificationChannel( + NotificationChannel(channelId, "OpenLess Overlay", NotificationManager.IMPORTANCE_LOW), + ) + } + return Notification.Builder(this, channelId) + .setContentTitle("OpenLess") + .setContentText(contentText) + .setSmallIcon(R.mipmap.ic_launcher) + .build() + } + + private fun circleDrawable(color: Int, strokeColor: Int, strokeWidth: Int): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(color) + setStroke(strokeWidth, strokeColor) + } + } + + private fun overlaySize(): Int { + val root = rootView + val measured = maxOf(root?.width ?: 0, root?.height ?: 0) + return measured.takeIf { it > 0 } ?: dp(OpenLessAndroidPreferences.overlaySizeDp(this)) + } + + private fun clampToScreen(params: WindowManager.LayoutParams) { + val iconSize = overlaySize() + val margin = dp(8) + val maxX = (resources.displayMetrics.widthPixels - iconSize - margin).coerceAtLeast(margin) + val maxY = (resources.displayMetrics.heightPixels - iconSize - margin).coerceAtLeast(margin) + params.x = params.x.coerceIn(margin, maxX) + params.y = params.y.coerceIn(margin, maxY) + } + + private fun loadSavedPosition(): Pair { + val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + val defaultX = dp(24) + val defaultY = dp(120) + val x = prefs.getInt(PREF_KEY_X, defaultX) + val y = prefs.getInt(PREF_KEY_Y, defaultY) + return x to y + } + + private fun savePosition(x: Int, y: Int) { + getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + .edit() + .putInt(PREF_KEY_X, x) + .putInt(PREF_KEY_Y, y) + .apply() + } + + private fun isTapActivationMode(): Boolean { + return OpenLessAndroidPreferences.overlayActivationMode(this) == "tap" + } + + private fun showToast(message: String) { + Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + } + + private fun dp(value: Int): Int { + return (value * resources.displayMetrics.density).toInt() + } + + private inline fun withOverlayLock(block: () -> T): T { + return synchronized(overlayLock) { + block() + } + } + + private data class VisualStyle( + val alpha: Float, + val fill: Int, + val stroke: Int, + val strokeWidth: Int, + val enabled: Boolean, + ) + + private enum class OverlayVisualState { + Idle, + Armed, + Recording, + Processing, + Error, + } + + private enum class SwipeDirection { + Left, + Right, + Up, + Down, + } + + companion object { + const val ACTION_SHOW = "com.openless.app.overlay.SHOW" + const val ACTION_HIDE = "com.openless.app.overlay.HIDE" + const val ACTION_REPLACE_OVERLAY = "com.openless.app.overlay.REPLACE_OVERLAY" + const val ACTION_REFRESH_LAYOUT = "com.openless.app.overlay.REFRESH_LAYOUT" + const val ACTION_TOGGLE_EXPAND = "com.openless.app.overlay.TOGGLE_EXPAND" + const val ACTION_START_RECORDING = "com.openless.app.overlay.START_RECORDING" + const val ACTION_KEYBOARD_CHANGED = "com.openless.app.overlay.KEYBOARD_CHANGED" + const val EXTRA_KEYBOARD_VISIBLE = "keyboard_visible" + const val EXTRA_KEYBOARD_TOP = "keyboard_top" + const val EXTRA_KEYBOARD_BOTTOM = "keyboard_bottom" + private const val DEFAULT_ICON_SIZE_DP = 72 + private const val MIN_ICON_IMAGE_SIZE_DP = 32 + private const val ICON_PADDING_DP = 12 + private const val DRAG_SLOP_PX = 8 + private const val SWIPE_THRESHOLD_DP = 56 + private const val SWIPE_VERTICAL_RATIO = 0.6f + private const val PREFS_NAME = "openless_overlay" + private const val PREF_KEY_X = "overlay_x" + private const val PREF_KEY_Y = "overlay_y" + private const val NOTIFICATION_ID = 42001 + private const val TAG = "OpenLessOverlayService" + + private val overlayLock = Any() + private val overlayRoots = mutableListOf() + + @Volatile + var instance: OpenLessOverlayService? = null + private set + } +} diff --git a/openless-all/app/android/kotlin/OpenLessPermissionBridge.kt b/openless-all/app/android/kotlin/OpenLessPermissionBridge.kt new file mode 100644 index 00000000..9fb437fd --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessPermissionBridge.kt @@ -0,0 +1,46 @@ +package com.openless.app + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import java.util.concurrent.atomic.AtomicBoolean + +object OpenLessPermissionBridge { + private const val TAG = "OpenLessPermissionBridge" + + private val requestInFlight = AtomicBoolean(false) + + @JvmStatic + fun requestRecordAudioPermission(context: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return true + } + if (context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + return true + } + if (!requestInFlight.compareAndSet(false, true)) { + Log.i(TAG, "RECORD_AUDIO permission request already in flight") + return false + } + return try { + val intent = Intent(context, MicrophonePermissionActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + false + } catch (error: Throwable) { + requestInFlight.set(false) + Log.w(TAG, "failed to launch RECORD_AUDIO permission activity", error) + context.checkSelfPermission(Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED + } + } + + @JvmStatic + fun resolveRecordAudioPermission(granted: Boolean) { + Log.i(TAG, "RECORD_AUDIO permission completed granted=$granted") + requestInFlight.set(false) + } +} diff --git a/openless-all/app/android/kotlin/OverlayPermissionActivity.kt b/openless-all/app/android/kotlin/OverlayPermissionActivity.kt new file mode 100644 index 00000000..d3a05f8f --- /dev/null +++ b/openless-all/app/android/kotlin/OverlayPermissionActivity.kt @@ -0,0 +1,27 @@ +package com.openless.app + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings + +/** + * 引导用户授权 SYSTEM_ALERT_WINDOW。 + * Rust 命令 request_android_overlay_permission 通过 Intent 启动本 Activity。 + */ +class OverlayPermissionActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && !Settings.canDrawOverlays(this)) { + val intent = Intent( + Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:$packageName"), + ) + startActivity(intent) + } + finish() + } +} diff --git a/openless-all/app/android/kotlin/README.md b/openless-all/app/android/kotlin/README.md new file mode 100644 index 00000000..465107de --- /dev/null +++ b/openless-all/app/android/kotlin/README.md @@ -0,0 +1,27 @@ +# Android Kotlin scaffolding + +Copy these files into `src-tauri/gen/android/` after running: + +```bash +cd openless-all/app +npm run tauri:android:init +``` + +## Copy / merge paths + +| Source (this folder) | Destination (after init) | +| --- | --- | +| `OpenLessOverlayService.kt` | `gen/android/app/src/main/java/com/openless/app/OpenLessOverlayService.kt` | +| `OverlayPermissionActivity.kt` | `gen/android/app/src/main/java/com/openless/app/OverlayPermissionActivity.kt` | +| `AndroidManifest.v1.snippet.xml` | 在 `../manifests/`,merge 进 `gen/android/.../AndroidManifest.xml` | +| `AndroidManifest.v3.snippet.xml` | 在 `../manifests/`,**future / not complete** — overlay v3 only | + +Tauri `android init` generates the base manifest under `gen/android/app/src/main/AndroidManifest.xml`. +Merge the v1 snippet permissions into that file before building APK v1. + +## Manifest snippets + +- **v1** (`AndroidManifest.v1.snippet.xml`): `RECORD_AUDIO` and `MODIFY_AUDIO_SETTINGS` for in-app dictation — required for APK v1. +- **v3** (`AndroidManifest.v3.snippet.xml`): overlay + foreground service — **not complete / future**. + +Do not treat v3 snippets as shipped; they document planned permissions and service entries only. diff --git a/openless-all/app/android/manifests/AndroidManifest.v1.snippet.xml b/openless-all/app/android/manifests/AndroidManifest.v1.snippet.xml new file mode 100644 index 00000000..0afe73c0 --- /dev/null +++ b/openless-all/app/android/manifests/AndroidManifest.v1.snippet.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/openless-all/app/android/manifests/AndroidManifest.v3.snippet.xml b/openless-all/app/android/manifests/AndroidManifest.v3.snippet.xml new file mode 100644 index 00000000..859f99b6 --- /dev/null +++ b/openless-all/app/android/manifests/AndroidManifest.v3.snippet.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + diff --git a/openless-all/app/android/manifests/res/xml/openless_accessibility_config.xml b/openless-all/app/android/manifests/res/xml/openless_accessibility_config.xml new file mode 100644 index 00000000..281b8c75 --- /dev/null +++ b/openless-all/app/android/manifests/res/xml/openless_accessibility_config.xml @@ -0,0 +1,9 @@ + + diff --git a/openless-all/app/android/manifests/res/xml/openless_ime_method.xml b/openless-all/app/android/manifests/res/xml/openless_ime_method.xml new file mode 100644 index 00000000..5a2afd44 --- /dev/null +++ b/openless-all/app/android/manifests/res/xml/openless_ime_method.xml @@ -0,0 +1,9 @@ + + + + diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 9795ebf4..567fcf6c 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -8,6 +8,12 @@ "build": "tsc && vite build", "preview": "vite preview", "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", + "merge:android-v1-manifest": "node scripts/merge-android-v1-manifest.mjs", + "merge:android-overlay-manifest": "node scripts/merge-android-overlay-manifest.mjs", + "copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs", "check:aura-skin": "node scripts/aura-skin-contract.test.mjs", "check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs", "check:hotkey-injection": "node scripts/check-hotkey-injection.mjs" diff --git a/openless-all/app/public/AppIcon.png b/openless-all/app/public/AppIcon.png index ee876eb5..3bdcb6b8 100755 Binary files a/openless-all/app/public/AppIcon.png and b/openless-all/app/public/AppIcon.png differ diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs new file mode 100644 index 00000000..d785d2e1 --- /dev/null +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -0,0 +1,187 @@ +#!/usr/bin/env node +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const appRoot = fileURLToPath(new URL('..', import.meta.url)); +const kotlinRoot = join(appRoot, 'android/kotlin'); +const manifestsRoot = join(appRoot, 'android/manifests'); +const androidIconRoot = join(appRoot, 'src-tauri/icons/android'); +const genRoot = join(appRoot, 'src-tauri/gen/android/app/src/main'); +const kotlinDest = join(genRoot, 'java/com/openless/app'); +const resDest = join(genRoot, 'res'); +const resXmlDest = join(genRoot, 'res/xml'); + +const KOTLIN_FILES = [ + 'OpenLessAppContext.kt', + 'OpenLessNative.kt', + 'OpenLessPermissionBridge.kt', + 'MicrophonePermissionActivity.kt', + 'OpenLessAndroidPreferences.kt', + 'OpenLessApplication.kt', + 'OpenLessOverlayService.kt', + 'OpenLessOverlayBridge.kt', + 'OpenLessAccessibilityService.kt', + 'OpenLessAccessibilityCommandReceiver.kt', + 'OverlayPermissionActivity.kt', +]; + +const XML_FILES = [ + ['res/xml/openless_accessibility_config.xml', 'openless_accessibility_config.xml'], +]; + +const GENERATED_ACCESSIBILITY_CONFIG = ` + +`; + +const GENERATED_STRINGS_SNIPPET = ` + OpenLess uses accessibility to detect the keyboard and paste dictation results without switching your current keyboard. +`; + +function printHelp() { + console.log(`Usage: node scripts/copy-android-scaffolding.mjs [options] + +Copy Kotlin scaffolding and XML resources into gen/android after \`tauri android init\`. + +Options: + --dry-run Print planned copies without writing + --help Show this help text +`); +} + +function parseArgs(argv) { + let dryRun = false; + for (const arg of argv) { + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + if (arg === '--dry-run') { + dryRun = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + return { dryRun }; +} + +function ensureDir(path, dryRun) { + if (dryRun || existsSync(path)) { + return; + } + mkdirSync(path, { recursive: true }); +} + +function mergeStringsXml(dryRun) { + const stringsPath = join(genRoot, 'res/values/strings.xml'); + if (!existsSync(stringsPath)) { + const content = ` +${GENERATED_STRINGS_SNIPPET} + +`; + if (dryRun) { + console.log(`[dry-run] Would create ${stringsPath}`); + return; + } + ensureDir(dirname(stringsPath), dryRun); + writeFileSync(stringsPath, content, 'utf8'); + console.log(`Created ${stringsPath}`); + return; + } + + const existing = readFileSync(stringsPath, 'utf8'); + if (existing.includes('openless_accessibility_description')) { + console.log(`OpenLess strings already present in ${stringsPath}; skipping.`); + return; + } + + const updated = existing.replace('', `${GENERATED_STRINGS_SNIPPET}\n`); + if (dryRun) { + console.log(`[dry-run] Would merge OpenLess strings into ${stringsPath}`); + return; + } + writeFileSync(stringsPath, updated, 'utf8'); + console.log(`Merged OpenLess strings into ${stringsPath}`); +} + +function copyDirectoryContents(srcRoot, destRoot, dryRun) { + if (!existsSync(srcRoot)) { + throw new Error(`Missing Android icon resources: ${srcRoot}`); + } + + ensureDir(destRoot, dryRun); + for (const entry of readdirSync(srcRoot)) { + const src = join(srcRoot, entry); + const dest = join(destRoot, entry); + if (statSync(src).isDirectory()) { + copyDirectoryContents(src, dest, dryRun); + continue; + } + if (dryRun) { + console.log(`[dry-run] Would copy ${src} -> ${dest}`); + continue; + } + ensureDir(dirname(dest), dryRun); + copyFileSync(src, dest); + console.log(`Copied ${dest}`); + } +} + +function main() { + const { dryRun } = parseArgs(process.argv.slice(2)); + + if (!existsSync(join(appRoot, 'src-tauri/gen/android'))) { + throw new Error( + `Generated Android project not found under src-tauri/gen/android.\nRun "npm run tauri -- android init --ci" first.`, + ); + } + + ensureDir(kotlinDest, dryRun); + ensureDir(resXmlDest, dryRun); + copyDirectoryContents(androidIconRoot, resDest, dryRun); + + for (const file of KOTLIN_FILES) { + const src = join(kotlinRoot, file); + const dest = join(kotlinDest, file); + if (!existsSync(src)) { + throw new Error(`Missing scaffolding file: ${src}`); + } + if (dryRun) { + console.log(`[dry-run] Would copy ${src} -> ${dest}`); + continue; + } + copyFileSync(src, dest); + console.log(`Copied ${file}`); + } + + for (const [relSrc, destName] of XML_FILES) { + const src = join(manifestsRoot, relSrc); + const dest = join(resXmlDest, destName); + const content = existsSync(src) + ? readFileSync(src, 'utf8') + : GENERATED_ACCESSIBILITY_CONFIG; + if (dryRun) { + console.log(`[dry-run] Would write ${dest}`); + continue; + } + writeFileSync(dest, content, 'utf8'); + console.log(`Wrote ${destName}`); + } + + mergeStringsXml(dryRun); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/openless-all/app/scripts/merge-android-overlay-manifest.mjs b/openless-all/app/scripts/merge-android-overlay-manifest.mjs new file mode 100644 index 00000000..66810c2f --- /dev/null +++ b/openless-all/app/scripts/merge-android-overlay-manifest.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const targetPath = fileURLToPath( + new URL('../src-tauri/gen/android/app/src/main/AndroidManifest.xml', import.meta.url), +); + +const PERMISSIONS = [ + 'android.permission.SYSTEM_ALERT_WINDOW', + 'android.permission.FOREGROUND_SERVICE', + 'android.permission.FOREGROUND_SERVICE_MICROPHONE', +]; + +const APPLICATION_SNIPPET = ` + + +`; + +const SERVICE_SNIPPETS = [ + ``, + ` + + + + + `, + ``, + ``, + ``, +]; + +function printHelp() { + console.log(`Usage: node scripts/merge-android-overlay-manifest.mjs [options] + +Merge overlay / accessibility declarations into generated AndroidManifest.xml. + +Options: + --dry-run Print planned changes without writing + --help Show this help text +`); +} + +function parseArgs(argv) { + let dryRun = false; + for (const arg of argv) { + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + if (arg === '--dry-run') { + dryRun = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + return { dryRun }; +} + +function permissionExists(manifestXml, permissionName) { + const escaped = permissionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`]*android:name="${escaped}"[^>]*\\/?>`).test(manifestXml); +} + +function mergePermissions(manifestXml) { + let content = manifestXml; + let changed = false; + for (const name of PERMISSIONS) { + if (permissionExists(content, name)) { + continue; + } + const line = ` `; + const applicationIdx = content.indexOf(''); + } + content = `${content.slice(0, applicationIdx)}${line}\n${content.slice(applicationIdx)}`; + changed = true; + } + return { content, changed }; +} + +function ensureApplicationName(manifestXml) { + if (/android:name="\.OpenLessApplication"/.test(manifestXml)) { + return { content: manifestXml, changed: false }; + } + const updated = manifestXml.replace( + /)/, + ''); + if (closingIdx === -1) { + throw new Error('Target manifest is missing '); + } + + for (const snippet of SERVICE_SNIPPETS) { + const marker = snippet.match(/android:name="([^"]+)"/)?.[1]; + if (!marker || snippetExists(content, marker)) { + continue; + } + content = `${content.slice(0, closingIdx)} ${snippet}\n${content.slice(closingIdx)}`; + changed = true; + } + + return { content, changed }; +} + +function main() { + const { dryRun } = parseArgs(process.argv.slice(2)); + + if (!existsSync(targetPath)) { + throw new Error( + `Generated Android manifest not found: ${targetPath}\nRun "npm run tauri -- android init --ci" first.`, + ); + } + + let content = readFileSync(targetPath, 'utf8'); + let changed = false; + + for (const step of [mergePermissions, ensureApplicationName, mergeApplicationChildren]) { + const result = step(content); + content = result.content; + changed = changed || result.changed; + } + + if (!changed) { + console.log(`Overlay manifest entries already present in ${targetPath}; skipping merge.`); + return; + } + + if (dryRun) { + console.log(`[dry-run] Would merge overlay manifest entries into ${targetPath}`); + return; + } + + writeFileSync(targetPath, content, 'utf8'); + console.log(`Merged overlay / accessibility entries into ${targetPath}`); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/openless-all/app/scripts/merge-android-v1-manifest.mjs b/openless-all/app/scripts/merge-android-v1-manifest.mjs new file mode 100644 index 00000000..e5982d8d --- /dev/null +++ b/openless-all/app/scripts/merge-android-v1-manifest.mjs @@ -0,0 +1,133 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +const targetPath = fileURLToPath( + new URL('../src-tauri/gen/android/app/src/main/AndroidManifest.xml', import.meta.url), +); +const sourcePath = fileURLToPath( + new URL('../android/manifests/AndroidManifest.v1.snippet.xml', import.meta.url), +); + +const PERMISSION_LINE_RE = + /]*android:name="([^"]+)"[^>]*\/?>/g; + +function printHelp() { + console.log(`Usage: node scripts/merge-android-v1-manifest.mjs [options] + +Merge APK v1 permissions from android/manifests into the generated +AndroidManifest.xml (post \`tauri android init\`). + +Options: + --dry-run Print planned changes without writing the manifest + --help Show this help text + +Target: ${targetPath} +Source: ${sourcePath} +`); +} + +function parseArgs(argv) { + let dryRun = false; + for (const arg of argv) { + if (arg === '--help' || arg === '-h') { + printHelp(); + process.exit(0); + } + if (arg === '--dry-run') { + dryRun = true; + continue; + } + throw new Error(`Unknown argument: ${arg}`); + } + return { dryRun }; +} + +function extractPermissionLines(snippetXml) { + const lines = []; + for (const match of snippetXml.matchAll(PERMISSION_LINE_RE)) { + lines.push({ name: match[1], line: match[0] }); + } + if (lines.length === 0) { + throw new Error( + `Source manifest snippet does not contain any uses-permission entries: ${sourcePath}`, + ); + } + return lines; +} + +function permissionExists(manifestXml, permissionName) { + const escaped = permissionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`]*android:name="${escaped}"[^>]*\\/?>`).test(manifestXml); +} + +function mergePermissionLines(manifestXml, permissionLines) { + const missing = permissionLines.filter((permission) => !permissionExists(manifestXml, permission.name)); + if (missing.length === 0) { + return { changed: false, content: manifestXml }; + } + + const insertionBlock = (indent) => missing.map((permission) => `${indent}${permission.line}`).join('\n') + '\n'; + + const applicationIdx = manifestXml.indexOf(''); + if (closingManifestIdx === -1) { + throw new Error(`Target manifest is missing : ${targetPath}`); + } + + const indent = ' '; + return { + changed: true, + content: `${manifestXml.slice(0, closingManifestIdx)}${insertionBlock(indent)}${manifestXml.slice(closingManifestIdx)}`, + }; +} + +function main() { + const { dryRun } = parseArgs(process.argv.slice(2)); + + if (!existsSync(targetPath)) { + throw new Error( + `Generated Android manifest not found: ${targetPath}\nRun "npm run tauri -- android init --ci" first.`, + ); + } + if (!existsSync(sourcePath)) { + throw new Error(`Source manifest snippet not found: ${sourcePath}`); + } + + const permissionLines = extractPermissionLines(readFileSync(sourcePath, 'utf8')); + const manifestXml = readFileSync(targetPath, 'utf8'); + const { changed, content } = mergePermissionLines(manifestXml, permissionLines); + + if (!changed) { + console.log(`APK v1 permissions already present in ${targetPath}; skipping merge.`); + return; + } + + if (dryRun) { + console.log(`[dry-run] Would merge APK v1 permissions into ${targetPath}`); + for (const permission of permissionLines) { + console.log(`[dry-run] Permission line: ${permission.line}`); + } + return; + } + + writeFileSync(targetPath, content, 'utf8'); + console.log(`Merged APK v1 permissions into ${targetPath}`); +} + +try { + main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + process.exit(1); +} diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index fdde3bfe..dda80bcc 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -50,7 +50,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" dependencies = [ "alsa-sys", - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "libc", ] @@ -346,9 +346,9 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "axum" @@ -422,7 +422,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools", @@ -430,7 +430,7 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", + "shlex 1.3.0", "syn 2.0.117", ] @@ -457,9 +457,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -534,9 +534,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -545,19 +545,28 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", ] +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytecheck" @@ -644,7 +653,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cairo-sys-rs", "glib", "libc", @@ -716,14 +725,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.61" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -776,9 +785,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -904,7 +913,7 @@ version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -917,7 +926,7 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-graphics-types 0.2.0", "foreign-types 0.5.0", @@ -941,7 +950,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "libc", ] @@ -959,9 +968,9 @@ dependencies = [ [[package]] name = "coreaudio-sys" -version = "0.2.17" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ceec7a6067e62d6f931a2baf6f3a751f4a892595bcec1461a3c94ef9949864b6" +checksum = "b9b4739a805a62757a83e5654fa3faabec0442666b263bb2287d5a8185bfd953" dependencies = [ "bindgen", ] @@ -1062,7 +1071,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf 0.13.1", + "phf", "smallvec", ] @@ -1339,7 +1348,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -1347,9 +1356,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1453,9 +1462,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "embed-resource" @@ -1637,7 +1646,7 @@ dependencies = [ "anyhow", "ferrous-opencc-compiler", "fst", - "phf 0.13.1", + "phf", "phf_codegen", "rkyv", "serde", @@ -1669,13 +1678,12 @@ dependencies = [ [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -1771,9 +1779,9 @@ dependencies = [ [[package]] name = "foundry-local-sdk" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6f6b9ce8ef529348022814444562c54d7216417e6ed89af3d56807f52e5788" +checksum = "16fc2dc5ba41aeb97864c9ca63976f81ad867cf26d191d8f018559b180581082" dependencies = [ "async-openai", "futures-core", @@ -2041,11 +2049,10 @@ dependencies = [ [[package]] name = "getset" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ - "proc-macro-error2", "proc-macro2", "quote", "syn 2.0.117", @@ -2089,7 +2096,7 @@ version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "futures-channel", "futures-core", "futures-executor", @@ -2262,9 +2269,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -2320,9 +2327,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2365,9 +2372,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2454,7 +2461,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -2633,7 +2640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -2663,16 +2670,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is-docker" version = "0.2.0" @@ -2738,9 +2735,9 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "log", @@ -2751,9 +2748,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -2846,13 +2843,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.97" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2884,7 +2880,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "serde", "unicode-segmentation", ] @@ -2973,14 +2969,11 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags 2.11.1", "libc", - "plain", - "redox_syscall 0.7.5", ] [[package]] @@ -2989,7 +2982,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", ] @@ -3027,9 +3020,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru-slab" @@ -3086,9 +3079,9 @@ checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmap2" @@ -3148,9 +3141,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -3169,9 +3162,9 @@ dependencies = [ [[package]] name = "muda" -version = "0.19.1" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ae8844f63b5b118e334e205585b8c5c17b984121dbdb179d44aeb087ffad3cb" +checksum = "47a2e3dff89cd322c66647942668faee0a2b1f88ea6cbb4d374b4a8d7e92528c" dependencies = [ "crossbeam-channel", "dpi", @@ -3231,7 +3224,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2076a31b7010b17a38c01907c45b945e8f11495ee4dd588309718901b1f7a5b7" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "jni-sys 0.3.1", "log", "ndk-sys 0.5.0+25.2.9519653", @@ -3245,7 +3238,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "jni-sys 0.3.1", "log", "ndk-sys 0.6.0+11769913", @@ -3284,7 +3277,7 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22f9786d56d972959e1408b6a93be6af13b9c1392036c5c1fafa08a1b0c6ee87" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "derive_builder", "getset", @@ -3319,7 +3312,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -3380,9 +3373,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -3498,7 +3491,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -3514,7 +3507,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", @@ -3528,7 +3521,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -3539,7 +3532,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3561,7 +3554,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2 0.6.4", ] @@ -3572,7 +3565,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -3617,7 +3610,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -3644,7 +3637,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -3656,7 +3649,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -3669,7 +3662,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2 0.6.4", "objc2-core-foundation", ] @@ -3680,7 +3673,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3692,7 +3685,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2 0.6.4", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", @@ -3704,7 +3697,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -3717,7 +3710,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -3729,7 +3722,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.6.2", "objc2 0.6.4", "objc2-cloud-kit", @@ -3760,7 +3753,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.6.2", "objc2 0.6.4", "objc2-app-kit 0.3.2", @@ -3805,9 +3798,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "open" -version = "5.3.4" +version = "5.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f3bab717c29a857abf75fcef718d441ec7cb2725f937343c734740a985d37fd" +checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c" dependencies = [ "dunce", "is-wsl", @@ -3842,10 +3835,12 @@ dependencies = [ "hmac", "hyper", "hyper-util", + "jni 0.21.1", "keyring", "libc", "local-ip-address", "log", + "ndk-context", "objc2 0.5.2", "objc2-app-kit 0.2.2", "objc2-foundation 0.2.2", @@ -3861,6 +3856,7 @@ dependencies = [ "sherpa-onnx", "simplelog", "subtle", + "tao", "tar", "tauri", "tauri-build", @@ -3884,11 +3880,11 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.79" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "foreign-types 0.3.2", "libc", @@ -3915,9 +3911,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.115" +version = "0.9.116" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" dependencies = [ "cc", "libc", @@ -4014,7 +4010,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link 0.2.1", ] @@ -4062,24 +4058,14 @@ dependencies = [ "indexmap 2.14.0", ] -[[package]] -name = "phf" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" -dependencies = [ - "phf_macros 0.11.3", - "phf_shared 0.11.3", -] - [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros 0.13.1", - "phf_shared 0.13.1", + "phf_macros", + "phf_shared", "serde", ] @@ -4089,18 +4075,8 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_generator" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" -dependencies = [ - "phf_shared 0.11.3", - "rand 0.8.6", + "phf_generator", + "phf_shared", ] [[package]] @@ -4110,20 +4086,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", - "phf_shared 0.13.1", -] - -[[package]] -name = "phf_macros" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" -dependencies = [ - "phf_generator 0.11.3", - "phf_shared 0.11.3", - "proc-macro2", - "quote", - "syn 2.0.117", + "phf_shared", ] [[package]] @@ -4132,22 +4095,13 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", "syn 2.0.117", ] -[[package]] -name = "phf_shared" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" -dependencies = [ - "siphasher", -] - [[package]] name = "phf_shared" version = "0.13.1" @@ -4180,12 +4134,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plist" version = "1.9.0" @@ -4218,7 +4166,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -4320,7 +4268,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -4347,28 +4295,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -4412,9 +4338,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.39.3" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "721da970c312655cde9b4ffe0547f20a8494866a4af5ff51f18b7c633d0c870b" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", ] @@ -4588,16 +4514,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", -] - -[[package]] -name = "redox_syscall" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" -dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -4644,9 +4561,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -4667,9 +4584,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rend" @@ -4730,9 +4647,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -4813,7 +4730,7 @@ checksum = "73389e0c99e664f919275ab5b5b0471391fe9a8de61e1dff9b1eaf56a90f16e3" dependencies = [ "bytecheck", "bytes", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "indexmap 2.14.0", "munge", "ptr_meta", @@ -4856,7 +4773,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -4880,9 +4797,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5050,7 +4967,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -5063,7 +4980,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5086,12 +5003,12 @@ version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cssparser", "derive_more", "log", "new_debug_unreachable", - "phf 0.13.1", + "phf", "phf_codegen", "precomputed-hash", "rustc-hash", @@ -5164,9 +5081,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -5218,11 +5135,12 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.19.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -5237,9 +5155,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.19.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -5339,6 +5257,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "sigchld" version = "0.2.4" @@ -5417,15 +5341,15 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -5446,7 +5370,7 @@ dependencies = [ "objc2-foundation 0.3.2", "objc2-quartz-core 0.3.2", "raw-window-handle", - "redox_syscall 0.5.18", + "redox_syscall", "tracing", "wasm-bindgen", "web-sys", @@ -5499,7 +5423,7 @@ checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared 0.13.1", + "phf_shared", "precomputed-hash", ] @@ -5509,8 +5433,8 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator 0.13.1", - "phf_shared 0.13.1", + "phf_generator", + "phf_shared", "proc-macro2", "quote", ] @@ -5585,7 +5509,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5615,11 +5539,11 @@ dependencies = [ [[package]] name = "tao" -version = "0.35.2" +version = "0.35.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a33f7f9e486ade65fcf1e45c440f9236c904f5c1002cdc7fc6ae582777345ce4" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "block2 0.6.2", "core-foundation 0.10.1", "core-graphics 0.25.0", @@ -5666,9 +5590,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -5683,9 +5607,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.11.0" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d059f2527558d9dba6f186dec4772610e1aecfd3f94002397613e7e648752b66" +checksum = "437404997acf375d85f1177afa7e11bb971f274ed6a7b83a2a3e339015f4cc28" dependencies = [ "anyhow", "bytes", @@ -5711,7 +5635,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest 0.13.3", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -5734,9 +5658,9 @@ dependencies = [ [[package]] name = "tauri-build" -version = "2.6.0" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be9aa8c59a894f76c29a002501c589de5eb4987a5913d62a6e0a47f320901988" +checksum = "4aa1f9055fc23919a54e4e125052bed16ed04aef0487086e758fe01a67b451c7" dependencies = [ "anyhow", "cargo_toml", @@ -5755,9 +5679,9 @@ dependencies = [ [[package]] name = "tauri-codegen" -version = "2.6.0" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e4e8230d565106aa19dfbaa01a7ed01abf78047fe0577a83377224bd1bf20e" +checksum = "e4a0319528a025a38c4078e7dae2c446f4e63620ddb0659a643ede1cb38f90e9" dependencies = [ "base64 0.22.1", "brotli", @@ -5782,9 +5706,9 @@ dependencies = [ [[package]] name = "tauri-macros" -version = "2.6.0" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc8de2cddbbc33dbdf4c84f170121886595efdbcc9cb4b3d76342b79d082cedc" +checksum = "ae6cb4e3896c21d2f6da5b31251d2faea0153bba56ed0e970f918115dbee4924" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -5796,9 +5720,9 @@ dependencies = [ [[package]] name = "tauri-plugin" -version = "2.6.0" +version = "2.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8d5f58bfd0cdcfdbc0a68dc08b354eea2afc551b421de91b07b69e0dd769d57" +checksum = "e126abc9e84e35cdfd01596140a73a1850cdb0df0a23acf0185776c30b469a6e" dependencies = [ "anyhow", "glob", @@ -5899,7 +5823,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "windows-sys 0.60.2", - "zbus 5.15.0", + "zbus 5.16.0", ] [[package]] @@ -5918,7 +5842,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest 0.13.3", + "reqwest 0.13.4", "rustls", "semver", "serde", @@ -5937,9 +5861,9 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.11.0" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e42bbcb76237351fbaa02f08d808c537dc12eb5a6eabbf3e517b50056334d95" +checksum = "48222d7116c8807eaa6fe2f372e023fae125084e61e6eca6d70b7961cdf129ef" dependencies = [ "cookie", "dpi", @@ -5962,9 +5886,9 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.11.0" +version = "2.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cadb13dad0c681e1e0a2c49ae488f0e2906ded3d57e7a0017f4aaf46e387117" +checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", @@ -5988,9 +5912,9 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.9.0" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55f61d2bf7188fbcf2b0ed095b67a6bc498f713c939314bb19eb700118a573b7" +checksum = "092379df9a707631978e6c56b1bc2401d387f01e2d4a3c123360d167bbb9aa95" dependencies = [ "anyhow", "brotli", @@ -6004,7 +5928,7 @@ dependencies = [ "json-patch", "log", "memchr", - "phf 0.11.3", + "phf", "plist", "proc-macro2", "quote", @@ -6181,9 +6105,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.2" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "110a78583f19d5cdb2c5ccf321d1290344e71313c6c37d43520d386027d18386" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -6247,11 +6171,11 @@ dependencies = [ "futures-util", "log", "rustls", - "rustls-native-certs", "rustls-pki-types", "tokio", "tokio-rustls", "tungstenite", + "webpki-roots 0.26.11", ] [[package]] @@ -6306,7 +6230,7 @@ dependencies = [ "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -6362,14 +6286,14 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -6378,7 +6302,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -6404,20 +6328,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -6530,9 +6454,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -6600,9 +6524,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -6718,9 +6642,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -6811,9 +6735,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.120" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -6824,9 +6748,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.70" +version = "0.4.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af934872acec734c2d80e6617bbb5ff4f12b052dd8e6332b0817bce889516084" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" dependencies = [ "js-sys", "wasm-bindgen", @@ -6834,9 +6758,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.120" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6844,9 +6768,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.120" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -6857,9 +6781,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.120" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] @@ -6918,7 +6842,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -6943,7 +6867,7 @@ version = "0.31.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "rustix", "wayland-backend", "wayland-scanner", @@ -6955,7 +6879,7 @@ version = "0.32.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-scanner", @@ -6967,7 +6891,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "wayland-backend", "wayland-client", "wayland-protocols", @@ -6996,9 +6920,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.97" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2eadbac71025cd7b0834f20d1fe8472e8495821b4e9801eb0a60bd1f19827602" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" dependencies = [ "js-sys", "wasm-bindgen", @@ -7020,7 +6944,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7cff6eef815df1834fd250e3a2ff436044d82a9f1bc1980ca1dbdf07effc538" dependencies = [ - "phf 0.13.1", + "phf", "phf_codegen", "string_cache", "string_cache_codegen", @@ -7300,6 +7224,19 @@ dependencies = [ "windows-strings 0.4.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-future" version = "0.2.1" @@ -7806,9 +7743,9 @@ checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -7906,7 +7843,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -8099,9 +8036,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -8154,9 +8091,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.15.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1" +checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285" dependencies = [ "async-broadcast", "async-executor", @@ -8181,10 +8118,10 @@ dependencies = [ "uds_windows", "uuid", "windows-sys 0.61.2", - "winnow 1.0.2", - "zbus_macros 5.15.0", + "winnow 1.0.3", + "zbus_macros 5.16.0", "zbus_names 4.3.2", - "zvariant 5.11.0", + "zvariant 5.12.0", ] [[package]] @@ -8202,17 +8139,17 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.15.0" +version = "5.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" +checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", "zbus_names 4.3.2", - "zvariant 5.11.0", - "zvariant_utils 3.3.1", + "zvariant 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -8233,24 +8170,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7074f3e50b894eac91750142016d30d0a89be8e67dbfd9704fb875825760e52d" dependencies = [ "serde", - "winnow 1.0.2", - "zvariant 5.11.0", + "winnow 1.0.3", + "zvariant 5.12.0", ] [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", @@ -8259,9 +8196,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -8449,16 +8386,16 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee" +checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0" dependencies = [ "endi", "enumflags2", "serde", - "winnow 1.0.2", - "zvariant_derive 5.11.0", - "zvariant_utils 3.3.1", + "winnow 1.0.3", + "zvariant_derive 5.12.0", + "zvariant_utils 3.4.0", ] [[package]] @@ -8476,15 +8413,15 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.11.0" +version = "5.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" +checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737" dependencies = [ "proc-macro-crate 3.5.0", "proc-macro2", "quote", "syn 2.0.117", - "zvariant_utils 3.3.1", + "zvariant_utils 3.4.0", ] [[package]] @@ -8500,13 +8437,13 @@ dependencies = [ [[package]] name = "zvariant_utils" -version = "3.3.1" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691" +checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6" dependencies = [ "proc-macro2", "quote", "serde", "syn 2.0.117", - "winnow 1.0.2", + "winnow 1.0.3", ] diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 3dd38f47..5f6d0bcc 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -18,11 +18,10 @@ cc = "1.1" [dependencies] # 锁 ~2.11 因为 npm @tauri-apps/api 与 plugin-dialog 都已升 2.11; # tauri build 跨 minor 一致性检查会拒绝 npm 2.11 + Rust 2.10 的组合。 -tauri = { version = "~2.11", features = ["macos-private-api", "tray-icon"] } +# macos-private-api must live in [dependencies] so tauri_build can match tauri.conf.json +# macOSPrivateApi; tray-icon stays desktop-only in the target table below. +tauri = { version = "~2.11", features = ["macos-private-api"] } tauri-plugin-shell = "2" -tauri-plugin-updater = "2" -tauri-plugin-single-instance = "2" -tauri-plugin-autostart = "2" tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -39,9 +38,9 @@ getrandom = "0.3" bzip2 = "0.4" tar = "0.4" tokio = { version = "1", features = ["full"] } -tokio-tungstenite = { version = "0.24", features = ["rustls-tls-native-roots"] } +tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" -reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "native-tls", "stream", "system-proxy"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "rustls-tls", "stream", "system-proxy"] } zip = "2" thiserror = "1" anyhow = "1" @@ -56,10 +55,17 @@ bytes = "1" url = "2" raw-window-handle = "0.6" ferrous-opencc = "0.4" +# Audio capture — shared across desktop and mobile. +cpal = "0.15" -# Hotkey + audio + insertion +# Desktop-only plugins, hotkey/insertion helpers, and tray-icon (not built for mobile). +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +reqwest = { version = "0.12", default-features = false, features = ["native-tls"] } +tauri = { version = "~2.11", features = ["macos-private-api", "tray-icon"] } +tauri-plugin-updater = "2" +tauri-plugin-single-instance = "2" +tauri-plugin-autostart = "2" global-hotkey = "0.6" -cpal = "0.15" enigo = "0.2" arboard = { version = "3", features = ["wayland-data-control"] } rcgen = "^0.13" @@ -83,11 +89,16 @@ features = ["windows-native"] [target.'cfg(target_os = "linux")'.dependencies] dbus = "0.9" -[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies.keyring] +[target.'cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))'.dependencies.keyring] version = "3.6.3" default-features = false features = ["linux-native-sync-persistent", "crypto-rust"] +[target.'cfg(target_os = "android")'.dependencies] +jni = "0.21" +ndk-context = "0.1" +tao = "0.35" + [target.'cfg(target_os = "macos")'.dependencies] block2 = "0.5" core-foundation = "0.10" diff --git a/openless-all/app/src-tauri/build.rs b/openless-all/app/src-tauri/build.rs index fe64d59f..c7332edb 100644 --- a/openless-all/app/src-tauri/build.rs +++ b/openless-all/app/src-tauri/build.rs @@ -5,9 +5,20 @@ fn main() { #[cfg(target_os = "macos")] build_qwen_asr_macos(); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") { + link_android_cpp_runtime(); + } + tauri_build::build(); } +/// cpal → oboe → oboe-sys 会编译 C++;最终 cdylib 需显式链接 NDK libc++。 +fn link_android_cpp_runtime() { + // oboe-ext 已部分静态链入 libc++;补链 c++abi 提供 __cxa_pure_virtual 等 ABI 符号。 + println!("cargo:rustc-link-lib=c++_static"); + println!("cargo:rustc-link-lib=c++abi"); +} + #[cfg(target_os = "windows")] fn link_windows_common_controls_v6_manifest_dependency() { let mut source_path = std::path::PathBuf::from( diff --git a/openless-all/app/src-tauri/capabilities/default.json b/openless-all/app/src-tauri/capabilities/default.json index 760d74ed..759ae4f2 100644 --- a/openless-all/app/src-tauri/capabilities/default.json +++ b/openless-all/app/src-tauri/capabilities/default.json @@ -2,6 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Default capabilities for OpenLess windows", + "platforms": ["macOS", "windows", "linux"], "windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow"], "permissions": [ "core:default", diff --git a/openless-all/app/src-tauri/capabilities/mobile.json b/openless-all/app/src-tauri/capabilities/mobile.json new file mode 100644 index 00000000..911ad21d --- /dev/null +++ b/openless-all/app/src-tauri/capabilities/mobile.json @@ -0,0 +1,18 @@ +{ + "$schema": "../gen/schemas/mobile-schema.json", + "identifier": "mobile", + "description": "Capabilities for OpenLess Android main window", + "platforms": ["android"], + "windows": ["main", "qa"], + "permissions": [ + "core:default", + "core:window:default", + "core:window:allow-show", + "core:window:allow-hide", + "core:window:allow-set-focus", + "core:webview:default", + "core:event:default", + "shell:allow-open", + "dialog:default" + ] +} diff --git a/openless-all/app/src-tauri/icons/android/drawable/ic_overlay_logo.xml b/openless-all/app/src-tauri/icons/android/drawable/ic_overlay_logo.xml new file mode 100644 index 00000000..12c75565 --- /dev/null +++ b/openless-all/app/src-tauri/icons/android/drawable/ic_overlay_logo.xml @@ -0,0 +1,4 @@ + + diff --git a/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png b/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png index 776a8ca2..f8696157 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png and b/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png b/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png index 5d3b4d18..fff60b8a 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png and b/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png b/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png index 184e80b9..9a133041 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png and b/openless-all/app/src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png b/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png index cda13e09..163c19bd 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png and b/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png b/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png index c300c13e..48132e3e 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png and b/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png b/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png index 6ea9db97..0ed6b5b3 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png and b/openless-all/app/src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png b/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png index 42f5c671..eeb2a49b 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png and b/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png b/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png index 7e77d7df..bf5c0c5f 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png and b/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png b/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png index 83bc0d45..16125b39 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png and b/openless-all/app/src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png b/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png index 7d53a159..c51aef19 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png and b/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png b/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png index bb34a590..4c063c8c 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png b/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png index 6490428c..4ca17315 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png and b/openless-all/app/src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png b/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png index cfe887bf..3964920c 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png and b/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png index 8baebd64..56d8948a 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png b/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png index 404db72a..bd389617 100644 Binary files a/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png and b/openless-all/app/src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/openless-all/app/src-tauri/src/android/accessibility.rs b/openless-all/app/src-tauri/src/android/accessibility.rs new file mode 100644 index 00000000..ec5ec976 --- /dev/null +++ b/openless-all/app/src-tauri/src/android/accessibility.rs @@ -0,0 +1,123 @@ +//! Android accessibility service integration for keyboard detection and paste insertion. + +use serde::Serialize; + +use crate::android::types::{AndroidAccessibilityState, AndroidAccessibilityStatus}; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AndroidAccessibilityPermissionResult { + pub launched: bool, + pub message: String, +} + +pub fn get_android_accessibility_status() -> AndroidAccessibilityStatus { + #[cfg(target_os = "android")] + { + android_impl::get_android_accessibility_status() + } + + #[cfg(not(target_os = "android"))] + { + AndroidAccessibilityStatus { + state: AndroidAccessibilityState::NotAndroid, + enabled: false, + message: "Android accessibility backend is only available on Android".to_string(), + } + } +} + +pub fn request_android_accessibility_permission() -> AndroidAccessibilityPermissionResult { + #[cfg(target_os = "android")] + { + android_impl::request_android_accessibility_permission() + } + + #[cfg(not(target_os = "android"))] + { + AndroidAccessibilityPermissionResult { + launched: false, + message: "Android accessibility settings are only available on Android".to_string(), + } + } +} + +pub fn paste_via_accessibility() -> bool { + #[cfg(target_os = "android")] + { + return android_impl::paste_via_accessibility(); + } + + #[cfg(not(target_os = "android"))] + false +} + +#[cfg(target_os = "android")] +mod android_impl { + use super::{AndroidAccessibilityPermissionResult, AndroidAccessibilityStatus}; + use crate::android::types::{AndroidAccessibilityState, AndroidAccessibilityStatus as Status}; + + pub fn get_android_accessibility_status() -> AndroidAccessibilityStatus { + let enabled = match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::accessibility_enabled(env, context) + }) { + Ok(enabled) => enabled, + Err(error) => { + return Status { + state: AndroidAccessibilityState::NotEnabled, + enabled: false, + message: error, + }; + } + }; + if !enabled { + return Status { + state: AndroidAccessibilityState::NotEnabled, + enabled: false, + message: "请在系统设置中启用 OpenLess 无障碍服务".to_string(), + }; + } + + match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::accessibility_operational(env, context) + }) { + Ok(true) => Status { + state: AndroidAccessibilityState::Enabled, + enabled: true, + message: "无障碍服务已启用".to_string(), + }, + Ok(false) => Status { + state: AndroidAccessibilityState::NotEnabled, + enabled: false, + message: "无障碍服务已开启,但当前未运行或已被系统标记为故障,请重新开启 OpenLess 无障碍服务".to_string(), + }, + Err(error) => Status { + state: AndroidAccessibilityState::NotEnabled, + enabled: false, + message: error, + }, + } + } + + pub fn request_android_accessibility_permission() -> AndroidAccessibilityPermissionResult { + match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::launch_accessibility_settings(env, context) + }) { + Ok(()) => AndroidAccessibilityPermissionResult { + launched: true, + message: "已打开无障碍设置".to_string(), + }, + Err(error) => AndroidAccessibilityPermissionResult { + launched: false, + message: error, + }, + } + } + + pub fn paste_via_accessibility() -> bool { + crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::accessibility_paste(env, context) + }) + .unwrap_or(false) + } +} diff --git a/openless-all/app/src-tauri/src/android/insert.rs b/openless-all/app/src-tauri/src/android/insert.rs new file mode 100644 index 00000000..13f4aedb --- /dev/null +++ b/openless-all/app/src-tauri/src/android/insert.rs @@ -0,0 +1,51 @@ +//! Android cross-app text insertion strategies. + +#![cfg(target_os = "android")] +use crate::android::types::AndroidInsertStrategy; +use crate::insertion::TextInserter; +use crate::types::InsertStatus; + +pub fn android_insert_with_strategy( + inserter: &TextInserter, + text: &str, + strategy: AndroidInsertStrategy, +) -> InsertStatus { + if text.is_empty() { + return InsertStatus::CopiedFallback; + } + + match strategy { + AndroidInsertStrategy::Clipboard => clipboard_fallback(inserter, text), + AndroidInsertStrategy::Accessibility + | AndroidInsertStrategy::Auto + | AndroidInsertStrategy::Ime => { + try_accessibility(inserter, text).unwrap_or_else(|| clipboard_fallback(inserter, text)) + } + } +} + +fn try_accessibility(inserter: &TextInserter, text: &str) -> Option { + if !crate::android::accessibility::get_android_accessibility_status().enabled { + log::info!("[android-insert] accessibility service not enabled"); + return None; + } + if !matches!(inserter.copy_fallback(text), InsertStatus::CopiedFallback) { + return None; + } + if crate::android::accessibility::paste_via_accessibility() { + Some(InsertStatus::Inserted) + } else { + log::warn!("[android-insert] accessibility paste failed; text remains on clipboard"); + Some(InsertStatus::CopiedFallback) + } +} + +fn clipboard_fallback(inserter: &TextInserter, text: &str) -> InsertStatus { + let status = inserter.copy_fallback(text); + if matches!(status, InsertStatus::CopiedFallback) { + let _ = crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::show_overlay_toast(env, context, "已复制到剪贴板") + }); + } + status +} diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs new file mode 100644 index 00000000..dcaeae8a --- /dev/null +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -0,0 +1,510 @@ +//! Shared JNI helpers for Android Rust modules. + +#[cfg(target_os = "android")] +pub mod android { + use jni::objects::{JClass, JObject, JString, JValue}; + use jni::JNIEnv; + use jni::JavaVM; + + pub fn with_android_env( + f: impl for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> Result, + ) -> Result { + let android_context = ndk_context::android_context(); + let vm = unsafe { + JavaVM::from_raw(android_context.vm().cast()) + .map_err(|error| format!("attach Android JVM: {error}"))? + }; + let mut env = vm + .attach_current_thread() + .map_err(|error| format!("attach Android thread: {error}"))?; + let context = unsafe { JObject::from_raw(android_context.context() as jni::sys::jobject) }; + f(&mut env, &context) + } + + pub fn call_static_void( + env: &mut JNIEnv, + class_name: &str, + method: &str, + sig: &str, + args: &[JValue], + ) -> Result<(), String> { + let class = env + .find_class(class_name) + .map_err(|error| format!("find class {class_name}: {error}"))?; + env.call_static_method(class, method, sig, args) + .map_err(|error| format!("call {class_name}.{method}: {error}"))?; + Ok(()) + } + + fn load_context_class<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + class_name: &str, + ) -> Result, String> { + let class_loader = env + .call_method(context, "getClassLoader", "()Ljava/lang/ClassLoader;", &[]) + .and_then(|value| value.l()) + .map_err(|error| format!("get Context class loader: {error}"))?; + let class_name_obj = jobject_str(env, class_name)?; + let class_obj = env + .call_method( + &class_loader, + "loadClass", + "(Ljava/lang/String;)Ljava/lang/Class;", + &[JValue::Object(&class_name_obj)], + ) + .and_then(|value| value.l()) + .map_err(|error| format!("load app class {class_name}: {error}"))?; + Ok(JClass::from(class_obj)) + } + + fn call_static_void_with_context_class<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + class_name: &str, + method: &str, + sig: &str, + args: &[JValue], + ) -> Result<(), String> { + let class = load_context_class(env, context, class_name)?; + env.call_static_method(class, method, sig, args) + .map_err(|error| format!("call {class_name}.{method}: {error}"))?; + Ok(()) + } + + fn call_static_bool_with_context_class<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + class_name: &str, + method: &str, + sig: &str, + args: &[JValue], + ) -> Result { + let class = load_context_class(env, context, class_name)?; + env.call_static_method(class, method, sig, args) + .and_then(|value| value.z()) + .map_err(|error| format!("call {class_name}.{method}: {error}")) + } + + pub fn jstring<'local>( + env: &mut JNIEnv<'local>, + value: &str, + ) -> Result, String> { + env.new_string(value) + .map_err(|error| format!("create jstring: {error}")) + } + + fn jobject_str<'local>( + env: &mut JNIEnv<'local>, + value: &str, + ) -> Result, String> { + Ok(jstring(env, value)?.into()) + } + + pub fn start_activity_class( + env: &mut JNIEnv, + context: &JObject, + class_name: &str, + ) -> Result<(), String> { + start_activity_class_with_flags(env, context, class_name, 0x10000000) + } + + pub fn start_activity_class_with_flags( + env: &mut JNIEnv, + context: &JObject, + class_name: &str, + flags: i32, + ) -> Result<(), String> { + let intent = env + .new_object("android/content/Intent", "()V", &[]) + .map_err(|error| format!("create activity intent: {error}"))?; + let class_name_obj = jobject_str(env, class_name)?; + let component = env + .new_object( + "android/content/ComponentName", + "(Landroid/content/Context;Ljava/lang/String;)V", + &[JValue::Object(context), JValue::Object(&class_name_obj)], + ) + .map_err(|error| format!("create component name: {error}"))?; + env.call_method( + &intent, + "setComponent", + "(Landroid/content/ComponentName;)Landroid/content/Intent;", + &[JValue::Object(&component)], + ) + .map_err(|error| format!("set activity component: {error}"))?; + env.call_method( + &intent, + "addFlags", + "(I)Landroid/content/Intent;", + &[JValue::Int(flags)], + ) + .map_err(|error| format!("set intent flags: {error}"))?; + env.call_method( + context, + "startActivity", + "(Landroid/content/Intent;)V", + &[JValue::Object(&intent)], + ) + .map_err(|error| format!("start activity: {error}"))?; + Ok(()) + } + + pub fn start_service_action( + env: &mut JNIEnv, + context: &JObject, + service_class: &str, + action: &str, + ) -> Result<(), String> { + let intent = env + .new_object("android/content/Intent", "()V", &[]) + .map_err(|error| format!("create service intent: {error}"))?; + let service_class_obj = jobject_str(env, service_class)?; + let component = env + .new_object( + "android/content/ComponentName", + "(Landroid/content/Context;Ljava/lang/String;)V", + &[JValue::Object(context), JValue::Object(&service_class_obj)], + ) + .map_err(|error| format!("create component name: {error}"))?; + env.call_method( + &intent, + "setComponent", + "(Landroid/content/ComponentName;)Landroid/content/Intent;", + &[JValue::Object(&component)], + ) + .map_err(|error| format!("set service component: {error}"))?; + let action_obj = jobject_str(env, action)?; + env.call_method( + &intent, + "setAction", + "(Ljava/lang/String;)Landroid/content/Intent;", + &[JValue::Object(&action_obj)], + ) + .map_err(|error| format!("set service action: {error}"))?; + let start_method = if action.ends_with(".HIDE") || action.ends_with(".SHOW") { + "startService" + } else if android_sdk_int(env)? >= 26 { + "startForegroundService" + } else { + "startService" + }; + env.call_method( + context, + start_method, + "(Landroid/content/Intent;)Landroid/content/ComponentName;", + &[JValue::Object(&intent)], + ) + .map_err(|error| format!("{start_method}: {error}"))?; + Ok(()) + } + + pub fn can_draw_overlays(env: &mut JNIEnv, context: &JObject) -> Result { + if android_sdk_int(env)? < 23 { + return Ok(true); + } + env.call_static_method( + "android/provider/Settings", + "canDrawOverlays", + "(Landroid/content/Context;)Z", + &[JValue::Object(context)], + ) + .and_then(|value| value.z()) + .map_err(|error| format!("Settings.canDrawOverlays: {error}")) + } + + pub fn check_self_permission( + env: &mut JNIEnv, + context: &JObject, + permission: &str, + ) -> Result { + if android_sdk_int(env)? < 23 { + return Ok(true); + } + let permission_obj = jobject_str(env, permission)?; + let result = env + .call_method( + context, + "checkSelfPermission", + "(Ljava/lang/String;)I", + &[JValue::Object(&permission_obj)], + ) + .and_then(|value| value.i()) + .map_err(|error| format!("Context.checkSelfPermission({permission}): {error}"))?; + Ok(result == 0) + } + + pub fn request_record_audio_permission<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + ) -> Result { + call_static_bool_with_context_class( + env, + context, + "com.openless.app.OpenLessPermissionBridge", + "requestRecordAudioPermission", + "(Landroid/content/Context;)Z", + &[JValue::Object(context)], + ) + } + + pub fn launch_app_details_settings(env: &mut JNIEnv, context: &JObject) -> Result<(), String> { + let action_obj = jobject_str(env, "android.settings.APPLICATION_DETAILS_SETTINGS")?; + let null_obj = JObject::null(); + let package_name = env + .call_method(context, "getPackageName", "()Ljava/lang/String;", &[]) + .and_then(|value| value.l()) + .map_err(|error| format!("Context.getPackageName: {error}"))?; + let package_prefix = jobject_str(env, "package")?; + let uri = env + .call_static_method( + "android/net/Uri", + "fromParts", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Landroid/net/Uri;", + &[ + JValue::Object(&package_prefix), + JValue::Object(&package_name), + JValue::Object(&null_obj), + ], + ) + .and_then(|value| value.l()) + .map_err(|error| format!("Uri.fromParts(package): {error}"))?; + start_settings_intent(env, context, &action_obj, Some(&uri)) + } + + pub fn launch_overlay_settings(env: &mut JNIEnv, context: &JObject) -> Result<(), String> { + if android_sdk_int(env)? < 23 { + return Ok(()); + } + let action_obj = jobject_str(env, "android.settings.action.MANAGE_OVERLAY_PERMISSION")?; + let null_obj = JObject::null(); + let package_name = env + .call_method(context, "getPackageName", "()Ljava/lang/String;", &[]) + .and_then(|value| value.l()) + .map_err(|error| format!("Context.getPackageName: {error}"))?; + let package_prefix = jobject_str(env, "package")?; + let uri = env + .call_static_method( + "android/net/Uri", + "fromParts", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Landroid/net/Uri;", + &[ + JValue::Object(&package_prefix), + JValue::Object(&package_name), + JValue::Object(&null_obj), + ], + ) + .and_then(|value| value.l()) + .map_err(|error| format!("Uri.fromParts(package): {error}"))?; + start_settings_intent(env, context, &action_obj, Some(&uri)) + } + + pub fn android_sdk_int(env: &mut JNIEnv) -> Result { + env.get_static_field("android/os/Build$VERSION", "SDK_INT", "I") + .and_then(|value| value.i()) + .map_err(|error| format!("read SDK_INT: {error}")) + } + + pub fn copy_to_clipboard( + env: &mut JNIEnv, + context: &JObject, + text: &str, + ) -> Result { + let clipboard_name = jobject_str(env, "clipboard")?; + let clipboard = env + .call_method( + context, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(&clipboard_name)], + ) + .and_then(|value| value.l()) + .map_err(|error| format!("get clipboard service: {error}"))?; + let label = jobject_str(env, "OpenLess")?; + let text_obj = jobject_str(env, text)?; + let clip = env + .call_static_method( + "android/content/ClipData", + "newPlainText", + "(Ljava/lang/CharSequence;Ljava/lang/CharSequence;)Landroid/content/ClipData;", + &[JValue::Object(&label), JValue::Object(&text_obj)], + ) + .and_then(|value| value.l()) + .map_err(|error| format!("new ClipData: {error}"))?; + env.call_method( + &clipboard, + "setPrimaryClip", + "(Landroid/content/ClipData;)V", + &[JValue::Object(&clip)], + ) + .map_err(|error| format!("setPrimaryClip: {error}"))?; + Ok(true) + } + + pub fn notify_overlay_bridge<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + state: &str, + message: Option<&str>, + ) -> Result<(), String> { + let state_obj = jobject_str(env, state)?; + let message_obj = jobject_str(env, message.unwrap_or(""))?; + call_static_void_with_context_class( + env, + context, + "com.openless.app.OpenLessOverlayBridge", + "onCapsuleStateChanged", + "(Ljava/lang/String;Ljava/lang/String;)V", + &[JValue::Object(&state_obj), JValue::Object(&message_obj)], + ) + } + + pub fn show_overlay_toast<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + message: &str, + ) -> Result<(), String> { + let message_obj = jobject_str(env, message)?; + call_static_void_with_context_class( + env, + context, + "com.openless.app.OpenLessOverlayBridge", + "showToast", + "(Ljava/lang/String;)V", + &[JValue::Object(&message_obj)], + ) + } + + pub fn accessibility_paste<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + ) -> Result { + call_static_bool_with_context_class( + env, + context, + "com.openless.app.OpenLessAccessibilityService", + "pasteToFocusedField", + "()Z", + &[], + ) + } + + pub fn accessibility_selected_text<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + ) -> Result, String> { + let class = load_context_class( + env, + context, + "com.openless.app.OpenLessAccessibilityService", + )?; + let value = env + .call_static_method(class, "captureSelectedText", "()Ljava/lang/String;", &[]) + .and_then(|value| value.l()) + .map_err(|error| { + format!("call com.openless.app.OpenLessAccessibilityService.captureSelectedText: {error}") + })?; + if value.is_null() { + return Ok(None); + } + let text = env + .get_string(&JString::from(value)) + .map_err(|error| format!("read selected text jstring: {error}"))? + .to_string_lossy() + .into_owned(); + if text.trim().is_empty() { + Ok(None) + } else { + Ok(Some(text)) + } + } + + pub fn accessibility_enabled<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + ) -> Result { + call_static_bool_with_context_class( + env, + context, + "com.openless.app.OpenLessAccessibilityService", + "isEnabled", + "(Landroid/content/Context;)Z", + &[JValue::Object(context)], + ) + } + + pub fn accessibility_operational<'local>( + env: &mut JNIEnv<'local>, + context: &JObject<'local>, + ) -> Result { + call_static_bool_with_context_class( + env, + context, + "com.openless.app.OpenLessAccessibilityService", + "isOperational", + "(Landroid/content/Context;)Z", + &[JValue::Object(context)], + ) + } + + pub fn launch_accessibility_settings( + env: &mut JNIEnv, + context: &JObject, + ) -> Result<(), String> { + let action_obj = jobject_str(env, "android.settings.ACCESSIBILITY_SETTINGS")?; + start_settings_intent(env, context, &action_obj, None) + } + + fn start_settings_intent( + env: &mut JNIEnv, + context: &JObject, + action_obj: &JObject, + data_uri: Option<&JObject>, + ) -> Result<(), String> { + let intent = env + .new_object( + "android/content/Intent", + "(Ljava/lang/String;)V", + &[JValue::Object(&action_obj)], + ) + .map_err(|error| format!("create settings intent: {error}"))?; + if let Some(uri) = data_uri { + env.call_method( + &intent, + "setData", + "(Landroid/net/Uri;)Landroid/content/Intent;", + &[JValue::Object(uri)], + ) + .map_err(|error| format!("set settings intent data: {error}"))?; + } + env.call_method( + &intent, + "addFlags", + "(I)Landroid/content/Intent;", + &[JValue::Int(0x10000000)], + ) + .map_err(|error| format!("set intent flags: {error}"))?; + env.call_method( + context, + "startActivity", + "(Landroid/content/Intent;)V", + &[JValue::Object(&intent)], + ) + .map_err(|error| format!("start settings activity: {error}"))?; + Ok(()) + } + + pub fn export_jstring(env: &mut JNIEnv, value: &str) -> jni::sys::jstring { + env.new_string(value) + .map(|s| s.into_raw()) + .unwrap_or(std::ptr::null_mut()) + } + + pub fn export_jboolean(value: bool) -> jni::sys::jboolean { + if value { + 1 + } else { + 0 + } + } +} diff --git a/openless-all/app/src-tauri/src/android/mod.rs b/openless-all/app/src-tauri/src/android/mod.rs new file mode 100644 index 00000000..0f58039e --- /dev/null +++ b/openless-all/app/src-tauri/src/android/mod.rs @@ -0,0 +1,25 @@ +//! Android platform integration (JNI, overlay, accessibility, insert). + +pub mod accessibility; +#[cfg(target_os = "android")] +pub mod insert; +pub mod jni; +pub mod native_bridge; +pub mod overlay; +pub use crate::types::android_types as types; + +pub use accessibility::{ + get_android_accessibility_status, paste_via_accessibility, + request_android_accessibility_permission, AndroidAccessibilityPermissionResult, +}; +#[cfg(target_os = "android")] +pub use insert::android_insert_with_strategy; +pub use native_bridge::{ + hide_overlay, is_overlay_visible, notify_capsule_state, refresh_overlay_if_visible, + refresh_overlay_layout, register_android_coordinator, replace_overlay, show_overlay, +}; +pub use overlay::{ + get_android_overlay_status, hide_android_overlay, refresh_android_overlay_if_visible, + refresh_android_overlay_layout, replace_android_overlay, request_android_overlay_permission, + show_android_overlay, AndroidOverlayPermissionResult, +}; diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs new file mode 100644 index 00000000..9673e2ce --- /dev/null +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -0,0 +1,396 @@ +//! JNI bridge between Kotlin overlay code and Rust Coordinator. + +use std::sync::{Arc, OnceLock}; + +use crate::coordinator::Coordinator; +use crate::types::{CapsulePayload, CapsuleState}; + +static COORDINATOR: OnceLock> = OnceLock::new(); +static OVERLAY_VISIBLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +pub fn register_android_coordinator(coordinator: Arc) { + let _ = COORDINATOR.set(coordinator); +} + +pub fn notify_capsule_state(payload: &CapsulePayload) { + #[cfg(target_os = "android")] + { + let state = capsule_state_name(payload.state); + let message = payload.message.as_deref(); + if let Err(error) = crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::notify_overlay_bridge(env, context, state, message) + }) { + log::warn!("[android-native] notify overlay bridge failed: {error}"); + } + } + let _ = payload; +} + +pub fn show_overlay() -> Result<(), String> { + #[cfg(target_os = "android")] + { + crate::android::jni::android::with_android_env(|env, context| { + show_overlay_with_context(env, context) + })?; + } + Ok(()) +} + +pub fn hide_overlay() -> Result<(), String> { + #[cfg(target_os = "android")] + { + crate::android::jni::android::with_android_env(|env, context| { + hide_overlay_with_context(env, context) + })?; + } + Ok(()) +} + +pub fn replace_overlay() -> Result<(), String> { + #[cfg(target_os = "android")] + { + crate::android::jni::android::with_android_env(|env, context| { + replace_overlay_with_context(env, context) + })?; + } + Ok(()) +} + +pub fn refresh_overlay_layout() -> Result<(), String> { + #[cfg(target_os = "android")] + { + crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::start_service_action( + env, + context, + "com.openless.app.OpenLessOverlayService", + "com.openless.app.overlay.REFRESH_LAYOUT", + ) + })?; + } + Ok(()) +} + +pub fn refresh_overlay_if_visible() -> Result<(), String> { + if is_overlay_visible() { + refresh_overlay_layout() + } else { + Ok(()) + } +} + +#[cfg(target_os = "android")] +fn show_overlay_with_context( + env: &mut jni::JNIEnv, + context: &jni::objects::JObject, +) -> Result<(), String> { + crate::android::jni::android::start_service_action( + env, + context, + "com.openless.app.OpenLessOverlayService", + "com.openless.app.overlay.SHOW", + )?; + OVERLAY_VISIBLE.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) +} + +#[cfg(target_os = "android")] +fn hide_overlay_with_context( + env: &mut jni::JNIEnv, + context: &jni::objects::JObject, +) -> Result<(), String> { + crate::android::jni::android::start_service_action( + env, + context, + "com.openless.app.OpenLessOverlayService", + "com.openless.app.overlay.HIDE", + )?; + OVERLAY_VISIBLE.store(false, std::sync::atomic::Ordering::SeqCst); + Ok(()) +} + +#[cfg(target_os = "android")] +fn replace_overlay_with_context( + env: &mut jni::JNIEnv, + context: &jni::objects::JObject, +) -> Result<(), String> { + crate::android::jni::android::start_service_action( + env, + context, + "com.openless.app.OpenLessOverlayService", + "com.openless.app.overlay.REPLACE_OVERLAY", + )?; + OVERLAY_VISIBLE.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) +} + +pub fn is_overlay_visible() -> bool { + OVERLAY_VISIBLE.load(std::sync::atomic::Ordering::SeqCst) +} + +pub fn overlay_trigger_mode_name() -> &'static str { + let Some(coordinator) = COORDINATOR.get() else { + return "background"; + }; + match coordinator.android_overlay_trigger() { + crate::types::AndroidOverlayTrigger::Background => "background", + crate::types::AndroidOverlayTrigger::Keyboard => "keyboard", + crate::types::AndroidOverlayTrigger::Always => "always", + } +} + +fn spawn_start_dictation(translation: bool) { + let Some(coordinator) = COORDINATOR.get().cloned() else { + log::warn!("[android-native] coordinator unavailable"); + return; + }; + tauri::async_runtime::spawn(async move { + let result = if translation { + coordinator.start_dictation_with_translation().await + } else { + coordinator.start_dictation().await + }; + if let Err(error) = result { + log::warn!( + "[android-native] {} failed: {error}", + if translation { + "start_dictation_with_translation" + } else { + "start_dictation" + } + ); + } + }); +} + +fn spawn_stop_dictation() { + let Some(coordinator) = COORDINATOR.get().cloned() else { + log::warn!("[android-native] coordinator unavailable"); + return; + }; + tauri::async_runtime::spawn(async move { + if let Err(error) = coordinator.stop_dictation().await { + log::warn!("[android-native] stop_dictation failed: {error}"); + } + }); +} + +fn spawn_stop_dictation_with_translation(translation: bool) { + let Some(coordinator) = COORDINATOR.get().cloned() else { + log::warn!("[android-native] coordinator unavailable"); + return; + }; + tauri::async_runtime::spawn(async move { + if let Err(error) = coordinator + .stop_dictation_with_translation(translation) + .await + { + log::warn!("[android-native] stop_dictation_with_translation failed: {error}"); + } + }); +} + +fn spawn_cancel_dictation() { + let Some(coordinator) = COORDINATOR.get().cloned() else { + return; + }; + coordinator.cancel_dictation(); +} + +fn spawn_switch_style_pack() { + let Some(coordinator) = COORDINATOR.get().cloned() else { + log::warn!("[android-native] coordinator unavailable"); + return; + }; + coordinator.switch_to_previous_style_pack(); +} + +fn spawn_open_qa_from_overlay() { + let Some(coordinator) = COORDINATOR.get().cloned() else { + log::warn!("[android-native] coordinator unavailable"); + return; + }; + log::info!("[android-native] open_qa_from_overlay requested"); + tauri::async_runtime::spawn(async move { + if let Err(error) = coordinator.open_qa_from_overlay().await { + log::warn!("[android-native] open_qa_from_overlay failed: {error}"); + } + }); +} + +fn spawn_finalize_qa_from_overlay() { + let Some(coordinator) = COORDINATOR.get().cloned() else { + log::warn!("[android-native] coordinator unavailable"); + return; + }; + log::info!("[android-native] finalize_qa_from_overlay requested"); + tauri::async_runtime::spawn(async move { + if let Err(error) = coordinator.finalize_qa_from_overlay().await { + log::warn!("[android-native] finalize_qa_from_overlay failed: {error}"); + } + }); +} + +fn capsule_state_name(state: CapsuleState) -> &'static str { + match state { + CapsuleState::Idle => "idle", + CapsuleState::Recording => "recording", + CapsuleState::Transcribing => "transcribing", + CapsuleState::Polishing => "polishing", + CapsuleState::Done => "done", + CapsuleState::Cancelled => "cancelled", + CapsuleState::Error => "error", + } +} + +#[cfg(target_os = "android")] +mod jni_exports { + use super::*; + use jni::objects::{JClass, JObject}; + use jni::sys::{jboolean, jstring, JNIEnv}; + use jni::JNIEnv as JniEnv; + + unsafe fn with_jni_context( + env_ptr: *mut JNIEnv, + context: JObject, + f: impl for<'local> FnOnce(&mut JniEnv<'local>, &JObject<'local>) -> Result, + ) -> Result { + let mut env = + JniEnv::from_raw(env_ptr).map_err(|error| format!("attach JNI env: {error}"))?; + f(&mut env, &context) + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStartDictation( + _env: *mut JNIEnv, + _class: JClass, + ) { + spawn_start_dictation(false); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStartDictationWithTranslation( + _env: *mut JNIEnv, + _class: JClass, + translation: jboolean, + ) { + spawn_start_dictation(translation != 0); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStopDictation( + _env: *mut JNIEnv, + _class: JClass, + ) { + spawn_stop_dictation(); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeStopDictationWithTranslation( + _env: *mut JNIEnv, + _class: JClass, + translation: jboolean, + ) { + spawn_stop_dictation_with_translation(translation != 0); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeCancelDictation( + _env: *mut JNIEnv, + _class: JClass, + ) { + spawn_cancel_dictation(); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeSwitchStylePack( + _env: *mut JNIEnv, + _class: JClass, + ) { + spawn_switch_style_pack(); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeOpenQaFromOverlay( + _env: *mut JNIEnv, + _class: JClass, + ) { + spawn_open_qa_from_overlay(); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeFinalizeQaFromOverlay( + _env: *mut JNIEnv, + _class: JClass, + ) { + spawn_finalize_qa_from_overlay(); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeShowOverlay( + env: *mut JNIEnv, + _class: JClass, + context: JObject, + ) { + let _ = with_jni_context(env, context, |env, context| { + show_overlay_with_context(env, context) + }); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeHideOverlay( + env: *mut JNIEnv, + _class: JClass, + context: JObject, + ) { + let _ = with_jni_context(env, context, |env, context| { + hide_overlay_with_context(env, context) + }); + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeCanDrawOverlays( + env: *mut JNIEnv, + _class: JClass, + context: JObject, + ) -> jboolean { + let visible = with_jni_context(env, context, |env, context| { + crate::android::jni::android::can_draw_overlays(env, context) + }) + .unwrap_or(false); + crate::android::jni::android::export_jboolean(visible) + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeIsOverlayVisible( + _env: *mut JNIEnv, + _class: JClass, + ) -> jboolean { + crate::android::jni::android::export_jboolean(is_overlay_visible()) + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeGetOverlayTriggerMode( + env: *mut JNIEnv, + _class: JClass, + ) -> jstring { + let mode = overlay_trigger_mode_name(); + match JniEnv::from_raw(env) { + Ok(mut env) => crate::android::jni::android::export_jstring(&mut env, mode), + Err(_) => std::ptr::null_mut(), + } + } + + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeNotifyOverlayPermissionChanged( + env: *mut JNIEnv, + _class: JClass, + context: JObject, + ) { + if overlay_trigger_mode_name() == "always" { + let _ = with_jni_context(env, context, |env, context| { + show_overlay_with_context(env, context) + }); + } + } +} diff --git a/openless-all/app/src-tauri/src/android/overlay.rs b/openless-all/app/src-tauri/src/android/overlay.rs new file mode 100644 index 00000000..4b45b455 --- /dev/null +++ b/openless-all/app/src-tauri/src/android/overlay.rs @@ -0,0 +1,145 @@ +//! Android overlay window permission and foreground service integration. + +use serde::Serialize; + +use crate::android::types::AndroidOverlayStatus; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AndroidOverlayPermissionResult { + pub launched: bool, + pub message: String, +} + +pub fn get_android_overlay_status() -> AndroidOverlayStatus { + #[cfg(target_os = "android")] + { + android_impl::get_android_overlay_status() + } + + #[cfg(not(target_os = "android"))] + { + use crate::android::types::AndroidOverlayPermissionState; + + AndroidOverlayStatus { + permission: AndroidOverlayPermissionState::NotAndroid, + overlay_visible: false, + message: "Android overlay is only available on Android".to_string(), + } + } +} + +pub fn request_android_overlay_permission() -> AndroidOverlayPermissionResult { + #[cfg(target_os = "android")] + { + android_impl::request_android_overlay_permission() + } + + #[cfg(not(target_os = "android"))] + { + AndroidOverlayPermissionResult { + launched: false, + message: "Android overlay permission is only available on Android".to_string(), + } + } +} + +pub fn show_android_overlay() -> Result<(), String> { + #[cfg(target_os = "android")] + { + return crate::android::native_bridge::show_overlay(); + } + #[cfg(not(target_os = "android"))] + { + Err("Android overlay is only available on Android".to_string()) + } +} + +pub fn hide_android_overlay() -> Result<(), String> { + #[cfg(target_os = "android")] + { + return crate::android::native_bridge::hide_overlay(); + } + #[cfg(not(target_os = "android"))] + { + Err("Android overlay is only available on Android".to_string()) + } +} + +pub fn refresh_android_overlay_if_visible() -> Result<(), String> { + #[cfg(target_os = "android")] + { + return crate::android::native_bridge::refresh_overlay_if_visible(); + } + #[cfg(not(target_os = "android"))] + { + Err("Android overlay is only available on Android".to_string()) + } +} + +pub fn refresh_android_overlay_layout() -> Result<(), String> { + #[cfg(target_os = "android")] + { + return crate::android::native_bridge::refresh_overlay_layout(); + } + #[cfg(not(target_os = "android"))] + { + Err("Android overlay is only available on Android".to_string()) + } +} + +pub fn replace_android_overlay() -> Result<(), String> { + #[cfg(target_os = "android")] + { + return crate::android::native_bridge::replace_overlay(); + } + #[cfg(not(target_os = "android"))] + { + Err("Android overlay is only available on Android".to_string()) + } +} + +#[cfg(target_os = "android")] +mod android_impl { + use super::{AndroidOverlayPermissionResult, AndroidOverlayStatus}; + use crate::android::types::{AndroidOverlayPermissionState, AndroidOverlayStatus as Status}; + + pub fn get_android_overlay_status() -> AndroidOverlayStatus { + let granted = crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::can_draw_overlays(env, context) + }) + .unwrap_or(false); + Status { + permission: if granted { + AndroidOverlayPermissionState::Granted + } else { + AndroidOverlayPermissionState::NotGranted + }, + overlay_visible: crate::android::native_bridge::is_overlay_visible(), + message: if granted { + "悬浮窗权限已授予".to_string() + } else { + "请在系统设置中授予悬浮窗权限".to_string() + }, + } + } + + pub fn request_android_overlay_permission() -> AndroidOverlayPermissionResult { + match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::start_activity_class( + env, + context, + "com.openless.app.OverlayPermissionActivity", + ) + }) { + Ok(()) => AndroidOverlayPermissionResult { + launched: true, + message: "已打开悬浮窗权限设置".to_string(), + }, + Err(error) => AndroidOverlayPermissionResult { + launched: false, + message: error, + }, + } + } +} diff --git a/openless-all/app/src-tauri/src/android/types.rs b/openless-all/app/src-tauri/src/android/types.rs new file mode 100644 index 00000000..c32d6e02 --- /dev/null +++ b/openless-all/app/src-tauri/src/android/types.rs @@ -0,0 +1,323 @@ +//! Android-specific preference types and status payloads. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AndroidInsertStrategy { + Auto, + Ime, + Accessibility, + Clipboard, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AndroidOverlayTrigger { + Background, + Keyboard, + Always, +} + +impl AndroidOverlayTrigger { + pub fn normalized(self) -> Self { + match self { + AndroidOverlayTrigger::Keyboard => AndroidOverlayTrigger::Background, + trigger => trigger, + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AndroidOverlayActivationMode { + Tap, + LongPress, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AndroidOverlayLeftSwipeAction { + Translation, + StylePack, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AndroidOverlayCancelSwipeDirection { + Up, + Down, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AndroidAccessibilityState { + Enabled, + NotEnabled, + NotAndroid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AndroidAccessibilityStatus { + pub state: AndroidAccessibilityState, + pub enabled: bool, + pub message: String, +} + +pub fn default_android_insert_strategy() -> AndroidInsertStrategy { + AndroidInsertStrategy::Accessibility +} + +pub fn default_android_overlay_trigger() -> AndroidOverlayTrigger { + AndroidOverlayTrigger::Background +} + +pub fn default_android_overlay_activation_mode() -> AndroidOverlayActivationMode { + AndroidOverlayActivationMode::Tap +} + +pub fn default_android_overlay_left_swipe_action() -> AndroidOverlayLeftSwipeAction { + AndroidOverlayLeftSwipeAction::Translation +} + +pub fn default_android_overlay_cancel_swipe_direction() -> AndroidOverlayCancelSwipeDirection { + AndroidOverlayCancelSwipeDirection::Up +} + +pub fn default_android_overlay_size_dp() -> u32 { + 72 +} + +pub fn normalize_android_insert_strategy(strategy: AndroidInsertStrategy) -> AndroidInsertStrategy { + match strategy { + AndroidInsertStrategy::Auto | AndroidInsertStrategy::Ime => { + AndroidInsertStrategy::Accessibility + } + strategy => strategy, + } +} + +pub fn normalize_android_overlay_size_dp(size_dp: u32) -> u32 { + size_dp.clamp(48, 120) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AndroidOverlaySettingsAction { + None, + RefreshLayout, + Transition { + from: AndroidOverlayTrigger, + to: AndroidOverlayTrigger, + }, +} + +pub fn classify_android_overlay_settings_change( + previous: &super::UserPreferences, + next: &super::UserPreferences, +) -> AndroidOverlaySettingsAction { + let trigger_changed = + previous.android_overlay_trigger.normalized() != next.android_overlay_trigger.normalized(); + let size_changed = normalize_android_overlay_size_dp(previous.android_overlay_size_dp) + != normalize_android_overlay_size_dp(next.android_overlay_size_dp); + + if trigger_changed { + return AndroidOverlaySettingsAction::Transition { + from: previous.android_overlay_trigger.normalized(), + to: next.android_overlay_trigger.normalized(), + }; + } + + if size_changed { + return AndroidOverlaySettingsAction::RefreshLayout; + } + + AndroidOverlaySettingsAction::None +} + +#[cfg(test)] +mod android_overlay_tests { + use super::*; + use crate::types::UserPreferences; + + fn overlay_prefs( + trigger: AndroidOverlayTrigger, + size_dp: u32, + activation: AndroidOverlayActivationMode, + ) -> UserPreferences { + let mut prefs = UserPreferences::default(); + prefs.android_overlay_trigger = trigger; + prefs.android_overlay_size_dp = size_dp; + prefs.android_overlay_activation_mode = activation; + prefs + } + + #[test] + fn size_only_change_returns_refresh_layout() { + let previous = overlay_prefs( + AndroidOverlayTrigger::Always, + 72, + AndroidOverlayActivationMode::Tap, + ); + let next = overlay_prefs( + AndroidOverlayTrigger::Always, + 96, + AndroidOverlayActivationMode::Tap, + ); + assert_eq!( + classify_android_overlay_settings_change(&previous, &next), + AndroidOverlaySettingsAction::RefreshLayout, + ); + } + + #[test] + fn trigger_only_change_returns_transition() { + let previous = overlay_prefs( + AndroidOverlayTrigger::Background, + 72, + AndroidOverlayActivationMode::Tap, + ); + let next = overlay_prefs( + AndroidOverlayTrigger::Always, + 72, + AndroidOverlayActivationMode::Tap, + ); + assert_eq!( + classify_android_overlay_settings_change(&previous, &next), + AndroidOverlaySettingsAction::Transition { + from: AndroidOverlayTrigger::Background, + to: AndroidOverlayTrigger::Always, + }, + ); + } + + #[test] + fn trigger_and_size_change_returns_transition_only() { + let previous = overlay_prefs( + AndroidOverlayTrigger::Background, + 72, + AndroidOverlayActivationMode::Tap, + ); + let next = overlay_prefs( + AndroidOverlayTrigger::Always, + 96, + AndroidOverlayActivationMode::Tap, + ); + assert_eq!( + classify_android_overlay_settings_change(&previous, &next), + AndroidOverlaySettingsAction::Transition { + from: AndroidOverlayTrigger::Background, + to: AndroidOverlayTrigger::Always, + }, + ); + } + + #[test] + fn activation_only_change_returns_none() { + let previous = overlay_prefs( + AndroidOverlayTrigger::Always, + 72, + AndroidOverlayActivationMode::Tap, + ); + let next = overlay_prefs( + AndroidOverlayTrigger::Always, + 72, + AndroidOverlayActivationMode::LongPress, + ); + assert_eq!( + classify_android_overlay_settings_change(&previous, &next), + AndroidOverlaySettingsAction::None, + ); + } + + #[test] + fn out_of_bounds_size_200_to_120_returns_none_after_normalize() { + let previous = overlay_prefs( + AndroidOverlayTrigger::Always, + 200, + AndroidOverlayActivationMode::Tap, + ); + let next = overlay_prefs( + AndroidOverlayTrigger::Always, + 120, + AndroidOverlayActivationMode::Tap, + ); + assert_eq!( + classify_android_overlay_settings_change(&previous, &next), + AndroidOverlaySettingsAction::None, + ); + } + + #[test] + fn out_of_bounds_size_below_min_normalizes_to_same_returns_none() { + let previous = overlay_prefs( + AndroidOverlayTrigger::Always, + 30, + AndroidOverlayActivationMode::Tap, + ); + let next = overlay_prefs( + AndroidOverlayTrigger::Always, + 48, + AndroidOverlayActivationMode::Tap, + ); + assert_eq!( + classify_android_overlay_settings_change(&previous, &next), + AndroidOverlaySettingsAction::None, + ); + } + + #[test] + fn identical_normalized_size_returns_none() { + let previous = overlay_prefs( + AndroidOverlayTrigger::Always, + 72, + AndroidOverlayActivationMode::Tap, + ); + let next = overlay_prefs( + AndroidOverlayTrigger::Always, + 72, + AndroidOverlayActivationMode::Tap, + ); + assert_eq!( + classify_android_overlay_settings_change(&previous, &next), + AndroidOverlaySettingsAction::None, + ); + } + + #[test] + fn keyboard_trigger_normalizes_to_background_for_transition() { + let previous = overlay_prefs( + AndroidOverlayTrigger::Keyboard, + 72, + AndroidOverlayActivationMode::Tap, + ); + let next = overlay_prefs( + AndroidOverlayTrigger::Always, + 72, + AndroidOverlayActivationMode::Tap, + ); + assert_eq!( + classify_android_overlay_settings_change(&previous, &next), + AndroidOverlaySettingsAction::Transition { + from: AndroidOverlayTrigger::Background, + to: AndroidOverlayTrigger::Always, + }, + ); + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AndroidOverlayPermissionState { + Granted, + NotGranted, + NotAndroid, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AndroidOverlayStatus { + pub permission: AndroidOverlayPermissionState, + pub overlay_visible: bool, + pub message: String, +} diff --git a/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs b/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs index 04d18545..91628f68 100644 --- a/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs +++ b/openless-all/app/src-tauri/src/asr/local/apple_speech_provider.rs @@ -77,10 +77,11 @@ impl AppleSpeechAsr { // SFSpeechRecognizer 是阻塞且基于 objc runloop 的同步桥接;放到 // spawn_blocking 不占 tokio runtime。与 LocalQwenAsr 走同一个 Tauri // 持有的 runtime handle。 - let result = - tauri::async_runtime::spawn_blocking(move || transcribe_pcm_blocking(&pcm, duration_ms)) - .await - .context("apple-speech transcribe spawn_blocking join 失败")?; + let result = tauri::async_runtime::spawn_blocking(move || { + transcribe_pcm_blocking(&pcm, duration_ms) + }) + .await + .context("apple-speech transcribe spawn_blocking join 失败")?; if result.is_ok() { self.buffer.lock().clear(); @@ -265,8 +266,9 @@ fn build_outcome(result: *mut AnyObject, error: *mut AnyObject) -> RecognitionOu } fn speech_recognizer_class() -> Result<&'static AnyClass> { - AnyClass::get("SFSpeechRecognizer") - .ok_or_else(|| anyhow!("SFSpeechRecognizer 类不可用(需要 macOS 10.15+ 并链接 Speech.framework)")) + AnyClass::get("SFSpeechRecognizer").ok_or_else(|| { + anyhow!("SFSpeechRecognizer 类不可用(需要 macOS 10.15+ 并链接 Speech.framework)") + }) } /// `[[SFSpeechRecognizer alloc] init]` —— 用系统当前 locale。 diff --git a/openless-all/app/src-tauri/src/asr/local/download.rs b/openless-all/app/src-tauri/src/asr/local/download.rs index 81f4f04d..fa4d4e90 100644 --- a/openless-all/app/src-tauri/src/asr/local/download.rs +++ b/openless-all/app/src-tauri/src/asr/local/download.rs @@ -226,18 +226,20 @@ impl DownloadManager { pub(crate) fn build_client() -> Result { // native-tls (macOS=SecureTransport) 不像 rustls 那样把 CDN unclean close - // 当致命错误。 + // 当致命错误。Android/iOS 无 native-tls feature,走默认 rustls。 // // User-Agent 用 aria2 的——hfd(hf-mirror 官方推荐)就是 aria2 包装, // 实测 aria2 UA 在 HF 反滥用规则里走白名单不挨 throttle;自定义 UA // (`openless/x`) 在 sustained 传输后会被 mirror 主动切流。 - reqwest::Client::builder() - .use_native_tls() + let mut builder = reqwest::Client::builder() .user_agent("aria2/1.36.0") .connect_timeout(std::time::Duration::from_secs(30)) - .pool_idle_timeout(std::time::Duration::from_secs(60)) - .build() - .context("build reqwest client failed") + .pool_idle_timeout(std::time::Duration::from_secs(60)); + #[cfg(not(mobile))] + { + builder = builder.use_native_tls(); + } + builder.build().context("build reqwest client failed") } async fn run_download( diff --git a/openless-all/app/src-tauri/src/asr/local/test_run.rs b/openless-all/app/src-tauri/src/asr/local/test_run.rs index 3f60abb9..930bebde 100644 --- a/openless-all/app/src-tauri/src/asr/local/test_run.rs +++ b/openless-all/app/src-tauri/src/asr/local/test_run.rs @@ -55,7 +55,10 @@ pub async fn run_test(model_id: ModelId) -> Result { for fname in &required_files { let path = dir.join(fname); if !path.exists() { - anyhow::bail!("模型文件缺失:{fname},请重新下载(预期路径:{})", path.display()); + anyhow::bail!( + "模型文件缺失:{fname},请重新下载(预期路径:{})", + path.display() + ); } let meta = std::fs::metadata(&path) .map_err(|e| anyhow::anyhow!("读取 {fname} 元数据失败:{e}"))?; diff --git a/openless-all/app/src-tauri/src/commands/credentials.rs b/openless-all/app/src-tauri/src/commands/credentials.rs index 1778199e..164b6ac2 100644 --- a/openless-all/app/src-tauri/src/commands/credentials.rs +++ b/openless-all/app/src-tauri/src/commands/credentials.rs @@ -28,6 +28,14 @@ pub(crate) fn asr_configured_for_provider(provider: &str, snap: &CredentialsSnap if provider == "volcengine" { return volcengine_configured(snap); } + if cfg!(mobile) + && (provider == crate::asr::local::PROVIDER_ID + || provider == crate::asr::local::sherpa::PROVIDER_ID + || provider == crate::asr::local::foundry::PROVIDER_ID + || provider == crate::asr::local::APPLE_SPEECH_PROVIDER_ID) + { + return false; + } if provider == crate::asr::local::PROVIDER_ID || active_apple_speech_asr_is_supported(provider) || active_foundry_asr_is_supported(provider) @@ -100,12 +108,14 @@ fn configured(field: &Option) -> bool { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg(not(mobile))] pub(crate) struct LocalAsrReleasePlan { pub(crate) qwen: bool, pub(crate) foundry: bool, pub(crate) sherpa: bool, } +#[cfg(not(mobile))] pub(crate) fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan { LocalAsrReleasePlan { qwen: provider != crate::asr::local::PROVIDER_ID, @@ -114,6 +124,7 @@ pub(crate) fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrRel } } +#[cfg(not(mobile))] pub(crate) async fn release_foundry_runtime_if_inactive( runtime: &Arc, release_foundry: bool, @@ -126,6 +137,7 @@ pub(crate) async fn release_foundry_runtime_if_inactive( } } +#[cfg(not(mobile))] pub(crate) async fn release_sherpa_runtime_if_inactive( runtime: &Arc, release_sherpa: bool, @@ -154,6 +166,26 @@ pub fn set_credential(window: Window, account: String, value: String) -> Result< Ok(()) } +#[cfg(mobile)] +#[tauri::command] +pub async fn set_active_asr_provider( + _coord: CoordinatorState<'_>, + provider: String, +) -> Result<(), String> { + if provider == crate::asr::local::PROVIDER_ID + || provider == crate::asr::local::sherpa::PROVIDER_ID + || provider == crate::asr::local::foundry::PROVIDER_ID + || provider == crate::asr::local::APPLE_SPEECH_PROVIDER_ID + { + return Err("Local ASR is not available on mobile".to_string()); + } + if CredentialsVault::get_active_asr() == provider { + return Ok(()); + } + CredentialsVault::set_active_asr_provider(&provider).map_err(|e| e.to_string()) +} + +#[cfg(not(mobile))] #[tauri::command] pub async fn set_active_asr_provider( coord: CoordinatorState<'_>, diff --git a/openless-all/app/src-tauri/src/commands/misc.rs b/openless-all/app/src-tauri/src/commands/misc.rs index bb2bfc9c..83506a6a 100644 --- a/openless-all/app/src-tauri/src/commands/misc.rs +++ b/openless-all/app/src-tauri/src/commands/misc.rs @@ -36,30 +36,74 @@ pub async fn check_network() -> NetworkCheckResult { #[tauri::command] pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus { + #[cfg(mobile)] + { + let _ = coord; + return HotkeyStatus { + adapter: crate::types::HotkeyAdapterKind::Unavailable, + state: crate::types::HotkeyStatusState::Failed, + message: Some("移动端不支持全局热键".into()), + last_error: Some(crate::types::HotkeyInstallError { + code: "unavailable".into(), + message: "Global hotkeys are not available on mobile".into(), + }), + }; + } + #[cfg(not(mobile))] coord.hotkey_status() } #[tauri::command] pub fn get_hotkey_capability(coord: CoordinatorState<'_>) -> HotkeyCapability { + #[cfg(mobile)] + { + let _ = coord; + return HotkeyCapability::current(); + } + #[cfg(not(mobile))] coord.hotkey_capability() } #[tauri::command] pub fn set_shortcut_recording_active(coord: CoordinatorState<'_>, active: bool) { + #[cfg(mobile)] + { + let _ = (coord, active); + return; + } + #[cfg(not(mobile))] coord.set_shortcut_recording_active(active); } #[tauri::command] +#[cfg(not(mobile))] pub fn get_windows_ime_status() -> WindowsImeStatus { crate::windows_ime_profile::get_windows_ime_status() } #[tauri::command] +#[cfg(mobile)] +pub fn list_microphone_devices() -> Result, String> { + Ok(Vec::new()) +} + +#[tauri::command] +#[cfg(not(mobile))] pub fn list_microphone_devices() -> Result, String> { crate::recorder::list_input_devices().map_err(|e| e.to_string()) } #[tauri::command] +#[cfg(mobile)] +pub async fn start_microphone_level_monitor( + _app: AppHandle, + _device_name: String, +) -> Result<(), String> { + Ok(()) +} + +#[tauri::command] +#[cfg(not(mobile))] pub async fn start_microphone_level_monitor( app: AppHandle, device_name: String, @@ -93,6 +137,12 @@ pub async fn start_microphone_level_monitor( #[tauri::command] pub async fn stop_microphone_level_monitor(app: AppHandle) { + #[cfg(mobile)] + { + let _ = app; + return; + } + #[cfg(not(mobile))] let _ = tauri::async_runtime::spawn_blocking(move || { let state = app.state::(); let recorder = state.lock().take(); diff --git a/openless-all/app/src-tauri/src/commands/mod.rs b/openless-all/app/src-tauri/src/commands/mod.rs index 53ebd656..2c128ba5 100644 --- a/openless-all/app/src-tauri/src/commands/mod.rs +++ b/openless-all/app/src-tauri/src/commands/mod.rs @@ -10,8 +10,11 @@ use std::sync::Arc; +#[cfg(not(mobile))] use parking_lot::Mutex; -use tauri::{Manager, State}; +#[cfg(not(mobile))] +use tauri::Manager; +use tauri::State; // 跨域共享的 crate 级导入:以 `pub(crate) use` 重导出,子模块用 `use super::*;` // 即可拿到,避免在 16 个文件里重复同一组 import。 @@ -19,18 +22,22 @@ pub(crate) use serde::Serialize; pub(crate) use serde_json::Value; pub(crate) use tauri::{AppHandle, Emitter, Window}; +#[cfg(not(mobile))] pub(crate) use crate::asr::local::foundry::{ model_alias_is_known, FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus, DEFAULT_MODEL_ALIAS, PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID, }; +#[cfg(not(mobile))] pub(crate) use crate::asr::local::sherpa::{ model_alias_is_known as sherpa_model_alias_is_known, SherpaCatalogModel, SherpaPrepareProgressPayload, SherpaRuntimeStatus, DEFAULT_MODEL_ALIAS as SHERPA_DEFAULT_MODEL_ALIAS, }; +#[cfg(not(mobile))] pub(crate) use crate::asr::local::sherpa_download::{ fetch_remote_info as fetch_sherpa_remote_info, SherpaDownloadManager, SherpaRemoteInfo, }; +#[cfg(not(mobile))] pub(crate) use crate::asr::local::{FoundryLocalRuntime, Mirror, SherpaOnnxRuntime}; pub(crate) use crate::coordinator::Coordinator; pub(crate) use crate::net; @@ -44,73 +51,90 @@ pub(crate) use crate::polish::{ OpenAICompatibleConfig, OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL, CODEX_OAUTH_PROVIDER_ID, }; +#[cfg(not(mobile))] pub(crate) use crate::recorder::{AudioConsumer, Recorder}; +#[cfg(not(mobile))] +pub(crate) use crate::types::WindowsImeStatus; pub(crate) use crate::types::{ - builtin_style_pack_id, default_active_style_pack_id, ChineseScriptPreference, ComboBinding, - CorrectionRule, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability, - HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding, StylePack, StylePackKind, - StylePackRuntimeDiagnostics, StyleSystemPrompts, UpdateChannel, UserPreferences, - VocabPresetStore, WindowsImeStatus, + builtin_style_pack_id, default_active_style_pack_id, AndroidAccessibilityStatus, + AndroidOverlayStatus, ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, + DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, + PolishMode, ShortcutBinding, StylePack, StylePackKind, StylePackRuntimeDiagnostics, + StyleSystemPrompts, UpdateChannel, UserPreferences, VocabPresetStore, }; mod credentials; mod dictation; mod dictionary; +#[cfg(not(mobile))] mod foundry_asr; mod github_oauth; mod history; mod hotkeys; +#[cfg(not(mobile))] mod local_asr; mod marketplace; mod misc; mod permissions_cmds; mod providers; mod qa; +#[cfg(not(mobile))] mod remote_input; mod settings; +#[cfg(not(mobile))] mod sherpa_asr; mod style_packs; pub use credentials::*; pub use dictation::*; pub use dictionary::*; +#[cfg(not(mobile))] pub use foundry_asr::*; pub use github_oauth::*; pub use history::*; pub use hotkeys::*; +#[cfg(not(mobile))] pub use local_asr::*; pub use marketplace::*; pub use misc::*; pub use permissions_cmds::*; pub use providers::*; pub use qa::*; +#[cfg(not(mobile))] pub use remote_input::*; pub use settings::*; // sherpa_onnx_asr_* 命令整组 `#[cfg(target_os = "windows")]`(见 lib.rs 的 // generate_handler! 清单)。非 Windows 平台这组 glob 重导出无人引用,会触发 // unused_imports;这是平台 cfg 的正常结果,不是真正的死代码。 +#[cfg(not(mobile))] #[allow(unused_imports)] pub use sherpa_asr::*; pub use style_packs::*; pub(crate) type CoordinatorState<'a> = State<'a, Arc>; +#[cfg(not(mobile))] pub type MicrophoneMonitorState = Mutex>; +#[cfg(not(mobile))] pub type TrayMicrophoneMenuState = Mutex>; +#[cfg(not(mobile))] pub struct TrayMicrophoneMenuItem { pub id: String, pub device_name: String, pub item: tauri::menu::CheckMenuItem, } +#[cfg(not(mobile))] pub fn sync_tray_microphone_selection(items: &[TrayMicrophoneMenuItem], device_name: &str) { for item in items { let _ = item.item.set_checked(item.device_name == device_name); } } +#[cfg(not(mobile))] pub(crate) struct LevelProbeConsumer; +#[cfg(not(mobile))] impl AudioConsumer for LevelProbeConsumer { fn consume_pcm_chunk(&self, _pcm: &[u8]) {} } @@ -188,7 +212,7 @@ pub(crate) fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), St .map_err(|e| e.to_string()) } -#[cfg(all(unix, not(target_os = "macos")))] +#[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))] pub(crate) fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> { std::process::Command::new("xdg-open") .arg(path) diff --git a/openless-all/app/src-tauri/src/commands/permissions_cmds.rs b/openless-all/app/src-tauri/src/commands/permissions_cmds.rs index d9351636..2a908386 100644 --- a/openless-all/app/src-tauri/src/commands/permissions_cmds.rs +++ b/openless-all/app/src-tauri/src/commands/permissions_cmds.rs @@ -1,5 +1,46 @@ use super::*; +#[tauri::command] +pub fn get_platform_capabilities() -> crate::types::PlatformCapabilities { + crate::types::PlatformCapabilities::current() +} + +#[tauri::command] +pub fn get_android_overlay_status() -> AndroidOverlayStatus { + crate::android::get_android_overlay_status() +} + +#[tauri::command] +pub fn request_android_overlay_permission() -> crate::android::AndroidOverlayPermissionResult { + crate::android::request_android_overlay_permission() +} + +#[tauri::command] +pub fn show_android_overlay() -> Result<(), String> { + crate::android::show_android_overlay() +} + +#[tauri::command] +pub fn hide_android_overlay() -> Result<(), String> { + crate::android::hide_android_overlay() +} + +#[tauri::command] +pub fn get_android_accessibility_status() -> AndroidAccessibilityStatus { + crate::android::get_android_accessibility_status() +} + +#[tauri::command] +pub fn request_android_accessibility_permission( +) -> crate::android::AndroidAccessibilityPermissionResult { + crate::android::request_android_accessibility_permission() +} + +#[tauri::command] +pub fn open_external_url(url: String) -> Result<(), String> { + crate::external_url::open_external_url(&url) +} + #[tauri::command] pub fn check_accessibility_permission() -> PermissionStatus { permissions::check_accessibility() diff --git a/openless-all/app/src-tauri/src/commands/providers.rs b/openless-all/app/src-tauri/src/commands/providers.rs index 2f302e8e..faa830dd 100644 --- a/openless-all/app/src-tauri/src/commands/providers.rs +++ b/openless-all/app/src-tauri/src/commands/providers.rs @@ -245,6 +245,9 @@ async fn validate_bailian_asr_provider() -> Result<(), String> { } pub(crate) fn active_asr_is_keyless_for_validation(provider: &str) -> bool { + if cfg!(mobile) { + return false; + } provider == crate::asr::local::PROVIDER_ID || active_apple_speech_asr_is_supported(provider) || active_foundry_asr_is_supported(provider) @@ -264,11 +267,11 @@ pub(crate) fn active_apple_speech_asr_is_supported(provider: &str) -> bool { } pub(crate) fn active_foundry_asr_is_supported(provider: &str) -> bool { - #[cfg(target_os = "windows")] + #[cfg(all(not(mobile), target_os = "windows"))] { provider == FOUNDRY_LOCAL_PROVIDER_ID } - #[cfg(not(target_os = "windows"))] + #[cfg(not(all(not(mobile), target_os = "windows")))] { let _ = provider; false @@ -276,11 +279,11 @@ pub(crate) fn active_foundry_asr_is_supported(provider: &str) -> bool { } pub(crate) fn active_sherpa_asr_is_supported(provider: &str) -> bool { - #[cfg(target_os = "windows")] + #[cfg(all(not(mobile), target_os = "windows"))] { provider == crate::asr::local::sherpa::PROVIDER_ID } - #[cfg(not(target_os = "windows"))] + #[cfg(not(all(not(mobile), target_os = "windows")))] { let _ = provider; false diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index 8d7a626b..779f8787 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -48,6 +48,19 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) { coord.qa_window_pin(pinned); } +/// 移动端 QA 面板录音按钮:Idle -> begin_qa_session,Recording -> end_qa_session。 +#[tauri::command] +pub async fn qa_toggle_recording(coord: CoordinatorState<'_>) -> Result<(), String> { + coord.qa_toggle_recording().await; + Ok(()) +} + +/// QA 面板键盘输入:复用语音 QA 的 LLM 管线,只替换问题来源。 +#[tauri::command] +pub async fn qa_submit_text(coord: CoordinatorState<'_>, text: String) -> Result<(), String> { + coord.qa_submit_text(text).await +} + /// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗。 #[tauri::command] pub fn less_computer_window_dismiss(coord: CoordinatorState<'_>) { diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 56b01698..f5771de5 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -169,6 +169,7 @@ pub(crate) fn persist_settings( Ok(()) } +#[cfg(not(mobile))] #[tauri::command] pub fn set_settings( coord: CoordinatorState<'_>, @@ -180,10 +181,13 @@ pub fn set_settings( let remote_prev = coord.prefs().get(); let packs = coord.style_packs().list().map_err(|e| e.to_string())?; sync_style_pack_preferences(&mut prefs, &packs); + prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized(); // 广播给所有 webview。issue #205:QaPanel 跑在独立 webview, // 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时 // 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。 persist_settings(&*coord, prefs.clone())?; + #[cfg(target_os = "android")] + coord.apply_android_overlay_settings_change(&remote_prev, &prefs); // refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。 // set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调 // 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改 @@ -213,6 +217,25 @@ pub fn set_settings( Ok(()) } +#[cfg(mobile)] +#[tauri::command] +pub fn set_settings( + coord: CoordinatorState<'_>, + app: AppHandle, + mut prefs: UserPreferences, +) -> Result<(), String> { + let previous = coord.prefs().get(); + let packs = coord.style_packs().list().map_err(|e| e.to_string())?; + sync_style_pack_preferences(&mut prefs, &packs); + prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized(); + persist_settings(&*coord, prefs.clone())?; + #[cfg(target_os = "android")] + coord.apply_android_overlay_settings_change(&previous, &prefs); + let _ = app.emit("prefs:changed", &prefs); + let _ = app.emit_to("main", "prefs:changed", &prefs); + Ok(()) +} + // ─────────────────────────── release channel (Beta opt-in) ─────────────────────────── // // 渠道偏好的写入路径跟 set_settings 复用 persist_settings:保持热键兜底归一化 @@ -341,6 +364,7 @@ fn extract_between(haystack: &str, open: &str, close: &str) -> Option { #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] +#[cfg(not(mobile))] pub struct AppUpdateMetadata { pub rid: tauri::ResourceId, pub current_version: String, @@ -357,6 +381,7 @@ pub struct AppUpdateMetadata { /// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。 /// 返回 None = 当前是最新;Some(metadata) = 有新版可装。 #[tauri::command] +#[cfg(not(mobile))] pub async fn app_check_update_with_channel( coord: CoordinatorState<'_>, webview: tauri::Webview, @@ -401,6 +426,7 @@ pub async fn app_check_update_with_channel( /// 把 fetch_latest_beta_release 找到的最新 prerelease tag 拼成 -beta manifest URL 对。 /// 顺序:先镜像(fastgit.cc 代理 GitHub),后直连 —— 跟 tauri.conf 现有 Stable /// endpoints 一致,让国内访问优先打到 CDN。 +#[cfg(not(mobile))] async fn resolve_beta_manifest_endpoints() -> Result, String> { let Some(latest) = fetch_latest_beta_release().await? else { return Err("尚未发布过 Beta 版本".to_string()); diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 71bea4d3..4e69526d 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -35,6 +35,7 @@ use crate::coordinator_state::{ publish_abort_idle_after_restore, start_processing_if_listening, startup_race_status, BeginOutcome, SessionId, SessionPhase, SessionState, StartupRaceStatus, }; +use crate::correction::apply_correction_rules; use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ @@ -61,52 +62,106 @@ use crate::windows_ime_ipc::ImeSubmitTarget; #[cfg(target_os = "windows")] use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; -mod asr_setup; -mod capsule; mod dictation; -mod dictation_end; -mod dictation_session; -mod dictation_streaming; -mod dictation_voice_agent; -mod hotkey_supervisors; -mod ime_insertion; -mod llm_pipeline; mod qa; -mod qa_session; mod resources; -mod voice_agent_hotkeys; - -// glob 重导出:让 dictation.rs/qa.rs/resources.rs/impl 里所有 `super::裸名` -// 引用继续通过父模块解析(拆分前的 `use super::*` 契约)。 -pub(crate) use asr_setup::*; -pub(crate) use capsule::*; -pub(crate) use dictation_end::*; -pub(crate) use dictation_session::*; -pub(crate) use dictation_streaming::*; -pub(crate) use dictation_voice_agent::*; -pub(crate) use hotkey_supervisors::*; -pub(crate) use ime_insertion::*; -pub(crate) use llm_pipeline::*; -pub(crate) use qa_session::*; -pub(crate) use voice_agent_hotkeys::*; + +pub(super) fn qa_event_target() -> &'static str { + #[cfg(target_os = "android")] + { + "main" + } + #[cfg(not(target_os = "android"))] + { + "qa" + } +} #[cfg(test)] -use dictation_session::dictation_error_code; -use dictation::{handle_pressed_edge, handle_released_edge}; -use dictation_session::{begin_session, cancel_session, request_stop_during_starting}; +use dictation::dictation_error_code; +use dictation::{ + begin_session, cancel_session, end_session, handle_pressed_edge, handle_released_edge, + request_stop_during_starting, +}; #[cfg(any(debug_assertions, test))] use dictation::{handle_pressed, handle_released}; -use qa::{close_qa_panel, handle_qa_hotkey_pressed, QaPhase, QaSessionState}; +use qa::{ + close_qa_panel, handle_qa_hotkey_pressed, handle_qa_option_edge, open_qa_panel, QaPhase, + QaSessionState, +}; #[cfg(test)] use resources::discard_startup_resources_for_session; use resources::{ acquire_recording_mute, cancel_active_asr, release_recording_mute, selected_microphone_device_name, stop_microphone_preview_monitor, stop_qa_recorder, - SessionResource, SharedRecordingMuteState, + take_asr_for_session, take_recorder_for_session, SessionResource, SharedRecordingMuteState, }; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum CapsuleShowStrategy { + NoActivate, + FallbackShow, +} + +fn capsule_show_strategy_for_platform() -> CapsuleShowStrategy { + // ⚠️ 如果改下面的 cfg 列表,**必须**同步更新单元测试 + // `capsule_show_strategy_matches_platform_activation_contract` 的两组 cfg — + // 否则 Linux CI 直接红(PR #451 即是这种漏改)。 + #[cfg(any(target_os = "macos", target_os = "windows"))] + { + CapsuleShowStrategy::NoActivate + } + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + { + CapsuleShowStrategy::FallbackShow + } +} + +static CAPSULE_NO_ACTIVATE_FALLBACK_WARNED: AtomicBool = AtomicBool::new(false); +static CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED: AtomicBool = AtomicBool::new(false); +static CAPSULE_FIRST_SHOW_LOGGED: AtomicBool = AtomicBool::new(false); +// #470 诊断 v2:capsule webview 句柄取不到时的一次性门,区分「窗口压根没创建」(A0)。 +static CAPSULE_WINDOW_MISSING_LOGGED: AtomicBool = AtomicBool::new(false); + +/// 给 #470 诊断日志用的 capsule 状态短名。显式枚举每个变体到 &'static str, +/// 不走 `Debug` —— 哪天 CapsuleState 加了 `String` 字段,`:?` 会把 ASR / polish +/// 内容意外灌进日志(pr_agent 提的 forward-looking 隐患);这里只输出状态名。 +fn capsule_state_log_name(state: CapsuleState) -> &'static str { + match state { + CapsuleState::Idle => "idle", + CapsuleState::Recording => "recording", + CapsuleState::Transcribing => "transcribing", + CapsuleState::Polishing => "polishing", + CapsuleState::Done => "done", + CapsuleState::Cancelled => "cancelled", + CapsuleState::Error => "error", + } +} + +fn show_capsule_window_for_recording( + app: &AppHandle, + window: &tauri::WebviewWindow, +) { + let mut needs_fallback = true; + if capsule_show_strategy_for_platform() == CapsuleShowStrategy::NoActivate { + needs_fallback = !show_capsule_window_no_activate(app, window); + if needs_fallback && !CAPSULE_NO_ACTIVATE_FALLBACK_WARNED.swap(true, Ordering::SeqCst) { + // 产品取舍:no-activate 是 macOS/AeroSpace 的主路径;但如果 ns_window + // 暂不可用,仍优先保住录音反馈,不让用户以为听写没启动。fallback 可能 + // 重新触发 workspace 跳转,只在 no-activate 失败时作为降级路径。 + log::warn!("[capsule] no-activate show failed; falling back to window.show()"); + } + } + + if needs_fallback { + if let Err(e) = window.show() { + log::warn!("[capsule] show fallback failed: {e}"); + } + } +} + #[derive(Clone)] -pub(crate) enum ActiveAsr { +enum ActiveAsr { Volcengine(Arc), Whisper(Arc), Mimo(Arc), @@ -120,7 +175,6 @@ pub(crate) enum ActiveAsr { #[cfg(target_os = "macos")] Local(Arc), /// Apple Speech(SFSpeechRecognizer)系统本地 ASR;只在 macOS 可达。 - /// 无模型下载、无凭据,首次使用弹系统授权(issue #574)。 #[cfg(target_os = "macos")] AppleSpeech(Arc), } @@ -170,7 +224,7 @@ pub struct Coordinator { inner: Arc, } -pub(crate) struct Inner { +struct Inner { app: Mutex>, history: HistoryStore, prefs: PreferencesStore, @@ -252,28 +306,19 @@ pub(crate) struct Inner { /// supervisor 线程,但 integration test 和未来 RunEvent::Exit 钩子需要这条 /// 显式退出路径。审计 3.1.2。 shutdown: AtomicBool, - // ── 远程输入(局域网手机录音)───────────────────────────── - /// true = 当前 begin_session 应跳过本地 cpal,改用手机经 WS 推来的 PCM。 - /// 由 Coordinator::start_remote_dictation 在 begin_session 前置位。 - remote_source_active: AtomicBool, - /// 远程会话的音频入口:begin_session 把组装好的 AudioConsumer 存这里, - /// WS server 收到手机 PCM 时取出 consume_pcm_chunk。等价于本地 cpal 喂 recorder。 + #[cfg(not(mobile))] remote_audio_sink: Mutex>>, - /// 远程输入 HTTPS+WS 服务句柄。None = 未启动。 + #[cfg(not(mobile))] remote_server: Mutex>, - /// refresh_remote_server 的代数:每次调用自增,spawn 出的任务持自己的代数, - /// 持锁后发现已有更新代排队则直接让位(连点开关/连改端口只跑最后一轮)。 + #[cfg(not(mobile))] remote_refresh_gen: AtomicU64, - /// 串行化「停旧 → 启新」全流程的异步锁。无串行化时两轮 refresh 可交错: - /// 后到者 take 到 None 跳过关停、去 bind 旧服务尚未释放的端口 → 误报 port-in-use。 + #[cfg(not(mobile))] remote_refresh_lock: tokio::sync::Mutex<()>, - /// 当前远程输入配对码(6 位数字)。进程内有效,不持久化(每次启动可轮换)。 + #[cfg(not(mobile))] remote_pin: Mutex>, - /// PC 端当前界面语言(BCP-47,如 "zh-CN")。前端切换语言时经命令同步, - /// H5 录音页据此渲染对应语言。进程内镜像,不持久化(前端会在启动/切换时重新下发)。 + #[cfg(not(mobile))] remote_locale: Mutex, - /// 远程「仅回传」开关:true = 手机端关掉了「电脑落字」,本次远程听写不插入到电脑光标, - /// 只把最终文字回传给手机(见 dictation 落字处 + remote:result)。默认 false(照常落字)。 + #[cfg(not(mobile))] remote_no_insert: AtomicBool, /// Less Computer 连续对话:true=浮窗里已有进行中的会话,下一轮 `claude --continue` 续上下文; /// 关闭浮窗(dismiss)复位为 false,下次说话开新会话。 @@ -281,7 +326,7 @@ pub(crate) struct Inner { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ActionHotkeyKind { +enum ActionHotkeyKind { SwitchStyle, OpenApp, } @@ -350,13 +395,19 @@ impl Coordinator { qa_stream_cancelled: Arc::new(AtomicBool::new(false)), local_asr_cache: Arc::new(crate::asr::local::LocalAsrCache::new()), shutdown: AtomicBool::new(false), - remote_source_active: AtomicBool::new(false), + #[cfg(not(mobile))] remote_audio_sink: Mutex::new(None), + #[cfg(not(mobile))] remote_server: Mutex::new(None), + #[cfg(not(mobile))] remote_refresh_gen: AtomicU64::new(0), + #[cfg(not(mobile))] remote_refresh_lock: tokio::sync::Mutex::new(()), + #[cfg(not(mobile))] remote_pin: Mutex::new(None), + #[cfg(not(mobile))] remote_locale: Mutex::new(String::from("zh-CN")), + #[cfg(not(mobile))] remote_no_insert: AtomicBool::new(false), less_computer_conversation: AtomicBool::new(false), }), @@ -426,13 +477,19 @@ impl Coordinator { foundry_local_runtime, sherpa_onnx_runtime, shutdown: AtomicBool::new(false), - remote_source_active: AtomicBool::new(false), + #[cfg(not(mobile))] remote_audio_sink: Mutex::new(None), + #[cfg(not(mobile))] remote_server: Mutex::new(None), + #[cfg(not(mobile))] remote_refresh_gen: AtomicU64::new(0), + #[cfg(not(mobile))] remote_refresh_lock: tokio::sync::Mutex::new(()), + #[cfg(not(mobile))] remote_pin: Mutex::new(None), + #[cfg(not(mobile))] remote_locale: Mutex::new(String::from("zh-CN")), + #[cfg(not(mobile))] remote_no_insert: AtomicBool::new(false), less_computer_conversation: AtomicBool::new(false), }), @@ -493,6 +550,101 @@ impl Coordinator { *self.inner.app.lock() = Some(handle); } + pub fn android_insert_strategy(&self) -> crate::types::AndroidInsertStrategy { + self.inner.prefs.get().android_insert_strategy + } + + pub fn android_overlay_trigger(&self) -> crate::types::AndroidOverlayTrigger { + self.inner.prefs.get().android_overlay_trigger.normalized() + } + + pub fn apply_android_overlay_settings_change( + &self, + previous: &crate::types::UserPreferences, + next: &crate::types::UserPreferences, + ) { + #[cfg(target_os = "android")] + { + use crate::types::android_types::{ + classify_android_overlay_settings_change, AndroidOverlaySettingsAction, + }; + match classify_android_overlay_settings_change(previous, next) { + AndroidOverlaySettingsAction::None => {} + AndroidOverlaySettingsAction::RefreshLayout => { + self.refresh_android_overlay_layout(); + } + AndroidOverlaySettingsAction::Transition { from, to } => { + self.transition_android_overlay_trigger(from, to); + } + } + } + let _ = (previous, next); + } + + pub fn transition_android_overlay_trigger( + &self, + from: crate::types::AndroidOverlayTrigger, + to: crate::types::AndroidOverlayTrigger, + ) { + #[cfg(target_os = "android")] + { + use crate::types::AndroidOverlayTrigger; + fn overlay_trigger_log_name(trigger: AndroidOverlayTrigger) -> &'static str { + match trigger.normalized() { + AndroidOverlayTrigger::Background => "background", + AndroidOverlayTrigger::Keyboard => "keyboard", + AndroidOverlayTrigger::Always => "always", + } + } + if from == to { + return; + } + log::info!( + "[coord] overlay transition from={} to={}", + overlay_trigger_log_name(from), + overlay_trigger_log_name(to), + ); + match (from, to) { + ( + AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard, + AndroidOverlayTrigger::Always, + ) => { + let _ = crate::android::replace_android_overlay(); + } + ( + AndroidOverlayTrigger::Always, + AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard, + ) => { + let _ = crate::android::hide_android_overlay(); + } + _ => {} + } + } + let _ = (from, to); + } + + pub fn apply_android_overlay_on_startup(&self) { + #[cfg(target_os = "android")] + { + use crate::types::AndroidOverlayTrigger; + match self.android_overlay_trigger() { + AndroidOverlayTrigger::Always => { + let _ = crate::android::replace_android_overlay(); + } + AndroidOverlayTrigger::Background | AndroidOverlayTrigger::Keyboard => { + let _ = crate::android::hide_android_overlay(); + } + } + } + } + + pub fn refresh_android_overlay_layout(&self) { + #[cfg(target_os = "android")] + { + let _ = crate::android::refresh_android_overlay_layout(); + } + } + /// 让所有 hotkey supervisor loop(dictation / qa / combo / translation / /// switch_style / open_app)在下一轮 sleep / poll 后退出。生产场景下进程退出 /// 一并 reap 所有线程,但 integration test 和未来 RunEvent::Exit 钩子需要 @@ -864,88 +1016,13 @@ impl Coordinator { /// 内联审批卡的 Approve / Deny 回执:解析等待中的 token。 pub fn less_computer_approve(&self, token: &str, approved: bool) { - dictation_voice_agent::resolve_less_computer_approval(token, approved); + dictation::resolve_less_computer_approval(token, approved); } pub fn history(&self) -> &HistoryStore { &self.inner.history } - /// 用**当前配置的** ASR provider 对一段已归档的 16k/mono/16-bit PCM 重新转录 - /// (issue #613「重新转录」)。复用 `build_qa_asr_start`,对所有 provider 统一: - /// 流式 provider 先 open_session 再灌音并取 final,批处理 provider 直接灌音后 - /// transcribe。整段转写按 provider 设置超时,防止挂死。 - /// - /// 只做 ASR,不做润色/落字/写历史 —— 回写历史由 command 层完成,保持本方法纯粹。 - pub async fn retranscribe_pcm(&self, pcm: Vec) -> Result { - let inner = &self.inner; - let active_asr = CredentialsVault::get_active_asr(); - let start = build_qa_asr_start(inner, &active_asr).await?; - start.open_streaming_session().await?; - let consumer = start.recorder_consumer(); - consumer.consume_pcm_chunk(&pcm); - let audio_secs = (pcm::pcm_duration_ms(&pcm) as f64) / 1000.0; - let timeout = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); - let raw = match start.active_asr() { - ActiveAsr::Volcengine(asr) => { - asr.send_last_frame().await.map_err(|e| e.to_string())?; - tokio::time::timeout(timeout, asr.await_final_result()) - .await - .map_err(|_| "重新转录超时".to_string())? - .map_err(|e| e.to_string())? - } - ActiveAsr::Bailian(asr) => { - asr.send_last_frame().await.map_err(|e| e.to_string())?; - tokio::time::timeout(timeout, asr.await_final_result()) - .await - .map_err(|_| "重新转录超时".to_string())? - .map_err(|e| e.to_string())? - } - ActiveAsr::Whisper(w) => { - let timeout = cloud_whisper_transcribe_timeout(audio_secs); - tokio::time::timeout(timeout, w.transcribe()) - .await - .map_err(|_| "重新转录超时".to_string())? - .map_err(|e| e.to_string())? - } - ActiveAsr::Mimo(m) => tokio::time::timeout(timeout, m.transcribe()) - .await - .map_err(|_| "重新转录超时".to_string())? - .map_err(|e| e.to_string())?, - #[cfg(target_os = "windows")] - ActiveAsr::FoundryLocalWhisper(local) => local - .transcribe(asr_setup::foundry_audio_transcribe_timeout_duration()) - .await - .map_err(|e| e.to_string())?, - #[cfg(target_os = "windows")] - ActiveAsr::SherpaOnnxLocal(local) => local - .transcribe(asr_setup::sherpa_audio_transcribe_timeout_duration()) - .await - .map_err(|e| e.to_string())?, - #[cfg(target_os = "macos")] - ActiveAsr::Local(local) => { - let dur = asr_setup::local_qwen_transcribe_timeout( - (local.buffer_duration_ms() as f64) / 1000.0, - ); - inner.local_asr_cache.touch(); - let out = tokio::time::timeout(dur, local.transcribe()) - .await - .map_err(|_| "重新转录超时".to_string())? - .map_err(|e| e.to_string())?; - asr_setup::schedule_local_asr_release(inner); - out - } - #[cfg(target_os = "macos")] - ActiveAsr::AppleSpeech(local) => { - let dur = asr_setup::local_qwen_transcribe_timeout(audio_secs); - tokio::time::timeout(dur, local.transcribe()) - .await - .map_err(|_| "重新转录超时".to_string())? - .map_err(|e| e.to_string())? - } - }; - Ok(raw.text) - } pub fn prefs(&self) -> &PreferencesStore { &self.inner.prefs } @@ -1067,6 +1144,15 @@ impl Coordinator { begin_session(&self.inner).await } + pub async fn start_dictation_with_translation(&self) -> Result<(), String> { + begin_session(&self.inner).await?; + self.inner + .translation_modifier_seen + .store(true, Ordering::SeqCst); + log::info!("[coord] android overlay translation dictation started"); + Ok(()) + } + pub async fn stop_dictation(&self) -> Result<(), String> { if self.inner.state.lock().phase == SessionPhase::Starting { request_stop_during_starting(&self.inner, "manual stop"); @@ -1075,47 +1161,34 @@ impl Coordinator { end_session(&self.inner).await } + pub async fn stop_dictation_with_translation(&self, translation: bool) -> Result<(), String> { + if translation { + mark_translation_modifier_seen(&self.inner); + } + self.stop_dictation().await + } + pub fn cancel_dictation(&self) { cancel_session(&self.inner); } - // ───────────────────────── 远程输入(局域网手机录音)───────────────────────── - // 把"远程输入"实现为一次普通听写会话,只是音频源换成手机经 WS 推来的 PCM: - // 完整复用 begin_session / end_session / cancel_session(一行不改)。本地与远程 - // 共用 inner.state,天然互斥。详见 dictation::start_recorder_for_starting 的远程分支。 - - /// 手机点"开始录音"。本地听写正在进行(phase != Idle)则拒绝并回 "busy"; - /// 否则置位 remote 标志后走 begin_session(内部跳过 cpal,把 consumer 存进 sink)。 - /// 设置远程「仅回传」开关(手机端「电脑落字」开关的反值)。true = 不落字、只回传。 + #[cfg(not(mobile))] pub fn set_remote_no_insert(&self, no_insert: bool) { self.inner .remote_no_insert .store(no_insert, Ordering::SeqCst); } + #[cfg(not(mobile))] pub async fn start_remote_dictation(&self) -> Result<(), String> { - // busy 判定与 remote_source_active 置位都在 begin_session_with_source 的 - // state 临界区内原子完成(与本地热键的 begin_session_state 同构)。之前是 - // 锁外预检查 + 锁外置位,竞态输家会把残留标志泄给抢先启动的本地会话。 - let r = begin_session_with_source(&self.inner, true).await; - if let Err(e) = &r { - // busy = 标志从未置位,不能清——清了会破坏正在进行的远程会话 - // (手机重复点「开始」就会走到这里)。置位之后的失败(ASR 凭据等)才回滚。 - if e != REMOTE_BUSY { - self.clear_remote_source(); - } - } - r + begin_session(&self.inner).await } - /// WS 每收到一帧二进制 PCM 调一次。仅 Starting/Listening 阶段转发给已组装的 - /// consumer(流式 ASR 的 DeferredAsrBridge 在 attach 前自缓冲,不丢早期音频)。 + #[cfg(not(mobile))] pub fn feed_remote_pcm(&self, pcm: &[u8]) { - { - let phase = self.inner.state.lock().phase; - if phase != SessionPhase::Listening && phase != SessionPhase::Starting { - return; - } + let phase = self.inner.state.lock().phase; + if phase != SessionPhase::Listening && phase != SessionPhase::Starting { + return; } let sink = self.inner.remote_audio_sink.lock().clone(); if let Some(consumer) = sink { @@ -1123,18 +1196,8 @@ impl Coordinator { } } - /// 手机点"停止"。Starting 阶段记 pending_stop(等启动完成自动收尾);否则走 - /// end_session(转写→润色→光标落字,与本地一致)。 - /// 远程标志的清理不在这里做:end_session 内的 RemoteFlagsJanitor 在会话真正 - /// 回到 Idle 时统一清。这里清会在 double-stop(第二次调用对 Processing 中的 - /// 在飞 end_session 早退后)把标志过早清掉——在飞调用读到 false 后, - /// 「仅回传」开关失效(文字落到 PC)且 remote:result 不再回传手机。 + #[cfg(not(mobile))] pub async fn stop_remote_dictation(&self) -> Result<(), String> { - // 守卫:当前会话不是远程发起的则忽略。否则手机的 stop 会终止 PC 用户 - // 正在进行的本地听写(stop/cancel 方向没有 busy 那样的天然互斥)。 - if !self.inner.remote_source_active.load(Ordering::SeqCst) { - return Ok(()); - } if self.inner.state.lock().phase == SessionPhase::Starting { request_stop_during_starting(&self.inner, "remote stop"); return Ok(()); @@ -1142,22 +1205,13 @@ impl Coordinator { end_session(&self.inner).await } - /// 手机断连 / 点取消:丢弃本次,不落字。 - /// 手机锁屏/切后台/Wi-Fi 抖动都会触发 WS 断连进而走到这里——守卫确保只 - /// 取消远程发起的会话,不误杀 PC 用户正在进行的本地听写。 + #[cfg(not(mobile))] pub fn cancel_remote_dictation(&self) { - if !self.inner.remote_source_active.load(Ordering::SeqCst) { - return; - } cancel_session(&self.inner); - self.clear_remote_source(); - } - - fn clear_remote_source(&self) { - clear_remote_source_flags(&self.inner); + *self.inner.remote_audio_sink.lock() = None; } - /// 当前远程输入运行态(供命令/前端查询)。 + #[cfg(not(mobile))] pub fn remote_input_status(&self) -> crate::remote_server::RemoteInputStatus { let prefs = self.inner.prefs.get(); let handle = self.inner.remote_server.lock(); @@ -1180,11 +1234,10 @@ impl Coordinator { } } - /// 重新生成 6 位配对码并重启服务。 + #[cfg(not(mobile))] pub fn regenerate_remote_pin(self: &Arc) -> String { let pin = crate::remote_server::generate_pin(); *self.inner.remote_pin.lock() = Some(pin.clone()); - // 写盘持久化,否则下次启动会读回旧的持久化码、把这次重置覆盖掉。 if let Some(app) = self.inner.app.lock().clone() { crate::remote_server::save_pin(&app, &pin); } @@ -1192,8 +1245,7 @@ impl Coordinator { pin } - /// 同步 PC 端界面语言(前端切换语言时调用)。H5 录音页据此选择显示语言。 - /// 仅接受受支持的白名单值,非法输入忽略(值会注入到 H5 的 lang,需防注入)。 + #[cfg(not(mobile))] pub fn set_remote_locale(&self, locale: String) { const SUPPORTED: [&str; 5] = ["zh-CN", "zh-TW", "en", "ja", "ko"]; if SUPPORTED.contains(&locale.as_str()) { @@ -1201,24 +1253,20 @@ impl Coordinator { } } - /// 当前 PC 端界面语言(供 H5 首页注入 lang)。 + #[cfg(not(mobile))] pub fn remote_locale(&self) -> String { self.inner.remote_locale.lock().clone() } - /// 按 prefs 启停 / 重启远程输入服务。在 setup 与 prefs 变更(端口/开关)时调用。 + #[cfg(not(mobile))] pub fn refresh_remote_server(self: &Arc) { let coord = Arc::clone(self); let gen = self.inner.remote_refresh_gen.fetch_add(1, Ordering::SeqCst) + 1; tauri::async_runtime::spawn(async move { - // 串行化整个「停旧 → 启新」:并发的两轮 refresh 交错时,后到者会 take 到 - // None 跳过关停、去 bind 旧服务还没释放的端口 → 误报 port-in-use。 let _serial = coord.inner.remote_refresh_lock.lock().await; - // 已有更新代排队(用户连点开关/连改端口):本代直接让位,只跑最后一轮。 if coord.inner.remote_refresh_gen.load(Ordering::SeqCst) != gen { return; } - // 先停旧(优雅关停) let old = coord.inner.remote_server.lock().take(); if let Some(handle) = old { handle.shutdown().await; @@ -1227,16 +1275,16 @@ impl Coordinator { let app = coord.inner.app.lock().clone(); if !prefs.remote_input_enabled { if let Some(app) = &app { - let _ = - app.emit("remote-input:running", serde_json::json!({"running": false})); + let _ = app.emit( + "remote-input:running", + serde_json::json!({"running": false}), + ); } return; } let Some(app) = app else { return; }; - // PIN:进程内 remote_pin 缺失时从磁盘读持久化的(没有才新生成并写盘)—— - // 否则每次重启配对码都变,用户得反复找新码(这正是"配对码错误"的根因)。 let pin = { let mut guard = coord.inner.remote_pin.lock(); if guard.is_none() { @@ -1244,7 +1292,6 @@ impl Coordinator { } guard.clone().unwrap_or_default() }; - log::info!("[remote-input] 当前配对码 = {pin}(在手机上输入这个)"); let port = prefs.remote_input_port; match crate::remote_server::start(crate::remote_server::RemoteServerConfig { port, @@ -1274,6 +1321,21 @@ impl Coordinator { }); } + pub fn switch_to_previous_style_pack(&self) { + switch_to_previous_style(&self.inner); + } + + pub async fn open_qa_from_overlay(&self) -> Result<(), String> { + log::info!("[coord] overlay QA open requested"); + open_qa_panel(&self.inner); + begin_qa_session(&self.inner).await + } + + pub async fn finalize_qa_from_overlay(&self) -> Result<(), String> { + log::info!("[coord] overlay QA finalize requested"); + finalize_dictation_as_qa_question(&self.inner).await + } + /// 返回当前听写阶段(read-only 快照),供 CLI 入口在 dispatch toggle 时决策。 /// 与原热键边沿走的 `handle_pressed` 分支完全相同的判定逻辑:Idle → start, /// Listening → stop。可用于桌面快捷键 → CLI 转发的备用触发路径。 @@ -1288,6 +1350,14 @@ impl Coordinator { handle_qa_hotkey_pressed(&self.inner).await; } + pub async fn qa_toggle_recording(&self) { + handle_qa_option_edge(&self.inner).await; + } + + pub async fn qa_submit_text(&self, text: String) -> Result<(), String> { + submit_qa_text_question(&self.inner, text).await + } + pub fn set_shortcut_recording_active(&self, active: bool) { self.inner .shortcut_recording_active @@ -1370,6 +1440,68 @@ impl Coordinator { .map_err(|e| e.to_string()) } + pub async fn retranscribe_pcm(&self, pcm: Vec) -> Result { + let inner = &self.inner; + let active_asr = CredentialsVault::get_active_asr(); + let start = build_qa_asr_start(inner, &active_asr).await?; + start.open_streaming_session().await?; + let consumer = start.recorder_consumer(); + consumer.consume_pcm_chunk(&pcm); + let timeout = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + let raw = match start.active_asr() { + ActiveAsr::Volcengine(asr) => { + asr.send_last_frame().await.map_err(|e| e.to_string())?; + tokio::time::timeout(timeout, asr.await_final_result()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())? + } + ActiveAsr::Bailian(asr) => { + asr.send_last_frame().await.map_err(|e| e.to_string())?; + tokio::time::timeout(timeout, asr.await_final_result()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())? + } + ActiveAsr::Whisper(w) => tokio::time::timeout(timeout, w.transcribe()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())?, + ActiveAsr::Mimo(m) => tokio::time::timeout(timeout, m.transcribe()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())?, + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(local) => local + .transcribe(foundry_audio_transcribe_timeout_duration()) + .await + .map_err(|e| e.to_string())?, + #[cfg(target_os = "windows")] + ActiveAsr::SherpaOnnxLocal(local) => local + .transcribe(sherpa_audio_transcribe_timeout_duration()) + .await + .map_err(|e| e.to_string())?, + #[cfg(target_os = "macos")] + ActiveAsr::Local(local) => { + let dur = + local_qwen_transcribe_timeout((local.buffer_duration_ms() as f64) / 1000.0); + inner.local_asr_cache.touch(); + let out = tokio::time::timeout(dur, local.transcribe()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())?; + schedule_local_asr_release(inner); + out + } + #[cfg(target_os = "macos")] + ActiveAsr::AppleSpeech(local) => tokio::time::timeout(timeout, local.transcribe()) + .await + .map_err(|_| "重新转录超时".to_string())? + .map_err(|e| e.to_string())?, + }; + Ok(raw.text) + } + pub fn preview_style_pack_runtime( &self, style_pack: &crate::types::StylePack, @@ -1420,56 +1552,5459 @@ impl Coordinator { } } -// ─────────────────────────── hotkey bridging ─────────────────────────── - -// ─────────────────────────── session lifecycle ─────────────────────────── - -// ─────────────────────────── helpers ─────────────────────────── - -#[cfg(any(debug_assertions, test))] -fn hotkey_injection_dry_run_enabled() -> bool { - std::env::var_os("OPENLESS_HOTKEY_INJECTION_DRY_RUN").is_some() +fn raw_style_pack_uses_llm(pack: &crate::types::StylePack) -> bool { + !(pack.kind == crate::types::StylePackKind::Builtin + && pack.id == crate::types::BUILTIN_STYLE_PACK_RAW_ID + && pack.prompt == crate::types::StyleSystemPrompts::default().raw) } -#[cfg(any(debug_assertions, test))] -fn debug_transcript_override_text() -> Option { - let path = std::env::var_os("OPENLESS_DEBUG_TRANSCRIPT_FILE")?; - let text = std::fs::read_to_string(path).ok()?; - let trimmed = text.trim().to_string(); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } +fn raw_mode_uses_llm(style_system_prompt: &str) -> bool { + style_system_prompt != crate::types::StyleSystemPrompts::default().raw } -#[cfg(test)] -mod tests; - -/// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。 -/// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在 -/// 「准备做下一步副作用前」用。 -fn startup_race_status_for_starting( - inner: &Arc, - captured_session_id: SessionId, -) -> StartupRaceStatus { - let state = inner.state.lock(); - startup_race_status(&state, captured_session_id) -} +// ─────────────────────────── hotkey bridging ─────────────────────────── -fn set_phase_idle_if_session_matches(inner: &Arc, session_id: SessionId) { - let mut state = inner.state.lock(); - if state.session_id == session_id { - state.phase = SessionPhase::Idle; - } -} +fn hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + let capability = HotkeyMonitor::capability(); + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + let prefs = inner.prefs.get(); -/// 清远程音频源标志(幂等)。必须在远程会话生命周期的**每个**终结点调用: -/// 残留的 `remote_source_active=true` 会让下一次本地听写误走远程分支 -/// (跳过 cpal、挂上 sink 等手机 PCM),本地录音从此失效。 -/// 终结点:stop/cancel_remote_dictation、start 失败回滚、cancel_session、 -/// pending_stop 的延迟 end_session(finish_starting_session)。 -pub(crate) fn clear_remote_source_flags(inner: &Inner) { - inner.remote_source_active.store(false, Ordering::SeqCst); - *inner.remote_audio_sink.lock() = None; + if inner.hotkey.lock().is_some() { + return; + } + // Linux: 启动前检查 fcitx5 插件是否可用 + #[cfg(target_os = "linux")] + if !crate::linux_fcitx::available() { + *inner.hotkey_status.lock() = HotkeyStatus { + adapter: capability.adapter, + state: HotkeyStatusState::Failed, + message: Some("fcitx5 插件不可用 — 请确保 fcitx5 已安装且在运行".into()), + last_error: Some(crate::types::HotkeyInstallError { + code: "fcitx5_unavailable".into(), + message: "fcitx5 插件 DBus 接口无响应".into(), + }), + }; + log::warn!("[hotkey-supervisor] fcitx5 plugin unavailable, retrying..."); + attempts += 1; + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + *inner.hotkey_status.lock() = HotkeyStatus { + adapter: capability.adapter, + state: HotkeyStatusState::Starting, + message: Some(format!("正在安装全局快捷键监听(第 {} 次)", attempts + 1)), + last_error: None, + }; + let trigger = crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey) + .unwrap_or(crate::types::HotkeyTrigger::Custom); + let binding = crate::types::HotkeyBinding { + trigger, + mode: prefs.hotkey.mode, + keys: None, + }; + let (tx, rx) = mpsc::channel::(); + #[cfg(target_os = "linux")] + let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone()); + match HotkeyMonitor::start(binding, tx) { + Ok(monitor) => { + let adapter = monitor.kind(); + *inner.hotkey.lock() = Some(monitor); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } + *inner.hotkey_status.lock() = HotkeyStatus { + adapter, + state: HotkeyStatusState::Installed, + message: Some(format!("{} 已安装", adapter.display_name())), + last_error: None, + }; + log::info!( + "[coord] hotkey listener installed (after {} attempt(s))", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-hotkey-bridge".into()) + .spawn(move || hotkey_bridge_loop(inner_clone, rx)) + .ok(); + // Linux: 启动 fcitx5 插件信号监听作为热键源。 + #[cfg(target_os = "linux")] + { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + let custom_key = custom_dictation_key_string(&inner); + crate::linux_fcitx::start_dictation_signal_listener( + fcitx_tx, + fcitx_binding.clone(), + qa_trigger, + translation_trigger, + custom_key, + ); + if fcitx_binding.trigger == crate::types::HotkeyTrigger::Custom { + sync_custom_dictation_to_plugin(&inner); + } else { + crate::linux_fcitx::sync_binding_to_plugin(&fcitx_binding); + } + } + return; + } + Err(e) => { + attempts += 1; + let error_message = e.message.clone(); + *inner.hotkey_status.lock() = HotkeyStatus { + adapter: capability.adapter, + state: HotkeyStatusState::Failed, + message: Some(error_message.clone()), + last_error: Some(e), + }; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] hotkey listener attempt #{attempts} failed: {}; retrying in 3s", + error_message + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +// ─────────────────────────── QA hotkey supervisor ─────────────────────────── + +fn qa_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + // 用户已经把 QA 关掉就睡着等 prefs 改动;改动通过 update_qa_hotkey_binding 唤醒。 + let binding = match inner.prefs.get().qa_hotkey.clone() { + Some(b) => b, + None => { + inner.qa_hotkey.lock().take(); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + }; + if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() { + inner.qa_hotkey.lock().take(); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if inner.qa_hotkey.lock().is_some() { + // 已注册成功 → 不重复装;睡 5s 复查( binding 变化由 update 路径手动触发 )。 + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + // global-hotkey crate 在 macOS 走 Carbon RegisterEventHotKey,要求 manager + // 在主线程构造,否则 register() 看起来 Ok 但事件根本不会派发——这是 issue #118 + // PR #119 第一版漏掉的关键步骤,导致用户按了 hotkey 完全无反应。这里通过 + // run_on_main_thread 把 QaHotkeyMonitor::start 跳到主线程跑,结果再回 channel。 + let app = inner.app.lock().clone(); + let app = match app { + Some(a) => a, + None => { + // 启动期 AppHandle 还没 bind,再等。 + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = QaHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + // run_on_main_thread 是 fire-and-forget;等主线程跑完结果回来。给 5s 上限避免 + // 主线程繁忙时 supervisor 永久阻塞。 + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] QA hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *inner.qa_hotkey.lock() = Some(monitor); + log::info!( + "[coord] QA hotkey listener installed on main thread (after {} attempt(s))", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-qa-hotkey-bridge".into()) + .spawn(move || qa_hotkey_bridge_loop(inner_clone, rx)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!("[coord] QA hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn qa_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + QaHotkeyEvent::Pressed => { + async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); + } + } + } +} + +// ─────────────────────────── combo hotkey supervisor ─────────────────────────── + +// ─────────────────────── coding agent hotkey supervisor ─────────────────────── + +fn coding_agent_hotkey_supervisor_loop(inner: Arc) { + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + update_coding_agent_hotkey_binding_now(&inner); + std::thread::sleep(std::time::Duration::from_secs(5)); + } +} + +fn update_coding_agent_hotkey_binding_now(inner: &Arc) { + #[cfg(not(target_os = "macos"))] + { + // Less Computer is intentionally macOS-only for now; keep Windows/Linux hidden and inert. + take_coding_agent_hotkeys_on_main_thread(inner); + return; + } + + #[cfg(target_os = "macos")] + { + let prefs = inner.prefs.get(); + let Some(binding) = prefs.coding_agent_voice_hotkey.clone() else { + take_coding_agent_hotkeys_on_main_thread(inner); + log::info!("[less-computer] hotkey disabled"); + return; + }; + if !prefs.coding_agent_enabled || is_unconfigured_shortcut(&binding) { + take_coding_agent_hotkeys_on_main_thread(inner); + return; + } + + if let Some(modifier_binding) = less_computer_modifier_binding(&binding) { + take_coding_agent_combo_hotkey_on_main_thread(inner); + if let Some(monitor) = inner.coding_agent_modifier_hotkey.lock().as_ref() { + monitor.update_binding(modifier_binding); + return; + } + let (tx, rx) = mpsc::channel::(); + match HotkeyMonitor::start(modifier_binding, tx) { + Ok(monitor) => { + *inner.coding_agent_modifier_hotkey.lock() = Some(monitor); + log::info!( + "[less-computer] modifier hotkey installed ({})", + binding.display_label() + ); + let bridge_inner = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-less-computer-modifier-bridge".into()) + .spawn(move || less_computer_modifier_bridge_loop(bridge_inner, rx)) + .ok(); + } + Err(e) => log::warn!("[less-computer] modifier hotkey install failed: {e}"), + } + return; + } + + inner.coding_agent_modifier_hotkey.lock().take(); + let app = match inner.app.lock().clone() { + Some(app) => app, + None => { + log::warn!("[less-computer] AppHandle 未 bind,跳过组合键注册"); + return; + } + }; + let inner_clone = Arc::clone(inner); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + if let Some(monitor) = inner_clone.coding_agent_combo_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(binding_for_main.clone()) { + log::warn!("[less-computer] combo hotkey update failed: {e}"); + } + return; + } + let (tx, rx) = mpsc::channel::(); + match ComboHotkeyMonitor::start(binding_for_main.clone(), tx) { + Ok(monitor) => { + *inner_clone.coding_agent_combo_hotkey.lock() = Some(monitor); + log::info!( + "[less-computer] combo hotkey installed ({})", + binding_for_main.display_label() + ); + let bridge_inner = Arc::clone(&inner_clone); + std::thread::Builder::new() + .name("openless-less-computer-combo-bridge".into()) + .spawn(move || less_computer_combo_bridge_loop(bridge_inner, rx)) + .ok(); + } + Err(e) => log::warn!("[less-computer] combo hotkey install failed: {e}"), + } + }); + } +} + +#[cfg(target_os = "macos")] +fn less_computer_modifier_binding( + binding: &crate::types::ShortcutBinding, +) -> Option { + let trigger = crate::shortcut_binding::legacy_modifier_trigger(binding)?; + Some(crate::types::HotkeyBinding { + trigger, + mode: crate::types::HotkeyMode::Hold, + keys: None, + }) +} + +fn less_computer_modifier_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + HotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_less_computer_pressed(&inner_cloned).await + }); + } + HotkeyEvent::Released => { + async_runtime::block_on(async { + handle_less_computer_released(&inner_cloned).await + }); + } + HotkeyEvent::Cancelled => cancel_session(&inner_cloned), + HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {} + } + } +} + +fn less_computer_combo_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + ComboHotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_less_computer_pressed(&inner_cloned).await + }); + } + ComboHotkeyEvent::Released => { + async_runtime::block_on(async { + handle_less_computer_released(&inner_cloned).await + }); + } + } + } +} + +async fn handle_less_computer_pressed(inner: &Arc) { + let prefs = inner.prefs.get(); + if !prefs.coding_agent_enabled { + return; + } + if !matches!(inner.state.lock().phase, SessionPhase::Idle) { + log::info!("[less-computer] press ignored: dictation session already active"); + return; + } + if !matches!(inner.qa_state.lock().phase, QaPhase::Idle) { + log::info!("[less-computer] press ignored: QA session active"); + return; + } + + if begin_session(inner).await.is_err() { + return; + } + let started = { + let mut state = inner.state.lock(); + if matches!( + state.phase, + SessionPhase::Starting | SessionPhase::Listening + ) { + state.voice_agent = true; + log::info!( + "[less-computer] voice session started (session={:?})", + state.session_id + ); + true + } else { + false + } + }; + // 一按下键(开始录音)就点亮整屏彩虹描边,贯穿 录音 → 处理 → 出结果,完成/关闭才熄灭。 + if started { + if let Some(app) = inner.app.lock().clone() { + crate::show_less_computer_glow(&app); + } + } +} + +async fn handle_less_computer_released(inner: &Arc) { + let (phase, voice_agent) = { + let state = inner.state.lock(); + (state.phase, state.voice_agent) + }; + if !voice_agent { + return; + } + match phase { + SessionPhase::Listening => { + let _ = end_session(inner).await; + // 收尾后熄灭整屏描边。正常路径 run_voice_agent_transcript 已熄过、这里兜底; + // 空转写/出错路径不进 run_voice_agent_transcript,全靠这里熄,否则描边卡住不灭。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } + } + SessionPhase::Starting => { + // 握手中松手:排队;正常路径真正收尾在 begin 续流的 end_session → run_voice_agent_transcript 熄灭。 + request_stop_during_starting(inner, "less-computer release edge"); + // 但若初始化失败永远到不了 Listening(不会进 run_voice_agent_transcript), + // 描边会永久卡屏 → 这里兜底熄灭。Listening 分支已有熄灭逻辑,故只在 Starting 加。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } + } + _ => {} + } +} + +fn take_coding_agent_hotkeys_on_main_thread(inner: &Arc) { + inner.coding_agent_modifier_hotkey.lock().take(); + take_coding_agent_combo_hotkey_on_main_thread(inner); +} + +fn take_coding_agent_combo_hotkey_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.coding_agent_combo_hotkey.lock().take(); + }); + } else { + inner.coding_agent_combo_hotkey.lock().take(); + } +} + +/// 快取用:抓当前选中文本 → Claude 润色 → 回插(替换选区)。全程胶囊反馈。 +async fn handle_coding_agent_quick(inner: &Arc) { + let prefs = inner.prefs.get(); + if !prefs.coding_agent_enabled { + return; + } + let selection = tauri::async_runtime::spawn_blocking(crate::selection::capture_selection) + .await + .ok() + .flatten(); + let source_text = match selection { + Some(ctx) => ctx.text, + None => { + log::info!("[coding-agent] 快取用:没有选中文本"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some("请先选中文本,再按快捷键".to_string()), + None, + ); + return; + } + }; + + log::info!( + "[coding-agent] 快取用:润色 {} 字", + source_text.chars().count() + ); + emit_capsule( + inner, + CapsuleState::Polishing, + 0.0, + 0, + Some("Claude 润色中…".to_string()), + None, + ); + + let prompt = format!( + "请润色下面这段文字,使其更通顺自然、表达更清晰,保持原意、语言和事实不变。\ + 直接输出润色后的文本,不要加任何解释、前缀或引号:\n\n{source_text}" + ); + + // 纯文本润色:不需要任何工具 → plan 只读、无 guard、便宜快、最可靠。 + let mut req = crate::coding_agent::CodingAgentRequest::new("quick-polish", prompt); + req.model = prefs + .coding_agent_model + .clone() + .filter(|m| !m.trim().is_empty()) + .or_else(|| Some("sonnet".to_string())); + req.permission_mode = crate::coding_agent::CodingAgentPermissionMode::Plan; + req.allowed_tools = Vec::new(); + req.max_budget_usd = Some(0.2); + req.timeout_secs = 60; + req.session_persistence = false; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let cancel = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let run = async_runtime::spawn(async move { + crate::coding_agent::run_claude_agent("claude", req, tx, cancel).await + }); + + let mut final_text = String::new(); + let mut error_msg: Option = None; + while let Some(ev) = rx.recv().await { + match ev { + crate::coding_agent::CodingAgentEvent::Completed { text, .. } => final_text = text, + crate::coding_agent::CodingAgentEvent::Error { message, .. } => { + error_msg = Some(message) + } + _ => {} + } + } + let run_result = run.await; + + let final_text = final_text.trim().to_string(); + if final_text.is_empty() { + let msg = error_msg + .or_else(|| match run_result { + Ok(Err(e)) => Some(e.to_string()), + _ => None, + }) + .unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string()); + log::warn!("[coding-agent] 快取用失败: {msg}"); + emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(msg), None); + return; + } + + let inserted = final_text.chars().count() as u32; + let inner2 = Arc::clone(inner); + let restore = prefs.restore_clipboard_after_paste; + let paste_shortcut = prefs.paste_shortcut; + let _ = tauri::async_runtime::spawn_blocking(move || { + inner2.inserter.insert(&final_text, restore, paste_shortcut) + }) + .await; + log::info!("[coding-agent] 快取用:已回插 {inserted} 字"); + emit_capsule(inner, CapsuleState::Done, 0.0, 0, None, Some(inserted)); +} + +fn combo_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + // 读当前 prefs + let prefs = inner.prefs.get(); + if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() { + // 不是 Custom → 睡着等 prefs 改动 + take_combo_hotkey_on_main_thread(&inner); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let binding = prefs.dictation_hotkey.clone(); + if is_unconfigured_shortcut(&binding) { + take_combo_hotkey_on_main_thread(&inner); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if inner.combo_hotkey.lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = inner.app.lock().clone(); + let app = match app { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] combo hotkey 第 {attempts} 次注册超时(主线程未回执);3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *inner.combo_hotkey.lock() = Some(monitor); + log::info!( + "[coord] combo hotkey listener installed on main thread (after {} attempt(s))", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-combo-hotkey-bridge".into()) + .spawn(move || combo_hotkey_bridge_loop(inner_clone, rx)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!("[coord] combo hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试"); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + // P0 #468/#475: 同 hotkey_bridge_loop —— Pressed/Released 必须串行 await, + // 否则 latch 竞态导致 combo 快捷键二次按键失效。 + ComboHotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_pressed_edge(&inner_cloned).await; + }); + } + ComboHotkeyEvent::Released => { + async_runtime::block_on(async { + handle_released_edge(&inner_cloned).await; + }); + } + } + } +} + +fn translation_hotkey_supervisor_loop(inner: Arc) { + let mut attempts: u32 = 0; + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + let binding = inner.prefs.get().translation_hotkey; + if is_builtin_translation_shift(&binding) + || crate::shortcut_binding::legacy_modifier_trigger(&binding).is_some() + { + take_translation_hotkey_on_main_thread(&inner); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + let (qa_trigger, translation_trigger) = modifier_shortcut_triggers(&inner); + monitor.update_modifier_shortcuts(qa_trigger, translation_trigger); + } + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if inner.translation_hotkey.lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = match inner.app.lock().clone() { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *inner.translation_hotkey.lock() = Some(monitor); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-translation-hotkey-bridge".into()) + .spawn(move || translation_hotkey_bridge_loop(inner_clone, rx)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] translation hotkey 第 {attempts} 次注册失败: {e}; 3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn update_translation_hotkey_on_main_thread( + inner: Arc, + binding: crate::types::ShortcutBinding, +) -> Result<(), ComboHotkeyError> { + if let Some(monitor) = inner.translation_hotkey.lock().as_ref() { + return monitor.update_binding(binding); + } + let (tx, rx) = mpsc::channel::(); + let monitor = ComboHotkeyMonitor::start(binding, tx)?; + *inner.translation_hotkey.lock() = Some(monitor); + let bridge_inner = Arc::clone(&inner); + std::thread::Builder::new() + .name("openless-translation-hotkey-bridge".into()) + .spawn(move || translation_hotkey_bridge_loop(bridge_inner, rx)) + .map_err(|e| ComboHotkeyError::RegisterFailed(format!("spawn bridge thread: {e}")))?; + Ok(()) +} + +fn translation_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + if matches!(evt, ComboHotkeyEvent::Pressed) { + mark_translation_modifier_seen(&inner); + } + } +} + +fn action_hotkey_supervisor_loop(inner: Arc, kind: ActionHotkeyKind) { + let mut attempts: u32 = 0; + loop { + if inner.shutdown.load(Ordering::SeqCst) { + return; + } + // None = 用户主动停用:反注册并睡着等 prefs 改动(由 update 路径唤醒)。 + let Some(binding) = action_hotkey_binding(&inner, kind) else { + take_action_hotkey_on_main_thread(&inner, kind); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + }; + if is_modifier_only_shortcut(&binding) { + take_action_hotkey_on_main_thread(&inner, kind); + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + if action_hotkey_slot(&inner, kind).lock().is_some() { + std::thread::sleep(std::time::Duration::from_secs(5)); + continue; + } + + let app = match inner.app.lock().clone() { + Some(a) => a, + None => { + std::thread::sleep(std::time::Duration::from_secs(1)); + continue; + } + }; + + let (tx, rx) = mpsc::channel::(); + let (init_tx, init_rx) = + mpsc::sync_channel::>(1); + let binding_for_main = binding.clone(); + let _ = app.run_on_main_thread(move || { + let result = ComboHotkeyMonitor::start(binding_for_main, tx); + let _ = init_tx.send(result); + }); + + let init_result = match init_rx.recv_timeout(std::time::Duration::from_secs(5)) { + Ok(r) => r, + Err(_) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] action hotkey {kind:?} 第 {attempts} 次注册超时;3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + continue; + } + }; + + match init_result { + Ok(monitor) => { + *action_hotkey_slot(&inner, kind).lock() = Some(monitor); + log::info!( + "[coord] action hotkey {kind:?} listener installed after {} attempt(s)", + attempts + 1 + ); + let inner_clone = Arc::clone(&inner); + std::thread::Builder::new() + .name(action_hotkey_bridge_thread_name(kind).into()) + .spawn(move || action_hotkey_bridge_loop(inner_clone, rx, kind)) + .ok(); + attempts = 0; + } + Err(e) => { + attempts += 1; + if attempts <= 3 || attempts % 10 == 0 { + log::warn!( + "[coord] action hotkey {kind:?} 第 {attempts} 次注册失败: {e}; 3s 后重试" + ); + } + std::thread::sleep(std::time::Duration::from_secs(3)); + } + } + } +} + +fn action_hotkey_bridge_loop( + inner: Arc, + rx: mpsc::Receiver, + kind: ActionHotkeyKind, +) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + if matches!(evt, ComboHotkeyEvent::Pressed) { + handle_action_hotkey_pressed(&inner, kind); + } + } +} + +fn handle_action_hotkey_pressed(inner: &Arc, kind: ActionHotkeyKind) { + match kind { + ActionHotkeyKind::SwitchStyle => switch_to_previous_style(inner), + ActionHotkeyKind::OpenApp => { + if let Some(app) = inner.app.lock().clone() { + let app_for_main = app.clone(); + let _ = app.run_on_main_thread(move || { + crate::show_main_window(&app_for_main); + }); + } + } + } +} + +fn switch_to_previous_style(inner: &Arc) { + let mut prefs = inner.prefs.get(); + let packs = match inner.style_packs.list() { + Ok(packs) => packs, + Err(error) => { + log::warn!("[coord] switch style hotkey failed to load style packs: {error}"); + return; + } + }; + let enabled: Vec = + packs.into_iter().filter(|pack| pack.enabled).collect(); + if enabled.len() <= 1 { + log::info!("[coord] switch style hotkey ignored: enabled style count <= 1"); + return; + } + let current_index = enabled + .iter() + .position(|pack| pack.id == prefs.active_style_pack_id) + .unwrap_or(0); + let next_index = if current_index == 0 { + enabled.len() - 1 + } else { + current_index - 1 + }; + prefs.active_style_pack_id = enabled[next_index].id.clone(); + sync_style_pack_preferences(&mut prefs, &enabled); + if let Err(e) = inner.prefs.set(prefs.clone()) { + log::warn!("[coord] switch style hotkey 保存失败: {e}"); + } else { + log::info!( + "[coord] switch style hotkey changed active style pack to {}", + prefs.active_style_pack_id + ); + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit("prefs:changed", &prefs); + let _ = app.emit_to("main", "prefs:changed", &prefs); + let app_for_main = app.clone(); + let _ = app.run_on_main_thread(move || { + if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) { + log::warn!("[tray] refresh style menu after switch style hotkey failed: {err}"); + } + }); + } + } +} + +fn take_combo_hotkey_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.combo_hotkey.lock().take(); + }); + } else { + inner.combo_hotkey.lock().take(); + } +} + +fn take_translation_hotkey_on_main_thread(inner: &Arc) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + inner.translation_hotkey.lock().take(); + }); + } else { + inner.translation_hotkey.lock().take(); + } +} + +fn take_action_hotkey_on_main_thread(inner: &Arc, kind: ActionHotkeyKind) { + let app = inner.app.lock().clone(); + if let Some(app) = app { + let inner = Arc::clone(inner); + let _ = app.run_on_main_thread(move || { + action_hotkey_slot(&inner, kind).lock().take(); + }); + } else { + action_hotkey_slot(inner, kind).lock().take(); + } +} + +fn action_hotkey_slot( + inner: &Arc, + kind: ActionHotkeyKind, +) -> &Mutex> { + match kind { + ActionHotkeyKind::SwitchStyle => &inner.switch_style_hotkey, + ActionHotkeyKind::OpenApp => &inner.open_app_hotkey, + } +} + +fn action_hotkey_binding( + inner: &Arc, + kind: ActionHotkeyKind, +) -> Option { + let prefs = inner.prefs.get(); + match kind { + ActionHotkeyKind::SwitchStyle => prefs.switch_style_hotkey, + ActionHotkeyKind::OpenApp => prefs.open_app_hotkey, + } +} + +fn is_modifier_only_shortcut(binding: &crate::types::ShortcutBinding) -> bool { + binding.modifiers.is_empty() + && (binding.primary.eq_ignore_ascii_case("shift") + || crate::shortcut_binding::legacy_modifier_trigger(binding).is_some()) +} + +fn is_unconfigured_shortcut(binding: &crate::types::ShortcutBinding) -> bool { + binding.primary.trim().is_empty() +} + +fn action_hotkey_bridge_thread_name(kind: ActionHotkeyKind) -> &'static str { + match kind { + ActionHotkeyKind::SwitchStyle => "openless-switch-style-hotkey-bridge", + ActionHotkeyKind::OpenApp => "openless-open-app-hotkey-bridge", + } +} + +fn is_builtin_translation_shift(binding: &crate::types::ShortcutBinding) -> bool { + binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") +} + +/// Linux: 从 prefs 读取自定义组合键,同步到 fcitx5 插件。 +#[cfg(target_os = "linux")] +fn custom_dictation_key_string(inner: &Arc) -> Option { + let prefs = inner.prefs.get(); + let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(&prefs.dictation_hotkey); + if key_string.is_empty() { + None + } else { + Some(key_string) + } +} + +#[cfg(target_os = "linux")] +fn sync_custom_dictation_to_plugin(inner: &Arc) { + let prefs = inner.prefs.get(); + let dictation = &prefs.dictation_hotkey; + let key_string = crate::linux_fcitx::binding_to_fcitx_key_string(dictation); + if key_string.is_empty() { + return; + } + match crate::linux_fcitx::set_custom_dictation_trigger(&key_string) { + Ok(()) => log::info!( + "[fcitx] Synced custom dictation trigger '{}' to plugin", + key_string + ), + Err(e) => log::warn!("[fcitx] Failed to sync custom dictation trigger: {e}"), + } +} + +fn modifier_shortcut_triggers( + inner: &Arc, +) -> ( + Option, + Option, +) { + let prefs = inner.prefs.get(); + let qa_trigger = prefs + .qa_hotkey + .as_ref() + .and_then(crate::shortcut_binding::legacy_modifier_trigger); + let translation_trigger = if is_builtin_translation_shift(&prefs.translation_hotkey) { + None + } else { + crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey) + }; + (qa_trigger, translation_trigger) +} + +fn mark_translation_modifier_seen(inner: &Arc) { + let phase = inner.state.lock().phase; + if matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { + inner + .translation_modifier_seen + .store(true, Ordering::SeqCst); + log::info!("[coord] translation modifier seen during {phase:?}"); + } +} + +fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { + while let Ok(evt) = rx.recv() { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + continue; + } + let inner_cloned = Arc::clone(&inner); + match evt { + // P0 #468/#475: Pressed/Released 必须串行处理,否则在 Windows 上 WH_KEYBOARD_LL + // 边沿间隔微秒级 → 两个独立 spawn 的 task 被 work-stealing 调度器并行执行 → + // `hotkey_trigger_held` latch 翻转顺序错乱 → 下次按键被静默吞掉 + // (UI 关不掉 / 录音停不下来)。改为 bridge 线程内 block_on 顺序 await, + // recv 的 FIFO 顺序就是 handler 执行顺序。 + // 注意:handle_pressed_edge / handle_released_edge 内部走 .await(含网络 + // 握手),会暂时阻塞本 bridge 线程;Hold 模式短按时 Released 会排队在 channel + // 里直到 begin_session 完成,但 SessionPhase::Starting 已经有 + // request_stop_during_starting 兜底,begin_session 完成进 Listening 后 + // bridge 立刻 recv Released → end_session,行为正确,仅有短暂 stop 延迟。 + HotkeyEvent::Pressed => { + async_runtime::block_on(async { + handle_pressed_edge(&inner_cloned).await; + }); + } + HotkeyEvent::Released => { + async_runtime::block_on(async { + handle_released_edge(&inner_cloned).await; + }); + } + HotkeyEvent::Cancelled => { + cancel_session(&inner_cloned); + } + HotkeyEvent::TranslationModifierPressed => { + let translation_hotkey = inner_cloned.prefs.get().translation_hotkey; + if is_builtin_translation_shift(&translation_hotkey) + || crate::shortcut_binding::legacy_modifier_trigger(&translation_hotkey) + .is_some() + { + mark_translation_modifier_seen(&inner_cloned); + } + } + HotkeyEvent::QaShortcutPressed => { + async_runtime::spawn(async move { handle_qa_hotkey_pressed(&inner_cloned).await }); + } + } + } +} + +fn reset_shortcut_held_state(inner: &Arc) { + inner.hotkey_trigger_held.store(false, Ordering::SeqCst); + if let Some(monitor) = inner.hotkey.lock().as_ref() { + monitor.reset_held_state(); + } + let prefs = inner.prefs.get(); + if let Some(binding) = prefs.qa_hotkey.as_ref() { + if crate::shortcut_binding::legacy_modifier_trigger(binding).is_none() { + if let Some(monitor) = inner.qa_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(binding.clone()) { + log::warn!("[coord] reset QA hotkey latch failed: {e}"); + } + } + } + } + if !is_builtin_translation_shift(&prefs.translation_hotkey) + && crate::shortcut_binding::legacy_modifier_trigger(&prefs.translation_hotkey).is_none() + { + if let Some(monitor) = inner.translation_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(prefs.translation_hotkey.clone()) { + log::warn!("[coord] reset translation hotkey latch failed: {e}"); + } + } + } + if let Some(switch_style) = prefs.switch_style_hotkey.as_ref() { + if !is_modifier_only_shortcut(switch_style) { + if let Some(monitor) = inner.switch_style_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(switch_style.clone()) { + log::warn!("[coord] reset switch-style hotkey latch failed: {e}"); + } + } + } + } + if let Some(open_app) = prefs.open_app_hotkey.as_ref() { + if !is_modifier_only_shortcut(open_app) { + if let Some(monitor) = inner.open_app_hotkey.lock().as_ref() { + if let Err(e) = monitor.update_binding(open_app.clone()) { + log::warn!("[coord] reset open-app hotkey latch failed: {e}"); + } + } + } + } +} + +async fn handle_window_hotkey_event( + inner: &Arc, + event_type: String, + key: String, + code: String, + repeat: bool, +) -> Result<(), String> { + if inner.shortcut_recording_active.load(Ordering::SeqCst) { + return Ok(()); + } + if event_type == "keydown" && key == "Escape" { + // Esc 路由(issue #161):QA 浮窗可见时优先取消 QA(不动 dictation); + // 否则走 dictation 取消通路。之前无条件 cancel_session 导致 QA 浮窗 + // 按 Esc 杀的是 dictation 而 QA 流还在烧 token。 + let qa_active = { + let st = inner.qa_state.lock(); + st.panel_visible || st.phase != QaPhase::Idle + }; + if qa_active { + close_qa_panel(inner); + } else { + cancel_session(inner); + } + return Ok(()); + } + + #[cfg(not(target_os = "windows"))] + { + let _ = (inner, event_type, key, code, repeat); + Ok(()) + } + + #[cfg(target_os = "windows")] + { + if !window_hotkey_fallback_enabled() { + if event_type == "keydown" && !repeat { + log::info!( + "[window-hotkey] ignored because Windows lifecycle owner is the low-level hook" + ); + } + return Ok(()); + } + + let Some(trigger) = + crate::shortcut_binding::legacy_modifier_trigger(&inner.prefs.get().dictation_hotkey) + else { + return Ok(()); + }; + if !window_key_matches_trigger(trigger, &key, &code) { + return Ok(()); + } + + match event_type.as_str() { + "keydown" => { + if repeat { + return Ok(()); + } + log::info!( + "[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}" + ); + handle_pressed_edge(inner).await; + } + "keyup" => { + log::info!("[window-hotkey] released trigger={trigger:?} code={code}"); + handle_released_edge(inner).await; + } + _ => {} + } + Ok(()) + } +} + +fn window_hotkey_fallback_enabled() -> bool { + crate::types::HotkeyCapability::current().explicit_fallback_available +} + +#[cfg(any(target_os = "windows", test))] +fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, code: &str) -> bool { + use crate::types::HotkeyTrigger; + + match trigger { + HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight", + HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft", + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { + (key == "Alt" || key == "AltGraph") && code == "AltRight" + } + HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltLeft", + HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", + HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", + // MediaPlayPause 走 WH_KEYBOARD_LL,不走 window hotkey fallback + HotkeyTrigger::MediaPlayPause => false, + // Custom 走 global-hotkey crate,不走 window hotkey fallback + HotkeyTrigger::Custom => false, + } +} + +// ─────────────────────────── session lifecycle ─────────────────────────── + +/// QA 录音 runtime error 监听器。镜像 `spawn_recorder_error_monitor` 的语义但走 QA +/// 收尾路径(`finish_qa_with_error` 替代 `abort_recording_with_error`)。 +/// 用 qa_state.session_id 守卫 stale 事件。详见 issue #168。 +fn spawn_qa_recorder_error_monitor(inner: &Arc, rx: mpsc::Receiver) { + let captured_session_id = inner.qa_state.lock().session_id; + let inner = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-qa-recorder-error-monitor".into()) + .spawn(move || { + if let Ok(err) = rx.recv() { + let current_session_id = inner.qa_state.lock().session_id; + if captured_session_id != current_session_id { + log::warn!( + "[coord] QA recorder error from stale session {} dropped (current={}, err={})", + captured_session_id, + current_session_id, + err + ); + return; + } + log::error!("[coord] QA recorder runtime error: {err}"); + finish_qa_with_error(&inner, format!("录音设备异常: {err}")); + } + }) + .ok(); +} + +#[cfg(target_os = "windows")] +fn store_prepared_windows_ime_session( + slots: &mut Vec, + session_id: SessionId, + prepared: PreparedWindowsImeSession, +) { + slots.retain(|slot| slot.session_id != session_id); + slots.push(PreparedWindowsImeSessionSlot { + session_id, + prepared, + }); +} + +#[cfg(target_os = "windows")] +fn take_matching_prepared_windows_ime_session( + slots: &mut Vec, + session_id: SessionId, +) -> Option { + let index = slots + .iter() + .position(|slot| slot.session_id == session_id)?; + Some(slots.remove(index).prepared) +} + +#[cfg(target_os = "windows")] +fn take_current_prepared_windows_ime_session_for_restore( + slots: &mut Vec, + session_id: SessionId, + current_session_id: SessionId, +) -> Option { + let prepared = take_matching_prepared_windows_ime_session(slots, session_id)?; + if current_session_id == session_id { + Some(prepared) + } else { + None + } +} + +#[cfg(target_os = "windows")] +fn restore_prepared_windows_ime_session(inner: &Arc, session_id: SessionId) { + let state = inner.state.lock(); + let prepared = { + let mut slot = inner.prepared_windows_ime_session.lock(); + take_current_prepared_windows_ime_session_for_restore( + &mut slot, + session_id, + state.session_id, + ) + }; + if let Some(prepared) = prepared { + inner.windows_ime.restore_session(prepared); + } +} + +#[cfg(not(target_os = "windows"))] +fn restore_prepared_windows_ime_session(_inner: &Arc, _session_id: SessionId) {} + +#[cfg(target_os = "windows")] +async fn insert_with_windows_ime_first( + inner: &Arc, + session_id: SessionId, + polished: &str, + restore_clipboard: bool, + allow_non_tsf_insertion_fallback: bool, + paste_shortcut: PasteShortcut, + ime_target: Option, +) -> InsertStatus { + let prepared = { + let mut slot = inner.prepared_windows_ime_session.lock(); + take_matching_prepared_windows_ime_session(&mut slot, session_id) + }; + let Some(prepared) = prepared else { + log::warn!("[windows-ime] no prepared TSF session for this dictation"); + if should_try_non_tsf_insertion_fallback( + allow_non_tsf_insertion_fallback, + InsertStatus::Failed, + ) { + return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut); + } + log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert"); + return InsertStatus::Failed; + }; + + let request = crate::windows_ime_ipc::ImeSubmitRequest { + session_id: Uuid::new_v4().to_string(), + text: polished.to_string(), + created_at: Utc::now().to_rfc3339(), + target: ime_target, + }; + + let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await { + Ok(status) => status, + Err(error) => { + log::warn!("[windows-ime] TSF submit failed: {error}"); + InsertStatus::Failed + } + }; + inner.windows_ime.restore_session(prepared); + + if ime_status == InsertStatus::Inserted { + ime_status + } else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) { + insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut) + } else { + log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled"); + InsertStatus::Failed + } +} + +#[cfg(target_os = "windows")] +fn should_try_non_tsf_insertion_fallback( + allow_non_tsf_insertion_fallback: bool, + ime_status: InsertStatus, +) -> bool { + allow_non_tsf_insertion_fallback && ime_status != InsertStatus::Inserted +} + +#[cfg(target_os = "windows")] +fn insert_via_non_tsf_fallback( + inner: &Arc, + polished: &str, + _restore_clipboard: bool, + _paste_shortcut: PasteShortcut, +) -> InsertStatus { + let status = finish_non_tsf_insertion_fallback( + || inner.inserter.insert_via_unicode_keystrokes(polished), + || inner.inserter.copy_fallback(polished), + ); + + match status { + InsertStatus::Inserted => { + log::warn!( + "[windows-ime] TSF unavailable; inserted via paced Unicode SendInput fallback" + ); + } + InsertStatus::CopiedFallback => { + log::warn!( + "[windows-ime] TSF unavailable; Unicode SendInput failed, left text on clipboard" + ); + } + InsertStatus::PasteSent | InsertStatus::Failed => { + log::warn!( + "[windows-ime] TSF unavailable; Unicode SendInput fallback failed and copy fallback failed" + ); + } + } + + status +} + +#[cfg(any(target_os = "windows", test))] +fn finish_non_tsf_insertion_fallback( + mut unicode_fallback: U, + mut copy_only_fallback: C, +) -> InsertStatus +where + U: FnMut() -> InsertStatus, + C: FnMut() -> InsertStatus, +{ + match unicode_fallback() { + InsertStatus::Inserted => InsertStatus::Inserted, + InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => { + match copy_only_fallback() { + InsertStatus::CopiedFallback => InsertStatus::CopiedFallback, + // TextInserter::copy_fallback is copy-only: success is CopiedFallback. + // Treat any other status as failure so this helper never invents an insert. + InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => { + InsertStatus::Failed + } + } + } + } +} + +#[cfg(test)] +mod non_tsf_fallback_tests { + use super::finish_non_tsf_insertion_fallback; + use crate::types::InsertStatus; + + #[test] + fn unicode_fallback_runs_before_copy_fallback() { + let mut copy_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::Inserted, + || { + copy_called = true; + InsertStatus::CopiedFallback + }, + ); + + assert_eq!(status, InsertStatus::Inserted); + assert!(!copy_called); + } + + #[test] + fn copy_fallback_runs_after_unicode_failure() { + let mut copy_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::Failed, + || { + copy_called = true; + InsertStatus::CopiedFallback + }, + ); + + assert_eq!(status, InsertStatus::CopiedFallback); + assert!(copy_called); + } + + #[test] + fn double_failure_does_not_pretend_text_was_copied() { + let mut copy_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::Failed, + || { + copy_called = true; + InsertStatus::Failed + }, + ); + + assert_eq!(status, InsertStatus::Failed); + assert!(copy_called); + } +} + +// ─────────────────────────── helpers ─────────────────────────── + +#[cfg(any(debug_assertions, test))] +fn hotkey_injection_dry_run_enabled() -> bool { + std::env::var_os("OPENLESS_HOTKEY_INJECTION_DRY_RUN").is_some() +} + +#[cfg(any(debug_assertions, test))] +fn debug_transcript_override_text() -> Option { + let path = std::env::var_os("OPENLESS_DEBUG_TRANSCRIPT_FILE")?; + let text = std::fs::read_to_string(path).ok()?; + let trimmed = text.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} + +fn ensure_microphone_permission(_inner: &Arc) -> Result<(), String> { + use crate::permissions::{self, PermissionStatus}; + + #[cfg(target_os = "windows")] + { + if permissions::windows_microphone_access_explicitly_denied() { + return Err("需要麦克风权限,当前状态: Denied".to_string()); + } + return Ok(()); + } + + let status = permissions::check_microphone(); + if matches!( + status, + PermissionStatus::Granted | PermissionStatus::NotApplicable + ) { + return Ok(()); + } + + // 听写路径不抢前台焦点:缺 mic 权限时直接请求系统授权,不再先 show_main_window。 + // 用户在设置页手动点“请求权限”仍走 request_microphone_from_foreground,那是显式操作。 + // 这里若系统不弹框,后续会通过 capsule error 引导用户主动去权限页处理。详见 #166。 + let requested = permissions::request_microphone(); + if matches!( + requested, + PermissionStatus::Granted | PermissionStatus::NotApplicable + ) { + Ok(()) + } else { + Err(format!("需要麦克风权限,当前状态: {requested:?}")) + } +} + +fn ensure_asr_credentials() -> Result<(), String> { + let active_asr = CredentialsVault::get_active_asr(); + + // 本地 Qwen3-ASR 没有"凭据"概念,但需要:(a) macOS 平台 (b) 模型已下载。 + if crate::asr::local::is_local_qwen3(&active_asr) { + #[cfg(not(target_os = "macos"))] + { + return Err("本地 ASR 当前仅支持 macOS(Windows 见 issue #256)".to_string()); + } + #[cfg(target_os = "macos")] + { + return ensure_local_qwen3_model_ready(); + } + } + + if crate::asr::local::foundry::is_foundry_local_whisper(&active_asr) { + #[cfg(not(target_os = "windows"))] + { + return Err("Foundry Local Whisper 当前仅支持 Windows".to_string()); + } + #[cfg(target_os = "windows")] + { + return Ok(()); + } + } + + if crate::asr::local::sherpa::is_sherpa_onnx_local(&active_asr) { + #[cfg(not(target_os = "windows"))] + { + return Err("sherpa-onnx local ASR 当前仅支持 Windows".to_string()); + } + #[cfg(target_os = "windows")] + { + return Ok(()); + } + } + + if is_whisper_compatible_provider(&active_asr) || is_bailian_provider(&active_asr) { + let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) + .ok() + .flatten() + .unwrap_or_default(); + if api_key.trim().is_empty() { + return Err("请先在设置中填写 ASR 服务商 API Key".to_string()); + } + return Ok(()); + } + + let creds = read_volc_credentials(); + if creds.app_id.trim().is_empty() || creds.access_token.trim().is_empty() { + Err("请先在设置中填写火山引擎 ASR App Key 和 Access Key".to_string()) + } else { + Ok(()) + } +} + +#[cfg(test)] +fn is_keyless_local_asr_provider(id: &str) -> bool { + if crate::asr::local::is_local_qwen3(id) { + return true; + } + #[cfg(target_os = "macos")] + if crate::asr::local::is_apple_speech(id) { + return true; + } + #[cfg(target_os = "windows")] + { + crate::asr::local::foundry::is_foundry_local_whisper(id) + || crate::asr::local::sherpa::is_sherpa_onnx_local(id) + } + #[cfg(not(target_os = "windows"))] + { + let _ = id; + false + } +} + +#[cfg(target_os = "macos")] +fn ensure_local_qwen3_model_ready() -> Result<(), String> { + let prefs = || -> Result { + // 这里没法拿到 inner,直接读 preferences.json 即可(Coordinator 写盘后总是同步的)。 + crate::persistence::PreferencesStore::new() + .map_err(|e| e.to_string()) + .map(|s| s.get()) + }()?; + let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) + .ok_or_else(|| format!("未知的本地模型 id: {}", prefs.local_asr_active_model))?; + if !crate::asr::local::models::is_downloaded(model_id) { + return Err(format!( + "本地模型 {} 未下载完整,请到 设置 → 模型设置 中下载", + model_id.as_str() + )); + } + Ok(()) +} + +/// 一次 dictation 结束后,按 prefs.local_asr_keep_loaded_secs 决定何时释放 +/// 内存里的 Qwen3-ASR 引擎。0 = 立即释放;其它值 = sleep N 秒后看 last_used。 +/// 多次会话叠加多个 sleep 任务,每个独立 check:只要中间又被使用过就跳过释放。 +fn schedule_local_asr_release(inner: &Arc) { + let keep_secs = inner.prefs.get().local_asr_keep_loaded_secs; + let cache = Arc::clone(&inner.local_asr_cache); + if keep_secs == 0 { + cache.release_now(); + return; + } + let dur = std::time::Duration::from_secs(keep_secs as u64); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(dur).await; + cache.release_if_idle(dur); + }); +} + +#[cfg(target_os = "windows")] +fn foundry_local_asr_release_keep_secs(inner: &Arc) -> u32 { + inner.prefs.get().foundry_local_asr_keep_loaded_secs +} + +#[cfg(target_os = "windows")] +#[derive(Clone, Copy)] +enum AsrReleaseSession { + Dictation(SessionId), + Qa(SessionId), +} + +#[cfg(target_os = "windows")] +fn asr_release_session_is_current(inner: &Arc, session: AsrReleaseSession) -> bool { + match session { + AsrReleaseSession::Dictation(session_id) => inner.state.lock().session_id == session_id, + AsrReleaseSession::Qa(session_id) => inner.qa_state.lock().session_id == session_id, + } +} + +#[cfg(target_os = "windows")] +fn schedule_foundry_local_asr_release(inner: &Arc, session: AsrReleaseSession) { + let keep_secs = foundry_local_asr_release_keep_secs(inner); + let runtime = Arc::clone(&inner.foundry_local_runtime); + let inner = Arc::clone(inner); + tauri::async_runtime::spawn(async move { + if keep_secs > 0 { + tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await; + } + if !asr_release_session_is_current(&inner, session) { + return; + } + if let Err(error) = runtime.release_now().await { + log::warn!("[foundry-asr] scheduled release failed: {error:#}"); + } + }); +} + +#[cfg(target_os = "windows")] +fn sherpa_onnx_release_keep_secs(inner: &Arc) -> u32 { + inner.prefs.get().sherpa_onnx_keep_loaded_secs +} + +/// 与 `schedule_foundry_local_asr_release` 同形:session_id 老旧则不释放, +/// 避免下一轮 session 立即重加载同一个 offline batch 模型。 +#[cfg(target_os = "windows")] +fn schedule_sherpa_onnx_release(inner: &Arc, session: AsrReleaseSession) { + let keep_secs = sherpa_onnx_release_keep_secs(inner); + let runtime = Arc::clone(&inner.sherpa_onnx_runtime); + let inner = Arc::clone(inner); + tauri::async_runtime::spawn(async move { + if keep_secs > 0 { + tokio::time::sleep(std::time::Duration::from_secs(keep_secs as u64)).await; + } + if !asr_release_session_is_current(&inner, session) { + return; + } + if let Err(error) = runtime.release_now().await { + log::warn!("[sherpa-asr] scheduled release failed: {error:#}"); + } + }); +} + +#[cfg(target_os = "macos")] +async fn build_local_qwen3( + inner: &Arc, +) -> anyhow::Result> { + let prefs = inner.prefs.get(); + let model_id = crate::asr::local::ModelId::from_str(&prefs.local_asr_active_model) + .ok_or_else(|| anyhow::anyhow!("未知本地模型 id: {}", prefs.local_asr_active_model))?; + let dir = crate::asr::local::models::model_dir(model_id)?; + let app = inner + .app + .lock() + .clone() + .ok_or_else(|| anyhow::anyhow!("AppHandle 未绑定"))?; + // 走缓存:如果已有同 id 的引擎在内存里就直接复用,避免每次会话都重加载 + // 1.2GB+ 模型。第一次加载阻塞数秒,spawn_blocking 不卡 tokio runtime。 + let cache = Arc::clone(&inner.local_asr_cache); + let mid = model_id.as_str().to_string(); + let engine = tauri::async_runtime::spawn_blocking(move || cache.get_or_load(&mid, &dir)) + .await + .map_err(|e| anyhow::anyhow!("spawn_blocking join failed: {e:#}"))??; + Ok(Arc::new(crate::asr::local::LocalQwenAsr::new(app, engine))) +} + +#[cfg(target_os = "macos")] +fn build_apple_speech() -> Arc { + Arc::new(crate::asr::local::AppleSpeechAsr::new()) +} + +/// `whisper` 是 OpenAI 原生;`siliconflow` / `zhipu` / `groq` 都暴露 +/// OpenAI 兼容的 `/audio/transcriptions`,统一走 `WhisperBatchASR`。 +/// 新增 OpenAI 兼容 ASR 时只需在这里加一项。 +/// +/// 注:DashScope 的 Qwen3-ASR-Flash 不在此列——它用 MultiModalConversation +/// (messages=[{content:[{audio:...}]}]) 协议,不是 Whisper multipart,需要 +/// 单独 ASR 客户端,留给 V2。 +fn is_whisper_compatible_provider(id: &str) -> bool { + matches!( + id, + "whisper" | "siliconflow" | "zhipu" | "groq" | "openrouter" + ) +} + +/// 该 provider 的请求体编码方式。OpenRouter 的 `/audio/transcriptions` 是 +/// `application/json` + base64 音频(issue #582),其余兼容厂商沿用 multipart。 +fn whisper_request_format(provider_id: &str) -> crate::asr::whisper::AsrRequestFormat { + match provider_id { + "openrouter" => crate::asr::whisper::AsrRequestFormat::OpenRouterJson, + _ => crate::asr::whisper::AsrRequestFormat::Multipart, + } +} + +/// 该 provider 的 `/audio/transcriptions` 是否支持 `response_format=verbose_json` +/// 并返回带 `no_speech_prob` / `avg_logprob` / `compression_ratio` 的 segments, +/// 用于幻听过滤。 +/// +/// - `whisper`(OpenAI)/ `groq`:原生 Whisper,完整支持,过滤有效。 +/// - `siliconflow`:模型是 SenseVoice / TeleSpeech,文档无 `response_format`, +/// 发送 verbose_json 可能被拒,**保持关闭**走旧的 `json`。 +/// - `zhipu`(GLM-ASR):虽接受 verbose_json,但不产出上述指标,过滤是空转; +/// 为最小化行为变更,这里也**保持关闭**,仅对确证有收益的 whisper/groq 开启。 +fn whisper_supports_verbose_json(provider_id: &str) -> bool { + matches!(provider_id, "whisper" | "groq") +} + +fn is_bailian_provider(id: &str) -> bool { + id == crate::asr::bailian::PROVIDER_ID +} + +fn is_mimo_provider(id: &str) -> bool { + id == crate::asr::mimo::PROVIDER_ID +} + +fn apply_chinese_script_preference(text: &str, pref: ChineseScriptPreference) -> String { + if text.is_empty() { + return String::new(); + } + let config = match pref { + ChineseScriptPreference::Simplified => Some(BuiltinConfig::T2s), + ChineseScriptPreference::Traditional => Some(BuiltinConfig::S2t), + ChineseScriptPreference::Auto => None, + }; + let Some(config) = config else { + return text.to_string(); + }; + match OpenCC::from_config(config) { + Ok(converter) => converter.convert(text), + Err(err) => { + log::warn!("[coord] OpenCC init failed, skip script conversion: {err}"); + text.to_string() + } + } +} + +enum QaAsrStart { + Volcengine { + asr: Arc, + bridge: Arc, + }, + Bailian { + asr: Arc, + bridge: Arc, + }, + Ready { + active: ActiveAsr, + consumer: Arc, + }, +} + +impl QaAsrStart { + fn active_asr(&self) -> ActiveAsr { + match self { + QaAsrStart::Volcengine { asr, .. } => ActiveAsr::Volcengine(Arc::clone(asr)), + QaAsrStart::Bailian { asr, .. } => ActiveAsr::Bailian(Arc::clone(asr)), + QaAsrStart::Ready { active, .. } => active.clone(), + } + } + + fn recorder_consumer(&self) -> Arc { + match self { + QaAsrStart::Volcengine { bridge, .. } => Arc::clone(bridge) as _, + QaAsrStart::Bailian { bridge, .. } => Arc::clone(bridge) as _, + QaAsrStart::Ready { consumer, .. } => Arc::clone(consumer), + } + } + + async fn open_streaming_session(&self) -> Result<(), String> { + match self { + QaAsrStart::Volcengine { asr, bridge } => { + asr.open_session().await.map_err(|e| e.to_string())?; + let target: Arc = Arc::clone(asr) as _; + let flushed = bridge.attach(target); + log::info!("[coord] QA ASR connected; flushed {flushed} deferred audio bytes"); + Ok(()) + } + QaAsrStart::Bailian { asr, bridge } => { + asr.open_session().await.map_err(|e| e.to_string())?; + let target: Arc = Arc::clone(asr) as _; + let flushed = bridge.attach(target); + log::info!( + "[coord] QA Bailian ASR connected; flushed {flushed} deferred audio bytes" + ); + Ok(()) + } + QaAsrStart::Ready { .. } => Ok(()), + } + } +} + +async fn build_qa_asr_start(inner: &Arc, active_asr: &str) -> Result { + #[cfg(target_os = "windows")] + if foundry::is_foundry_local_whisper(active_asr) { + let prefs = inner.prefs.get(); + let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) { + prefs.foundry_local_asr_model.clone() + } else { + foundry::DEFAULT_MODEL_ALIAS.to_string() + }; + let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string(); + let language_hint = if language_hint.is_empty() { + None + } else { + Some(language_hint) + }; + let local = Arc::new(FoundryLocalWhisperAsr::new( + Arc::clone(&inner.foundry_local_runtime), + model_alias, + prefs.foundry_local_runtime_source.clone(), + language_hint, + )); + let active = ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)); + let consumer: Arc = local; + return Ok(QaAsrStart::Ready { active, consumer }); + } + + #[cfg(target_os = "windows")] + if sherpa::is_sherpa_onnx_local(active_asr) { + let prefs = inner.prefs.get(); + let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) { + prefs.sherpa_onnx_model.clone() + } else { + sherpa::DEFAULT_MODEL_ALIAS.to_string() + }; + let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string(); + let language_hint = if language_hint.is_empty() { + None + } else { + Some(language_hint) + }; + let token_handler = inner.app.lock().clone().map(|app| { + Arc::new(move |piece: String| { + if let Err(error) = app.emit("local-asr-token", piece) { + log::warn!("[sherpa-asr] emit token failed: {error}"); + } + }) as crate::asr::local::sherpa_provider::SherpaTokenHandler + }); + let local = SherpaOnnxAsr::new_for_model( + Arc::clone(&inner.sherpa_onnx_runtime), + model_alias, + language_hint, + token_handler, + ) + .await + .map_err(|e| format!("sherpa-onnx init failed: {e}"))?; + let local = Arc::new(local); + let active = ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)); + let consumer: Arc = local; + return Ok(QaAsrStart::Ready { active, consumer }); + } + + #[cfg(target_os = "macos")] + if crate::asr::local::is_local_qwen3(active_asr) { + let local = build_local_qwen3(inner) + .await + .map_err(|e| format!("local ASR init failed: {e}"))?; + let active = ActiveAsr::Local(Arc::clone(&local)); + let consumer: Arc = local; + return Ok(QaAsrStart::Ready { active, consumer }); + } + + #[cfg(target_os = "macos")] + if crate::asr::local::is_apple_speech(active_asr) { + let local = build_apple_speech(); + let active = ActiveAsr::AppleSpeech(Arc::clone(&local)); + let consumer: Arc = local; + return Ok(QaAsrStart::Ready { active, consumer }); + } + + match active_asr_provider_kind(active_asr) { + ActiveAsrProviderKind::Bailian => Ok(QaAsrStart::Bailian { + asr: Arc::new(BailianRealtimeASR::new(read_bailian_credentials())), + bridge: Arc::new(DeferredAsrBridge::new()), + }), + ActiveAsrProviderKind::Mimo => { + let (api_key, base_url, model) = read_mimo_credentials(); + let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model)); + let active = ActiveAsr::Mimo(Arc::clone(&mimo)); + let consumer: Arc = mimo; + Ok(QaAsrStart::Ready { active, consumer }) + } + ActiveAsrProviderKind::WhisperCompatible => { + let (api_key, base_url, model) = read_whisper_credentials(); + let whisper_prompt = + crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner)); + let whisper = Arc::new( + WhisperBatchASR::new( + api_key, + base_url, + model, + whisper_prompt, + batch_asr_chunk_limit_ms(active_asr), + whisper_supports_verbose_json(active_asr), + ) + .with_request_format(whisper_request_format(active_asr)), + ); + let active = ActiveAsr::Whisper(Arc::clone(&whisper)); + let consumer: Arc = whisper; + Ok(QaAsrStart::Ready { active, consumer }) + } + ActiveAsrProviderKind::Volcengine => Ok(QaAsrStart::Volcengine { + asr: Arc::new(VolcengineStreamingASR::new( + read_volc_credentials(), + enabled_hotwords(inner), + )), + bridge: Arc::new(DeferredAsrBridge::new()), + }), + } +} + +/// 润色文本;失败时返回原文 + 失败原因,调用方据此弹错误胶囊 + 写历史 error_code。 +/// 之前固定返回 String,调用方拿不到失败信号 → 用户感知"为什么风格设置没生效"。issue #57。 +/// 流式润色的三态结果。让上层(dictation pipeline)能区分「已经流出去了」、 +/// 「降级到一次性」和「真失败了走 raw 兜底」三种 case。 +pub enum StreamingPolishOutcome { + /// 流式润色成功,`String` 是已经一边流一边交给 `on_delta` 的全部文本(用于写 + /// history、做词条命中统计)。调用方不应再 `inserter.insert(&text)`,因为字符 + /// 已经通过键盘事件落到光标处。 + Streamed(String), + /// 当前配置不支持流式:用户没开 streaming_insert / Gemini provider / Codex + /// provider / Raw 模式 / 翻译模式 / 不是 macOS。调用方应回到现有的 + /// `polish_or_passthrough` 一次性路径,跟历史行为完全一致。 + UnsupportedFallback, + /// 流式过程中失败(HTTP / 解析 / 空流等)。`String` 是失败原因,调用方应当 + /// 走 raw 兜底(同 `polish_or_passthrough` 失败分支的语义)。 + Failed(String), +} + +/// 流式润色入口。在不支持流式的所有 case 都返回 `UnsupportedFallback`,让调用方 +/// 透明降级。不修改任何持久化 / 焦点 / 光标状态。 +/// +/// `on_delta` 每收到一个 SSE chunk 就被调用一次(同步),调用方负责把 chunk 实际 +/// 模拟键盘事件落到光标 —— 见 `coordinator/dictation.rs` 的流式分支。 +/// `should_cancel` 用户取消时返回 true,立即 break SSE 读循环避免烧 quota。 +pub async fn polish_or_passthrough_streaming( + raw: &RawTranscript, + mode: PolishMode, + hotwords: &[String], + style_system_prompt: &str, + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + llm_thinking_enabled: bool, + front_app: Option<&str>, + prior_turns: &[(String, String)], + on_delta: F, + should_cancel: C, +) -> StreamingPolishOutcome +where + F: Fn(&str) + Send + Sync, + C: Fn() -> bool + Send + Sync, +{ + if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) { + log::info!("[coord] streaming polish skipped: mode=Raw, fall back to one-shot"); + return StreamingPolishOutcome::UnsupportedFallback; + } + let active_llm = CredentialsVault::get_active_llm(); + if active_llm == "gemini" { + log::info!( + "[coord] streaming polish skipped: active LLM provider=gemini (v1 not implemented), fall back to one-shot" + ); + return StreamingPolishOutcome::UnsupportedFallback; + } + let provider = match build_active_llm_provider(llm_thinking_enabled) { + Ok(p) => p, + Err(e) => { + log::error!("[coord] streaming polish: build provider failed: {e}"); + return StreamingPolishOutcome::Failed(e.to_string()); + } + }; + if !provider.supports_streaming_polish() { + log::info!( + "[coord] streaming polish skipped: provider does not support streaming (likely codex OAuth), fall back to one-shot" + ); + return StreamingPolishOutcome::UnsupportedFallback; + } + log::info!( + "[coord] streaming polish START: provider=openai-compatible mode={:?} raw_chars={} prior_turns={}", + mode, + raw.text.chars().count(), + prior_turns.len() + ); + match provider + .polish_streaming( + &raw.text, + mode, + hotwords, + style_system_prompt, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + prior_turns, + on_delta, + should_cancel, + ) + .await + { + Ok(text) => { + log::info!( + "[coord] streaming polish OK: final_chars={}", + text.chars().count() + ); + StreamingPolishOutcome::Streamed(text) + } + Err(e) => { + let reason = e.to_string(); + log::error!("[coord] streaming polish FAILED: {reason}"); + StreamingPolishOutcome::Failed(reason) + } + } +} + +async fn polish_or_passthrough( + raw: &RawTranscript, + mode: PolishMode, + hotwords: &[String], + style_system_prompt: &str, + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + llm_thinking_enabled: bool, + front_app: Option<&str>, + prior_turns: &[(String, String)], +) -> (String, Option) { + if mode == PolishMode::Raw && !raw_mode_uses_llm(style_system_prompt) { + return (raw.text.clone(), None); + } + match polish_text( + &raw.text, + mode, + hotwords, + style_system_prompt, + working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app, + prior_turns, + ) + .await + { + Ok(s) => (s, None), + Err(e) => { + let reason = e.to_string(); + log::error!("[coord] polish failed, falling back to raw: {reason}"); + (raw.text.clone(), Some(reason)) + } + } +} + +async fn polish_text( + raw: &str, + mode: PolishMode, + hotwords: &[String], + style_system_prompt: &str, + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + llm_thinking_enabled: bool, + front_app: Option<&str>, + prior_turns: &[(String, String)], +) -> anyhow::Result { + // 谷歌 Gemini 分支:所有 LLM provider 共用 ark.* 凭据槽,唯独 Gemini 走原生 + // generateContent / 自带 thinkingConfig 控制;其余 provider 走 OpenAI + // 兼容协议,并在该路径里按 provider/channel 下发对应的思考开关。 + let active_llm = CredentialsVault::get_active_llm(); + if active_llm == "gemini" { + let (api_key, model, base_url) = read_gemini_credentials()?; + let provider = GeminiProvider::new( + GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled), + ); + return Ok(provider + .polish( + raw, + mode, + hotwords, + style_system_prompt, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + prior_turns, + ) + .await?); + } + + let provider = build_active_llm_provider(llm_thinking_enabled)?; + Ok(provider + .polish( + raw, + mode, + hotwords, + style_system_prompt, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + prior_turns, + ) + .await?) +} + +/// 专用翻译(仅翻译、不润色、单轮)。现作为"润色+翻译"合成调用解析失败时的兜底—— +/// 模型没按两段格式输出时,退回这里拿一段干净译文,而不是把畸形输出当译文插入。 +async fn translate_text( + raw: &str, + target_language: &str, + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + llm_thinking_enabled: bool, + front_app: Option<&str>, +) -> anyhow::Result { + // 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑。 + let active_llm = CredentialsVault::get_active_llm(); + if active_llm == "gemini" { + let (api_key, model, base_url) = read_gemini_credentials()?; + let provider = GeminiProvider::new( + GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled), + ); + return Ok(provider + .translate_to( + raw, + target_language, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) + .await?); + } + + let provider = build_active_llm_provider(llm_thinking_enabled)?; + Ok(provider + .translate_to( + raw, + target_language, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + ) + .await?) +} + +/// "润色+翻译"单次调用的两段哨兵。模型按 `SRC\n源文\nTGT\n译文` 输出,解析器据此切分。 +/// 这两个串必须与 build_polish_translate_system_prompt 写给模型的完全一致。 +const POLISH_TRANSLATE_SRC_MARKER: &str = "[[OPENLESS_POLISHED_SOURCE]]"; +const POLISH_TRANSLATE_TGT_MARKER: &str = "[[OPENLESS_TRANSLATION]]"; + +/// 合成"先润色源文、再翻译"的系统提示词:在原翻译 prompt 之上追加"额外输出润色后源文" +/// 与严格两段格式(覆盖原 prompt 末尾的"只输出译文")。译文仍是要插入用户光标的主产物, +/// 故完整保留原翻译规则;润色后的源文只作对话上下文用,轻量清理即可。 +fn build_polish_translate_system_prompt(target_language: &str) -> String { + let base = crate::polish::prompts::translate_system_prompt(target_language); + format!( + "{base}\n\n\ + # 额外输出:润色后的源文(仅用于对话上下文,不展示给用户)\n\ + 在译文之前,先把上面的原始转写**按它本来的语言**润色一遍:去掉口癖(嗯 / 那个 / um)、\ + 补必要标点、纠正明显的识别错误,但**不翻译、不改写风格、不增删意思**。\n\n\ + # 输出格式(覆盖上面\u{201C}只输出译文\u{201D}的说明,严格遵守)\n\ + 严格按下面两段输出,两个标记必须原样出现、各占一行,标记之外不要有任何多余文字:\n\ + {src}\n\ + (这里放润色后的源文,保持原语言)\n\ + {tgt}\n\ + (这里放翻译成\u{300C}{lang}\u{300D}的译文)", + base = base, + src = POLISH_TRANSLATE_SRC_MARKER, + tgt = POLISH_TRANSLATE_TGT_MARKER, + lang = target_language, + ) +} + +/// 解析"润色+翻译"单次调用输出 → Some((润色后源文, 译文))。 +/// 找到译文标记且译文非空 → Some((源文, 译文)):源文标记缺失 / 源文段为空时源文为 None, +/// 译文取标记之后的干净正文。**没有译文标记、或译文段为空(模型截断 / 只吐了标记)→ None**, +/// 表示没拿到可信译文,交由调用方退回专用翻译——避免把空串当"成功译文"插进光标而丢字。 +fn split_polish_translate_output(raw: &str) -> Option<(Option, String)> { + let tgt_idx = raw.find(POLISH_TRANSLATE_TGT_MARKER)?; + let translation = raw[tgt_idx + POLISH_TRANSLATE_TGT_MARKER.len()..] + .trim() + .to_string(); + if translation.is_empty() { + return None; + } + let before_tgt = &raw[..tgt_idx]; + let source = before_tgt + .find(POLISH_TRANSLATE_SRC_MARKER) + .map(|i| { + before_tgt[i + POLISH_TRANSLATE_SRC_MARKER.len()..] + .trim() + .to_string() + }) + .filter(|s| !s.is_empty()); + Some((source, translation)) +} + +/// 翻译路径——单次 LLM 调用同时润色源文 + 翻译。和 polish 一样失败时返回原文 + 失败原因, +/// 避免"不丢字"约定被违反(CLAUDE.md)。返回 (要插入的译文, 润色后源文供上下文用, 失败原因)。 +#[allow(clippy::too_many_arguments)] +async fn polish_and_translate_or_passthrough( + raw: &RawTranscript, + target_language: &str, + mode: PolishMode, + hotwords: &[String], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + llm_thinking_enabled: bool, + front_app: Option<&str>, + prior_turns: &[(String, String)], +) -> (String, Option, Option) { + let system_prompt = build_polish_translate_system_prompt(target_language); + match polish_text( + &raw.text, + mode, + hotwords, + &system_prompt, + working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app, + prior_turns, + ) + .await + { + Ok(out) => match split_polish_translate_output(&out) { + Some((source, translation)) => (translation, source, None), + None => { + // 模型没按两段格式输出:退回专用翻译拿一段干净译文,避免把畸形输出插进光标。 + // 此时无可信源文,这条翻译历史不参与后续普通润色上下文。 + log::warn!( + "[coord] polish+translate output missing markers; falling back to plain translate" + ); + match translate_text( + &raw.text, + target_language, + working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app, + ) + .await + { + Ok(translation) => (translation, None, None), + Err(e) => { + let reason = e.to_string(); + log::error!("[coord] fallback translate failed, using raw: {reason}"); + (raw.text.clone(), None, Some(reason)) + } + } + } + }, + Err(e) => { + let reason = e.to_string(); + log::error!("[coord] polish+translate failed, falling back to raw: {reason}"); + (raw.text.clone(), None, Some(reason)) + } + } +} + +fn read_whisper_credentials() -> (String, String, String) { + let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) + .ok() + .flatten() + .unwrap_or_default(); + let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint) + .ok() + .flatten() + .unwrap_or_default(); + let model = CredentialsVault::get(CredentialAccount::AsrModel) + .ok() + .flatten() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "whisper-1".to_string()); + (api_key, base_url, model) +} + +fn read_mimo_credentials() -> (String, String, String) { + let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) + .ok() + .flatten() + .unwrap_or_default(); + let base_url = CredentialsVault::get(CredentialAccount::AsrEndpoint) + .ok() + .flatten() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::asr::mimo::DEFAULT_ENDPOINT.to_string()); + let model = CredentialsVault::get(CredentialAccount::AsrModel) + .ok() + .flatten() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::asr::mimo::DEFAULT_MODEL.to_string()); + (api_key, base_url, model) +} + +fn read_bailian_credentials() -> BailianCredentials { + let api_key = CredentialsVault::get(CredentialAccount::AsrApiKey) + .ok() + .flatten() + .unwrap_or_default(); + let endpoint = CredentialsVault::get(CredentialAccount::AsrEndpoint) + .ok() + .flatten() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::asr::bailian::DEFAULT_ENDPOINT.to_string()); + let model = CredentialsVault::get(CredentialAccount::AsrModel) + .ok() + .flatten() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| crate::asr::bailian::DEFAULT_MODEL.to_string()); + let vocabulary_id = CredentialsVault::get(CredentialAccount::AsrVocabularyId) + .ok() + .flatten() + .filter(|s| !s.trim().is_empty()); + BailianCredentials { + api_key, + endpoint, + model, + vocabulary_id, + } +} + +fn read_volc_credentials() -> VolcengineCredentials { + let app_id = CredentialsVault::get(CredentialAccount::VolcengineAppKey) + .ok() + .flatten() + .unwrap_or_default(); + let access_token = CredentialsVault::get(CredentialAccount::VolcengineAccessKey) + .ok() + .flatten() + .unwrap_or_default(); + let resource_id = CredentialsVault::get(CredentialAccount::VolcengineResourceId) + .ok() + .flatten() + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| VolcengineCredentials::default_resource_id().to_string()); + VolcengineCredentials { + app_id, + access_token, + resource_id, + } +} + +fn enabled_hotwords(inner: &Arc) -> Vec { + inner + .vocab + .list() + .unwrap_or_default() + .into_iter() + .map(|e| DictionaryHotword { + phrase: e.phrase, + enabled: e.enabled, + }) + .collect() +} + +// ─────────────────────────── QA session lifecycle ─────────────────────────── + +async fn finalize_dictation_as_qa_question(inner: &Arc) -> Result<(), String> { + log::info!("[coord] QA finalize from overlay: capturing selection before opening panel"); + let selection = capture_selection(); + let selection_preview_text = selection.as_ref().map(|s| s.text.clone()); + + log::info!("[coord] QA finalize from overlay: opening panel and waiting for ASR result"); + open_qa_panel(inner); + { + let mut state = inner.qa_state.lock(); + state.phase = QaPhase::Processing; + state.cancelled = false; + state.session_id = new_session_id(); + state.front_app = capture_frontmost_app(); + state.selection = selection; + } + inner.qa_stream_cancelled.store(false, Ordering::SeqCst); + + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ + "kind": "loading", + "selection_preview": selection_preview_text, + "messages": messages, + }), + ); + } + + let raw = match take_current_dictation_transcript_for_qa(inner).await { + Ok(Some(raw)) => raw, + Ok(None) => { + log::info!("[coord] QA finalize from overlay: no transcript produced"); + finish_qa_idle_silently(inner); + return Ok(()); + } + Err(error) => { + finish_qa_with_error(inner, error.clone()); + return Err(error); + } + }; + log::info!( + "[coord] QA finalize from overlay: transcript ready chars={} duration_ms={}", + raw.text.chars().count(), + raw.duration_ms + ); + answer_qa_question_text(inner, raw.text.trim().to_string(), raw.duration_ms).await +} + +async fn submit_qa_text_question(inner: &Arc, text: String) -> Result<(), String> { + let question = text.trim().to_string(); + if question.is_empty() { + return Ok(()); + } + + { + let mut state = inner.qa_state.lock(); + if !state.panel_visible { + state.panel_visible = true; + state.messages.clear(); + state.front_app = capture_frontmost_app(); + state.qa_focus_target = capture_focus_target(); + } + if state.phase != QaPhase::Idle { + return Err("QA is busy".to_string()); + } + state.phase = QaPhase::Processing; + state.cancelled = false; + state.session_id = new_session_id(); + if state.selection.is_none() { + state.selection = capture_selection(); + } + } + inner.qa_stream_cancelled.store(false, Ordering::SeqCst); + + let selection_preview_text = inner + .qa_state + .lock() + .selection + .as_ref() + .map(|selection| selection.text.clone()); + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ + "kind": "thinking", + "selection_preview": selection_preview_text, + "messages": messages, + }), + ); + } + + answer_qa_question_text(inner, question, 0).await +} + +async fn take_current_dictation_transcript_for_qa( + inner: &Arc, +) -> Result, String> { + wait_for_dictation_listening(inner).await?; + + let current_session_id = { + let mut state = inner.state.lock(); + let Some(session_id) = start_processing_if_listening(&mut state) else { + return Ok(None); + }; + session_id + }; + + let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64; + emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None); + + if let Some(rec) = take_recorder_for_session(inner, current_session_id) { + rec.stop(); + release_recording_mute(inner, "dictation"); + } + + let Some(asr) = take_asr_for_session(inner, current_session_id) else { + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(None); + }; + + let mut raw = match transcribe_overlay_dictation_asr(inner, current_session_id, asr).await { + Ok(raw) => raw, + Err(error) => { + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + finish_qa_with_error(inner, format!("识别失败: {error}")); + return Err(error); + } + }; + + if inner.state.lock().cancelled { + log::info!("[coord] overlay QA: cancel detected after ASR — discarding transcript"); + restore_prepared_windows_ime_session(inner, current_session_id); + { + let mut state = inner.state.lock(); + state.phase = SessionPhase::Idle; + state.focus_target = None; + } + return Ok(None); + } + + #[cfg(any(debug_assertions, test))] + if raw.text.trim().is_empty() { + if let Some(debug_text) = debug_transcript_override_text() { + raw.text = debug_text; + } + } + + if raw.text.trim().is_empty() { + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + finish_qa_idle_silently(inner); + return Ok(None); + } + + if let Ok(rules) = inner.correction_rules.list() { + let corrected = apply_correction_rules(&raw.text, &rules); + if corrected != raw.text { + raw.text = corrected; + } + } + + restore_prepared_windows_ime_session(inner, current_session_id); + { + let mut state = inner.state.lock(); + state.phase = SessionPhase::Idle; + state.focus_target = None; + } + Ok(Some(raw)) +} + +async fn wait_for_dictation_listening(inner: &Arc) -> Result<(), String> { + const MAX_WAIT_MS: u64 = 3_000; + const STEP_MS: u64 = 20; + let deadline = std::time::Instant::now() + std::time::Duration::from_millis(MAX_WAIT_MS); + + loop { + let phase = { inner.state.lock().phase }; + match phase { + SessionPhase::Starting if std::time::Instant::now() < deadline => { + tokio::time::sleep(std::time::Duration::from_millis(STEP_MS)).await; + } + SessionPhase::Starting => { + return Err("dictation startup timed out before QA finalize".to_string()); + } + _ => return Ok(()), + } + } +} + +async fn transcribe_overlay_dictation_asr( + _inner: &Arc, + _current_session_id: SessionId, + asr: ActiveAsr, +) -> Result { + let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr); + match asr { + ActiveAsr::Volcengine(asr) => { + debug_assert!(uses_global_timeout); + if let Err(error) = asr.send_last_frame().await { + log::error!("[coord] overlay QA: send last frame failed: {error}"); + } + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => { + asr.cancel(); + Err("global timeout".to_string()) + } + } + } + ActiveAsr::Bailian(asr) => { + debug_assert!(uses_global_timeout); + if let Err(error) = asr.send_last_frame().await { + log::error!("[coord] overlay QA: Bailian send last frame failed: {error}"); + } + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => { + asr.cancel(); + Err("bailian global timeout".to_string()) + } + } + } + ActiveAsr::Whisper(whisper) => { + debug_assert!(uses_global_timeout); + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, whisper.transcribe()).await { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err("whisper global timeout".to_string()), + } + } + ActiveAsr::Mimo(mimo) => { + debug_assert!(uses_global_timeout); + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, mimo.transcribe()).await { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err("mimo global timeout".to_string()), + } + } + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(local) => { + debug_assert!(!uses_global_timeout); + match local + .transcribe(foundry_audio_transcribe_timeout_duration()) + .await + { + Ok(raw) => { + schedule_foundry_local_asr_release( + _inner, + AsrReleaseSession::Dictation(_current_session_id), + ); + Ok(raw) + } + Err(error) => { + schedule_foundry_local_asr_release( + _inner, + AsrReleaseSession::Dictation(_current_session_id), + ); + Err(error.to_string()) + } + } + } + #[cfg(target_os = "windows")] + ActiveAsr::SherpaOnnxLocal(local) => { + debug_assert!(!uses_global_timeout); + match local + .transcribe(sherpa_audio_transcribe_timeout_duration()) + .await + { + Ok(raw) => { + schedule_sherpa_onnx_release( + _inner, + AsrReleaseSession::Dictation(_current_session_id), + ); + Ok(raw) + } + Err(error) => { + schedule_sherpa_onnx_release( + _inner, + AsrReleaseSession::Dictation(_current_session_id), + ); + Err(error.to_string()) + } + } + } + #[cfg(target_os = "macos")] + ActiveAsr::Local(local) => { + debug_assert!(uses_global_timeout); + let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; + let timeout_duration = local_qwen_transcribe_timeout(audio_secs); + let result = tokio::time::timeout(timeout_duration, local.transcribe()).await; + _inner.local_asr_cache.touch(); + schedule_local_asr_release(_inner); + match result { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err("local qwen transcribe timeout".to_string()), + } + } + #[cfg(target_os = "macos")] + ActiveAsr::AppleSpeech(local) => { + debug_assert!(uses_global_timeout); + match tokio::time::timeout( + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS), + local.transcribe(), + ) + .await + { + Ok(Ok(raw)) => Ok(raw), + Ok(Err(error)) => Err(error.to_string()), + Err(_) => Err("apple speech transcribe timeout".to_string()), + } + } + } +} + +async fn answer_qa_question_text( + inner: &Arc, + question: String, + duration_ms: u64, +) -> Result<(), String> { + if question.trim().is_empty() { + finish_qa_idle_silently(inner); + return Ok(()); + } + + let user_content = { + let st = inner.qa_state.lock(); + let is_first_turn = st.messages.is_empty(); + let sel_text = st + .selection + .as_ref() + .map(|s| s.text.clone()) + .unwrap_or_default(); + if is_first_turn && !sel_text.trim().is_empty() { + format!( + "# 选区原文\n{}\n\n# 我的问题\n{}", + sel_text.trim(), + question + ) + } else { + question.clone() + } + }; + + inner + .qa_state + .lock() + .messages + .push(crate::types::QaChatMessage { + role: "user".to_string(), + content: user_content, + }); + + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ + "kind": "thinking", + "messages": messages, + }), + ); + } + + emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None); + + let prefs = inner.prefs.get(); + let working_languages = prefs.working_languages.clone(); + let chinese_script_preference = prefs.chinese_script_preference; + let output_language_preference = prefs.output_language_preference; + let llm_thinking_enabled = prefs.llm_thinking_enabled; + let (messages_for_llm, front_app) = { + let st = inner.qa_state.lock(); + (st.messages.clone(), st.front_app.clone()) + }; + + let captured_session_id = inner.qa_state.lock().session_id; + let inner_for_delta = Arc::clone(inner); + let on_delta = move |chunk: &str| { + let cur_id = inner_for_delta.qa_state.lock().session_id; + if cur_id != captured_session_id { + return; + } + if let Some(app) = inner_for_delta.app.lock().clone() { + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ + "kind": "answer_delta", + "chunk": chunk, + }), + ); + } + }; + + let cancel_flag = Arc::clone(&inner.qa_stream_cancelled); + let should_cancel = move || cancel_flag.load(Ordering::Relaxed); + + let answer = match answer_chat_dispatch( + &messages_for_llm, + &working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app.as_deref(), + on_delta, + should_cancel, + ) + .await + { + Ok(answer) => answer, + Err(error) => { + inner.qa_state.lock().messages.pop(); + finish_qa_with_error(inner, format!("回答失败: {error}")); + return Err(error.to_string()); + } + }; + + if inner.qa_state.lock().cancelled { + inner.qa_state.lock().messages.pop(); + finish_qa_idle_silently(inner); + return Ok(()); + } + + inner + .qa_state + .lock() + .messages + .push(crate::types::QaChatMessage { + role: "assistant".to_string(), + content: answer.clone(), + }); + + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ + "kind": "answer", + "messages": messages, + }), + ); + } + + emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + + if prefs.qa_save_history { + let session = DictationSession { + id: Uuid::new_v4().to_string(), + created_at: Utc::now().to_rfc3339(), + raw_transcript: question.clone(), + final_text: answer, + mode: PolishMode::Raw, + style_pack_id: None, + translation_active: false, + polish_source: None, + app_bundle_id: None, + app_name: front_app, + insert_status: InsertStatus::CopiedFallback, + error_code: Some("qaSession".to_string()), + duration_ms: Some(duration_ms), + dictionary_entry_count: None, + has_audio_recording: None, + }; + let prefs_snapshot = inner.prefs.get(); + if let Err(error) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { + log::error!("[coord] overlay QA history append failed: {error}"); + } + } + + inner.qa_state.lock().phase = QaPhase::Idle; + Ok(()) +} + +/// 划词语音问答会话(issue #118)。 +/// +/// 与 dictation 完全分离: +/// - 不进 SessionPhase(互不抢锁) +/// - 不写 history.json(除非 prefs.qa_save_history=true 才旁路写一条 placeholder) +/// - 用独立的 qa_recorder + qa_asr,复用现有 Volcengine ASR 通路 +async fn begin_qa_session(inner: &Arc) -> Result<(), String> { + { + let mut state = inner.qa_state.lock(); + if !state.panel_visible { + // 防御:浮窗没开就被叫到这里说明路由错了,直接退出。 + return Ok(()); + } + if state.phase != QaPhase::Idle { + return Ok(()); + } + state.phase = QaPhase::Recording; + state.cancelled = false; + state.session_id = new_session_id(); + state.front_app = capture_frontmost_app(); + state.selection = None; + } + // 重置 SSE 取消标志:上一轮可能 set 过的 true 留着会让本轮流式立即 break。 + inner.qa_stream_cancelled.store(false, Ordering::SeqCst); + + // 抓选区。每轮按 Option 都重新抓一次:用户多轮提问中可以重新选别处文字。 + // + // - macOS:浮窗走 orderFrontRegardless,不成为 key window,原 app 仍是 frontmost, + // AX/Cmd+C fallback 都能拿到。 + // - Windows:#466 修复后 show_qa_window_no_activate 主动抓焦点,QA 此刻已是前台, + // simulate_copy 会跑在 QA 自己 webview 上 → 抓不到。focus-dance 上半场:把焦点临时 + // 还给"用户原 app 的 HWND"。 + // + // 多轮场景的目标刷新:用户开 QA 后可能 Alt+Tab 切到别的 app 选新文字。如果还死认 + // open_qa_panel 时记下的初始 HWND,会把焦点抢回错的 app(pr_agent stale-focus 关注点)。 + // 策略:每轮先看当前前台是不是本进程的窗口(QA / capsule / main)—— 是 → 用户没切 + // 走,沿用 saved;不是 → 用户切到了真正的外部 app,刷新 saved 为当前 HWND。 + // 抓完选区后下半场再把焦点交还 QA,让 ESC/X 继续可用。 + #[cfg(target_os = "windows")] + { + // 合并两次 lock:原来分 lock #1 写 + lock #2 读,两者之间 close_qa_panel 在别的 + // 线程把 qa_focus_target 清成 None 会被覆盖回旧 HWND。Cloud 评审指出的 TOCTOU。 + // 单次加锁里既写最新外部前台、再读出来交给后面的 restore_focus_target_if_possible + // —— capture_external_focus_target() 内部只调 GetForegroundWindow / pid 查询, + // 不会反向取 qa_state 锁,持锁期间调用安全。 + let saved_target = { + let mut state = inner.qa_state.lock(); + if let Some(current_external) = capture_external_focus_target() { + state.qa_focus_target = Some(current_external); + } + state.qa_focus_target + }; + let _ = restore_focus_target_if_possible(saved_target); + } + let selection = capture_selection(); + #[cfg(target_os = "windows")] + if let Some(app) = inner.app.lock().clone() { + crate::refocus_qa_window(&app); + } + let selection_preview_text = selection.as_ref().map(|s| s.text.clone()); + inner.qa_state.lock().selection = selection.clone(); + + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ + "kind": "recording", + "selection_preview": selection_preview_text, + "messages": messages, + }), + ); + } + + // 2. QA 与 dictation 使用同一个 active ASR 入口。不要回退火山,否则用户配置 + // 百炼 / Whisper / 本地 ASR 后,浮窗仍会偷偷走另一套凭据。 + let active_asr = CredentialsVault::get_active_asr(); + if let Err(message) = ensure_asr_credentials() { + log::warn!("[coord] QA: active ASR credentials missing: {message}"); + finish_qa_with_error(inner, format!("缺少 ASR 凭据:{message}")); + return Err(message); + } + + if let Err(message) = ensure_microphone_permission(inner) { + log::warn!("[coord] QA: microphone permission gate failed: {message}"); + finish_qa_with_error(inner, message.clone()); + return Err(message); + } + + let qa_asr = match build_qa_asr_start(inner, &active_asr).await { + Ok(qa_asr) => qa_asr, + Err(message) => { + log::error!("[coord] QA active ASR init failed: {message}"); + finish_qa_with_error(inner, format!("ASR 初始化失败: {message}")); + return Err(message); + } + }; + let consumer = qa_asr.recorder_consumer(); + *inner.qa_asr.lock() = Some(qa_asr.active_asr()); + + // QA recorder 不需要 RMS 节流到胶囊;前端 QA 浮窗有自己的电平视图, + // Android 的 QA 面板嵌在 main WebView;桌面端仍发给独立 qa 窗口。 + let inner_for_level = Arc::clone(inner); + let last_emit_at = Arc::new(Mutex::new(None::)); + const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33; + let level_handler: Arc = Arc::new(move |level| { + let phase = inner_for_level.qa_state.lock().phase; + if phase != QaPhase::Recording { + return; + } + let now = Instant::now(); + { + let mut last = last_emit_at.lock(); + if let Some(prev) = *last { + if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 { + return; + } + } + *last = Some(now); + } + if let Some(app) = inner_for_level.app.lock().clone() { + let _ = app.emit_to( + qa_event_target(), + "qa:level", + serde_json::json!({ "level": level }), + ); + } + // 同步把电平推给底部胶囊,让 QA 录音也有跟主听写一致的可视反馈。 + emit_capsule( + &inner_for_level, + CapsuleState::Recording, + level, + 0, + None, + None, + ); + }); + + let microphone_device_name = selected_microphone_device_name(inner); + stop_microphone_preview_monitor(inner, "QA recorder"); + acquire_recording_mute(inner, "qa").await; + // QA 默认不留痕(qa_save_history 默认 false),录音文件归档也跟着不开。 + // 调试 QA 麦克风请用主听写路径。 + match Recorder::start(microphone_device_name, consumer, level_handler, None) { + Ok((rec, runtime_errors, archive_active)) => { + // QA 路径不写 dictation 的 history,但仍把 archive 状态归零,避免 dictation + // 接力时读到上一个 QA session 的过期值。 + inner + .audio_archive_active + .store(archive_active, std::sync::atomic::Ordering::Relaxed); + *inner.qa_recorder.lock() = Some(rec); + // QA 也跟主听写一样监听 cpal runtime error。设备中途消失 / panic 时 + // 不能让 QA 永远卡在 Recording 没反馈。详见 issue #168。 + spawn_qa_recorder_error_monitor(inner, runtime_errors); + } + Err(e) => { + log::error!("[coord] QA recorder start failed: {e}"); + if let Some(asr) = inner.qa_asr.lock().take() { + cancel_active_asr(asr); + } + release_recording_mute(inner, "qa"); + finish_qa_with_error(inner, format!("录音启动失败: {e}")); + return Err(e.to_string()); + } + } + + if let Err(e) = qa_asr.open_streaming_session().await { + log::error!("[coord] QA: open ASR session failed: {e}"); + stop_qa_recorder(inner); + if let Some(asr) = inner.qa_asr.lock().take() { + cancel_active_asr(asr); + } + finish_qa_with_error(inner, format!("ASR 连接失败: {e}")); + return Err(e); + } + + // cancel race:在 await 期间用户可能 dismiss 了浮窗。 + if inner.qa_state.lock().cancelled { + log::info!("[coord] QA cancel raced during open_session — aborting begin"); + if let Some(asr) = inner.qa_asr.lock().take() { + cancel_active_asr(asr); + } + stop_qa_recorder(inner); + inner.qa_state.lock().phase = QaPhase::Idle; + return Ok(()); + } + + // 显式弹胶囊到 Recording。level_handler 后续会持续推电平,胶囊里"录音中…" + // 的视觉反馈跟主听写完全一致。 + emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); + + Ok(()) +} + +async fn end_qa_session(inner: &Arc) -> Result<(), String> { + { + let mut state = inner.qa_state.lock(); + if state.phase != QaPhase::Recording { + return Ok(()); + } + state.phase = QaPhase::Processing; + } + + // 胶囊进入 Transcribing:用户视觉上看到"识别中"。 + emit_capsule(inner, CapsuleState::Transcribing, 0.0, 0, None, None); + + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ "kind": "loading" }), + ); + } + + stop_qa_recorder(inner); + + let asr = match inner.qa_asr.lock().take() { + Some(a) => a, + None => { + inner.qa_state.lock().phase = QaPhase::Idle; + return Ok(()); + } + }; + + #[cfg_attr(not(target_os = "windows"), allow(unused_variables))] + let qa_session_id = inner.qa_state.lock().session_id; + let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr); + let raw = match asr { + ActiveAsr::Volcengine(asr) => { + debug_assert!(uses_global_timeout); + if let Err(e) = asr.send_last_frame().await { + log::error!("[coord] QA: send last frame failed: {e}"); + } + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] QA: await final failed: {e}"); + finish_qa_with_error(inner, format!("识别失败: {e}")); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] QA: 全局超时 {} 秒 - 强制恢复", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + asr.cancel(); + finish_qa_with_error(inner, "识别超时".to_string()); + return Err("global timeout".to_string()); + } + } + } + ActiveAsr::Bailian(asr) => { + debug_assert!(uses_global_timeout); + if let Err(e) = asr.send_last_frame().await { + log::error!("[coord] QA: Bailian send last frame failed: {e}"); + } + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] QA: Bailian await final failed: {e}"); + finish_qa_with_error(inner, format!("识别失败: {e}")); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] QA: Bailian 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + asr.cancel(); + finish_qa_with_error(inner, "识别超时".to_string()); + return Err("bailian global timeout".to_string()); + } + } + } + ActiveAsr::Whisper(w) => { + debug_assert!(uses_global_timeout); + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, w.transcribe()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] QA: whisper transcribe failed: {e}"); + finish_qa_with_error(inner, format!("识别失败: {e}")); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] QA: whisper 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + finish_qa_with_error(inner, "识别超时".to_string()); + return Err("whisper global timeout".to_string()); + } + } + } + ActiveAsr::Mimo(m) => { + debug_assert!(uses_global_timeout); + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, m.transcribe()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] QA: MiMo ASR transcribe failed: {e}"); + finish_qa_with_error(inner, format!("识别失败: {e}")); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] QA: MiMo ASR 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + finish_qa_with_error(inner, "识别超时".to_string()); + return Err("mimo global timeout".to_string()); + } + } + } + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(local) => { + debug_assert!(!uses_global_timeout); + match local + .transcribe(foundry_audio_transcribe_timeout_duration()) + .await + { + Ok(r) => { + schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id)); + r + } + Err(e) => { + schedule_foundry_local_asr_release(inner, AsrReleaseSession::Qa(qa_session_id)); + if inner.qa_state.lock().cancelled { + log::info!( + "[coord] QA Foundry Local Whisper transcribe cancelled — discarding transcript" + ); + finish_qa_idle_silently(inner); + return Ok(()); + } + log::error!("[coord] QA Foundry Local Whisper transcribe failed: {e:#}"); + finish_qa_with_error(inner, format!("本地识别失败: {e}")); + return Err(e.to_string()); + } + } + } + #[cfg(target_os = "windows")] + ActiveAsr::SherpaOnnxLocal(local) => { + debug_assert!(!uses_global_timeout); + match local + .transcribe(sherpa_audio_transcribe_timeout_duration()) + .await + { + Ok(r) => { + schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id)); + r + } + Err(e) => { + schedule_sherpa_onnx_release(inner, AsrReleaseSession::Qa(qa_session_id)); + if inner.qa_state.lock().cancelled { + log::info!( + "[coord] QA sherpa-onnx transcribe cancelled — discarding transcript" + ); + finish_qa_idle_silently(inner); + return Ok(()); + } + log::error!("[coord] QA sherpa-onnx transcribe failed: {e:#}"); + finish_qa_with_error(inner, format!("本地识别失败: {e}")); + return Err(e.to_string()); + } + } + } + #[cfg(target_os = "macos")] + ActiveAsr::Local(local) => { + debug_assert!(uses_global_timeout); + let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; + let timeout_duration = local_qwen_transcribe_timeout(audio_secs); + log::info!( + "[coord] QA local Qwen3-ASR transcribe: audio={:.2}s timeout={}s", + audio_secs, + timeout_duration.as_secs() + ); + let result = tokio::time::timeout(timeout_duration, local.transcribe()).await; + inner.local_asr_cache.touch(); + schedule_local_asr_release(inner); + match result { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] QA local Qwen3-ASR transcribe failed: {e:#}"); + finish_qa_with_error(inner, format!("本地识别失败: {e}")); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] QA local Qwen3-ASR transcribe timeout after {}s", + timeout_duration.as_secs() + ); + finish_qa_with_error(inner, "本地识别超时".to_string()); + return Err("local qwen transcribe timeout".to_string()); + } + } + } + #[cfg(target_os = "macos")] + ActiveAsr::AppleSpeech(local) => { + debug_assert!(uses_global_timeout); + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, local.transcribe()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] QA Apple Speech transcribe failed: {e:#}"); + finish_qa_with_error(inner, format!("本地识别失败: {e}")); + return Err(e.to_string()); + } + Err(_) => { + log::error!("[coord] QA Apple Speech transcribe timeout"); + finish_qa_with_error(inner, "本地识别超时".to_string()); + return Err("apple speech transcribe timeout".to_string()); + } + } + } + }; + + // cancel race:用户在 transcribe 中按 Esc / dismiss → 静默退出。 + if inner.qa_state.lock().cancelled { + log::info!("[coord] QA cancel detected after ASR — discarding transcript"); + finish_qa_idle_silently(inner); + return Ok(()); + } + + let question = raw.text.trim().to_string(); + if question.is_empty() { + // 静默录音:不调 LLM,不弹错误,直接关浮窗。 + log::info!("[coord] QA: empty transcript → silent dismiss"); + finish_qa_idle_silently(inner); + return Ok(()); + } + + // 拼这一轮的 user 消息:第一轮(messages 还空)把选区原文嵌进去; + // 之后的轮次只送提问,让 LLM 顺着上下文回答。详见 issue #118 v2。 + let user_content = { + let st = inner.qa_state.lock(); + let is_first_turn = st.messages.is_empty(); + let sel_text = st + .selection + .as_ref() + .map(|s| s.text.clone()) + .unwrap_or_default(); + if is_first_turn && !sel_text.trim().is_empty() { + format!( + "# 选区原文\n{}\n\n# 我的问题\n{}", + sel_text.trim(), + question + ) + } else { + question.clone() + } + }; + + inner + .qa_state + .lock() + .messages + .push(crate::types::QaChatMessage { + role: "user".to_string(), + content: user_content, + }); + + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ + "kind": "thinking", + "messages": messages, + }), + ); + } + + // 胶囊:思考阶段(复用 dictation 的 Polishing 状态——视觉上是"润色中",QA 借用一下)。 + emit_capsule(inner, CapsuleState::Polishing, 0.0, 0, None, None); + + let prefs = inner.prefs.get(); + let working_languages = prefs.working_languages.clone(); + let chinese_script_preference = prefs.chinese_script_preference; + let output_language_preference = prefs.output_language_preference; + let llm_thinking_enabled = prefs.llm_thinking_enabled; + let (messages_for_llm, front_app) = { + let st = inner.qa_state.lock(); + (st.messages.clone(), st.front_app.clone()) + }; + + // 流式回调:每个 SSE delta 立刻推一帧 qa:state{kind:"answer_delta"} 给前端, + // 浮窗里气泡边收边长。最终的 messages 由 answer 事件统一下发(保证一致性)。 + // + // session_id 守卫(issue #161):闭包捕获本会话 id;用户取消 → 关浮窗 → 开新浮窗 + // 开新一轮时,旧的 in-flight LLM 流仍可能 emit chunk,必须在 emit 前比对当前 + // qa_state.session_id == 捕获 id,否则跳过——避免旧会话的字漏进新气泡。 + let captured_session_id = inner.qa_state.lock().session_id; + let inner_for_delta = Arc::clone(inner); + let on_delta = move |chunk: &str| { + let cur_id = inner_for_delta.qa_state.lock().session_id; + if cur_id != captured_session_id { + return; // 旧 session 漏来的 chunk,丢弃 + } + if let Some(app) = inner_for_delta.app.lock().clone() { + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ + "kind": "answer_delta", + "chunk": chunk, + }), + ); + } + }; + + // SSE 流取消旗标:cancel_qa_session / close_qa_panel 会 set true, + // polish 的 SSE loop 每帧检查 → break,释放 HTTP body。详见 issue #161。 + let cancel_flag = Arc::clone(&inner.qa_stream_cancelled); + let should_cancel = move || cancel_flag.load(Ordering::Relaxed); + + let answer = match answer_chat_dispatch( + &messages_for_llm, + &working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app.as_deref(), + on_delta, + should_cancel, + ) + .await + { + Ok(s) => s, + Err(e) => { + log::error!("[coord] QA: LLM answer failed: {e}"); + // 把刚 push 的 user 消息回滚,避免 retry 重复 + inner.qa_state.lock().messages.pop(); + finish_qa_with_error(inner, format!("回答失败: {e}")); + return Err(e.to_string()); + } + }; + + if inner.qa_state.lock().cancelled { + log::info!("[coord] QA cancel detected before answer — discarding"); + // 同样回滚未配对的 user 消息 + inner.qa_state.lock().messages.pop(); + finish_qa_idle_silently(inner); + return Ok(()); + } + + inner + .qa_state + .lock() + .messages + .push(crate::types::QaChatMessage { + role: "assistant".to_string(), + content: answer.clone(), + }); + + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ + "kind": "answer", + "messages": messages, + }), + ); + } + + // 胶囊直接收掉。QA 不走 insertion,没"已粘贴 N 字"语义;浮窗里答案就是用户的反馈。 + // (之前用 Done 状态会被 capsule UI 错误地渲染上一次 dictation 残留的 message/insertedChars。) + emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + + // 可选:写一条 history(QA 类型)。当前 DictationSession schema 不能直接表达 + // "QuestionAnswer" 类型,因此简单做法:勾选 qa_save_history 时写一条 + // mode=Raw、error_code=Some("qaSession") 的 placeholder,避免污染 schema 同时 + // 让用户能在历史里翻到这次问答的字面值。详见 issue #118。 + if prefs.qa_save_history { + let session = DictationSession { + id: Uuid::new_v4().to_string(), + created_at: Utc::now().to_rfc3339(), + raw_transcript: question.clone(), + final_text: answer.clone(), + mode: PolishMode::Raw, + style_pack_id: None, + translation_active: false, + polish_source: None, + app_bundle_id: None, + app_name: front_app.clone(), + insert_status: InsertStatus::CopiedFallback, + error_code: Some("qaSession".to_string()), + duration_ms: Some(raw.duration_ms), + dictionary_entry_count: None, + has_audio_recording: None, + }; + let prefs_snapshot = inner.prefs.get(); + if let Err(e) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { + log::error!("[coord] QA history append failed: {e}"); + } + } + + inner.qa_state.lock().phase = QaPhase::Idle; + Ok(()) +} + +/// 把出错状态送到前端浮窗 + 胶囊错误闪一下 + 复位 phase。 +/// 浮窗保持可见(v2:错误后用户可以再按 Option 重试);messages 一并送过去 +/// 让前端继续渲染历史对话。 +fn finish_qa_with_error(inner: &Arc, message: String) { + stop_qa_recorder(inner); + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ + "kind": "error", + "error": message, + "messages": messages, + }), + ); + } + emit_capsule(inner, CapsuleState::Error, 0.0, 0, Some(message), None); + schedule_capsule_idle(inner, 1500); + let mut state = inner.qa_state.lock(); + state.phase = QaPhase::Idle; + state.cancelled = false; +} + +/// 静默收尾:发 idle 事件给前端,phase 复位。**不关浮窗**(v2:浮窗只在用户 +/// Esc/X 或再按 QA hotkey 时才关);多轮对话历史保留。胶囊也即刻收掉。 +fn finish_qa_idle_silently(inner: &Arc) { + if let Some(app) = inner.app.lock().clone() { + let messages = inner.qa_state.lock().messages.clone(); + let _ = app.emit_to( + qa_event_target(), + "qa:state", + serde_json::json!({ + "kind": "idle", + "messages": messages, + }), + ); + } + emit_capsule(inner, CapsuleState::Idle, 0.0, 0, None, None); + let mut state = inner.qa_state.lock(); + state.phase = QaPhase::Idle; + state.cancelled = false; + state.selection = None; +} + +fn cancel_qa_session(inner: &Arc) { + let phase = inner.qa_state.lock().phase; + if phase == QaPhase::Idle { + return; + } + inner.qa_state.lock().cancelled = true; + // SSE 流取消旗标——polish::chat_completion_history_streaming 的 loop 每帧检查 + // 这个 flag,true 时立即 break 不再 drain HTTP body,避免取消后 LLM 仍烧 token。 + // 详见 issue #161。 + inner.qa_stream_cancelled.store(true, Ordering::SeqCst); + stop_qa_recorder(inner); + if let Some(asr) = inner.qa_asr.lock().take() { + cancel_active_asr(asr); + } + // Processing 阶段保持 phase 让 end_qa_session 自然走完 cancel 检查; + // 否则直接复位。 + if phase != QaPhase::Processing { + inner.qa_state.lock().phase = QaPhase::Idle; + } + log::info!("[coord] QA session cancelled (was {phase:?})"); +} + +async fn answer_chat_dispatch( + messages: &[crate::types::QaChatMessage], + working_languages: &[String], + chinese_script_preference: ChineseScriptPreference, + output_language_preference: OutputLanguagePreference, + llm_thinking_enabled: bool, + front_app: Option<&str>, + on_delta: F, + should_cancel: C, +) -> anyhow::Result +where + F: Fn(&str) + Send + Sync, + C: Fn() -> bool + Send + Sync, +{ + // 见 polish_text 顶部注释——同样的 Gemini / OpenAI-compatible 路由逻辑, + // QA 流式回答走 Gemini 原生 :streamGenerateContent?alt=sse。 + let active_llm = CredentialsVault::get_active_llm(); + if active_llm == "gemini" { + let (api_key, model, base_url) = read_gemini_credentials()?; + let provider = GeminiProvider::new( + GeminiConfig::new(api_key, model, base_url).with_thinking_enabled(llm_thinking_enabled), + ); + return Ok(provider + .answer_chat_streaming( + messages, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + on_delta, + should_cancel, + ) + .await?); + } + + let provider = build_active_llm_provider(llm_thinking_enabled)?; + Ok(provider + .answer_chat_streaming( + messages, + working_languages, + chinese_script_preference, + output_language_preference, + front_app, + on_delta, + should_cancel, + ) + .await?) +} + +/// 读 Gemini 凭据。所有 LLM provider 共用 ark.* 槽位(persistence 没做 per-provider +/// 隔离),所以这里也是从 `ArkApiKey` / `ArkModelId` / `ArkEndpoint` 三个槽读, +/// 但回退默认值改成谷歌的:base_url 默认 `https://generativelanguage.googleapis.com/v1beta`, +/// 模型默认 `gemini-2.5-flash`。Settings.tsx::onLlmProviderChange 在用户切到 gemini +/// 时会强制把 endpoint/model 覆盖为这两个默认值,所以 99% 情况下槽里读出来就是 +/// 这两个;这里的 `unwrap_or_else` 是给极端情况兜底(如旧版本切换 bug 留下的脏数据)。 +/// +/// base_url 末尾去掉 `/`,让 `llm_gemini::generate_content_url` 拼接稳定。 +/// 不去 `/chat/completions` 后缀——OpenAI 兼容路径才会有那个后缀,原生 Gemini 不会。 +fn read_gemini_credentials() -> anyhow::Result<(String, String, String)> { + let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); + let model = CredentialsVault::get(CredentialAccount::ArkModelId)? + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "gemini-2.5-flash".to_string()); + let base_url = CredentialsVault::get(CredentialAccount::ArkEndpoint)? + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| "https://generativelanguage.googleapis.com/v1beta".to_string()); + if api_key.trim().is_empty() { + anyhow::bail!("API Key 为空"); + } + let base_url = base_url.trim_end_matches('/').to_string(); + Ok((api_key, model, base_url)) +} + +fn build_active_llm_provider(llm_thinking_enabled: bool) -> anyhow::Result { + let active = CredentialsVault::get_active_llm(); + let model = + CredentialsVault::get(CredentialAccount::ArkModelId)?.filter(|s| !s.trim().is_empty()); + if active == CODEX_OAUTH_PROVIDER_ID { + let config = + CodexOAuthConfig::new(model.unwrap_or_else(|| CODEX_DEFAULT_MODEL.to_string())) + .with_thinking_enabled(llm_thinking_enabled); + return Ok(ActiveLLMProvider::Codex(CodexOAuthLLMProvider::new(config))); + } + + let api_key = CredentialsVault::get(CredentialAccount::ArkApiKey)?.unwrap_or_default(); + let model = model.unwrap_or_else(|| "deepseek-v3-2".to_string()); + let endpoint = resolve_ark_endpoint(&api_key)?; + let base_url = endpoint + .trim_end_matches("/chat/completions") + .trim_end_matches('/') + .to_string(); + let config = OpenAICompatibleConfig::new(active, "OpenLess LLM", base_url, api_key, model) + .with_thinking_enabled(llm_thinking_enabled); + Ok(ActiveLLMProvider::OpenAI(OpenAICompatibleLLMProvider::new( + config, + ))) +} + +fn resolve_ark_endpoint(api_key: &str) -> anyhow::Result { + let endpoint = CredentialsVault::get(CredentialAccount::ArkEndpoint)?.filter(|s| !s.is_empty()); + resolve_ark_endpoint_with_policy(api_key, endpoint) +} + +fn resolve_ark_endpoint_with_policy( + api_key: &str, + endpoint: Option, +) -> anyhow::Result { + if api_key.trim().is_empty() && endpoint.is_none() { + anyhow::bail!("API Key 为空"); + } + Ok(endpoint + .unwrap_or_else(|| "https://ark.cn-beijing.volces.com/api/v3/chat/completions".to_string())) +} + +#[cfg(test)] +mod tests { + use super::dictation::abort_recording_with_error; + use super::*; + use crate::types::{HotkeyMode, HotkeyTrigger}; + use once_cell::sync::Lazy; + + static ENV_LOCK: Lazy> = Lazy::new(|| tokio::sync::Mutex::new(())); + + fn session_id(n: u128) -> SessionId { + Uuid::from_u128(n) + } + + #[test] + fn split_polish_translate_parses_both_sections() { + let out = format!( + "{POLISH_TRANSLATE_SRC_MARKER}\n你好,世界。\n{POLISH_TRANSLATE_TGT_MARKER}\nHello, world." + ); + let (source, translation) = split_polish_translate_output(&out).expect("both markers"); + assert_eq!(source.as_deref(), Some("你好,世界。")); + assert_eq!(translation, "Hello, world."); + } + + #[test] + fn split_polish_translate_no_translation_marker_returns_none_for_fallback() { + // 完全没有译文标记 → None,调用方据此退回专用翻译拿干净译文。 + assert_eq!(split_polish_translate_output(" Hello, world. "), None); + } + + #[test] + fn split_polish_translate_empty_translation_returns_none_for_fallback() { + // 有译文标记但内容为空(截断 / 只吐标记)→ None,避免空串当成功译文插入光标。 + let out = + format!("{POLISH_TRANSLATE_SRC_MARKER}\n你好。\n{POLISH_TRANSLATE_TGT_MARKER}\n "); + assert_eq!(split_polish_translate_output(&out), None); + } + + #[test] + fn split_polish_translate_only_translation_marker_keeps_clean_translation() { + let out = format!("noise{POLISH_TRANSLATE_TGT_MARKER}\nHola"); + let (source, translation) = split_polish_translate_output(&out).expect("tgt marker"); + assert_eq!(source, None); + assert_eq!(translation, "Hola"); + } + + #[test] + fn split_polish_translate_empty_source_section_is_none() { + let out = format!("{POLISH_TRANSLATE_SRC_MARKER}\n \n{POLISH_TRANSLATE_TGT_MARKER}\nHi"); + let (source, translation) = split_polish_translate_output(&out).expect("tgt marker"); + assert_eq!(source, None); + assert_eq!(translation, "Hi"); + } + + #[test] + fn build_polish_translate_prompt_contains_markers_and_target() { + let p = build_polish_translate_system_prompt("日本語"); + assert!(p.contains(POLISH_TRANSLATE_SRC_MARKER)); + assert!(p.contains(POLISH_TRANSLATE_TGT_MARKER)); + assert!(p.contains("日本語")); + } + + #[tokio::test] + async fn hotkey_injection_gate_logs_pressed_and_cancels() { + let _ = env_logger::builder() + .filter_level(log::LevelFilter::Info) + .is_test(false) + .try_init(); + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + coordinator.inject_hotkey_click_for_dev().await.unwrap(); + + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + + /// 复现并验证目标 2(a):按下 Less Computer 键必须弹出可见胶囊。 + /// 这里直接驱动 bridge 会调用的 handler,断言 begin_session 确实下发了可见胶囊。 + #[tokio::test] + async fn less_computer_press_emits_visible_capsule() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + { + let mut prefs = coordinator.inner.prefs.get(); + prefs.coding_agent_enabled = true; + coordinator.inner.prefs.set(prefs).unwrap(); + } + // 前置:还没弹过任何胶囊。 + assert!(coordinator.inner.last_capsule_state.lock().is_none()); + + // 等价于「按下 Less Computer 键」:bridge_loop 收到 Pressed 后就是调这个 handler。 + super::handle_less_computer_pressed(&coordinator.inner).await; + + assert_eq!( + *coordinator.inner.last_capsule_state.lock(), + Some(CapsuleState::Recording), + "按下 Less Computer 键必须进入录音并弹出可见胶囊" + ); + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + + #[tokio::test] + async fn begin_session_dry_run_enters_listening_and_clears_stale_edges() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + let old_session_id = coordinator.inner.state.lock().session_id; + { + let mut state = coordinator.inner.state.lock(); + state.pending_stop = true; + state.cancelled = true; + } + + coordinator.start_dictation().await.unwrap(); + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, SessionPhase::Listening); + assert!(!state.pending_stop); + assert!(!state.cancelled); + assert_ne!(state.session_id, old_session_id); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + + #[tokio::test] + async fn begin_session_ignores_non_idle_phase() { + let _guard = ENV_LOCK.lock().await; + std::env::set_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN", "1"); + + let coordinator = Coordinator::new(); + let old_session_id = { + let mut state = coordinator.inner.state.lock(); + state.phase = SessionPhase::Processing; + state.session_id = session_id(99); + state.session_id + }; + + coordinator.start_dictation().await.unwrap(); + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, SessionPhase::Processing); + assert_eq!(state.session_id, old_session_id); + + std::env::remove_var("OPENLESS_HOTKEY_INJECTION_DRY_RUN"); + } + + #[test] + fn window_key_matcher_mirrors_windows_trigger_aliases() { + let cases = [ + (HotkeyTrigger::RightControl, "Control", "ControlRight"), + (HotkeyTrigger::LeftControl, "Control", "ControlLeft"), + (HotkeyTrigger::RightOption, "Alt", "AltRight"), + (HotkeyTrigger::RightAlt, "AltGraph", "AltRight"), + (HotkeyTrigger::RightCommand, "Meta", "MetaRight"), + (HotkeyTrigger::LeftOption, "Alt", "AltLeft"), + // Mirrors Windows trigger_to_vk_code aliases. + (HotkeyTrigger::Fn, "Control", "ControlRight"), + ]; + for (trigger, key, code) in cases { + assert!( + window_key_matches_trigger(trigger, key, code), + "{trigger:?} should match {key}/{code}" + ); + } + + assert!(!window_key_matches_trigger( + HotkeyTrigger::RightControl, + "Control", + "ControlLeft" + )); + assert!(!window_key_matches_trigger( + HotkeyTrigger::LeftOption, + "Alt", + "AltRight" + )); + assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn")); + } + + #[test] + fn windows_local_providers_are_keyless_and_not_whisper_compatible() { + #[cfg(target_os = "windows")] + assert!(is_keyless_local_asr_provider( + crate::asr::local::foundry::PROVIDER_ID + )); + #[cfg(target_os = "windows")] + assert!(is_keyless_local_asr_provider( + crate::asr::local::sherpa::PROVIDER_ID + )); + #[cfg(not(target_os = "windows"))] + assert!(!is_keyless_local_asr_provider( + crate::asr::local::foundry::PROVIDER_ID + )); + #[cfg(not(target_os = "windows"))] + assert!(!is_keyless_local_asr_provider( + crate::asr::local::sherpa::PROVIDER_ID + )); + assert!(!is_whisper_compatible_provider( + crate::asr::local::foundry::PROVIDER_ID + )); + assert!(!is_whisper_compatible_provider( + crate::asr::local::sherpa::PROVIDER_ID + )); + assert!(!is_whisper_compatible_provider( + crate::asr::mimo::PROVIDER_ID + )); + } + + #[test] + fn verbose_json_enabled_only_for_whisper_family() { + // verbose_json + 幻听过滤只对返回完整 Whisper 指标的 provider 开启。 + assert!(whisper_supports_verbose_json("whisper")); + assert!(whisper_supports_verbose_json("groq")); + // SiliconFlow(SenseVoice/TeleSpeech) / Zhipu(GLM-ASR) 保持旧的 json 行为。 + assert!(!whisper_supports_verbose_json("siliconflow")); + assert!(!whisper_supports_verbose_json("zhipu")); + } + + #[test] + fn openrouter_is_whisper_compatible_json_provider() { + use crate::asr::whisper::AsrRequestFormat; + // issue #582:OpenRouter 走 whisper 兼容路由,但请求体是 JSON+base64。 + assert!(is_whisper_compatible_provider("openrouter")); + assert_eq!( + whisper_request_format("openrouter"), + AsrRequestFormat::OpenRouterJson + ); + // 其余兼容厂商保持 multipart。 + assert_eq!( + whisper_request_format("whisper"), + AsrRequestFormat::Multipart + ); + assert_eq!(whisper_request_format("groq"), AsrRequestFormat::Multipart); + // OpenRouter 的 JSON 协议不吃 response_format,verbose_json 保持关闭。 + assert!(!whisper_supports_verbose_json("openrouter")); + // base64 膨胀,长录音保守按 30s 切分。 + assert_eq!(batch_asr_chunk_limit_ms("openrouter"), Some(30_000)); + } + + #[test] + fn qa_asr_provider_kind_tracks_active_provider() { + assert_eq!( + active_asr_provider_kind(crate::asr::bailian::PROVIDER_ID), + ActiveAsrProviderKind::Bailian + ); + assert_eq!( + active_asr_provider_kind("whisper"), + ActiveAsrProviderKind::WhisperCompatible + ); + assert_eq!( + active_asr_provider_kind(crate::asr::mimo::PROVIDER_ID), + ActiveAsrProviderKind::Mimo + ); + assert_eq!( + active_asr_provider_kind("volcengine"), + ActiveAsrProviderKind::Volcengine + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn coordinator_shares_app_foundry_runtime() { + let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new()); + let coordinator = Coordinator::new_with_foundry_runtime(Arc::clone(&runtime)); + + assert!(Arc::ptr_eq( + &runtime, + &coordinator.inner.foundry_local_runtime + )); + } + + #[cfg(target_os = "windows")] + #[test] + fn foundry_transcribe_skips_global_timeout_for_first_run_provisioning() { + let provider = Arc::new(crate::asr::local::FoundryLocalWhisperAsr::new( + Arc::new(crate::asr::local::FoundryLocalRuntime::new()), + crate::asr::local::foundry::DEFAULT_MODEL_ALIAS.to_string(), + "auto".to_string(), + None, + )); + let active_asr = ActiveAsr::FoundryLocalWhisper(provider); + + assert!(!asr_transcribe_uses_global_timeout(&active_asr)); + } + + #[cfg(target_os = "windows")] + #[test] + fn foundry_audio_transcribe_timeout_is_separate_from_prepare() { + let timeout = foundry_audio_transcribe_timeout_duration(); + + assert_eq!( + timeout, + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) + ); + } + + #[test] + fn local_qwen_timeout_floors_at_global_timeout_for_short_audio() { + // 5s 录音:5 × 0.6 = 3, +10 = 13, max(15) = 15。短录音保留 15s 兜底。 + assert_eq!( + local_qwen_transcribe_timeout(5.0), + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) + ); + } + + #[test] + fn local_qwen_timeout_scales_with_audio_duration() { + // 60s 录音:60 × 0.6 = 36, +10 = 46s。覆盖 RTF ≈ 0.5 的边界。 + assert_eq!( + local_qwen_transcribe_timeout(60.0), + std::time::Duration::from_secs(46) + ); + } + + #[test] + fn local_qwen_timeout_ceils_partial_seconds() { + // 10.1s 录音:10.1 × 0.6 = 6.06, ceil = 7, +10 = 17, max(15) = 17。 + assert_eq!( + local_qwen_transcribe_timeout(10.1), + std::time::Duration::from_secs(17) + ); + } + + #[test] + fn local_qwen_timeout_handles_zero_duration() { + // 0 时长(空 buffer 边界):0 × 0.6 = 0, +10 = 10, max(15) = 15。 + assert_eq!( + local_qwen_transcribe_timeout(0.0), + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn foundry_release_uses_foundry_keep_loaded_preference() { + let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new()); + let coordinator = Coordinator::new_with_foundry_runtime(runtime); + let mut prefs = coordinator.inner.prefs.get(); + prefs.local_asr_keep_loaded_secs = 3; + prefs.foundry_local_asr_keep_loaded_secs = 7; + coordinator.inner.prefs.set(prefs).unwrap(); + + assert_eq!(foundry_local_asr_release_keep_secs(&coordinator.inner), 7); + } + + #[cfg(target_os = "windows")] + #[test] + fn foundry_release_guard_rejects_stale_dictation_session() { + let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new()); + let coordinator = Coordinator::new_with_foundry_runtime(runtime); + let old_session_id = coordinator.inner.state.lock().session_id; + + assert!(asr_release_session_is_current( + &coordinator.inner, + AsrReleaseSession::Dictation(old_session_id) + )); + + coordinator.inner.state.lock().session_id = new_session_id(); + + assert!(!asr_release_session_is_current( + &coordinator.inner, + AsrReleaseSession::Dictation(old_session_id) + )); + } + + #[cfg(target_os = "windows")] + #[test] + fn local_asr_release_guard_rejects_stale_qa_session() { + let runtime = Arc::new(crate::asr::local::FoundryLocalRuntime::new()); + let coordinator = Coordinator::new_with_foundry_runtime(runtime); + let old_session_id = coordinator.inner.qa_state.lock().session_id; + + assert!(asr_release_session_is_current( + &coordinator.inner, + AsrReleaseSession::Qa(old_session_id) + )); + + coordinator.inner.qa_state.lock().session_id = new_session_id(); + + assert!(!asr_release_session_is_current( + &coordinator.inner, + AsrReleaseSession::Qa(old_session_id) + )); + } + + #[test] + fn resolve_ark_endpoint_rejects_blank_key_without_custom_endpoint() { + assert_eq!( + resolve_ark_endpoint_with_policy("", None) + .unwrap_err() + .to_string(), + "API Key 为空" + ); + } + + #[test] + fn resolve_ark_endpoint_allows_blank_key_with_custom_endpoint() { + let endpoint = resolve_ark_endpoint_with_policy( + "", + Some("https://example.com/v1/chat/completions".to_string()), + ) + .unwrap(); + assert_eq!(endpoint, "https://example.com/v1/chat/completions"); + } + + #[test] + fn deferred_asr_bridge_flushes_startup_audio_before_live_chunks() { + #[derive(Default)] + struct RecordingConsumer { + bytes: Mutex>, + } + + impl crate::asr::AudioConsumer for RecordingConsumer { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + self.bytes.lock().extend_from_slice(pcm); + } + } + + let bridge = DeferredAsrBridge::new(); + crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[1, 2]); + crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[3, 4]); + + let target = Arc::new(RecordingConsumer::default()); + let target_for_attach: Arc = target.clone(); + assert_eq!(bridge.attach(target_for_attach), 4); + + crate::recorder::AudioConsumer::consume_pcm_chunk(&bridge, &[5, 6]); + assert_eq!(&*target.bytes.lock(), &[1, 2, 3, 4, 5, 6]); + } + + #[tokio::test] + async fn manual_stop_during_starting_is_queued() { + let coordinator = Coordinator::new(); + { + let mut state = coordinator.inner.state.lock(); + state.phase = SessionPhase::Starting; + state.pending_stop = false; + } + + coordinator.stop_dictation().await.unwrap(); + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, SessionPhase::Starting); + assert!(state.pending_stop); + } + + #[tokio::test] + async fn stop_dictation_from_listening_without_asr_returns_idle() { + let coordinator = Coordinator::new(); + { + let mut state = coordinator.inner.state.lock(); + state.phase = SessionPhase::Listening; + state.session_id = session_id(123); + } + + coordinator.stop_dictation().await.unwrap(); + + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + } + + #[test] + fn cancel_session_state_machine_is_table_driven() { + let cases = [ + (SessionPhase::Idle, SessionPhase::Idle, false), + (SessionPhase::Starting, SessionPhase::Idle, true), + (SessionPhase::Listening, SessionPhase::Idle, true), + (SessionPhase::Processing, SessionPhase::Processing, true), + (SessionPhase::Inserting, SessionPhase::Inserting, false), + ]; + + for (initial, expected_phase, expected_cancelled) in cases { + let coordinator = Coordinator::new(); + { + let mut state = coordinator.inner.state.lock(); + state.phase = initial; + state.cancelled = false; + state.focus_target = Some(1); + } + + coordinator.cancel_dictation(); + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, expected_phase, "initial={initial:?}"); + assert_eq!(state.cancelled, expected_cancelled, "initial={initial:?}"); + if matches!(initial, SessionPhase::Starting | SessionPhase::Listening) { + assert!(state.focus_target.is_none(), "initial={initial:?}"); + } + } + } + + #[test] + fn recorder_runtime_error_aborts_active_session() { + let coordinator = Coordinator::new(); + { + let mut state = coordinator.inner.state.lock(); + state.phase = SessionPhase::Listening; + state.cancelled = false; + } + + abort_recording_with_error(&coordinator.inner, "录音中断: stream failed".to_string()); + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, SessionPhase::Idle); + assert!(state.cancelled); + assert!(coordinator.inner.recorder.lock().is_none()); + assert!(coordinator.inner.asr.lock().is_none()); + } + + #[test] + fn abort_recording_keeps_session_non_idle_until_restore_can_run() { + let mut state = SessionState::default(); + state.phase = SessionPhase::Listening; + state.cancelled = false; + state.session_id = session_id(7); + + let abort = begin_recording_abort_before_restore(&mut state).unwrap(); + + assert_eq!(abort.session_id, session_id(7)); + assert!(state.cancelled); + assert_eq!(state.phase, SessionPhase::Listening); + + publish_abort_idle_after_restore(&mut state, abort.session_id); + + assert_eq!(state.phase, SessionPhase::Idle); + } + + #[tokio::test] + async fn pressed_edge_during_inserting_does_not_start_new_session() { + let coordinator = Coordinator::new(); + { + let mut state = coordinator.inner.state.lock(); + state.phase = SessionPhase::Inserting; + state.session_id = session_id(41); + } + + handle_pressed_edge(&coordinator.inner).await; + + let state = coordinator.inner.state.lock(); + assert_eq!(state.phase, SessionPhase::Inserting); + assert_eq!(state.session_id, session_id(41)); + } + + #[tokio::test] + async fn repeated_pressed_edge_during_hold_session_does_not_restart() { + let coordinator = Coordinator::new(); + coordinator + .inner + .prefs + .set(crate::types::UserPreferences { + hotkey: crate::types::HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Hold, + keys: None, + }, + ..Default::default() + }) + .unwrap(); + coordinator.inner.state.lock().phase = SessionPhase::Listening; + coordinator + .inner + .hotkey_trigger_held + .store(true, Ordering::SeqCst); + + handle_pressed_edge(&coordinator.inner).await; + + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + } + + #[test] + fn enabling_shortcut_recording_clears_dictation_hold_latch() { + let coordinator = Coordinator::new(); + coordinator + .inner + .hotkey_trigger_held + .store(true, Ordering::SeqCst); + + coordinator.set_shortcut_recording_active(true); + + assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + } + + #[test] + fn window_hotkey_fallback_is_disabled_when_no_explicit_fallback_is_advertised() { + assert_eq!( + window_hotkey_fallback_enabled(), + crate::types::HotkeyCapability::current().explicit_fallback_available + ); + } + + #[test] + fn capsule_show_strategy_matches_platform_activation_contract() { + // 平台列表必须与 capsule_show_strategy_for_platform 的 cfg 完全一致: + // 改实现里的 #[cfg] 时,一并改这两个 #[cfg],否则 Linux CI 直接红 + // (fcitx5 PR #451 把 Linux 加进 NoActivate 但漏改本测试,CI 失败)。 + #[cfg(any(target_os = "macos", target_os = "windows"))] + assert_eq!( + capsule_show_strategy_for_platform(), + CapsuleShowStrategy::NoActivate + ); + + #[cfg(not(any(target_os = "macos", target_os = "windows")))] + assert_eq!( + capsule_show_strategy_for_platform(), + CapsuleShowStrategy::FallbackShow + ); + } + + #[test] + #[cfg(target_os = "windows")] + fn prepared_windows_ime_slot_is_taken_only_for_matching_session() { + let mut slots = vec![PreparedWindowsImeSessionSlot { + session_id: session_id(2), + prepared: PreparedWindowsImeSession::unavailable(), + }]; + + assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(1)).is_none()); + assert_eq!( + slots.iter().map(|slot| slot.session_id).collect::>(), + vec![session_id(2)] + ); + + assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(2)).is_some()); + assert!(slots.is_empty()); + } + + #[test] + #[cfg(target_os = "windows")] + fn prepared_windows_ime_sessions_keep_overlapping_snapshots() { + let mut slots = Vec::new(); + store_prepared_windows_ime_session( + &mut slots, + session_id(1), + PreparedWindowsImeSession::unavailable(), + ); + store_prepared_windows_ime_session( + &mut slots, + session_id(2), + PreparedWindowsImeSession::unavailable(), + ); + + assert_eq!( + slots.iter().map(|slot| slot.session_id).collect::>(), + vec![session_id(1), session_id(2)] + ); + + assert!(take_matching_prepared_windows_ime_session(&mut slots, session_id(1)).is_some()); + assert_eq!( + slots.iter().map(|slot| slot.session_id).collect::>(), + vec![session_id(2)] + ); + } + + #[test] + #[cfg(target_os = "windows")] + fn stale_prepared_windows_ime_restore_discards_old_snapshot_without_restoring() { + let mut slots = Vec::new(); + store_prepared_windows_ime_session( + &mut slots, + session_id(1), + PreparedWindowsImeSession::unavailable(), + ); + store_prepared_windows_ime_session( + &mut slots, + session_id(2), + PreparedWindowsImeSession::unavailable(), + ); + + assert!(take_current_prepared_windows_ime_session_for_restore( + &mut slots, + session_id(1), + session_id(2) + ) + .is_none()); + assert_eq!( + slots.iter().map(|slot| slot.session_id).collect::>(), + vec![session_id(2)] + ); + } + + #[test] + #[cfg(target_os = "windows")] + fn non_tsf_insertion_fallback_gate_blocks_only_when_disabled() { + assert!(should_try_non_tsf_insertion_fallback( + true, + InsertStatus::CopiedFallback + )); + assert!(should_try_non_tsf_insertion_fallback( + true, + InsertStatus::Failed + )); + assert!(!should_try_non_tsf_insertion_fallback( + true, + InsertStatus::Inserted + )); + assert!(!should_try_non_tsf_insertion_fallback( + false, + InsertStatus::CopiedFallback + )); + assert!(!should_try_non_tsf_insertion_fallback( + false, + InsertStatus::Failed + )); + } + + #[test] + fn focus_restore_failure_uses_specific_error_code_when_insert_fails() { + assert_eq!( + dictation_error_code(InsertStatus::Failed, false, false, false), + Some("focusRestoreFailed") + ); + } + + #[test] + #[cfg(target_os = "windows")] + fn missing_windows_hwnd_is_not_present() { + use windows::Win32::Foundation::HWND; + + assert!(!windows_hwnd_is_present(HWND::default())); + } + + #[test] + #[cfg(target_os = "windows")] + fn tsf_required_failure_keeps_tsf_error_when_focus_was_ready() { + assert_eq!( + dictation_error_code(InsertStatus::Failed, false, true, false), + Some("windowsImeTsfRequired") + ); + } + + #[test] + fn startup_race_check_treats_newer_session_as_stale() { + let mut state = SessionState::default(); + state.phase = SessionPhase::Starting; + state.cancelled = false; + state.session_id = session_id(2); + + assert_eq!( + startup_race_status(&state, session_id(1)), + StartupRaceStatus::StaleContinuation + ); + } + + #[test] + fn startup_race_check_is_table_driven_for_begin_session_edges() { + let cases = [ + ( + SessionPhase::Starting, + false, + session_id(7), + StartupRaceStatus::ActiveStarting, + ), + ( + SessionPhase::Starting, + true, + session_id(7), + StartupRaceStatus::CancelRaced, + ), + ( + SessionPhase::Idle, + false, + session_id(7), + StartupRaceStatus::CancelRaced, + ), + ( + SessionPhase::Listening, + false, + session_id(7), + StartupRaceStatus::CancelRaced, + ), + ( + SessionPhase::Starting, + false, + session_id(8), + StartupRaceStatus::StaleContinuation, + ), + ]; + + for (phase, cancelled, actual_session_id, expected) in cases { + let mut state = SessionState::default(); + state.phase = phase; + state.cancelled = cancelled; + state.session_id = actual_session_id; + + assert_eq!( + startup_race_status(&state, session_id(7)), + expected, + "phase={phase:?} cancelled={cancelled} actual_session={actual_session_id}" + ); + } + } + + #[test] + fn begin_recording_abort_is_noop_after_prior_cancel_or_idle() { + let cases = [ + (SessionPhase::Idle, false), + (SessionPhase::Processing, false), + (SessionPhase::Listening, true), + ]; + + for (phase, cancelled) in cases { + let mut state = SessionState::default(); + state.phase = phase; + state.cancelled = cancelled; + + assert!(begin_recording_abort_before_restore(&mut state).is_none()); + assert_eq!(state.phase, phase); + assert_eq!(state.cancelled, cancelled); + } + } + + #[test] + fn stale_startup_cleanup_keeps_newer_asr_resource() { + let coordinator = Coordinator::new(); + let newer_asr = Arc::new(WhisperBatchASR::new( + "key".to_string(), + "http://localhost".to_string(), + "model".to_string(), + None, + None, + false, + )); + *coordinator.inner.asr.lock() = Some(SessionResource::new( + session_id(2), + ActiveAsr::Whisper(Arc::clone(&newer_asr)), + )); + + discard_startup_resources_for_session(&coordinator.inner, session_id(1)); + + assert_eq!( + coordinator + .inner + .asr + .lock() + .as_ref() + .map(|resource| resource.session_id), + Some(session_id(2)) + ); + + discard_startup_resources_for_session(&coordinator.inner, session_id(2)); + + assert!(coordinator.inner.asr.lock().is_none()); + } +} + +fn enabled_phrases(inner: &Arc) -> Vec { + inner + .vocab + .list() + .unwrap_or_default() + .into_iter() + .filter(|e| e.enabled) + .map(|e| e.phrase) + .collect() +} + +/// 终止态(Done / Cancelled / Error)后延迟 N ms 把胶囊改回 Idle,让浮窗自动消失。 +/// 用户点 ✕ / ✓ / 中途出错 / 按 Esc 都走这里,统一 2 秒。 +const CAPSULE_AUTO_HIDE_DELAY_MS: u64 = 2000; + +/// Toggle 模式下,end_session 将 phase 设为 Idle 后在此时间内禁止新的 begin_session。 +/// 避免用户三连按时第 3 次按下误激活新听写(此时胶囊仍在离场动画周期内)。 +/// 值取 capsule EXIT_ANIM_MS (360ms) + 余量 ≈ 600ms。 +const POST_SESSION_COOLDOWN_MS: u64 = 600; + +/// Coordinator 全局超时保护:防止 ASR await_final_result() 永远挂起。 +/// 设置为 15 秒(比 ASR 的 12 秒 FINAL_RESULT_TIMEOUT 稍长), +/// 只在 ASR 超时机制失效时作为最后的防线触发。 +const COORDINATOR_GLOBAL_TIMEOUT_SECS: u64 = 15; + +#[cfg(target_os = "windows")] +fn foundry_audio_transcribe_timeout_duration() -> std::time::Duration { + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) +} + +/// 本地 Qwen3-ASR 的动态转写超时。固定 15 秒在长录音(≥ 30s)+ 慢机器 +/// (RTF ≈ 0.3–0.5)上必然超时把整段内容丢掉。改用 max(15, ceil(audio_s +/// × 0.6) + 10):基础保留 15s 兜住短录音;长录音按音频长度的 0.6 倍 + +/// 10s 余量,覆盖 RTF ≤ 0.5 的机器。 +fn local_qwen_transcribe_timeout(audio_secs: f64) -> std::time::Duration { + let secs = ((audio_secs * 0.6).ceil() as u64) + .saturating_add(10) + .max(COORDINATOR_GLOBAL_TIMEOUT_SECS); + std::time::Duration::from_secs(secs) +} + +/// sherpa-onnx offline batch 暂与 Foundry 同档;后续按 Windows 真机 CPU/模型 +/// 实测结果再调整。 +#[cfg(target_os = "windows")] +fn sherpa_audio_transcribe_timeout_duration() -> std::time::Duration { + std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS) +} + +pub(crate) fn validate_llm_endpoint(raw: &str) -> anyhow::Result<()> { + use std::net::IpAddr; + + let url = + url::Url::parse(raw).map_err(|e| anyhow::anyhow!("LLM endpoint 不是合法 URL:{e}"))?; + let host = url + .host_str() + .ok_or_else(|| anyhow::anyhow!("LLM endpoint 缺少主机名"))? + .to_ascii_lowercase(); + + const METADATA_HOSTS: [&str; 2] = ["metadata.google.internal", "169.254.169.254"]; + if METADATA_HOSTS.iter().any(|m| host.contains(m)) { + anyhow::bail!("LLM endpoint 指向云元数据服务,已拒绝:{host}"); + } + + let scheme = url.scheme(); + let bare_host = host + .strip_prefix('[') + .and_then(|h| h.strip_suffix(']')) + .unwrap_or(host.as_str()); + + let Ok(ip) = bare_host.parse::() else { + if bare_host == "localhost" { + return Ok(()); + } + if scheme != "https" { + anyhow::bail!("LLM endpoint 必须使用 https(仅 localhost / 局域网允许 http):{raw}"); + } + return Ok(()); + }; + + let canonical = match ip { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map(IpAddr::V4).unwrap_or(ip), + v4 => v4, + }; + + let is_lan = match canonical { + IpAddr::V4(v4) => ip_v4_is_lan(v4), + IpAddr::V6(v6) => ip_v6_is_lan(v6), + }; + if is_lan { + return Ok(()); + } + + let is_blocked = match canonical { + IpAddr::V4(v4) => ip_v4_is_blocked(v4), + IpAddr::V6(v6) => ip_v6_is_blocked(v6), + }; + if is_blocked { + anyhow::bail!("LLM endpoint 指向保留/危险地址,已拒绝(防 SSRF):{ip}"); + } + + if scheme != "https" { + anyhow::bail!("LLM endpoint 必须使用 https(仅 localhost / 局域网允许 http):{raw}"); + } + + Ok(()) +} + +fn ip_v4_is_lan(ip: std::net::Ipv4Addr) -> bool { + ip.is_loopback() || ip.is_private() +} + +fn ip_v4_is_blocked(ip: std::net::Ipv4Addr) -> bool { + let octets = ip.octets(); + let is_cgnat = octets[0] == 100 && (64..=127).contains(&octets[1]); + ip.is_link_local() || ip.is_unspecified() || ip.is_broadcast() || is_cgnat +} + +fn ip_v6_is_lan(ip: std::net::Ipv6Addr) -> bool { + let segs = ip.segments(); + let is_ula = (segs[0] & 0xfe00) == 0xfc00; + ip.is_loopback() || is_ula +} + +fn ip_v6_is_blocked(ip: std::net::Ipv6Addr) -> bool { + let segs = ip.segments(); + let is_link_local = (segs[0] & 0xffc0) == 0xfe80; + ip.is_unspecified() || is_link_local +} + +/// 检查 begin_session 的 await 间隙是否被 cancel_session 打断。 +/// 必须在持有 state lock 的瞬间读,结果一拿就过期,所以用 helper 名字提醒只在 +/// 「准备做下一步副作用前」用。 +fn startup_race_status_for_starting( + inner: &Arc, + captured_session_id: SessionId, +) -> StartupRaceStatus { + let state = inner.state.lock(); + startup_race_status(&state, captured_session_id) +} + +fn set_phase_idle_if_session_matches(inner: &Arc, session_id: SessionId) { + let mut state = inner.state.lock(); + if state.session_id == session_id { + state.phase = SessionPhase::Idle; + } +} + +fn schedule_capsule_idle(inner: &Arc, delay_ms: u64) { + let inner_clone = Arc::clone(inner); + async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; + // 必须 dictation **和** QA 同时空闲才能隐藏胶囊。否则旧 dictation Done timer + // 的尾巴会在新 QA 录音/思考中把胶囊意外收掉(issue #118 v2 复现)。 + let dictation_idle = inner_clone.state.lock().phase == SessionPhase::Idle; + let qa_idle = inner_clone.qa_state.lock().phase == QaPhase::Idle; + if dictation_idle && qa_idle { + emit_capsule(&inner_clone, CapsuleState::Idle, 0.0, 0, None, None); + } + }); +} + +/// 与 capture_focus_target 类似,但前台窗口属于本进程(即用户停在 QA / capsule / main +/// 等自家窗口)时返回 None,让 caller 区分"用户没切到别处" vs "用户切到了另一个真正的 +/// 外部 app"。issue #466 多轮场景下用来刷新 qa_focus_target。 +#[cfg(target_os = "windows")] +fn capture_external_focus_target() -> Option { + use windows::Win32::System::Threading::GetCurrentProcessId; + use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId}; + + unsafe { + let hwnd = GetForegroundWindow(); + if hwnd.0.is_null() { + return None; + } + let mut pid: u32 = 0; + GetWindowThreadProcessId(hwnd, Some(&mut pid)); + if pid == GetCurrentProcessId() { + return None; + } + Some(hwnd.0 as usize) + } +} + +#[cfg(not(target_os = "windows"))] +fn capture_external_focus_target() -> Option { + None +} + +#[cfg(target_os = "windows")] +fn capture_focus_target() -> Option { + use windows::Win32::UI::WindowsAndMessaging::GetForegroundWindow; + + let foreground = unsafe { GetForegroundWindow() }; + if foreground.0.is_null() { + None + } else { + Some(foreground.0 as usize) + } +} + +#[cfg(not(target_os = "windows"))] +fn capture_focus_target() -> Option { + None +} + +/// 捕获用户开始 dictation 时的前台 app 标签("localizedName (bundle.id)"),用作 LLM +/// polish/translate 的上下文前提,让模型按 app 调风格。详见 issue #116。 +/// +/// macOS 走 NSWorkspace.frontmostApplication(公开 API,无需额外权限); +/// Windows 复用前台 HWND 拿窗口标题;Linux/其他平台返回 None。 +#[cfg(target_os = "macos")] +fn capture_frontmost_app() -> Option { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + + unsafe { + let cls = AnyClass::get("NSWorkspace")?; + let workspace: *mut AnyObject = msg_send![cls, sharedWorkspace]; + if workspace.is_null() { + return None; + } + let app: *mut AnyObject = msg_send![workspace, frontmostApplication]; + if app.is_null() { + return None; + } + let name_obj: *mut AnyObject = msg_send![app, localizedName]; + let bundle_obj: *mut AnyObject = msg_send![app, bundleIdentifier]; + let name = nsstring_to_string(name_obj); + let bundle = nsstring_to_string(bundle_obj); + match (name, bundle) { + (Some(n), Some(b)) => Some(format!("{n} ({b})")), + (Some(n), None) => Some(n), + (None, Some(b)) => Some(b), + (None, None) => None, + } + } +} + +#[cfg(target_os = "macos")] +unsafe fn nsstring_to_string(ns_string: *mut objc2::runtime::AnyObject) -> Option { + use objc2::msg_send; + if ns_string.is_null() { + return None; + } + let utf8: *const std::os::raw::c_char = unsafe { msg_send![ns_string, UTF8String] }; + if utf8.is_null() { + return None; + } + let cstr = unsafe { std::ffi::CStr::from_ptr(utf8) }; + let s = cstr.to_string_lossy().into_owned(); + if s.is_empty() { + None + } else { + Some(s) + } +} + +#[cfg(target_os = "windows")] +fn capture_frontmost_app() -> Option { + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetWindowTextLengthW, GetWindowTextW, + }; + + unsafe { + let hwnd = GetForegroundWindow(); + if hwnd.0.is_null() { + return None; + } + let len = GetWindowTextLengthW(hwnd); + if len <= 0 { + return None; + } + let mut buf = vec![0u16; (len + 1) as usize]; + let copied = GetWindowTextW(hwnd, &mut buf); + if copied <= 0 { + return None; + } + let title = String::from_utf16_lossy(&buf[..copied as usize]); + if title.is_empty() { + None + } else { + Some(title) + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn capture_frontmost_app() -> Option { + None +} + +#[cfg(target_os = "windows")] +fn restore_focus_target_if_possible(target: Option) -> bool { + use std::ffi::c_void; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, IsIconic, IsWindow, SetForegroundWindow, ShowWindow, SW_RESTORE, + }; + + let Some(raw_target) = target else { + log::warn!("[coord] no original Windows insertion target captured"); + return false; + }; + let hwnd = HWND(raw_target as *mut c_void); + if hwnd.0.is_null() { + return false; + } + if !unsafe { IsWindow(hwnd).as_bool() } { + log::warn!("[coord] original Windows insertion target is no longer a valid window"); + return false; + } + + let foreground = unsafe { GetForegroundWindow() }; + if foreground == hwnd { + return true; + } + + if unsafe { IsIconic(hwnd).as_bool() } { + let _ = unsafe { ShowWindow(hwnd, SW_RESTORE) }; + } + let _ = unsafe { SetForegroundWindow(hwnd) }; + std::thread::sleep(std::time::Duration::from_millis(60)); + + let foreground = unsafe { GetForegroundWindow() }; + if foreground != hwnd { + log::warn!("[coord] failed to restore original Windows insertion target before paste"); + return false; + } + true +} + +#[cfg(not(target_os = "windows"))] +fn restore_focus_target_if_possible(_target: Option) -> bool { + true +} + +#[cfg(target_os = "windows")] +fn windows_hwnd_is_present(hwnd: windows::Win32::Foundation::HWND) -> bool { + hwnd != windows::Win32::Foundation::HWND::default() +} + +#[cfg(target_os = "windows")] +fn capture_ime_submit_target() -> Option { + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetGUIThreadInfo, GetWindowThreadProcessId, GUITHREADINFO, + }; + + let foreground = unsafe { GetForegroundWindow() }; + if !windows_hwnd_is_present(foreground) { + return None; + } + + let mut foreground_process_id = 0; + let foreground_thread_id = + unsafe { GetWindowThreadProcessId(foreground, Some(&mut foreground_process_id)) }; + if foreground_thread_id == 0 { + return None; + } + + let mut gui_info = GUITHREADINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let target_window = if unsafe { GetGUIThreadInfo(foreground_thread_id, &mut gui_info).is_ok() } + && windows_hwnd_is_present(gui_info.hwndFocus) + { + gui_info.hwndFocus + } else { + foreground + }; + + let mut process_id = 0; + let thread_id = unsafe { GetWindowThreadProcessId(target_window, Some(&mut process_id)) }; + if process_id == 0 || thread_id == 0 { + return None; + } + + Some(ImeSubmitTarget { + process_id, + thread_id, + }) +} + +// Windows topmost overlay 的已知 OS 级限制(issue #457): +// `SetWindowPos(HWND_TOPMOST)` 让 capsule 在普通桌面合成、最大化窗口、borderless +// windowed fullscreen 上正常叠加;但**对独占全屏(exclusive fullscreen)DirectX / +// OpenGL 应用无效** —— 那条路径绕过桌面合成器,标准 topmost 窗口不参与合成 → +// 用户看不见 capsule。这是 OS 层面的限制,用户空间无法绕过(除非接入 DirectX +// overlay,工程量与风险都不在 surgical 修复范围内)。 +// +// 用户侧 workaround:把游戏切到 borderless windowed fullscreen(Minecraft Java 默认 +// 即是;F11 在不同版本表现不一致,按设置里的「全屏」选项决定)。 +// +// 相关 UIPI 限制:若游戏以管理员身份运行而 OpenLess 不是,`WH_KEYBOARD_LL` 收不到 +// 游戏的按键 → hotkey 完全不触发。这里跟 SetWindowPos 路径无关,但同源不可绕过。 +#[cfg(target_os = "windows")] +fn show_capsule_window_no_activate( + _app: &AppHandle, + window: &tauri::WebviewWindow, +) -> bool { + use raw_window_handle::{HasWindowHandle, RawWindowHandle}; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + SetWindowPos, ShowWindow, HWND_TOPMOST, SWP_NOACTIVATE, SWP_NOMOVE, SWP_NOSIZE, + SWP_SHOWWINDOW, SW_SHOWNOACTIVATE, + }; + + let Ok(handle) = window.window_handle() else { + // #470 诊断 v2:Win32 show 路径最可能的暗点之一。此前静默 return, + // 无法观测「胶囊完全不显示」是否卡在这里。 + log::warn!( + "[capsule] no_activate failed: window_handle() unavailable — Win32 show skipped" + ); + return false; + }; + let RawWindowHandle::Win32(raw) = handle.as_raw() else { + log::warn!("[capsule] no_activate failed: non-Win32 RawWindowHandle — Win32 show skipped"); + return false; + }; + let hwnd = HWND(raw.hwnd.get() as *mut _); + + let _ = unsafe { ShowWindow(hwnd, SW_SHOWNOACTIVATE) }; + let _ = unsafe { + SetWindowPos( + hwnd, + HWND_TOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW, + ) + }; + true +} + +#[cfg(target_os = "macos")] +fn show_capsule_window_no_activate( + _app: &AppHandle, + window: &tauri::WebviewWindow, +) -> bool { + use objc2::msg_send; + use objc2::runtime::AnyObject; + + let Ok(handle) = window.ns_window() else { + return false; + }; + let ns_window = handle as *mut AnyObject; + if ns_window.is_null() { + return false; + } + + // emit_capsule 已经把窗口操作 marshal 到 Tauri 主线程;这里不能再调用 + // window.show()/set_focus()/NSApp.activate,否则 AeroSpace 会把 workspace 切回 + // OpenLess 主窗口所在空间。先让胶囊加入所有 Spaces,再用 + // orderFrontRegardless 做无激活展示。 + if let Err(e) = window.set_visible_on_all_workspaces(true) { + log::warn!("[capsule] set visible on all macOS Spaces failed: {e}"); + } + + unsafe { + const NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES: usize = 1 << 0; + const NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY: usize = 1 << 8; + let behavior: usize = msg_send![ns_window, collectionBehavior]; + let behavior = behavior + | NS_WINDOW_COLLECTION_BEHAVIOR_CAN_JOIN_ALL_SPACES + | NS_WINDOW_COLLECTION_BEHAVIOR_FULL_SCREEN_AUXILIARY; + let _: () = msg_send![ns_window, setCollectionBehavior: behavior]; + let _: () = msg_send![ns_window, orderFrontRegardless]; + } + true +} + +#[cfg(target_os = "linux")] +fn show_capsule_window_no_activate( + _app: &AppHandle, + _window: &tauri::WebviewWindow, +) -> bool { + true +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +fn show_capsule_window_no_activate( + _app: &AppHandle, + _window: &tauri::WebviewWindow, +) -> bool { + false +} + +#[cfg(target_os = "windows")] +fn hide_capsule_window_if_present() { + use std::iter::once; + use windows::core::PCWSTR; + use windows::Win32::Foundation::HWND; + use windows::Win32::UI::WindowsAndMessaging::{ + FindWindowW, SetWindowPos, ShowWindow, HWND_NOTOPMOST, SWP_HIDEWINDOW, SWP_NOACTIVATE, + SWP_NOMOVE, SWP_NOSIZE, SW_HIDE, + }; + + let title: Vec = "OpenLess Capsule".encode_utf16().chain(once(0)).collect(); + let hwnd = match unsafe { FindWindowW(PCWSTR::null(), PCWSTR(title.as_ptr())) } { + Ok(hwnd) => hwnd, + Err(_) => return, + }; + if hwnd == HWND::default() || hwnd.0.is_null() { + return; + } + + let _ = unsafe { ShowWindow(hwnd, SW_HIDE) }; + let _ = unsafe { + SetWindowPos( + hwnd, + HWND_NOTOPMOST, + 0, + 0, + 0, + 0, + SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_HIDEWINDOW, + ) + }; +} + +#[cfg(not(target_os = "windows"))] +fn hide_capsule_window_if_present() {} + +fn emit_capsule( + inner: &Arc, + state: CapsuleState, + level: f32, + elapsed_ms: u64, + message: Option, + inserted_chars: Option, +) { + // 在 app 句柄校验之前记录,便于无 GUI 的测试断言「按下热键 → 弹了哪种胶囊」。 + *inner.last_capsule_state.lock() = Some(state); + let app_opt = inner.app.lock().clone(); + let Some(app) = app_opt else { return }; + let translation = inner.translation_modifier_seen.load(Ordering::SeqCst); + let operating = inner.state.lock().voice_agent; + let payload = CapsulePayload { + state, + level, + elapsed_ms, + message, + inserted_chars, + translation, + operating, + }; + + #[cfg(target_os = "android")] + crate::android::notify_capsule_state(&payload); + + // visible / translation 是「这一帧 capsule:state event 的 payload」内容 —— + // 必须在 call-site(即音频线程触发 emit_capsule 时)就算定,否则 main thread + // 闭包里读到的将是「下一帧」的 state,跟实际下发给 JS 的 payload 不一致。 + let visible = !matches!(state, CapsuleState::Idle); + + // Linux: 通过 fcitx5 插件在候选词列表下方显示听写状态,不干扰输入法预编辑。 + // 只在文本变化时调用 DBus,避免录音中 ~30Hz 的音频电平回调重复调用。 + #[cfg(target_os = "linux")] + { + use std::sync::Mutex; + static LAST_AUX: Mutex> = Mutex::new(None); + + let aux = match state { + CapsuleState::Idle => None, + CapsuleState::Recording => Some("🎤 收音中..."), + CapsuleState::Transcribing => Some("🔄 识别中..."), + CapsuleState::Polishing => Some("✨ 润色中..."), + CapsuleState::Done => Some("✅ 已插入"), + CapsuleState::Cancelled => Some("— 已取消"), + CapsuleState::Error => Some("❌ 出错"), + }; + + let mut last = LAST_AUX.lock().unwrap(); + if aux != last.as_deref() { + *last = aux.map(String::from); + // 代数计数器:每次状态变化 +1,retry 线程只在自己代数仍为最新时生效。 + // 避免 Recording→Idle→Recording 快速切换时多个 retry 重复触发。 + static RETRY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + // fetch_add 返回旧值,所以 latest_gen > gen+1 才表示"在我之后又发生了变更"。 + let gen = RETRY_GEN.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + match aux { + Some(t) => { + log::info!("[capsule] set_aux_down: {t} gen={gen}"); + let text = t.to_string(); + std::thread::spawn(move || { + let current = LAST_AUX.lock().unwrap().clone(); + if current.as_deref() != Some(&text) { + log::info!( + "[capsule] set_aux_down skipped: state changed to {current:?}" + ); + return; + } + if let Err(e) = crate::linux_fcitx::set_aux_down(&text) { + log::warn!("[capsule] set_aux_down failed: {e}"); + } + }); + // 终态(Done/Cancelled/Error)3 秒后自动清除,避免一直跟随焦点。 + if matches!( + state, + CapsuleState::Done | CapsuleState::Cancelled | CapsuleState::Error + ) { + let text = t.to_string(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(3)); + let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst); + if latest_gen > gen + 1 { + return; + } + let current = LAST_AUX.lock().unwrap().clone(); + if current.as_deref() != Some(&text) { + return; + } + log::info!("[capsule] auto-clear terminal state: {text}"); + let _ = crate::linux_fcitx::set_aux_down(""); + *LAST_AUX.lock().unwrap() = None; + }); + } + } + None => { + log::info!("[capsule] clear_aux_down gen={gen}"); + std::thread::spawn(move || { + let latest_gen = RETRY_GEN.load(std::sync::atomic::Ordering::SeqCst); + if latest_gen > gen + 1 { + log::info!( + "[capsule] clear_aux_down skipped: gen {gen}, latest {latest_gen}" + ); + return; + } + let current = LAST_AUX.lock().unwrap().clone(); + if current.is_some() { + log::info!( + "[capsule] clear_aux_down skipped: state changed to {current:?}" + ); + return; + } + if let Err(e) = crate::linux_fcitx::clear_aux_down() { + log::warn!("[capsule] clear_aux_down failed: {e}"); + } + }); + } + } + } + } + + // emit_capsule 会被 cpal process_callback(音频回调线程)调用 ~30 Hz —— 在该 + // 线程上调用 NSWindow / HWND API 会撞 macOS dispatch_assert_queue_fail SIGTRAP + // 或者 Win32 SendMessage 死锁。把 window.show/hide + 位置调整 marshal 到主线程; + // app.emit_to 走 Tauri 内部事件总线,本身线程安全,保留同步调用。详见 audit 3.2.2。 + // + // show_capsule(用户偏好)在主线程执行时再读 —— 用户可以在录音过程中改设置, + // 闭包入队到真正跑之间窗口上限是一两帧(~16-33ms),用最新值消除 stale-pref + // 闪烁。pr_agent 关注点 — 见 audit follow-up。 + let inner_for_main = Arc::clone(inner); + let app_for_main = app.clone(); + let _ = app.run_on_main_thread(move || { + let Some(window) = app_for_main.get_webview_window("capsule") else { + // #470 诊断 v2:比 A/B/C 更靠前的暗点 A0 —— capsule webview 句柄取不到 + // (窗口未创建/已销毁)。此前静默 return,无法观测。一次性 warn。 + if !CAPSULE_WINDOW_MISSING_LOGGED.swap(true, Ordering::SeqCst) { + log::warn!( + "[capsule] capsule webview window not found — emit_capsule show path skipped (state={})", + capsule_state_log_name(state) + ); + } + return; + }; + let show_capsule = inner_for_main.prefs.get().show_capsule; + // Linux: 不操作胶囊窗口(不 show/hide,不 reposition)。 + // 文字通过 fcitx5 插件直接 commit,用户始终在目标 app 中。 + #[cfg(target_os = "linux")] + { + return; + } + #[cfg(not(target_os = "linux"))] + { + + // 三平台统一:Done / Cancelled / Error 状态保留 ~1.5s toast + // (schedule_capsule_idle 之后会回 Idle 隐藏)。 + // Windows 上 linger 的真实问题(截图选中 / 死区 / 拖拽卡顿)由 #140 加的 + // `hide_capsule_window_if_present()` Win32 hard-hide 在 visible=false 分支 + // 处理,不依赖把 Done/Cancelled/Error 打成 invisible。详见 PR #140 评论。 + maybe_position_capsule_bottom_center(&inner_for_main, &window, translation); + if show_capsule && visible { + // 用户报"看不到胶囊"时第一时间能在 log 里确认:胶囊路径有跑、show_capsule + // 开关是 true、当前进入 visible 帧 —— 排除 prefs 没存住 / emit_capsule 没触 + // 发 / state 一直 Idle 这几类常见 root cause。issue #470。 + if !CAPSULE_FIRST_SHOW_LOGGED.swap(true, Ordering::SeqCst) { + log::info!( + "[capsule] first show this session: show_capsule=true visible=true state={}", + capsule_state_log_name(state) + ); + } + show_capsule_window_for_recording(&app_for_main, &window); + // macOS/Windows 优先走 no-activate show,避免录音胶囊抢走当前工作 app 焦点。 + // 若 fallback 到 show(),OpenLess 已是前台 app 时再把 key window 还给 main。 + #[cfg(target_os = "macos")] + crate::restore_main_window_key_if_active(&app_for_main); + } else { + // show_capsule 开关被用户关掉但本次确实想显示(visible=true)的情况: + // 一次性 info log,让用户报"胶囊没显示"时能在日志里一眼看到根因 —— 维护者 + // 不必再让用户"去打开设置确认"。issue #470。 + if !show_capsule + && visible + && !CAPSULE_SUPPRESSED_BY_TOGGLE_LOGGED.swap(true, Ordering::SeqCst) + { + log::info!( + "[capsule] suppressed by user toggle: show_capsule=false visible=true state={}", + capsule_state_log_name(state) + ); + } + hide_capsule_window_if_present(); + let _ = window.hide(); + } + } + }); + + let _ = app.emit_to("capsule", "capsule:state", &payload); + // 主窗口也需要 capsule:state 事件:AudioCueListener 用它触发录音提示音。 + // Linux 上胶囊隐藏时提示音仍应工作,所以同时发给 main 窗口。 + let _ = app.emit_to("main", "capsule:state", &payload); +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CapsuleLayoutState { + translation_active: bool, + monitor_x: i32, + monitor_y: i32, + monitor_width: u32, + monitor_height: u32, + scale_bits: u64, +} + +/// 返回胶囊「应该摆放到的显示器」的标识信息。 +/// +/// 它看的显示器必须和 `position_capsule_bottom_center` 实际定位用的一致: +/// Windows 看「正在输入的 App 所在显示器」,其它平台看胶囊自己的显示器。 +/// 这是「是否需要重新定位」去重缓存(`maybe_position_capsule_bottom_center`) +/// 的 key,如果这里看错了显示器,就会出现「输入焦点移到另一块屏、胶囊却没 +/// 跟过去」的 bug。 +fn capsule_layout_snapshot( + window: &tauri::WebviewWindow, + translation_active: bool, +) -> Option { + // Windows:以「正在输入的 App 所在显示器」为基准。若用胶囊自己的 + // current_monitor,输入焦点切到另一块屏时胶囊仍在原屏 → 误判「没变化」 + // → 跳过重新定位。 + #[cfg(target_os = "windows")] + { + if let Some(mon) = crate::foreground_window_monitor() { + return Some(CapsuleLayoutState { + translation_active, + monitor_x: mon.left, + monitor_y: mon.top, + monitor_width: (mon.right - mon.left).max(0) as u32, + monitor_height: (mon.bottom - mon.top).max(0) as u32, + scale_bits: mon.scale.to_bits(), + }); + } + // 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor。 + } + let monitor = window.current_monitor().ok().flatten()?; + Some(CapsuleLayoutState { + translation_active, + monitor_x: monitor.position().x, + monitor_y: monitor.position().y, + monitor_width: monitor.size().width, + monitor_height: monitor.size().height, + scale_bits: monitor.scale_factor().to_bits(), + }) +} + +fn maybe_position_capsule_bottom_center( + inner: &Arc, + window: &tauri::WebviewWindow, + translation_active: bool, +) { + let Some(next) = capsule_layout_snapshot(window, translation_active) else { + return; + }; + { + let last = inner.capsule_layout.lock(); + if last.as_ref() == Some(&next) { + return; + } + } + if crate::position_capsule_bottom_center(window, translation_active).is_ok() { + let mut last = inner.capsule_layout.lock(); + *last = Some(next); + } +} + +// ─────────────────────────── audio bridge ─────────────────────────── + +struct DeferredAsrBridge { + state: Mutex, +} + +struct DeferredAsrState { + target: Option>, + pending_audio: Vec, + attaching: bool, +} + +impl DeferredAsrBridge { + fn new() -> Self { + Self { + state: Mutex::new(DeferredAsrState { + target: None, + pending_audio: Vec::new(), + attaching: false, + }), + } + } + + fn attach(&self, target: Arc) -> usize { + let mut flushed_bytes = 0; + { + let mut state = self.state.lock(); + state.attaching = true; + } + + loop { + let pending = { + let mut state = self.state.lock(); + if state.pending_audio.is_empty() { + state.target = Some(Arc::clone(&target)); + state.attaching = false; + return flushed_bytes; + } + std::mem::take(&mut state.pending_audio) + }; + flushed_bytes += pending.len(); + target.consume_pcm_chunk(&pending); + } + } +} + +impl crate::recorder::AudioConsumer for DeferredAsrBridge { + fn consume_pcm_chunk(&self, pcm: &[u8]) { + let target = { + let mut state = self.state.lock(); + if state.attaching { + state.pending_audio.extend_from_slice(pcm); + return; + } + if let Some(target) = state.target.as_ref() { + Some(Arc::clone(target)) + } else { + state.pending_audio.extend_from_slice(pcm); + None + } + }; + + if let Some(target) = target { + target.consume_pcm_chunk(pcm); + } + } } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 0eff9c51..cd5e263c 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1,14 +1,462 @@ -use std::sync::atomic::Ordering; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use crate::coordinator_state::request_stop_during_starting_state; +use crate::correction::apply_correction_rules; use crate::types::HotkeyMode; use super::qa::handle_qa_option_edge; +use super::resources::*; use super::*; /// 同一个 hotkey 边沿之间的最小间隔。低于此阈值的连按整体作为误触丢弃 —— /// 避免微动开关回弹 / 用户手抖双击造成的空转写报错和 ASR session 抢资源。 const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(250); +const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12); + +/// Less Computer 浮窗的 Tauri 事件名(前端 LessComputerPanel 订阅)。 +const LESS_COMPUTER_EVENT: &str = "less-computer:event"; + +/// Less Computer 内联审批:等待用户决断的 token → oneshot sender 注册表。 +/// +/// 无头 `claude -p` 没有 mid-run 的 `--permission-prompt-tool` 通道(v2.1.165 不支持), +/// 所以护栏拦截发生在「整轮跑完、护栏 deny 生效」之后。这个注册表是审批 UI 的实回路: +/// 后端发 `approval` 事件后把一个 oneshot 接收端挂在这里,等前端 `less_computer_approve` +/// 命令按 token 解析出用户决断(true=Approve / false=Deny)。 +static LESS_COMPUTER_APPROVALS: std::sync::OnceLock< + std::sync::Mutex>>, +> = std::sync::OnceLock::new(); + +fn less_computer_approvals( +) -> &'static std::sync::Mutex>> +{ + LESS_COMPUTER_APPROVALS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new())) +} + +/// 前端 `less_computer_approve` 命令调到这里:按 token 解析等待中的审批。 +/// token 不存在(已超时 / 已解析)时静默忽略。 +pub(super) fn resolve_less_computer_approval(token: &str, approved: bool) { + let sender = less_computer_approvals() + .lock() + .ok() + .and_then(|mut m| m.remove(token)); + if let Some(tx) = sender { + let _ = tx.send(approved); + log::info!("[less-computer] 审批 token={token} approved={approved}"); + } else { + log::info!("[less-computer] 审批 token={token} 已失效(超时/重复)"); + } +} + +/// 往 Less Computer 浮窗发一条事件(macOS only;前端按 `kind` 渲染聊天结构)。 +fn emit_less_computer(inner: &Arc, payload: serde_json::Value) { + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit_to("less-computer", LESS_COMPUTER_EVENT, payload); + } +} + +/// 跑流式润色路径(opt-in,跨平台)。 +/// +/// 平台差异: +/// - **macOS**:`switch_to_ascii` 切到 ABC 输入源(规避 CJK / 日文 IME 拦截 Unicode 事件), +/// session 结束 `restore_input_source` 切回。`type_unicode_chunk` 走 CGEvent FFI。 +/// - **Windows**:`switch_to_ascii` 是 no-op(SendInput Unicode 绕过 TSF); +/// `type_unicode_chunk` 走 `SendInput(KEYEVENTF_UNICODE)`。 +/// - **Linux(实验)**:`switch_to_ascii` 是 no-op;`type_unicode_chunk` 走 enigo +/// `Keyboard::text`。X11 / XTest 稳定。 +/// +/// 通用流程: +/// 1. `switch_to_ascii`(macOS)/ no-op(其他);失败则降级回一次性 `polish_or_passthrough`。 +/// 2. 起一个 `spawn_blocking` 后台任务,从 mpsc 收 SSE delta,按 12ms flush window +/// 合并后调 `type_unicode_chunk` 模拟键盘事件落到光标处。串行有序,无竞态。 +/// 3. 调 `polish_or_passthrough_streaming`,`on_delta` 把 chunk 塞进 mpsc。 +/// 4. 流结束 / 失败 / 取消 → drop mpsc 发送端 → typer 任务 drain 完剩余 delta 退出 → +/// `restore_input_source` 恢复用户原输入源(macOS 才有意义,其他平台 no-op)。 +/// 5. 返回 `(polished, polish_error, already_streamed)`: +/// - 成功:`(text, None, true)` — 字符已经在屏幕上,调用方应当跳过 `inserter.insert` +/// - 失败:`(raw_text, Some(reason), false)` — 流式过程出错,调用方走 raw 一次性兜底 +/// - 不支持:`run_streaming_polish` 内部直接调 `polish_or_passthrough` 透明降级 +/// +/// **不在流式路径里做**:`apply_chinese_script_preference` / `apply_correction_rules` +/// 这两步在 v1 跳过 —— 字符已经一边流一边落出去了,不好回退。需要的话只能关 toggle 走 +/// 一次性路径。 +#[allow(clippy::too_many_arguments)] +async fn run_streaming_polish( + inner: &Arc, + raw: &RawTranscript, + mode: PolishMode, + hotwords: &[String], + style_system_prompt: &str, + working_languages: &[String], + chinese_script_preference: crate::types::ChineseScriptPreference, + output_language_preference: crate::types::OutputLanguagePreference, + llm_thinking_enabled: bool, + front_app: Option<&str>, + prior_turns: &[(String, String)], +) -> (String, Option, bool) { + log::info!( + "[coord] streaming_insert path ENTER (raw_chars={})", + raw.text.chars().count() + ); + + let app = inner.app.lock().clone(); + let Some(app) = app else { + log::warn!("[coord] streaming_insert: no AppHandle in Inner; fall back to one-shot"); + let (p, e) = polish_or_passthrough( + raw, + mode, + hotwords, + style_system_prompt, + working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app, + prior_turns, + ) + .await; + return (p, e, false); + }; + + // 1. 切到 ABC 输入源。失败则降级 —— 流式路径上 CJK IME 拦截不是可恢复错误。 + log::info!("[coord] streaming_insert: switching input source to ABC"); + let prev_ime = match crate::unicode_keystroke::switch_to_ascii(&app).await { + Ok(prev) => { + log::info!( + "[coord] streaming_insert: switched to ABC (had_previous={})", + prev.is_some() + ); + prev + } + Err(e) => { + log::warn!( + "[coord] streaming_insert: switch_to_ascii failed: {e}; fall back to one-shot" + ); + let (p, err) = polish_or_passthrough( + raw, + mode, + hotwords, + style_system_prompt, + working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app, + prior_turns, + ) + .await; + return (p, err, false); + } + }; + + // 2. 起 typer 后台任务:从 mpsc 收 delta,串行调 type_unicode_chunk。 + // 同时累积 typed_text:屏幕上真正落字的内容,用于(a)SSE 中途失败时让 history + // 与用户实际看到的内容一致;(b)pr-agent #412 反馈 \"saved output diverges + // from what the user actually sees\"。 + let (tx, rx) = std::sync::mpsc::channel::(); + let typer_handle = tokio::task::spawn_blocking(move || { + drain_streaming_insert_deltas(rx, STREAMING_INSERT_FLUSH_INTERVAL) + }); + + // 3. 调流式润色,on_delta 塞 mpsc;should_cancel 检查 dictation 取消旗。 + let inner_for_cancel = Arc::clone(inner); + let should_cancel = move || inner_for_cancel.state.lock().cancelled; + let outcome = super::polish_or_passthrough_streaming( + raw, + mode, + hotwords, + style_system_prompt, + working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app, + prior_turns, + move |delta: &str| { + let _ = tx.send(delta.to_string()); + }, + should_cancel, + ) + .await; + // tx 已经被 move 进 on_delta 闭包;闭包随 polish_or_passthrough_streaming 返回 + // 而 drop,typer 那侧 blocking_recv 拿到 None 自然退出。 + + // 4. 等 typer 把缓冲 drain 完,拿到实际落字的全文 + 第一条失败原因。 + let (typed_text, typer_failure) = typer_handle.await.unwrap_or_else(|e| { + log::error!("[coord] streaming_insert: typer task join failed: {e}"); + (String::new(), Some(format!("typer join: {e}"))) + }); + let typed_chars = typed_text.chars().count(); + log::info!("[coord] streaming_insert: typer drained, typed {typed_chars} chars"); + + // 5. 无论流是否成功,都恢复用户原输入源。 + log::info!("[coord] streaming_insert: restoring input source"); + if let Err(e) = crate::unicode_keystroke::restore_input_source(&app, prev_ime).await { + log::warn!("[coord] streaming_insert: restore_input_source failed: {e}"); + } else { + log::info!("[coord] streaming_insert: input source restored"); + } + + // 6. 把 outcome 翻译成 (polished, polish_error, already_streamed)。 + match outcome { + super::StreamingPolishOutcome::Streamed(text) => { + log::info!( + "[coord] streaming_insert SUCCESS: polished_chars={} typed_chars={} typer_err={:?}", + text.chars().count(), + typed_chars, + typer_failure + ); + // 边界 case:polish 成功但 typer 在第一字就失败(最常见:session 开始时 + // 已处于 Secure Input;或 SendInput / enigo 拒绝)。屏幕上一字未见, + // already_streamed=true 会让上层跳过 inserter,最终用户看不到任何内容。 + // 这里显式回退到一次性兜底,让正常 inserter 路径写出 polish 结果。 + // pr-agent #412 反馈 \"Missing fallback\"。 + if typed_chars == 0 { + if let Some(reason) = typer_failure { + log::warn!( + "[coord] streaming_insert: zero chars typed despite polish success ({reason}); falling back to one-shot inserter" + ); + return (text, Some(reason), false); + } + } + // 先确定 final_text —— typer 中途失败时屏幕只有 typed_text 这一段, + // history 记完整 polish 反而会让用户复盘困惑。让 history / clipboard / + // 后续逻辑统统用 final_text,三处保持一致。 + // pr-agent #412 反馈 \"Clipboard Mismatch\":之前先写 text 到剪贴板再 + // 决定 typer 是否中途失败,导致 Cmd+V 粘出用户屏幕上没见过的内容。 + let (final_text, polish_err) = match typer_failure { + Some(e) => (typed_text, Some(format!("typing partially failed: {e}"))), + None => (text, None), + }; + // 把 final_text 写回剪贴板(默认 on,macOS/Windows 适用)。 + // Linux:fcitx5 插件已直写文字到目标 app,跳过剪贴板避免破坏用户数据。 + // Android/iOS:无 arboard 剪贴板路径,v1 依赖 IME commit。 + #[cfg(not(any(target_os = "linux", target_os = "android", target_os = "ios")))] + if inner.prefs.get().streaming_insert_save_clipboard { + match arboard::Clipboard::new() { + Ok(mut cb) => match cb.set_text(final_text.clone()) { + Ok(()) => log::info!( + "[coord] streaming_insert: final text written to clipboard ({} chars)", + final_text.chars().count() + ), + Err(e) => { + log::warn!("[coord] streaming_insert: clipboard set_text failed: {e}") + } + }, + Err(e) => { + log::warn!("[coord] streaming_insert: clipboard handle init failed: {e}") + } + } + } else { + log::info!("[coord] streaming_insert: clipboard save skipped (pref off)"); + } + (final_text, polish_err, true) + } + super::StreamingPolishOutcome::UnsupportedFallback => { + log::info!( + "[coord] streaming_insert: dispatch reported unsupported, fall back to one-shot" + ); + let (p, e) = polish_or_passthrough( + raw, + mode, + hotwords, + style_system_prompt, + working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app, + prior_turns, + ) + .await; + (p, e, false) + } + super::StreamingPolishOutcome::Failed(reason) => { + log::warn!( + "[coord] streaming_insert FAILED: {reason}; typed {typed_chars} chars before failure" + ); + // 流式失败但已经流了一部分 chars:用户屏幕上有半截 polish。history 应当 + // 跟屏幕一致 —— 记 typed_text 而不是 raw.text,否则保存内容跟用户看见的 + // 内容会分叉(pr-agent #412 \"Wrong final text\" 反馈)。 + // 一字都没流时 typed_text 是空串,回到 raw 一次性兜底。 + if typed_chars > 0 { + ( + typed_text, + Some(format!( + "streaming polish failed mid-stream after {typed_chars} chars: {reason}" + )), + true, + ) + } else { + (raw.text.clone(), Some(reason), false) + } + } + } +} + +fn drain_streaming_insert_deltas( + rx: std::sync::mpsc::Receiver, + flush_interval: std::time::Duration, +) -> (String, Option) { + drain_streaming_insert_deltas_with(rx, flush_interval, flush_streaming_insert_buffer) +} + +fn drain_streaming_insert_deltas_with( + rx: std::sync::mpsc::Receiver, + flush_interval: std::time::Duration, + mut flush_pending: F, +) -> (String, Option) +where + F: FnMut(&mut String, &mut String) -> Option, +{ + let mut typed_text = String::new(); + let mut first_failure: Option = None; + let mut pending = String::new(); + while let Ok(delta) = rx.recv() { + pending.push_str(&delta); + let flush_at = std::time::Instant::now() + flush_interval; + loop { + let now = std::time::Instant::now(); + if now >= flush_at { + break; + } + match rx.recv_timeout(flush_at.duration_since(now)) { + Ok(delta) => pending.push_str(&delta), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + first_failure = flush_pending(&mut pending, &mut typed_text); + return (typed_text, first_failure); + } + } + } + first_failure = flush_pending(&mut pending, &mut typed_text); + if first_failure.is_some() { + // 一旦类型链路出错(如 Secure Input 启用),后续 delta 全部丢弃,但仍 + // 把 mpsc drain 完,避免发送端阻塞。 + while rx.recv().is_ok() {} + break; + } + } + if first_failure.is_none() { + first_failure = flush_pending(&mut pending, &mut typed_text); + } + (typed_text, first_failure) +} + +fn flush_streaming_insert_buffer(pending: &mut String, typed_text: &mut String) -> Option { + flush_streaming_insert_buffer_with( + pending, + typed_text, + crate::unicode_keystroke::type_unicode_chunk, + ) +} + +fn flush_streaming_insert_buffer_with( + pending: &mut String, + typed_text: &mut String, + mut type_chunk: F, +) -> Option +where + F: FnMut(&str) -> Result, +{ + if pending.is_empty() { + return None; + } + let delta = std::mem::take(pending); + let delta_chars = delta.chars().count(); + match type_chunk(&delta) { + Ok(typed_chars) => { + let appended = append_typed_prefix(typed_text, &delta, typed_chars); + if appended < delta_chars { + let reason = format!( + "type_unicode_chunk typed only {appended}/{delta_chars} chars without error" + ); + log::error!( + "[coord] streaming_insert: {reason} at typed={} chars; \ + dropping remaining deltas", + typed_text.chars().count() + ); + Some(reason) + } else { + None + } + } + Err(e) => { + append_typed_prefix(typed_text, &delta, e.typed_chars()); + log::error!( + "[coord] streaming_insert: type_unicode_chunk failed at typed={} chars: {e}; \ + dropping remaining deltas", + typed_text.chars().count() + ); + Some(e.to_string()) + } + } +} + +fn finalize_polished_text( + polished: String, + translation_active: bool, + _raw_uses_llm: bool, + mode: PolishMode, + polish_error: &Option, + chinese_script_preference: crate::types::ChineseScriptPreference, + correction_rules: &[crate::types::CorrectionRule], + already_streamed: bool, +) -> String { + if already_streamed { + return polished; + } + let should_force_script = if translation_active { + polish_error.is_some() + } else { + mode == PolishMode::Raw || polish_error.is_some() + }; + let polished = if should_force_script { + apply_chinese_script_preference(&polished, chinese_script_preference) + } else { + polished + }; + if correction_rules.is_empty() { + polished + } else { + let corrected = apply_correction_rules(&polished, correction_rules); + if corrected != polished { + log::info!( + "[coord] correction rules adjusted final text ({} → {} chars)", + polished.chars().count(), + corrected.chars().count() + ); + } + corrected + } +} + +fn streaming_insert_eligible( + streaming_insert_enabled: bool, + translation_active: bool, + mode: PolishMode, + raw_uses_llm: bool, +) -> bool { + streaming_insert_enabled && !translation_active && (mode != PolishMode::Raw || raw_uses_llm) +} + +fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option { + if polish_failed { + // polish 失败优先告知用户,即使 insert 成功也要让用户知道这版是原文 + Some("润色失败,已插入原文".to_string()) + } else { + match status { + InsertStatus::Inserted => None, + InsertStatus::PasteSent => Some("已尝试粘贴".to_string()), + InsertStatus::CopiedFallback => Some(if cfg!(target_os = "windows") { + "已复制,请 Ctrl+V".to_string() + } else { + "已复制,请粘贴".to_string() + }), + InsertStatus::Failed => Some("插入失败".to_string()), + } + } +} pub(super) async fn handle_pressed_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); @@ -124,13 +572,1976 @@ pub(super) async fn handle_released(inner: &Arc) { } } +/// Less Computer 收尾:把转写当作指令交给无头 Claude,结果以胶囊展示(不插入到光标)。 +async fn run_voice_agent_transcript( + inner: &Arc, + _session_id: SessionId, + transcript: String, + elapsed: u64, +) -> Result<(), String> { + log::info!( + "[coord] Cloud Agent 语音:指令 {} 字", + transcript.chars().count() + ); + // 胶囊保留「处理中」反馈(用户熟悉的小录音条状态机);聊天浮窗承载完整对话。 + emit_capsule( + inner, + CapsuleState::Polishing, + 0.0, + elapsed, + Some("Claude 处理中…".to_string()), + None, + ); + + // 聊天浮窗:显示窗口 + 落用户气泡(语音指令转写)。macOS only(helper 内部 gating)。 + if let Some(app) = inner.app.lock().clone() { + crate::show_less_computer_window(&app); + // 全屏彩虹描边已在按下键时(handle_less_computer_pressed)点亮,这里不重复。 + } + // 连续对话:浮窗里已有进行中的会话 → 本轮 `claude --continue` 续上下文;否则是新会话(fresh)。 + // dismiss 关窗会把标志复位为 false。 + let continue_session = inner + .less_computer_conversation + .swap(true, Ordering::SeqCst); + emit_less_computer( + inner, + serde_json::json!({ "kind": "user", "text": transcript, "fresh": !continue_session }), + ); + + let prefs = inner.prefs.get(); + // 工作目录:用户设的 workdir,否则 $HOME。--add-dir 把文件作用域限定在此。 + let cwd = prefs + .coding_agent_workdir + .clone() + .filter(|d| !d.trim().is_empty()) + .map(std::path::PathBuf::from) + .or_else(|| std::env::var("HOME").ok().map(std::path::PathBuf::from)); + // 运行前 git 快照(cwd 是 git 仓库才有效;非仓库无副作用),便于回滚文件改动。 + if let Some(dir) = &cwd { + if let Some(sha) = crate::coding_agent::create_git_snapshot(dir) { + log::info!("[less-computer] 运行前 git 快照 {sha}(git stash apply 可回滚)"); + } + } + + // 钳制:语音 → shell 这条全自动路径禁止 bypassPermissions 绕过护栏(无人审、动手即生效)。 + // 即便用户在偏好里设了 bypass,这里也降级为 acceptEdits(仍带 deny 护栏)。 + let mode = match coding_agent_mode_from_pref(&prefs.coding_agent_permission_mode) { + crate::coding_agent::CodingAgentPermissionMode::BypassPermissions => { + log::warn!( + "[less-computer] 语音 Agent 路径禁止 bypassPermissions,已降级为 acceptEdits(保留护栏)" + ); + crate::coding_agent::CodingAgentPermissionMode::AcceptEdits + } + other => other, + }; + let model = prefs + .coding_agent_model + .clone() + .filter(|m| !m.trim().is_empty()) + .or_else(|| Some("sonnet".to_string())); + let prompt = crate::coding_agent::autonomous_prompt(&transcript); + + // 第一轮:默认护栏(高风险全 deny)。运行后若检测到护栏拦截,弹审批卡; + // 用户 Approve 则在第二轮把该高风险模式从 deny 移除 + 加进 allowed,重跑一次。 + let outcome = run_less_computer_once( + inner, + &prompt, + cwd.as_deref(), + mode, + model.as_deref(), + &[], + continue_session, + ) + .await; + + let final_outcome = match maybe_request_approval(inner, &outcome).await { + Some(approved_pattern) => { + log::info!("[less-computer] 审批通过,放行高风险模式后重跑:{approved_pattern}"); + run_less_computer_once( + inner, + &prompt, + cwd.as_deref(), + mode, + model.as_deref(), + &[approved_pattern], + continue_session, + ) + .await + } + None => outcome, + }; + + inner.state.lock().phase = SessionPhase::Idle; + // 工作结束:熄灭全屏彩虹描边(聊天浮窗保留,等用户读完/关闭)。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } + + match final_outcome { + LessComputerOutcome::Done { text, cost_usd } => { + let text = text.trim().to_string(); + if text.is_empty() { + let msg = "Claude 无结果(确认已登录 claude 且额度充足)".to_string(); + emit_less_computer( + inner, + serde_json::json!({ "kind": "error", "message": msg }), + ); + emit_capsule(inner, CapsuleState::Error, 0.0, elapsed, Some(msg), None); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("voice agent empty".to_string()); + } + log::info!("[coord] Cloud Agent 语音:返回 {} 字", text.chars().count()); + emit_less_computer( + inner, + serde_json::json!({ "kind": "completed", "text": text, "costUsd": cost_usd }), + ); + emit_capsule(inner, CapsuleState::Done, 0.0, elapsed, Some(text), None); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + Ok(()) + } + LessComputerOutcome::Failed { message } => { + log::warn!("[coord] Cloud Agent 语音失败: {message}"); + emit_less_computer( + inner, + serde_json::json!({ "kind": "error", "message": message }), + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(message), + None, + ); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + Err("voice agent failed".to_string()) + } + LessComputerOutcome::Cancelled => { + log::info!("[coord] Cloud Agent 语音已取消"); + emit_less_computer(inner, serde_json::json!({ "kind": "cancelled" })); + emit_capsule(inner, CapsuleState::Cancelled, 0.0, elapsed, None, None); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + Err("voice agent cancelled".to_string()) + } + } +} + +/// 一轮无头 Less Computer 运行的结果。 +enum LessComputerOutcome { + Done { text: String, cost_usd: Option }, + Failed { message: String }, + Cancelled, +} + +/// 跑一轮无头 Claude(「放行 + 护栏」),把 Delta/ToolUse 实时 stream 到聊天浮窗, +/// 终局收敛为 [`LessComputerOutcome`]。`extra_allow_patterns` 为审批通过后放行的 +/// 高风险子串(如 "git push --force"):从 deny 清单剔除 + 作为 `Bash(:*)` 加进 allowed。 +async fn run_less_computer_once( + inner: &Arc, + prompt: &str, + cwd: Option<&std::path::Path>, + mode: crate::coding_agent::CodingAgentPermissionMode, + model: Option<&str>, + extra_allow_patterns: &[String], + continue_session: bool, +) -> LessComputerOutcome { + // 护栏 deny:默认全量;审批放行的模式从 deny 中剔除。 + // 审批 UI 只回传命中的单个高风险子串,但同一风险有等价写法(如 --force / -f)。 + // 按「风险等价组」整组放行:只放行被点那一个会让等价写法仍卡在 deny(deny 优先级高于 + // allow)→ 命令仍被拦。见 guard::risk_equivalent_patterns。 + let mut deny = crate::coding_agent::guard::default_deny_rules(); + let approved_patterns: Vec = extra_allow_patterns + .iter() + .flat_map(|p| { + let group = crate::coding_agent::guard::risk_equivalent_patterns(p); + if group.is_empty() { + vec![p.clone()] + } else { + group.into_iter().map(|s| s.to_string()).collect() + } + }) + .collect(); + let allow_rules: Vec = approved_patterns + .iter() + .map(|p| format!("Bash({p}:*)")) + .collect(); + if !allow_rules.is_empty() { + deny.retain(|d| !allow_rules.iter().any(|a| a == d)); + } + let settings_json = serde_json::json!({ + "permissions": { "defaultMode": mode.as_cli_arg(), "deny": deny } + }); + let settings_path = std::env::temp_dir().join(format!( + "openless-less-computer-guard-{}.json", + uuid::Uuid::new_v4() + )); + // fail-closed:序列化或写入失败时立即中止,绝不在「无护栏」下把无效路径交给 + // `claude -p --settings`(找不到文件 = 完全裸跑)。宁可不跑也不裸跑。 + let settings_bytes = match serde_json::to_vec_pretty(&settings_json) { + Ok(b) => b, + Err(e) => { + log::warn!("[less-computer] 序列化护栏配置失败: {e}"); + return LessComputerOutcome::Failed { + message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(), + }; + } + }; + if let Err(e) = std::fs::write(&settings_path, settings_bytes) { + log::warn!("[less-computer] 写护栏配置失败: {e}"); + return LessComputerOutcome::Failed { + message: "护栏配置写入失败,已中止(拒绝在无护栏下执行)".into(), + }; + } + + let mut req = crate::coding_agent::CodingAgentRequest::new("less-computer", prompt.to_string()); + req.cwd = cwd.map(|p| p.to_path_buf()); + req.model = model.map(|m| m.to_string()); + req.permission_mode = mode; + // 写护栏成功后才设置:写失败已在上面 fail-closed 返回,不会带无效路径裸跑。 + req.settings_json_path = Some(settings_path.clone()); + // 去掉 WebFetch:无出站白名单时它是 prompt 注入 SSRF 面(诱导拉取内网/元数据端点)。 + // 保留 WebSearch(走搜索引擎,不直接抓任意 URL)。 + req.allowed_tools = vec![ + "Bash".into(), + "Read".into(), + "Edit".into(), + "Write".into(), + "Glob".into(), + "Grep".into(), + "WebSearch".into(), + ]; + req.allowed_tools.extend(allow_rules); + // 真实任务(开应用、多步操作、读写文件)常超过 120s/0.5$ → 老是「运行超时」。放宽到 + // 5 分钟 / 2$,给多步任务足够空间;仍有硬上限兜底,不会无限跑/烧钱。 + req.max_budget_usd = Some(2.0); + req.timeout_secs = 300; + // 连续对话需要保留会话:本轮保存(供下轮 --continue),第二轮起带 --continue 续上下文。 + req.session_persistence = true; + req.continue_session = continue_session; + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let cancel = Arc::new(AtomicBool::new(false)); + let cancel_for_runner = Arc::clone(&cancel); + let run = async_runtime::spawn(async move { + crate::coding_agent::run_claude_agent("claude", req, tx, cancel_for_runner).await + }); + let cancel_for_watcher = Arc::clone(&cancel); + let inner_for_cancel = Arc::clone(inner); + let cancel_watcher = async_runtime::spawn(async move { + loop { + if cancel_for_watcher.load(Ordering::Relaxed) { + return; + } + if inner_for_cancel.state.lock().cancelled { + cancel_for_watcher.store(true, Ordering::Relaxed); + return; + } + tokio::time::sleep(std::time::Duration::from_millis(120)).await; + } + }); + + let mut final_text = String::new(); + let mut cost_usd: Option = None; + let mut error_msg: Option = None; + let mut cancelled = false; + while let Some(ev) = rx.recv().await { + use crate::coding_agent::CodingAgentEvent as E; + match ev { + E::Started { .. } => { + emit_less_computer(inner, serde_json::json!({ "kind": "started" })); + } + E::Delta { text, .. } => { + emit_less_computer(inner, serde_json::json!({ "kind": "delta", "text": text })); + } + E::ToolUse { name, .. } => { + emit_less_computer(inner, serde_json::json!({ "kind": "tool", "name": name })); + } + E::Completed { + text, cost_usd: c, .. + } => { + final_text = text; + cost_usd = c; + } + E::Error { message, .. } => error_msg = Some(message), + E::Cancelled { .. } => cancelled = true, + } + } + let run_result = run.await; + cancel.store(true, Ordering::Relaxed); + let _ = cancel_watcher.await; + let _ = std::fs::remove_file(&settings_path); + + if cancelled + || matches!( + &run_result, + Ok(Err(crate::coding_agent::CodingAgentError::Cancelled)) + ) + { + return LessComputerOutcome::Cancelled; + } + + let trimmed = final_text.trim().to_string(); + if !trimmed.is_empty() { + LessComputerOutcome::Done { + text: trimmed, + cost_usd, + } + } else { + let message = error_msg + .or_else(|| match run_result { + Ok(Err(e)) => Some(e.to_string()), + _ => None, + }) + .unwrap_or_else(|| "Claude 无结果(确认已登录 claude 且额度充足)".to_string()); + LessComputerOutcome::Failed { message } + } +} + +/// 护栏拦截探测 + 内联审批(best-effort)。 +/// +/// 无头 `claude -p`(v2.1.165)没有 mid-run 的 `--permission-prompt-tool` 通道,所以 +/// 我们只能在「一轮跑完」后判断护栏是否拦了高风险动作:扫描终局文本里是否提到某个 +/// 高风险模式 + 权限/拒绝/blocked 关键词。命中则发 `approval` 事件、挂一个 oneshot 等 +/// 用户决断(前端 Approve/Deny → `less_computer_approve` 命令解析)。 +/// +/// 返回 `Some(pattern)` 表示用户 Approve 了某高风险模式 → 调用方应放行该模式重跑一轮; +/// `None` 表示无需审批 / 用户 Deny / 超时。**注意**这是「重跑放行」而非真正的 mid-run +/// 续跑——headless 下没有干净的 mid-run round-trip,详见 report。 +async fn maybe_request_approval( + inner: &Arc, + outcome: &LessComputerOutcome, +) -> Option { + let text = match outcome { + LessComputerOutcome::Done { text, .. } => text.as_str(), + LessComputerOutcome::Failed { message } => message.as_str(), + LessComputerOutcome::Cancelled => return None, + }; + let lowered = text.to_lowercase(); + // 必须同时出现「拒绝/权限/blocked」语义 + 某个已知高风险模式,才认为是护栏拦截, + // 避免把正常提到 "rm" 的回答误判成审批请求。 + let mentions_block = [ + "denied", + "permission", + "not allowed", + "blocked", + "拒绝", + "权限", + "被拦", + ] + .iter() + .any(|kw| lowered.contains(kw)); + if !mentions_block { + return None; + } + let hit = crate::coding_agent::guard::HIGH_RISK_PATTERNS + .iter() + .find(|(pat, _)| lowered.contains(*pat))?; + let (pattern, reason) = (hit.0.to_string(), hit.1.to_string()); + + // 挂 oneshot 等用户决断。 + let token = uuid::Uuid::new_v4().to_string(); + let (tx, rx) = tokio::sync::oneshot::channel::(); + if let Ok(mut map) = less_computer_approvals().lock() { + map.insert(token.clone(), tx); + } + emit_less_computer( + inner, + serde_json::json!({ + "kind": "approval", + "token": token, + "command": pattern, + "reason": reason, + }), + ); + + // 等用户点 Approve/Deny;90s 无响应按 Deny 处理并清理注册表项。 + let approved = match tokio::time::timeout(std::time::Duration::from_secs(90), rx).await { + Ok(Ok(v)) => v, + _ => { + less_computer_approvals() + .lock() + .ok() + .map(|mut m| m.remove(&token)); + false + } + }; + if approved { + Some(pattern) + } else { + None + } +} + +/// 把 prefs 里的权限模式字符串映射成枚举;未知值回落到 acceptEdits(放行+护栏的默认)。 +fn coding_agent_mode_from_pref(s: &str) -> crate::coding_agent::CodingAgentPermissionMode { + use crate::coding_agent::CodingAgentPermissionMode as M; + match s.trim() { + "plan" => M::Plan, + "default" => M::Default, + "bypassPermissions" => M::BypassPermissions, + _ => M::AcceptEdits, + } +} + +pub(super) fn request_stop_during_starting(inner: &Arc, reason: &str) { + { + let mut state = inner.state.lock(); + if !request_stop_during_starting_state(&mut state) { + return; + } + } + log::info!("[coord] {reason} during Starting — queued"); + stop_recorder_if_pending_start_stop(inner); +} + +pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { + let current_session_id = { + let mut state = inner.state.lock(); + let Some(session_id) = + begin_session_state(&mut state, capture_focus_target(), capture_frontmost_app()) + else { + return Ok(()); + }; + if let Some(label) = state.front_app.as_deref() { + log::info!("[coord] front_app captured: {label}"); + } + session_id + }; + #[cfg(target_os = "windows")] + { + let prepared = inner.windows_ime.prepare_session(); + let mut slots = inner.prepared_windows_ime_session.lock(); + store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); + } + // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 + inner + .translation_modifier_seen + .store(false, Ordering::SeqCst); + + #[cfg(any(debug_assertions, test))] + if hotkey_injection_dry_run_enabled() { + emit_capsule(inner, CapsuleState::Recording, 0.0, 0, None, None); + inner.state.lock().phase = SessionPhase::Listening; + log::info!("[coord] session started (hotkey-injection dry-run)"); + return Ok(()); + } + + if let Err(message) = ensure_asr_credentials() { + log::warn!("[coord] ASR credential gate failed: {message}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(message.clone()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + return Err(message); + } + + let active_asr = CredentialsVault::get_active_asr(); + + if let Err(message) = ensure_microphone_permission(inner) { + log::warn!("[coord] microphone permission gate failed: {message}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(message.clone()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(message); + } + + // 不在这里 emit Recording capsule —— 让 start_recorder_for_starting 在 + // Recorder::start 成功后再发,确保「用户看到录音条」时 mic 已经在 capture。 + // 之前在这一行就 emit 会让用户看到录音条后立刻开口,但 mic 还在 cpal init + // 窗口(50-200ms)内 → 开头几个字物理上录不到。详见 issue 备注。 + #[cfg(target_os = "windows")] + if foundry::is_foundry_local_whisper(&active_asr) { + let prefs = inner.prefs.get(); + let model_alias = if foundry::model_alias_is_known(&prefs.foundry_local_asr_model) { + prefs.foundry_local_asr_model.clone() + } else { + foundry::DEFAULT_MODEL_ALIAS.to_string() + }; + let language_hint = prefs.foundry_local_asr_language_hint.trim().to_string(); + let language_hint = if language_hint.is_empty() { + None + } else { + Some(language_hint) + }; + let local = Arc::new(FoundryLocalWhisperAsr::new( + Arc::clone(&inner.foundry_local_runtime), + model_alias, + prefs.foundry_local_runtime_source.clone(), + language_hint, + )); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::FoundryLocalWhisper(Arc::clone(&local)), + ); + let consumer: Arc = local; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + return Ok(()); + } + + // Windows sherpa-onnx-local:与 Foundry 同形分支,复用 Recorder / + // ActiveAsr / start_recorder_and_enter_listening。offline 模型走 batch; + // online 模型在 provider 内部 worker 中边录边解码,并通过 local-asr-token + // 推 partial 给前端胶囊。 + #[cfg(target_os = "windows")] + if sherpa::is_sherpa_onnx_local(&active_asr) { + let prefs = inner.prefs.get(); + let model_alias = if sherpa::model_alias_is_known(&prefs.sherpa_onnx_model) { + prefs.sherpa_onnx_model.clone() + } else { + sherpa::DEFAULT_MODEL_ALIAS.to_string() + }; + let language_hint = prefs.sherpa_onnx_language_hint.trim().to_string(); + let language_hint = if language_hint.is_empty() { + None + } else { + Some(language_hint) + }; + let token_handler = inner.app.lock().clone().map(|app| { + Arc::new(move |piece: String| { + if let Err(error) = app.emit("local-asr-token", piece) { + log::warn!("[sherpa-asr] emit token failed: {error}"); + } + }) as crate::asr::local::sherpa_provider::SherpaTokenHandler + }); + let local = match SherpaOnnxAsr::new_for_model( + Arc::clone(&inner.sherpa_onnx_runtime), + model_alias, + language_hint, + token_handler, + ) + .await + { + Ok(local) => Arc::new(local), + Err(e) => { + log::error!("[coord] sherpa-onnx init failed: {e:#}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("本地模型初始化失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(format!("sherpa-onnx init failed: {e}")); + } + }; + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::SherpaOnnxLocal(Arc::clone(&local)), + ); + let consumer: Arc = local; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + return Ok(()); + } + + #[cfg(target_os = "macos")] + if crate::asr::local::is_local_qwen3(&active_asr) { + let local = match build_local_qwen3(inner).await { + Ok(l) => l, + Err(e) => { + log::error!("[coord] 本地 Qwen3-ASR 初始化失败: {e:#}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("本地模型初始化失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(format!("local ASR init failed: {e}")); + } + }; + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Local(Arc::clone(&local)), + ); + let consumer: Arc = local; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + return Ok(()); + } + + if is_bailian_provider(&active_asr) { + let asr = Arc::new(BailianRealtimeASR::new(read_bailian_credentials())); + let bridge = Arc::new(DeferredAsrBridge::new()); + let consumer: Arc = bridge.clone(); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Bailian(Arc::clone(&asr)), + ); + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; + + if let Err(e) = asr.open_session().await { + log::error!("[coord] open Bailian ASR session failed: {e}"); + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale Bailian ASR open_session error from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::CancelRaced => { + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::ActiveStarting => { + asr.cancel(); + } + } + discard_startup_resources_for_session(inner, current_session_id); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("ASR 连接失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::ActiveStarting => {} + StartupRaceStatus::CancelRaced => { + log::info!("[coord] cancel raced during Bailian ASR open_session — aborting begin"); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale Bailian ASR open_session continuation from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + } + let target: Arc = asr; + let flushed_bytes = bridge.attach(target); + log::info!("[coord] Bailian ASR connected; flushed {flushed_bytes} deferred audio bytes"); + finish_starting_session(inner, current_session_id).await; + } else if is_mimo_provider(&active_asr) { + let (api_key, base_url, model) = read_mimo_credentials(); + let mimo = Arc::new(MimoBatchASR::new(api_key, base_url, model)); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Mimo(Arc::clone(&mimo)), + ); + let consumer: Arc = mimo; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + } else if is_whisper_compatible_provider(&active_asr) { + let (api_key, base_url, model) = read_whisper_credentials(); + // 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や + // 専門用語の同音・近形誤認識を ASR 段階で抑える。Polish LLM 側には + // 既に system prompt として注入済みだが、Whisper 出力が大きく崩れる + // と Polish でも救えない(特に CJK で顕著)。Volcengine ASR は元々 + // hotword を受け取っており、UI 説明文も「ASR ホットワードと後処理 + // モデルのコンテキスト両方に渡される」と明示しているので、Whisper + // 互換プロバイダにも揃えるのが筋。 + let whisper_prompt = + crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner)); + let whisper = Arc::new( + WhisperBatchASR::new( + api_key, + base_url, + model, + whisper_prompt, + batch_asr_chunk_limit_ms(&active_asr), + whisper_supports_verbose_json(&active_asr), + ) + .with_request_format(whisper_request_format(&active_asr)), + ); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Whisper(Arc::clone(&whisper)), + ); + let consumer: Arc = whisper; + start_recorder_and_enter_listening(inner, current_session_id, &active_asr, consumer) + .await?; + } else { + let hotwords = enabled_hotwords(inner); + let creds = read_volc_credentials(); + let asr = Arc::new(VolcengineStreamingASR::new(creds, hotwords)); + let bridge = Arc::new(DeferredAsrBridge::new()); + let consumer: Arc = bridge.clone(); + store_asr_for_session( + inner, + current_session_id, + ActiveAsr::Volcengine(Arc::clone(&asr)), + ); + start_recorder_for_starting(inner, current_session_id, &active_asr, consumer).await?; + + if let Err(e) = asr.open_session().await { + log::error!("[coord] open ASR session failed: {e}"); + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale ASR open_session error from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::CancelRaced => { + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::ActiveStarting => {} + } + discard_startup_resources_for_session(inner, current_session_id); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("ASR 连接失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + // open_session.await 期间用户可能按了 Esc / 改变心意。如果 cancel_session + // 已触发(cancelled=true 或 phase 被改回 Idle),别再装 ASR,直接善后。 + // audit HIGH #1。 + match startup_race_status_for_starting(inner, current_session_id) { + StartupRaceStatus::ActiveStarting => {} + StartupRaceStatus::CancelRaced => { + log::info!("[coord] cancel raced during ASR open_session — aborting begin"); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + StartupRaceStatus::StaleContinuation => { + log::info!( + "[coord] stale ASR open_session continuation from session {current_session_id} — ignoring" + ); + asr.cancel(); + discard_startup_resources_for_session(inner, current_session_id); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + } + let target: Arc = asr; + let flushed_bytes = bridge.attach(target); + log::info!("[coord] ASR connected; flushed {flushed_bytes} deferred audio bytes"); + finish_starting_session(inner, current_session_id).await; + } + + Ok(()) +} + +pub(super) async fn start_recorder_for_starting( + inner: &Arc, + session_id: SessionId, + active_asr: &str, + consumer: Arc, +) -> Result<(), String> { + let inner_for_level = Arc::clone(inner); + // 节流:电平回调本身约 185 Hz(cpal 默认音频块),全部转发到前端会让 CSS + // transition 互相覆盖、视觉上"被平均"成静止。限制为 ~30 Hz(33ms 最少间隔), + // 配合 CSS 短 transition 让每次 emit 完整可见。 + let last_emit_at = Arc::new(Mutex::new(None::)); + const LEVEL_EMIT_MIN_INTERVAL_MS: u64 = 33; + let level_handler: Arc = Arc::new(move |level| { + let phase = inner_for_level.state.lock().phase; + if phase != SessionPhase::Listening && phase != SessionPhase::Starting { + return; + } + let now = Instant::now(); + { + let mut last = last_emit_at.lock(); + if let Some(prev) = *last { + if now.duration_since(prev).as_millis() < LEVEL_EMIT_MIN_INTERVAL_MS as u128 { + return; + } + } + *last = Some(now); + } + let elapsed = inner_for_level + .state + .lock() + .started_at + .elapsed() + .as_millis() as u64; + emit_capsule( + &inner_for_level, + CapsuleState::Recording, + level, + elapsed, + None, + None, + ); + }); + + let microphone_device_name = selected_microphone_device_name(inner); + stop_microphone_preview_monitor(inner, "dictation recorder"); + acquire_recording_mute(inner, "dictation").await; + let audio_archive_path = if inner.prefs.get().record_audio_for_debug { + // 用 coordinator 的 SessionId 作为文件名,跟 history 那条记录 id 对齐(见 + // 下游 polish 收尾时 `history_session_id = current_session_id.to_string()`)。 + // 顺手把超龄 / 超量录音清理一下,避免 debug 开关常开时磁盘膨胀。 + let prefs = inner.prefs.get(); + let _ = crate::persistence::prune_recordings( + prefs.history_retention_days, + prefs.audio_recording_max_entries, + ); + crate::persistence::recording_path_for_session(&session_id.to_string()).ok() + } else { + None + }; + match Recorder::start( + microphone_device_name, + consumer, + level_handler, + audio_archive_path, + ) { + Ok((rec, runtime_errors, archive_active)) => { + // 把 archive 实际创建状态存到 Inner,让 history 写入路径(含 empty-transcript + // 失败分支)读真实情况,而不是 prefs 开关。修 pr_agent "Wrong Flag" 反馈。 + inner + .audio_archive_active + .store(archive_active, std::sync::atomic::Ordering::Relaxed); + store_recorder_for_session(inner, session_id, rec); + spawn_recorder_error_monitor(inner, runtime_errors); + // 不在这里 emit Recording capsule。 + // Recorder::start Ok 仅代表 cpal Stream::play 完成,不代表 audio + // 线程已经在向 consumer 推 PCM —— macOS CoreAudio AudioUnit 启动到 + // 第一帧 process_callback 中间有 50–200 ms 间隙(Windows 类似)。 + // 之前在这里立即 emit Recording 会让用户「看到录音条」就开口,但前几个 + // 字落在 cpal init 窗口里被吞,反映为短录音漏首字(用户报告)。 + // + // 现改为:level_handler 第一次被触发时才 emit Recording capsule。 + // recorder.rs::process_callback 的顺序是 consume_pcm_chunk → level_handler, + // 所以 level_handler 第一次执行 == PCM 已经真实流到 consumer。从这一刻 + // 起用户说什么都被录到。capsule 自然就晚 50–200 ms 出现,但出现 == + // mic 真的在录,匹配「麦先录、UI 再弹」的预期。 + // + // 原本的竞态保护交还给两条已有路径: + // - stop_recorder_if_pending_start_stop:短按时把 capsule 切到 + // Transcribing;recorder 已 stop,level_handler 不会再发火。 + // - level_handler 内部 phase 检查:cancel / 错误使 phase 不在 + // {Starting, Listening} 时直接 return,不会在错误状态上盖 + // Recording。 + stop_recorder_if_pending_start_stop(inner); + log::info!("[coord] recorder started (asr={active_asr}, phase=Starting)"); + } + Err(e) => { + log::error!("[coord] recorder start failed: {e}"); + cancel_asr_for_session(inner, session_id); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + 0, + Some(format!("录音启动失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, session_id); + release_recording_mute(inner, "dictation"); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + } + + Ok(()) +} + +pub(super) fn spawn_recorder_error_monitor(inner: &Arc, rx: mpsc::Receiver) { + // 捕获当前 session_id:err 来时若 id 已经不一致说明是上一 session 的迟到事件, + // 不能去 abort 当前 active 的新 session(它录得好好的)。 + let captured_session_id = inner.state.lock().session_id; + let inner = Arc::clone(inner); + std::thread::Builder::new() + .name("openless-recorder-error-monitor".into()) + .spawn(move || { + if let Ok(err) = rx.recv() { + let current_session_id = inner.state.lock().session_id; + if captured_session_id != current_session_id { + log::warn!( + "[coord] recorder error from stale session {} dropped (current={}, err={})", + captured_session_id, + current_session_id, + err + ); + return; + } + log::error!("[coord] recorder runtime error: {err}"); + abort_recording_with_error(&inner, format!("录音中断: {err}")); + } + }) + .ok(); +} + +pub(super) fn abort_recording_with_error(inner: &Arc, message: String) { + let Some(abort) = ({ + let mut state = inner.state.lock(); + begin_recording_abort_before_restore(&mut state) + }) else { + return; + }; + + discard_startup_resources_for_session(inner, abort.session_id); + restore_prepared_windows_ime_session(inner, abort.session_id); + { + let mut state = inner.state.lock(); + publish_abort_idle_after_restore(&mut state, abort.session_id); + } + + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + abort.elapsed, + Some(message), + None, + ); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); +} + +pub(super) async fn start_recorder_and_enter_listening( + inner: &Arc, + session_id: SessionId, + active_asr: &str, + consumer: Arc, +) -> Result<(), String> { + start_recorder_for_starting(inner, session_id, active_asr, consumer).await?; + finish_starting_session(inner, session_id).await; + Ok(()) +} + +pub(super) async fn finish_starting_session(inner: &Arc, session_id: SessionId) { + // audit HIGH #1:转 Listening 之前在同一 lock 内检查 cancel race。 + // 之前是无条件 phase=Listening,会把 cancel_session 在 await 期间设的 Idle + // 反向覆盖回 Listening → 用户的 cancel 边沿被吞掉。 + let outcome = { + let mut state = inner.state.lock(); + finish_starting_session_state(&mut state, session_id) + }; + match outcome { + BeginOutcome::StaleContinuation => { + log::info!( + "[coord] stale recorder/ASR startup continuation from session {session_id} — ignoring" + ); + discard_startup_resources_for_session(inner, session_id); + restore_prepared_windows_ime_session(inner, session_id); + } + BeginOutcome::CancelRaced => { + log::info!("[coord] cancel raced during recorder/ASR startup — aborting begin"); + discard_startup_resources_for_session(inner, session_id); + restore_prepared_windows_ime_session(inner, session_id); + set_phase_idle_if_session_matches(inner, session_id); + } + BeginOutcome::Started | BeginOutcome::PendingStop => { + log::info!("[coord] session started"); + if matches!(outcome, BeginOutcome::PendingStop) { + log::info!("[coord] applying pending_stop edge → end_session immediately"); + let _ = end_session(inner).await; + } + } + } +} + +pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { + let current_session_id = { + let mut state = inner.state.lock(); + let Some(session_id) = start_processing_if_listening(&mut state) else { + return Ok(()); + }; + session_id + }; + + let elapsed = inner.state.lock().started_at.elapsed().as_millis() as u64; + emit_capsule(inner, CapsuleState::Transcribing, 0.0, elapsed, None, None); + + if let Some(rec) = take_recorder_for_session(inner, current_session_id) { + rec.stop(); + release_recording_mute(inner, "dictation"); + } + + let asr_opt = take_asr_for_session(inner, current_session_id); + let asr = match asr_opt { + Some(a) => a, + None => { + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + return Ok(()); + } + }; + + let uses_global_timeout = asr_transcribe_uses_global_timeout(&asr); + let raw = match asr { + ActiveAsr::Volcengine(asr) => { + debug_assert!(uses_global_timeout); + if let Err(e) = asr.send_last_frame().await { + log::error!("[coord] send last frame failed: {e}"); + } + // 添加全局超时保护:防止 await_final_result() 永远挂起 + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] await final failed: {e}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + // 全局超时:最后的防线 + log::error!( + "[coord] 全局超时 {} 秒 - 强制恢复", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + // 清理 ASR session,避免资源泄漏 + asr.cancel(); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("global timeout".to_string()); + } + } + } + ActiveAsr::Whisper(w) => { + debug_assert!(uses_global_timeout); + // Whisper 也添加类似的超时保护 + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, w.transcribe()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] whisper transcribe failed: {e}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] whisper 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("whisper global timeout".to_string()); + } + } + } + ActiveAsr::Mimo(m) => { + debug_assert!(uses_global_timeout); + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, m.transcribe()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] MiMo ASR transcribe failed: {e}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] MiMo ASR 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("mimo global timeout".to_string()); + } + } + } + ActiveAsr::Bailian(asr) => { + debug_assert!(uses_global_timeout); + if let Err(e) = asr.send_last_frame().await { + log::error!("[coord] Bailian send last frame failed: {e}"); + } + let timeout_duration = std::time::Duration::from_secs(COORDINATOR_GLOBAL_TIMEOUT_SECS); + match tokio::time::timeout(timeout_duration, asr.await_final_result()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] Bailian await final failed: {e}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] Bailian 全局超时 {} 秒", + COORDINATOR_GLOBAL_TIMEOUT_SECS + ); + asr.cancel(); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("bailian global timeout".to_string()); + } + } + } + #[cfg(target_os = "windows")] + ActiveAsr::FoundryLocalWhisper(local) => { + debug_assert!(!uses_global_timeout); + match local + .transcribe(foundry_audio_transcribe_timeout_duration()) + .await + { + Ok(r) => { + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + r + } + Err(e) => { + if inner.state.lock().cancelled { + log::info!( + "[coord] Foundry Local Whisper transcribe cancelled — discarding transcript" + ); + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + log::error!("[coord] Foundry Local Whisper transcribe failed: {e:#}"); + schedule_foundry_local_asr_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("本地识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + } + } + // Windows sherpa-onnx offline batch:停止录音后整段转写,再复用现有 + // polish / insert / history 收尾路径。 + #[cfg(target_os = "windows")] + ActiveAsr::SherpaOnnxLocal(local) => { + debug_assert!(!uses_global_timeout); + match local + .transcribe(sherpa_audio_transcribe_timeout_duration()) + .await + { + Ok(r) => { + schedule_sherpa_onnx_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + r + } + Err(e) => { + if inner.state.lock().cancelled { + log::info!( + "[coord] sherpa-onnx transcribe cancelled — discarding transcript" + ); + schedule_sherpa_onnx_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + log::error!("[coord] sherpa-onnx transcribe failed: {e:#}"); + schedule_sherpa_onnx_release( + inner, + AsrReleaseSession::Dictation(current_session_id), + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("本地识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + } + } + #[cfg(target_os = "macos")] + ActiveAsr::Local(local) => { + debug_assert!(uses_global_timeout); + // 缓存命中时 transcribe 不含 load 时间;冷启动 load 已在 build_local_qwen3 + // 提前完成。但 transcribe 本身受音频长度影响:用户实测 RTF ≈ 0.3,慢机 + // 可达 0.5;15s 固定超时在 ≥ 30s 录音上会把整段结果丢掉。改用动态 + // 超时 max(15, ceil(audio_s × 0.6) + 10),公式与单测见 + // `local_qwen_transcribe_timeout`。 + let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; + let timeout_duration = local_qwen_transcribe_timeout(audio_secs); + log::info!( + "[coord] local Qwen3-ASR transcribe: audio={:.2}s timeout={}s", + audio_secs, + timeout_duration.as_secs() + ); + let result = tokio::time::timeout(timeout_duration, local.transcribe()).await; + inner.local_asr_cache.touch(); + schedule_local_asr_release(inner); + match result { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + log::error!("[coord] local Qwen3-ASR transcribe failed: {e:#}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("本地识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] local Qwen3-ASR 动态超时 {}s(音频 {:.2}s)", + timeout_duration.as_secs(), + audio_secs + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("local global timeout".to_string()); + } + } + } + // Apple Speech:系统语音识别,无模型加载耗时。批处理 transcribe 受音频 + // 长度影响,沿用 local_qwen_transcribe_timeout 的动态超时公式。 + #[cfg(target_os = "macos")] + ActiveAsr::AppleSpeech(local) => { + debug_assert!(uses_global_timeout); + let audio_secs = (local.buffer_duration_ms() as f64) / 1000.0; + let timeout_duration = local_qwen_transcribe_timeout(audio_secs); + log::info!( + "[coord] Apple Speech transcribe: audio={:.2}s timeout={}s", + audio_secs, + timeout_duration.as_secs() + ); + match tokio::time::timeout(timeout_duration, local.transcribe()).await { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + if inner.state.lock().cancelled { + log::info!( + "[coord] Apple Speech transcribe cancelled - discarding transcript" + ); + restore_prepared_windows_ime_session(inner, current_session_id); + set_phase_idle_if_session_matches(inner, current_session_id); + return Ok(()); + } + log::error!("[coord] Apple Speech transcribe failed: {e:#}"); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some(format!("本地识别失败: {e}")), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err(e.to_string()); + } + Err(_) => { + log::error!( + "[coord] Apple Speech 动态超时 {}s(音频 {:.2}s)", + timeout_duration.as_secs(), + audio_secs + ); + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("识别超时".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("apple-speech global timeout".to_string()); + } + } + } + }; + + // ASR 完成后 cancel 检查:用户在 transcribe 进行中按 Esc 时,这里就会命中。 + // 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。 + if inner.state.lock().cancelled { + log::info!("[coord] cancel detected after ASR — discarding transcript"); + restore_prepared_windows_ime_session(inner, current_session_id); + // PR #387 的「cancel 后清 focus_target」契约要在 Processing 路径上也成立。 + // cancel_session 在 Processing 阶段故意跳过 finish_cancel_session_state(让 + // 这里收尾),但此前的 end_session 没把 focus_target 清掉。logic-review + // 2026-05-10 P3 (🚩) 把这条补完。 + { + let mut state = inner.state.lock(); + state.phase = SessionPhase::Idle; + state.focus_target = None; + } + return Ok(()); + } + + // ASR 返回空转写护栏(来自 PR #66):写一条 emptyTranscript 失败历史 + 错误胶囊, + // 与 main 上其它 error 路径保持一致(带 schedule_capsule_idle 让胶囊自动消失)。 + let mut raw = raw; + + #[cfg(any(debug_assertions, test))] + if raw.text.trim().is_empty() { + if let Some(debug_text) = debug_transcript_override_text() { + log::info!( + "[coord] using debug transcript override (chars={})", + debug_text.chars().count() + ); + raw.text = debug_text; + } + } + + if raw.text.trim().is_empty() { + let session = DictationSession { + id: Uuid::new_v4().to_string(), + created_at: Utc::now().to_rfc3339(), + raw_transcript: raw.text.clone(), + final_text: String::new(), + mode: inner.prefs.get().default_mode, + style_pack_id: None, + translation_active: false, + polish_source: None, + app_bundle_id: None, + app_name: None, + insert_status: InsertStatus::Failed, + error_code: Some("emptyTranscript".to_string()), + duration_ms: Some(raw.duration_ms), + dictionary_entry_count: Some(enabled_phrases(inner).len() as u32), + // empty-transcript(ASR 没识别到任何文字)也保留 wav 标记——这是用户最想 + // 通过原始录音定位"是不是麦克风太小声 / ASR 模型问题"的场景。修 pr_agent + // "Missing Audio" 反馈。 + has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)), + }; + let prefs_snapshot = inner.prefs.get(); + if let Err(e) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { + log::error!("[coord] history append failed: {e}"); + } + emit_capsule( + inner, + CapsuleState::Error, + 0.0, + elapsed, + Some("没有识别到语音".to_string()), + None, + ); + restore_prepared_windows_ime_session(inner, current_session_id); + inner.state.lock().phase = SessionPhase::Idle; + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + return Err("ASR returned empty transcript".to_string()); + } + + let correction_rules = match inner.correction_rules.list() { + Ok(rules) => rules, + Err(e) => { + log::warn!("[coord] load correction rules failed: {e}; continue without correction"); + Vec::new() + } + }; + let front_app = inner.state.lock().front_app.clone(); + if !correction_rules.is_empty() { + let corrected = apply_correction_rules(&raw.text, &correction_rules); + if corrected != raw.text { + log::info!( + "[coord] correction rules adjusted raw transcript ({} → {} chars)", + raw.text.chars().count(), + corrected.chars().count() + ); + raw.text = corrected; + } + } + + // Cloud Agent 语音分流:长按升级的会话不走润色/插入,转写交给 Claude 跑任务、结果弹胶囊。 + if inner.state.lock().voice_agent { + return run_voice_agent_transcript(inner, current_session_id, raw.text.clone(), elapsed) + .await; + } + + emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None); + + let prefs = inner.prefs.get(); + let pack = match inner + .style_packs + .get_or_default_active(&prefs.active_style_pack_id) + { + Ok(pack) => pack, + Err(error) => { + log::warn!( + "[coord] active style pack unavailable, falling back to builtin light: {error}" + ); + crate::types::builtin_style_pack_for_mode(PolishMode::Light) + } + }; + let mode = pack.base_mode; + let hotword_strs = enabled_phrases(inner); + let working_languages = prefs.working_languages.clone(); + let chinese_script_preference = prefs.chinese_script_preference; + let output_language_preference = prefs.output_language_preference; + let llm_thinking_enabled = prefs.llm_thinking_enabled; + let style_system_prompt = pack.prompt.clone(); + let raw_uses_llm = mode == PolishMode::Raw && super::raw_style_pack_uses_llm(&pack); + let translation_target = prefs.translation_target_language.trim().to_string(); + let translation_active = + inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty(); + log::info!( + "[style-pack] runtime dispatch session_id={} active_pack={} kind={:?} mode={:?} raw_chars={} prompt_chars={} raw_uses_llm={} translation_active={} hotwords={} working_languages={:?}", + current_session_id, + pack.id, + pack.kind, + mode, + raw.text.chars().count(), + style_system_prompt.chars().count(), + raw_uses_llm, + translation_active, + hotword_strs.len(), + working_languages + ); + // 对话感知 polish:拉最近 N 分钟的会话作为 LLM 上下文。翻译现在也走"润色+翻译"单次 + // LLM 调用,所以翻译路径同样需要上下文;只有 Raw 且不走 LLM 才没意义。窗口=0 时为空 Vec。 + // 只复用同一 active style pack 的历史;翻译历史按当前是否翻译决定喂译文还是润色后源文 + // (见 eligible_polish_context_turns)。 + let polish_context_window_minutes = prefs.polish_context_window_minutes; + let prior_turns: Vec<(String, String)> = if (translation_active + || mode != PolishMode::Raw + || raw_uses_llm) + && polish_context_window_minutes > 0 + { + match inner + .history + .recent_within_minutes(polish_context_window_minutes) + { + Ok(sessions) => eligible_polish_context_turns(sessions, &pack.id, translation_active), + Err(e) => { + log::warn!("[coord] fetch polish context failed: {e}; fall back to single-turn"); + Vec::new() + } + } + } else { + Vec::new() + }; + // 流式插入 opt-in 路径:开关打开 + 非翻译 + 非 Raw 模式 → 进入流式分支。 + // 任何不满足都走原一次性 polish_or_passthrough 路径,行为跟历史完全一致。 + let streaming_eligible = streaming_insert_eligible( + prefs.streaming_insert, + translation_active, + mode, + raw_uses_llm, + ); + log::info!( + "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" + ); + + // Linux: emit_capsule(Polishing) 已通过 fcitx5 auxDown 显示 "✨ 润色中...", + // 无需在此重复调用。 + + // 翻译会话润色后的源语言文本(译文前的中间产物),仅翻译路径解析成功时有值, + // 写进 history 供后续普通润色轮复用(剔除译文、避免外语污染)。 + let mut polish_source: Option = None; + let (polished, polish_error, already_streamed) = if translation_active { + log::info!( + "[coord] translation mode → target=\u{300C}{}\u{300D} working={:?} front_app={:?}", + translation_target, + working_languages, + front_app + ); + let (p, src, e) = polish_and_translate_or_passthrough( + &raw, + &translation_target, + mode, + &hotword_strs, + &working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app.as_deref(), + &prior_turns, + ) + .await; + polish_source = src; + (p, e, false) + } else if streaming_eligible { + run_streaming_polish( + inner, + &raw, + mode, + &hotword_strs, + &style_system_prompt, + &working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app.as_deref(), + &prior_turns, + ) + .await + } else { + let (p, e) = polish_or_passthrough( + &raw, + mode, + &hotword_strs, + &style_system_prompt, + &working_languages, + chinese_script_preference, + output_language_preference, + llm_thinking_enabled, + front_app.as_deref(), + &prior_turns, + ) + .await; + (p, e, false) + }; + + let polished = finalize_polished_text( + polished, + translation_active, + raw_uses_llm, + mode, + &polish_error, + chinese_script_preference, + &correction_rules, + already_streamed, + ); + // 原子化最后一次 cancel 检查 + 转 Inserting: + // 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting, + // cancel_session 就拒绝介入(Cmd+V 已发出,撤销不掉)。这是 audit HIGH #2 的修复, + // 之前 check 与 inserter.insert 之间有窗口期。 + // + // 流式路径例外:`already_streamed = true` 表示字符已经一边流一边落到光标了, + // 撤销不掉。即使 cancel 旗在中途被立起来,也只能尊重「已经发生」的事实,进入 + // Inserting 状态完成 history / vocab 等收尾工作。 + let proceed_to_insert = { + let mut state = inner.state.lock(); + if state.cancelled && !already_streamed { + state.phase = SessionPhase::Idle; + false + } else { + state.phase = SessionPhase::Inserting; + true + } + }; + if !proceed_to_insert { + log::info!( + "[coord] cancel detected before insert — discarding output (chars={})", + polished.chars().count() + ); + restore_prepared_windows_ime_session(inner, current_session_id); + return Ok(()); + } + + let focus_target = inner.state.lock().focus_target; + let focus_ready_for_paste = restore_focus_target_if_possible(focus_target); + let prefs = inner.prefs.get(); + let restore_clipboard = prefs.restore_clipboard_after_paste; + let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; + let paste_shortcut = prefs.paste_shortcut; + // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 + let status = if already_streamed { + log::info!( + "[coord] insertion skipped: {} chars already streamed via unicode_keystroke (polish_error={:?})", + polished.chars().count(), + polish_error + ); + InsertStatus::Inserted + } else { + #[cfg(target_os = "android")] + { + crate::android::android_insert_with_strategy( + &inner.inserter, + &polished, + inner.prefs.get().android_insert_strategy, + ) + } + #[cfg(not(target_os = "android"))] + if focus_ready_for_paste { + #[cfg(target_os = "windows")] + { + let ime_target = capture_ime_submit_target(); + insert_with_windows_ime_first( + inner, + current_session_id, + &polished, + restore_clipboard, + allow_non_tsf_insertion_fallback, + paste_shortcut, + ime_target, + ) + .await + } + #[cfg(not(target_os = "windows"))] + { + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) + } + } else { + #[cfg(target_os = "linux")] + { + // Linux: fcitx5 commitString 无需窗口焦点,始终尝试插入。 + inner + .inserter + .insert(&polished, restore_clipboard, paste_shortcut) + } + #[cfg(not(target_os = "linux"))] + { + log::warn!( + "[coord] original insertion target is not foreground; copied output without paste" + ); + if allow_non_tsf_insertion_fallback { + inner.inserter.copy_fallback(&polished) + } else { + InsertStatus::Failed + } + } + } + }; + restore_prepared_windows_ime_session(inner, current_session_id); + let inserted_chars = polished.chars().count() as u32; + + // 累计每条 enabled 词条在最终文本中的命中次数。 + // 用 polished(最终插入的文本)扫描,与用户实际看到的输出一致。 + let total_hits: u64 = match inner.vocab.record_hits(&polished) { + Ok(n) => n, + Err(e) => { + log::error!("[coord] record_hits failed: {e}"); + 0 + } + }; + // 词汇本页面在打开时通常需要立即看到 hits 增长,否则用户得手动切走再切回来才刷新。 + // 命中数 > 0 时通知前端:Vocab 页面订阅 vocab:updated 即时 listVocab() 重新加载。 + if total_hits > 0 { + if let Some(app) = inner.app.lock().clone() { + let _ = app.emit("vocab:updated", total_hits); + } + } + + // polish 失败时在 history 里标记 polishFailed,让用户能在历史详情看到为什么这次输出 + // 不是预期的 mode 风格。即使失败也不丢词 — final_text 仍是原文(保留"用户的话不丢"语义)。 + let error_code = dictation_error_code( + status, + polish_error.is_some(), + focus_ready_for_paste, + allow_non_tsf_insertion_fallback, + ) + .map(str::to_string); + let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); + + // 与 coordinator 内部 SessionId 对齐:方便 recorder 旁路写盘的 `.wav` + // 跟 history 这条 DictationSession.id 同名,前端凭 id 就能找到对应录音文件。 + let history_session_id = current_session_id.to_string(); + let history_created_at = Utc::now().to_rfc3339(); + let prefs_snapshot = inner.prefs.get(); + let session = DictationSession { + id: history_session_id.clone(), + created_at: history_created_at.clone(), + raw_transcript: raw.text.clone(), + final_text: polished.clone(), + mode, + style_pack_id: Some(pack.id.clone()), + translation_active, + polish_source, + app_bundle_id: None, + app_name: None, + insert_status: status, + error_code, + duration_ms: Some(raw.duration_ms), + // 历史详情页的"X 个热词"显示:用本次实际命中次数(每个匹配实例算一次), + // 比"启用词条总数"更能反映本段口述命中了多少。u64 → u32 截断对单段听写足够。 + dictionary_entry_count: Some(total_hits.min(u32::MAX as u64) as u32), + // 用 begin_session 时 Recorder::start 返回的实际写盘状态,而不是 prefs 开关—— + // 开关打开但路径创建失败时这里是 false,避免前端渲染播放按钮后端 404。 + has_audio_recording: Some(inner.audio_archive_active.load(Ordering::Relaxed)), + }; + if let Err(e) = inner.history.append_with_retention( + session, + prefs_snapshot.history_retention_days, + prefs_snapshot.history_max_entries, + ) { + log::error!("[coord] history append failed: {e}"); + } + let done_message = if tsf_required_insert_failed { + Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) + } else { + default_done_message(status, polish_error.is_some()) + }; + + emit_capsule( + inner, + CapsuleState::Done, + 0.0, + elapsed, + done_message, + Some(inserted_chars), + ); + + { + let mut state = inner.state.lock(); + state.phase = SessionPhase::Idle; + state.focus_target = None; + } + // Toggle 模式冷却:设冷却时间戳,POST_SESSION_COOLDOWN_MS 内禁止新的 activate。 + // 覆盖胶囊离场动画周期,避免三连按第 3 次误激活(issue #545)。 + { + let now = std::time::Instant::now(); + *inner.session_cooldown_until.lock() = + Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS)); + } + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + + Ok(()) +} + +pub(super) fn dictation_error_code( + status: InsertStatus, + polish_failed: bool, + focus_ready_for_paste: bool, + allow_non_tsf_insertion_fallback: bool, +) -> Option<&'static str> { + if !focus_ready_for_paste && status == InsertStatus::Failed { + Some("focusRestoreFailed") + } else if cfg!(target_os = "windows") + && focus_ready_for_paste + && !allow_non_tsf_insertion_fallback + && status == InsertStatus::Failed + { + Some("windowsImeTsfRequired") + } else if polish_failed { + Some("polishFailed") + } else { + None + } +} + +pub(super) fn cancel_session(inner: &Arc) { + let Some(decision) = ({ + let mut state = inner.state.lock(); + let phase = state.phase; + let decision = begin_cancel_session_state(&mut state); + if phase == SessionPhase::Inserting { + log::info!("[coord] cancel ignored — already in Inserting phase, can't undo paste"); + } + decision + }) else { + return; + }; + + stop_recorder_for_session(inner, decision.session_id); + cancel_asr_for_session(inner, decision.session_id); + restore_prepared_windows_ime_session(inner, decision.session_id); + // Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾; + // 其他阶段直接转 Idle。 + if decision.phase != SessionPhase::Processing { + let mut state = inner.state.lock(); + finish_cancel_session_state(&mut state, decision); + // 只有真正把 phase 设为 Idle 时才设冷却(避免离场动画期间误激活)。 + let now = std::time::Instant::now(); + *inner.session_cooldown_until.lock() = + Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS)); + } + emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None); + log::info!("[coord] session cancelled (was {:?})", decision.phase); + schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); + // 取消时也熄灭整屏彩虹描边(dictation session 没开描边,hide 是无害 no-op)。 + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } +} + +fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> usize { + let mut end = 0; + let mut appended = 0; + for (idx, ch) in delta.char_indices().take(typed_chars) { + end = idx + ch.len_utf8(); + appended += 1; + } + target.push_str(&delta[..end]); + appended +} + +fn eligible_polish_context_turns( + sessions: Vec, + active_style_pack_id: &str, + current_translation_active: bool, +) -> Vec<(String, String)> { + sessions + .into_iter() + // 只取实际成功润色过的会话作为上下文:失败的会话 final_text 是 raw 兜底, + // 喂回 LLM 会让模型以为"上一轮我什么都没做"——没意义且占 token。 + // 这条同时保证下面 filter_map 里翻译历史的 final_text 一定是真译文(而非 passthrough + // 原文)——失败 / 兜底的翻译会话 error_code 非空,已在此被滤掉。 + .filter(|s| s.error_code.is_none() && !s.final_text.trim().is_empty()) + // 风格包切换 = 上下文边界。旧历史没有 style_pack_id,无法证明同源,保守排除。 + .filter(|s| s.style_pack_id.as_deref() == Some(active_style_pack_id)) + // 翻译历史按"下一轮是否也翻译"决定喂哪一段,既保留对话连续性又不让译文串味: + // - 当前是翻译轮 → 喂译文(final_text),保持目标语言一致; + // - 当前是普通轮 → 喂润色后的源文(polish_source),把译文剔除掉;源文缺失(解析 + // 失败 / 旧历史)则整条跳过——宁可少一条上下文,也不让外语译文混进普通润色。 + // - 普通历史无论当前轮是什么,都喂 final_text(本就是源语言润色结果)。 + .filter_map(|s| { + if s.translation_active && !current_translation_active { + s.polish_source + .filter(|src| !src.trim().is_empty()) + .map(|src| (s.raw_transcript, src)) + } else { + Some((s.raw_transcript, s.final_text)) + } + }) + .collect() +} + #[cfg(test)] mod tests { - use super::batch_asr_chunk_limit_ms; - use crate::coordinator::{ - append_typed_prefix, default_done_message, drain_streaming_insert_deltas_with, - eligible_polish_context_turns, finalize_polished_text, flush_streaming_insert_buffer_with, - streaming_insert_eligible, + use super::{ + append_typed_prefix, batch_asr_chunk_limit_ms, default_done_message, + drain_streaming_insert_deltas_with, eligible_polish_context_turns, finalize_polished_text, + flush_streaming_insert_buffer_with, streaming_insert_eligible, }; use crate::types::{ ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, PolishMode, @@ -341,50 +2752,9 @@ mod tests { false, PolishMode::Light, false, - crate::types::ChineseScriptPreference::Auto, )); } - // issue #622:非 Auto 字形偏好必须关闭流式,改走会做字形转换的一次性路径。 - #[test] - fn streaming_insert_ineligible_when_chinese_script_forced() { - for pref in [ - crate::types::ChineseScriptPreference::Traditional, - crate::types::ChineseScriptPreference::Simplified, - ] { - assert!(!streaming_insert_eligible( - true, - false, - PolishMode::Light, - false, - pref, - )); - } - } - - // issue #622:非 Auto + 成功 LLM 润色(非流式、无 error)时,最终插入文字 - // 必须套用字形转换,不能只靠 prompt 指示。覆盖 issue 给出的两个验收用例。 - #[test] - fn finalize_forces_traditional_even_on_successful_polish() { - let cases = [ - ("你知道你今天想要做什么吗?", "你知道你今天想要做什麼嗎?"), - ("所以你已经考过了吗?", "所以你已經考過了嗎?"), - ]; - for (input, expected) in cases { - let out = finalize_polished_text( - input.to_string(), - false, // translation_active - true, // raw_uses_llm - PolishMode::Light, // 成功润色路径(非 Raw、非 error) - &None, // 无 polish_error - crate::types::ChineseScriptPreference::Traditional, - &[], // 无 correction rules - false, // already_streamed - ); - assert_eq!(out, expected); - } - } - #[test] fn batch_asr_chunk_limit_applies_only_to_zhipu() { assert_eq!(batch_asr_chunk_limit_ms("zhipu"), Some(30_000)); @@ -463,4 +2833,9 @@ mod tests { fn platform_type_error() -> crate::unicode_keystroke::TypeError { crate::unicode_keystroke::TypeError::EnigoText("fail".into()) } + + #[cfg(target_os = "android")] + fn platform_type_error() -> crate::unicode_keystroke::TypeError { + crate::unicode_keystroke::TypeError::Unavailable + } } diff --git a/openless-all/app/src-tauri/src/coordinator/qa.rs b/openless-all/app/src-tauri/src/coordinator/qa.rs index 971622d4..a97473ce 100644 --- a/openless-all/app/src-tauri/src/coordinator/qa.rs +++ b/openless-all/app/src-tauri/src/coordinator/qa.rs @@ -8,7 +8,7 @@ use crate::types::CapsuleState; use super::{ begin_qa_session, cancel_qa_session, capture_focus_target, capture_frontmost_app, emit_capsule, - end_qa_session, Inner, + end_qa_session, qa_event_target, Inner, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -108,7 +108,7 @@ pub(super) fn open_qa_panel(inner: &Arc) { if let Some(app) = inner.app.lock().clone() { crate::show_qa_window(&app, "idle"); let _ = app.emit_to( - "qa", + qa_event_target(), "qa:state", serde_json::json!({ "kind": "idle", diff --git a/openless-all/app/src-tauri/src/coordinator/resources.rs b/openless-all/app/src-tauri/src/coordinator/resources.rs index 71183687..03184636 100644 --- a/openless-all/app/src-tauri/src/coordinator/resources.rs +++ b/openless-all/app/src-tauri/src/coordinator/resources.rs @@ -104,14 +104,21 @@ pub(super) fn selected_microphone_device_name(inner: &Arc) -> Option, owner: &str) { - let Some(app) = inner.app.lock().as_ref().cloned() else { - return; - }; - let state = app.state::(); - let recorder = state.lock().take(); - if let Some(recorder) = recorder { - log::info!("[recorder] stopping microphone preview monitor before {owner}"); - recorder.stop(); + #[cfg(mobile)] + { + let _ = (inner, owner); + } + #[cfg(not(mobile))] + { + let Some(app) = inner.app.lock().as_ref().cloned() else { + return; + }; + let state = app.state::(); + let recorder = state.lock().take(); + if let Some(recorder) = recorder { + log::info!("[recorder] stopping microphone preview monitor before {owner}"); + recorder.stop(); + } } } diff --git a/openless-all/app/src-tauri/src/external_url.rs b/openless-all/app/src-tauri/src/external_url.rs new file mode 100644 index 00000000..0fdebeab --- /dev/null +++ b/openless-all/app/src-tauri/src/external_url.rs @@ -0,0 +1,70 @@ +pub fn open_external_url(url: &str) -> Result<(), String> { + let parsed = url::Url::parse(url).map_err(|error| format!("invalid URL: {error}"))?; + match parsed.scheme() { + "http" | "https" => {} + scheme => return Err(format!("unsupported URL scheme: {scheme}")), + } + + platform_open_external_url(parsed.as_str()) +} + +#[cfg(target_os = "android")] +fn platform_open_external_url(url: &str) -> Result<(), String> { + use jni::objects::{JObject, JValue}; + + let android_context = ndk_context::android_context(); + let vm = unsafe { + jni::JavaVM::from_raw(android_context.vm().cast()) + .map_err(|error| format!("attach Android JVM: {error}"))? + }; + let mut env = vm + .attach_current_thread() + .map_err(|error| format!("attach Android thread: {error}"))?; + let context = unsafe { JObject::from_raw(android_context.context() as jni::sys::jobject) }; + + let action = env + .new_string("android.intent.action.VIEW") + .map_err(|error| format!("create Intent action: {error}"))?; + let url = env + .new_string(url) + .map_err(|error| format!("create URL string: {error}"))?; + let uri = env + .call_static_method( + "android/net/Uri", + "parse", + "(Ljava/lang/String;)Landroid/net/Uri;", + &[JValue::Object(&JObject::from(url))], + ) + .and_then(|value| value.l()) + .map_err(|error| format!("parse URL into Android Uri: {error}"))?; + let intent = env + .new_object( + "android/content/Intent", + "(Ljava/lang/String;Landroid/net/Uri;)V", + &[JValue::Object(&JObject::from(action)), JValue::Object(&uri)], + ) + .map_err(|error| format!("create Android Intent: {error}"))?; + + // Context may be an application context; NEW_TASK keeps startActivity valid there. + env.call_method( + &intent, + "addFlags", + "(I)Landroid/content/Intent;", + &[JValue::Int(0x10000000)], + ) + .map_err(|error| format!("set Android Intent flags: {error}"))?; + env.call_method( + &context, + "startActivity", + "(Landroid/content/Intent;)V", + &[JValue::Object(&intent)], + ) + .map_err(|error| format!("start Android URL activity: {error}"))?; + + Ok(()) +} + +#[cfg(not(target_os = "android"))] +fn platform_open_external_url(_url: &str) -> Result<(), String> { + Err("native external URL fallback is only wired on Android".to_string()) +} diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 3ecdc4e9..d4c5ac18 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -5,15 +5,19 @@ //! - macOS:用 CoreGraphics CGEvent 直接 post Cmd+V。 //! - Windows / Linux:用 enigo 按 `PasteShortcut` 模拟。 +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] use std::time::Duration; +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] use once_cell::sync::Lazy; +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] use parking_lot::Mutex; use crate::types::{InsertStatus, PasteShortcut}; -/// 粘贴完成到尝试恢复剪贴板之间的延迟,给目标应用读取剪贴板留出时间。 +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750); pub struct TextInserter; @@ -56,7 +60,7 @@ impl TextInserter { insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut) } - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] pub fn insert_via_clipboard_fallback( &self, text: &str, @@ -83,37 +87,35 @@ impl TextInserter { } } - /// macOS 路径:保存原剪贴板 → 写转写文字 → post Cmd+V → 按需恢复原剪贴板。 - /// `paste_shortcut` 在 macOS 不使用(固定 Cmd+V),仅为对齐跨平台签名。 + /// macOS 路径:写剪贴板 + post Cmd+V。两个 `_` 参数仅为对齐跨平台签名。 #[cfg(target_os = "macos")] pub fn insert( &self, text: &str, restore_clipboard_after_paste: bool, - _paste_shortcut: PasteShortcut, + paste_shortcut: PasteShortcut, ) -> InsertStatus { if text.is_empty() { return InsertStatus::CopiedFallback; } - // issue #525:先记下用户原剪贴板,粘贴成功后再恢复,避免覆盖用户手动复制的内容。 - // 此前 macOS 完全不实现恢复(恢复机制曾被 cfg(not macos) 排除),导致设置里的 - // 「恢复剪贴板」开关在 macOS 上无效。 - let restore_plan = match copy_to_clipboard_with_restore_plan(text) { - Ok(plan) => plan, - Err(err) => { - log::error!("[insertion] clipboard write failed: {}", err); - return InsertStatus::Failed; - } - }; - if let Err(err) = simulate_paste() { - log::warn!("[insertion] simulated paste failed: {}", err); - // 粘贴失败:把转写文字留在剪贴板供用户手动粘贴,不恢复。 - return InsertStatus::CopiedFallback; + if !copy_to_clipboard(text) { + return InsertStatus::Failed; } - if restore_clipboard_after_paste { - schedule_clipboard_restore(restore_plan); + macos_insert_status_after_paste(simulate_paste()) + } + + /// Android:跨应用输入由 dictation 流程按用户策略处理;通用插入只写剪贴板兜底。 + #[cfg(target_os = "android")] + pub fn insert( + &self, + text: &str, + _restore_clipboard_after_paste: bool, + _paste_shortcut: PasteShortcut, + ) -> InsertStatus { + if text.is_empty() { + return InsertStatus::CopiedFallback; } - insertion_success_status() + self.copy_fallback(text) } /// 只写剪贴板、不模拟粘贴。用于目标控件活跃状态无法验证时的兜底路径。 @@ -155,29 +157,45 @@ where } } +#[cfg(target_os = "macos")] +fn macos_insert_status_after_paste(result: Result<(), String>) -> InsertStatus { + match result { + Ok(()) => insertion_success_status(), + Err(err) => { + log::warn!("[insertion] simulated paste failed: {}", err); + InsertStatus::CopiedFallback + } + } +} + impl Default for TextInserter { fn default() -> Self { Self::new() } } +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] #[derive(Debug)] struct ClipboardRestorePlan { inserted_text: String, previous_text: Option, } +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] #[derive(Debug, Clone)] struct PendingClipboardRestore { latest_restore_id: u64, original_text: Option, } +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] static NEXT_CLIPBOARD_RESTORE_ID: AtomicU64 = AtomicU64::new(1); +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] static PENDING_CLIPBOARD_RESTORE: Lazy>> = Lazy::new(|| Mutex::new(None)); +#[cfg(not(any(target_os = "android", target_os = "ios")))] fn copy_to_clipboard(text: &str) -> bool { let mut clipboard = match arboard::Clipboard::new() { Ok(c) => c, @@ -193,6 +211,28 @@ fn copy_to_clipboard(text: &str) -> bool { true } +#[cfg(any(target_os = "android", target_os = "ios"))] +fn copy_to_clipboard(text: &str) -> bool { + #[cfg(target_os = "android")] + { + return crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::copy_to_clipboard(env, context, text) + }) + .unwrap_or_else(|error| { + log::error!("[insertion] android clipboard failed: {error}"); + false + }); + } + + #[cfg(target_os = "ios")] + { + let _ = text; + log::warn!("[insertion] mobile clipboard fallback unavailable"); + false + } +} + +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn copy_to_clipboard_with_restore_plan(text: &str) -> Result { let mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?; let previous_text = match clipboard.get_text() { @@ -214,7 +254,7 @@ fn copy_to_clipboard_with_restore_plan(text: &str) -> Result) -> (u64, Option) { let restore_id = NEXT_CLIPBOARD_RESTORE_ID.fetch_add(1, Ordering::SeqCst); let original_text = { @@ -264,6 +306,7 @@ fn remember_pending_clipboard_restore(previous_text: Option) -> (u64, Op (restore_id, original_text) } +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn restore_clipboard_after_delay( plan: ClipboardRestorePlan, original_text: Option, @@ -314,6 +357,7 @@ fn restore_clipboard_after_delay( clear_pending_clipboard_restore(restore_id); } +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn is_latest_clipboard_restore(restore_id: u64) -> bool { matches!( PENDING_CLIPBOARD_RESTORE.lock().as_ref(), @@ -321,6 +365,7 @@ fn is_latest_clipboard_restore(restore_id: u64) -> bool { ) } +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn clear_pending_clipboard_restore(restore_id: u64) { let mut pending = PENDING_CLIPBOARD_RESTORE.lock(); if matches!(pending.as_ref(), Some(batch) if batch.latest_restore_id == restore_id) { @@ -328,6 +373,7 @@ fn clear_pending_clipboard_restore(restore_id: u64) { } } +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn should_restore_clipboard(current_text: Option<&str>, inserted_text: &str) -> bool { matches!(current_text, Some(current) if current == inserted_text) } @@ -344,7 +390,7 @@ fn simulate_paste() -> Result<(), String> { } /// 把 `PasteShortcut` 拆成 `(modifiers, primary)`,顺序决定按下/释放顺序。 -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn paste_keys(shortcut: PasteShortcut) -> (Vec, enigo::Key) { use enigo::Key; match shortcut { @@ -354,7 +400,7 @@ fn paste_keys(shortcut: PasteShortcut) -> (Vec, enigo::Key) { } } -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn simulate_paste(shortcut: PasteShortcut) -> Result<(), String> { use enigo::{Direction, Enigo, Keyboard, Settings}; let (modifiers, primary) = paste_keys(shortcut); @@ -398,7 +444,7 @@ fn insertion_success_status() -> InsertStatus { InsertStatus::Inserted } -#[cfg(not(target_os = "macos"))] +#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn insertion_success_status() -> InsertStatus { InsertStatus::PasteSent } @@ -548,6 +594,7 @@ mod tests { use std::time::Duration; #[test] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn restore_only_when_clipboard_still_holds_inserted_text() { assert!(should_restore_clipboard( Some("dictated text"), @@ -562,7 +609,7 @@ mod tests { /// 配置的快捷键必须真实映射到对应按键。只比较 modifier 数 + 主键,规避 enigo 内部 PartialEq。 #[test] - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn paste_keys_match_configured_shortcut() { use enigo::Key; @@ -591,7 +638,7 @@ mod tests { inserter.insert("", true, PasteShortcut::CtrlV), InsertStatus::CopiedFallback ); - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] { assert_eq!( inserter.insert_via_clipboard_fallback("", true, PasteShortcut::CtrlV), @@ -677,7 +724,7 @@ mod tests { } #[test] - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn pending_clipboard_restore_keeps_first_original_until_latest_restore() { *PENDING_CLIPBOARD_RESTORE.lock() = None; @@ -699,7 +746,7 @@ mod tests { } #[test] - #[cfg(not(target_os = "macos"))] + #[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))] fn clipboard_restore_skips_when_clipboard_no_longer_matches_inserted_text() { assert!(should_restore_clipboard( Some("dictated text"), @@ -714,17 +761,15 @@ mod tests { #[test] #[cfg(target_os = "macos")] - fn macos_paste_success_reports_inserted_and_guards_restore() { - // 粘贴成功 → Inserted;恢复仅在剪贴板仍是刚插入的转写文字时进行(issue #525)。 - assert_eq!(insertion_success_status(), InsertStatus::Inserted); - assert!(should_restore_clipboard( - Some("dictated text"), - "dictated text" - )); - assert!(!should_restore_clipboard( - Some("user changed clipboard"), - "dictated text" - )); + fn macos_direct_write_or_paste_failure_keeps_copied_fallback_available() { + assert_eq!( + macos_insert_status_after_paste(Ok(())), + InsertStatus::Inserted + ); + assert_eq!( + macos_insert_status_after_paste(Err("AX direct write unavailable".to_string())), + InsertStatus::CopiedFallback + ); } #[test] diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 9f6bc010..021ee9fa 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -14,35 +14,70 @@ //! - coordinator: dictation state machine glue //! - commands: Tauri IPC surface +mod android; mod asr; mod audio_mute; mod cli; mod coding_agent; +#[cfg(not(mobile))] +mod combo_hotkey; +#[cfg(mobile)] +#[path = "mobile_stubs/combo_hotkey.rs"] mod combo_hotkey; mod commands; mod coordinator; mod coordinator_state; mod correction; +mod external_url; +#[cfg(not(mobile))] mod global_hotkey_runtime; +#[cfg(not(mobile))] +#[path = "hotkey.rs"] +mod hotkey; +#[cfg(mobile)] +#[path = "mobile_stubs/hotkey.rs"] mod hotkey; mod insertion; #[cfg(target_os = "linux")] mod linux_fcitx; mod llm_gemini; +#[cfg(mobile)] +mod mobile_runtime; mod net; mod permissions; mod persistence; mod polish; +#[cfg(not(mobile))] +mod qa_hotkey; +#[cfg(mobile)] +#[path = "mobile_stubs/qa_hotkey.rs"] mod qa_hotkey; mod recorder; +#[cfg(not(mobile))] mod remote_server; +#[cfg(not(mobile))] +#[path = "selection.rs"] mod selection; +#[cfg(mobile)] +#[path = "mobile_stubs/selection.rs"] +mod selection; +#[cfg(not(mobile))] +mod shortcut_binding; +#[cfg(mobile)] +#[path = "mobile_stubs/shortcut_binding.rs"] mod shortcut_binding; mod types; +#[cfg(not(mobile))] +mod unicode_keystroke; +#[cfg(mobile)] +#[path = "mobile_stubs/unicode_keystroke.rs"] mod unicode_keystroke; +#[cfg(target_os = "windows")] mod windows_ime_ipc; mod windows_ime_profile; +#[cfg(target_os = "windows")] mod windows_ime_protocol; +#[cfg(target_os = "windows")] mod windows_ime_session; use std::sync::atomic::{AtomicBool, Ordering}; @@ -58,10 +93,13 @@ const OPENLESS_BUNDLE_ID: &str = "com.openless.app"; /// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition, /// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。 static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false); +#[cfg(not(mobile))] static TRAY_MICROPHONE_WATCHER_STOPPING: AtomicBool = AtomicBool::new(false); +#[cfg(not(mobile))] use tauri::menu::{ CheckMenuItemBuilder, Menu, MenuBuilder, MenuItemBuilder, Submenu, SubmenuBuilder, }; +#[cfg(not(mobile))] use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent}; use tauri::{ AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalPosition, PhysicalSize, @@ -72,6 +110,267 @@ use crate::types::PolishMode; #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + #[cfg(mobile)] + { + mobile_runtime::run(); + return; + } + #[cfg(not(mobile))] + run_desktop(); +} + +macro_rules! app_invoke_handler_desktop { + () => { + tauri::generate_handler![ + commands::get_settings, + commands::get_default_style_system_prompts, + commands::set_settings, + commands::get_remote_input_status, + commands::list_local_ips, + commands::regenerate_remote_pin, + commands::set_remote_locale, + commands::get_update_channel, + commands::set_update_channel, + commands::fetch_latest_beta_release, + commands::app_check_update_with_channel, + commands::check_network, + commands::get_hotkey_status, + commands::get_hotkey_capability, + commands::set_shortcut_recording_active, + commands::get_windows_ime_status, + commands::get_platform_capabilities, + commands::get_android_overlay_status, + commands::request_android_overlay_permission, + commands::show_android_overlay, + commands::hide_android_overlay, + commands::get_android_accessibility_status, + commands::request_android_accessibility_permission, + commands::open_external_url, + commands::list_microphone_devices, + commands::start_microphone_level_monitor, + commands::stop_microphone_level_monitor, + commands::get_credentials, + commands::set_credential, + commands::list_history, + commands::delete_history_entry, + commands::clear_history, + commands::read_audio_recording, + commands::retranscribe_recording, + commands::marketplace_list, + commands::marketplace_detail, + commands::marketplace_install, + commands::marketplace_upload, + commands::marketplace_like, + commands::marketplace_my_likes, + commands::marketplace_my_packs, + commands::marketplace_delete, + commands::github_device_flow_start, + commands::github_device_flow_poll, + commands::list_vocab, + commands::add_vocab, + commands::remove_vocab, + commands::set_vocab_enabled, + commands::list_correction_rules, + commands::add_correction_rule, + commands::remove_correction_rule, + commands::set_correction_rule_enabled, + commands::list_vocab_presets, + commands::save_vocab_presets, + commands::start_dictation, + commands::stop_dictation, + commands::cancel_dictation, + coding_agent::commands::coding_agent_detect, + coding_agent::commands::coding_agent_run_test, + coding_agent::commands::coding_agent_cancel_test, + coding_agent::commands::coding_agent_command_risk, + commands::handle_window_hotkey_event, + #[cfg(debug_assertions)] + commands::inject_hotkey_click_for_dev, + commands::repolish, + commands::list_style_packs, + commands::create_style_pack_from_template, + commands::save_style_pack, + commands::preview_style_pack_runtime, + commands::set_active_style_pack, + commands::set_style_pack_enabled, + commands::reset_builtin_style_pack, + commands::delete_style_pack, + commands::import_style_pack_from_zip, + commands::export_style_pack_to_zip, + commands::set_default_polish_mode, + commands::set_style_enabled, + commands::check_accessibility_permission, + commands::request_accessibility_permission, + commands::check_microphone_permission, + commands::request_microphone_permission, + commands::open_system_settings, + commands::trigger_microphone_prompt, + commands::read_credential, + commands::set_active_asr_provider, + commands::set_active_llm_provider, + commands::get_qa_hotkey_label, + commands::set_qa_hotkey, + commands::validate_shortcut_binding, + commands::set_dictation_hotkey, + commands::set_translation_hotkey, + commands::set_switch_style_hotkey, + commands::set_open_app_hotkey, + commands::qa_window_dismiss, + commands::qa_window_pin, + commands::less_computer_window_dismiss, + commands::less_computer_window_resize, + commands::less_computer_approve, + commands::validate_combo_hotkey, + commands::set_combo_hotkey, + commands::validate_provider_credentials, + commands::list_provider_models, + commands::local_asr_get_settings, + commands::local_asr_storage_settings, + commands::local_asr_set_models_base_dir, + commands::local_asr_set_active_model, + commands::local_asr_set_mirror, + commands::local_asr_list_models, + commands::local_asr_fetch_remote_info, + commands::local_asr_download_model, + commands::local_asr_cancel_download, + commands::local_asr_delete_model, + commands::local_asr_model_dir, + commands::local_asr_reveal_model_dir, + commands::local_asr_reveal_models_root, + commands::local_asr_test_model, + commands::local_asr_engine_status, + commands::local_asr_release_engine, + commands::local_asr_preload, + commands::local_asr_set_keep_loaded_secs, + commands::foundry_local_asr_status, + commands::foundry_local_asr_catalog, + commands::foundry_local_asr_set_model, + commands::foundry_local_asr_set_language_hint, + commands::foundry_local_asr_set_runtime_source, + commands::foundry_local_asr_prepare, + commands::foundry_local_asr_cancel_prepare, + commands::foundry_local_asr_release, + commands::foundry_local_asr_model_dir, + commands::foundry_local_asr_delete_model, + commands::foundry_local_asr_reveal_model_dir, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_status, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_catalog, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_fetch_remote_info, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_download_model, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_cancel_download, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_set_model, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_set_language_hint, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_prepare, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_cancel_prepare, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_release, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_model_dir, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_delete_model, + #[cfg(target_os = "windows")] + commands::sherpa_onnx_asr_reveal_model_dir, + commands::export_error_log, + restart_app, + ] + }; +} + +/// Android/iOS: only commands usable without desktop hotkeys, tray, updater, or local ASR. +#[macro_export] +macro_rules! app_invoke_handler_mobile { + () => { + tauri::generate_handler![ + $crate::commands::get_settings, + $crate::commands::get_default_style_system_prompts, + $crate::commands::set_settings, + $crate::commands::check_network, + $crate::commands::get_platform_capabilities, + $crate::commands::get_android_overlay_status, + $crate::commands::request_android_overlay_permission, + $crate::commands::show_android_overlay, + $crate::commands::hide_android_overlay, + $crate::commands::get_android_accessibility_status, + $crate::commands::request_android_accessibility_permission, + $crate::commands::open_external_url, + $crate::commands::list_microphone_devices, + $crate::commands::start_microphone_level_monitor, + $crate::commands::stop_microphone_level_monitor, + $crate::commands::get_credentials, + $crate::commands::set_credential, + $crate::commands::read_credential, + $crate::commands::set_active_asr_provider, + $crate::commands::set_active_llm_provider, + $crate::commands::validate_provider_credentials, + $crate::commands::list_provider_models, + $crate::commands::list_history, + $crate::commands::delete_history_entry, + $crate::commands::clear_history, + $crate::commands::read_audio_recording, + $crate::commands::retranscribe_recording, + $crate::commands::marketplace_list, + $crate::commands::marketplace_detail, + $crate::commands::marketplace_install, + $crate::commands::marketplace_upload, + $crate::commands::marketplace_like, + $crate::commands::marketplace_my_likes, + $crate::commands::marketplace_my_packs, + $crate::commands::marketplace_delete, + $crate::commands::github_device_flow_start, + $crate::commands::github_device_flow_poll, + $crate::commands::list_vocab, + $crate::commands::add_vocab, + $crate::commands::remove_vocab, + $crate::commands::set_vocab_enabled, + $crate::commands::list_correction_rules, + $crate::commands::add_correction_rule, + $crate::commands::remove_correction_rule, + $crate::commands::set_correction_rule_enabled, + $crate::commands::list_vocab_presets, + $crate::commands::save_vocab_presets, + $crate::commands::start_dictation, + $crate::commands::stop_dictation, + $crate::commands::cancel_dictation, + $crate::commands::qa_window_dismiss, + $crate::commands::qa_window_pin, + $crate::commands::qa_toggle_recording, + $crate::commands::qa_submit_text, + $crate::commands::repolish, + $crate::commands::list_style_packs, + $crate::commands::create_style_pack_from_template, + $crate::commands::save_style_pack, + $crate::commands::preview_style_pack_runtime, + $crate::commands::set_active_style_pack, + $crate::commands::set_style_pack_enabled, + $crate::commands::reset_builtin_style_pack, + $crate::commands::delete_style_pack, + $crate::commands::import_style_pack_from_zip, + $crate::commands::export_style_pack_to_zip, + $crate::commands::set_default_polish_mode, + $crate::commands::set_style_enabled, + $crate::commands::check_accessibility_permission, + $crate::commands::request_accessibility_permission, + $crate::commands::check_microphone_permission, + $crate::commands::request_microphone_permission, + $crate::commands::open_system_settings, + $crate::commands::trigger_microphone_prompt, + $crate::commands::export_error_log, + $crate::restart_app, + ] + }; +} + +#[cfg(not(mobile))] +fn run_desktop() { let foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new()); let sherpa_onnx_runtime = Arc::new(asr::local::SherpaOnnxRuntime::new()); let sherpa_download_manager = @@ -328,8 +627,6 @@ pub fn run() { let app_handle = app.handle().clone(); coordinator.bind_app(app_handle); coordinator.start_hotkey_listener(); - // 远程输入:按 prefs 启动局域网录音服务(未启用时为 no-op)。 - coordinator.refresh_remote_server(); // QA / custom combo hotkeys use `global-hotkey` (Carbon on macOS). // Start those after RunEvent::Ready, when the AppKit event loop is live. if std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1") { @@ -346,159 +643,7 @@ pub fn run() { Ok(()) }) - .invoke_handler(tauri::generate_handler![ - commands::get_settings, - commands::get_default_style_system_prompts, - commands::set_settings, - commands::get_remote_input_status, - commands::list_local_ips, - commands::regenerate_remote_pin, - commands::set_remote_locale, - commands::get_update_channel, - commands::set_update_channel, - commands::fetch_latest_beta_release, - commands::app_check_update_with_channel, - commands::check_network, - commands::get_hotkey_status, - commands::get_hotkey_capability, - commands::set_shortcut_recording_active, - commands::get_windows_ime_status, - commands::list_microphone_devices, - commands::start_microphone_level_monitor, - commands::stop_microphone_level_monitor, - commands::get_credentials, - commands::set_credential, - commands::list_history, - commands::delete_history_entry, - commands::clear_history, - commands::read_audio_recording, - commands::retranscribe_recording, - commands::marketplace_list, - commands::marketplace_detail, - commands::marketplace_install, - commands::marketplace_upload, - commands::marketplace_like, - commands::marketplace_my_likes, - commands::marketplace_my_packs, - commands::marketplace_delete, - commands::github_device_flow_start, - commands::github_device_flow_poll, - commands::list_vocab, - commands::add_vocab, - commands::remove_vocab, - commands::set_vocab_enabled, - commands::list_correction_rules, - commands::add_correction_rule, - commands::remove_correction_rule, - commands::set_correction_rule_enabled, - commands::list_vocab_presets, - commands::save_vocab_presets, - commands::start_dictation, - commands::stop_dictation, - commands::cancel_dictation, - coding_agent::commands::coding_agent_detect, - coding_agent::commands::coding_agent_run_test, - coding_agent::commands::coding_agent_cancel_test, - coding_agent::commands::coding_agent_command_risk, - commands::handle_window_hotkey_event, - #[cfg(debug_assertions)] - commands::inject_hotkey_click_for_dev, - commands::repolish, - commands::list_style_packs, - commands::create_style_pack_from_template, - commands::save_style_pack, - commands::preview_style_pack_runtime, - commands::set_active_style_pack, - commands::set_style_pack_enabled, - commands::reset_builtin_style_pack, - commands::delete_style_pack, - commands::import_style_pack_from_zip, - commands::export_style_pack_to_zip, - commands::set_default_polish_mode, - commands::set_style_enabled, - commands::check_accessibility_permission, - commands::request_accessibility_permission, - commands::check_microphone_permission, - commands::request_microphone_permission, - commands::open_system_settings, - commands::trigger_microphone_prompt, - commands::read_credential, - commands::set_active_asr_provider, - commands::set_active_llm_provider, - commands::get_qa_hotkey_label, - commands::set_qa_hotkey, - commands::validate_shortcut_binding, - commands::set_dictation_hotkey, - commands::set_translation_hotkey, - commands::set_switch_style_hotkey, - commands::set_open_app_hotkey, - commands::qa_window_dismiss, - commands::qa_window_pin, - commands::less_computer_window_dismiss, - commands::less_computer_window_resize, - commands::less_computer_approve, - commands::validate_combo_hotkey, - commands::set_combo_hotkey, - commands::validate_provider_credentials, - commands::list_provider_models, - commands::local_asr_get_settings, - commands::local_asr_storage_settings, - commands::local_asr_set_models_base_dir, - commands::local_asr_set_active_model, - commands::local_asr_set_mirror, - commands::local_asr_list_models, - commands::local_asr_fetch_remote_info, - commands::local_asr_download_model, - commands::local_asr_cancel_download, - commands::local_asr_delete_model, - commands::local_asr_model_dir, - commands::local_asr_reveal_model_dir, - commands::local_asr_reveal_models_root, - commands::local_asr_test_model, - commands::local_asr_engine_status, - commands::local_asr_release_engine, - commands::local_asr_preload, - commands::local_asr_set_keep_loaded_secs, - commands::foundry_local_asr_status, - commands::foundry_local_asr_catalog, - commands::foundry_local_asr_set_model, - commands::foundry_local_asr_set_language_hint, - commands::foundry_local_asr_set_runtime_source, - commands::foundry_local_asr_prepare, - commands::foundry_local_asr_cancel_prepare, - commands::foundry_local_asr_release, - commands::foundry_local_asr_model_dir, - commands::foundry_local_asr_delete_model, - commands::foundry_local_asr_reveal_model_dir, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_status, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_catalog, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_fetch_remote_info, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_download_model, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_cancel_download, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_set_model, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_set_language_hint, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_prepare, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_cancel_prepare, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_release, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_model_dir, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_delete_model, - #[cfg(target_os = "windows")] - commands::sherpa_onnx_asr_reveal_model_dir, - commands::export_error_log, - restart_app, - ]) + .invoke_handler(app_invoke_handler_desktop!()) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|app, event| match event { @@ -539,21 +684,25 @@ pub fn run() { }); } +#[cfg(not(mobile))] struct MicrophoneTrayMenu { submenu: Submenu, items: Vec, } +#[cfg(not(mobile))] struct StyleTrayMenu { submenu: Submenu, } +#[cfg(not(mobile))] struct TrayMenu { menu: Menu, microphone_items: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] +#[cfg(not(mobile))] struct TrayPolishModeMenuEntry { id: String, label: &'static str, @@ -562,9 +711,13 @@ struct TrayPolishModeMenuEntry { } fn tray_style_menu_enabled() -> bool { - cfg!(target_os = "windows") + #[cfg(all(not(mobile), target_os = "windows"))] + return true; + #[cfg(not(all(not(mobile), target_os = "windows")))] + false } +#[cfg(not(mobile))] fn tray_polish_mode_menu_entries(selected: PolishMode) -> Vec { [ (PolishMode::Raw, "style-raw"), @@ -582,6 +735,7 @@ fn tray_polish_mode_menu_entries(selected: PolishMode) -> Vec Option { match id { "style-raw" => Some(PolishMode::Raw), @@ -592,6 +746,7 @@ fn parse_tray_polish_mode_id(id: &str) -> Option { } } +#[cfg(not(mobile))] fn build_tray_menu>( app: &M, coordinator: &Arc, @@ -617,6 +772,7 @@ fn build_tray_menu>( }) } +#[cfg(not(mobile))] fn build_style_tray_menu>( app: &M, coordinator: &Arc, @@ -639,6 +795,7 @@ fn build_style_tray_menu>( }) } +#[cfg(not(mobile))] fn build_microphone_tray_menu>( app: &M, coordinator: &Arc, @@ -697,6 +854,7 @@ fn build_microphone_tray_menu>( }) } +#[cfg(not(mobile))] pub(crate) fn refresh_tray_microphone_menu(app: &AppHandle) -> tauri::Result<()> { let coordinator = app.state::>(); let tray_menu = build_tray_menu(app, &coordinator)?; @@ -708,6 +866,7 @@ pub(crate) fn refresh_tray_microphone_menu(app: &AppHandle) -> tauri::Result<()> Ok(()) } +#[cfg(not(mobile))] fn microphone_device_signature() -> Option> { match recorder::list_input_devices() { Ok(devices) => Some( @@ -723,6 +882,7 @@ fn microphone_device_signature() -> Option> { } } +#[cfg(not(mobile))] fn start_tray_microphone_watcher(app: AppHandle) { TRAY_MICROPHONE_WATCHER_STOPPING.store(false, Ordering::Relaxed); if let Err(err) = std::thread::Builder::new() @@ -756,6 +916,7 @@ fn start_tray_microphone_watcher(app: AppHandle) { } } +#[cfg(not(mobile))] fn handle_microphone_tray_menu_event(app: &AppHandle, id: &str) { let tray_items = app.state::(); let items = tray_items.lock(); @@ -775,6 +936,7 @@ fn handle_microphone_tray_menu_event(app: &AppHandle, id: &str) { commands::sync_tray_microphone_selection(&items, &selected.device_name); } +#[cfg(not(mobile))] fn handle_style_tray_menu_event(app: &AppHandle, id: &str) -> bool { let Some(mode) = parse_tray_polish_mode_id(id) else { return false; @@ -790,6 +952,11 @@ fn handle_style_tray_menu_event(app: &AppHandle, id: &str) -> bool { true } +#[cfg(mobile)] +pub(crate) fn refresh_tray_microphone_menu(_app: &AppHandle) -> tauri::Result<()> { + Ok(()) +} + /// 把 Win11 原生标题栏底色刷成白色,与应用 sidebar 视觉统一。需要 Win11 22H2+ /// (Build 22621+) 才支持 `DWMWA_CAPTION_COLOR`(35);老 Windows 上 DwmSetWindowAttribute /// 返回错误,仅打 warn 不阻塞启动。 @@ -955,7 +1122,7 @@ pub fn log_dir_path() -> std::path::PathBuf { .join("Logs"); } } - #[cfg(all(unix, not(target_os = "macos")))] + #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))] { if let Ok(home) = std::env::var("HOME") { return std::path::PathBuf::from(home) @@ -965,6 +1132,12 @@ pub fn log_dir_path() -> std::path::PathBuf { .join("logs"); } } + #[cfg(target_os = "android")] + { + if let Ok(dir) = std::env::var("TAURI_ANDROID_APP_DATA_DIR") { + return std::path::PathBuf::from(dir).join("logs"); + } + } std::env::temp_dir().join("OpenLess") } @@ -972,6 +1145,7 @@ pub(crate) fn show_main_window(app: &AppHandle) { activate_window_mode(app); if let Some(w) = app.get_webview_window("main") { let _ = w.show(); + #[cfg(not(mobile))] let _ = w.unminimize(); let _ = w.set_focus(); } @@ -1215,35 +1389,6 @@ fn bottom_visual_position( (x, y) } -/// 把窗口左上角 `(x, y)`(同 area 同坐标系,physical px)夹到给定矩形内, -/// **保证整窗(含自身 w×h)落在 area 内可见**。area 为工作区时即可避开任务栏。 -/// -/// 纯函数,无 Win32 依赖,便于单测多显示器 / 负原点 / 异常 DPI 输入。issue #470: -/// 此前 Windows 分支只夹上边(`y.max(mon.top)`),左/右/下未夹,多屏负坐标下胶囊 -/// 可能被算到屏外却无任何观测。这里四边都夹。 -/// -/// area 比窗口还小时(`area_right - w < area_left`),`max_x` 退化为 `area_left`, -/// `clamp` 把左上角收回 area 左上角,保证至少左上角可见、不溢出为负超界。 -#[cfg_attr(not(target_os = "windows"), allow(dead_code))] -fn clamp_to_monitor( - x: i32, - y: i32, - w: i32, - h: i32, - area_left: i32, - area_top: i32, - area_right: i32, - area_bottom: i32, -) -> (i32, i32) { - // 右/下边界 = area 右下角减去窗口自身尺寸,确保整窗可见。 - // 用 saturating_sub 防 area_right/area_bottom 为极小(含 i32::MIN 近邻)时减法溢出。 - let max_x = area_right.saturating_sub(w).max(area_left); - let max_y = area_bottom.saturating_sub(h).max(area_top); - let clamped_x = x.clamp(area_left, max_x); - let clamped_y = y.clamp(area_top, max_y); - (clamped_x, clamped_y) -} - /// 把 QA 浮窗放到屏幕底部居中、紧贴胶囊上方。tauri 启动期 + show 之前都会调一次, /// 防止用户切换显示器后位置错乱。 fn position_qa_window(window: &tauri::WebviewWindow) -> tauri::Result<()> { @@ -1269,21 +1414,36 @@ fn position_qa_window(window: &tauri::WebviewWindow) -> ta /// 显示 QA 窗口并发一条状态事件(前端订阅 `qa:state`)。 /// `content_kind` 是不透明字符串("loading" / "answer" / "idle" 等), -/// 让前端 React 视图自行决定渲染哪一种。 -/// -/// ## 跨端焦点契约(#164 / #466,三端**有意**不同,勿盲目对齐) -/// - **macOS**:`orderFrontRegardless` —— 窗口可见但不成为 key window,frontmost -/// 始终是用户原 app,AX / Cmd+C fallback 能直接读到选区。**全程不抢焦点**。 -/// - **Windows**:`show_qa_window_no_activate` 实际是 `show()` + `set_focus()`, -/// 出现的那一帧会短暂抢前台。这是 #466 对 #164 的**有意取舍**:WebView2 子窗口有 -/// 独立 focus 模型,不主动抓焦点则 QA webview 收不到键盘事件 → ESC 到不了 React -/// 监听、X 按钮 first-click 被 OS 当激活点击吃掉。代价由 `coordinator/qa.rs` 的 -/// focus-dance 补偿:抓选区前用 `qa_focus_target` 把焦点临时还给用户原 app, -/// `simulate_copy` 跑完再 `refocus_qa_window` 收回。**移除 set_focus 会同时回归 -/// #164 的反面(ESC/X 失效),别删。** 详见 `show_qa_window_no_activate` 内注释。 -/// - **Linux**:`window.show()`。qa 窗口静态配置 `focus: false`(tauri.conf.json), -/// Tauri 将其建成非激活窗口,因此 show() **不抢焦点**,与 macOS 契约一致。 +/// 让前端 React 视图自行决定渲染哪一种。**不**抢前台 app 焦点(保证 Cmd+C +/// fallback 仍能从原 app 拿到选区)。 pub(crate) fn show_qa_window(app: &AppHandle, content_kind: &str) { + #[cfg(target_os = "android")] + { + const FLAG_ACTIVITY_NEW_TASK: i32 = 0x10000000; + const FLAG_ACTIVITY_REORDER_TO_FRONT: i32 = 0x00020000; + const FLAG_ACTIVITY_SINGLE_TOP: i32 = 0x20000000; + let flags = + FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_REORDER_TO_FRONT | FLAG_ACTIVITY_SINGLE_TOP; + match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::start_activity_class_with_flags( + env, + context, + "com.openless.app.MainActivity", + flags, + ) + }) { + Ok(()) => log::info!("[qa] android requested MainActivity foreground for QA"), + Err(error) => log::warn!("[qa] android failed to foreground MainActivity: {error}"), + } + log::info!("[qa] android emit qa:state to main kind={content_kind}"); + let _ = app.emit_to( + "main", + "qa:state", + serde_json::json!({ "kind": content_kind }), + ); + return; + } + let Some(window) = app.get_webview_window("qa") else { log::info!("[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})"); return; @@ -1335,8 +1495,6 @@ pub(crate) fn show_qa_window(app: &AppHandle, content_kind log::warn!("[qa] show fallback failed: {e}"); } } - // Linux:qa 窗口静态配置 focus:false → Tauri 建成非激活窗口,window.show() 不抢 - // 焦点,与 macOS「不抢焦点」契约一致(无需 Windows 那套 set_focus + focus-dance)。 #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] if let Err(e) = window.show() { log::warn!("[qa] show failed: {e}"); @@ -1378,6 +1536,12 @@ fn make_qa_window_draggable_macos(window: &tauri::WebviewWind /// 隐藏 QA 窗口。供 commands::qa_window_dismiss / coordinator session 收尾共用。 pub(crate) fn hide_qa_window(app: &AppHandle) { + #[cfg(target_os = "android")] + { + let _ = app.emit_to("main", "qa:dismiss", serde_json::json!({})); + return; + } + if let Some(window) = app.get_webview_window("qa") { let _ = window.hide(); } @@ -1491,22 +1655,18 @@ pub(crate) fn show_less_computer_glow(app: &AppHandle) { .ok() .flatten() .or_else(|| app.primary_monitor().ok().flatten()); - // 逻辑坐标的「铺满整屏」矩形 (x, y, w, h)。f64 元组可 Copy:既在 show 前先铺一次, - // 也在主线程 realize(orderFront)后再铺一次(见下,修首次半屏 bug)。 - let bounds: Option<(f64, f64, f64, f64)> = monitor.map(|m| { - let scale = m.scale_factor(); - let size = m.size(); - let pos = m.position(); - ( + if let Some(monitor) = monitor { + let scale = monitor.scale_factor(); + let size = monitor.size(); + let pos = monitor.position(); + let _ = window.set_position(tauri::LogicalPosition::new( pos.x as f64 / scale, pos.y as f64 / scale, + )); + let _ = window.set_size(tauri::LogicalSize::new( size.width as f64 / scale, size.height as f64 / scale, - ) - }); - if let Some((x, y, w, h)) = bounds { - let _ = window.set_position(tauri::LogicalPosition::new(x, y)); - let _ = window.set_size(tauri::LogicalSize::new(w, h)); + )); } // 点击穿透:纯视觉浮层,绝不拦截鼠标。 let _ = window.set_ignore_cursor_events(true); @@ -1534,14 +1694,6 @@ pub(crate) fn show_less_computer_glow(app: &AppHandle) { let _ = window_clone.show(); } } - // 首次使用彩虹边框只画半屏并卡住:glow 窗口 conf 初始 800×600 且 visible:false, - // 首次 show 前从未 realize —— current_monitor() 取不到 / show 前的 set_size 没贴住整屏, - // webview 首帧按 800×600 画出半屏描边。这里在 realize(orderFront)之后**再铺满一次**, - // 强制 webview 按整屏重排重绘。后续使用窗口已 realize,show 前那次就够、不闪。 - if let Some((x, y, w, h)) = bounds { - let _ = window_clone.set_position(tauri::LogicalPosition::new(x, y)); - let _ = window_clone.set_size(tauri::LogicalSize::new(w, h)); - } }); } @@ -1645,12 +1797,6 @@ pub(crate) struct ForegroundMonitor { pub(crate) top: i32, pub(crate) right: i32, pub(crate) bottom: i32, - /// 工作区矩形(physical px,去掉任务栏)。多端一致:胶囊优先夹到工作区内, - /// 避免压住任务栏。取不到时回退为整屏矩形。issue #470。 - pub(crate) work_left: i32, - pub(crate) work_top: i32, - pub(crate) work_right: i32, - pub(crate) work_bottom: i32, /// 该显示器的有效 DPI 缩放(1.0 = 96dpi)。 pub(crate) scale: f64, } @@ -1688,10 +1834,6 @@ pub(crate) fn foreground_window_monitor() -> Option { top: mi.rcMonitor.top, right: mi.rcMonitor.right, bottom: mi.rcMonitor.bottom, - work_left: mi.rcWork.left, - work_top: mi.rcWork.top, - work_right: mi.rcWork.right, - work_bottom: mi.rcWork.bottom, scale: (dpi_x as f64 / 96.0).max(0.1), }) } @@ -1724,24 +1866,15 @@ pub(crate) fn position_capsule_bottom_center( let offset_from_bottom = (capsule_visual_height(translation_active) + 80.0 + bounds.bottom_inset) * scale; let y = ((mon.bottom as f64) - offset_from_bottom).round() as i32; - - // #470:四边都夹到「工作区」内(去掉任务栏),保证整窗可见。GetMonitorInfoW - // 取不到 rcWork 时(理论上不会,rcWork 总随 rcMonitor 一同填)退回整屏矩形。 - let (work_l, work_t, work_r, work_b) = - if mon.work_right > mon.work_left && mon.work_bottom > mon.work_top { - (mon.work_left, mon.work_top, mon.work_right, mon.work_bottom) - } else { - (mon.left, mon.top, mon.right, mon.bottom) - }; - let (clamped_x, clamped_y) = - clamp_to_monitor(x, y, phys_w, phys_h, work_l, work_t, work_r, work_b); + let clamped_y = y.max(mon.top); + // #470 诊断 v2:当前只夹了上边(.max(mon.top)),未夹下/左/右。多显示器、 + // 负坐标或异常 DPI 下胶囊可能被算到屏幕外却无任何观测。记录显示器几何与 + // 最终落点,用于证伪/证实「胶囊定位到屏幕外」(C 子嫌疑)。 log::debug!( - "[capsule] win position: mon=({},{})..({},{}) work=({},{})..({},{}) scale={:.2} size=({}x{}) -> raw=({},{}) clamped=({},{})", - mon.left, mon.top, mon.right, mon.bottom, - work_l, work_t, work_r, work_b, - scale, phys_w, phys_h, x, y, clamped_x, clamped_y + "[capsule] win position: mon=({},{})..({},{}) scale={:.2} size=({}x{}) -> x={} y={} clamped_y={}", + mon.left, mon.top, mon.right, mon.bottom, scale, phys_w, phys_h, x, y, clamped_y ); - window.set_position(PhysicalPosition::new(clamped_x, clamped_y))?; + window.set_position(PhysicalPosition::new(x, clamped_y))?; return Ok(()); } // 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor 逻辑。 @@ -1822,7 +1955,7 @@ fn capsule_height_for_qa() -> f64 { mod tests { use super::{ bottom_center_position, bottom_visual_position, capsule_height_for_qa, - capsule_visual_height, capsule_window_bounds, clamp_to_monitor, logical_monitor_frame, + capsule_visual_height, capsule_window_bounds, logical_monitor_frame, parse_tray_polish_mode_id, rotate_log_if_too_large, tray_polish_mode_menu_entries, tray_style_menu_enabled, LogicalMonitorFrame, LOG_ROTATE_LIMIT_BYTES, }; @@ -1971,63 +2104,6 @@ mod tests { assert_eq!(pos, (610.0, -176.0)); } - // ---- #470: capsule 四边 clamp(纯函数,合成多显示器 / 负原点 / 1.5x DPI 输入)---- - - #[test] - fn clamp_to_monitor_leaves_on_screen_position_untouched() { - // 1080p 主屏正中偏下,整窗本就可见 → 原样返回。 - let (x, y) = clamp_to_monitor(800, 900, 264, 126, 0, 0, 1920, 1040); - assert_eq!((x, y), (800, 900)); - } - - #[test] - fn clamp_to_monitor_pulls_back_off_screen_right_and_bottom() { - // x/y 算到了屏幕右下外侧 → 收回到「右下角减去窗口尺寸」,整窗仍可见。 - let (x, y) = clamp_to_monitor(2000, 1200, 264, 126, 0, 0, 1920, 1040); - assert_eq!((x, y), (1920 - 264, 1040 - 126)); - // 整窗右/下边界都落在 area 内。 - assert!(x + 264 <= 1920); - assert!(y + 126 <= 1040); - } - - #[test] - fn clamp_to_monitor_pulls_back_when_right_edge_overflows_inside_area() { - // 左上角 x=1800 本在 area 内,但 x+w=2064 越过右边界 1920 → - // 应被左移到「右边界 - 窗口宽」,整窗右缘恰好贴住 area_right。 - let (x, _y) = clamp_to_monitor(1800, 900, 264, 126, 0, 0, 1920, 1040); - assert_eq!(x, 1920 - 264); - assert!(x + 264 <= 1920); - } - - #[test] - fn clamp_to_monitor_pushes_into_negative_origin_left_monitor() { - // 副屏在主屏左侧(负 X 原点),落点算到了副屏左外侧 → 夹回 area_left。 - // 1.5x DPI 下尺寸偏大,但 area 仍宽于窗口,左上角夹到 (-2560, top)。 - let (x, y) = clamp_to_monitor(-3000, -100, 294, 138, -2560, 0, 0, 1440); - assert_eq!(x, -2560); - assert_eq!(y, 0); - // 右/下仍在 area 内。 - assert!(x >= -2560 && x + 294 <= 0); - assert!(y >= 0 && y + 138 <= 1440); - } - - #[test] - fn clamp_to_monitor_respects_work_area_above_taskbar() { - // 工作区底部 = 1040(任务栏占了 1040..1080)。落点本在任务栏区域(y=1030), - // 应被夹到「工作区底 - 窗口高」之上,胶囊整窗不压任务栏。 - let (_x, y) = clamp_to_monitor(800, 1030, 264, 126, 0, 0, 1920, 1040); - assert_eq!(y, 1040 - 126); - assert!(y + 126 <= 1040); - } - - #[test] - fn clamp_to_monitor_degrades_gracefully_when_window_wider_than_area() { - // 病态输入:area 比窗口还窄(罕见,但要保证不 panic、不溢出为负超界)。 - // max_x 钳到 area_left,clamp 把左上角收回 area_left。 - let (x, y) = clamp_to_monitor(500, 500, 800, 600, 0, 0, 400, 300); - assert_eq!((x, y), (0, 0)); - } - #[test] fn oversized_log_rotates_to_single_archive() { let dir = std::env::temp_dir().join(format!("openless-log-rotate-{}", std::process::id())); diff --git a/openless-all/app/src-tauri/src/mobile_runtime.rs b/openless-all/app/src-tauri/src/mobile_runtime.rs new file mode 100644 index 00000000..f2f672be --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_runtime.rs @@ -0,0 +1,79 @@ +//! Minimal Tauri mobile runtime — single main window, no tray/hotkey/updater. + +use std::sync::Arc; + +use tauri::{AppHandle, Manager, RunEvent}; + +use crate::coordinator::Coordinator; + +pub fn run() { + let coordinator = Arc::new(Coordinator::new()); + + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_dialog::init()) + .manage(coordinator.clone()) + .setup(move |app| { + crate::init_file_logger(); + log::info!("=== OpenLess mobile 启动 ==="); + initialize_android_ndk_context_for_audio(); + + if let Some(main) = app.get_webview_window("main") { + let _ = main.show(); + } + if let Some(qa) = app.get_webview_window("qa") { + let _ = qa.hide(); + } + + coordinator.bind_app(app.handle().clone()); + #[cfg(target_os = "android")] + { + crate::android::register_android_coordinator(coordinator.clone()); + coordinator.apply_android_overlay_on_startup(); + } + Ok(()) + }) + .invoke_handler(crate::app_invoke_handler_mobile!()) + .build(tauri::generate_context!()) + .expect("error while building tauri mobile application") + .run(|app, event| match event { + RunEvent::Exit => { + let coordinator = app.state::>(); + coordinator.stop_hotkey_listener(); + } + _ => {} + }); +} + +#[allow(dead_code)] +pub(crate) fn show_main_window(app: &AppHandle) { + if let Some(w) = app.get_webview_window("main") { + let _ = w.show(); + let _ = w.set_focus(); + } +} + +#[cfg(target_os = "android")] +fn initialize_android_ndk_context_for_audio() { + static INIT: std::sync::Once = std::sync::Once::new(); + + INIT.call_once(|| { + let Some(context) = tao::platform::android::prelude::main_android_context() else { + log::warn!("[android] tao Android context unavailable; audio backend may fail"); + return; + }; + + let result = std::panic::catch_unwind(|| unsafe { + ndk_context::initialize_android_context(context.java_vm, context.context_jobject); + }); + + if result.is_ok() { + log::info!("[android] initialized ndk-context for audio backend"); + } else { + log::warn!("[android] ndk-context was already initialized or rejected initialization"); + } + }); +} + +#[cfg(not(target_os = "android"))] +fn initialize_android_ndk_context_for_audio() {} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/combo_hotkey.rs b/openless-all/app/src-tauri/src/mobile_stubs/combo_hotkey.rs new file mode 100644 index 00000000..3a7907ef --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/combo_hotkey.rs @@ -0,0 +1,46 @@ +//! Mobile stub — combo hotkeys are unavailable on Android/iOS. + +use std::sync::mpsc::Sender; + +use crate::types::ShortcutBinding; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ComboHotkeyEvent { + Pressed, + Released, +} + +#[derive(Debug, thiserror::Error)] +pub enum ComboHotkeyError { + #[error("不支持的修饰键: {0}")] + UnsupportedModifier(String), + #[error("不支持的主键: {0}")] + UnsupportedKey(String), + #[error("注册全局快捷键失败: {0}")] + RegisterFailed(String), + #[error("初始化全局快捷键管理器失败: {0}")] + ManagerInitFailed(String), +} + +pub struct ComboHotkeyMonitor; + +impl ComboHotkeyMonitor { + pub fn start( + _binding: ShortcutBinding, + _tx: Sender, + ) -> Result { + Err(ComboHotkeyError::RegisterFailed( + "Combo hotkeys are not available on mobile".into(), + )) + } + + pub fn update_binding(&self, _binding: ShortcutBinding) -> Result<(), ComboHotkeyError> { + Ok(()) + } +} + +pub fn validate_binding(_binding: &ShortcutBinding) -> Result<(), ComboHotkeyError> { + Err(ComboHotkeyError::RegisterFailed( + "Combo hotkeys are not available on mobile".into(), + )) +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs new file mode 100644 index 00000000..59c6c7e7 --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs @@ -0,0 +1,49 @@ +//! Mobile stub — global hotkeys are unavailable on Android/iOS. + +use std::sync::mpsc::Sender; + +use crate::types::{ + HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyInstallError, HotkeyTrigger, +}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HotkeyEvent { + Pressed, + Released, + Cancelled, + TranslationModifierPressed, + QaShortcutPressed, +} + +pub struct HotkeyMonitor; + +impl HotkeyMonitor { + pub fn start( + _binding: HotkeyBinding, + _tx: Sender, + ) -> Result { + Err(HotkeyInstallError { + code: "unavailable".into(), + message: "Global hotkeys are not available on mobile".into(), + }) + } + + pub fn update_binding(&self, _binding: HotkeyBinding) {} + + pub fn update_modifier_shortcuts( + &self, + _qa_trigger: Option, + _translation_trigger: Option, + ) { + } + + pub fn kind(&self) -> HotkeyAdapterKind { + HotkeyAdapterKind::Unavailable + } + + pub fn reset_held_state(&self) {} + + pub fn capability() -> HotkeyCapability { + HotkeyCapability::current() + } +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/qa_hotkey.rs b/openless-all/app/src-tauri/src/mobile_stubs/qa_hotkey.rs new file mode 100644 index 00000000..b94c7acb --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/qa_hotkey.rs @@ -0,0 +1,33 @@ +//! Mobile stub — QA hotkeys are unavailable on Android/iOS. + +use std::sync::mpsc::Sender; + +use crate::types::ShortcutBinding; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QaHotkeyEvent { + Pressed, +} + +#[derive(Debug, thiserror::Error)] +pub enum QaHotkeyError { + #[error("注册 QA 快捷键失败: {0}")] + RegisterFailed(String), +} + +pub struct QaHotkeyMonitor; + +impl QaHotkeyMonitor { + pub fn start( + _binding: ShortcutBinding, + _tx: Sender, + ) -> Result { + Err(QaHotkeyError::RegisterFailed( + "QA hotkeys are not available on mobile".into(), + )) + } + + pub fn update_binding(&self, _binding: ShortcutBinding) -> Result<(), QaHotkeyError> { + Ok(()) + } +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/selection.rs b/openless-all/app/src-tauri/src/mobile_stubs/selection.rs new file mode 100644 index 00000000..ef48faa6 --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/selection.rs @@ -0,0 +1,54 @@ +//! Mobile selection capture. + +const SELECTION_MAX_CHARS: usize = 4000; +const SELECTION_TRUNCATE_HEAD: usize = 2000; +const SELECTION_TRUNCATE_TAIL: usize = 2000; +const SELECTION_TRUNCATED_MARKER: &str = "\n[…truncated…]\n"; + +#[derive(Debug, Clone)] +pub struct SelectionContext { + pub text: String, + pub source_app: Option, +} + +#[cfg(target_os = "android")] +pub fn capture_selection() -> Option { + let text = match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::accessibility_selected_text(env, context) + }) { + Ok(Some(text)) => text, + Ok(None) => return None, + Err(error) => { + log::warn!("[selection] Android accessibility selection read failed: {error}"); + return None; + } + }; + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + log::info!( + "[selection] Android accessibility read OK ({} chars)", + trimmed.chars().count() + ); + Some(SelectionContext { + text: truncate_selection(trimmed), + source_app: Some("Android accessibility".to_string()), + }) +} + +#[cfg(not(target_os = "android"))] +pub fn capture_selection() -> Option { + None +} + +fn truncate_selection(text: &str) -> String { + let total: usize = text.chars().count(); + if total <= SELECTION_MAX_CHARS { + return text.to_string(); + } + let head: String = text.chars().take(SELECTION_TRUNCATE_HEAD).collect(); + let tail_start = total.saturating_sub(SELECTION_TRUNCATE_TAIL); + let tail: String = text.chars().skip(tail_start).collect(); + format!("{head}{SELECTION_TRUNCATED_MARKER}{tail}") +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs b/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs new file mode 100644 index 00000000..7946a44d --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs @@ -0,0 +1,38 @@ +//! Mobile stub — shortcut binding validation is unavailable on mobile. + +use crate::types::{HotkeyTrigger, ShortcutBinding}; + +#[derive(Debug, thiserror::Error)] +pub enum ShortcutBindingError { + #[error("快捷键在移动端不可用")] + Unavailable, +} + +pub fn validate_binding(_binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> { + Err(ShortcutBindingError::Unavailable) +} + +pub fn parse_global_hotkey(_binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> { + Err(ShortcutBindingError::Unavailable) +} + +pub fn legacy_modifier_trigger(_binding: &ShortcutBinding) -> Option { + None +} + +pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding { + let primary = match trigger { + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => "RightOption", + HotkeyTrigger::LeftOption => "LeftOption", + HotkeyTrigger::RightControl => "RightControl", + HotkeyTrigger::LeftControl => "LeftControl", + HotkeyTrigger::RightCommand => "RightCommand", + HotkeyTrigger::Fn => "Fn", + HotkeyTrigger::MediaPlayPause => "MediaPlayPause", + HotkeyTrigger::Custom => "RightOption", + }; + ShortcutBinding { + primary: primary.into(), + modifiers: Vec::new(), + } +} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/unicode_keystroke.rs b/openless-all/app/src-tauri/src/mobile_stubs/unicode_keystroke.rs new file mode 100644 index 00000000..9adc27ed --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/unicode_keystroke.rs @@ -0,0 +1,40 @@ +//! Mobile stub — unicode keystroke streaming is unavailable on mobile. + +use tauri::{AppHandle, Runtime}; + +#[derive(Debug, thiserror::Error)] +pub enum TypeError { + #[allow(dead_code)] + #[error("{source} after {typed_chars} chars were sent")] + Partial { + typed_chars: usize, + #[source] + source: Box, + }, + #[error("unicode keystroke unavailable on mobile")] + Unavailable, +} + +impl TypeError { + pub fn typed_chars(&self) -> usize { + match self { + TypeError::Partial { typed_chars, .. } => *typed_chars, + _ => 0, + } + } +} + +pub async fn switch_to_ascii(_app: &AppHandle) -> Result, TypeError> { + Err(TypeError::Unavailable) +} + +pub async fn restore_input_source( + _app: &AppHandle, + _previous: Option<()>, +) -> Result<(), TypeError> { + Ok(()) +} + +pub fn type_unicode_chunk(_text: &str) -> Result { + Err(TypeError::Unavailable) +} diff --git a/openless-all/app/src-tauri/src/permissions.rs b/openless-all/app/src-tauri/src/permissions.rs index ef1452d4..c525a92d 100644 --- a/openless-all/app/src-tauri/src/permissions.rs +++ b/openless-all/app/src-tauri/src/permissions.rs @@ -244,9 +244,54 @@ mod platform { } } -// ─────────────────────────── Windows / 其他 ─────────────────────────── +// ─────────────────────────── Android ─────────────────────────── -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "android")] +mod platform { + use super::PermissionStatus; + + pub fn check_accessibility() -> PermissionStatus { + PermissionStatus::NotApplicable + } + + pub fn request_accessibility() -> PermissionStatus { + PermissionStatus::NotApplicable + } + + pub fn check_microphone() -> PermissionStatus { + match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::check_self_permission( + env, + context, + "android.permission.RECORD_AUDIO", + ) + }) { + Ok(true) => PermissionStatus::Granted, + Ok(false) => PermissionStatus::Denied, + Err(error) => { + log::warn!("[mic] Android RECORD_AUDIO permission check failed: {error}"); + PermissionStatus::NotDetermined + } + } + } + + pub fn request_microphone() -> PermissionStatus { + match crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::request_record_audio_permission(env, context) + }) { + Ok(true) => PermissionStatus::Granted, + Ok(false) => PermissionStatus::NotDetermined, + Err(error) => { + log::warn!("[mic] Android RECORD_AUDIO permission request failed: {error}"); + check_microphone() + } + } + } +} + +// ─────────────────────────── Windows / Linux / 其他 ─────────────────────────── + +#[cfg(all(not(target_os = "macos"), not(target_os = "android")))] mod platform { use super::PermissionStatus; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 15157ca2..901950f3 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -48,10 +48,8 @@ const LEGACY_CREDS_FILE: &str = "credentials.json"; const KEYRING_CREDENTIALS_ACCOUNT: &str = "credentials.v1"; const KEYRING_CREDENTIALS_CHUNK_PREFIX: &str = "credentials.v1.chunk."; -/// HMAC 密钥长度(字节)。 -const HISTORY_HMAC_KEY_LEN: usize = 32; -/// history HMAC sidecar 文件后缀:`history.json.hmac`。 -const HISTORY_HMAC_SUFFIX: &str = ".hmac"; +#[cfg(target_os = "android")] +const ANDROID_CREDENTIALS_FILE: &str = "credentials.enc.json"; // Windows Credential Manager caps one credential blob at 2560 bytes. keyring stores // passwords as UTF-16 on Windows, so keep each JSON chunk comfortably below that. const KEYRING_CHUNK_MAX_UTF16_UNITS: usize = 1000; @@ -95,17 +93,6 @@ fn store_credentials_cache(root: &CredsRoot) { #[cfg(test)] fn reset_credentials_cache_for_tests() { *credentials_cache().lock() = None; - *credentials_manifest_cache().lock() = None; -} - -/// issue #602:进程内缓存「上次成功读/写的 chunk manifest」。save_credentials 用它 -/// 替代保存前的 keychain manifest 读 —— macOS 上这次读本身就要过 ACL 检查(弹窗)。 -/// None = 本进程还没成功读/写过 manifest(冷启动或 keyring 不可用),此时才回 -/// keychain 读真实 manifest,保证 UUID-generation 旧 chunks 的清理信息不丢。 -static CREDENTIALS_MANIFEST_CACHE: OnceLock>> = OnceLock::new(); - -fn credentials_manifest_cache() -> &'static Mutex> { - CREDENTIALS_MANIFEST_CACHE.get_or_init(|| Mutex::new(None)) } // ───────────────────────── path helpers ───────────────────────── @@ -126,7 +113,7 @@ fn data_dir() -> Result { Ok(PathBuf::from(appdata).join("OpenLess")) } - #[cfg(all(unix, not(target_os = "macos")))] + #[cfg(all(unix, not(target_os = "macos"), not(target_os = "android")))] { if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { if !xdg.is_empty() { @@ -139,6 +126,14 @@ fn data_dir() -> Result { .join("share") .join("OpenLess")) } + + #[cfg(target_os = "android")] + { + if let Ok(dir) = std::env::var("TAURI_ANDROID_APP_DATA_DIR") { + return Ok(PathBuf::from(dir).join("OpenLess")); + } + Ok(std::env::temp_dir().join("OpenLess")) + } } fn ensure_dir(dir: &Path) -> Result<()> { @@ -448,31 +443,6 @@ fn atomic_write(path: &Path, contents: &[u8]) -> Result<()> { Ok(()) } -/// 与 `atomic_write` 相同,但(unix)在 rename **之前**把 tmp 文件设 0o600。 -/// -/// issue #609 M-01:原先「rename 后再 chmod」之间存在一段世界可读窗口(tmp 按 -/// umask 创建,目标文件 rename 后到收紧权限前可被同机其他用户读到)。在 rename -/// 前对 tmp chmod,保证目标文件一出现就已是 0o600,无暴露窗口。仅用于 history.json -/// 与其 sidecar(含明文/完整性数据),其余配置走普通 `atomic_write`。 -fn atomic_write_private(path: &Path, contents: &[u8]) -> Result<()> { - if let Some(parent) = path.parent() { - ensure_dir(parent)?; - } - let file_name = path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_default(); - let tmp_path = path.with_file_name(format!("{file_name}.tmp-{}", Uuid::new_v4().simple())); - fs::write(&tmp_path, contents) - .with_context(|| format!("write tmp failed: {}", tmp_path.display()))?; - restrict_file_permissions_best_effort(&tmp_path); - if let Err(err) = fs::rename(&tmp_path, path) { - let _ = fs::remove_file(&tmp_path); - return Err(err).with_context(|| format!("rename failed: {}", path.display())); - } - Ok(()) -} - fn read_or_default Deserialize<'de> + Default>(path: &Path) -> Result { if !path.exists() { return Ok(T::default()); @@ -680,15 +650,54 @@ fn credentials_path() -> Result { } } +#[cfg(not(target_os = "android"))] fn keyring_entry() -> Result { keyring_entry_for(KEYRING_CREDENTIALS_ACCOUNT) } +#[cfg(not(target_os = "android"))] fn keyring_entry_for(account: &str) -> Result { keyring::Entry::new(CredentialsVault::SERVICE_NAME, account) .context("open system credential vault") } +#[cfg(target_os = "android")] +fn android_credentials_path() -> Result { + Ok(data_dir()?.join(ANDROID_CREDENTIALS_FILE)) +} + +#[cfg(target_os = "android")] +fn load_android_credentials() -> Result> { + let path = android_credentials_path()?; + if !path.exists() { + return Ok(None); + } + let bytes = fs::read(&path).with_context(|| format!("read failed: {}", path.display()))?; + if bytes.is_empty() { + return Ok(None); + } + // Stub: base64 envelope — replace with Keystore-backed AES when JNI lands. + use base64::Engine; + let decoded = base64::engine::general_purpose::STANDARD + .decode(bytes) + .context("decode android credentials envelope")?; + let root = + serde_json::from_slice::(&decoded).context("parse android credentials json")?; + Ok(Some(root)) +} + +#[cfg(target_os = "android")] +fn save_android_credentials(root: &CredsRoot) -> Result<()> { + let cleaned = clean_credentials(root); + let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; + use base64::Engine; + let encoded = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + let path = android_credentials_path()?; + ensure_dir(path.parent().unwrap_or_else(|| Path::new(".")))?; + fs::write(&path, encoded).with_context(|| format!("write failed: {}", path.display()))?; + Ok(()) +} + fn clean_credentials(root: &CredsRoot) -> CredsRoot { let mut cleaned = root.clone(); cleaned.providers.asr.retain(|_, v| !v.is_empty()); @@ -733,7 +742,7 @@ fn remove_legacy_credentials_file_best_effort() { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize)] struct CredsChunkManifest { openless_credentials_storage: String, version: u32, @@ -773,23 +782,6 @@ fn chunk_json_payload(json: &str) -> Vec { chunks } -/// issue #602:给定「上次成功落盘的 JSON」与「本次要写的各 chunk」,返回每个新 chunk -/// 是否可跳过重写(与同号旧 chunk 逐字节一致)。注意 chunk 按偏移切分:靠前字段的 -/// 变长改动会移动后续所有 chunk 边界(全部重写,等同旧行为);等长改动/无改动则只 -/// 写真正变化的 chunk。previous_json=None(冷缓存/旧 UUID 代际)→ 全部重写。 -fn chunk_skip_mask(previous_json: Option<&str>, new_chunks: &[String]) -> Vec { - let prev_chunks = previous_json.map(chunk_json_payload); - new_chunks - .iter() - .enumerate() - .map(|(index, chunk)| { - prev_chunks - .as_ref() - .is_some_and(|prev| prev.get(index) == Some(chunk)) - }) - .collect() -} - fn read_chunk_manifest(json: &str) -> Option { let manifest = serde_json::from_str::(json).ok()?; if manifest.openless_credentials_storage == "chunked" && manifest.version == 1 { @@ -799,6 +791,7 @@ fn read_chunk_manifest(json: &str) -> Option { } } +#[cfg(not(target_os = "android"))] fn get_keyring_password(account: &str) -> Result> { match keyring_entry_for(account)?.get_password() { Ok(value) => Ok(Some(value)), @@ -809,6 +802,7 @@ fn get_keyring_password(account: &str) -> Result> { } } +#[cfg(not(target_os = "android"))] fn delete_keyring_password(account: &str) { match keyring_entry_for(account).and_then(|entry| { entry @@ -819,169 +813,7 @@ fn delete_keyring_password(account: &str) { } } -// ───────────── issue #609 F-03:history.json HMAC 完整性 ───────────── - -/// 进程内缓存的 history HMAC 密钥,避免每次读写都打 keyring。 -static HISTORY_HMAC_KEY: OnceLock>> = OnceLock::new(); - -/// 取(必要时生成)history HMAC 密钥。 -/// -/// 首次用时从 keyring 读 32 字节 hex;不存在则用 OS CSPRNG 生成并写回 keyring。 -/// keyring 不可用(如 Linux 无 secret service)→ 返回 None,调用方据此**退化为不 -/// 校验**(保持可用,但不提供完整性保证);用 `OnceLock` 把这个状态固化到进程。 -/// -/// issue #609 M-03:keyring 不可用时只能放弃完整性校验,这里 `log::warn!` 一次。 -/// **后续可做**:keyring 缺失时把 HMAC 密钥落到一个 0o600 文件里兜底(当前留作 -/// future work,因为文件密钥与明文 history 同目录,威胁模型收益有限)。 -fn history_hmac_key() -> Option> { - HISTORY_HMAC_KEY - .get_or_init(|| match load_or_create_history_hmac_key() { - Ok(key) => Some(key), - Err(e) => { - log::warn!( - "[history] 完整性校验未激活(keyring 不可用),本次运行跳过 HMAC 校验(仍按内容读写):{e}" - ); - None - } - }) - .clone() -} - -/// history HMAC 密钥 / enrolled 标志的 0o600 文件路径。 -/// -/// **为什么从 keyring 迁到文件**(原 #609 F-03 放 keychain):macOS 上每个钥匙串条目各自 ACL, -/// 且 ad-hoc 签名下「始终允许」不持久、条目删建即清空 ACL —— 导致用户**每次听写后反复弹钥匙串 -/// 授权**(凭据 3 次之外又多出 HMAC 密钥 + enrolled 共 2 次)。HMAC 密钥与明文 history 同目录、 -/// at-rest 防护同为 OS 文件权限(M-01),放文件与放 keychain 安全**收益对等**,却彻底消除钥匙串 -/// 弹窗。真正的 API 密钥仍留在 keychain(CredentialsVault)。 -fn history_hmac_key_path() -> Result { - Ok(data_dir()?.join("history_hmac.key")) -} - -fn history_hmac_enrolled_path() -> Result { - Ok(data_dir()?.join(".history_hmac_enrolled.v1")) -} - -fn load_or_create_history_hmac_key() -> Result> { - let _guard = credentials_lock().lock(); - let key_path = history_hmac_key_path()?; - if let Ok(hex_key) = fs::read_to_string(&key_path) { - let trimmed = hex_key.trim(); - if !trimmed.is_empty() { - let key = - decode_hex(trimmed).with_context(|| "history HMAC key file is not valid hex")?; - if key.len() == HISTORY_HMAC_KEY_LEN { - return Ok(key); - } - log::warn!("[history] HMAC 密钥文件长度异常,重新生成"); - } - } - // 密钥文件不存在 = 首次启用文件存储(含从旧 keychain 版升级)。生成新 key;旧 history 可能 - // 是用「旧 keychain key」签的 —— 新 key 会让其 HMAC 不匹配而被误判篡改清空。迁移:删掉旧 - // sidecar 与 enrolled 文件,让旧 history 走 legacy 路径被重新接受 + 用新 key 补签。全程不读 - // 旧 keychain,零钥匙串弹窗、不丢历史。 - let mut key = vec![0u8; HISTORY_HMAC_KEY_LEN]; - getrandom::fill(&mut key).map_err(|e| anyhow!("OS CSPRNG 生成 HMAC 密钥失败:{e}"))?; - atomic_write_private(&key_path, encode_hex(&key).as_bytes()) - .context("写入 history HMAC 密钥文件失败")?; - if let Ok(dir) = data_dir() { - let _ = fs::remove_file(history_hmac_sidecar_path(&dir.join(HISTORY_FILE))); - } - if let Ok(enrolled) = history_hmac_enrolled_path() { - let _ = fs::remove_file(enrolled); - } - Ok(key) -} - -/// issue #609 C-01:「是否已启用 HMAC」标志的抽象,便于单测注入内存实现。 -/// -/// 标志存 keyring(不是文件),因为它本身要抗删除:sidecar 是文件、易删, -/// 一旦攻击者删掉 sidecar,靠这个 keyring 标志判定「本应有 sidecar 却没了」= -/// 篡改,而不是误当 legacy 接受。 -trait HmacEnrollment { - /// 标志已置位(曾经写过 sidecar)。keyring 不可读时返回 false(退化)。 - fn is_enrolled(&self) -> bool; - /// 置位标志(幂等)。keyring 不可写时静默忽略(best-effort)。 - fn set_enrolled(&self); -} - -/// 生产实现:enrolled 标志落 0o600 文件(不再用 keychain,原因见 history_hmac_key_path 注释: -/// 避免 ad-hoc 签名下每次听写反复弹钥匙串)。标志文件存在 = 已启用(曾写过 sidecar)。 -/// 读不到 / 写不了都按「未启用」处理(退化与 M-03 一致)。 -struct FileEnrollment; - -impl HmacEnrollment for FileEnrollment { - fn is_enrolled(&self) -> bool { - history_hmac_enrolled_path() - .map(|p| p.exists()) - .unwrap_or(false) - } - - fn set_enrolled(&self) { - let Ok(path) = history_hmac_enrolled_path() else { - return; - }; - // 已置位则不重复写(幂等)。 - if path.exists() { - return; - } - if let Err(e) = atomic_write_private(&path, b"1") { - log::warn!("[history] 置位 HMAC enrolled 标志文件失败:{e}"); - } - } -} - -fn encode_hex(bytes: &[u8]) -> String { - use std::fmt::Write; - let mut s = String::with_capacity(bytes.len() * 2); - for b in bytes { - // write! 到 String 不会失败;直接吞掉 Result 即可。 - let _ = write!(s, "{b:02x}"); - } - s -} - -fn decode_hex(s: &str) -> Result> { - let s = s.trim(); - if s.len() % 2 != 0 { - return Err(anyhow!("hex 长度为奇数")); - } - (0..s.len()) - .step_by(2) - .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow!("hex 解析失败:{e}"))) - .collect() -} - -/// 计算 `HMAC-SHA256(key, bytes)`,返回 hex。 -fn compute_history_hmac(key: &[u8], bytes: &[u8]) -> String { - use hmac::{Hmac, Mac}; - // 标准 HMAC:new_from_slice 接受任意长度 key(内部按 RFC2104 处理 key padding)。 - let mut mac = as Mac>::new_from_slice(key).expect("HMAC 接受任意长度 key"); - mac.update(bytes); - encode_hex(&mac.finalize().into_bytes()) -} - -fn history_hmac_sidecar_path(history_path: &Path) -> PathBuf { - let mut name = history_path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| HISTORY_FILE.to_string()); - name.push_str(HISTORY_HMAC_SUFFIX); - history_path.with_file_name(name) -} - -/// 常量时间比较两段 hex HMAC,避免计时侧信道。两者都是定长 hex,长度不等直接 false。 -/// issue #609 H-02:用 `subtle::ConstantTimeEq` 而非手写 XOR 循环,避免编译器把 -/// 短路优化引回去(手写循环不保证不被向量化/提前退出)。 -fn hmac_hex_eq(a: &str, b: &str) -> bool { - use subtle::ConstantTimeEq; - let (a, b) = (a.trim().as_bytes(), b.trim().as_bytes()); - if a.len() != b.len() { - return false; - } - a.ct_eq(b).into() -} - +#[cfg(not(target_os = "android"))] fn load_keyring_credentials() -> Result> { let Some(json_or_manifest) = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT)? else { return Ok(None); @@ -989,8 +821,6 @@ fn load_keyring_credentials() -> Result> { let manifest = read_chunk_manifest(&json_or_manifest) .ok_or_else(|| anyhow!("invalid system credential vault manifest"))?; - // issue #602:manifest 刚从 keychain 读出,进缓存 —— 后续 save 不必再读一次。 - *credentials_manifest_cache().lock() = Some(manifest.clone()); let mut json = String::new(); for index in 0..manifest.chunks { let account = chunk_account(manifest.generation.as_deref(), index); @@ -1004,6 +834,7 @@ fn load_keyring_credentials() -> Result> { .context("decode system credential vault payload") } +#[cfg(not(target_os = "android"))] fn load_legacy_keyring_credentials() -> CredsRoot { match load_legacy_keyring_credentials_for_update() { Ok(root) => root, @@ -1014,6 +845,7 @@ fn load_legacy_keyring_credentials() -> CredsRoot { } } +#[cfg(not(target_os = "android"))] fn load_legacy_keyring_credentials_for_update() -> Result { let mut root = CredsRoot::default(); for account in CredentialAccount::all() { @@ -1027,6 +859,7 @@ fn load_legacy_keyring_credentials_for_update() -> Result { Ok(clean_credentials(&root)) } +#[cfg(not(target_os = "android"))] fn remove_legacy_keyring_credentials() { for account in CredentialAccount::all() { delete_keyring_password(account.keyring_account()); @@ -1048,9 +881,12 @@ fn load_legacy_sources_without_migration() -> CredsRoot { return legacy; } - let legacy_vault = load_legacy_keyring_credentials(); - if legacy_vault_has_credentials(&legacy_vault) { - return legacy_vault; + #[cfg(not(target_os = "android"))] + { + let legacy_vault = load_legacy_keyring_credentials(); + if legacy_vault_has_credentials(&legacy_vault) { + return legacy_vault; + } } CredsRoot::default() @@ -1069,15 +905,19 @@ fn migrate_legacy_sources() -> CredsRoot { fn migrate_legacy_sources_for_update() -> Result { if let Some(legacy) = load_legacy_credentials() { save_credentials(&legacy)?; + #[cfg(not(target_os = "android"))] remove_legacy_keyring_credentials(); return Ok(legacy); } - let legacy_vault = load_legacy_keyring_credentials_for_update()?; - if legacy_vault_has_credentials(&legacy_vault) { - save_credentials(&legacy_vault)?; - remove_legacy_keyring_credentials(); - return Ok(legacy_vault); + #[cfg(not(target_os = "android"))] + { + let legacy_vault = load_legacy_keyring_credentials_for_update()?; + if legacy_vault_has_credentials(&legacy_vault) { + save_credentials(&legacy_vault)?; + remove_legacy_keyring_credentials(); + return Ok(legacy_vault); + } } Ok(CredsRoot::default()) @@ -1087,6 +927,22 @@ fn load_credentials() -> CredsRoot { if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return cached; } + + #[cfg(target_os = "android")] + { + let root = match load_android_credentials() { + Ok(Some(root)) => root, + Ok(None) => CredsRoot::default(), + Err(e) => { + log::warn!("[vault] android credential read failed: {e}"); + CredsRoot::default() + } + }; + store_credentials_cache(&root); + return root; + } + + #[cfg(not(target_os = "android"))] match load_keyring_credentials() { Ok(Some(root)) => { // 不在这里调 remove_legacy_keyring_credentials() —— 它内部对每个 @@ -1123,6 +979,18 @@ fn load_credentials_for_update() -> Result { if let Some(cached) = credentials_cache().lock().as_ref().cloned() { return Ok(cached); } + + #[cfg(target_os = "android")] + { + let root = match load_android_credentials()? { + Some(root) => root, + None => CredsRoot::default(), + }; + store_credentials_cache(&root); + return Ok(root); + } + + #[cfg(not(target_os = "android"))] match load_keyring_credentials() { Ok(Some(root)) => { // 同 load_credentials:不再每次 update 都尝试 delete legacy keyring @@ -1146,96 +1014,70 @@ fn load_credentials_for_update() -> Result { fn save_credentials(root: &CredsRoot) -> Result<()> { let cleaned = clean_credentials(root); - let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; - // issue #602:上次成功读/写的 manifest 有进程缓存时不再回 keychain 读 —— - // macOS 上这次读本身就要过 ACL 检查(一次弹窗)。冷路径才读真实 manifest。 - let previous_manifest = credentials_manifest_cache().lock().clone().or_else(|| { - get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) + + #[cfg(target_os = "android")] + { + save_android_credentials(&cleaned)?; + store_credentials_cache(&cleaned); + return Ok(()); + } + + #[cfg(not(target_os = "android"))] + { + let json = serde_json::to_string(&cleaned).context("encode credentials failed")?; + let previous_manifest = get_keyring_password(KEYRING_CREDENTIALS_ACCOUNT) .ok() .flatten() - .and_then(|value| read_chunk_manifest(&value)) - }); - let chunks = chunk_json_payload(&json); - - // issue #602:切换供应商等小改动会触发整套「重写所有 chunks + manifest」,每个 - // keychain 条目各自 ACL、各弹一次「OpenLess 想访问钥匙串」。用进程缓存里上次 - // 成功落盘的 root 反推各 chunk 旧内容,内容没变的 chunk 跳过重写。仅当旧 - // manifest 已是稳定名(generation=None)时可跳 —— UUID 代际的旧 chunk 账户名 - // 不同,内容相同也必须写到新稳定名。缓存序列化顺序偶有差异时只会多写(回到 - // 旧行为),不会漏写。 - let previous_json: Option = match &previous_manifest { - Some(m) if m.generation.is_none() => credentials_cache() - .lock() - .as_ref() - .and_then(|prev| serde_json::to_string(prev).ok()), - _ => None, - }; - let skip = chunk_skip_mask(previous_json.as_deref(), &chunks); - - // 先写所有 chunks(稳定名),再写 manifest —— 保证 partial-write 不会让 - // manifest 指向不完整 chunks。stable name 让 macOS Keychain ACL 一次允许后 - // 长期有效,不再因 UUID 轮换反复弹窗(这是 PR #277 早期 UUID-rotation - // 设计的回退)。 - let mut chunks_written = 0usize; - for (index, chunk) in chunks.iter().enumerate() { - if skip[index] { - continue; - } - let account = chunk_account(None, index); - keyring_entry_for(&account)? - .set_password(chunk) - .with_context(|| format!("write system credential vault chunk {index}"))?; - chunks_written += 1; - } + .and_then(|value| read_chunk_manifest(&value)); + let chunks = chunk_json_payload(&json); - let manifest = CredsChunkManifest { - openless_credentials_storage: "chunked".to_string(), - version: 1, - generation: None, - chunks: chunks.len(), - }; - // manifest 内容只由 chunks 数决定:数量没变且旧 manifest 已是稳定名时内容 - // 逐字节一致,跳过重写(又省一次 ACL 弹窗)。 - let manifest_unchanged = previous_manifest - .as_ref() - .is_some_and(|m| m.generation.is_none() && m.chunks == chunks.len()); - if !manifest_unchanged { + // 先写所有 chunks(稳定名),再写 manifest —— 保证 partial-write 不会让 + // manifest 指向不完整 chunks。stable name 让 macOS Keychain ACL 一次允许后 + // 长期有效,不再因 UUID 轮换反复弹窗(这是 PR #277 早期 UUID-rotation + // 设计的回退)。 + for (index, chunk) in chunks.iter().enumerate() { + let account = chunk_account(None, index); + keyring_entry_for(&account)? + .set_password(chunk) + .with_context(|| format!("write system credential vault chunk {index}"))?; + } + + let manifest = CredsChunkManifest { + openless_credentials_storage: "chunked".to_string(), + version: 1, + generation: None, + chunks: chunks.len(), + }; let manifest_json = serde_json::to_string(&manifest).context("encode credential manifest failed")?; keyring_entry()? .set_password(&manifest_json) .context("write system credential vault manifest")?; - } - log::info!( - "[vault] save_credentials: {chunks_written}/{} chunks written, manifest {}", - chunks.len(), - if manifest_unchanged { "unchanged" } else { "rewritten" } - ); - - // 清理旧 chunks: - // 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name) - // 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx - if let Some(previous) = previous_manifest { - match previous.generation.as_deref() { - Some(prev_gen) => { - for index in 0..previous.chunks { - delete_keyring_password(&chunk_account(Some(prev_gen), index)); + + // 清理旧 chunks: + // 1) 旧 manifest 用 UUID generation → 那一代 chunks 全删(迁移到 stable name) + // 2) 旧 manifest 也是 stable name,但 chunks 数量比这次多 → 删多余的 idx + if let Some(previous) = previous_manifest { + match previous.generation.as_deref() { + Some(prev_gen) => { + for index in 0..previous.chunks { + delete_keyring_password(&chunk_account(Some(prev_gen), index)); + } } - } - None => { - for index in chunks.len()..previous.chunks { - delete_keyring_password(&chunk_account(None, index)); + None => { + for index in chunks.len()..previous.chunks { + delete_keyring_password(&chunk_account(None, index)); + } } } } - } - remove_legacy_credentials_file_best_effort(); - // 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。 - // 见 CREDENTIALS_CACHE 的 doc。 - store_credentials_cache(&cleaned); - *credentials_manifest_cache().lock() = Some(manifest); - Ok(()) + remove_legacy_credentials_file_best_effort(); + // 写完成功后立刻刷新 process cache —— 同进程后续读不再回 Keychain。 + // 见 CREDENTIALS_CACHE 的 doc。 + store_credentials_cache(&cleaned); + Ok(()) + } } fn lookup_account(root: &CredsRoot, account: CredentialAccount) -> Option { @@ -1308,20 +1150,6 @@ fn write_account(root: &mut CredsRoot, account: CredentialAccount, value: Option // ───────────────────────── HistoryStore ───────────────────────── -/// 听写历史(`history.json`)。 -/// -/// **At-rest 行为(issue #609 F-03 / F-04)**: -/// - 内容以**明文 JSON** 落盘(`DictationSession[]`),便于用户导出/审阅。 -/// - 机密性靠 **OS 文件系统权限**:unix 下写入后收紧到 `0o600`(仅属主可读写); -/// Windows 走 `%APPDATA%` 的 per-user ACL。 -/// - 完整性靠 **HMAC-SHA256**(F-03):密钥 32 字节随机、存**同目录 0o600 文件** -/// (`history_hmac.key`,原 keyring 版因 ad-hoc 签名反复弹钥匙串而迁出,密钥与明文 -/// history 同目录、安全收益对等);每次写入算 HMAC 写 sidecar `history.json.hmac`; -/// 读取时校验,不匹配则 fail-safe 返回空历史,绝不把被篡改的历史喂给下游 LLM。 -/// -/// **已知残留**:尚未做**完整静态加密(at-rest encryption)**——本地能读文件的 -/// 攻击者仍可读到明文历史(但无法在不被发现的情况下篡改)。完整加密(用 keyring -/// 派生密钥加密整个文件)留待后续,见 issue #609 F-04(明确允许"clearly document")。 pub struct HistoryStore { path: PathBuf, lock: Mutex<()>, @@ -1411,9 +1239,6 @@ impl HistoryStore { self.write_locked(&sessions) } - /// 原地替换 id 匹配的历史条目(保持原位置)。用于「重新转录」成功后回写 - /// rawTranscript / finalText / error_code(issue #613)。找不到对应 id 时返回 - /// `Ok(false)`,调用方据此提示「历史条目已不存在」。 pub fn update_entry(&self, updated: DictationSession) -> Result { let _guard = self.lock.lock(); let mut sessions = self.read_locked()?; @@ -1430,142 +1255,13 @@ impl HistoryStore { self.write_locked(&Vec::::new()) } - /// issue #609 F-03:读 history 前先做 HMAC 完整性校验。 - /// - /// - HMAC 密钥不可用(keyring 缺失等)→ 退化为不校验,按内容直接读(保持可用)。 - /// - 文件不存在 / 为空 → 空历史(正常首次启动)。 - /// - sidecar 存在且 HMAC 不匹配 → **判定被投毒/损坏,fail-safe 返回空历史**并 - /// log::warn,绝不把被篡改的历史喂给下游 LLM(对话感知 polish)。 - /// - sidecar 缺失但**标志未置位** → 真正的 legacy 文件:接受当前内容、补写 sidecar、 - /// 置位 enrolled 标志完成迁移(issue #609 C-01)。 - /// - sidecar 缺失但**标志已置位** → 攻击者删了 sidecar 想伪装 legacy:fail-safe 返回空。 fn read_locked(&self) -> Result> { - read_history_with_key(&self.path, history_hmac_key().as_deref(), &FileEnrollment) + read_or_default::>(&self.path) } - /// issue #609 F-03/F-04/C-01:写 history 后算 HMAC 写 sidecar;unix 下把两文件都设 - /// 0o600;首次写顺带置位 enrolled 标志。 fn write_locked(&self, sessions: &[DictationSession]) -> Result<()> { let json = serde_json::to_vec_pretty(sessions).context("encode history failed")?; - write_history_with_key( - &self.path, - &json, - history_hmac_key().as_deref(), - &FileEnrollment, - ) - } -} - -/// 读 history 并按 `key` 做 HMAC 完整性校验(纯函数,便于单测)。 -/// -/// `key == None`:无密钥,退化为不校验,按内容直接读(与历史行为一致)。 -/// 详细语义见 `HistoryStore::read_locked` 的文档。 -fn read_history_with_key( - path: &Path, - key: Option<&[u8]>, - enrollment: &dyn HmacEnrollment, -) -> Result> { - let Some(key) = key else { - return read_or_default::>(path); - }; - // TOCTOU 收口(rust):不先 exists() 再 read(),直接 read(),NotFound 当空历史。 - let bytes = match fs::read(path) { - Ok(bytes) => bytes, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(e).with_context(|| format!("read failed: {}", path.display())), - }; - if bytes.is_empty() { - return Ok(Vec::new()); - } - let sidecar = history_hmac_sidecar_path(path); - let expected = compute_history_hmac(key, &bytes); - match fs::read_to_string(&sidecar) { - Ok(stored) => { - if !hmac_hex_eq(stored.trim(), &expected) { - // 投毒/损坏:fail-safe,不解析、不喂下游。 - log::warn!( - "[history] HMAC 校验失败(疑似被篡改或损坏),fail-safe 返回空历史:{}", - path.display() - ); - return Ok(Vec::new()); - } - serde_json::from_slice::>(&bytes) - .with_context(|| format!("decode failed: {}", path.display())) - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - // issue #609 C-01:sidecar 缺失要分两种情况。 - if enrollment.is_enrolled() { - // 标志已置位:本应有 sidecar 却没了 → 攻击者删 sidecar 想伪装 legacy - // 绕过 HMAC。fail-safe:返回空历史,绝不接受、绝不补签。 - log::warn!( - "[history] HMAC 已启用但 sidecar 缺失(疑似被删除以绕过完整性校验),fail-safe 返回空历史:{}", - path.display() - ); - return Ok(Vec::new()); - } - // 真正的 legacy:老用户的 history.json 从没带过 .hmac → 接受、补写 sidecar、 - // 置位 enrolled 标志完成迁移。此后再缺 sidecar 就会落到上面的 fail-safe。 - log::info!( - "[history] 未发现 HMAC sidecar 且未启用,视为 legacy 文件,接受并补写完整性标记" - ); - if let Err(err) = write_hmac_sidecar(&sidecar, &expected) { - log::warn!("[history] 迁移补写 HMAC sidecar 失败:{err}"); - } else { - enrollment.set_enrolled(); - } - serde_json::from_slice::>(&bytes) - .with_context(|| format!("decode failed: {}", path.display())) - } - Err(e) => { - Err(e).with_context(|| format!("read HMAC sidecar failed: {}", sidecar.display())) - } - } -} - -/// 写 history JSON、收紧权限、按 `key` 写 HMAC sidecar(纯函数,便于单测)。 -/// -/// issue #609 C-01:成功写出 sidecar 后**幂等置位 enrolled 标志**——这样此后任何 -/// sidecar 缺失都会被读路径判定为篡改,而不是误当 legacy 接受。 -fn write_history_with_key( - path: &Path, - json: &[u8], - key: Option<&[u8]>, - enrollment: &dyn HmacEnrollment, -) -> Result<()> { - // M-01:history.json 明文存储,at-rest 防护靠 OS 文件系统权限——unix 在 rename - // **之前**就把 tmp 文件设 0o600,消除「rename 后再 chmod」之间的世界可读窗口。 - atomic_write_private(path, json)?; - if let Some(key) = key { - let sidecar = history_hmac_sidecar_path(path); - let mac = compute_history_hmac(key, json); - match write_hmac_sidecar(&sidecar, &mac) { - Ok(()) => enrollment.set_enrolled(), - // sidecar 写失败不阻断主写入(数据已落盘),但记一笔——也不置位标志, - // 让下次读仍能走 legacy 迁移补写,而不是误判篡改。 - Err(e) => log::warn!("[history] 写 HMAC sidecar 失败:{e}"), - } - } - Ok(()) -} - -/// 写 HMAC sidecar 文件并(unix)在 rename 前设 0o600(M-01:无 umask 暴露窗口)。 -fn write_hmac_sidecar(sidecar: &Path, hmac_hex: &str) -> Result<()> { - atomic_write_private(sidecar, hmac_hex.as_bytes()) -} - -/// issue #609 F-04:unix 下把文件权限收紧到 0o600(仅属主可读写)。 -/// Windows / 其他平台 no-op(依赖用户目录 ACL)。best-effort:失败只 warn。 -fn restrict_file_permissions_best_effort(path: &Path) { - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Err(e) = fs::set_permissions(path, fs::Permissions::from_mode(0o600)) { - log::warn!("[history] 设置 {} 权限 0o600 失败:{e}", path.display()); - } - } - #[cfg(not(unix))] - { - let _ = path; + atomic_write(&self.path, &json) } } @@ -2835,217 +2531,14 @@ impl CredentialsVault { #[cfg(test)] mod tests { use super::{ - chunk_json_payload, chunk_skip_mask, compute_history_hmac, decode_hex, encode_hex, - history_hmac_sidecar_path, hmac_hex_eq, list_vocab_presets, read_history_with_key, - read_preferences, save_vocab_presets, sync_style_pack_preferences, - validate_correction_rule_syntax, write_history_with_key, HmacEnrollment, + chunk_json_payload, list_vocab_presets, read_preferences, save_vocab_presets, + sync_style_pack_preferences, validate_correction_rule_syntax, KEYRING_CHUNK_MAX_UTF16_UNITS, }; use crate::types::{builtin_style_packs, CustomStylePrompts, VocabPreset, VocabPresetStore}; - use std::cell::Cell; use std::fs; use std::path::PathBuf; - /// 内存版 enrolled 标志,单测注入,不打真 keyring。 - #[derive(Default)] - struct MemEnrollment { - enrolled: Cell, - } - - impl MemEnrollment { - fn new() -> Self { - Self::default() - } - fn enrolled() -> Self { - let m = Self::default(); - m.enrolled.set(true); - m - } - } - - impl HmacEnrollment for MemEnrollment { - fn is_enrolled(&self) -> bool { - self.enrolled.get() - } - fn set_enrolled(&self) { - self.enrolled.set(true); - } - } - - fn history_test_dir() -> PathBuf { - let dir = - std::env::temp_dir().join(format!("openless-history-hmac-{}", uuid::Uuid::new_v4())); - fs::create_dir_all(&dir).expect("create temp dir"); - dir - } - - // 一条最小但合法的 DictationSession JSON 数组,足够 serde 往返。 - // DictationSession 是 camelCase 序列化。 - const SAMPLE_HISTORY_JSON: &str = r#"[{ - "id": "s1", - "createdAt": "2026-06-07T00:00:00Z", - "rawTranscript": "你好", - "finalText": "你好。", - "mode": "light", - "appBundleId": null, - "appName": null, - "insertStatus": "inserted", - "errorCode": null, - "durationMs": null, - "dictionaryEntryCount": null - }]"#; - - #[test] - fn hex_roundtrip() { - let bytes = [0u8, 1, 15, 16, 255, 128, 42]; - assert_eq!(decode_hex(&encode_hex(&bytes)).unwrap(), bytes); - assert_eq!(encode_hex(&[0xab, 0xcd]), "abcd"); - assert!(decode_hex("xyz").is_err()); - } - - #[test] - fn hmac_hex_eq_constant_time_matches() { - let key = b"k"; - let a = compute_history_hmac(key, b"hello"); - let b = compute_history_hmac(key, b"hello"); - let c = compute_history_hmac(key, b"world"); - assert!(hmac_hex_eq(&a, &b)); - assert!(!hmac_hex_eq(&a, &c)); - assert!(!hmac_hex_eq(&a, "deadbeef")); - } - - #[test] - fn history_write_then_read_passes_verification() { - let dir = history_test_dir(); - let path = dir.join("history.json"); - let key = [7u8; 32]; - let enr = MemEnrollment::new(); - write_history_with_key(&path, SAMPLE_HISTORY_JSON.as_bytes(), Some(&key), &enr).unwrap(); - // sidecar 应存在,且写入后置位 enrolled 标志。 - assert!(history_hmac_sidecar_path(&path).exists()); - assert!(enr.is_enrolled(), "首次写 sidecar 后必须置位 enrolled 标志"); - let sessions = read_history_with_key(&path, Some(&key), &enr).unwrap(); - assert_eq!(sessions.len(), 1); - assert_eq!(sessions[0].id, "s1"); - } - - #[test] - fn history_tampered_bytes_fail_safe_to_empty() { - let dir = history_test_dir(); - let path = dir.join("history.json"); - let key = [9u8; 32]; - let enr = MemEnrollment::new(); - write_history_with_key(&path, SAMPLE_HISTORY_JSON.as_bytes(), Some(&key), &enr).unwrap(); - // 攻击者篡改 history.json,但 sidecar 仍是旧 HMAC → 校验失败。 - let tampered = SAMPLE_HISTORY_JSON.replace("你好。", "被注入的内容"); - fs::write(&path, tampered.as_bytes()).unwrap(); - let sessions = read_history_with_key(&path, Some(&key), &enr).unwrap(); - assert!( - sessions.is_empty(), - "篡改后必须 fail-safe 返回空历史,不喂下游" - ); - } - - #[test] - fn history_legacy_without_sidecar_is_accepted_and_migrated() { - let dir = history_test_dir(); - let path = dir.join("history.json"); - let key = [3u8; 32]; - // 老用户:只有 history.json,没有 .hmac,且 enrolled 标志未置位。 - fs::write(&path, SAMPLE_HISTORY_JSON.as_bytes()).unwrap(); - let sidecar = history_hmac_sidecar_path(&path); - assert!(!sidecar.exists()); - let enr = MemEnrollment::new(); - // 首次读:接受并补写 sidecar,置位标志。 - let sessions = read_history_with_key(&path, Some(&key), &enr).unwrap(); - assert_eq!(sessions.len(), 1); - assert!( - sidecar.exists(), - "legacy 读取后必须补写 HMAC sidecar 完成迁移" - ); - assert!( - enr.is_enrolled(), - "legacy 迁移后必须置位 enrolled 标志(C-01)" - ); - // 迁移后再读,HMAC 已匹配,仍正常返回。 - let again = read_history_with_key(&path, Some(&key), &enr).unwrap(); - assert_eq!(again.len(), 1); - } - - /// issue #609 C-01 核心回归:enrolled 已置位后攻击者删 sidecar 想伪装 legacy, - /// 必须 fail-safe 返回空(不再误当 legacy 接受+补签)。 - #[test] - fn history_enrolled_then_sidecar_deleted_fails_safe_not_legacy() { - let dir = history_test_dir(); - let path = dir.join("history.json"); - let key = [5u8; 32]; - let enr = MemEnrollment::new(); - // 正常写入:sidecar 生成,标志置位。 - write_history_with_key(&path, SAMPLE_HISTORY_JSON.as_bytes(), Some(&key), &enr).unwrap(); - let sidecar = history_hmac_sidecar_path(&path); - assert!(sidecar.exists()); - assert!(enr.is_enrolled()); - // 攻击者篡改 history.json 并删除 sidecar,企图把篡改内容伪装成 legacy。 - let tampered = SAMPLE_HISTORY_JSON.replace("你好。", "被注入的内容"); - fs::write(&path, tampered.as_bytes()).unwrap(); - fs::remove_file(&sidecar).unwrap(); - let sessions = read_history_with_key(&path, Some(&key), &enr).unwrap(); - assert!( - sessions.is_empty(), - "enrolled 后 sidecar 缺失必须判定篡改、fail-safe 返回空,不接受、不补签" - ); - // 关键:不得偷偷补回 sidecar(不补签)。 - assert!(!sidecar.exists(), "fail-safe 路径不得为攻击者补写 sidecar"); - } - - /// enrolled 已置位、sidecar 缺失但 history 也未篡改 —— 仍按篡改处理(fail-safe)。 - /// 因为读路径无法区分「无害删除」与「篡改后删除」,一律保守。 - #[test] - fn history_enrolled_sidecar_missing_is_failsafe_even_if_content_intact() { - let dir = history_test_dir(); - let path = dir.join("history.json"); - let key = [6u8; 32]; - let enr = MemEnrollment::enrolled(); - // history 内容合法,但 sidecar 从未写入(已 enrolled)。 - fs::write(&path, SAMPLE_HISTORY_JSON.as_bytes()).unwrap(); - let sessions = read_history_with_key(&path, Some(&key), &enr).unwrap(); - assert!(sessions.is_empty(), "enrolled + 无 sidecar → fail-safe"); - } - - #[test] - fn history_no_key_reads_without_verification() { - let dir = history_test_dir(); - let path = dir.join("history.json"); - fs::write(&path, SAMPLE_HISTORY_JSON.as_bytes()).unwrap(); - // key=None:退化为不校验,按内容读。 - let sessions = read_history_with_key(&path, None, &MemEnrollment::new()).unwrap(); - assert_eq!(sessions.len(), 1); - } - - #[cfg(unix)] - #[test] - fn history_write_sets_0600_permissions() { - use std::os::unix::fs::PermissionsExt; - let dir = history_test_dir(); - let path = dir.join("history.json"); - let key = [1u8; 32]; - write_history_with_key( - &path, - SAMPLE_HISTORY_JSON.as_bytes(), - Some(&key), - &MemEnrollment::new(), - ) - .unwrap(); - let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600, "history.json 应为 0o600"); - let sidecar_mode = fs::metadata(history_hmac_sidecar_path(&path)) - .unwrap() - .permissions() - .mode() - & 0o777; - assert_eq!(sidecar_mode, 0o600, "sidecar 应为 0o600"); - } - #[test] fn credential_payload_chunks_stay_under_windows_blob_limit() { let payload = format!( @@ -3062,41 +2555,6 @@ mod tests { .all(|chunk| chunk.encode_utf16().count() <= KEYRING_CHUNK_MAX_UTF16_UNITS)); } - #[test] - fn chunk_skip_mask_skips_unchanged_and_rewrites_changed() { - // issue #602:内容完全一致 → 全部跳过(no-op save 不再碰 keychain chunks)。 - let json = format!( - "{}{}", - "a".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS), - "b".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS) - ); - let chunks = chunk_json_payload(&json); - assert_eq!(chunks.len(), 2); - assert!(chunk_skip_mask(Some(&json), &chunks).iter().all(|s| *s)); - - // 等长改动只落在第 2 个 chunk → 第 1 个跳过、第 2 个重写。 - let changed = format!( - "{}{}", - "a".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS), - "c".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS) - ); - let changed_chunks = chunk_json_payload(&changed); - assert_eq!( - chunk_skip_mask(Some(&json), &changed_chunks), - vec![true, false] - ); - - // 冷缓存(None)→ 全部重写,等同旧行为。 - assert!(chunk_skip_mask(None, &chunks).iter().all(|s| !*s)); - - // 旧内容更短 → 超出部分无旧 chunk 可比,必须写。 - let shorter = "a".repeat(KEYRING_CHUNK_MAX_UTF16_UNITS); - assert_eq!( - chunk_skip_mask(Some(&shorter), &chunks), - vec![true, false] - ); - } - #[test] fn legacy_streaming_insert_false_is_migrated_and_marker_is_persisted() { let tmp: PathBuf = diff --git a/openless-all/app/src-tauri/src/recorder.rs b/openless-all/app/src-tauri/src/recorder.rs index a943b783..a87e1ee9 100644 --- a/openless-all/app/src-tauri/src/recorder.rs +++ b/openless-all/app/src-tauri/src/recorder.rs @@ -311,7 +311,8 @@ fn build_input_stream( .map_err(|e| classify_default_config_err(e.to_string()))?; let sample_format = supported.sample_format(); - let config: StreamConfig = supported.config(); + let default_config: StreamConfig = supported.config(); + let config = stable_input_config_for_platform(&default_config); let input_sr = config.sample_rate.0; let channels = config.channels as usize; @@ -324,21 +325,59 @@ fn build_input_stream( ); let state = Arc::new(StreamState::new()); - let stream = build_stream_for_format( + let stream = match build_stream_for_format( &device, &config, sample_format, - consumer, - level_handler, - archiver, + Arc::clone(&consumer), + Arc::clone(&level_handler), + archiver.clone(), Arc::clone(&state), input_sr, channels, - runtime_error_tx, - )?; + runtime_error_tx.clone(), + ) { + Ok(stream) => stream, + Err(err) if config != default_config => { + log::warn!( + "[recorder] stable input config failed; falling back to default config: {err}" + ); + build_stream_for_format( + &device, + &default_config, + sample_format, + consumer, + level_handler, + archiver, + Arc::clone(&state), + default_config.sample_rate.0, + default_config.channels as usize, + runtime_error_tx, + )? + } + Err(err) => return Err(err), + }; Ok((stream, state)) } +#[cfg(target_os = "android")] +fn stable_input_config_for_platform(default_config: &StreamConfig) -> StreamConfig { + let mut config = default_config.clone(); + if config.channels > 1 { + log::info!( + "[recorder] android forcing mono input channels: {} -> 1", + config.channels + ); + config.channels = 1; + } + config +} + +#[cfg(not(target_os = "android"))] +fn stable_input_config_for_platform(default_config: &StreamConfig) -> StreamConfig { + default_config.clone() +} + fn select_input_device( host: &cpal::Host, microphone_device_name: Option<&str>, diff --git a/openless-all/app/src-tauri/src/remote_server/mod.rs b/openless-all/app/src-tauri/src/remote_server/mod.rs index d6eb39c4..347cc263 100644 --- a/openless-all/app/src-tauri/src/remote_server/mod.rs +++ b/openless-all/app/src-tauri/src/remote_server/mod.rs @@ -299,30 +299,47 @@ fn build_router(state: Arc) -> Router { .route("/", get(index_handler)) .route( "/app.js", - get(|| async { ([(axum::http::header::CONTENT_TYPE, HEADER_JS)], assets::APP_JS) }), + get(|| async { + ( + [(axum::http::header::CONTENT_TYPE, HEADER_JS)], + assets::APP_JS, + ) + }), ) .route( "/style.css", get(|| async { - ([(axum::http::header::CONTENT_TYPE, HEADER_CSS)], assets::STYLE_CSS) + ( + [(axum::http::header::CONTENT_TYPE, HEADER_CSS)], + assets::STYLE_CSS, + ) }), ) .route( "/icon.png", get(|| async { - ([(axum::http::header::CONTENT_TYPE, "image/png")], assets::ICON_PNG) + ( + [(axum::http::header::CONTENT_TYPE, "image/png")], + assets::ICON_PNG, + ) }), ) .route( "/mic.png", get(|| async { - ([(axum::http::header::CONTENT_TYPE, "image/png")], assets::MIC_PNG) + ( + [(axum::http::header::CONTENT_TYPE, "image/png")], + assets::MIC_PNG, + ) }), ) .route( "/done.png", get(|| async { - ([(axum::http::header::CONTENT_TYPE, "image/png")], assets::DONE_PNG) + ( + [(axum::http::header::CONTENT_TYPE, "image/png")], + assets::DONE_PNG, + ) }), ) // 证书下载:手机在浏览器打开它即可下载并安装信任(iOS Safari 的 wss 不复用 @@ -331,7 +348,10 @@ fn build_router(state: Arc) -> Router { "/cert.cer", get(|State(state): State>| async move { ( - [(axum::http::header::CONTENT_TYPE, "application/x-x509-ca-cert")], + [( + axum::http::header::CONTENT_TYPE, + "application/x-x509-ca-cert", + )], state.cert_der.clone(), ) }), @@ -548,19 +568,25 @@ async fn handle_ws(mut socket: WebSocket, state: Arc, peer_ip: IpAddr) match authed { AuthResult::Ok => { log::info!("[remote-input] 配对成功,进入录音会话"); - let _ = socket.send(send_json(&serde_json::json!({"type":"auth","ok":true}))).await; + let _ = socket + .send(send_json(&serde_json::json!({"type":"auth","ok":true}))) + .await; } AuthResult::BadPin => { log::warn!("[remote-input] 配对码错误,已拒绝"); let _ = socket - .send(send_json(&serde_json::json!({"type":"auth","ok":false,"reason":"bad-pin"}))) + .send(send_json( + &serde_json::json!({"type":"auth","ok":false,"reason":"bad-pin"}), + )) .await; return; } AuthResult::Locked => { log::warn!("[remote-input] 配对已锁定(连续错误过多),已拒绝"); let _ = socket - .send(send_json(&serde_json::json!({"type":"auth","ok":false,"reason":"locked"}))) + .send(send_json( + &serde_json::json!({"type":"auth","ok":false,"reason":"locked"}), + )) .await; return; } @@ -666,7 +692,9 @@ async fn handle_control(txt: &str, state: &Arc, socket: &mut WebSocket) Err(reason) => { log::warn!("[remote-input] 开始录音被拒:{reason}"); let _ = socket - .send(send_json(&serde_json::json!({"type":"busy","reason":reason}))) + .send(send_json( + &serde_json::json!({"type":"busy","reason":reason}), + )) .await; } } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 7df37f60..8794081e 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -3,6 +3,22 @@ use serde::{Deserialize, Serialize}; +#[path = "android/types.rs"] +pub mod android_types; + +use android_types::{ + default_android_insert_strategy, default_android_overlay_activation_mode, + default_android_overlay_cancel_swipe_direction, default_android_overlay_left_swipe_action, + default_android_overlay_size_dp, default_android_overlay_trigger, + normalize_android_insert_strategy, normalize_android_overlay_size_dp, +}; +pub use android_types::{ + AndroidAccessibilityState, AndroidAccessibilityStatus, AndroidInsertStrategy, + AndroidOverlayActivationMode, AndroidOverlayCancelSwipeDirection, + AndroidOverlayLeftSwipeAction, AndroidOverlayPermissionState, AndroidOverlayStatus, + AndroidOverlayTrigger, +}; + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] #[derive(Default)] @@ -636,6 +652,18 @@ pub struct UserPreferences { /// 热键 2:快取用键(选中→Claude→回插)。默认 `None`(用户自配)。 #[serde(default)] pub coding_agent_quick_hotkey: Option, + /// 局域网远程输入服务开关。桌面端启动 HTTPS+WS 服务,手机浏览器推 PCM 到电脑。 + #[serde(default)] + pub remote_input_enabled: bool, + /// 局域网远程输入服务端口。 + #[serde(default = "default_remote_input_port")] + pub remote_input_port: u16, + /// 当前远程输入 PIN。真实运行时 PIN 另有进程内/磁盘路径维护,此字段保留 wire 兼容。 + #[serde(default)] + pub remote_input_pin: String, + /// 远程输入默认按钮模式。 + #[serde(default = "default_remote_input_mode")] + pub remote_input_default_mode: String, /// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。 /// 仅在 active_asr_provider == "local-qwen3" 时有意义。 #[serde(default = "default_local_asr_model")] @@ -749,19 +777,28 @@ pub struct UserPreferences { /// 上传 / 点赞需要带这个 header;空时上传被后端 401。 #[serde(default)] pub marketplace_dev_login: String, - /// ── 远程输入(局域网手机录音)──────────────────────────────── - /// 是否启用远程输入 HTTPS+WS 服务。默认 false(关闭,按需手动开启)。 - #[serde(default)] - pub remote_input_enabled: bool, - /// 远程输入服务监听端口(HTTPS)。默认 8443。 - #[serde(default = "default_remote_input_port")] - pub remote_input_port: u16, - /// 远程输入配对码(6 位数字)。空 = server 首次启动时随机生成并回写。 - #[serde(default)] - pub remote_input_pin: String, - /// 手机录音页默认交互方式:"toggle"(点击切换)/ "hold"(按住说话)。 - #[serde(default = "default_remote_input_mode")] - pub remote_input_default_mode: String, + /// Android: text insertion strategy for cross-app dictation results. + #[serde(default = "default_android_insert_strategy")] + pub android_insert_strategy: AndroidInsertStrategy, + /// Android: when to show the floating overlay control. + #[serde(default = "default_android_overlay_trigger")] + pub android_overlay_trigger: AndroidOverlayTrigger, + /// Android: how the floating overlay enters the armed interaction state. + #[serde(default = "default_android_overlay_activation_mode")] + pub android_overlay_activation_mode: AndroidOverlayActivationMode, + /// Android: action performed by left swiping while the overlay is armed. + #[serde(default = "default_android_overlay_left_swipe_action")] + pub android_overlay_left_swipe_action: AndroidOverlayLeftSwipeAction, + /// Android: vertical swipe direction that cancels recording. + #[serde(default = "default_android_overlay_cancel_swipe_direction")] + pub android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, + /// Android: floating overlay control diameter in dp. + #[serde(default = "default_android_overlay_size_dp")] + pub android_overlay_size_dp: u32, +} + +fn default_local_asr_model() -> String { + "qwen3-asr-0.6b".into() } fn default_remote_input_port() -> u16 { @@ -772,10 +809,6 @@ fn default_remote_input_mode() -> String { "toggle".into() } -fn default_local_asr_model() -> String { - "qwen3-asr-0.6b".into() -} - fn default_history_retention_days() -> u32 { 7 } @@ -871,6 +904,14 @@ struct UserPreferencesWire { coding_agent_panel_hotkey: Option, #[serde(default)] coding_agent_quick_hotkey: Option, + #[serde(default)] + remote_input_enabled: bool, + #[serde(default = "default_remote_input_port")] + remote_input_port: u16, + #[serde(default)] + remote_input_pin: String, + #[serde(default = "default_remote_input_mode")] + remote_input_default_mode: String, #[serde(default = "default_local_asr_model")] local_asr_active_model: String, #[serde(default = "default_local_asr_mirror")] @@ -919,14 +960,18 @@ struct UserPreferencesWire { marketplace_base_url: String, #[serde(default)] marketplace_dev_login: String, - #[serde(default)] - remote_input_enabled: bool, - #[serde(default = "default_remote_input_port")] - remote_input_port: u16, - #[serde(default)] - remote_input_pin: String, - #[serde(default = "default_remote_input_mode")] - remote_input_default_mode: String, + #[serde(default = "default_android_insert_strategy")] + android_insert_strategy: AndroidInsertStrategy, + #[serde(default = "default_android_overlay_trigger")] + android_overlay_trigger: AndroidOverlayTrigger, + #[serde(default = "default_android_overlay_activation_mode")] + android_overlay_activation_mode: AndroidOverlayActivationMode, + #[serde(default = "default_android_overlay_left_swipe_action")] + android_overlay_left_swipe_action: AndroidOverlayLeftSwipeAction, + #[serde(default = "default_android_overlay_cancel_swipe_direction")] + android_overlay_cancel_swipe_direction: AndroidOverlayCancelSwipeDirection, + #[serde(default = "default_android_overlay_size_dp")] + android_overlay_size_dp: u32, } impl Default for UserPreferencesWire { @@ -970,6 +1015,10 @@ impl Default for UserPreferencesWire { coding_agent_voice_hotkey: prefs.coding_agent_voice_hotkey, coding_agent_panel_hotkey: prefs.coding_agent_panel_hotkey, coding_agent_quick_hotkey: prefs.coding_agent_quick_hotkey, + remote_input_enabled: prefs.remote_input_enabled, + remote_input_port: prefs.remote_input_port, + remote_input_pin: prefs.remote_input_pin, + remote_input_default_mode: prefs.remote_input_default_mode, local_asr_active_model: prefs.local_asr_active_model, local_asr_mirror: prefs.local_asr_mirror, local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs, @@ -994,10 +1043,12 @@ impl Default for UserPreferencesWire { audio_recording_max_entries: prefs.audio_recording_max_entries, marketplace_base_url: prefs.marketplace_base_url, marketplace_dev_login: prefs.marketplace_dev_login, - remote_input_enabled: prefs.remote_input_enabled, - remote_input_port: prefs.remote_input_port, - remote_input_pin: prefs.remote_input_pin, - remote_input_default_mode: prefs.remote_input_default_mode, + android_insert_strategy: prefs.android_insert_strategy, + android_overlay_trigger: prefs.android_overlay_trigger, + android_overlay_activation_mode: prefs.android_overlay_activation_mode, + android_overlay_left_swipe_action: prefs.android_overlay_left_swipe_action, + android_overlay_cancel_swipe_direction: prefs.android_overlay_cancel_swipe_direction, + android_overlay_size_dp: prefs.android_overlay_size_dp, } } } @@ -1058,6 +1109,10 @@ impl<'de> Deserialize<'de> for UserPreferences { coding_agent_voice_hotkey: wire.coding_agent_voice_hotkey, coding_agent_panel_hotkey: wire.coding_agent_panel_hotkey, coding_agent_quick_hotkey: wire.coding_agent_quick_hotkey, + remote_input_enabled: wire.remote_input_enabled, + remote_input_port: wire.remote_input_port, + remote_input_pin: wire.remote_input_pin, + remote_input_default_mode: wire.remote_input_default_mode, custom_combo_hotkey: wire.custom_combo_hotkey, translation_hotkey: wire .translation_hotkey @@ -1094,10 +1149,16 @@ impl<'de> Deserialize<'de> for UserPreferences { audio_recording_max_entries: wire.audio_recording_max_entries, marketplace_base_url: wire.marketplace_base_url, marketplace_dev_login: wire.marketplace_dev_login, - remote_input_enabled: wire.remote_input_enabled, - remote_input_port: wire.remote_input_port, - remote_input_pin: wire.remote_input_pin, - remote_input_default_mode: wire.remote_input_default_mode, + android_insert_strategy: normalize_android_insert_strategy( + wire.android_insert_strategy, + ), + android_overlay_trigger: wire.android_overlay_trigger.normalized(), + android_overlay_activation_mode: wire.android_overlay_activation_mode, + android_overlay_left_swipe_action: wire.android_overlay_left_swipe_action, + android_overlay_cancel_swipe_direction: wire.android_overlay_cancel_swipe_direction, + android_overlay_size_dp: normalize_android_overlay_size_dp( + wire.android_overlay_size_dp, + ), }) } } @@ -1232,15 +1293,7 @@ const OUTPUT_BLOCK: &str = "# 输出\n\ /// 自带 # 角色 + {{HOTWORDS}} + 八节主体(结构化判断、双层格式、首行收尾、ASR 纠错、 /// 原样保留、禁止事项、输出),因此 Structured 模式跳过标准 ROLE_BLOCK / COMMON_RULES / /// OUTPUT_BLOCK wrapper,避免与 v2 内的同名段落重复。 -const STRUCTURED_BUILTIN_PROMPT: &str = r#"# ⚡ 第一指令(高于一切,先执行再看细则) - -先数原文里有几件「可区分的事项」: -- **≥2 件 → 必须输出编号清单**(行首 1. 2. 3.),**禁止**把多件事揉成一整段。≥3 件还要按主题归类、子项另起一行用 (a) (b)。 -- 恰好 1 件 → 才输出连贯段落。 - -判断依据是「事项数」,**不是**原文有没有标点 / 换行 / 已经编号。只要有 2 件以上事项却揉进一段话 = 直接失败。最终形态照本文末尾「# 示例」里的样子输出。 - -# 角色 +const STRUCTURED_BUILTIN_PROMPT: &str = r#"# 角色 你是「清晰结构」整理器。用户输入来自语音识别(ASR),常带错别字、同音字、英文术语音译、断句缺失、语序混乱、口语化表达等问题。 @@ -1802,6 +1855,10 @@ impl Default for UserPreferences { coding_agent_voice_hotkey: default_coding_agent_voice_hotkey(), coding_agent_panel_hotkey: default_coding_agent_panel_hotkey(), coding_agent_quick_hotkey: None, + remote_input_enabled: false, + remote_input_port: default_remote_input_port(), + remote_input_pin: String::new(), + remote_input_default_mode: default_remote_input_mode(), local_asr_active_model: default_local_asr_model(), local_asr_mirror: default_local_asr_mirror(), local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(), @@ -1826,10 +1883,13 @@ impl Default for UserPreferences { audio_recording_max_entries: None, marketplace_base_url: String::new(), marketplace_dev_login: String::new(), - remote_input_enabled: false, - remote_input_port: default_remote_input_port(), - remote_input_pin: String::new(), - remote_input_default_mode: default_remote_input_mode(), + android_insert_strategy: default_android_insert_strategy(), + android_overlay_trigger: default_android_overlay_trigger(), + android_overlay_activation_mode: default_android_overlay_activation_mode(), + android_overlay_left_swipe_action: default_android_overlay_left_swipe_action(), + android_overlay_cancel_swipe_direction: default_android_overlay_cancel_swipe_direction( + ), + android_overlay_size_dp: default_android_overlay_size_dp(), } } } @@ -2038,6 +2098,8 @@ pub enum HotkeyAdapterKind { MacEventTap, WindowsLowLevel, Fcitx5, + /// Mobile platforms do not expose desktop global hotkey adapters. + Unavailable, } impl HotkeyAdapterKind { @@ -2046,6 +2108,7 @@ impl HotkeyAdapterKind { HotkeyAdapterKind::MacEventTap => "macOS Event Tap", HotkeyAdapterKind::WindowsLowLevel => "Windows 低层键盘 hook", HotkeyAdapterKind::Fcitx5 => "fcitx5 输入法插件", + HotkeyAdapterKind::Unavailable => "不可用", } } } @@ -2201,6 +2264,21 @@ pub struct HotkeyCapability { impl HotkeyCapability { pub fn current() -> Self { + #[cfg(mobile)] + { + return Self { + adapter: HotkeyAdapterKind::Unavailable, + available_triggers: Vec::new(), + requires_accessibility_permission: false, + supports_modifier_only_trigger: false, + supports_side_specific_modifiers: false, + explicit_fallback_available: false, + status_hint: Some( + "移动端不支持全局热键;请使用应用内录音按钮或悬浮窗(需授权)。".into(), + ), + }; + } + #[cfg(target_os = "macos")] { Self { @@ -2245,7 +2323,7 @@ impl HotkeyCapability { }; } - #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + #[cfg(all(not(target_os = "macos"), not(target_os = "windows"), not(mobile)))] { Self { adapter: HotkeyAdapterKind::Fcitx5, @@ -2307,6 +2385,68 @@ pub struct WindowsImeStatus { pub dll_path: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PlatformCapabilities { + pub platform: String, + pub supports_ime_input: bool, + pub supports_overlay: bool, + pub supports_desktop_hotkey: bool, + pub supports_tray: bool, + pub supports_local_asr: bool, + pub supports_in_app_dictation: bool, + pub supports_auto_update: bool, +} + +impl PlatformCapabilities { + pub fn current() -> Self { + #[cfg(target_os = "android")] + { + Self { + platform: "android".to_string(), + supports_ime_input: false, + supports_overlay: true, + supports_desktop_hotkey: false, + supports_tray: false, + supports_local_asr: false, + supports_in_app_dictation: true, + supports_auto_update: false, + } + } + + #[cfg(all( + any(target_os = "android", target_os = "ios"), + not(target_os = "android") + ))] + { + Self { + platform: "mobile".to_string(), + supports_ime_input: false, + supports_overlay: false, + supports_desktop_hotkey: false, + supports_tray: false, + supports_local_asr: false, + supports_in_app_dictation: false, + supports_auto_update: false, + } + } + + #[cfg(not(any(target_os = "android", target_os = "ios")))] + { + Self { + platform: "desktop".to_string(), + supports_ime_input: cfg!(target_os = "windows"), + supports_overlay: true, + supports_desktop_hotkey: true, + supports_tray: true, + supports_local_asr: cfg!(any(target_os = "macos", target_os = "windows")), + supports_in_app_dictation: false, + supports_auto_update: true, + } + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub enum HotkeyStatusState { diff --git a/openless-all/app/src-tauri/tauri.android.conf.json b/openless-all/app/src-tauri/tauri.android.conf.json new file mode 100644 index 00000000..05ae8f0a --- /dev/null +++ b/openless-all/app/src-tauri/tauri.android.conf.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "identifier": "com.openless.app", + "app": { + "windows": [ + { + "label": "main", + "title": "OpenLess", + "width": 1240, + "height": 800, + "minWidth": 360, + "minHeight": 640, + "resizable": true, + "decorations": true, + "visible": true + } + ] + } +} diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index 8411fed7..6545e9a3 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -128,6 +128,9 @@ "infoPlist": "Info.plist", "entitlements": "Entitlements.plist" }, + "android": { + "minSdkVersion": 26 + }, "windows": { "nsis": { "installMode": "perMachine", diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index bb40bc2b..79c60f76 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -9,9 +9,12 @@ import { checkMicrophonePermission, getHotkeyStatus, getSettings, + getPlatformCapabilities, handleWindowHotkeyEvent, isTauri, + qaWindowDismiss, } from './lib/ipc'; +import type { PlatformCapabilities } from './lib/types'; import { isWindowHotkeyKeyboardCandidate, windowMouseHotkeyCode, @@ -30,6 +33,7 @@ interface AppProps { } type Gate = 'onboarding' | 'ready'; +const ANDROID_SETUP_WIZARD_COMPLETE_KEY = 'openless.androidSetupWizardComplete'; export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, forcedOs }: AppProps) { if (isCapsule) { @@ -48,6 +52,65 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force const os = forcedOs ?? detectOS(); // Windows 启动不应被权限探测阻塞首屏。 const [gate, setGate] = useState('ready'); + const [platformCaps, setPlatformCaps] = useState(null); + const [mobileQaOpen, setMobileQaOpen] = useState(false); + const completeOnboarding = () => { + if (platformCaps?.platform === 'android') { + localStorage.setItem(ANDROID_SETUP_WIZARD_COMPLETE_KEY, '1'); + } + setGate('ready'); + }; + useEffect(() => { + if (!isTauri) return; + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + + useEffect(() => { + if (!isTauri || platformCaps?.platform !== 'android') return; + let unlistenState: (() => void) | undefined; + let unlistenDismiss: (() => void) | undefined; + let cancelled = false; + (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + const stateHandle = await listen('qa:state', () => { + console.info('[qa] android qa:state received; opening embedded panel'); + setMobileQaOpen(true); + }); + const dismissHandle = await listen('qa:dismiss', () => { + console.info('[qa] android qa:dismiss received; closing embedded panel'); + setMobileQaOpen(false); + }); + if (cancelled) { + stateHandle(); + dismissHandle(); + } else { + unlistenState = stateHandle; + unlistenDismiss = dismissHandle; + } + } catch (error) { + console.warn('[qa] mobile route listener setup failed', error); + } + })(); + return () => { + cancelled = true; + unlistenState?.(); + unlistenDismiss?.(); + }; + }, [platformCaps?.platform]); + + useEffect(() => { + if (!mobileQaOpen || platformCaps?.platform !== 'android') return; + window.history.pushState({ openlessQa: true }, '', window.location.href); + const onPopState = () => { + setMobileQaOpen(false); + void qaWindowDismiss().catch(error => console.warn('[qa] mobile back dismiss failed', error)); + }; + window.addEventListener('popstate', onPopState); + return () => { + window.removeEventListener('popstate', onPopState); + }; + }, [mobileQaOpen, platformCaps?.platform]); useEffect(() => { if (!isTauri) return; @@ -94,13 +157,30 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force if (!isTauri) return; let cancelled = false; - if (os === 'win') { - // 超时保护:50 次 × 200ms = 10s。hotkey hook 永远 starting(被反作弊 / EDR - // / UAC 拦)时不让 UI 死锁灰屏,过 10s 强 setGate('ready') 让用户进 - // Permissions 页看 hotkey_status.lastError 处理。详见 issue #163。 - const POLL_INTERVAL_MS = 200; - const POLL_MAX_ATTEMPTS = 50; - const pollHotkeyStatus = async () => { + void (async () => { + const caps = await getPlatformCapabilities(); + if (cancelled) return; + + if (caps.platform === 'android') { + if (localStorage.getItem(ANDROID_SETUP_WIZARD_COMPLETE_KEY) !== '1') { + setGate('onboarding'); + return; + } + const m = await checkMicrophonePermission(); + if (cancelled) return; + // notDetermined is non-blocking on Android — show grant flow in-app instead + // of trapping users on onboarding while JNI/runtime permission is pending. + const blocked = m === 'denied' || m === 'restricted'; + setGate(blocked ? 'onboarding' : 'ready'); + return; + } + + if (os === 'win') { + // 超时保护:50 次 × 200ms = 10s。hotkey hook 永远 starting(被反作弊 / EDR + // / UAC 拦)时不让 UI 死锁灰屏,过 10s 强 setGate('ready') 让用户进 + // Permissions 页看 hotkey_status.lastError 处理。详见 issue #163。 + const POLL_INTERVAL_MS = 200; + const POLL_MAX_ATTEMPTS = 50; let attempts = 0; while (!cancelled && attempts < POLL_MAX_ATTEMPTS) { attempts += 1; @@ -118,19 +198,9 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force ); setGate('ready'); } - }; - void pollHotkeyStatus().catch(error => { - console.warn('[startup] hotkey status polling failed', error); - if (!cancelled) { - setGate('ready'); - } - }); - return () => { - cancelled = true; - }; - } + return; + } - (async () => { const [a, m] = await Promise.all([ checkAccessibilityPermission(), checkMicrophonePermission(), @@ -139,7 +209,13 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force const aOk = a === 'granted' || a === 'notApplicable'; const mOk = m === 'granted' || m === 'notApplicable'; setGate(aOk && mOk ? 'ready' : 'onboarding'); - })(); + })().catch(error => { + console.warn('[startup] permission gate failed', error); + if (!cancelled) { + setGate('ready'); + } + }); + return () => { cancelled = true; }; @@ -180,8 +256,25 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force return ( - {gate === 'onboarding' ? setGate('ready')} /> : } - {gate === 'ready' && } + {platformCaps?.platform === 'android' && ( +
+ { + setMobileQaOpen(false); + if (window.history.state?.openlessQa === true) { + window.history.back(); + } + }} + /> +
+ )} + {!mobileQaOpen && (gate === 'onboarding' ? ( + + ) : ( + + ))} + {gate === 'ready' && platformCaps?.supportsAutoUpdate === true && }
); } diff --git a/openless-all/app/src/components/AudioCue.tsx b/openless-all/app/src/components/AudioCue.tsx index 5ad4f2b2..e3f5f4ad 100644 --- a/openless-all/app/src/components/AudioCue.tsx +++ b/openless-all/app/src/components/AudioCue.tsx @@ -1,9 +1,9 @@ // 录音提示音:监听 capsule:state 事件,在"开始录音"边沿播放合成提示音。 // 独立组件,不依赖胶囊窗口显示——Linux 上胶囊隐藏也能正常工作。 -// 全平台通用,在 FloatingShellBody 中渲染。 +// Android Web Audio 输出会触发部分设备的录音输入路由切换,移动端禁用。 import { useEffect, useRef } from 'react'; -import { isTauri } from '../lib/ipc'; +import { isAndroid, isTauri } from '../lib/ipc'; import { playRecordStartCue, primeAudioCue, stopAudioCue } from '../lib/audioCue'; import type { CapsuleState, UserPreferences } from '../lib/types'; @@ -18,10 +18,11 @@ interface CapsulePayload { export function AudioCueListener() { const audioCueEnabledRef = useRef(true); const prevStateRef = useRef('idle' as CapsuleState); + const audioCueRuntimeEnabled = !isAndroid(); // 读取设置(默认开启) useEffect(() => { - if (!isTauri) return; + if (!isTauri || !audioCueRuntimeEnabled) return; let cancelled = false; (async () => { try { @@ -33,11 +34,11 @@ export function AudioCueListener() { } })(); return () => { cancelled = true; }; - }, []); + }, [audioCueRuntimeEnabled]); // 监听设置变更 useEffect(() => { - if (!isTauri) return; + if (!isTauri || !audioCueRuntimeEnabled) return; let unlisten: (() => void) | undefined; let cancelled = false; (async () => { @@ -48,17 +49,17 @@ export function AudioCueListener() { }).then(fn => { if (!cancelled) unlisten = fn; }).catch(() => {}); })(); return () => { cancelled = true; unlisten?.(); }; - }, []); + }, [audioCueRuntimeEnabled]); // 预热 AudioContext useEffect(() => { - if (!isTauri) return; + if (!isTauri || !audioCueRuntimeEnabled) return; primeAudioCue(); - }, []); + }, [audioCueRuntimeEnabled]); // 监听 capsule 状态边沿 useEffect(() => { - if (!isTauri) return; + if (!isTauri || !audioCueRuntimeEnabled) return; let unlisten: (() => void) | undefined; let cancelled = false; (async () => { @@ -75,7 +76,7 @@ export function AudioCueListener() { }).then(fn => { if (!cancelled) unlisten = fn; }).catch(() => {}); })(); return () => { cancelled = true; unlisten?.(); }; - }, []); + }, [audioCueRuntimeEnabled]); return null; } diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index c6a5f096..0d0cfa0e 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -2,30 +2,21 @@ // 状态机 + 对话框 UI。两边各自调用 useAutoUpdate(),dialog 渲染条件相同。 // // 渠道感知:check 不再走 plugin-updater 的 JS check()(它只看 tauri.conf 配的 -// Stable manifest URL),改为 invoke('app_check_update_with_channel')。 +// Stable manifest URL),改为 appCheckUpdateWithChannel()(ipc 层按 +// supportsAutoUpdate 在 Android 上 no-op)。 // Rust 那边按 prefs.update_channel 决定 manifest URL;返回的 metadata 直接 // `new Update(metadata)` 复用 plugin 的 download / install / close 实现, // 我们不重复造下载和签名校验。 import { useEffect, useRef, useState } from 'react'; -import { invoke } from '@tauri-apps/api/core'; import type { DownloadEvent } from '@tauri-apps/plugin-updater'; import { Update } from '@tauri-apps/plugin-updater'; import { useTranslation } from 'react-i18next'; -import { isTauri, restartApp, type UpdateChannel } from '../lib/ipc'; +import { appCheckUpdateWithChannel, isTauri, restartApp, type AppUpdateMetadata, type UpdateChannel } from '../lib/ipc'; import { Btn } from '../pages/_atoms'; const UPDATE_CHECK_TIMEOUT_MS = 15_000; -interface AppUpdateMetadata { - rid: number; - currentVersion: string; - version: string; - date?: string | null; - body?: string | null; - rawJson: Record; -} - export type UpdateStatus = | 'idle' | 'checking' @@ -102,10 +93,10 @@ export function useAutoUpdate(): UseAutoUpdate { } // Rust 侧按 update_channel 拼 manifest URL:Stable → tauri.conf 默认; // Beta → fetch_latest_beta_release 拼出 -beta manifest URL 后再 check。 - const metadata = await invoke('app_check_update_with_channel', { - timeoutMs: UPDATE_CHECK_TIMEOUT_MS, - channel: channel ?? null, - }); + const metadata = await appCheckUpdateWithChannel( + UPDATE_CHECK_TIMEOUT_MS, + channel ?? null, + ); if (!metadata) { setStatus('none'); return; diff --git a/openless-all/app/src/components/AutoUpdateGate.tsx b/openless-all/app/src/components/AutoUpdateGate.tsx index a1953dac..7ff1b6b7 100644 --- a/openless-all/app/src/components/AutoUpdateGate.tsx +++ b/openless-all/app/src/components/AutoUpdateGate.tsx @@ -2,8 +2,10 @@ // 受 prefs.autoUpdateCheck 开关控制;关闭时只走 Settings → 关于 的手动按钮。 // 找到新版本时直接挂 UpdateDialog;不弹自定义通知,沿用既有 dialog 视觉。 -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate'; +import { getPlatformCapabilities } from '../lib/ipc'; +import type { PlatformCapabilities } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000; @@ -12,7 +14,12 @@ const STARTUP_DELAY_MS = 4_000; export function AutoUpdateGate() { const { prefs } = useHotkeySettings(); const u = useAutoUpdate(); - const enabled = prefs?.autoUpdateCheck ?? true; + const [platformCaps, setPlatformCaps] = useState(null); + const enabled = (prefs?.autoUpdateCheck ?? true) && platformCaps?.supportsAutoUpdate === true; + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); // 用 ref 保持 tick 闭包始终读到最新的 useAutoUpdate 返回值。 // 之前直接捕获 `u` 会让 60min interval 触发时读旧 status 闭包——例如用户已经 @@ -43,6 +50,8 @@ export function AutoUpdateGate() { }; }, [enabled]); + if (platformCaps?.supportsAutoUpdate !== true) return null; + if (!isDialogStatus(u.status)) return null; return ( (); const [providerPromptOpen, setProviderPromptOpen] = useState(false); const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false); + const mobileLayout = useMobileLayout(); // tab 切换的 cross-fade:旧页 blur+fade out(180ms),结束后挂载新页(走 ol-page-slide enter)。 // displayTab 是实际渲染的 tab,currentTab 是用户点中的目标 tab。 @@ -96,12 +98,17 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia [t], ); const Page = (NAV.find((n) => n.id === displayTab) ?? NAV[0]).cmp; + const activeNav = NAV.find(n => n.id === currentTab) ?? NAV[0]; // sidebar nav 滑动指示器:测量当前 active button 的 offsetTop / height, // 用一个 absolute pill 平滑滑过去,而不是每个按钮各自瞬切背景色。 const navItemRefs = useRef>([]); const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null); useLayoutEffect(() => { + if (mobileLayout) { + setPillRect(null); + return; + } if (settingsOpen) { setPillRect(null); return; @@ -114,11 +121,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia const el = navItemRefs.current[idx]; if (!el) return; setPillRect({ top: el.offsetTop, height: el.offsetHeight }); - }, [currentTab, settingsOpen, NAV]); + }, [currentTab, settingsOpen, NAV, mobileLayout]); useEffect(() => { let cancelled = false; (async () => { + const caps = await getPlatformCapabilities(); + if (cancelled || caps.platform === 'android') return; const credentials = await getCredentials(); const promptDeferredValue = window.sessionStorage.getItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY); if (!cancelled && shouldShowProviderSetupPrompt(credentials, promptDeferredValue)) { @@ -131,11 +140,18 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia }, []); useEffect(() => { - const acknowledgedValue = window.localStorage.getItem(HOTKEY_MODE_MIGRATION_ACK_KEY); - const deferredValue = window.sessionStorage.getItem(HOTKEY_MODE_MIGRATION_DEFERRED_KEY); - if (shouldShowHotkeyModeMigrationPrompt(acknowledgedValue, deferredValue)) { - setHotkeyModePromptOpen(true); - } + let cancelled = false; + void getPlatformCapabilities().then((caps) => { + if (cancelled || caps.platform === 'android') return; + const acknowledgedValue = window.localStorage.getItem(HOTKEY_MODE_MIGRATION_ACK_KEY); + const deferredValue = window.sessionStorage.getItem(HOTKEY_MODE_MIGRATION_DEFERRED_KEY); + if (shouldShowHotkeyModeMigrationPrompt(acknowledgedValue, deferredValue)) { + setHotkeyModePromptOpen(true); + } + }); + return () => { + cancelled = true; + }; }, []); // 之前监听的 NAVIGATE_LOCAL_ASR_EVENT 已无意义——「模型设置」独立 tab 已下线, @@ -181,37 +197,68 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia }; return ( -
+
{/* Main shell — flush with the frosted backplate (no separate float). */}
- {/* Sidebar — 透明地坐在外层磨砂底板上,让 LOGO/导航/快捷键/BETA/footer 共用同一片磨砂玻璃 */} - + )} - {/* Main content — Linux 禁用透明窗口后使用不透明面;其他平台保留玻璃层。 - 悬浮台到右边 / 下边的间距相等(都 8px),左侧贴 sidebar(0)。 */} -
+ {/* Main content — Linux 禁用透明窗口后使用不透明面;其他平台保留玻璃层。 */} +
+ + {mobileLayout && ( + + )}
{/* Settings modal — rendered inside this window */} @@ -416,12 +446,205 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia color: var(--ol-ink-3); font-weight: 500; } + .ol-aura-sidebar { + padding: 14px 12px 14px; + background: var(--ol-sidebar-bg); + border-right: 1px solid var(--ol-sidebar-border); + } + .ol-aura-sidebar-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 6px 10px 16px; + margin-bottom: 6px; + border-radius: 0; + background: var(--ol-sidebar-brand-bg); + border: 1px solid var(--ol-sidebar-brand-border); + box-shadow: none; + } + .ol-aura-sidebar-brand-mark { + width: 26px; + height: 26px; + border-radius: 8px; + box-shadow: none; + box-sizing: border-box; + padding: 3px; + object-fit: contain; + } + .ol-aura-sidebar-brand-title { + font-size: 14px; + font-weight: 600; + font-family: var(--ol-font-display); + color: var(--ol-ink); + } + .ol-aura-sidebar-brand-kicker { + font-size: 10.5px; + color: var(--ol-ink-4); + font-family: var(--ol-font-mono); + letter-spacing: .08em; + } + .ol-aura-sidebar-pill { + background: var(--ol-sidebar-pill-bg); + border-radius: 12px; + border: 1px solid var(--ol-sidebar-pill-border); + box-shadow: none; + } + .ol-aura-sidebar-nav-btn { + padding: 8px 12px; + border-radius: 12px; + border: 0; + background: transparent; + font-family: inherit; + font-size: 13px; + cursor: default; + transition: color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick); + text-align: left; + position: relative; + z-index: 1; + } + .ol-aura-sidebar-footer { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 10px 0; + margin-top: 10px; + border-top: 1px solid var(--ol-sidebar-footer-border); + } + .ol-aura-sidebar-version { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 10px 12px; + font-family: var(--ol-font-sans); + font-size: 11px; + color: var(--ol-ink-4); + background: var(--ol-sidebar-version-bg); + border: 1px solid var(--ol-sidebar-version-border); + border-radius: var(--ol-pill-radius); + box-shadow: none; + } + .ol-aura-beta-tag { + display: inline-block; + padding: 2px 8px; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--ol-blue); + background: rgba(37,99,235,0.10); + border-radius: 999px; + } + .ol-aura-sidebar-settings { + padding: 10px 12px; + border-radius: 12px; + border: 1px solid var(--ol-sidebar-settings-border); + background: var(--ol-sidebar-settings-bg); + box-shadow: none; + } + .ol-aura-sidebar-settings.ol-nav-btn-active { + background: var(--ol-sidebar-settings-active-bg); + box-shadow: none; + } + .ol-aura-console-main { + border-radius: ${mobileLayout ? '0' : 'var(--ol-panel-radius)'}; + } + .ol-aura-mobile-topbar { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: calc(10px + env(safe-area-inset-top, 0px)) 14px 10px; + border-bottom: 1px solid var(--ol-sidebar-border); + background: var(--ol-sidebar-bg); + } + .ol-aura-mobile-brand { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + } + .ol-aura-mobile-brand-mark { + width: 30px; + height: 30px; + border-radius: 8px; + flex-shrink: 0; + box-sizing: border-box; + padding: 3px; + object-fit: contain; + } + .ol-aura-mobile-brand-title { + font-size: 14px; + font-weight: 700; + color: var(--ol-ink); + line-height: 1.15; + } + .ol-aura-mobile-brand-section { + margin-top: 2px; + font-size: 11px; + color: var(--ol-ink-4); + font-family: var(--ol-font-mono); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .ol-aura-mobile-settings { + width: 36px; + height: 36px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + color: var(--ol-ink-3); + background: var(--ol-sidebar-settings-bg); + border: 1px solid var(--ol-sidebar-settings-border); + } + .ol-aura-mobile-settings-active { + color: var(--ol-ink); + background: var(--ol-sidebar-settings-active-bg); + } + .ol-aura-mobile-nav { + flex-shrink: 0; + display: grid; + grid-template-columns: repeat(${NAV.length}, minmax(0, 1fr)); + gap: 2px; + padding: 7px 8px calc(7px + env(safe-area-inset-bottom, 0px)); + border-top: 1px solid var(--ol-sidebar-border); + background: var(--ol-sidebar-bg); + } + .ol-aura-mobile-nav-btn { + min-width: 0; + height: 50px; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + border-radius: 12px; + color: var(--ol-ink-4); + font-size: 10px; + font-weight: 600; + line-height: 1.1; + } + .ol-aura-mobile-nav-btn span { + max-width: 100%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + .ol-aura-mobile-nav-btn-active { + color: var(--ol-ink); + background: var(--ol-sidebar-pill-bg); + border: 1px solid var(--ol-sidebar-pill-border); + } .ol-nav-btn.ol-nav-btn-active { color: var(--ol-ink); font-weight: 600; } .ol-nav-btn:not(.ol-nav-btn-active):hover { - background: rgba(0,0,0,0.04); + background: var(--ol-nav-hover-bg); color: var(--ol-ink); } @keyframes ol-page-slide { @@ -457,7 +680,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; alignItems: 'center', justifyContent: 'center', padding: 28, - background: 'rgba(15,17,22,0.28)', + background: 'var(--ol-overlay-bg)', backdropFilter: 'blur(6px) saturate(140%)', WebkitBackdropFilter: 'blur(6px) saturate(140%)', animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)', @@ -522,7 +745,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void; borderRadius: 8, border: 0, background: 'var(--ol-ink)', - color: '#fff', + color: 'var(--ol-on-accent)', fontFamily: 'inherit', fontSize: 12.5, fontWeight: 500, @@ -550,7 +773,7 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () => alignItems: 'center', justifyContent: 'center', padding: 28, - background: 'rgba(15,17,22,0.28)', + background: 'var(--ol-overlay-bg)', backdropFilter: 'blur(6px) saturate(140%)', WebkitBackdropFilter: 'blur(6px) saturate(140%)', animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)', @@ -615,7 +838,7 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () => borderRadius: 8, border: 0, background: 'var(--ol-ink)', - color: '#fff', + color: 'var(--ol-on-accent)', fontFamily: 'inherit', fontSize: 12.5, fontWeight: 500, diff --git a/openless-all/app/src/components/Onboarding.tsx b/openless-all/app/src/components/Onboarding.tsx index 2d23ed21..bb518005 100644 --- a/openless-all/app/src/components/Onboarding.tsx +++ b/openless-all/app/src/components/Onboarding.tsx @@ -1,26 +1,280 @@ -// Onboarding.tsx — 首次运行权限引导。 -// -// 触发条件:App.tsx 启动检查 accessibility + microphone,任一未授权则渲染本组件而非主 Shell。 -// 与 Swift `Sources/OpenLessApp/Onboarding/` 同语义,但简化为单页三步。 +// Onboarding.tsx — first-run permission and service setup. -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; +import { AndroidPermissionsPanel } from '@android/components/AndroidPermissionsPanel'; +import { checkAndroidMicrophoneAccess, requestAndroidMicrophoneAccess } from '@android/lib/androidMicrophonePermission'; import { checkAccessibilityPermission, checkMicrophonePermission, + getPlatformCapabilities, openSystemSettings, requestAccessibilityPermission, requestMicrophonePermission, } from '../lib/ipc'; import { getHotkeyTriggerLabel } from '../lib/hotkey'; -import type { PermissionStatus } from '../lib/types'; +import type { PermissionStatus, PlatformCapabilities } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; +import { ProvidersSection } from '../pages/settings/ProvidersSection'; interface OnboardingProps { onComplete: () => void; } +type AndroidStepId = + | 'microphone' + | 'accessibility' + | 'overlayPermission' + | 'overlayConfig' + | 'asr' + | 'llm'; + export function Onboarding({ onComplete }: OnboardingProps) { + const { t } = useTranslation(); + const [platformCaps, setPlatformCaps] = useState(null); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + + if (!platformCaps) { + return ; + } + + if (platformCaps.platform === 'android') { + return ; + } + + return ; +} + +function AndroidOnboarding({ onComplete }: OnboardingProps) { + const { t } = useTranslation(); + const [stepIndex, setStepIndex] = useState(0); + + const steps = useMemo>( + () => [ + { + id: 'microphone', + title: t('onboarding.androidSteps.microphoneTitle'), + desc: t('onboarding.androidSteps.microphoneDesc'), + }, + { + id: 'accessibility', + title: t('onboarding.androidSteps.accessibilityTitle'), + desc: t('onboarding.androidSteps.accessibilityDesc'), + }, + { + id: 'overlayPermission', + title: t('onboarding.androidSteps.overlayPermissionTitle'), + desc: t('onboarding.androidSteps.overlayPermissionDesc'), + }, + { + id: 'overlayConfig', + title: t('onboarding.androidSteps.overlayConfigTitle'), + desc: t('onboarding.androidSteps.overlayConfigDesc'), + }, + { + id: 'asr', + title: t('onboarding.androidSteps.asrTitle'), + desc: t('onboarding.androidSteps.asrDesc'), + }, + { + id: 'llm', + title: t('onboarding.androidSteps.llmTitle'), + desc: t('onboarding.androidSteps.llmDesc'), + }, + ], + [t], + ); + + const current = steps[stepIndex] ?? steps[0]; + const isFirst = stepIndex === 0; + const isLast = stepIndex === steps.length - 1; + + const goNext = () => { + if (isLast) { + onComplete(); + return; + } + setStepIndex((value) => Math.min(value + 1, steps.length - 1)); + }; + + return ( + +
+ + +
+ {steps.map((step, index) => ( +
+ ))} +
+ +
+
+
+ {t('onboarding.androidStepCounter', { current: stepIndex + 1, total: steps.length })} +
+
{current.title}
+
+ {current.desc} +
+
+ + +
+ +
+ + +
+ + +
+ + ); +} + +function AndroidStepContent({ step }: { step: AndroidStepId }) { + if (step === 'microphone') { + return ; + } + if (step === 'accessibility') { + return ; + } + if (step === 'overlayPermission') { + return ; + } + if (step === 'overlayConfig') { + return ; + } + if (step === 'asr') { + return ; + } + return ; +} + +function AndroidMicrophoneStep() { + const { t } = useTranslation(); + const [status, setStatus] = useState('notDetermined'); + const [busy, setBusy] = useState(false); + + const refresh = async () => { + setStatus(await checkAndroidMicrophoneAccess()); + }; + + useEffect(() => { + void refresh(); + const id = window.setInterval(refresh, 3000); + const onFocus = () => { void refresh(); }; + window.addEventListener('focus', onFocus); + return () => { + window.clearInterval(id); + window.removeEventListener('focus', onFocus); + }; + }, []); + + const request = async () => { + setBusy(true); + try { + if (status === 'denied' || status === 'restricted') { + await openSystemSettings('microphone'); + } else { + setStatus(await requestAndroidMicrophoneAccess()); + } + await refresh(); + } finally { + setBusy(false); + } + }; + + const granted = status === 'granted' || status === 'notApplicable'; + return ( + +
+
+
{t('onboarding.micTitle')}
+
+ {t('onboarding.micDesc')} +
+
+ +
+ +
+ ); +} + +function DesktopOnboarding({ + onComplete, + platformCaps: _platformCaps, +}: OnboardingProps & { platformCaps: PlatformCapabilities }) { const { t } = useTranslation(); const [accessibility, setAccessibility] = useState('notDetermined'); const [microphone, setMicrophone] = useState('notDetermined'); @@ -28,6 +282,8 @@ export function Onboarding({ onComplete }: OnboardingProps) { const refreshTimeoutRef = useRef(null); const { capability } = useHotkeySettings(); + const requiresAccessibility = !!capability?.requiresAccessibilityPermission; + const refresh = async () => { const [a, m] = await Promise.all([ checkAccessibilityPermission(), @@ -35,7 +291,9 @@ export function Onboarding({ onComplete }: OnboardingProps) { ]); setAccessibility(a); setMicrophone(m); - if ((a === 'granted' || a === 'notApplicable') && (m === 'granted' || m === 'notApplicable')) { + const aOk = !requiresAccessibility || a === 'granted' || a === 'notApplicable'; + const mOk = m === 'granted' || m === 'notApplicable'; + if (aOk && mOk) { onComplete(); } }; @@ -43,7 +301,6 @@ export function Onboarding({ onComplete }: OnboardingProps) { useEffect(() => { refresh(); const id = window.setInterval(refresh, 1000); - // 用户从系统设置切回来时立刻刷新 const onFocus = () => refresh(); window.addEventListener('focus', onFocus); return () => { @@ -51,7 +308,7 @@ export function Onboarding({ onComplete }: OnboardingProps) { window.removeEventListener('focus', onFocus); if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current); }; - }, []); + }, [requiresAccessibility]); const onGrantAccessibility = async () => { setBusy(true); @@ -83,74 +340,45 @@ export function Onboarding({ onComplete }: OnboardingProps) { }; return ( -
+
-
-
- OL -
-
-
{t('onboarding.welcome')}
-
- {t('onboarding.intro')} -
-
-
+ - + {requiresAccessibility && ( + + )} -
+
{t('onboarding.footerHint')}
+ + ); +} + +function OnboardingLoading({ label }: { label: string }) { + return ( +
+ {label}
); } +function OnboardingSurface({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function BrandHeader({ title, desc, compact = false }: { title: string; desc: string; compact?: boolean }) { + return ( +
+ OpenLess +
+
{title}
+
+ {desc} +
+
+
+ ); +} + +function AndroidStepCard({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function StatusBadge({ granted, label }: { granted: boolean; label: string }) { + return ( + + {label} + + ); +} + interface StepProps { index: number; title: string; @@ -255,3 +571,54 @@ function PermissionStep({ index, title, desc, status, actionLabel, onAction, dis
); } + +const primaryButtonStyle = { + flex: 1, + minHeight: 42, + padding: '10px 14px', + fontSize: 13, + fontWeight: 600, + fontFamily: 'inherit', + border: 0, + borderRadius: 10, + background: 'var(--ol-ink)', + color: '#fff', + cursor: 'default', +} as const; + +const secondaryButtonStyle = { + flex: 1, + minHeight: 42, + padding: '10px 14px', + fontSize: 13, + fontWeight: 600, + fontFamily: 'inherit', + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 10, + background: 'var(--ol-surface)', + color: 'var(--ol-ink-2)', + cursor: 'default', +} as const; + +const plainButtonStyle = { + width: '100%', + padding: '10px 14px', + fontSize: 12.5, + fontWeight: 500, + fontFamily: 'inherit', + border: 0, + borderRadius: 8, + background: 'transparent', + color: 'var(--ol-ink-4)', + cursor: 'default', +} as const; + +const footerHintStyle = { + marginTop: 18, + padding: '12px 14px', + borderRadius: 8, + background: 'var(--ol-surface-2)', + fontSize: 11.5, + color: 'var(--ol-ink-3)', + lineHeight: 1.6, +} as const; diff --git a/openless-all/app/src/components/SettingsModal.tsx b/openless-all/app/src/components/SettingsModal.tsx index 7cd7917e..f55a682f 100644 --- a/openless-all/app/src/components/SettingsModal.tsx +++ b/openless-all/app/src/components/SettingsModal.tsx @@ -2,21 +2,20 @@ // // 重构(2026-05):原本是「外层弹窗侧栏 + 设置页内层侧栏」双层嵌套,用户点 // 「设置」还要再面对第二个侧栏。现在拍平成单层 —— 通用 / 服务 / 隐私 / 高级 / -// 个性化 / 关于 六个 tab + 帮助外链组。每个 tab 的内容见 pages/settings/。 +// 个性化 / 关于 六个 tab。每个 tab 的内容见 pages/settings/。 // // 设计原则:每个可见控件都必须可用。没有后端支撑的占位(账号 / 主题切换 等) // 不在此弹窗出现。 import { useLayoutEffect, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Icon } from './Icon'; import { SavedToast } from './SavedToast'; import { useSavedToastListener } from '../lib/savedEvent'; -import { openExternal } from '../lib/ipc'; import type { OS } from './WindowChrome'; import { GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs'; import { AboutSection } from '../pages/settings/AboutSection'; +import { useMobileLayout } from '../lib/useMobileLayout'; // 稳定 tab ID(与 i18n key `modal.sections.*` 一致)。 export type SettingsSectionId = @@ -35,14 +34,8 @@ interface SettingsModalProps { interface ModalNavItem { id: string; icon: string; - external?: boolean; - href?: string; } -const HELP_URL = 'https://github.com/appergb/openless#readme'; -const RELEASE_NOTES_URL = 'https://github.com/appergb/openless/releases'; - -// 第一组:可选中的 tab;第二组:外部链接(永远不 active)。 const TAB_ITEMS: ModalNavItem[] = [ { id: 'general', icon: 'settings' }, { id: 'services', icon: 'cloud' }, @@ -50,71 +43,82 @@ const TAB_ITEMS: ModalNavItem[] = [ { id: 'advanced', icon: 'bolt' }, { id: 'about', icon: 'info' }, ]; -const LINK_ITEMS: ModalNavItem[] = [ - { id: 'helpCenter', icon: 'help', external: true, href: HELP_URL }, - { id: 'releaseNotes', icon: 'doc', external: true, href: RELEASE_NOTES_URL }, -]; export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) { const { t } = useTranslation(); const [section, setSection] = useState(initialSettingsSection ?? 'general'); const savedToast = useSavedToastListener(); + const mobile = useMobileLayout(); // 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill。 const tabRefs = useRef>([]); const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null); useLayoutEffect(() => { + if (mobile) { + setPillRect(null); + return; + } const idx = TAB_ITEMS.findIndex(it => it.id === section); const el = tabRefs.current[idx]; if (!el) return; setPillRect({ top: el.offsetTop, height: el.offsetHeight }); - }, [section]); + }, [section, mobile]); - // issue #580:用 Portal 渲染到 document.body,脱离页面 overflow:hidden 容器的 - // stacking context。否则 WebKitGTK(Debian/KDE Wayland)下页面自绘滚动条 - // (.ol-thinscroll) 不创建独立合成层,z-index 无法隔离,滚动时会盖在弹窗之上。 - // 配合 position:fixed 覆盖整窗。 - return createPortal( + return (
e.stopPropagation()} style={{ - width: '100%', maxWidth: 880, height: '100%', maxHeight: 600, - background: 'var(--ol-surface)', - borderRadius: 14, - border: '0.5px solid rgba(0,0,0,.08)', - boxShadow: '0 30px 80px -20px rgba(15,17,22,.35), 0 0 0 0.5px rgba(0,0,0,.06)', - display: 'flex', overflow: 'hidden', + width: '100%', + maxWidth: 920, + height: '100%', + maxHeight: mobile ? 'none' : 620, + display: 'flex', + flexDirection: mobile ? 'column' : 'row', + overflow: 'hidden', animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)', position: 'relative', }}> {/* ─── 单层侧栏 ────────────────────────────────────────────── */} {/* ─── 内容区 ────────────────────────────────────────────── 父容器 overflow:hidden + 列向 flex;关闭按钮、section 标题固定在头部, 只有最里层的 scroll wrapper 真正滚动。 */} -
+
{/* "已保存" toast:right:54 避开 28×28 关闭按钮 + 12px gap。 */} -

+

{t(`modal.sections.${section}`)}

+ style={{ + flex: 1, + minHeight: 0, + overflow: 'auto', + padding: mobile + ? '8px 14px calc(18px + env(safe-area-inset-bottom, 0px))' + : '10px 28px 28px', + }}> {/* key=section 让切 tab 时整块重挂载,ol-tab-fade 轻微淡入。 */}
-
, - document.body, + +
); } const navBtnStyle = { display: 'flex', alignItems: 'center', gap: 10, - padding: '7px 10px', - borderRadius: 8, border: 0, - background: 'transparent', - fontFamily: 'inherit', fontSize: 13, - cursor: 'default', textAlign: 'left' as const, - position: 'relative' as const, - zIndex: 1, - transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)', }; diff --git a/openless-all/app/src/components/WindowChrome.tsx b/openless-all/app/src/components/WindowChrome.tsx index 2e74b212..4e75e03a 100644 --- a/openless-all/app/src/components/WindowChrome.tsx +++ b/openless-all/app/src/components/WindowChrome.tsx @@ -1,6 +1,6 @@ import { type CSSProperties, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'; -export type OS = 'mac' | 'win' | 'linux'; +export type OS = 'mac' | 'win' | 'linux' | 'android'; export function detectOS(): OS { if (typeof navigator === 'undefined') return 'mac'; @@ -9,6 +9,7 @@ export function detectOS(): OS { ).userAgentData?.platform ?? ''; const hints = `${navigator.userAgent || ''} ${navigator.platform || ''} ${uaDataPlatform}`; if (/Mac|iPhone|iPad|iPod/.test(hints)) return 'mac'; + if (/Android/i.test(hints)) return 'android'; if (/Windows|Win32|Win64/.test(hints)) return 'win'; if (/Linux|X11|Wayland/.test(hints)) return 'linux'; return 'mac'; @@ -33,8 +34,8 @@ export function WindowChrome({ }: WindowChromeProps) { // Windows: decorations:true 时外层不画圆角/边框/阴影/标题栏,避免与原生窗口重叠。 // Linux: decorations:false 时外层画 14px 圆角 + 自定义标题栏。 - const shellRadius = os === 'mac' ? 0 : os === 'win' ? 0 : 14; - const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 14; + const shellRadius = os === 'mac' ? 0 : os === 'win' || os === 'android' ? 0 : 14; + const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : os === 'android' ? 0 : 14; const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : os === 'linux' ? LINUX_TITLEBAR_HEIGHT : 0; // macOS / Windows 共用半透明玻璃 background + backdropFilter。 diff --git a/openless-all/app/src/components/ui/Row.tsx b/openless-all/app/src/components/ui/Row.tsx index a2723017..867693ac 100644 --- a/openless-all/app/src/components/ui/Row.tsx +++ b/openless-all/app/src/components/ui/Row.tsx @@ -1,6 +1,7 @@ // Row — two-column row used in the Settings modal sub-sections. import type { ReactNode } from 'react'; +import { useMobileLayout } from '../../lib/useMobileLayout'; interface RowProps { label: string; @@ -9,13 +10,14 @@ interface RowProps { } export function Row({ label, desc, children }: RowProps) { + const mobile = useMobileLayout(); return ( -
-
+
+
{label}
{desc &&
{desc}
}
-
{children}
+
{children}
); } diff --git a/openless-all/app/src/components/ui/SelectLite.tsx b/openless-all/app/src/components/ui/SelectLite.tsx index 4822e50a..ec3ebebe 100644 --- a/openless-all/app/src/components/ui/SelectLite.tsx +++ b/openless-all/app/src/components/ui/SelectLite.tsx @@ -29,6 +29,7 @@ import { } from 'react'; import { createPortal } from 'react-dom'; import { Icon } from '../Icon'; +import { useMobileLayout } from '../../lib/useMobileLayout'; export interface SelectOption { value: string; @@ -81,6 +82,7 @@ export function SelectLite({ ariaLabel, onOpenChange, }: SelectLiteProps) { + const mobile = useMobileLayout(); const [open, setOpen] = useState(false); // leaving 让 popover 在卸载前播完 exit keyframe(用户报"没有收缩动画"——之前直接 unmount) const [leaving, setLeaving] = useState(false); @@ -264,6 +266,14 @@ export function SelectLite({ const triggerStyle: CSSProperties = { ...DEFAULT_TRIGGER_STYLE, ...style, + boxSizing: 'border-box', + ...(mobile + ? { + width: style?.width ?? '100%', + minWidth: 0, + maxWidth: '100%', + } + : null), opacity: disabled ? 0.5 : 1, cursor: disabled ? 'not-allowed' : 'default', }; diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 2a8f3cce..fbbc2fc5 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -49,6 +49,11 @@ export const en: typeof zhCN = { emptyTitle: 'Press {{recordHotkey}} to ask', emptyDesc: 'Select text in any app, press {{recordHotkey}} once to start recording, press it again to submit. Answers appear here. You can ask follow-up questions in the same panel.', recordingHint: 'Recording… press {{recordHotkey}} again to submit', + mobileRecordLabel: 'record button', + mobileRecordStart: 'Start recording', + mobileRecordStop: 'Stop and submit', + composerPlaceholder: 'Type a question. Enter to send, Shift+Enter for a new line', + composerSend: 'Send', statusIdle: 'Press {{recordHotkey}} to ask', statusRecording: 'Recording', statusThinking: 'Thinking', @@ -223,6 +228,28 @@ export const en: typeof zhCN = { actionRequestMic: 'Request access', accessibilityHint: 'After granting, you must **fully quit OpenLess** and reopen it (a macOS TCC requirement).', footerHint: 'This onboarding closes automatically once both permissions are granted. If it persists, quit OpenLess from the menu bar and relaunch.', + androidContinue: 'Continue to app', + androidFooterHint: 'Microphone access is required for dictation. Tap Request access above, or continue and grant it later from Overview.', + androidTitle: 'Set up OpenLess', + androidIntro: 'Complete mobile permissions and services step by step.', + androidStepCounter: 'Step {{current}} of {{total}}', + androidBack: 'Back', + androidNext: 'Next', + androidFinish: 'Finish and enter', + androidSteps: { + microphoneTitle: 'Microphone permission', + microphoneDesc: 'Show the Android system permission sheet and allow OpenLess to record voice.', + accessibilityTitle: 'Accessibility service', + accessibilityDesc: 'Paste recognition results back into the active input field and help detect the input context.', + overlayPermissionTitle: 'Floating window permission', + overlayPermissionDesc: 'Allow OpenLess to show the recording control over other apps.', + overlayConfigTitle: 'Floating window settings', + overlayConfigDesc: 'Configure visibility, activation, swipe actions, and button size.', + asrTitle: 'ASR cloud service', + asrDesc: 'Configure the speech-to-text provider, key, endpoint, and model.', + llmTitle: 'LLM service', + llmDesc: 'Configure the language model used for polishing, translation, and Q&A.', + }, }, overview: { kicker: 'DASHBOARD', @@ -258,6 +285,20 @@ export const en: typeof zhCN = { recentLoadFailed: 'Could not load recent transcripts. Please retry.', historyRetry: 'Retry', weekDays: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'], + inAppDictation: { + title: 'In-app dictation', + start: 'Start recording', + stop: 'Stop recording', + idle: 'Tap to start recording', + recording: 'Recording…', + processing: 'Processing…', + }, + androidMicBanner: { + title: 'Microphone permission needed', + desc: 'Grant microphone access to use in-app dictation and voice input.', + grant: 'Request access', + openSettings: 'Open settings', + }, }, history: { kicker: 'HISTORY', @@ -775,6 +816,7 @@ export const en: typeof zhCN = { disable: 'Disable', confirmHint: 'Click ✓ on the capsule', notSupported: 'Not yet supported', + androidReadOnly: 'Global shortcuts are not available on Android. Use the record button on the overview page.', }, permissions: { title: 'Permissions', @@ -798,6 +840,7 @@ export const en: typeof zhCN = { indeterminate: 'Undetermined', openSystem: 'Open System Settings', grant: 'Grant', + rerunAndroidSetup: 'Run setup again', hotkeyInstalled: 'Installed', hotkeyStarting: 'Installing…', hotkeyFailed: 'Listener failed', @@ -805,6 +848,65 @@ export const en: typeof zhCN = { windowsImeDesc: 'Temporarily switches to the OpenLess TSF IME during voice sessions to avoid clipboard insertion limits.', windowsImeInstalled: 'Installed', windowsImeUnavailable: 'Unavailable', + androidImeLabel: 'Input method (IME)', + androidImeSelected: 'Selected', + androidImeEnabled: 'Enabled', + androidImeDisabled: 'Not enabled', + androidOverlayLabel: 'Floating overlay', + androidAccessibilityLabel: 'Accessibility service', + androidAccessibilityImpact: 'Enable it to output results to the current input field without switching keyboards. If disabled, results are copied to the clipboard for manual paste.', + androidInsertStrategyLabel: 'Text insertion strategy', + androidOverlayTriggerLabel: 'Overlay visibility', + androidOverlayActivationModeLabel: 'Overlay activation', + androidOverlayLeftSwipeActionLabel: 'Left swipe action', + androidOverlayCancelSwipeDirectionLabel: 'Cancel swipe direction', + androidOverlaySizeLabel: 'Overlay size', + androidOverlaySizeHint: 'Adjusts the floating button diameter and keeps its current position.', + androidInsertStrategy: { + accessibility: 'Auto output to input field', + clipboard: 'Clipboard only', + }, + androidInsertStrategyHint: { + accessibility: 'Requires accessibility; falls back to clipboard when unavailable.', + clipboard: 'No accessibility permission required; copies only for manual paste.', + }, + androidOverlayTrigger: { + background: 'When app is backgrounded', + keyboard: 'When keyboard appears', + always: 'Always visible', + }, + androidOverlayTriggerHint: { + background: 'Simple and battery-friendly; no overlay while typing in other apps.', + keyboard: 'This mode is shelved. Existing settings are moved back to background.', + always: 'Always available, but permanently on screen.', + }, + androidOverlayTriggerDisabled: { + keyboard: 'Keyboard-triggered display is shelved. Overlay gestures will replace keyboard detection.', + }, + androidOverlayActivationMode: { + tap: 'Tap to arm', + long_press: 'Long press to arm', + }, + androidOverlayActivationModeHint: { + tap: 'First tap arms the overlay; second tap starts normal dictation.', + long_press: 'Hold to arm the overlay; release stops the current recording or QA turn.', + }, + androidOverlayLeftSwipeAction: { + translation: 'Translation dictation', + style_pack: 'Switch style pack', + }, + androidOverlayLeftSwipeActionHint: { + translation: 'Left swipe while armed starts translation dictation.', + style_pack: 'Left swipe while armed switches to the previous style pack.', + }, + androidOverlayCancelSwipeDirection: { + up: 'Swipe up', + down: 'Swipe down', + }, + androidOverlayCancelSwipeDirectionHint: { + up: 'Swipe up while recording to cancel without transcription or insertion.', + down: 'Swipe down while recording to cancel without transcription or insertion.', + }, windowsIme: { installed: 'Installed. Voice input temporarily switches to the OpenLess IME.', notInstalled: 'Not installed. OpenLess is using the clipboard/WM_PASTE fallback.', @@ -1002,6 +1104,7 @@ export const en: typeof zhCN = { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows low-level keyboard hook', fcitx5: 'fcitx5 input method plugin', + unavailable: 'Unavailable', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 46bd7e03..5942e5a6 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -51,6 +51,11 @@ export const ja: typeof zhCN = { emptyTitle: '{{recordHotkey}} を押して質問を開始', emptyDesc: '任意のアプリでテキストを選択した後、{{recordHotkey}} を 1 回押して録音を開始し、もう 1 回押して送信します。回答はここに表示され、続けて追加質問が可能です。', recordingHint: '録音中… {{recordHotkey}} をもう一度押して終了し、質問します', + mobileRecordLabel: '録音ボタン', + mobileRecordStart: '録音を開始', + mobileRecordStop: '終了して送信', + composerPlaceholder: '質問を入力。Enter で送信、Shift+Enter で改行', + composerSend: '送信', statusIdle: '{{recordHotkey}} で質問', statusRecording: '録音中', statusThinking: '思考中', @@ -225,6 +230,28 @@ export const ja: typeof zhCN = { actionRequestMic: '許可ダイアログを表示', accessibilityHint: '許可後は **OpenLess を完全に終了** してから再起動してください(macOS TCC の仕様)。', footerHint: 'すべての権限が揃うとこのガイドは自動で閉じます。閉じない場合はメニューバーの OpenLess → 終了 から再起動してください。', + androidContinue: 'アプリに進む', + androidFooterHint: '音声入力にはマイク権限が必要です。上の「許可ダイアログを表示」をタップするか、先にアプリへ進み、概要ページで後から許可してください。', + androidTitle: 'OpenLess を設定', + androidIntro: 'モバイル権限とサービス設定を順番に完了します。', + androidStepCounter: '{{current}} / {{total}}', + androidBack: '戻る', + androidNext: '次へ', + androidFinish: '完了して開始', + androidSteps: { + microphoneTitle: 'マイク権限', + microphoneDesc: 'Android のシステム権限カードを表示し、OpenLess の録音を許可します。', + accessibilityTitle: 'アクセシビリティサービス', + accessibilityDesc: '認識結果を現在の入力欄へ貼り付け、入力環境の検出を補助します。', + overlayPermissionTitle: 'フローティングウィンドウ権限', + overlayPermissionDesc: '他のアプリ上に録音コントロールを表示できるようにします。', + overlayConfigTitle: 'フローティングウィンドウ設定', + overlayConfigDesc: '表示タイミング、起動方法、スワイプ操作、ボタンサイズを設定します。', + asrTitle: 'ASR クラウドサービス', + asrDesc: '音声認識サービスのプロバイダー、キー、エンドポイント、モデルを設定します。', + llmTitle: 'LLM サービス', + llmDesc: '整文、翻訳、Q&A に使う言語モデルサービスを設定します。', + }, }, overview: { kicker: 'DASHBOARD', @@ -260,6 +287,20 @@ export const ja: typeof zhCN = { recentLoadFailed: '最近の認識を読み込めません。再試行してください。', historyRetry: '再試行', weekDays: ['日', '月', '火', '水', '木', '金', '土'], + inAppDictation: { + title: 'アプリ内音声入力', + start: '録音開始', + stop: '録音停止', + idle: 'タップして録音開始', + recording: '録音中…', + processing: '処理中…', + }, + androidMicBanner: { + title: 'マイク権限が必要です', + desc: 'マイクを許可すると、アプリ内音声入力が使えます。', + grant: '許可ダイアログを表示', + openSettings: '設定を開く', + }, }, history: { kicker: 'HISTORY', @@ -777,6 +818,7 @@ export const ja: typeof zhCN = { disable: '無効化', confirmHint: '右側の ✓ をクリック', notSupported: '未対応', + androidReadOnly: 'Android ではグローバルショートカットは使えません。概要ページの録音ボタンを使ってください。', }, permissions: { title: '権限', @@ -800,6 +842,7 @@ export const ja: typeof zhCN = { indeterminate: '未確定', openSystem: 'システム設定を開く', grant: '許可する', + rerunAndroidSetup: 'セットアップを再実行', hotkeyInstalled: 'インストール済み', hotkeyStarting: 'インストール中…', hotkeyFailed: '監視失敗', @@ -807,6 +850,31 @@ export const ja: typeof zhCN = { windowsImeDesc: '音声セッション中に OpenLess TSF IME へ一時的に切り替え、クリップボード入力の制限を回避します。', windowsImeInstalled: 'インストール済み', windowsImeUnavailable: '利用不可', + androidImeLabel: '入力メソッド (IME)', + androidImeSelected: '選択中', + androidImeEnabled: '有効', + androidImeDisabled: '無効', + androidOverlayLabel: 'フローティングオーバーレイ', + androidAccessibilityLabel: 'アクセシビリティ', + androidAccessibilityImpact: '有効にすると、キーボードを切り替えずに現在の入力欄へ結果を出力します。無効の場合はクリップボードへコピーし、手動で貼り付けます。', + androidInsertStrategyLabel: '挿入方式', + androidOverlayTriggerLabel: '表示タイミング', + androidOverlayActivationModeLabel: '起動方法', + androidOverlayLeftSwipeActionLabel: '左スワイプ動作', + androidOverlayCancelSwipeDirectionLabel: 'キャンセル方向', + androidOverlaySizeLabel: 'オーバーレイサイズ', + androidOverlaySizeHint: 'フローティングボタンの直径を調整し、現在位置を保持します。', + androidInsertStrategy: { accessibility: '入力欄へ自動出力', clipboard: 'クリップボードのみ' }, + androidInsertStrategyHint: { accessibility: 'アクセシビリティが必要です。使えない場合はクリップボードにコピーします。', clipboard: 'アクセシビリティ権限は不要です。コピー後に手動で貼り付けます。' }, + androidOverlayTrigger: { background: 'バックグラウンド', keyboard: 'キーボード表示時', always: '常時' }, + androidOverlayTriggerHint: { background: 'シンプル', keyboard: 'このモードは保留中です。既存設定はバックグラウンドに戻します。', always: '常に表示' }, + androidOverlayTriggerDisabled: { keyboard: 'キーボード表示時の表示は保留中です。今後はフローティングウィンドウのジェスチャーで置き換えます。' }, + androidOverlayActivationMode: { tap: 'タップで起動', long_press: '長押しで起動' }, + androidOverlayActivationModeHint: { tap: '1回目のタップで待機状態に入り、2回目のタップで通常の音声入力を開始します。', long_press: '押している間だけ待機状態に入り、離すと現在の録音またはQAターンを終了します。' }, + androidOverlayLeftSwipeAction: { translation: '翻訳入力', style_pack: 'スタイルパック切替' }, + androidOverlayLeftSwipeActionHint: { translation: '待機状態で左スワイプすると翻訳入力を開始します。', style_pack: '待機状態で左スワイプすると前のスタイルパックへ切り替えます。' }, + androidOverlayCancelSwipeDirection: { up: '上へスワイプ', down: '下へスワイプ' }, + androidOverlayCancelSwipeDirectionHint: { up: '録音中に上へスワイプすると、文字起こしや挿入をせずにキャンセルします。', down: '録音中に下へスワイプすると、文字起こしや挿入をせずにキャンセルします。' }, windowsIme: { installed: 'インストール済み。音声入力時に OpenLess IME へ一時的に切り替えます。', notInstalled: '未インストール。OpenLess は現在クリップボード / WM_PASTE フォールバックを使用しています。', @@ -1004,6 +1072,7 @@ export const ja: typeof zhCN = { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低レベルキーボードフック', fcitx5: 'fcitx5 インプットメソッドプラグイン', + unavailable: '利用不可', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index ff86d915..07030b0c 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -51,6 +51,11 @@ export const ko: typeof zhCN = { emptyTitle: '{{recordHotkey}} 를 눌러 질문 시작', emptyDesc: '아무 앱에서 텍스트를 선택한 후 {{recordHotkey}} 를 한 번 눌러 녹음을 시작하고, 다시 한 번 눌러 종료 후 제출합니다. 답변이 여기에 표시되며 연속해서 후속 질문이 가능합니다.', recordingHint: '녹음 중… {{recordHotkey}} 를 다시 눌러 종료하고 질문', + mobileRecordLabel: '녹음 버튼', + mobileRecordStart: '녹음 시작', + mobileRecordStop: '종료하고 제출', + composerPlaceholder: '질문을 입력하세요. Enter로 보내고 Shift+Enter로 줄바꿈', + composerSend: '보내기', statusIdle: '{{recordHotkey}} 로 질문', statusRecording: '녹음 중', statusThinking: '생각 중', @@ -225,6 +230,28 @@ export const ko: typeof zhCN = { actionRequestMic: '권한 대화상자 표시', accessibilityHint: '허용 후에는 **OpenLess 를 완전히 종료** 한 다음 다시 실행해야 합니다(macOS TCC 규칙).', footerHint: '모든 권한이 부여되면 이 가이드는 자동으로 닫힙니다. 닫히지 않으면 메뉴 막대의 OpenLess → 종료 후 앱을 다시 실행해 주세요.', + androidContinue: '앱으로 계속', + androidFooterHint: '받아쓰기에는 마이크 권한이 필요합니다. 위의 권한 요청을 탭하거나, 앱으로 먼저 들어가 개요 페이지에서 나중에 허용할 수 있습니다.', + androidTitle: 'OpenLess 설정', + androidIntro: '모바일 권한과 서비스 설정을 단계별로 완료합니다.', + androidStepCounter: '{{current}} / {{total}} 단계', + androidBack: '이전', + androidNext: '다음', + androidFinish: '완료하고 시작', + androidSteps: { + microphoneTitle: '마이크 권한', + microphoneDesc: 'Android 시스템 권한 카드를 표시하고 OpenLess 녹음을 허용합니다.', + accessibilityTitle: '접근성 서비스', + accessibilityDesc: '인식 결과를 현재 입력란에 붙여넣고 입력 환경 감지를 보조합니다.', + overlayPermissionTitle: '플로팅 창 권한', + overlayPermissionDesc: '다른 앱 위에 녹음 제어 버튼을 표시할 수 있게 합니다.', + overlayConfigTitle: '플로팅 창 설정', + overlayConfigDesc: '표시 시점, 활성화 방식, 스와이프 동작, 버튼 크기를 설정합니다.', + asrTitle: 'ASR 클라우드 서비스', + asrDesc: '음성 인식 서비스의 공급자, 키, 엔드포인트, 모델을 설정합니다.', + llmTitle: 'LLM 서비스', + llmDesc: '문장 다듬기, 번역, Q&A에 사용할 언어 모델 서비스를 설정합니다.', + }, }, overview: { kicker: 'DASHBOARD', @@ -260,6 +287,20 @@ export const ko: typeof zhCN = { recentLoadFailed: '최근 인식 기록을 불러올 수 없습니다. 다시 시도해 주세요.', historyRetry: '다시 시도', weekDays: ['일', '월', '화', '수', '목', '금', '토'], + inAppDictation: { + title: '앱 내 받아쓰기', + start: '녹음 시작', + stop: '녹음 중지', + idle: '탭하여 녹음 시작', + recording: '녹음 중…', + processing: '처리 중…', + }, + androidMicBanner: { + title: '마이크 권한이 필요합니다', + desc: '마이크를 허용하면 앱 내 받아쓰기와 음성 입력을 사용할 수 있습니다.', + grant: '권한 요청', + openSettings: '설정 열기', + }, }, history: { kicker: 'HISTORY', @@ -777,6 +818,7 @@ export const ko: typeof zhCN = { disable: '비활성화', confirmHint: '오른쪽 ✓ 클릭', notSupported: '지원되지 않음', + androidReadOnly: 'Android에서는 전역 단축키를 사용할 수 없습니다. 개요 페이지의 녹음 버튼을 사용하세요.', }, permissions: { title: '권한', @@ -800,6 +842,7 @@ export const ko: typeof zhCN = { indeterminate: '미결정', openSystem: '시스템 설정 열기', grant: '허용', + rerunAndroidSetup: '설정 마법사 다시 실행', hotkeyInstalled: '설치됨', hotkeyStarting: '설치 중…', hotkeyFailed: '감지 실패', @@ -807,6 +850,31 @@ export const ko: typeof zhCN = { windowsImeDesc: '음성 세션 동안 OpenLess TSF 입력기로 일시적으로 전환하여 클립보드 입력 제한을 회피하기 위해 사용.', windowsImeInstalled: '설치됨', windowsImeUnavailable: '사용 불가', + androidImeLabel: '입력기 (IME)', + androidImeSelected: '선택됨', + androidImeEnabled: '활성화됨', + androidImeDisabled: '비활성', + androidOverlayLabel: '플로팅 오버레이', + androidAccessibilityLabel: '접근성 서비스', + androidAccessibilityImpact: '켜면 키보드를 전환하지 않고 현재 입력칸에 결과를 출력합니다. 끄면 클립보드에 복사되며 직접 붙여넣어야 합니다.', + androidInsertStrategyLabel: '텍스트 삽입 방식', + androidOverlayTriggerLabel: '오버레이 표시', + androidOverlayActivationModeLabel: '오버레이 활성화', + androidOverlayLeftSwipeActionLabel: '왼쪽 스와이프 동작', + androidOverlayCancelSwipeDirectionLabel: '취소 스와이프 방향', + androidOverlaySizeLabel: '오버레이 크기', + androidOverlaySizeHint: '플로팅 버튼 지름을 조정하고 현재 위치를 유지합니다.', + androidInsertStrategy: { accessibility: '입력칸에 자동 출력', clipboard: '클립보드만' }, + androidInsertStrategyHint: { accessibility: '접근성 서비스가 필요합니다. 사용할 수 없으면 클립보드에 복사합니다.', clipboard: '접근성 권한이 필요 없으며 직접 붙여넣습니다.' }, + androidOverlayTrigger: { background: '백그라운드', keyboard: '키보드 표시 시', always: '항상' }, + androidOverlayTriggerHint: { background: '단순', keyboard: '이 모드는 보류되었습니다. 기존 설정은 백그라운드로 되돌립니다.', always: '항상 표시' }, + androidOverlayTriggerDisabled: { keyboard: '키보드 표시 감지는 보류되었습니다. 이후 오버레이 제스처로 대체합니다.' }, + androidOverlayActivationMode: { tap: '탭으로 활성화', long_press: '길게 눌러 활성화' }, + androidOverlayActivationModeHint: { tap: '첫 탭은 대기 상태로 전환하고, 두 번째 탭은 일반 받아쓰기를 시작합니다.', long_press: '누르고 있는 동안 대기 상태가 되며, 손을 떼면 현재 녹음 또는 QA 턴을 종료합니다.' }, + androidOverlayLeftSwipeAction: { translation: '번역 받아쓰기', style_pack: '스타일 팩 전환' }, + androidOverlayLeftSwipeActionHint: { translation: '대기 상태에서 왼쪽으로 밀면 번역 받아쓰기를 시작합니다.', style_pack: '대기 상태에서 왼쪽으로 밀면 이전 스타일 팩으로 전환합니다.' }, + androidOverlayCancelSwipeDirection: { up: '위로 스와이프', down: '아래로 스와이프' }, + androidOverlayCancelSwipeDirectionHint: { up: '녹음 중 위로 밀면 전사와 삽입 없이 취소합니다.', down: '녹음 중 아래로 밀면 전사와 삽입 없이 취소합니다.' }, windowsIme: { installed: '설치됨. 음성 입력 시 OpenLess 입력기로 일시 전환됩니다.', notInstalled: '설치되지 않음. OpenLess 는 현재 클립보드 / WM_PASTE 폴백을 사용합니다.', @@ -1004,6 +1072,7 @@ export const ko: typeof zhCN = { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 저수준 키보드 후크', fcitx5: 'fcitx5 입력기 플러그인', + unavailable: '사용 불가', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index b0d9b60f..db8f3c3a 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -47,6 +47,11 @@ export const zhCN = { emptyTitle: '按 {{recordHotkey}} 开始提问', emptyDesc: '在任意 app 选中一段文字后,按一次 {{recordHotkey}} 开始录音,再按一次结束并提交。回答会显示在这里,可以连续多轮追问。', recordingHint: '录音中…再按一次 {{recordHotkey}} 结束并提问', + mobileRecordLabel: '录音按钮', + mobileRecordStart: '开始录音', + mobileRecordStop: '结束并提交', + composerPlaceholder: '输入问题,Enter 发送,Shift+Enter 换行', + composerSend: '发送', statusIdle: '按 {{recordHotkey}} 提问', statusRecording: '录音中', statusThinking: '思考中', @@ -221,6 +226,28 @@ export const zhCN = { actionRequestMic: '弹出授权', accessibilityHint: '授权后必须**完全退出 OpenLess** 再重新打开(macOS TCC 规则)。', footerHint: '授权全部完成后此引导自动关闭。如果一直不消失,从菜单栏 OpenLess → 退出,重新打开 App。', + androidContinue: '先进入应用', + androidFooterHint: '听写需要麦克风权限。可点击上方「弹出授权」,或先进入应用后在概览页继续授权。', + androidTitle: '配置 OpenLess', + androidIntro: '按步骤完成移动端权限和服务配置。', + androidStepCounter: '第 {{current}} / {{total}} 项', + androidBack: '上一步', + androidNext: '下一步', + androidFinish: '完成并进入', + androidSteps: { + microphoneTitle: '麦克风权限', + microphoneDesc: '调用 Android 系统授权卡片,允许 OpenLess 录制语音。', + accessibilityTitle: '无障碍服务', + accessibilityDesc: '用于把识别结果粘贴回当前输入框,并辅助检测输入环境。', + overlayPermissionTitle: '悬浮窗权限', + overlayPermissionDesc: '允许 OpenLess 在其他应用上显示录音控制按钮。', + overlayConfigTitle: '悬浮窗配置', + overlayConfigDesc: '设置悬浮窗显示时机、触发方式、滑动动作和按钮大小。', + asrTitle: 'ASR 云服务', + asrDesc: '配置语音转文字服务的供应商、密钥、接口地址和模型。', + llmTitle: 'LLM 服务', + llmDesc: '配置文本润色、翻译和问答使用的语言模型服务。', + }, }, overview: { kicker: 'DASHBOARD', @@ -256,6 +283,20 @@ export const zhCN = { recentLoadFailed: '无法读取最近识别,请重试。', historyRetry: '重试', weekDays: ['日', '一', '二', '三', '四', '五', '六'], + inAppDictation: { + title: '应用内录音', + start: '开始录音', + stop: '停止录音', + idle: '点击开始录音', + recording: '录音中…', + processing: '处理中…', + }, + androidMicBanner: { + title: '需要麦克风权限', + desc: '授权麦克风后可使用应用内录音与语音输入。', + grant: '弹出授权', + openSettings: '打开系统设置', + }, }, history: { kicker: 'HISTORY', @@ -773,6 +814,7 @@ export const zhCN = { disable: '停用', confirmHint: '点击右侧 ✓', notSupported: '暂未支持', + androidReadOnly: 'Android 不支持全局快捷键,请在概览页使用录音按钮。', }, permissions: { title: '权限', @@ -796,6 +838,7 @@ export const zhCN = { indeterminate: '未确定', openSystem: '打开系统设置', grant: '授权', + rerunAndroidSetup: '重新运行设置向导', hotkeyInstalled: '已安装', hotkeyStarting: '安装中…', hotkeyFailed: '监听失败', @@ -803,6 +846,65 @@ export const zhCN = { windowsImeDesc: '语音输入时临时切到 OpenLess TSF,绕过剪贴板限制。', windowsImeInstalled: '已安装', windowsImeUnavailable: '不可用', + androidImeLabel: '输入法 (IME)', + androidImeSelected: '已选中', + androidImeEnabled: '已启用', + androidImeDisabled: '未启用', + androidOverlayLabel: '悬浮窗', + androidAccessibilityLabel: '无障碍服务', + androidAccessibilityImpact: '开启后可在不切换键盘的情况下把结果输出到当前输入框;不开启时仍会复制到剪贴板,需要手动粘贴。', + androidInsertStrategyLabel: '文本插入策略', + androidOverlayTriggerLabel: '悬浮窗显示时机', + androidOverlayActivationModeLabel: '悬浮窗激活方式', + androidOverlayLeftSwipeActionLabel: '左滑动作', + androidOverlayCancelSwipeDirectionLabel: '取消录音滑向', + androidOverlaySizeLabel: '悬浮窗大小', + androidOverlaySizeHint: '调整悬浮按钮直径,保存后在当前悬浮窗上生效并保留位置。', + androidInsertStrategy: { + accessibility: '自动输出到输入框', + clipboard: '仅剪贴板', + }, + androidInsertStrategyHint: { + accessibility: '需要开启无障碍服务;不可用时会复制到剪贴板。', + clipboard: '不需要无障碍权限,结果只复制到剪贴板,由你手动粘贴。', + }, + androidOverlayTrigger: { + background: '应用退到后台', + keyboard: '弹出键盘时', + always: '始终显示', + }, + androidOverlayTriggerHint: { + background: '省电、实现简单;其他 App 输入时不会自动出现。', + keyboard: '该模式已暂缓,历史配置会自动改为“应用退到后台”。', + always: '入口始终可见,但会一直占屏。', + }, + androidOverlayTriggerDisabled: { + keyboard: '“弹出键盘时”暂缓开放,后续将以悬浮窗手势替代键盘检测。', + }, + androidOverlayActivationMode: { + tap: '点按激活', + long_press: '长按激活', + }, + androidOverlayActivationModeHint: { + tap: '第一次点按进入激活态,第二次点按开始普通听写。', + long_press: '按住进入激活态;松开时结束当前录音或问答轮次。', + }, + androidOverlayLeftSwipeAction: { + translation: '翻译听写', + style_pack: '切换风格包', + }, + androidOverlayLeftSwipeActionHint: { + translation: '激活态左滑后按翻译模式录音。', + style_pack: '激活态左滑后切换到上一个风格包。', + }, + androidOverlayCancelSwipeDirection: { + up: '向上滑', + down: '向下滑', + }, + androidOverlayCancelSwipeDirectionHint: { + up: '录音中向上滑取消本次听写,不转写、不插入。', + down: '录音中向下滑取消本次听写,不转写、不插入。', + }, windowsIme: { installed: '已安装,按需切到 OpenLess 输入法。', notInstalled: '未安装,走剪贴板 / WM_PASTE 兜底。', @@ -1000,6 +1102,7 @@ export const zhCN = { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低层键盘 hook', fcitx5: 'fcitx5 输入法插件', + unavailable: '不可用', }, }, localAsr: { diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index c61ed06e..523716fd 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -49,6 +49,11 @@ export const zhTW: typeof zhCN = { emptyTitle: '按 {{recordHotkey}} 開始提問', emptyDesc: '在任意 app 選中一段文字後,按一次 {{recordHotkey}} 開始錄音,再按一次結束並提交。回答會顯示在這裏,可以連續多輪追問。', recordingHint: '錄音中…再按一次 {{recordHotkey}} 結束並提問', + mobileRecordLabel: '錄音按鈕', + mobileRecordStart: '開始錄音', + mobileRecordStop: '結束並提交', + composerPlaceholder: '輸入問題,Enter 發送,Shift+Enter 換行', + composerSend: '發送', statusIdle: '按 {{recordHotkey}} 提問', statusRecording: '錄音中', statusThinking: '思考中', @@ -223,6 +228,28 @@ export const zhTW: typeof zhCN = { actionRequestMic: '彈出授權', accessibilityHint: '授權後必須**完全退出 OpenLess** 再重新打開(macOS TCC 規則)。', footerHint: '授權全部完成後此引導自動關閉。如果一直不消失,從菜單欄 OpenLess → 退出,重新打開 App。', + androidContinue: '先進入應用', + androidFooterHint: '聽寫需要麥克風權限。可點擊上方「彈出授權」,或先進入應用後在概覽頁繼續授權。', + androidTitle: '配置 OpenLess', + androidIntro: '按步驟完成移動端權限和服務配置。', + androidStepCounter: '第 {{current}} / {{total}} 項', + androidBack: '上一步', + androidNext: '下一步', + androidFinish: '完成並進入', + androidSteps: { + microphoneTitle: '麥克風權限', + microphoneDesc: '調用 Android 系統授權卡片,允許 OpenLess 錄製語音。', + accessibilityTitle: '無障礙服務', + accessibilityDesc: '用於把識別結果貼回當前輸入框,並輔助檢測輸入環境。', + overlayPermissionTitle: '懸浮窗權限', + overlayPermissionDesc: '允許 OpenLess 在其他應用上顯示錄音控制按鈕。', + overlayConfigTitle: '懸浮窗配置', + overlayConfigDesc: '設置懸浮窗顯示時機、觸發方式、滑動動作和按鈕大小。', + asrTitle: 'ASR 雲服務', + asrDesc: '配置語音轉文字服務的供應商、密鑰、接口地址和模型。', + llmTitle: 'LLM 服務', + llmDesc: '配置文本潤色、翻譯和問答使用的語言模型服務。', + }, }, overview: { kicker: 'DASHBOARD', @@ -258,6 +285,20 @@ export const zhTW: typeof zhCN = { recentLoadFailed: '無法讀取最近識別,請重試。', historyRetry: '重試', weekDays: ['日', '一', '二', '三', '四', '五', '六'], + inAppDictation: { + title: '應用內錄音', + start: '開始錄音', + stop: '停止錄音', + idle: '點擊開始錄音', + recording: '錄音中…', + processing: '處理中…', + }, + androidMicBanner: { + title: '需要麥克風權限', + desc: '授權麥克風後可使用應用內錄音與語音輸入。', + grant: '彈出授權', + openSettings: '打開系統設置', + }, }, history: { kicker: 'HISTORY', @@ -775,6 +816,7 @@ export const zhTW: typeof zhCN = { disable: '停用', confirmHint: '點擊右側 ✓', notSupported: '暫未支持', + androidReadOnly: 'Android 不支援全域快捷鍵,請在概覽頁使用錄音按鈕。', }, permissions: { title: '權限', @@ -798,6 +840,7 @@ export const zhTW: typeof zhCN = { indeterminate: '未確定', openSystem: '打開系統設置', grant: '授權', + rerunAndroidSetup: '重新執行設定向導', hotkeyInstalled: '已安裝', hotkeyStarting: '安裝中…', hotkeyFailed: '監聽失敗', @@ -805,6 +848,31 @@ export const zhTW: typeof zhCN = { windowsImeDesc: '用於在語音會話期間臨時切換到 OpenLess TSF 輸入法,避免剪貼板插入限制。', windowsImeInstalled: '已安裝', windowsImeUnavailable: '不可用', + androidImeLabel: '輸入法 (IME)', + androidImeSelected: '已選中', + androidImeEnabled: '已啟用', + androidImeDisabled: '未啟用', + androidOverlayLabel: '懸浮窗', + androidAccessibilityLabel: '無障礙服務', + androidAccessibilityImpact: '開啟後可在不切換鍵盤的情況下把結果輸出到目前輸入框;未開啟時仍會複製到剪貼簿,需要手動貼上。', + androidInsertStrategyLabel: '文字插入策略', + androidOverlayTriggerLabel: '懸浮窗顯示時機', + androidOverlayActivationModeLabel: '懸浮窗啟用方式', + androidOverlayLeftSwipeActionLabel: '左滑動作', + androidOverlayCancelSwipeDirectionLabel: '取消錄音滑向', + androidOverlaySizeLabel: '懸浮窗大小', + androidOverlaySizeHint: '調整懸浮按鈕直徑,儲存後在目前懸浮窗上生效並保留位置。', + androidInsertStrategy: { accessibility: '自動輸出到輸入框', clipboard: '僅剪貼簿' }, + androidInsertStrategyHint: { accessibility: '需要開啟無障礙服務;不可用時會複製到剪貼簿。', clipboard: '不需要無障礙權限,只複製到剪貼簿,由你手動貼上。' }, + androidOverlayTrigger: { background: '退到背景', keyboard: '鍵盤彈出時', always: '常駐' }, + androidOverlayTriggerHint: { background: '省電', keyboard: '此模式已暫緩,既有設定會改回退到背景。', always: '一直佔屏' }, + androidOverlayTriggerDisabled: { keyboard: '「鍵盤彈出時」暫緩開放,後續將以懸浮窗手勢取代鍵盤偵測。' }, + androidOverlayActivationMode: { tap: '點按啟用', long_press: '長按啟用' }, + androidOverlayActivationModeHint: { tap: '第一次點按進入啟用狀態,第二次點按開始普通聽寫。', long_press: '按住進入啟用狀態;放開時結束目前錄音或問答輪次。' }, + androidOverlayLeftSwipeAction: { translation: '翻譯聽寫', style_pack: '切換風格包' }, + androidOverlayLeftSwipeActionHint: { translation: '啟用狀態左滑後按翻譯模式錄音。', style_pack: '啟用狀態左滑後切換到上一個風格包。' }, + androidOverlayCancelSwipeDirection: { up: '向上滑', down: '向下滑' }, + androidOverlayCancelSwipeDirectionHint: { up: '錄音中向上滑取消本次聽寫,不轉寫、不插入。', down: '錄音中向下滑取消本次聽寫,不轉寫、不插入。' }, windowsIme: { installed: '已安裝。語音輸入時會臨時切換到 OpenLess 輸入法。', notInstalled: '未安裝。OpenLess 正在使用剪貼板 / WM_PASTE 兜底。', @@ -1002,6 +1070,7 @@ export const zhTW: typeof zhCN = { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低層鍵盤 hook', fcitx5: 'fcitx5 輸入法插件', + unavailable: '不可用', }, }, localAsr: { diff --git a/openless-all/app/src/lib/androidMicrophonePermission.ts b/openless-all/app/src/lib/androidMicrophonePermission.ts new file mode 100644 index 00000000..74490bd6 --- /dev/null +++ b/openless-all/app/src/lib/androidMicrophonePermission.ts @@ -0,0 +1 @@ +export * from '@android/lib/androidMicrophonePermission'; diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 22201685..95c67847 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -15,6 +15,7 @@ import type { HotkeyStatus, MicrophoneDevice, PermissionStatus, + PlatformCapabilities, CodingAgentPermissionMode, PolishMode, QaHotkeyBinding, @@ -29,13 +30,16 @@ import type { VocabPresetStore, WindowsImeStatus, } from "./types" -export type { UpdateChannel } from "./types" +export type { UpdateChannel, PlatformCapabilities } from "./types" import { OL_DATA } from "./mockData" import { defaultAppShortcutModifiers, defaultQaShortcut, formatComboLabel, } from "./hotkey" +import { + getPlatformCapabilities as loadPlatformCapabilities, +} from "./platform" declare global { interface Window { @@ -47,6 +51,56 @@ const isTauri = globalThis.window !== undefined && "__TAURI_INTERNALS__" in globalThis.window +let platformCapsPromise: Promise | null = null + +async function platformCapabilities(): Promise { + platformCapsPromise ??= loadPlatformCapabilities() + return platformCapsPromise +} + +export async function getPlatformCapabilities(): Promise { + return platformCapabilities() +} + +export { + getAndroidOverlayStatus, + requestAndroidOverlayPermission, + showAndroidOverlay, + hideAndroidOverlay, + getAndroidAccessibilityStatus, + requestAndroidAccessibilityPermission, +} from '../../android/frontend/lib/androidIpc'; + +export { isAndroid, isDesktop, isMobile } from "./platform" + +const androidHotkeyCapability: HotkeyCapability = { + adapter: "unavailable", + availableTriggers: [], + requiresAccessibilityPermission: false, + supportsModifierOnlyTrigger: false, + supportsSideSpecificModifiers: false, + explicitFallbackAvailable: false, + statusHint: + "移动端不支持全局热键;请使用应用内录音按钮或悬浮窗(需授权)。", +} + +const androidHotkeyStatus: HotkeyStatus = { + adapter: "unavailable", + state: "failed", + message: "移动端不支持全局热键", + lastError: { + code: "unavailable", + message: "Global hotkeys are not available on mobile", + }, +} + +const androidWindowsImeStatus: WindowsImeStatus = { + state: "notWindows", + usingTsfBackend: false, + message: "Not available on Android", + dllPath: null, +} + export async function invokeOrMock( cmd: string, args: Record | undefined, @@ -136,6 +190,12 @@ let mockSettings: UserPreferences = { remoteInputPort: 8443, remoteInputPin: "000000", remoteInputDefaultMode: "toggle", + androidInsertStrategy: "accessibility", + androidOverlayTrigger: "background", + androidOverlayActivationMode: "tap", + androidOverlayLeftSwipeAction: "translation", + androidOverlayCancelSwipeDirection: "up", + androidOverlaySizeDp: 72, } const mockFullStylePrompts: StyleSystemPrompts = { @@ -579,40 +639,95 @@ export interface LatestBetaRelease { publishedAt: string } +export interface AppUpdateMetadata { + rid: number + currentVersion: string + version: string + date?: string | null + body?: string | null + rawJson: Record +} + export function getUpdateChannel(): Promise { - return invokeOrMock( - "get_update_channel", - undefined, - () => "stable" as UpdateChannel, - ) + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return "stable" as UpdateChannel + } + return invokeOrMock( + "get_update_channel", + undefined, + () => "stable" as UpdateChannel, + ) + }) } export function setUpdateChannel(channel: UpdateChannel): Promise { - return invokeOrMock("set_update_channel", { channel }, () => undefined) + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return undefined + } + return invokeOrMock("set_update_channel", { channel }, () => undefined) + }) } export function fetchLatestBetaRelease(): Promise { - return invokeOrMock("fetch_latest_beta_release", undefined, () => null) + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return null + } + return invokeOrMock("fetch_latest_beta_release", undefined, () => null) + }) +} + +export function appCheckUpdateWithChannel( + timeoutMs: number, + channel?: UpdateChannel | null, +): Promise { + return platformCapabilities().then((caps) => { + if (!caps.supportsAutoUpdate) { + return null + } + return invokeOrMock( + "app_check_update_with_channel", + { timeoutMs, channel: channel ?? null }, + () => null, + ) + }) } export function getHotkeyStatus(): Promise { - return invokeOrMock("get_hotkey_status", undefined, () => mockHotkeyStatus) + return platformCapabilities().then((caps) => { + if (!caps.supportsDesktopHotkey) { + return androidHotkeyStatus + } + return invokeOrMock("get_hotkey_status", undefined, () => mockHotkeyStatus) + }) } export function getHotkeyCapability(): Promise { - return invokeOrMock( - "get_hotkey_capability", - undefined, - () => mockHotkeyCapability, - ) + return platformCapabilities().then((caps) => { + if (!caps.supportsDesktopHotkey) { + return androidHotkeyCapability + } + return invokeOrMock( + "get_hotkey_capability", + undefined, + () => mockHotkeyCapability, + ) + }) } export function getWindowsImeStatus(): Promise { - return invokeOrMock( - "get_windows_ime_status", - undefined, - () => mockWindowsImeStatus, - ) + return platformCapabilities().then((caps) => { + if (caps.platform === "android") { + return androidWindowsImeStatus + } + return invokeOrMock( + "get_windows_ime_status", + undefined, + () => mockWindowsImeStatus, + ) + }) } export interface NetworkCheckResult { @@ -849,11 +964,16 @@ export function handleWindowHotkeyEvent( code: string, repeat: boolean, ): Promise { - return invokeOrMock( - "handle_window_hotkey_event", - { event_type: eventType, key, code, repeat }, - () => undefined, - ) + return platformCapabilities().then((caps) => { + if (!caps.supportsDesktopHotkey) { + return undefined + } + return invokeOrMock( + "handle_window_hotkey_event", + { event_type: eventType, key, code, repeat }, + () => undefined, + ) + }) } // ── Polish ───────────────────────────────────────────────────────────── @@ -1093,7 +1213,7 @@ export function requestMicrophonePermission(): Promise { } export function openSystemSettings( - pane: "accessibility" | "microphone", + pane: "accessibility" | "microphone" | "overlay", ): Promise { return invokeOrMock("open_system_settings", { pane }, () => undefined) } @@ -1127,6 +1247,14 @@ export function qaWindowPin(pinned: boolean): Promise { return invokeOrMock("qa_window_pin", { pinned }, () => undefined) } +export function qaToggleRecording(): Promise { + return invokeOrMock("qa_toggle_recording", undefined, () => undefined) +} + +export function qaSubmitText(text: string): Promise { + return invokeOrMock("qa_submit_text", { text }, () => undefined) +} + // ── Less Computer 浮窗 ──────────────────────────────────────────────── /** 用户点 ✕ / 按 Esc 关闭 Less Computer 浮窗(隐藏窗口)。 */ export function lessComputerWindowDismiss(): Promise { @@ -1203,8 +1331,21 @@ export async function openExternal(url: string): Promise { window.open(url, "_blank", "noopener,noreferrer") return } - const { open } = await import("@tauri-apps/plugin-shell") - await open(url) + try { + const { open } = await import("@tauri-apps/plugin-shell") + await open(url) + return + } catch (error) { + console.warn("[external-open] shell plugin failed", error) + } + try { + const { invoke } = await import("@tauri-apps/api/core") + await invoke("open_external_url", { url }) + return + } catch (error) { + console.warn("[external-open] native fallback failed", error) + } + window.open(url, "_blank", "noopener,noreferrer") } /** diff --git a/openless-all/app/src/lib/platform.ts b/openless-all/app/src/lib/platform.ts new file mode 100644 index 00000000..03badcdb --- /dev/null +++ b/openless-all/app/src/lib/platform.ts @@ -0,0 +1,122 @@ +// Platform capability detection for desktop vs Android APK targets. +// Prefers Tauri `get_platform_capabilities`; falls back to UA / OS heuristics. + +import { detectOS } from '../components/WindowChrome'; +import type { PlatformCapabilities, PlatformKind } from './types'; + +export type { PlatformCapabilities, PlatformKind }; + +let cachedCapabilities: PlatformCapabilities | null = null; + +function detectAndroidFromUa(): boolean { + if (typeof navigator === 'undefined') return false; + const uaDataPlatform = + (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform ?? ''; + const hints = `${navigator.userAgent || ''} ${navigator.platform || ''} ${uaDataPlatform}`; + return /Android/i.test(hints); +} + +function detectIosFromUa(): boolean { + if (typeof navigator === 'undefined') return false; + const uaDataPlatform = + (navigator as Navigator & { userAgentData?: { platform?: string } }) + .userAgentData?.platform ?? ''; + const hints = `${navigator.userAgent || ''} ${navigator.platform || ''} ${uaDataPlatform}`; + return /iPhone|iPad|iPod/i.test(hints); +} + +/** Unavailable capability flags for iOS / other non-Android mobile targets. */ +const MOBILE_UNAVAILABLE: PlatformCapabilities = { + platform: 'mobile', + supportsDesktopHotkey: false, + supportsTray: false, + supportsOverlay: false, + supportsImeInput: false, + supportsLocalAsr: false, + supportsInAppDictation: false, + supportsAutoUpdate: false, +}; + +export function isAndroid(): boolean { + if (cachedCapabilities) return cachedCapabilities.platform === 'android'; + return detectOS() === 'android' || detectAndroidFromUa(); +} + +export function isMobile(): boolean { + if (cachedCapabilities) { + return ( + cachedCapabilities.platform === 'mobile' || + cachedCapabilities.platform === 'android' + ); + } + return isAndroid() || detectIosFromUa(); +} + +export function isDesktop(): boolean { + if (cachedCapabilities) return cachedCapabilities.platform === 'desktop'; + return !isMobile(); +} + +export function inferPlatformCapabilities(): PlatformCapabilities { + if (isAndroid()) { + return { + platform: 'android', + supportsDesktopHotkey: false, + supportsTray: false, + supportsOverlay: true, + supportsImeInput: false, + supportsLocalAsr: false, + supportsInAppDictation: true, + supportsAutoUpdate: false, + }; + } + + if (detectIosFromUa()) { + return MOBILE_UNAVAILABLE; + } + + const os = detectOS(); + return { + platform: 'desktop', + supportsDesktopHotkey: true, + supportsTray: true, + supportsOverlay: true, + supportsImeInput: os === 'win', + supportsLocalAsr: os === 'mac' || os === 'win', + supportsInAppDictation: false, + supportsAutoUpdate: true, + }; +} + +export async function getPlatformCapabilities(): Promise { + if (cachedCapabilities) return cachedCapabilities; + + const isTauri = + globalThis.window !== undefined && + '__TAURI_INTERNALS__' in globalThis.window; + + if (!isTauri) { + cachedCapabilities = inferPlatformCapabilities(); + return cachedCapabilities; + } + + try { + const { invoke } = await import('@tauri-apps/api/core'); + cachedCapabilities = await invoke( + 'get_platform_capabilities', + ); + return cachedCapabilities; + } catch (err) { + console.warn( + '[platform] get_platform_capabilities unavailable; using inferred defaults', + err, + ); + cachedCapabilities = inferPlatformCapabilities(); + return cachedCapabilities; + } +} + +export function getCachedPlatformCapabilities(): PlatformCapabilities | null { + return cachedCapabilities; +} diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index fd503121..96aea01b 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -84,6 +84,12 @@ const previousPrefs: UserPreferences = { remoteInputPort: 8443, remoteInputPin: '000000', remoteInputDefaultMode: 'toggle', + androidInsertStrategy: 'accessibility', + androidOverlayTrigger: 'background', + androidOverlayActivationMode: 'tap', + androidOverlayLeftSwipeAction: 'translation', + androidOverlayCancelSwipeDirection: 'up', + androidOverlaySizeDp: 72, }; const nextPrefs: UserPreferences = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 408bce47..4cbf63d2 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -2,6 +2,26 @@ // All keys are camelCase (Rust serializes with #[serde(rename_all = "camelCase")]). // PolishMode is an exception — Rust uses lowercase serialization. +import type { + AndroidAccessibilityStatus, + AndroidInsertStrategy, + AndroidOverlayActivationMode, + AndroidOverlayCancelSwipeDirection, + AndroidOverlayLeftSwipeAction, + AndroidOverlayStatus, + AndroidOverlayTrigger, +} from '../../android/frontend/lib/androidTypes'; + +export type { + AndroidAccessibilityStatus, + AndroidInsertStrategy, + AndroidOverlayActivationMode, + AndroidOverlayCancelSwipeDirection, + AndroidOverlayLeftSwipeAction, + AndroidOverlayStatus, + AndroidOverlayTrigger, +}; + export type PolishMode = 'raw' | 'light' | 'structured' | 'formal'; export type InsertStatus = 'inserted' | 'pasteSent' | 'copiedFallback' | 'failed'; @@ -78,7 +98,7 @@ export interface HotkeyBinding { keys?: HotkeyKey[] | null; } -export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'fcitx5'; +export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'fcitx5' | 'unavailable'; export interface HotkeyCapability { adapter: HotkeyAdapterKind; @@ -346,6 +366,18 @@ export interface UserPreferences { remoteInputPin: string; /** 手机录音页默认交互方式:'toggle'(点击切换)/ 'hold'(按住说话)。 */ remoteInputDefaultMode: 'toggle' | 'hold'; + /** Android: cross-app dictation insert strategy. */ + androidInsertStrategy: AndroidInsertStrategy; + /** Android: floating overlay visibility trigger mode. */ + androidOverlayTrigger: AndroidOverlayTrigger; + /** Android: how the floating overlay enters the armed interaction state. */ + androidOverlayActivationMode: AndroidOverlayActivationMode; + /** Android: action performed by left swiping while the overlay is armed. */ + androidOverlayLeftSwipeAction: AndroidOverlayLeftSwipeAction; + /** Android: vertical swipe direction that cancels recording. */ + androidOverlayCancelSwipeDirection: AndroidOverlayCancelSwipeDirection; + /** Android: floating overlay control diameter in dp. */ + androidOverlaySizeDp: number; } export interface MarketplaceListItem { @@ -495,3 +527,18 @@ export type PermissionStatus = | 'notDetermined' | 'restricted' | 'notApplicable'; + +/** Runtime platform kind returned by `get_platform_capabilities`. */ +export type PlatformKind = 'desktop' | 'android' | 'mobile'; + +/** Feature flags for desktop vs Android APK UI gating. Mirrors src-tauri PlatformCapabilities. */ +export interface PlatformCapabilities { + platform: PlatformKind; + supportsDesktopHotkey: boolean; + supportsTray: boolean; + supportsOverlay: boolean; + supportsImeInput: boolean; + supportsLocalAsr: boolean; + supportsInAppDictation: boolean; + supportsAutoUpdate: boolean; +} diff --git a/openless-all/app/src/lib/useMobileLayout.ts b/openless-all/app/src/lib/useMobileLayout.ts new file mode 100644 index 00000000..77a00995 --- /dev/null +++ b/openless-all/app/src/lib/useMobileLayout.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react'; +import { detectOS } from '../components/WindowChrome'; + +function shouldUseMobileLayout(breakpoint: number): boolean { + if (typeof window === 'undefined') return false; + const osQuery = new URLSearchParams(window.location.search).get('os'); + return osQuery === 'android' || detectOS() === 'android' || window.innerWidth < breakpoint; +} + +export function useMobileLayout(breakpoint = 720): boolean { + const [mobile, setMobile] = useState(() => shouldUseMobileLayout(breakpoint)); + + useEffect(() => { + const sync = () => setMobile(shouldUseMobileLayout(breakpoint)); + sync(); + window.addEventListener('resize', sync); + window.addEventListener('orientationchange', sync); + return () => { + window.removeEventListener('resize', sync); + window.removeEventListener('orientationchange', sync); + }; + }, [breakpoint]); + + return mobile; +} diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index cf821862..371c7d42 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -10,6 +10,7 @@ import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording, retr import type { DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; +import { useMobileLayout } from '../lib/useMobileLayout'; function useFilters(): Array<{ id: 'all' | PolishMode; label: string }> { const { t } = useTranslation(); @@ -35,6 +36,7 @@ function useModeLabel(): Record { export function History() { const { t } = useTranslation(); const os = detectOS(); + const mobile = useMobileLayout(); const FILTERS = useFilters(); const MODE_LABEL = useModeLabel(); const [filter, setFilter] = useState<'all' | PolishMode>('all'); @@ -222,8 +224,16 @@ export function History() {
} /> -
- +
+
setFilter(f.id)} style={{ padding: '3px 9px', fontSize: 11, borderRadius: 999, - border: '0.5px solid ' + (filter === f.id ? 'var(--ol-ink)' : 'var(--ol-line-strong)'), - background: filter === f.id ? 'var(--ol-ink)' : 'transparent', - color: filter === f.id ? '#fff' : 'var(--ol-ink-3)', + border: '0.5px solid ' + (filter === f.id ? 'var(--ol-pill-selected-border)' : 'var(--ol-line-strong)'), + background: filter === f.id ? 'var(--ol-pill-selected-bg)' : 'transparent', + color: filter === f.id ? 'var(--ol-pill-selected-ink)' : 'var(--ol-ink-3)', cursor: 'default', fontFamily: 'inherit', fontWeight: 500, transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick)', }} @@ -316,16 +326,16 @@ export function History() {
- + {item ? ( <> -
-
+
+
{formatTime(item.createdAt)} {MODE_LABEL[item.mode]} {formatDuration(item.durationMs, t)}
-
+
void onCopy()}>{justCopied ? t('common.copied') : t('common.copy')} {/* issue #613:失败条目(有错误码)且录音仍在时,提供「重新转录」。 */} {item.errorCode && item.hasAudioRecording && !audioMissingIds.has(item.id) && ( @@ -352,7 +362,7 @@ export function History() { key={item.id} /> )} -
+
{t('history.rawLabel')}

diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 84be5e92..8dadbc82 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -4,10 +4,27 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { formatComboLabel } from '../lib/hotkey'; -import { getCredentials, listHistory } from '../lib/ipc'; -import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types'; +import { + checkMicrophonePermission, + getCredentials, + getPlatformCapabilities, + listHistory, + startDictation, + stopDictation, +} from '../lib/ipc'; +import type { + CapsulePayload, + CapsuleState, + CredentialsStatus, + DictationSession, + PermissionStatus, + PlatformCapabilities, + PolishMode, +} from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { Btn, Card, PageHeader, Pill } from './_atoms'; +import { useMobileLayout } from '../lib/useMobileLayout'; +import { checkAndroidMicrophoneAccess, requestAndroidMicrophoneAccess } from '../lib/androidMicrophonePermission'; function useModeLabels(): Record { const { t } = useTranslation(); @@ -35,7 +52,6 @@ const ASR_NAME_KEY_BY_ID: Record = { 'foundry-local-whisper': 'asrFoundryLocalWhisper', 'sherpa-onnx-local': 'asrSherpaOnnxLocal', 'local-qwen3': 'asrLocalQwen3', - 'apple-speech': 'asrAppleSpeech', }; const LLM_NAME_KEY_BY_ID: Record = { @@ -54,6 +70,7 @@ const LLM_NAME_KEY_BY_ID: Record = { export function Overview({ onOpenHistory }: OverviewProps) { const { t } = useTranslation(); + const mobile = useMobileLayout(); const modeLabel = useModeLabels(); const [history, setHistory] = useState([]); const [historyError, setHistoryError] = useState(false); @@ -171,9 +188,19 @@ export function Overview({ onOpenHistory }: OverviewProps) { return ( <> - + -

+ + + +
-
+
0 ? t('overview.metricAvgTrend') : t('overview.metricNoData')} /> @@ -198,8 +225,8 @@ export function Overview({ onOpenHistory }: OverviewProps) { {/* 底部一行 = flex:1 撑满剩余高度(父 wrapper 是 display:flex/column)。 只有「最近识别」内部允许滚动;其他卡片按内容自然高度,不破裂底部圆角。 issue #243 follow-up:去掉外层 overflow 后底部圆角被裁的视觉问题。 */} -
- +
+
{t('overview.weekTitle')} {t('overview.weekUnit')} @@ -216,12 +243,12 @@ export function Overview({ onOpenHistory }: OverviewProps) {
- +
{t('overview.recentTitle')} {t('overview.recentAll')}
-
+
{historyError ? (
{t('overview.recentLoadFailed')} @@ -255,10 +282,11 @@ interface ProviderCardProps { function ProviderCard({ kind, name, subname, status }: ProviderCardProps) { const { t } = useTranslation(); + const mobile = useMobileLayout(); // ASR 卡用 mic 图标,其他用 sparkle —— 通过比较译文判断会随语言改变,故改用本地化无关的字面量比较。 const isAsr = kind === t('overview.asrKind'); return ( - +
-
+
{kind} {status === 'configured' && ( @@ -303,13 +331,14 @@ interface MetricProps { } function Metric({ icon, label, value, trend, accent }: MetricProps) { + const mobile = useMobileLayout(); return ( - +
{label}
-
{value}
+
{value}
{trend || ' '}
); @@ -344,9 +373,10 @@ function WeekChart({ data }: { data: number[] }) { function RecentRow({ session, modeLabel }: { session: DictationSession; modeLabel: Record }) { const { t } = useTranslation(); + const mobile = useMobileLayout(); return ( -
-
+
+
{formatTime(session.createdAt)} @@ -387,3 +417,189 @@ function weekDayLabels(names: string[]): string[] { } return out; } + +function AndroidMicGrantBanner() { + const { t } = useTranslation(); + const mobile = useMobileLayout(); + const [platformCaps, setPlatformCaps] = useState(null); + const [microphone, setMicrophone] = useState('loading'); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + + const refreshMic = useCallback(async () => { + if (platformCaps?.platform === 'android') { + setMicrophone(await checkAndroidMicrophoneAccess()); + return; + } + setMicrophone(await checkMicrophonePermission()); + }, [platformCaps?.platform]); + + useEffect(() => { + if (platformCaps?.platform !== 'android') return; + void refreshMic(); + const onFocus = () => { void refreshMic(); }; + window.addEventListener('focus', onFocus); + return () => window.removeEventListener('focus', onFocus); + }, [platformCaps?.platform, refreshMic]); + + if (platformCaps?.platform !== 'android') return null; + if (microphone === 'loading' || microphone === 'granted' || microphone === 'notApplicable') { + return null; + } + + const onGrant = async () => { + setBusy(true); + try { + setMicrophone(await requestAndroidMicrophoneAccess()); + } finally { + setBusy(false); + void refreshMic(); + } + }; + + return ( + +
+ +
+
+ {t('overview.androidMicBanner.title')} +
+
+ {t('overview.androidMicBanner.desc')} +
+
+
+ void onGrant()} disabled={busy} style={{ justifyContent: 'center', width: mobile ? '100%' : undefined }}> + {t('overview.androidMicBanner.grant')} + +
+ ); +} + +function InAppDictationControl() { + const { t } = useTranslation(); + const mobile = useMobileLayout(); + const [platformCaps, setPlatformCaps] = useState(null); + const [capsuleState, setCapsuleState] = useState('idle'); + const [busy, setBusy] = useState(false); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + + useEffect(() => { + if (!platformCaps?.supportsInAppDictation) return; + let cancelled = false; + let unlisten: (() => void) | undefined; + void (async () => { + try { + const { listen } = await import('@tauri-apps/api/event'); + const handle = await listen('capsule:state', (event) => { + if (cancelled) return; + setCapsuleState(event.payload.state); + }); + if (cancelled) { + handle(); + } else { + unlisten = handle; + } + } catch { + // browser dev mock + } + })(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [platformCaps?.supportsInAppDictation]); + + if (!platformCaps?.supportsInAppDictation) { + return null; + } + + const recording = capsuleState === 'recording'; + const processing = capsuleState === 'transcribing' || capsuleState === 'polishing'; + const statusLabel = recording + ? t('overview.inAppDictation.recording') + : processing + ? t('overview.inAppDictation.processing') + : t('overview.inAppDictation.idle'); + + const onToggle = async () => { + if (busy || processing) return; + setBusy(true); + try { + if (recording) { + await stopDictation(); + } else { + if (platformCaps?.platform === 'android') { + const current = await checkAndroidMicrophoneAccess(); + const status = current === 'granted' + ? current + : await requestAndroidMicrophoneAccess(); + if (status !== 'granted') return; + } + await startDictation(); + } + } catch (error) { + console.error('[overview] in-app dictation toggle failed', error); + } finally { + setBusy(false); + } + }; + + return ( + + +
+
+ {t('overview.inAppDictation.title')} +
+
+ {statusLabel} +
+
+ {!mobile && {statusLabel}} +
+ ); +} diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index 184e2fc3..847581ac 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -12,8 +12,16 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; -import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; -import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types'; +import { + getPlatformCapabilities, + getSettings, + isTauri, + qaSubmitText, + qaToggleRecording, + qaWindowDismiss, + qaWindowPin, +} from '../lib/ipc'; +import type { PlatformCapabilities, QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types'; import { getHotkeyBindingLabel } from '../lib/hotkey'; import { renderQaMarkdown, renderQaPlainText } from '../lib/qaMarkdown'; @@ -21,17 +29,24 @@ const SELECTION_PREVIEW_MAX = 60; type Status = 'idle' | 'recording' | 'thinking' | 'error'; -export function QaPanel() { +interface QaPanelProps { + embedded?: boolean; + onRequestClose?: () => void; +} + +export function QaPanel({ embedded = false, onRequestClose }: QaPanelProps = {}) { const { t, i18n } = useTranslation(); const [messages, setMessages] = useState([]); const [status, setStatus] = useState('idle'); const [errorMsg, setErrorMsg] = useState(''); const [selectionPreview, setSelectionPreview] = useState(''); + const [composerText, setComposerText] = useState(''); const [pinned, setPinned] = useState(false); /** 流式 LLM 答案:answer_delta 累积、answer 事件来时清空(最终内容已落到 messages)。 */ const [streamingAnswer, setStreamingAnswer] = useState(''); /** 录音电平:0..1。后端每帧 33ms 通过 qa:level emit。详见 issue #162。 */ const [level, setLevel] = useState(0); + const [platformCaps, setPlatformCaps] = useState(null); /** 用户当前的录音热键 label(如 "右 Option" / "Right Alt")。issue #205: * 原版硬编码 "Option",Windows 用户没这个键,文案失真。读 prefs 后由 i18n * 插值动态显示,平台与用户配置都能跟上。 */ @@ -40,6 +55,10 @@ export function QaPanel() { ); const tRef = useRef(t); tRef.current = t; + const embeddedRef = useRef(embedded); + embeddedRef.current = embedded; + const onRequestCloseRef = useRef(onRequestClose); + onRequestCloseRef.current = onRequestClose; // ── 后端事件订阅(mount 时订阅一次,永不重订阅)────────────────── useEffect(() => { @@ -75,14 +94,18 @@ export function QaPanel() { // ASR 在 finalize、user message 还没 push 的过渡帧。提前切到 thinking // 视图避免 UI 卡 recording 几百 ms 反馈缺失。详见 issue #161。 setStatus('thinking'); - setSelectionPreview(''); + if (payload.selection_preview != null) { + setSelectionPreview(payload.selection_preview); + } setErrorMsg(''); setStreamingAnswer(''); setLevel(0); break; case 'thinking': setStatus('thinking'); - setSelectionPreview(''); + if (payload.selection_preview != null) { + setSelectionPreview(payload.selection_preview); + } setErrorMsg(''); setStreamingAnswer(''); setLevel(0); @@ -95,7 +118,6 @@ export function QaPanel() { break; case 'answer': setStatus('idle'); - setSelectionPreview(''); setErrorMsg(''); // messages 已被上面的 setMessages 落定,清掉流式 buffer 避免和最终气泡重影。 setStreamingAnswer(''); @@ -111,7 +133,13 @@ export function QaPanel() { }); const dismissHandle = await listen('qa:dismiss', () => { setPinned(false); - void qaWindowDismiss(); + setSelectionPreview(''); + setComposerText(''); + if (embeddedRef.current) { + onRequestCloseRef.current?.(); + } else { + void qaWindowDismiss(); + } }); // qa:level — 录音电平,节流 ~33ms/帧。详见 issue #162。 const levelHandle = await listen<{ level: number }>('qa:level', event => { @@ -153,11 +181,12 @@ export function QaPanel() { if (event.key === 'Escape') { event.preventDefault(); void qaWindowDismiss(); + onRequestClose?.(); } }; window.addEventListener('keydown', onKey, true); return () => window.removeEventListener('keydown', onKey, true); - }, []); + }, [onRequestClose]); // ── 读取用户当前的录音热键 label,给 i18n 插值用(issue #205)。 // QaPanel 跑在独立 webview(label="qa"),没有 HotkeySettingsContext @@ -182,6 +211,25 @@ export function QaPanel() { }; }, [i18n.language]); + useEffect(() => { + let cancelled = false; + void getPlatformCapabilities() + .then(caps => { + if (!cancelled) setPlatformCaps(caps); + }) + .catch(err => { + console.warn('[QaPanel] load platform capabilities failed', err); + }); + return () => { + cancelled = true; + }; + }, []); + + const mobileRecordButton = platformCaps?.supportsDesktopHotkey === false; + const recordControlLabel = mobileRecordButton + ? t('qa.mobileRecordLabel') + : recordHotkeyLabel; + const onTogglePin = () => { const next = !pinned; setPinned(next); @@ -190,6 +238,18 @@ export function QaPanel() { const onClose = () => { void qaWindowDismiss(); + onRequestClose?.(); + }; + + const onSubmitText = () => { + const text = composerText.trim(); + if (!text || status === 'thinking' || status === 'recording') return; + setComposerText(''); + void qaSubmitText(text).catch(error => { + console.error('[QaPanel] qa_submit_text failed', error); + setErrorMsg(error instanceof Error ? error.message : String(error)); + setStatus('error'); + }); }; // ── 自动滚动到底(新消息进来时)──────────────────────────────────── @@ -201,18 +261,18 @@ export function QaPanel() { }, [messages, status]); return ( -
- +
+
{messages.length === 0 && status === 'idle' && ( - + )} {messages.length === 0 && status === 'recording' && ( )} @@ -222,7 +282,7 @@ export function QaPanel() { t={t} preview={selectionPreview} level={level} - recordHotkey={recordHotkeyLabel} + recordHotkey={recordControlLabel} /> )} {streamingAnswer && ( @@ -232,10 +292,27 @@ export function QaPanel() { )} {status === 'error' && ( - + )}
- + {mobileRecordButton && ( + { + if (status === 'thinking') return; + void qaToggleRecording(); + }} + /> + )} + +
); } @@ -246,9 +323,10 @@ interface ToolbarProps { pinned: boolean; onTogglePin: () => void; onClose: () => void; + embedded?: boolean; } -function Toolbar({ pinned, onTogglePin, onClose }: ToolbarProps) { +function Toolbar({ pinned, onTogglePin, onClose, embedded = false }: ToolbarProps) { const { t } = useTranslation(); // 拖动 (issue #205): // - macOS: lib.rs::make_qa_window_draggable_macos 在 NSWindow 层把整窗口设 @@ -258,7 +336,7 @@ function Toolbar({ pinned, onTogglePin, onClose }: ToolbarProps) { // 两条路径并存不冲突;data-tauri-drag-region 放在 toolbar 的空白 spacer 上,IconBtn // 作为 button 子元素仍然正常 click。 return ( -
+
['t']; + onClick: () => void; +}) { + const disabled = status === 'thinking'; + const recording = status === 'recording'; + return ( +
+ +
+ ); +} + +function QaComposer({ + value, + disabled, + t, + onChange, + onSubmit, +}: { + value: string; + disabled: boolean; + t: ReturnType['t']; + onChange: (value: string) => void; + onSubmit: () => void; +}) { + const canSubmit = value.trim().length > 0 && !disabled; + return ( +
+