feat(android): port Android APK runtime onto beta

This commit is contained in:
HKLHaoBin committed 2026-06-12 18:13:12 +08:00
1 parent cb63eaa766
commit 1ea467a001
128 files changed
+17873 -2203

No files matched your search

+108 -18
View File
@@ -4,8 +4,7 @@ name: Android APK (debug)
# - push v*-tauri tag → build debug APK, upload artifact + attach to GitHub Release
# - workflow_dispatch → build debug APK, upload artifact only (no release)
#
# v1 scope: in-app dictation APK with RECORD_AUDIO merged from android-scaffolding.
# IME v2 / overlay v3 manifest snippets are intentionally out of scope.
# Scope: full overlay/accessibility APK for ADB testing (v1 RECORD_AUDIO + overlay manifest merge).
on:
push:
@@ -33,6 +32,8 @@ jobs:
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: platform-tools
- name: Install NDK and accept SDK licenses
shell: bash
@@ -88,10 +89,18 @@ jobs:
working-directory: openless-all/app
run: npm run tauri -- android init --ci
- name: Merge APK v1 manifest (RECORD_AUDIO)
- 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
@@ -111,33 +120,114 @@ jobs:
working-directory: openless-all/app
run: npm run tauri:android:build
- name: Collect debug APK
- 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
apk_path="$(find src-tauri/gen/android -type f -name '*.apk' -path '*/outputs/*' | sort | head -n 1)"
if [ -z "$apk_path" ]; then
echo "::error::No APK found under src-tauri/gen/android/**/outputs/"
find src-tauri/gen/android -type f -name '*.apk' 2>/dev/null || true
exit 1
fi
if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then
label="${{ github.ref_name }}"
else
label="run-${{ github.run_number }}"
fi
dest="$RUNNER_TEMP/OpenLess-android-debug-${label}.apk"
cp "$apk_path" "$dest"
echo "path=$dest" >> "$GITHUB_OUTPUT"
echo "Collected APK: $apk_path → $dest"
export OPENLESS_APK_LABEL="$label"
python - <<'PY'
import os
import shutil
import sys
import zipfile
from pathlib import Path
- name: Upload Android APK artifact
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<<EOF\n")
for abi in expected:
output.write(f"{found[abi]}\n")
output.write("EOF\n")
PY
- name: Upload Android APK artifact (arm64-v8a)
uses: actions/upload-artifact@v4
with:
name: openless-android-debug
path: ${{ steps.apk.outputs.path }}
name: openless-android-debug-arm64-v8a
path: ${{ steps.apk.outputs.arm64_v8a_path }}
if-no-files-found: error
- name: Upload Android APK artifact (armeabi-v7a)
uses: actions/upload-artifact@v4
with:
name: openless-android-debug-armeabi-v7a
path: ${{ steps.apk.outputs.armeabi_v7a_path }}
if-no-files-found: error
- name: Upload Android APK artifact (x86)
uses: actions/upload-artifact@v4
with:
name: openless-android-debug-x86
path: ${{ steps.apk.outputs.x86_path }}
if-no-files-found: error
- name: Upload Android APK artifact (x86_64)
uses: actions/upload-artifact@v4
with:
name: openless-android-debug-x86_64
path: ${{ steps.apk.outputs.x86_64_path }}
if-no-files-found: error
- name: Attach APK to GitHub Release
@@ -148,4 +238,4 @@ jobs:
name: OpenLess ${{ github.ref_name }}
draft: false
prerelease: ${{ endsWith(github.ref_name, '-beta-tauri') }}
files: ${{ steps.apk.outputs.path }}
files: ${{ steps.apk.outputs.release_files }}
+1
View File
@@ -11,6 +11,7 @@ on:
branches: [main, beta]
pull_request:
branches: [main, beta]
workflow_dispatch:
jobs:
cross-platform:
+23 -1
View File
@@ -7,7 +7,7 @@ Package.resolved
xcuserdata/
DerivedData/
ChatGPT Image*.jpg
.cursor
# Local config / secrets
*.env
config.local.json
@@ -33,6 +33,28 @@ video-materials/
node_modules/
dist/
target/
# 本地从 CI / Actions 下载的 APK 及解压目录(非源码)
ci-artifacts/
*.apk
# Android 真机调试产物(日志 / 截图 / adb 导出 / 数据备份 / 临时还原目录)
diagnostics/
android-logs/
test-captures/
extracted/
android-crash*.log
android-run*.log
qa-*.log
qa-*.png
openless-package*.txt
openless-screenshot*.png
openless-android-run*.png
/openless_*.png
/run*.png
openless-appdata*.tar
openless-run*-ui.xml
tmp-run*/
.cargo/registry/
.cargo/git/
+6
View File
@@ -215,6 +215,12 @@ Go to [Releases](../../releases) and download:
xattr -cr /Applications/OpenLess.app
```
- **Windows**: `OpenLess_<version>_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
+315
View File
@@ -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-<tag>.apk` (e.g. `OpenLess-android-debug-v1.0.0-tauri.apk`)
- Manual dispatch: `OpenLess-android-debug-run-<run_number>.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`
+79
View File
@@ -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) — 分阶段产品计划
@@ -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<UserPreferences, AndroidPreferenceKey>;
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<T>(task: () => Promise<T>): Promise<T> {
const run = androidOverlaySaveQueue.then(task);
androidOverlaySaveQueue = run.then(() => undefined, () => undefined);
return run;
}
function persistAndroidOverlayPrefs(patch: Partial<UserPreferences>): Promise<AndroidPrefsSlice> {
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<AndroidOverlayStatus | null>(null);
const [androidAccessibility, setAndroidAccessibility] = useState<AndroidAccessibilityStatus | null>(null);
const [androidPrefs, setAndroidPrefs] = useState<Pick<UserPreferences, AndroidPreferenceKey> | null>(null);
const [sizeDraft, setSizeDraft] = useState<number | null>(null);
const sizeDebounceRef = useRef<number | null>(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 <K extends AndroidPreferenceKey>(key: K, value: UserPreferences[K]) => {
if (key !== 'androidOverlaySizeDp' && sizeDebounceRef.current) {
clearTimeout(sizeDebounceRef.current);
sizeDebounceRef.current = null;
}
const patch: Partial<UserPreferences> = {
[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 && (
<SettingRow label={t('settings.permissions.androidOverlayLabel')}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
{androidOverlay?.message && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: 220, textAlign: 'right' }}>
{androidOverlay.message}
</span>
)}
<AndroidOverlayStatusPill status={androidOverlay} />
{androidOverlay?.permission !== 'granted' && (
<Btn variant="ghost" size="sm" onClick={() => { void requestAndroidOverlayPermission().then(refreshAndroid); }}>
{t('settings.permissions.grant')}
</Btn>
)}
</div>
</SettingRow>
)}
{showAccessibility && (
<SettingRow label={t('settings.permissions.androidAccessibilityLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%', minWidth: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'flex-end', width: '100%', flexWrap: 'wrap', minWidth: 0 }}>
{androidAccessibility?.message && (
<span style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', maxWidth: 220, textAlign: 'right' }}>
{androidAccessibility.message}
</span>
)}
<AndroidAccessibilityStatusPill status={androidAccessibility} />
{!androidAccessibility?.enabled && (
<Btn variant="ghost" size="sm" onClick={() => { void requestAndroidAccessibilityPermission().then(refreshAndroid); }}>
{t('settings.permissions.openSystem')}
</Btn>
)}
</div>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', maxWidth: 300, textAlign: 'right' }}>
{t('settings.permissions.androidAccessibilityImpact')}
</span>
</div>
</SettingRow>
)}
{showOverlayConfig && (
<>
<SettingRow label={t('settings.permissions.androidInsertStrategyLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<select
value={androidPrefs?.androidInsertStrategy ?? 'accessibility'}
onChange={(event) => { void updateAndroidPref('androidInsertStrategy', event.target.value as AndroidInsertStrategy); }}
style={{ minWidth: 180, maxWidth: '100%' }}
>
<option value="accessibility">{t('settings.permissions.androidInsertStrategy.accessibility')}</option>
<option value="clipboard">{t('settings.permissions.androidInsertStrategy.clipboard')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t(`settings.permissions.androidInsertStrategyHint.${androidPrefs?.androidInsertStrategy ?? 'accessibility'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayTriggerLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<select
value={androidPrefs?.androidOverlayTrigger ?? 'background'}
onChange={(event) => { void updateAndroidPref('androidOverlayTrigger', event.target.value as AndroidOverlayTrigger); }}
style={{ minWidth: 180, maxWidth: '100%' }}
>
<option value="background">{t('settings.permissions.androidOverlayTrigger.background')}</option>
<option value="keyboard" disabled>{t('settings.permissions.androidOverlayTrigger.keyboard')}</option>
<option value="always">{t('settings.permissions.androidOverlayTrigger.always')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t(`settings.permissions.androidOverlayTriggerHint.${androidPrefs?.androidOverlayTrigger ?? 'background'}`)}
</span>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t('settings.permissions.androidOverlayTriggerDisabled.keyboard')}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayActivationModeLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<select
value={androidPrefs?.androidOverlayActivationMode ?? 'tap'}
onChange={(event) => { void updateAndroidPref('androidOverlayActivationMode', event.target.value as AndroidOverlayActivationMode); }}
style={{ minWidth: 180, maxWidth: '100%' }}
>
<option value="tap">{t('settings.permissions.androidOverlayActivationMode.tap')}</option>
<option value="long_press">{t('settings.permissions.androidOverlayActivationMode.long_press')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t(`settings.permissions.androidOverlayActivationModeHint.${androidPrefs?.androidOverlayActivationMode ?? 'tap'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayLeftSwipeActionLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<select
value={androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}
onChange={(event) => { void updateAndroidPref('androidOverlayLeftSwipeAction', event.target.value as AndroidOverlayLeftSwipeAction); }}
style={{ minWidth: 180, maxWidth: '100%' }}
>
<option value="translation">{t('settings.permissions.androidOverlayLeftSwipeAction.translation')}</option>
<option value="style_pack">{t('settings.permissions.androidOverlayLeftSwipeAction.style_pack')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t(`settings.permissions.androidOverlayLeftSwipeActionHint.${androidPrefs?.androidOverlayLeftSwipeAction ?? 'translation'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlayCancelSwipeDirectionLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<select
value={androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}
onChange={(event) => { void updateAndroidPref('androidOverlayCancelSwipeDirection', event.target.value as AndroidOverlayCancelSwipeDirection); }}
style={{ minWidth: 180, maxWidth: '100%' }}
>
<option value="up">{t('settings.permissions.androidOverlayCancelSwipeDirection.up')}</option>
<option value="down">{t('settings.permissions.androidOverlayCancelSwipeDirection.down')}</option>
</select>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t(`settings.permissions.androidOverlayCancelSwipeDirectionHint.${androidPrefs?.androidOverlayCancelSwipeDirection ?? 'up'}`)}
</span>
</div>
</SettingRow>
<SettingRow label={t('settings.permissions.androidOverlaySizeLabel')}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, alignItems: 'flex-end', width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 180, maxWidth: '100%' }}>
<input
type="range"
min={48}
max={120}
step={4}
value={sizeDraft ?? androidPrefs?.androidOverlaySizeDp ?? 72}
onChange={(event) => {
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 }}
/>
<span style={{ fontSize: 12, color: 'var(--ol-ink-3)', minWidth: 42, textAlign: 'right' }}>
{sizeDraft ?? androidPrefs?.androidOverlaySizeDp ?? 72} dp
</span>
</div>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', textAlign: 'right' }}>
{t('settings.permissions.androidOverlaySizeHint')}
</span>
</div>
</SettingRow>
</>
)}
</>
);
}
function AndroidOverlayStatusPill({ status }: { status: AndroidOverlayStatus | null }) {
const { t } = useTranslation();
if (!status) return <Pill tone="default">{t('settings.permissions.checking')}</Pill>;
if (status.permission === 'granted') {
return <Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.granted')}</Pill>;
}
return <Pill tone="outline">{t('settings.permissions.denied')}</Pill>;
}
function AndroidAccessibilityStatusPill({ status }: { status: AndroidAccessibilityStatus | null }) {
const { t } = useTranslation();
if (!status) return <Pill tone="default">{t('settings.permissions.checking')}</Pill>;
if (status.enabled) {
return <Pill tone="ok"><Icon name="check" size={11} />{t('settings.permissions.granted')}</Pill>;
}
return <Pill tone="outline">{t('settings.permissions.denied')}</Pill>;
}
@@ -0,0 +1,43 @@
import { invokeOrMock } from '../../../src/lib/ipc';
import type {
AndroidAccessibilityStatus,
AndroidOverlayStatus,
} from './androidTypes';
export function getAndroidOverlayStatus(): Promise<AndroidOverlayStatus> {
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<void> {
return invokeOrMock('show_android_overlay', undefined, () => undefined);
}
export function hideAndroidOverlay(): Promise<void> {
return invokeOrMock('hide_android_overlay', undefined, () => undefined);
}
export function getAndroidAccessibilityStatus(): Promise<AndroidAccessibilityStatus> {
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',
}));
}
@@ -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<AppPermissionStatus> {
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<AppPermissionStatus> {
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());
}
}
@@ -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));
}
@@ -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<out String>,
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
}
}
@@ -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"
}
}
@@ -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
}
}
@@ -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<File> {
val files = mutableListOf<File>()
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
}
}
@@ -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
}
}
@@ -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"
}
}
@@ -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)
}
@@ -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()
}
}
}
@@ -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<Int, Int> {
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 <T> 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<FrameLayout>()
@Volatile
var instance: OpenLessOverlayService? = null
private set
}
}
@@ -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)
}
}
@@ -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()
}
}
+27
View File
@@ -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.
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- APK v1: in-app dictation - merge into gen/android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Required for microphone capture inside the OpenLess app (v1 scope). -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
</manifest>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- FUTURE / NOT COMPLETE — overlay recording control (v3).
Requires overlay permission UX and foreground service lifecycle. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<application>
<service
android:name=".OpenLessOverlayService"
android:exported="false"
android:foregroundServiceType="microphone" />
<activity
android:name=".OverlayPermissionActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
</application>
</manifest>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowsChanged|typeViewFocused"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:description="@string/openless_accessibility_description"
android:notificationTimeout="100"
android:settingsActivity="com.openless.app.MainActivity" />
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<input-method xmlns:android="http://schemas.android.com/apk/res/android"
android:settingsActivity="com.openless.app.MainActivity"
android:supportsSwitchingToNextInputMethod="true">
<subtype
android:label="@string/openless_ime_label"
android:imeSubtypeLocale="zh_CN"
android:imeSubtypeMode="voice" />
</input-method>
+6
View File
@@ -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"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 363 KiB

