feat(android): port Android APK runtime onto beta
No files matched your search
@@ -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 }}
|
||||
@@ -11,6 +11,7 @@ on:
|
||||
branches: [main, beta]
|
||||
pull_request:
|
||||
branches: [main, beta]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cross-platform:
|
||||
|
||||
@@ -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/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
|
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);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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%" />
|
||||
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 9.2 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 54 KiB |
|
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();
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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。
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||