After

Width:  |  Height:  |  Size: 85 KiB

@@ -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 = `<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeWindowStateChanged|typeWindowsChanged|typeViewFocused"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagRetrieveInteractiveWindows"
android:canRetrieveWindowContent="true"
android:description="@string/openless_accessibility_description"
android:notificationTimeout="100"
android:settingsActivity="com.openless.app.MainActivity" />
`;
const GENERATED_STRINGS_SNIPPET = `
<string name="openless_accessibility_description">OpenLess uses accessibility to detect the keyboard and paste dictation results without switching your current keyboard.</string>
`;
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 = `<?xml version="1.0" encoding="utf-8"?>
<resources>${GENERATED_STRINGS_SNIPPET}
</resources>
`;
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('</resources>', `${GENERATED_STRINGS_SNIPPET}\n</resources>`);
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);
}
@@ -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 = `
<application android:name=".OpenLessApplication">
</application>
`;
const SERVICE_SNIPPETS = [
`<service
android:name=".OpenLessOverlayService"
android:exported="false"
android:foregroundServiceType="microphone" />`,
`<service
android:name=".OpenLessAccessibilityService"
android:process=":accessibility"
android:exported="false"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/openless_accessibility_config" />
</service>`,
`<receiver
android:name=".OpenLessAccessibilityCommandReceiver"
android:process=":accessibility"
android:exported="false" />`,
`<activity
android:name=".OverlayPermissionActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />`,
`<activity
android:name=".MicrophonePermissionActivity"
android:exported="false"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />`,
];
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(`<uses-permission[^>]*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 = ` <uses-permission android:name="${name}" />`;
const applicationIdx = content.indexOf('<application');
if (applicationIdx === -1) {
throw new Error('Target manifest is missing <application>');
}
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(
/<application(\s|>)/,
'<application android:name=".OpenLessApplication"$1',
);
return { content: updated, changed: updated !== manifestXml };
}
function snippetExists(manifestXml, marker) {
return manifestXml.includes(marker);
}
function mergeApplicationChildren(manifestXml) {
let content = manifestXml;
let changed = false;
const closingIdx = content.indexOf('</application>');
if (closingIdx === -1) {
throw new Error('Target manifest is missing </application>');
}
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);
}
@@ -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 =
/<uses-permission[^>]*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(`<uses-permission[^>]*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('<application');
if (applicationIdx !== -1) {
const indentMatch = manifestXml.slice(0, applicationIdx).match(/(^|\n)([ \t]*)<[^/][^\n]*$/);
const indent = indentMatch?.[2] ?? ' ';
return {
changed: true,
content: `${manifestXml.slice(0, applicationIdx)}${insertionBlock(indent)}${manifestXml.slice(applicationIdx)}`,
};
}
const closingManifestIdx = manifestXml.lastIndexOf('</manifest>');
if (closingManifestIdx === -1) {
throw new Error(`Target manifest is missing </manifest>: ${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);
}
+261 -324
View File
File diff suppressed because it is too large. Load diff
+21 -10
View File
@@ -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"
# Hotkey + audio + insertion
global-hotkey = "0.6"
# Audio capture — shared across desktop and mobile.
cpal = "0.15"
# 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"
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"
+11
View File
@@ -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(
@@ -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",
@@ -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"
]
}
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@mipmap/ic_launcher_foreground"
android:inset="18%" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

@@ -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)
}
}
@@ -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<InsertStatus> {
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
}
@@ -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<R>(
f: impl for<'local> FnOnce(&mut JNIEnv<'local>, &JObject<'local>) -> Result<R, String>,
) -> Result<R, String> {
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<JClass<'local>, 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<bool, String> {
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<JString<'local>, String> {
env.new_string(value)
.map_err(|error| format!("create jstring: {error}"))
}
fn jobject_str<'local>(
env: &mut JNIEnv<'local>,
value: &str,
) -> Result<JObject<'local>, 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<bool, String> {
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<bool, String> {
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<bool, String> {
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<i32, String> {
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<bool, String> {
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<bool, String> {
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<Option<String>, 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<bool, String> {
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<bool, String> {
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
}
}
}
@@ -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,
};
@@ -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<Arc<Coordinator>> = OnceLock::new();
static OVERLAY_VISIBLE: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
pub fn register_android_coordinator(coordinator: Arc<Coordinator>) {
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<R>(
env_ptr: *mut JNIEnv,
context: JObject,
f: impl for<'local> FnOnce(&mut JniEnv<'local>, &JObject<'local>) -> Result<R, String>,
) -> Result<R, String> {
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)
});
}
}
}
@@ -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,
},
}
}
}
@@ -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,
}
@@ -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。
@@ -226,18 +226,20 @@ impl DownloadManager {
pub(crate) fn build_client() -> Result<reqwest::Client> {
// 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(
@@ -55,7 +55,10 @@ pub async fn run_test(model_id: ModelId) -> Result<TestResult> {
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}"))?;
@@ -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<String>) -> 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<FoundryLocalRuntime>,
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<SherpaOnnxRuntime>,
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<'_>,
@@ -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<Vec<crate::recorder::MicrophoneDevice>, String> {
Ok(Vec::new())
}
#[tauri::command]
#[cfg(not(mobile))]
pub fn list_microphone_devices() -> Result<Vec<crate::recorder::MicrophoneDevice>, 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::<MicrophoneMonitorState>();
let recorder = state.lock().take();
+31 -7
View File
@@ -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<Coordinator>>;
#[cfg(not(mobile))]
pub type MicrophoneMonitorState = Mutex<Option<Recorder>>;
#[cfg(not(mobile))]
pub type TrayMicrophoneMenuState = Mutex<Vec<TrayMicrophoneMenuItem>>;
#[cfg(not(mobile))]
pub struct TrayMicrophoneMenuItem {
pub id: String,
pub device_name: String,
pub item: tauri::menu::CheckMenuItem<tauri::Wry>,
}
#[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)
@@ -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()
@@ -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
@@ -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<'_>) {
@@ -169,6 +169,7 @@ pub(crate) fn persist_settings<T: SettingsWriter>(
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<String> {
#[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<R: tauri::Runtime>(
coord: CoordinatorState<'_>,
webview: tauri::Webview<R>,
@@ -401,6 +426,7 @@ pub async fn app_check_update_with_channel<R: tauri::Runtime>(
/// 把 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<Vec<url::Url>, String> {
let Some(latest) = fetch_latest_beta_release().await? else {
return Err("尚未发布过 Beta 版本".to_string());
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -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<Inner>) {
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",
@@ -104,14 +104,21 @@ pub(super) fn selected_microphone_device_name(inner: &Arc<Inner>) -> Option<Stri
}
pub(super) fn stop_microphone_preview_monitor(inner: &Arc<Inner>, owner: &str) {
let Some(app) = inner.app.lock().as_ref().cloned() else {
return;
};
let state = app.state::<crate::commands::MicrophoneMonitorState>();
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::<crate::commands::MicrophoneMonitorState>();
let recorder = state.lock().take();
if let Some(recorder) = recorder {
log::info!("[recorder] stopping microphone preview monitor before {owner}");
recorder.stop();
}
}
}
@@ -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())
}
+87 -42
View File
@@ -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,
) -> InsertStatus {
if text.is_empty() {
return InsertStatus::CopiedFallback;
}
if !copy_to_clipboard(text) {
return InsertStatus::Failed;
}
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;
}
// 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 restore_clipboard_after_paste {
schedule_clipboard_restore(restore_plan);
}
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<String>,
}
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
#[derive(Debug, Clone)]
struct PendingClipboardRestore {
latest_restore_id: u64,
original_text: Option<String>,
}
#[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<Mutex<Option<PendingClipboardRestore>>> =
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<ClipboardRestorePlan, String> {
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<ClipboardRestorePla
})
}
#[cfg(not(target_os = "macos"))]
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
fn insert_with_clipboard_restore(
text: &str,
restore_clipboard_after_paste: bool,
@@ -239,6 +279,7 @@ fn insert_with_clipboard_restore(
insertion_success_status()
}
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
fn schedule_clipboard_restore(plan: ClipboardRestorePlan) {
let (restore_id, original_text) =
remember_pending_clipboard_restore(plan.previous_text.clone());
@@ -247,6 +288,7 @@ fn schedule_clipboard_restore(plan: ClipboardRestorePlan) {
});
}
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
fn remember_pending_clipboard_restore(previous_text: Option<String>) -> (u64, Option<String>) {
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<String>) -> (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<String>,
@@ -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>, enigo::Key) {
use enigo::Key;
match shortcut {
@@ -354,7 +400,7 @@ fn paste_keys(shortcut: PasteShortcut) -> (Vec<enigo::Key>, 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]
+382 -306
View File
@@ -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<tauri::Wry>,
items: Vec<commands::TrayMicrophoneMenuItem>,
}
#[cfg(not(mobile))]
struct StyleTrayMenu {
submenu: Submenu<tauri::Wry>,
}
#[cfg(not(mobile))]
struct TrayMenu {
menu: Menu<tauri::Wry>,
microphone_items: Vec<commands::TrayMicrophoneMenuItem>,
}
#[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<TrayPolishModeMenuEntry> {
[
(PolishMode::Raw, "style-raw"),
@@ -582,6 +735,7 @@ fn tray_polish_mode_menu_entries(selected: PolishMode) -> Vec<TrayPolishModeMenu
.collect()
}
#[cfg(not(mobile))]
fn parse_tray_polish_mode_id(id: &str) -> Option<PolishMode> {
match id {
"style-raw" => Some(PolishMode::Raw),
@@ -592,6 +746,7 @@ fn parse_tray_polish_mode_id(id: &str) -> Option<PolishMode> {
}
}
#[cfg(not(mobile))]
fn build_tray_menu<M: Manager<tauri::Wry>>(
app: &M,
coordinator: &Arc<coordinator::Coordinator>,
@@ -617,6 +772,7 @@ fn build_tray_menu<M: Manager<tauri::Wry>>(
})
}
#[cfg(not(mobile))]
fn build_style_tray_menu<M: Manager<tauri::Wry>>(
app: &M,
coordinator: &Arc<coordinator::Coordinator>,
@@ -639,6 +795,7 @@ fn build_style_tray_menu<M: Manager<tauri::Wry>>(
})
}
#[cfg(not(mobile))]
fn build_microphone_tray_menu<M: Manager<tauri::Wry>>(
app: &M,
coordinator: &Arc<coordinator::Coordinator>,
@@ -697,6 +854,7 @@ fn build_microphone_tray_menu<M: Manager<tauri::Wry>>(
})
}
#[cfg(not(mobile))]
pub(crate) fn refresh_tray_microphone_menu(app: &AppHandle) -> tauri::Result<()> {
let coordinator = app.state::<Arc<coordinator::Coordinator>>();
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<Vec<(String, bool)>> {
match recorder::list_input_devices() {
Ok(devices) => Some(
@@ -723,6 +882,7 @@ fn microphone_device_signature() -> Option<Vec<(String, bool)>> {
}
}
#[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::<commands::TrayMicrophoneMenuState>();
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<R: Runtime>(app: &AppHandle<R>) {
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<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> tauri::Result<()> {
@@ -1269,21 +1414,36 @@ fn position_qa_window<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> 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<R: tauri::Runtime>(app: &AppHandle<R>, 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<R: tauri::Runtime>(app: &AppHandle<R>, 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<R: tauri::Runtime>(window: &tauri::WebviewWind
/// 隐藏 QA 窗口。供 commands::qa_window_dismiss / coordinator session 收尾共用。
pub(crate) fn hide_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) {
#[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<R: tauri::Runtime>(app: &AppHandle<R>) {
.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<R: tauri::Runtime>(app: &AppHandle<R>) {
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<ForegroundMonitor> {
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<R: tauri::Runtime>(
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()));
@@ -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::<Arc<Coordinator>>();
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() {}
@@ -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<ComboHotkeyEvent>,
) -> Result<Self, ComboHotkeyError> {
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(),
))
}
@@ -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<HotkeyEvent>,
) -> Result<Self, HotkeyInstallError> {
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<HotkeyTrigger>,
_translation_trigger: Option<HotkeyTrigger>,
) {
}
pub fn kind(&self) -> HotkeyAdapterKind {
HotkeyAdapterKind::Unavailable
}
pub fn reset_held_state(&self) {}
pub fn capability() -> HotkeyCapability {
HotkeyCapability::current()
}
}
@@ -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<QaHotkeyEvent>,
) -> Result<Self, QaHotkeyError> {
Err(QaHotkeyError::RegisterFailed(
"QA hotkeys are not available on mobile".into(),
))
}
pub fn update_binding(&self, _binding: ShortcutBinding) -> Result<(), QaHotkeyError> {
Ok(())
}
}
@@ -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<String>,
}
#[cfg(target_os = "android")]
pub fn capture_selection() -> Option<SelectionContext> {
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<SelectionContext> {
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}")
}
@@ -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<HotkeyTrigger> {
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(),
}
}
@@ -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<TypeError>,
},
#[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<R: Runtime>(_app: &AppHandle<R>) -> Result<Option<()>, TypeError> {
Err(TypeError::Unavailable)
}
pub async fn restore_input_source<R: Runtime>(
_app: &AppHandle<R>,
_previous: Option<()>,
) -> Result<(), TypeError> {
Ok(())
}
pub fn type_unicode_chunk(_text: &str) -> Result<usize, TypeError> {
Err(TypeError::Unavailable)
}
+47 -2
View File
@@ -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};
File diff suppressed because it is too large. Load diff
+46 -7
View File
@@ -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>,
@@ -299,30 +299,47 @@ fn build_router(state: Arc<WsState>) -> 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<WsState>) -> Router {
"/cert.cer",
get(|State(state): State<Arc<WsState>>| 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<WsState>, 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<WsState>, 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;
}
}
+187 -47
View File
@@ -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<ShortcutBinding>,
/// 局域网远程输入服务开关。桌面端启动 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<ShortcutBinding>,
#[serde(default)]
coding_agent_quick_hotkey: Option<ShortcutBinding>,
#[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<String>,
}
#[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 {
@@ -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
}
]
}
}
@@ -128,6 +128,9 @@
"infoPlist": "Info.plist",
"entitlements": "Entitlements.plist"
},
"android": {
"minSdkVersion": 26
},
"windows": {
"nsis": {
"installMode": "perMachine",
+115 -22
View File
@@ -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<Gate>('ready');
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(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 (
<HotkeySettingsProvider>
{gate === 'onboarding' ? <Onboarding onComplete={() => setGate('ready')} /> : <FloatingShell os={os} />}
{gate === 'ready' && <AutoUpdateGate />}
{platformCaps?.platform === 'android' && (
<div style={{ display: mobileQaOpen ? 'block' : 'none', height: '100%' }}>
<QaPanel
embedded
onRequestClose={() => {
setMobileQaOpen(false);
if (window.history.state?.openlessQa === true) {
window.history.back();
}
}}
/>
</div>
)}
{!mobileQaOpen && (gate === 'onboarding' ? (
<Onboarding onComplete={completeOnboarding} />
) : (
<FloatingShell os={os} />
))}
{gate === 'ready' && platformCaps?.supportsAutoUpdate === true && <AutoUpdateGate />}
</HotkeySettingsProvider>
);
}
+11 -10
View File
@@ -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<boolean>(true);
const prevStateRef = useRef<CapsuleState>('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;
}
+7 -16
View File
@@ -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<string, unknown>;
}
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<AppUpdateMetadata | null>('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;
@@ -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<PlatformCapabilities | null>(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 (
<UpdateDialog
+306 -83
View File
@@ -26,13 +26,14 @@ import {
shouldShowHotkeyModeMigrationPrompt,
} from '../lib/hotkeyMigration';
import { applyFontScale, readFontScale } from '../lib/fontScale';
import { getCredentials } from '../lib/ipc';
import { getCredentials, getPlatformCapabilities } from '../lib/ipc';
import {
PROVIDER_SETUP_PROMPT_DEFERRED_KEY,
shouldShowProviderSetupPrompt,
} from '../lib/providerSetup';
import { type SettingsSectionId } from './SettingsModal';
import { useAppState, type AppTab } from '../state/useAppState';
import { useMobileLayout } from '../lib/useMobileLayout';
interface NavItem {
id: AppTab;
@@ -71,6 +72,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
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<Array<HTMLButtonElement | null>>([]);
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 (
<div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: os === 'mac' ? 28 : 0 }}>
<div
className="ol-app-shell-bg"
style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0, paddingTop: os === 'mac' ? 28 : 0 }}
>
{/* Main shell — flush with the frosted backplate (no separate float). */}
<div
style={{
flex: 1, minHeight: 0,
display: 'flex',
flexDirection: mobileLayout ? 'column' : 'row',
background: 'transparent',
overflow: 'hidden',
position: 'relative',
zIndex: 1,
}}>
{/* Sidebar — 透明地坐在外层磨砂底板上,让 LOGO/导航/快捷键/BETA/footer 共用同一片磨砂玻璃 */}
<aside
style={{
width: 188,
flexShrink: 0,
display: 'flex', flexDirection: 'column',
background: 'transparent',
padding: '10px 10px 12px',
}}>
{mobileLayout ? (
<div className="ol-aura-mobile-topbar">
<div className="ol-aura-mobile-brand">
<img
className="ol-aura-mobile-brand-mark"
src="AppIcon.png"
alt="OpenLess"
/>
<div style={{ minWidth: 0 }}>
<div className="ol-aura-mobile-brand-title">OpenLess</div>
<div className="ol-aura-mobile-brand-section">{activeNav.name}</div>
</div>
</div>
<button
type="button"
onClick={() => openSettings()}
className={settingsOpen ? 'ol-aura-mobile-settings ol-aura-mobile-settings-active' : 'ol-aura-mobile-settings'}
aria-label={t('shell.footer.settings')}
>
<Icon name="settings" size={17} />
</button>
</div>
) : (
/* Sidebar — 透明地坐在外层磨砂底板上,让 LOGO/导航/快捷键/BETA/footer 共用同一片磨砂玻璃 */
<aside
className="ol-aura-sidebar"
style={{
width: 196,
flexShrink: 0,
display: 'flex',
flexDirection: 'column',
}}>
{/* brand */}
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '2px 8px 12px' }}>
<div className="ol-aura-sidebar-brand">
<img
className="ol-aura-sidebar-brand-mark"
src="AppIcon.png"
alt="OpenLess"
style={{ width: 22, height: 22, borderRadius: 5, boxShadow: '0 1px 2px rgba(0,0,0,.1), 0 0 0 0.5px rgba(0,0,0,.06)' }} />
/>
<div style={{ fontSize: 13.5, fontWeight: 600, letterSpacing: '-0.01em', color: 'var(--ol-ink)' }}>OpenLess</div>
<div>
<div className="ol-aura-sidebar-brand-title">OpenLess</div>
<div className="ol-aura-sidebar-brand-kicker">VOICE CONSOLE</div>
</div>
</div>
{/* nav — 滑动指示器:active pill 是 absolute 元素,currentTab 改变时 top/height
@@ -220,6 +267,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
<nav style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
{pillRect && (
<div
className="ol-aura-sidebar-pill"
aria-hidden
style={{
position: 'absolute',
@@ -227,9 +275,6 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
right: 0,
top: pillRect.top,
height: pillRect.height,
background: 'var(--ol-surface)',
borderRadius: 8,
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
@@ -243,18 +288,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
key={n.id}
ref={el => { navItemRefs.current[i] = el; }}
onClick={() => setCurrentTab(n.id)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
className={active ? 'ol-nav-btn ol-nav-btn-active ol-aura-sidebar-nav-btn' : 'ol-nav-btn ol-aura-sidebar-nav-btn'}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: 'transparent',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default',
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
textAlign: 'left',
position: 'relative',
zIndex: 1,
}}>
<Icon name={n.icon} size={14} />
@@ -269,31 +305,10 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
{/* 底部两行:上行 = 版本 chip(含 BETA 标),下行 = 设置按钮。
单行布局在窄 sidebar 下会把「设置」挤成两行竖字 + 版本糊一起;
翻回两行同时把顺序反过来:设置真正落到最底,版本在它上面。 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 10 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
flexWrap: 'wrap',
padding: '0 10px',
fontFamily: 'var(--ol-font-sans)',
fontSize: 11,
color: 'var(--ol-ink-4)',
}}
>
<div className="ol-aura-sidebar-footer">
<div className="ol-aura-sidebar-version">
{IS_BETA_BUILD && (
<span style={{
display: 'inline-block',
padding: '2px 8px',
fontSize: 10,
fontWeight: 600,
letterSpacing: '0.04em',
textTransform: 'uppercase',
color: 'var(--ol-blue)',
background: 'rgba(37,99,235,0.10)',
borderRadius: 999,
}}>{t('shell.betaTag')}</span>
<span className="ol-aura-beta-tag">{t('shell.betaTag')}</span>
)}
<span>{t('shell.footer.version', { version: APP_VERSION_LABEL })}</span>
@@ -301,39 +316,33 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
<button
onClick={() => openSettings()}
className={settingsOpen ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
className={settingsOpen ? 'ol-nav-btn ol-nav-btn-active ol-aura-sidebar-settings' : 'ol-nav-btn ol-aura-sidebar-settings'}
style={{
display: 'flex', alignItems: 'center', gap: 10,
padding: '7px 10px',
borderRadius: 8, border: 0,
background: settingsOpen ? 'var(--ol-surface)' : 'transparent',
boxShadow: settingsOpen ? '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)' : 'none',
fontFamily: 'inherit', fontSize: 13,
cursor: 'default',
transition: 'color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick)',
textAlign: 'left',
}}
>
<Icon name="settings" size={14} />
<span style={{ flex: 1 }}>{t('shell.footer.settings')}</span>
</button>
</div>
</aside>
</aside>
)}
{/* Main content — Linux 禁用透明窗口后使用不透明面;其他平台保留玻璃层。
悬浮台到右边 / 下边的间距相等(都 8px),左侧贴 sidebar(0)。 */}
<div style={{ flex: 1, minWidth: 0, padding: '4px 8px 8px 0', display: 'flex' }}>
{/* Main content — Linux 禁用透明窗口后使用不透明面;其他平台保留玻璃层。 */}
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
padding: mobileLayout ? '0' : '4px 8px 6px 0',
display: 'flex',
}}
>
<main
className="ol-console-main"
className="ol-console-main ol-aura-panel ol-aura-console-main"
style={{
flex: 1, minWidth: 0,
overflow: 'hidden',
background: os === 'linux' ? 'var(--ol-surface)' : 'rgba(255, 255, 255, 0.62)',
backdropFilter: os === 'linux' ? 'none' : 'blur(18px) saturate(170%)',
WebkitBackdropFilter: os === 'linux' ? 'none' : 'blur(18px) saturate(170%)',
borderRadius: 'var(--ol-window-console-radius)',
border: '0.5px solid rgba(0,0,0,0.06)',
boxShadow: '0 1px 0 rgba(255,255,255,0.8) inset, 0 8px 24px -12px rgba(15,17,22,0.10), 0 2px 6px -2px rgba(15,17,22,0.06)',
display: 'flex',
flexDirection: 'column',
}}
@@ -358,7 +367,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
style={{
flex: 1, minHeight: 0,
overflow: 'auto',
padding: '24px 28px 32px',
padding: mobileLayout
? '16px 14px 18px'
: '24px 28px 32px',
// position:relative 让页面里的"已保存"toast 用 absolute top:16 right:16
// 锚到这块控制台卡的右上角,而不是横在页头变成长横幅。
position: 'relative',
@@ -379,6 +390,25 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
</div>
</main>
</div>
{mobileLayout && (
<nav className="ol-aura-mobile-nav" aria-label="OpenLess">
{NAV.map(n => {
const active = !settingsOpen && currentTab === n.id;
return (
<button
key={n.id}
type="button"
onClick={() => setCurrentTab(n.id)}
className={active ? 'ol-aura-mobile-nav-btn ol-aura-mobile-nav-btn-active' : 'ol-aura-mobile-nav-btn'}
>
<Icon name={n.icon} size={18} />
<span>{n.name}</span>
</button>
);
})}
</nav>
)}
</div>
{/* 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,
+444 -77
View File
@@ -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<PlatformCapabilities | null>(null);
useEffect(() => {
void getPlatformCapabilities().then(setPlatformCaps);
}, []);
if (!platformCaps) {
return <OnboardingLoading label={t('common.loading')} />;
}
if (platformCaps.platform === 'android') {
return <AndroidOnboarding onComplete={onComplete} />;
}
return <DesktopOnboarding onComplete={onComplete} platformCaps={platformCaps} />;
}
function AndroidOnboarding({ onComplete }: OnboardingProps) {
const { t } = useTranslation();
const [stepIndex, setStepIndex] = useState(0);
const steps = useMemo<Array<{ id: AndroidStepId; title: string; desc: string }>>(
() => [
{
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 (
<OnboardingSurface>
<div
style={{
width: '100%',
maxWidth: 560,
minHeight: '100%',
display: 'flex',
flexDirection: 'column',
gap: 14,
}}
>
<BrandHeader
title={t('onboarding.androidTitle')}
desc={t('onboarding.androidIntro')}
compact
/>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(${steps.length}, 1fr)`,
gap: 5,
padding: '0 2px',
}}
aria-hidden
>
{steps.map((step, index) => (
<div
key={step.id}
style={{
height: 4,
borderRadius: 999,
background: index <= stepIndex ? 'var(--ol-blue)' : 'var(--ol-line-soft)',
}}
/>
))}
</div>
<div
style={{
background: 'var(--ol-surface)',
border: '0.5px solid var(--ol-line)',
borderRadius: 14,
boxShadow: 'var(--ol-shadow-lg)',
padding: 18,
display: 'flex',
flexDirection: 'column',
gap: 14,
minWidth: 0,
}}
>
<div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-4)', marginBottom: 4 }}>
{t('onboarding.androidStepCounter', { current: stepIndex + 1, total: steps.length })}
</div>
<div style={{ fontSize: 17, fontWeight: 650 }}>{current.title}</div>
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.55, marginTop: 5 }}>
{current.desc}
</div>
</div>
<AndroidStepContent step={current.id} />
</div>
<div style={{ display: 'flex', gap: 10, width: '100%' }}>
<button
type="button"
onClick={() => setStepIndex((value) => Math.max(value - 1, 0))}
disabled={isFirst}
style={{
...secondaryButtonStyle,
opacity: isFirst ? 0.45 : 1,
}}
>
{t('onboarding.androidBack')}
</button>
<button type="button" onClick={goNext} style={primaryButtonStyle}>
{isLast ? t('onboarding.androidFinish') : t('onboarding.androidNext')}
</button>
</div>
<button type="button" onClick={onComplete} style={plainButtonStyle}>
{t('onboarding.androidContinue')}
</button>
</div>
</OnboardingSurface>
);
}
function AndroidStepContent({ step }: { step: AndroidStepId }) {
if (step === 'microphone') {
return <AndroidMicrophoneStep />;
}
if (step === 'accessibility') {
return <AndroidStepCard><AndroidPermissionsPanel mode="accessibility" /></AndroidStepCard>;
}
if (step === 'overlayPermission') {
return <AndroidStepCard><AndroidPermissionsPanel mode="overlayPermission" /></AndroidStepCard>;
}
if (step === 'overlayConfig') {
return <AndroidStepCard><AndroidPermissionsPanel mode="overlayConfig" /></AndroidStepCard>;
}
if (step === 'asr') {
return <ProvidersSection kind="asr" />;
}
return <ProvidersSection kind="llm" />;
}
function AndroidMicrophoneStep() {
const { t } = useTranslation();
const [status, setStatus] = useState<PermissionStatus>('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 (
<AndroidStepCard>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
<div>
<div style={{ fontSize: 13, fontWeight: 600 }}>{t('onboarding.micTitle')}</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.5, marginTop: 4 }}>
{t('onboarding.micDesc')}
</div>
</div>
<StatusBadge granted={granted} label={granted ? t('settings.permissions.granted') : t('settings.permissions.denied')} />
</div>
<button
type="button"
onClick={request}
disabled={busy || granted}
style={{
...primaryButtonStyle,
width: '100%',
opacity: busy || granted ? 0.55 : 1,
}}
>
{granted ? t('onboarding.actionGranted') : t('onboarding.actionRequestMic')}
</button>
</AndroidStepCard>
);
}
function DesktopOnboarding({
onComplete,
platformCaps: _platformCaps,
}: OnboardingProps & { platformCaps: PlatformCapabilities }) {
const { t } = useTranslation();
const [accessibility, setAccessibility] = useState<PermissionStatus>('notDetermined');
const [microphone, setMicrophone] = useState<PermissionStatus>('notDetermined');
@@ -28,6 +282,8 @@ export function Onboarding({ onComplete }: OnboardingProps) {
const refreshTimeoutRef = useRef<number | null>(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 (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 40,
fontFamily: 'var(--ol-font-sans)',
}}
>
<OnboardingSurface>
<div
style={{
width: 520,
width: 'min(520px, 100%)',
padding: 32,
boxSizing: 'border-box',
background: 'var(--ol-surface)',
borderRadius: 14,
border: '0.5px solid var(--ol-line)',
boxShadow: 'var(--ol-shadow-lg)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 18 }}>
<div
style={{
width: 52,
height: 52,
borderRadius: 13,
background: 'linear-gradient(135deg, #0a0a0b 0%, #2563eb 100%)',
color: '#fff',
fontSize: 22,
fontWeight: 700,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
OL
</div>
<div>
<div style={{ fontSize: 18, fontWeight: 600 }}>{t('onboarding.welcome')}</div>
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', marginTop: 2 }}>
{t('onboarding.intro')}
</div>
</div>
</div>
<BrandHeader title={t('onboarding.welcome')} desc={t('onboarding.intro')} />
{requiresAccessibility && (
<PermissionStep
index={1}
title={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityTitle') : t('onboarding.hotkeyTitle')}
desc={capability?.requiresAccessibilityPermission
? t('onboarding.accessibilityDesc', { trigger: getHotkeyTriggerLabel(capability.availableTriggers[0]) })
: capability?.statusHint ?? t('onboarding.hotkeyDesc')}
status={accessibility}
actionLabel={
!capability?.requiresAccessibilityPermission || accessibility === 'notApplicable'
? t('onboarding.actionNotApplicable')
: accessibility === 'granted'
? t('onboarding.actionGranted')
: accessibility === 'denied'
? t('onboarding.actionOpenSystem')
: t('onboarding.actionGrant')
}
onAction={onGrantAccessibility}
disabled={busy || !capability?.requiresAccessibilityPermission || accessibility === 'granted' || accessibility === 'notApplicable'}
hint={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityHint') : undefined}
/>
)}
<PermissionStep
index={1}
title={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityTitle') : t('onboarding.hotkeyTitle')}
desc={capability?.requiresAccessibilityPermission
? t('onboarding.accessibilityDesc', { trigger: getHotkeyTriggerLabel(capability.availableTriggers[0]) })
: capability?.statusHint ?? t('onboarding.hotkeyDesc')}
status={accessibility}
actionLabel={
!capability?.requiresAccessibilityPermission || accessibility === 'notApplicable'
? t('onboarding.actionNotApplicable')
: accessibility === 'granted'
? t('onboarding.actionGranted')
: accessibility === 'denied'
? t('onboarding.actionOpenSystem')
: t('onboarding.actionGrant')
}
onAction={onGrantAccessibility}
disabled={busy || !capability?.requiresAccessibilityPermission || accessibility === 'granted' || accessibility === 'notApplicable'}
hint={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityHint') : undefined}
/>
<PermissionStep
index={2}
index={requiresAccessibility ? 2 : 1}
title={t('onboarding.micTitle')}
desc={t('onboarding.micDesc')}
status={microphone}
@@ -165,24 +393,112 @@ export function Onboarding({ onComplete }: OnboardingProps) {
disabled={busy || microphone === 'granted'}
/>
<div
style={{
marginTop: 18,
padding: '12px 14px',
borderRadius: 8,
background: 'var(--ol-surface-2)',
fontSize: 11.5,
color: 'var(--ol-ink-3)',
lineHeight: 1.6,
}}
>
<div style={footerHintStyle}>
{t('onboarding.footerHint')}
</div>
</div>
</OnboardingSurface>
);
}
function OnboardingLoading({ label }: { label: string }) {
return (
<div
style={{
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontFamily: 'var(--ol-font-sans)',
color: 'var(--ol-ink-3)',
fontSize: 13,
}}
>
{label}
</div>
);
}
function OnboardingSurface({ children }: { children: ReactNode }) {
return (
<div
style={{
flex: 1,
minHeight: 0,
overflow: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 'calc(18px + env(safe-area-inset-top, 0px)) 16px calc(18px + env(safe-area-inset-bottom, 0px))',
boxSizing: 'border-box',
fontFamily: 'var(--ol-font-sans)',
}}
>
{children}
</div>
);
}
function BrandHeader({ title, desc, compact = false }: { title: string; desc: string; compact?: boolean }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: compact ? 12 : 14, marginBottom: compact ? 4 : 18 }}>
<img
src="AppIcon.png"
alt="OpenLess"
style={{
width: compact ? 48 : 52,
height: compact ? 48 : 52,
borderRadius: compact ? 12 : 13,
flexShrink: 0,
}}
/>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: compact ? 17 : 18, fontWeight: 650 }}>{title}</div>
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', marginTop: 2, lineHeight: 1.45 }}>
{desc}
</div>
</div>
</div>
);
}
function AndroidStepCard({ children }: { children: ReactNode }) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: 12,
padding: 14,
borderRadius: 10,
background: 'var(--ol-surface-2)',
border: '0.5px solid var(--ol-line-soft)',
minWidth: 0,
}}
>
{children}
</div>
);
}
function StatusBadge({ granted, label }: { granted: boolean; label: string }) {
return (
<span
style={{
flexShrink: 0,
fontSize: 11,
fontWeight: 600,
borderRadius: 999,
padding: '4px 8px',
color: granted ? 'var(--ol-ok)' : 'var(--ol-ink-4)',
background: granted ? 'rgba(40, 160, 90, 0.12)' : 'rgba(0,0,0,0.06)',
}}
>
{label}
</span>
);
}
interface StepProps {
index: number;
title: string;
@@ -255,3 +571,54 @@ function PermissionStep({ index, title, desc, status, actionLabel, onAction, dis
</div>
);
}
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;
+129 -71
View File
@@ -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<SettingsSectionId>(initialSettingsSection ?? 'general');
const savedToast = useSavedToastListener();
const mobile = useMobileLayout();
// 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill。
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
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 (
<div
onClick={onClose}
style={{
position: 'fixed', inset: 0,
background: 'rgba(15,17,22,0.32)',
position: 'absolute', inset: 0,
background: 'var(--ol-overlay-bg)',
backdropFilter: 'blur(8px) saturate(140%)',
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
padding: 28,
padding: mobile ? 0 : 28,
zIndex: 50,
animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
}}>
<div
className="ol-aura-settings"
onClick={(e) => 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',
}}>
{/* ─── 单层侧栏 ────────────────────────────────────────────── */}
<aside
className="ol-aura-settings-rail"
style={{
width: 200, flexShrink: 0,
background: 'rgba(247,247,250,0.7)',
borderRight: '0.5px solid var(--ol-line-soft)',
padding: '18px 12px',
display: 'flex', flexDirection: 'column', gap: 14,
width: mobile ? '100%' : 214,
flexShrink: 0,
display: 'flex',
flexDirection: mobile ? 'row' : 'column',
}}>
{/* tab 组 */}
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
<div
className="ol-thinscroll"
style={{
position: 'relative',
display: 'flex',
flexDirection: mobile ? 'row' : 'column',
gap: 1,
minWidth: 0,
flex: mobile ? '1 1 auto' : undefined,
overflowX: mobile ? 'auto' : undefined,
}}
>
{pillRect && (
<div
className="ol-aura-settings-pill"
aria-hidden
style={{
position: 'absolute',
@@ -122,9 +126,6 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
right: 0,
top: pillRect.top,
height: pillRect.height,
background: '#fff',
borderRadius: 8,
boxShadow: '0 1px 2px rgba(0,0,0,.05), 0 0 0 0.5px rgba(0,0,0,.06)',
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
pointerEvents: 'none',
zIndex: 0,
@@ -138,35 +139,30 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
key={it.id}
ref={el => { tabRefs.current[idx] = el; }}
onClick={() => setSection(it.id as SettingsSectionId)}
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
style={navBtnStyle}>
className={active ? 'ol-nav-btn ol-nav-btn-active ol-aura-settings-nav-btn' : 'ol-nav-btn ol-aura-settings-nav-btn'}
style={{ ...navBtnStyle, flexShrink: 0 }}>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
</button>
);
})}
</div>
{/* 外链组 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 1, paddingTop: 8, borderTop: '0.5px solid var(--ol-line-soft)' }}>
{LINK_ITEMS.map(it => (
<button
key={it.id}
onClick={() => { if (it.href) void openExternal(it.href); }}
className="ol-nav-btn"
style={navBtnStyle}>
<Icon name={it.icon} size={14} />
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
<Icon name="external" size={11} />
</button>
))}
</div>
</aside>
{/* ─── 内容区 ──────────────────────────────────────────────
父容器 overflow:hidden + 列向 flex;关闭按钮、section 标题固定在头部,
只有最里层的 scroll wrapper 真正滚动。 */}
<div style={{ flex: 1, minWidth: 0, overflow: 'hidden', position: 'relative', display: 'flex', flexDirection: 'column' }}>
<div
className="ol-aura-settings-content"
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
position: 'relative',
display: 'flex',
flexDirection: 'column',
}}
>
{/* "已保存" toast:right:54 避开 28×28 关闭按钮 + 12px gap。 */}
<SavedToast
saveState={savedToast.state}
@@ -175,28 +171,32 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
offsetStyle={{ position: 'absolute', top: 16, right: 54 }}
/>
<button
className="ol-aura-settings-close"
onClick={onClose}
style={{
position: 'absolute', top: 14, right: 14, zIndex: 2,
width: 28, height: 28, border: 0, borderRadius: 999,
background: 'transparent', color: 'var(--ol-ink-3)',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick)',
}}
onMouseEnter={e => (e.currentTarget.style.background = 'rgba(0,0,0,0.05)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
title={t('common.close')}>
<Icon name="close" size={14} />
</button>
<h2 style={{ margin: 0, padding: '22px 28px 8px', fontSize: 22, fontWeight: 600, letterSpacing: '-0.02em', flexShrink: 0 }}>
<h2 className="ol-aura-settings-title" style={{ margin: 0, flexShrink: 0 }}>
{t(`modal.sections.${section}`)}
</h2>
<div
className="ol-thinscroll"
style={{ flex: 1, minHeight: 0, overflow: 'auto', padding: '10px 28px 28px' }}>
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 轻微淡入。 */}
<div
key={section}
@@ -210,19 +210,77 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
</div>
</div>
</div>
</div>,
document.body,
<style>{`
.ol-aura-settings {
background: var(--ol-panel-bg);
border-radius: ${mobile ? '0' : 'var(--ol-shell-radius)'};
border: 1px solid var(--ol-panel-border);
box-shadow: var(--ol-panel-shadow);
}
.ol-aura-settings-rail {
padding: ${mobile ? 'calc(10px + env(safe-area-inset-top, 0px)) 10px 8px' : '20px 14px'};
gap: ${mobile ? '8px' : '16px'};
background: var(--ol-settings-rail-bg);
border-right: ${mobile ? '0' : '1px solid var(--ol-settings-rail-border)'};
border-bottom: ${mobile ? '1px solid var(--ol-settings-rail-border)' : '0'};
}
.ol-aura-settings-pill {
background: var(--ol-sidebar-pill-bg);
border-radius: 12px;
border: 1px solid var(--ol-sidebar-pill-border);
box-shadow: none;
}
.ol-aura-settings-nav-btn {
padding: ${mobile ? '8px 11px' : '7px 10px'};
border-radius: 12px;
border: 0;
background: transparent;
font-family: inherit;
font-size: 13px;
cursor: default;
text-align: left;
position: relative;
z-index: 1;
transition: color 0.16s var(--ol-motion-quick), background 0.16s var(--ol-motion-quick);
}
.ol-aura-settings-nav-btn.ol-nav-btn-active {
background: ${mobile ? 'var(--ol-sidebar-pill-bg)' : 'transparent'};
border: ${mobile ? '1px solid var(--ol-sidebar-pill-border)' : '0'};
}
.ol-aura-settings-links {
display: flex;
flex-direction: ${mobile ? 'row' : 'column'};
gap: 1px;
padding-top: ${mobile ? '0' : '10px'};
padding-left: ${mobile ? '8px' : '0'};
border-top: ${mobile ? '0' : '1px solid var(--ol-settings-links-border)'};
border-left: ${mobile ? '1px solid var(--ol-settings-links-border)' : '0'};
overflow-x: ${mobile ? 'auto' : 'visible'};
}
.ol-aura-settings-content {
background: var(--ol-settings-content-bg);
}
.ol-aura-settings-close {
background: var(--ol-settings-close-bg);
color: var(--ol-ink-3);
transition: background 0.16s var(--ol-motion-quick);
}
.ol-aura-settings-close:hover {
background: var(--ol-settings-close-hover-bg);
}
.ol-aura-settings-title {
padding: ${mobile ? '16px 48px 8px 16px' : '24px 28px 10px'};
font-size: ${mobile ? '20px' : '22px'};
font-weight: 600;
letter-spacing: -0.02em;
font-family: var(--ol-font-display);
color: var(--ol-ink);
}
`}</style>
</div>
);
}
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)',
};
@@ -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。
+5 -3
View File
@@ -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 (
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 16, padding: '12px 0', borderTop: '0.5px solid var(--ol-line-soft)' }}>
<div>
<div style={{ display: 'grid', gridTemplateColumns: mobile ? 'minmax(0, 1fr)' : '180px 1fr', gap: mobile ? 8 : 16, padding: '12px 0', borderTop: '0.5px solid var(--ol-line-soft)' }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--ol-ink)' }}>{label}</div>
{desc && <div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 4, lineHeight: 1.5 }}>{desc}</div>}
</div>
<div style={{ display: 'flex', alignItems: 'center' }}>{children}</div>
<div style={{ display: 'flex', alignItems: 'center', minWidth: 0, width: '100%', maxWidth: '100%', flexWrap: mobile ? 'wrap' : 'nowrap', gap: mobile ? 6 : undefined }}>{children}</div>
</div>
);
}
@@ -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',
};
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more