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
|
# - push v*-tauri tag → build debug APK, upload artifact + attach to GitHub Release
|
||||||
# - workflow_dispatch → build debug APK, upload artifact only (no release)
|
# - workflow_dispatch → build debug APK, upload artifact only (no release)
|
||||||
#
|
#
|
||||||
# v1 scope: in-app dictation APK with RECORD_AUDIO merged from android-scaffolding.
|
# Scope: full overlay/accessibility APK for ADB testing (v1 RECORD_AUDIO + overlay manifest merge).
|
||||||
# IME v2 / overlay v3 manifest snippets are intentionally out of scope.
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -33,6 +32,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Setup Android SDK
|
- name: Setup Android SDK
|
||||||
uses: android-actions/setup-android@v3
|
uses: android-actions/setup-android@v3
|
||||||
|
with:
|
||||||
|
packages: platform-tools
|
||||||
|
|
||||||
- name: Install NDK and accept SDK licenses
|
- name: Install NDK and accept SDK licenses
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -88,10 +89,18 @@ jobs:
|
|||||||
working-directory: openless-all/app
|
working-directory: openless-all/app
|
||||||
run: npm run tauri -- android init --ci
|
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
|
working-directory: openless-all/app
|
||||||
run: node scripts/merge-android-v1-manifest.mjs
|
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
|
- name: Prime Gradle wrapper
|
||||||
working-directory: openless-all/app
|
working-directory: openless-all/app
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -111,33 +120,114 @@ jobs:
|
|||||||
working-directory: openless-all/app
|
working-directory: openless-all/app
|
||||||
run: npm run tauri:android:build
|
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
|
id: apk
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: openless-all/app
|
working-directory: openless-all/app
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
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
|
if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then
|
||||||
label="${{ github.ref_name }}"
|
label="${{ github.ref_name }}"
|
||||||
else
|
else
|
||||||
label="run-${{ github.run_number }}"
|
label="run-${{ github.run_number }}"
|
||||||
fi
|
fi
|
||||||
dest="$RUNNER_TEMP/OpenLess-android-debug-${label}.apk"
|
export OPENLESS_APK_LABEL="$label"
|
||||||
cp "$apk_path" "$dest"
|
python - <<'PY'
|
||||||
echo "path=$dest" >> "$GITHUB_OUTPUT"
|
import os
|
||||||
echo "Collected APK: $apk_path → $dest"
|
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
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: openless-android-debug
|
name: openless-android-debug-arm64-v8a
|
||||||
path: ${{ steps.apk.outputs.path }}
|
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
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: Attach APK to GitHub Release
|
- name: Attach APK to GitHub Release
|
||||||
@@ -148,4 +238,4 @@ jobs:
|
|||||||
name: OpenLess ${{ github.ref_name }}
|
name: OpenLess ${{ github.ref_name }}
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: ${{ endsWith(github.ref_name, '-beta-tauri') }}
|
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]
|
branches: [main, beta]
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main, beta]
|
branches: [main, beta]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cross-platform:
|
cross-platform:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ Package.resolved
|
|||||||
xcuserdata/
|
xcuserdata/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
ChatGPT Image*.jpg
|
ChatGPT Image*.jpg
|
||||||
|
.cursor
|
||||||
# Local config / secrets
|
# Local config / secrets
|
||||||
*.env
|
*.env
|
||||||
config.local.json
|
config.local.json
|
||||||
@@ -33,6 +33,28 @@ video-materials/
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
target/
|
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/registry/
|
||||||
.cargo/git/
|
.cargo/git/
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,12 @@ Go to [Releases](../../releases) and download:
|
|||||||
xattr -cr /Applications/OpenLess.app
|
xattr -cr /Applications/OpenLess.app
|
||||||
```
|
```
|
||||||
- **Windows**: `OpenLess_<version>_x64-setup.exe` — run the installer.
|
- **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)**:
|
- **macOS (Homebrew)**:
|
||||||
```bash
|
```bash
|
||||||
brew tap appergb/openless https://github.com/appergb/openless
|
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",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"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:aura-skin": "node scripts/aura-skin-contract.test.mjs",
|
||||||
"check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs",
|
"check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs",
|
||||||
"check:hotkey-injection": "node scripts/check-hotkey-injection.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]
|
[dependencies]
|
||||||
# 锁 ~2.11 因为 npm @tauri-apps/api 与 plugin-dialog 都已升 2.11;
|
# 锁 ~2.11 因为 npm @tauri-apps/api 与 plugin-dialog 都已升 2.11;
|
||||||
# tauri build 跨 minor 一致性检查会拒绝 npm 2.11 + Rust 2.10 的组合。
|
# 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-shell = "2"
|
||||||
tauri-plugin-updater = "2"
|
|
||||||
tauri-plugin-single-instance = "2"
|
|
||||||
tauri-plugin-autostart = "2"
|
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
@@ -39,9 +38,9 @@ getrandom = "0.3"
|
|||||||
bzip2 = "0.4"
|
bzip2 = "0.4"
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
tokio = { version = "1", features = ["full"] }
|
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"
|
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"
|
zip = "2"
|
||||||
thiserror = "1"
|
thiserror = "1"
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
@@ -56,10 +55,17 @@ bytes = "1"
|
|||||||
url = "2"
|
url = "2"
|
||||||
raw-window-handle = "0.6"
|
raw-window-handle = "0.6"
|
||||||
ferrous-opencc = "0.4"
|
ferrous-opencc = "0.4"
|
||||||
|
# Audio capture — shared across desktop and mobile.
|
||||||
# Hotkey + audio + insertion
|
|
||||||
global-hotkey = "0.6"
|
|
||||||
cpal = "0.15"
|
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"
|
enigo = "0.2"
|
||||||
arboard = { version = "3", features = ["wayland-data-control"] }
|
arboard = { version = "3", features = ["wayland-data-control"] }
|
||||||
rcgen = "^0.13"
|
rcgen = "^0.13"
|
||||||
@@ -83,11 +89,16 @@ features = ["windows-native"]
|
|||||||
|
|
||||||
[target.'cfg(target_os = "linux")'.dependencies]
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
dbus = "0.9"
|
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"
|
version = "3.6.3"
|
||||||
default-features = false
|
default-features = false
|
||||||
features = ["linux-native-sync-persistent", "crypto-rust"]
|
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]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
block2 = "0.5"
|
block2 = "0.5"
|
||||||
core-foundation = "0.10"
|
core-foundation = "0.10"
|
||||||
|
|||||||
@@ -5,9 +5,20 @@ fn main() {
|
|||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
build_qwen_asr_macos();
|
build_qwen_asr_macos();
|
||||||
|
|
||||||
|
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("android") {
|
||||||
|
link_android_cpp_runtime();
|
||||||
|
}
|
||||||
|
|
||||||
tauri_build::build();
|
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")]
|
#[cfg(target_os = "windows")]
|
||||||
fn link_windows_common_controls_v6_manifest_dependency() {
|
fn link_windows_common_controls_v6_manifest_dependency() {
|
||||||
let mut source_path = std::path::PathBuf::from(
|
let mut source_path = std::path::PathBuf::from(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Default capabilities for OpenLess windows",
|
"description": "Default capabilities for OpenLess windows",
|
||||||
|
"platforms": ["macOS", "windows", "linux"],
|
||||||
"windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow"],
|
"windows": ["main", "capsule", "qa", "less-computer", "less-computer-glow"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"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 的同步桥接;放到
|
// SFSpeechRecognizer 是阻塞且基于 objc runloop 的同步桥接;放到
|
||||||
// spawn_blocking 不占 tokio runtime。与 LocalQwenAsr 走同一个 Tauri
|
// spawn_blocking 不占 tokio runtime。与 LocalQwenAsr 走同一个 Tauri
|
||||||
// 持有的 runtime handle。
|
// 持有的 runtime handle。
|
||||||
let result =
|
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||||
tauri::async_runtime::spawn_blocking(move || transcribe_pcm_blocking(&pcm, duration_ms))
|
transcribe_pcm_blocking(&pcm, duration_ms)
|
||||||
.await
|
})
|
||||||
.context("apple-speech transcribe spawn_blocking join 失败")?;
|
.await
|
||||||
|
.context("apple-speech transcribe spawn_blocking join 失败")?;
|
||||||
|
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
self.buffer.lock().clear();
|
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> {
|
fn speech_recognizer_class() -> Result<&'static AnyClass> {
|
||||||
AnyClass::get("SFSpeechRecognizer")
|
AnyClass::get("SFSpeechRecognizer").ok_or_else(|| {
|
||||||
.ok_or_else(|| anyhow!("SFSpeechRecognizer 类不可用(需要 macOS 10.15+ 并链接 Speech.framework)"))
|
anyhow!("SFSpeechRecognizer 类不可用(需要 macOS 10.15+ 并链接 Speech.framework)")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `[[SFSpeechRecognizer alloc] init]` —— 用系统当前 locale。
|
/// `[[SFSpeechRecognizer alloc] init]` —— 用系统当前 locale。
|
||||||
|
|||||||
@@ -226,18 +226,20 @@ impl DownloadManager {
|
|||||||
|
|
||||||
pub(crate) fn build_client() -> Result<reqwest::Client> {
|
pub(crate) fn build_client() -> Result<reqwest::Client> {
|
||||||
// native-tls (macOS=SecureTransport) 不像 rustls 那样把 CDN unclean close
|
// native-tls (macOS=SecureTransport) 不像 rustls 那样把 CDN unclean close
|
||||||
// 当致命错误。
|
// 当致命错误。Android/iOS 无 native-tls feature,走默认 rustls。
|
||||||
//
|
//
|
||||||
// User-Agent 用 aria2 的——hfd(hf-mirror 官方推荐)就是 aria2 包装,
|
// User-Agent 用 aria2 的——hfd(hf-mirror 官方推荐)就是 aria2 包装,
|
||||||
// 实测 aria2 UA 在 HF 反滥用规则里走白名单不挨 throttle;自定义 UA
|
// 实测 aria2 UA 在 HF 反滥用规则里走白名单不挨 throttle;自定义 UA
|
||||||
// (`openless/x`) 在 sustained 传输后会被 mirror 主动切流。
|
// (`openless/x`) 在 sustained 传输后会被 mirror 主动切流。
|
||||||
reqwest::Client::builder()
|
let mut builder = reqwest::Client::builder()
|
||||||
.use_native_tls()
|
|
||||||
.user_agent("aria2/1.36.0")
|
.user_agent("aria2/1.36.0")
|
||||||
.connect_timeout(std::time::Duration::from_secs(30))
|
.connect_timeout(std::time::Duration::from_secs(30))
|
||||||
.pool_idle_timeout(std::time::Duration::from_secs(60))
|
.pool_idle_timeout(std::time::Duration::from_secs(60));
|
||||||
.build()
|
#[cfg(not(mobile))]
|
||||||
.context("build reqwest client failed")
|
{
|
||||||
|
builder = builder.use_native_tls();
|
||||||
|
}
|
||||||
|
builder.build().context("build reqwest client failed")
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_download(
|
async fn run_download(
|
||||||
|
|||||||
@@ -55,7 +55,10 @@ pub async fn run_test(model_id: ModelId) -> Result<TestResult> {
|
|||||||
for fname in &required_files {
|
for fname in &required_files {
|
||||||
let path = dir.join(fname);
|
let path = dir.join(fname);
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
anyhow::bail!("模型文件缺失:{fname},请重新下载(预期路径:{})", path.display());
|
anyhow::bail!(
|
||||||
|
"模型文件缺失:{fname},请重新下载(预期路径:{})",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let meta = std::fs::metadata(&path)
|
let meta = std::fs::metadata(&path)
|
||||||
.map_err(|e| anyhow::anyhow!("读取 {fname} 元数据失败:{e}"))?;
|
.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" {
|
if provider == "volcengine" {
|
||||||
return volcengine_configured(snap);
|
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
|
if provider == crate::asr::local::PROVIDER_ID
|
||||||
|| active_apple_speech_asr_is_supported(provider)
|
|| active_apple_speech_asr_is_supported(provider)
|
||||||
|| active_foundry_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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub(crate) struct LocalAsrReleasePlan {
|
pub(crate) struct LocalAsrReleasePlan {
|
||||||
pub(crate) qwen: bool,
|
pub(crate) qwen: bool,
|
||||||
pub(crate) foundry: bool,
|
pub(crate) foundry: bool,
|
||||||
pub(crate) sherpa: bool,
|
pub(crate) sherpa: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub(crate) fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan {
|
pub(crate) fn local_asr_release_plan_for_provider(provider: &str) -> LocalAsrReleasePlan {
|
||||||
LocalAsrReleasePlan {
|
LocalAsrReleasePlan {
|
||||||
qwen: provider != crate::asr::local::PROVIDER_ID,
|
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(
|
pub(crate) async fn release_foundry_runtime_if_inactive(
|
||||||
runtime: &Arc<FoundryLocalRuntime>,
|
runtime: &Arc<FoundryLocalRuntime>,
|
||||||
release_foundry: bool,
|
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(
|
pub(crate) async fn release_sherpa_runtime_if_inactive(
|
||||||
runtime: &Arc<SherpaOnnxRuntime>,
|
runtime: &Arc<SherpaOnnxRuntime>,
|
||||||
release_sherpa: bool,
|
release_sherpa: bool,
|
||||||
@@ -154,6 +166,26 @@ pub fn set_credential(window: Window, account: String, value: String) -> Result<
|
|||||||
Ok(())
|
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]
|
#[tauri::command]
|
||||||
pub async fn set_active_asr_provider(
|
pub async fn set_active_asr_provider(
|
||||||
coord: CoordinatorState<'_>,
|
coord: CoordinatorState<'_>,
|
||||||
|
|||||||
@@ -36,30 +36,74 @@ pub async fn check_network() -> NetworkCheckResult {
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_hotkey_status(coord: CoordinatorState<'_>) -> HotkeyStatus {
|
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()
|
coord.hotkey_status()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn get_hotkey_capability(coord: CoordinatorState<'_>) -> HotkeyCapability {
|
pub fn get_hotkey_capability(coord: CoordinatorState<'_>) -> HotkeyCapability {
|
||||||
|
#[cfg(mobile)]
|
||||||
|
{
|
||||||
|
let _ = coord;
|
||||||
|
return HotkeyCapability::current();
|
||||||
|
}
|
||||||
|
#[cfg(not(mobile))]
|
||||||
coord.hotkey_capability()
|
coord.hotkey_capability()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_shortcut_recording_active(coord: CoordinatorState<'_>, active: bool) {
|
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);
|
coord.set_shortcut_recording_active(active);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub fn get_windows_ime_status() -> WindowsImeStatus {
|
pub fn get_windows_ime_status() -> WindowsImeStatus {
|
||||||
crate::windows_ime_profile::get_windows_ime_status()
|
crate::windows_ime_profile::get_windows_ime_status()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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> {
|
pub fn list_microphone_devices() -> Result<Vec<crate::recorder::MicrophoneDevice>, String> {
|
||||||
crate::recorder::list_input_devices().map_err(|e| e.to_string())
|
crate::recorder::list_input_devices().map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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(
|
pub async fn start_microphone_level_monitor(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
device_name: String,
|
device_name: String,
|
||||||
@@ -93,6 +137,12 @@ pub async fn start_microphone_level_monitor(
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn stop_microphone_level_monitor(app: AppHandle) {
|
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 _ = tauri::async_runtime::spawn_blocking(move || {
|
||||||
let state = app.state::<MicrophoneMonitorState>();
|
let state = app.state::<MicrophoneMonitorState>();
|
||||||
let recorder = state.lock().take();
|
let recorder = state.lock().take();
|
||||||
|
|||||||
@@ -10,8 +10,11 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use tauri::{Manager, State};
|
#[cfg(not(mobile))]
|
||||||
|
use tauri::Manager;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
// 跨域共享的 crate 级导入:以 `pub(crate) use` 重导出,子模块用 `use super::*;`
|
// 跨域共享的 crate 级导入:以 `pub(crate) use` 重导出,子模块用 `use super::*;`
|
||||||
// 即可拿到,避免在 16 个文件里重复同一组 import。
|
// 即可拿到,避免在 16 个文件里重复同一组 import。
|
||||||
@@ -19,18 +22,22 @@ pub(crate) use serde::Serialize;
|
|||||||
pub(crate) use serde_json::Value;
|
pub(crate) use serde_json::Value;
|
||||||
pub(crate) use tauri::{AppHandle, Emitter, Window};
|
pub(crate) use tauri::{AppHandle, Emitter, Window};
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub(crate) use crate::asr::local::foundry::{
|
pub(crate) use crate::asr::local::foundry::{
|
||||||
model_alias_is_known, FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus,
|
model_alias_is_known, FoundryCatalogModel, FoundryPrepareProgressPayload, FoundryRuntimeStatus,
|
||||||
DEFAULT_MODEL_ALIAS, PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID,
|
DEFAULT_MODEL_ALIAS, PROVIDER_ID as FOUNDRY_LOCAL_PROVIDER_ID,
|
||||||
};
|
};
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub(crate) use crate::asr::local::sherpa::{
|
pub(crate) use crate::asr::local::sherpa::{
|
||||||
model_alias_is_known as sherpa_model_alias_is_known, SherpaCatalogModel,
|
model_alias_is_known as sherpa_model_alias_is_known, SherpaCatalogModel,
|
||||||
SherpaPrepareProgressPayload, SherpaRuntimeStatus,
|
SherpaPrepareProgressPayload, SherpaRuntimeStatus,
|
||||||
DEFAULT_MODEL_ALIAS as SHERPA_DEFAULT_MODEL_ALIAS,
|
DEFAULT_MODEL_ALIAS as SHERPA_DEFAULT_MODEL_ALIAS,
|
||||||
};
|
};
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub(crate) use crate::asr::local::sherpa_download::{
|
pub(crate) use crate::asr::local::sherpa_download::{
|
||||||
fetch_remote_info as fetch_sherpa_remote_info, SherpaDownloadManager, SherpaRemoteInfo,
|
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::asr::local::{FoundryLocalRuntime, Mirror, SherpaOnnxRuntime};
|
||||||
pub(crate) use crate::coordinator::Coordinator;
|
pub(crate) use crate::coordinator::Coordinator;
|
||||||
pub(crate) use crate::net;
|
pub(crate) use crate::net;
|
||||||
@@ -44,73 +51,90 @@ pub(crate) use crate::polish::{
|
|||||||
OpenAICompatibleConfig, OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL,
|
OpenAICompatibleConfig, OpenAICompatibleLLMProvider, CODEX_DEFAULT_MODEL,
|
||||||
CODEX_OAUTH_PROVIDER_ID,
|
CODEX_OAUTH_PROVIDER_ID,
|
||||||
};
|
};
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub(crate) use crate::recorder::{AudioConsumer, Recorder};
|
pub(crate) use crate::recorder::{AudioConsumer, Recorder};
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
pub(crate) use crate::types::WindowsImeStatus;
|
||||||
pub(crate) use crate::types::{
|
pub(crate) use crate::types::{
|
||||||
builtin_style_pack_id, default_active_style_pack_id, ChineseScriptPreference, ComboBinding,
|
builtin_style_pack_id, default_active_style_pack_id, AndroidAccessibilityStatus,
|
||||||
CorrectionRule, CredentialsStatus, DictationSession, DictionaryEntry, HotkeyCapability,
|
AndroidOverlayStatus, ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus,
|
||||||
HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding, StylePack, StylePackKind,
|
DictationSession, DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference,
|
||||||
StylePackRuntimeDiagnostics, StyleSystemPrompts, UpdateChannel, UserPreferences,
|
PolishMode, ShortcutBinding, StylePack, StylePackKind, StylePackRuntimeDiagnostics,
|
||||||
VocabPresetStore, WindowsImeStatus,
|
StyleSystemPrompts, UpdateChannel, UserPreferences, VocabPresetStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
mod credentials;
|
mod credentials;
|
||||||
mod dictation;
|
mod dictation;
|
||||||
mod dictionary;
|
mod dictionary;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
mod foundry_asr;
|
mod foundry_asr;
|
||||||
mod github_oauth;
|
mod github_oauth;
|
||||||
mod history;
|
mod history;
|
||||||
mod hotkeys;
|
mod hotkeys;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
mod local_asr;
|
mod local_asr;
|
||||||
mod marketplace;
|
mod marketplace;
|
||||||
mod misc;
|
mod misc;
|
||||||
mod permissions_cmds;
|
mod permissions_cmds;
|
||||||
mod providers;
|
mod providers;
|
||||||
mod qa;
|
mod qa;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
mod remote_input;
|
mod remote_input;
|
||||||
mod settings;
|
mod settings;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
mod sherpa_asr;
|
mod sherpa_asr;
|
||||||
mod style_packs;
|
mod style_packs;
|
||||||
|
|
||||||
pub use credentials::*;
|
pub use credentials::*;
|
||||||
pub use dictation::*;
|
pub use dictation::*;
|
||||||
pub use dictionary::*;
|
pub use dictionary::*;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub use foundry_asr::*;
|
pub use foundry_asr::*;
|
||||||
pub use github_oauth::*;
|
pub use github_oauth::*;
|
||||||
pub use history::*;
|
pub use history::*;
|
||||||
pub use hotkeys::*;
|
pub use hotkeys::*;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub use local_asr::*;
|
pub use local_asr::*;
|
||||||
pub use marketplace::*;
|
pub use marketplace::*;
|
||||||
pub use misc::*;
|
pub use misc::*;
|
||||||
pub use permissions_cmds::*;
|
pub use permissions_cmds::*;
|
||||||
pub use providers::*;
|
pub use providers::*;
|
||||||
pub use qa::*;
|
pub use qa::*;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub use remote_input::*;
|
pub use remote_input::*;
|
||||||
pub use settings::*;
|
pub use settings::*;
|
||||||
// sherpa_onnx_asr_* 命令整组 `#[cfg(target_os = "windows")]`(见 lib.rs 的
|
// sherpa_onnx_asr_* 命令整组 `#[cfg(target_os = "windows")]`(见 lib.rs 的
|
||||||
// generate_handler! 清单)。非 Windows 平台这组 glob 重导出无人引用,会触发
|
// generate_handler! 清单)。非 Windows 平台这组 glob 重导出无人引用,会触发
|
||||||
// unused_imports;这是平台 cfg 的正常结果,不是真正的死代码。
|
// unused_imports;这是平台 cfg 的正常结果,不是真正的死代码。
|
||||||
|
#[cfg(not(mobile))]
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
pub use sherpa_asr::*;
|
pub use sherpa_asr::*;
|
||||||
pub use style_packs::*;
|
pub use style_packs::*;
|
||||||
|
|
||||||
pub(crate) type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
|
pub(crate) type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub type MicrophoneMonitorState = Mutex<Option<Recorder>>;
|
pub type MicrophoneMonitorState = Mutex<Option<Recorder>>;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub type TrayMicrophoneMenuState = Mutex<Vec<TrayMicrophoneMenuItem>>;
|
pub type TrayMicrophoneMenuState = Mutex<Vec<TrayMicrophoneMenuItem>>;
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub struct TrayMicrophoneMenuItem {
|
pub struct TrayMicrophoneMenuItem {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub device_name: String,
|
pub device_name: String,
|
||||||
pub item: tauri::menu::CheckMenuItem<tauri::Wry>,
|
pub item: tauri::menu::CheckMenuItem<tauri::Wry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub fn sync_tray_microphone_selection(items: &[TrayMicrophoneMenuItem], device_name: &str) {
|
pub fn sync_tray_microphone_selection(items: &[TrayMicrophoneMenuItem], device_name: &str) {
|
||||||
for item in items {
|
for item in items {
|
||||||
let _ = item.item.set_checked(item.device_name == device_name);
|
let _ = item.item.set_checked(item.device_name == device_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub(crate) struct LevelProbeConsumer;
|
pub(crate) struct LevelProbeConsumer;
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
impl AudioConsumer for LevelProbeConsumer {
|
impl AudioConsumer for LevelProbeConsumer {
|
||||||
fn consume_pcm_chunk(&self, _pcm: &[u8]) {}
|
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())
|
.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> {
|
pub(crate) fn open_path_in_file_manager(path: &std::path::Path) -> Result<(), String> {
|
||||||
std::process::Command::new("xdg-open")
|
std::process::Command::new("xdg-open")
|
||||||
.arg(path)
|
.arg(path)
|
||||||
|
|||||||
@@ -1,5 +1,46 @@
|
|||||||
use super::*;
|
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]
|
#[tauri::command]
|
||||||
pub fn check_accessibility_permission() -> PermissionStatus {
|
pub fn check_accessibility_permission() -> PermissionStatus {
|
||||||
permissions::check_accessibility()
|
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 {
|
pub(crate) fn active_asr_is_keyless_for_validation(provider: &str) -> bool {
|
||||||
|
if cfg!(mobile) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
provider == crate::asr::local::PROVIDER_ID
|
provider == crate::asr::local::PROVIDER_ID
|
||||||
|| active_apple_speech_asr_is_supported(provider)
|
|| active_apple_speech_asr_is_supported(provider)
|
||||||
|| active_foundry_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 {
|
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
|
provider == FOUNDRY_LOCAL_PROVIDER_ID
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(all(not(mobile), target_os = "windows")))]
|
||||||
{
|
{
|
||||||
let _ = provider;
|
let _ = provider;
|
||||||
false
|
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 {
|
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
|
provider == crate::asr::local::sherpa::PROVIDER_ID
|
||||||
}
|
}
|
||||||
#[cfg(not(target_os = "windows"))]
|
#[cfg(not(all(not(mobile), target_os = "windows")))]
|
||||||
{
|
{
|
||||||
let _ = provider;
|
let _ = provider;
|
||||||
false
|
false
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ pub fn qa_window_pin(coord: CoordinatorState<'_>, pinned: bool) {
|
|||||||
coord.qa_window_pin(pinned);
|
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 浮窗。
|
/// 用户点 ✕ / 按 Esc 关 Less Computer 浮窗。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn less_computer_window_dismiss(coord: CoordinatorState<'_>) {
|
pub fn less_computer_window_dismiss(coord: CoordinatorState<'_>) {
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ pub(crate) fn persist_settings<T: SettingsWriter>(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn set_settings(
|
pub fn set_settings(
|
||||||
coord: CoordinatorState<'_>,
|
coord: CoordinatorState<'_>,
|
||||||
@@ -180,10 +181,13 @@ pub fn set_settings(
|
|||||||
let remote_prev = coord.prefs().get();
|
let remote_prev = coord.prefs().get();
|
||||||
let packs = coord.style_packs().list().map_err(|e| e.to_string())?;
|
let packs = coord.style_packs().list().map_err(|e| e.to_string())?;
|
||||||
sync_style_pack_preferences(&mut prefs, &packs);
|
sync_style_pack_preferences(&mut prefs, &packs);
|
||||||
|
prefs.android_overlay_trigger = prefs.android_overlay_trigger.normalized();
|
||||||
// 广播给所有 webview。issue #205:QaPanel 跑在独立 webview,
|
// 广播给所有 webview。issue #205:QaPanel 跑在独立 webview,
|
||||||
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
|
// 没有 HotkeySettingsContext,必须靠事件感知录音键变化,否则面板可见时
|
||||||
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
|
// 用户改键会让浮窗里的 "{recordHotkey}" 文案一直停留在旧值。
|
||||||
persist_settings(&*coord, prefs.clone())?;
|
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,必须在主线程上跑。
|
// refresh_tray_microphone_menu 内部会调用 NSStatusItem.set_menu,必须在主线程上跑。
|
||||||
// set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调
|
// set_settings 本身是同步 Tauri command,在 IPC handler 线程上执行;从这里直接调
|
||||||
// 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改
|
// 会触发 macOS 主线程断言或在 dispatch 队列上死锁,导致整个 UI 无响应(用户改
|
||||||
@@ -213,6 +217,25 @@ pub fn set_settings(
|
|||||||
Ok(())
|
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) ───────────────────────────
|
// ─────────────────────────── release channel (Beta opt-in) ───────────────────────────
|
||||||
//
|
//
|
||||||
// 渠道偏好的写入路径跟 set_settings 复用 persist_settings:保持热键兜底归一化
|
// 渠道偏好的写入路径跟 set_settings 复用 persist_settings:保持热键兜底归一化
|
||||||
@@ -341,6 +364,7 @@ fn extract_between(haystack: &str, open: &str, close: &str) -> Option<String> {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub struct AppUpdateMetadata {
|
pub struct AppUpdateMetadata {
|
||||||
pub rid: tauri::ResourceId,
|
pub rid: tauri::ResourceId,
|
||||||
pub current_version: String,
|
pub current_version: String,
|
||||||
@@ -357,6 +381,7 @@ pub struct AppUpdateMetadata {
|
|||||||
/// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。
|
/// 不传则回落到 `prefs.update_channel`(后台 AutoUpdateGate 自动检查走这条)。
|
||||||
/// 返回 None = 当前是最新;Some(metadata) = 有新版可装。
|
/// 返回 None = 当前是最新;Some(metadata) = 有新版可装。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
#[cfg(not(mobile))]
|
||||||
pub async fn app_check_update_with_channel<R: tauri::Runtime>(
|
pub async fn app_check_update_with_channel<R: tauri::Runtime>(
|
||||||
coord: CoordinatorState<'_>,
|
coord: CoordinatorState<'_>,
|
||||||
webview: tauri::Webview<R>,
|
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 对。
|
/// 把 fetch_latest_beta_release 找到的最新 prerelease tag 拼成 -beta manifest URL 对。
|
||||||
/// 顺序:先镜像(fastgit.cc 代理 GitHub),后直连 —— 跟 tauri.conf 现有 Stable
|
/// 顺序:先镜像(fastgit.cc 代理 GitHub),后直连 —— 跟 tauri.conf 现有 Stable
|
||||||
/// endpoints 一致,让国内访问优先打到 CDN。
|
/// endpoints 一致,让国内访问优先打到 CDN。
|
||||||
|
#[cfg(not(mobile))]
|
||||||
async fn resolve_beta_manifest_endpoints() -> Result<Vec<url::Url>, String> {
|
async fn resolve_beta_manifest_endpoints() -> Result<Vec<url::Url>, String> {
|
||||||
let Some(latest) = fetch_latest_beta_release().await? else {
|
let Some(latest) = fetch_latest_beta_release().await? else {
|
||||||
return Err("尚未发布过 Beta 版本".to_string());
|
return Err("尚未发布过 Beta 版本".to_string());
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::types::CapsuleState;
|
|||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
begin_qa_session, cancel_qa_session, capture_focus_target, capture_frontmost_app, emit_capsule,
|
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)]
|
#[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() {
|
if let Some(app) = inner.app.lock().clone() {
|
||||||
crate::show_qa_window(&app, "idle");
|
crate::show_qa_window(&app, "idle");
|
||||||
let _ = app.emit_to(
|
let _ = app.emit_to(
|
||||||
"qa",
|
qa_event_target(),
|
||||||
"qa:state",
|
"qa:state",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"kind": "idle",
|
"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) {
|
pub(super) fn stop_microphone_preview_monitor(inner: &Arc<Inner>, owner: &str) {
|
||||||
let Some(app) = inner.app.lock().as_ref().cloned() else {
|
#[cfg(mobile)]
|
||||||
return;
|
{
|
||||||
};
|
let _ = (inner, owner);
|
||||||
let state = app.state::<crate::commands::MicrophoneMonitorState>();
|
}
|
||||||
let recorder = state.lock().take();
|
#[cfg(not(mobile))]
|
||||||
if let Some(recorder) = recorder {
|
{
|
||||||
log::info!("[recorder] stopping microphone preview monitor before {owner}");
|
let Some(app) = inner.app.lock().as_ref().cloned() else {
|
||||||
recorder.stop();
|
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。
|
//! - macOS:用 CoreGraphics CGEvent 直接 post Cmd+V。
|
||||||
//! - Windows / Linux:用 enigo 按 `PasteShortcut` 模拟。
|
//! - Windows / Linux:用 enigo 按 `PasteShortcut` 模拟。
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
|
|
||||||
use crate::types::{InsertStatus, PasteShortcut};
|
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);
|
const CLIPBOARD_RESTORE_DELAY: Duration = Duration::from_millis(750);
|
||||||
|
|
||||||
pub struct TextInserter;
|
pub struct TextInserter;
|
||||||
@@ -56,7 +60,7 @@ impl TextInserter {
|
|||||||
insert_with_clipboard_restore(text, restore_clipboard_after_paste, paste_shortcut)
|
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(
|
pub fn insert_via_clipboard_fallback(
|
||||||
&self,
|
&self,
|
||||||
text: &str,
|
text: &str,
|
||||||
@@ -83,37 +87,35 @@ impl TextInserter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// macOS 路径:保存原剪贴板 → 写转写文字 → post Cmd+V → 按需恢复原剪贴板。
|
/// macOS 路径:写剪贴板 + post Cmd+V。两个 `_` 参数仅为对齐跨平台签名。
|
||||||
/// `paste_shortcut` 在 macOS 不使用(固定 Cmd+V),仅为对齐跨平台签名。
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
pub fn insert(
|
pub fn insert(
|
||||||
&self,
|
&self,
|
||||||
text: &str,
|
text: &str,
|
||||||
restore_clipboard_after_paste: bool,
|
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,
|
_paste_shortcut: PasteShortcut,
|
||||||
) -> InsertStatus {
|
) -> InsertStatus {
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
return InsertStatus::CopiedFallback;
|
return InsertStatus::CopiedFallback;
|
||||||
}
|
}
|
||||||
// issue #525:先记下用户原剪贴板,粘贴成功后再恢复,避免覆盖用户手动复制的内容。
|
self.copy_fallback(text)
|
||||||
// 此前 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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 只写剪贴板、不模拟粘贴。用于目标控件活跃状态无法验证时的兜底路径。
|
/// 只写剪贴板、不模拟粘贴。用于目标控件活跃状态无法验证时的兜底路径。
|
||||||
@@ -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 {
|
impl Default for TextInserter {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct ClipboardRestorePlan {
|
struct ClipboardRestorePlan {
|
||||||
inserted_text: String,
|
inserted_text: String,
|
||||||
previous_text: Option<String>,
|
previous_text: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct PendingClipboardRestore {
|
struct PendingClipboardRestore {
|
||||||
latest_restore_id: u64,
|
latest_restore_id: u64,
|
||||||
original_text: Option<String>,
|
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);
|
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>>> =
|
static PENDING_CLIPBOARD_RESTORE: Lazy<Mutex<Option<PendingClipboardRestore>>> =
|
||||||
Lazy::new(|| Mutex::new(None));
|
Lazy::new(|| Mutex::new(None));
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "android", target_os = "ios")))]
|
||||||
fn copy_to_clipboard(text: &str) -> bool {
|
fn copy_to_clipboard(text: &str) -> bool {
|
||||||
let mut clipboard = match arboard::Clipboard::new() {
|
let mut clipboard = match arboard::Clipboard::new() {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -193,6 +211,28 @@ fn copy_to_clipboard(text: &str) -> bool {
|
|||||||
true
|
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> {
|
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 mut clipboard = arboard::Clipboard::new().map_err(|e| e.to_string())?;
|
||||||
let previous_text = match clipboard.get_text() {
|
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(
|
fn insert_with_clipboard_restore(
|
||||||
text: &str,
|
text: &str,
|
||||||
restore_clipboard_after_paste: bool,
|
restore_clipboard_after_paste: bool,
|
||||||
@@ -239,6 +279,7 @@ fn insert_with_clipboard_restore(
|
|||||||
insertion_success_status()
|
insertion_success_status()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
fn schedule_clipboard_restore(plan: ClipboardRestorePlan) {
|
fn schedule_clipboard_restore(plan: ClipboardRestorePlan) {
|
||||||
let (restore_id, original_text) =
|
let (restore_id, original_text) =
|
||||||
remember_pending_clipboard_restore(plan.previous_text.clone());
|
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>) {
|
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 restore_id = NEXT_CLIPBOARD_RESTORE_ID.fetch_add(1, Ordering::SeqCst);
|
||||||
let original_text = {
|
let original_text = {
|
||||||
@@ -264,6 +306,7 @@ fn remember_pending_clipboard_restore(previous_text: Option<String>) -> (u64, Op
|
|||||||
(restore_id, original_text)
|
(restore_id, original_text)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
fn restore_clipboard_after_delay(
|
fn restore_clipboard_after_delay(
|
||||||
plan: ClipboardRestorePlan,
|
plan: ClipboardRestorePlan,
|
||||||
original_text: Option<String>,
|
original_text: Option<String>,
|
||||||
@@ -314,6 +357,7 @@ fn restore_clipboard_after_delay(
|
|||||||
clear_pending_clipboard_restore(restore_id);
|
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 {
|
fn is_latest_clipboard_restore(restore_id: u64) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
PENDING_CLIPBOARD_RESTORE.lock().as_ref(),
|
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) {
|
fn clear_pending_clipboard_restore(restore_id: u64) {
|
||||||
let mut pending = PENDING_CLIPBOARD_RESTORE.lock();
|
let mut pending = PENDING_CLIPBOARD_RESTORE.lock();
|
||||||
if matches!(pending.as_ref(), Some(batch) if batch.latest_restore_id == restore_id) {
|
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 {
|
fn should_restore_clipboard(current_text: Option<&str>, inserted_text: &str) -> bool {
|
||||||
matches!(current_text, Some(current) if current == inserted_text)
|
matches!(current_text, Some(current) if current == inserted_text)
|
||||||
}
|
}
|
||||||
@@ -344,7 +390,7 @@ fn simulate_paste() -> Result<(), String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 把 `PasteShortcut` 拆成 `(modifiers, primary)`,顺序决定按下/释放顺序。
|
/// 把 `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) {
|
fn paste_keys(shortcut: PasteShortcut) -> (Vec<enigo::Key>, enigo::Key) {
|
||||||
use enigo::Key;
|
use enigo::Key;
|
||||||
match shortcut {
|
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> {
|
fn simulate_paste(shortcut: PasteShortcut) -> Result<(), String> {
|
||||||
use enigo::{Direction, Enigo, Keyboard, Settings};
|
use enigo::{Direction, Enigo, Keyboard, Settings};
|
||||||
let (modifiers, primary) = paste_keys(shortcut);
|
let (modifiers, primary) = paste_keys(shortcut);
|
||||||
@@ -398,7 +444,7 @@ fn insertion_success_status() -> InsertStatus {
|
|||||||
InsertStatus::Inserted
|
InsertStatus::Inserted
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
fn insertion_success_status() -> InsertStatus {
|
fn insertion_success_status() -> InsertStatus {
|
||||||
InsertStatus::PasteSent
|
InsertStatus::PasteSent
|
||||||
}
|
}
|
||||||
@@ -548,6 +594,7 @@ mod tests {
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
fn restore_only_when_clipboard_still_holds_inserted_text() {
|
fn restore_only_when_clipboard_still_holds_inserted_text() {
|
||||||
assert!(should_restore_clipboard(
|
assert!(should_restore_clipboard(
|
||||||
Some("dictated text"),
|
Some("dictated text"),
|
||||||
@@ -562,7 +609,7 @@ mod tests {
|
|||||||
|
|
||||||
/// 配置的快捷键必须真实映射到对应按键。只比较 modifier 数 + 主键,规避 enigo 内部 PartialEq。
|
/// 配置的快捷键必须真实映射到对应按键。只比较 modifier 数 + 主键,规避 enigo 内部 PartialEq。
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
fn paste_keys_match_configured_shortcut() {
|
fn paste_keys_match_configured_shortcut() {
|
||||||
use enigo::Key;
|
use enigo::Key;
|
||||||
|
|
||||||
@@ -591,7 +638,7 @@ mod tests {
|
|||||||
inserter.insert("", true, PasteShortcut::CtrlV),
|
inserter.insert("", true, PasteShortcut::CtrlV),
|
||||||
InsertStatus::CopiedFallback
|
InsertStatus::CopiedFallback
|
||||||
);
|
);
|
||||||
#[cfg(not(target_os = "macos"))]
|
#[cfg(not(any(target_os = "macos", target_os = "android", target_os = "ios")))]
|
||||||
{
|
{
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
inserter.insert_via_clipboard_fallback("", true, PasteShortcut::CtrlV),
|
inserter.insert_via_clipboard_fallback("", true, PasteShortcut::CtrlV),
|
||||||
@@ -677,7 +724,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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() {
|
fn pending_clipboard_restore_keeps_first_original_until_latest_restore() {
|
||||||
*PENDING_CLIPBOARD_RESTORE.lock() = None;
|
*PENDING_CLIPBOARD_RESTORE.lock() = None;
|
||||||
|
|
||||||
@@ -699,7 +746,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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() {
|
fn clipboard_restore_skips_when_clipboard_no_longer_matches_inserted_text() {
|
||||||
assert!(should_restore_clipboard(
|
assert!(should_restore_clipboard(
|
||||||
Some("dictated text"),
|
Some("dictated text"),
|
||||||
@@ -714,17 +761,15 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn macos_paste_success_reports_inserted_and_guards_restore() {
|
fn macos_direct_write_or_paste_failure_keeps_copied_fallback_available() {
|
||||||
// 粘贴成功 → Inserted;恢复仅在剪贴板仍是刚插入的转写文字时进行(issue #525)。
|
assert_eq!(
|
||||||
assert_eq!(insertion_success_status(), InsertStatus::Inserted);
|
macos_insert_status_after_paste(Ok(())),
|
||||||
assert!(should_restore_clipboard(
|
InsertStatus::Inserted
|
||||||
Some("dictated text"),
|
);
|
||||||
"dictated text"
|
assert_eq!(
|
||||||
));
|
macos_insert_status_after_paste(Err("AX direct write unavailable".to_string())),
|
||||||
assert!(!should_restore_clipboard(
|
InsertStatus::CopiedFallback
|
||||||
Some("user changed clipboard"),
|
);
|
||||||
"dictated text"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -14,35 +14,70 @@
|
|||||||
//! - coordinator: dictation state machine glue
|
//! - coordinator: dictation state machine glue
|
||||||
//! - commands: Tauri IPC surface
|
//! - commands: Tauri IPC surface
|
||||||
|
|
||||||
|
mod android;
|
||||||
mod asr;
|
mod asr;
|
||||||
mod audio_mute;
|
mod audio_mute;
|
||||||
mod cli;
|
mod cli;
|
||||||
mod coding_agent;
|
mod coding_agent;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
mod combo_hotkey;
|
||||||
|
#[cfg(mobile)]
|
||||||
|
#[path = "mobile_stubs/combo_hotkey.rs"]
|
||||||
mod combo_hotkey;
|
mod combo_hotkey;
|
||||||
mod commands;
|
mod commands;
|
||||||
mod coordinator;
|
mod coordinator;
|
||||||
mod coordinator_state;
|
mod coordinator_state;
|
||||||
mod correction;
|
mod correction;
|
||||||
|
mod external_url;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
mod global_hotkey_runtime;
|
mod global_hotkey_runtime;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
#[path = "hotkey.rs"]
|
||||||
|
mod hotkey;
|
||||||
|
#[cfg(mobile)]
|
||||||
|
#[path = "mobile_stubs/hotkey.rs"]
|
||||||
mod hotkey;
|
mod hotkey;
|
||||||
mod insertion;
|
mod insertion;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod linux_fcitx;
|
mod linux_fcitx;
|
||||||
mod llm_gemini;
|
mod llm_gemini;
|
||||||
|
#[cfg(mobile)]
|
||||||
|
mod mobile_runtime;
|
||||||
mod net;
|
mod net;
|
||||||
mod permissions;
|
mod permissions;
|
||||||
mod persistence;
|
mod persistence;
|
||||||
mod polish;
|
mod polish;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
mod qa_hotkey;
|
||||||
|
#[cfg(mobile)]
|
||||||
|
#[path = "mobile_stubs/qa_hotkey.rs"]
|
||||||
mod qa_hotkey;
|
mod qa_hotkey;
|
||||||
mod recorder;
|
mod recorder;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
mod remote_server;
|
mod remote_server;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
|
#[path = "selection.rs"]
|
||||||
mod selection;
|
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 shortcut_binding;
|
||||||
mod types;
|
mod types;
|
||||||
|
#[cfg(not(mobile))]
|
||||||
mod unicode_keystroke;
|
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_ipc;
|
||||||
mod windows_ime_profile;
|
mod windows_ime_profile;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
mod windows_ime_protocol;
|
mod windows_ime_protocol;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
mod windows_ime_session;
|
mod windows_ime_session;
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -58,10 +93,13 @@ const OPENLESS_BUNDLE_ID: &str = "com.openless.app";
|
|||||||
/// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition,
|
/// 第一次 show 时把 QA 浮窗摆到屏幕底部居中;之后的 show 不再 reposition,
|
||||||
/// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。
|
/// 让用户拖动后的位置在 hide → show 之间得以保持。详见 issue #118 v2。
|
||||||
static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false);
|
static QA_WINDOW_POSITIONED: AtomicBool = AtomicBool::new(false);
|
||||||
|
#[cfg(not(mobile))]
|
||||||
static TRAY_MICROPHONE_WATCHER_STOPPING: AtomicBool = AtomicBool::new(false);
|
static TRAY_MICROPHONE_WATCHER_STOPPING: AtomicBool = AtomicBool::new(false);
|
||||||
|
#[cfg(not(mobile))]
|
||||||
use tauri::menu::{
|
use tauri::menu::{
|
||||||
CheckMenuItemBuilder, Menu, MenuBuilder, MenuItemBuilder, Submenu, SubmenuBuilder,
|
CheckMenuItemBuilder, Menu, MenuBuilder, MenuItemBuilder, Submenu, SubmenuBuilder,
|
||||||
};
|
};
|
||||||
|
#[cfg(not(mobile))]
|
||||||
use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent};
|
use tauri::tray::{MouseButton, TrayIconBuilder, TrayIconEvent};
|
||||||
use tauri::{
|
use tauri::{
|
||||||
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalPosition, PhysicalSize,
|
AppHandle, Emitter, LogicalPosition, LogicalSize, Manager, PhysicalPosition, PhysicalSize,
|
||||||
@@ -72,6 +110,267 @@ use crate::types::PolishMode;
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
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 foundry_local_runtime = Arc::new(asr::local::FoundryLocalRuntime::new());
|
||||||
let sherpa_onnx_runtime = Arc::new(asr::local::SherpaOnnxRuntime::new());
|
let sherpa_onnx_runtime = Arc::new(asr::local::SherpaOnnxRuntime::new());
|
||||||
let sherpa_download_manager =
|
let sherpa_download_manager =
|
||||||
@@ -328,8 +627,6 @@ pub fn run() {
|
|||||||
let app_handle = app.handle().clone();
|
let app_handle = app.handle().clone();
|
||||||
coordinator.bind_app(app_handle);
|
coordinator.bind_app(app_handle);
|
||||||
coordinator.start_hotkey_listener();
|
coordinator.start_hotkey_listener();
|
||||||
// 远程输入:按 prefs 启动局域网录音服务(未启用时为 no-op)。
|
|
||||||
coordinator.refresh_remote_server();
|
|
||||||
// QA / custom combo hotkeys use `global-hotkey` (Carbon on macOS).
|
// QA / custom combo hotkeys use `global-hotkey` (Carbon on macOS).
|
||||||
// Start those after RunEvent::Ready, when the AppKit event loop is live.
|
// 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") {
|
if std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1") {
|
||||||
@@ -346,159 +643,7 @@ pub fn run() {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(app_invoke_handler_desktop!())
|
||||||
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,
|
|
||||||
])
|
|
||||||
.build(tauri::generate_context!())
|
.build(tauri::generate_context!())
|
||||||
.expect("error while building tauri application")
|
.expect("error while building tauri application")
|
||||||
.run(|app, event| match event {
|
.run(|app, event| match event {
|
||||||
@@ -539,21 +684,25 @@ pub fn run() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
struct MicrophoneTrayMenu {
|
struct MicrophoneTrayMenu {
|
||||||
submenu: Submenu<tauri::Wry>,
|
submenu: Submenu<tauri::Wry>,
|
||||||
items: Vec<commands::TrayMicrophoneMenuItem>,
|
items: Vec<commands::TrayMicrophoneMenuItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
struct StyleTrayMenu {
|
struct StyleTrayMenu {
|
||||||
submenu: Submenu<tauri::Wry>,
|
submenu: Submenu<tauri::Wry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
struct TrayMenu {
|
struct TrayMenu {
|
||||||
menu: Menu<tauri::Wry>,
|
menu: Menu<tauri::Wry>,
|
||||||
microphone_items: Vec<commands::TrayMicrophoneMenuItem>,
|
microphone_items: Vec<commands::TrayMicrophoneMenuItem>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
#[cfg(not(mobile))]
|
||||||
struct TrayPolishModeMenuEntry {
|
struct TrayPolishModeMenuEntry {
|
||||||
id: String,
|
id: String,
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
@@ -562,9 +711,13 @@ struct TrayPolishModeMenuEntry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn tray_style_menu_enabled() -> bool {
|
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> {
|
fn tray_polish_mode_menu_entries(selected: PolishMode) -> Vec<TrayPolishModeMenuEntry> {
|
||||||
[
|
[
|
||||||
(PolishMode::Raw, "style-raw"),
|
(PolishMode::Raw, "style-raw"),
|
||||||
@@ -582,6 +735,7 @@ fn tray_polish_mode_menu_entries(selected: PolishMode) -> Vec<TrayPolishModeMenu
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
fn parse_tray_polish_mode_id(id: &str) -> Option<PolishMode> {
|
fn parse_tray_polish_mode_id(id: &str) -> Option<PolishMode> {
|
||||||
match id {
|
match id {
|
||||||
"style-raw" => Some(PolishMode::Raw),
|
"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>>(
|
fn build_tray_menu<M: Manager<tauri::Wry>>(
|
||||||
app: &M,
|
app: &M,
|
||||||
coordinator: &Arc<coordinator::Coordinator>,
|
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>>(
|
fn build_style_tray_menu<M: Manager<tauri::Wry>>(
|
||||||
app: &M,
|
app: &M,
|
||||||
coordinator: &Arc<coordinator::Coordinator>,
|
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>>(
|
fn build_microphone_tray_menu<M: Manager<tauri::Wry>>(
|
||||||
app: &M,
|
app: &M,
|
||||||
coordinator: &Arc<coordinator::Coordinator>,
|
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<()> {
|
pub(crate) fn refresh_tray_microphone_menu(app: &AppHandle) -> tauri::Result<()> {
|
||||||
let coordinator = app.state::<Arc<coordinator::Coordinator>>();
|
let coordinator = app.state::<Arc<coordinator::Coordinator>>();
|
||||||
let tray_menu = build_tray_menu(app, &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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
fn microphone_device_signature() -> Option<Vec<(String, bool)>> {
|
fn microphone_device_signature() -> Option<Vec<(String, bool)>> {
|
||||||
match recorder::list_input_devices() {
|
match recorder::list_input_devices() {
|
||||||
Ok(devices) => Some(
|
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) {
|
fn start_tray_microphone_watcher(app: AppHandle) {
|
||||||
TRAY_MICROPHONE_WATCHER_STOPPING.store(false, Ordering::Relaxed);
|
TRAY_MICROPHONE_WATCHER_STOPPING.store(false, Ordering::Relaxed);
|
||||||
if let Err(err) = std::thread::Builder::new()
|
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) {
|
fn handle_microphone_tray_menu_event(app: &AppHandle, id: &str) {
|
||||||
let tray_items = app.state::<commands::TrayMicrophoneMenuState>();
|
let tray_items = app.state::<commands::TrayMicrophoneMenuState>();
|
||||||
let items = tray_items.lock();
|
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);
|
commands::sync_tray_microphone_selection(&items, &selected.device_name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(mobile))]
|
||||||
fn handle_style_tray_menu_event(app: &AppHandle, id: &str) -> bool {
|
fn handle_style_tray_menu_event(app: &AppHandle, id: &str) -> bool {
|
||||||
let Some(mode) = parse_tray_polish_mode_id(id) else {
|
let Some(mode) = parse_tray_polish_mode_id(id) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -790,6 +952,11 @@ fn handle_style_tray_menu_event(app: &AppHandle, id: &str) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(mobile)]
|
||||||
|
pub(crate) fn refresh_tray_microphone_menu(_app: &AppHandle) -> tauri::Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// 把 Win11 原生标题栏底色刷成白色,与应用 sidebar 视觉统一。需要 Win11 22H2+
|
/// 把 Win11 原生标题栏底色刷成白色,与应用 sidebar 视觉统一。需要 Win11 22H2+
|
||||||
/// (Build 22621+) 才支持 `DWMWA_CAPTION_COLOR`(35);老 Windows 上 DwmSetWindowAttribute
|
/// (Build 22621+) 才支持 `DWMWA_CAPTION_COLOR`(35);老 Windows 上 DwmSetWindowAttribute
|
||||||
/// 返回错误,仅打 warn 不阻塞启动。
|
/// 返回错误,仅打 warn 不阻塞启动。
|
||||||
@@ -955,7 +1122,7 @@ pub fn log_dir_path() -> std::path::PathBuf {
|
|||||||
.join("Logs");
|
.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") {
|
if let Ok(home) = std::env::var("HOME") {
|
||||||
return std::path::PathBuf::from(home)
|
return std::path::PathBuf::from(home)
|
||||||
@@ -965,6 +1132,12 @@ pub fn log_dir_path() -> std::path::PathBuf {
|
|||||||
.join("logs");
|
.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")
|
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);
|
activate_window_mode(app);
|
||||||
if let Some(w) = app.get_webview_window("main") {
|
if let Some(w) = app.get_webview_window("main") {
|
||||||
let _ = w.show();
|
let _ = w.show();
|
||||||
|
#[cfg(not(mobile))]
|
||||||
let _ = w.unminimize();
|
let _ = w.unminimize();
|
||||||
let _ = w.set_focus();
|
let _ = w.set_focus();
|
||||||
}
|
}
|
||||||
@@ -1215,35 +1389,6 @@ fn bottom_visual_position(
|
|||||||
(x, y)
|
(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 之前都会调一次,
|
/// 把 QA 浮窗放到屏幕底部居中、紧贴胶囊上方。tauri 启动期 + show 之前都会调一次,
|
||||||
/// 防止用户切换显示器后位置错乱。
|
/// 防止用户切换显示器后位置错乱。
|
||||||
fn position_qa_window<R: tauri::Runtime>(window: &tauri::WebviewWindow<R>) -> tauri::Result<()> {
|
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`)。
|
/// 显示 QA 窗口并发一条状态事件(前端订阅 `qa:state`)。
|
||||||
/// `content_kind` 是不透明字符串("loading" / "answer" / "idle" 等),
|
/// `content_kind` 是不透明字符串("loading" / "answer" / "idle" 等),
|
||||||
/// 让前端 React 视图自行决定渲染哪一种。
|
/// 让前端 React 视图自行决定渲染哪一种。**不**抢前台 app 焦点(保证 Cmd+C
|
||||||
///
|
/// fallback 仍能从原 app 拿到选区)。
|
||||||
/// ## 跨端焦点契约(#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 契约一致。
|
|
||||||
pub(crate) fn show_qa_window<R: tauri::Runtime>(app: &AppHandle<R>, content_kind: &str) {
|
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 {
|
let Some(window) = app.get_webview_window("qa") else {
|
||||||
log::info!("[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})");
|
log::info!("[qa] show 跳过:qa 窗口不存在 (content_kind={content_kind})");
|
||||||
return;
|
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}");
|
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")))]
|
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||||
if let Err(e) = window.show() {
|
if let Err(e) = window.show() {
|
||||||
log::warn!("[qa] show failed: {e}");
|
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 收尾共用。
|
/// 隐藏 QA 窗口。供 commands::qa_window_dismiss / coordinator session 收尾共用。
|
||||||
pub(crate) fn hide_qa_window<R: tauri::Runtime>(app: &AppHandle<R>) {
|
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") {
|
if let Some(window) = app.get_webview_window("qa") {
|
||||||
let _ = window.hide();
|
let _ = window.hide();
|
||||||
}
|
}
|
||||||
@@ -1491,22 +1655,18 @@ pub(crate) fn show_less_computer_glow<R: tauri::Runtime>(app: &AppHandle<R>) {
|
|||||||
.ok()
|
.ok()
|
||||||
.flatten()
|
.flatten()
|
||||||
.or_else(|| app.primary_monitor().ok().flatten());
|
.or_else(|| app.primary_monitor().ok().flatten());
|
||||||
// 逻辑坐标的「铺满整屏」矩形 (x, y, w, h)。f64 元组可 Copy:既在 show 前先铺一次,
|
if let Some(monitor) = monitor {
|
||||||
// 也在主线程 realize(orderFront)后再铺一次(见下,修首次半屏 bug)。
|
let scale = monitor.scale_factor();
|
||||||
let bounds: Option<(f64, f64, f64, f64)> = monitor.map(|m| {
|
let size = monitor.size();
|
||||||
let scale = m.scale_factor();
|
let pos = monitor.position();
|
||||||
let size = m.size();
|
let _ = window.set_position(tauri::LogicalPosition::new(
|
||||||
let pos = m.position();
|
|
||||||
(
|
|
||||||
pos.x as f64 / scale,
|
pos.x as f64 / scale,
|
||||||
pos.y as f64 / scale,
|
pos.y as f64 / scale,
|
||||||
|
));
|
||||||
|
let _ = window.set_size(tauri::LogicalSize::new(
|
||||||
size.width as f64 / scale,
|
size.width as f64 / scale,
|
||||||
size.height 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);
|
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();
|
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) top: i32,
|
||||||
pub(crate) right: i32,
|
pub(crate) right: i32,
|
||||||
pub(crate) bottom: 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)。
|
/// 该显示器的有效 DPI 缩放(1.0 = 96dpi)。
|
||||||
pub(crate) scale: f64,
|
pub(crate) scale: f64,
|
||||||
}
|
}
|
||||||
@@ -1688,10 +1834,6 @@ pub(crate) fn foreground_window_monitor() -> Option<ForegroundMonitor> {
|
|||||||
top: mi.rcMonitor.top,
|
top: mi.rcMonitor.top,
|
||||||
right: mi.rcMonitor.right,
|
right: mi.rcMonitor.right,
|
||||||
bottom: mi.rcMonitor.bottom,
|
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),
|
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 =
|
let offset_from_bottom =
|
||||||
(capsule_visual_height(translation_active) + 80.0 + bounds.bottom_inset) * scale;
|
(capsule_visual_height(translation_active) + 80.0 + bounds.bottom_inset) * scale;
|
||||||
let y = ((mon.bottom as f64) - offset_from_bottom).round() as i32;
|
let y = ((mon.bottom as f64) - offset_from_bottom).round() as i32;
|
||||||
|
let clamped_y = y.max(mon.top);
|
||||||
// #470:四边都夹到「工作区」内(去掉任务栏),保证整窗可见。GetMonitorInfoW
|
// #470 诊断 v2:当前只夹了上边(.max(mon.top)),未夹下/左/右。多显示器、
|
||||||
// 取不到 rcWork 时(理论上不会,rcWork 总随 rcMonitor 一同填)退回整屏矩形。
|
// 负坐标或异常 DPI 下胶囊可能被算到屏幕外却无任何观测。记录显示器几何与
|
||||||
let (work_l, work_t, work_r, work_b) =
|
// 最终落点,用于证伪/证实「胶囊定位到屏幕外」(C 子嫌疑)。
|
||||||
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);
|
|
||||||
log::debug!(
|
log::debug!(
|
||||||
"[capsule] win position: mon=({},{})..({},{}) work=({},{})..({},{}) scale={:.2} size=({}x{}) -> raw=({},{}) clamped=({},{})",
|
"[capsule] win position: mon=({},{})..({},{}) scale={:.2} size=({}x{}) -> x={} y={} clamped_y={}",
|
||||||
mon.left, mon.top, mon.right, mon.bottom,
|
mon.left, mon.top, mon.right, mon.bottom, scale, phys_w, phys_h, x, y, clamped_y
|
||||||
work_l, work_t, work_r, work_b,
|
|
||||||
scale, phys_w, phys_h, x, y, clamped_x, clamped_y
|
|
||||||
);
|
);
|
||||||
window.set_position(PhysicalPosition::new(clamped_x, clamped_y))?;
|
window.set_position(PhysicalPosition::new(x, clamped_y))?;
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
// 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor 逻辑。
|
// 仅当 Win32 取不到前台显示器时,落回下面的 current_monitor 逻辑。
|
||||||
@@ -1822,7 +1955,7 @@ fn capsule_height_for_qa() -> f64 {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
bottom_center_position, bottom_visual_position, capsule_height_for_qa,
|
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,
|
parse_tray_polish_mode_id, rotate_log_if_too_large, tray_polish_mode_menu_entries,
|
||||||
tray_style_menu_enabled, LogicalMonitorFrame, LOG_ROTATE_LIMIT_BYTES,
|
tray_style_menu_enabled, LogicalMonitorFrame, LOG_ROTATE_LIMIT_BYTES,
|
||||||
};
|
};
|
||||||
@@ -1971,63 +2104,6 @@ mod tests {
|
|||||||
assert_eq!(pos, (610.0, -176.0));
|
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]
|
#[test]
|
||||||
fn oversized_log_rotates_to_single_archive() {
|
fn oversized_log_rotates_to_single_archive() {
|
||||||
let dir = std::env::temp_dir().join(format!("openless-log-rotate-{}", std::process::id()));
|
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 {
|
mod platform {
|
||||||
use super::PermissionStatus;
|
use super::PermissionStatus;
|
||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
|
|||||||
@@ -311,7 +311,8 @@ fn build_input_stream(
|
|||||||
.map_err(|e| classify_default_config_err(e.to_string()))?;
|
.map_err(|e| classify_default_config_err(e.to_string()))?;
|
||||||
|
|
||||||
let sample_format = supported.sample_format();
|
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 input_sr = config.sample_rate.0;
|
||||||
let channels = config.channels as usize;
|
let channels = config.channels as usize;
|
||||||
|
|
||||||
@@ -324,21 +325,59 @@ fn build_input_stream(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let state = Arc::new(StreamState::new());
|
let state = Arc::new(StreamState::new());
|
||||||
let stream = build_stream_for_format(
|
let stream = match build_stream_for_format(
|
||||||
&device,
|
&device,
|
||||||
&config,
|
&config,
|
||||||
sample_format,
|
sample_format,
|
||||||
consumer,
|
Arc::clone(&consumer),
|
||||||
level_handler,
|
Arc::clone(&level_handler),
|
||||||
archiver,
|
archiver.clone(),
|
||||||
Arc::clone(&state),
|
Arc::clone(&state),
|
||||||
input_sr,
|
input_sr,
|
||||||
channels,
|
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))
|
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(
|
fn select_input_device(
|
||||||
host: &cpal::Host,
|
host: &cpal::Host,
|
||||||
microphone_device_name: Option<&str>,
|
microphone_device_name: Option<&str>,
|
||||||
|
|||||||
@@ -299,30 +299,47 @@ fn build_router(state: Arc<WsState>) -> Router {
|
|||||||
.route("/", get(index_handler))
|
.route("/", get(index_handler))
|
||||||
.route(
|
.route(
|
||||||
"/app.js",
|
"/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(
|
.route(
|
||||||
"/style.css",
|
"/style.css",
|
||||||
get(|| async {
|
get(|| async {
|
||||||
([(axum::http::header::CONTENT_TYPE, HEADER_CSS)], assets::STYLE_CSS)
|
(
|
||||||
|
[(axum::http::header::CONTENT_TYPE, HEADER_CSS)],
|
||||||
|
assets::STYLE_CSS,
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/icon.png",
|
"/icon.png",
|
||||||
get(|| async {
|
get(|| async {
|
||||||
([(axum::http::header::CONTENT_TYPE, "image/png")], assets::ICON_PNG)
|
(
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "image/png")],
|
||||||
|
assets::ICON_PNG,
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/mic.png",
|
"/mic.png",
|
||||||
get(|| async {
|
get(|| async {
|
||||||
([(axum::http::header::CONTENT_TYPE, "image/png")], assets::MIC_PNG)
|
(
|
||||||
|
[(axum::http::header::CONTENT_TYPE, "image/png")],
|
||||||
|
assets::MIC_PNG,
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
"/done.png",
|
"/done.png",
|
||||||
get(|| async {
|
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 不复用
|
// 证书下载:手机在浏览器打开它即可下载并安装信任(iOS Safari 的 wss 不复用
|
||||||
@@ -331,7 +348,10 @@ fn build_router(state: Arc<WsState>) -> Router {
|
|||||||
"/cert.cer",
|
"/cert.cer",
|
||||||
get(|State(state): State<Arc<WsState>>| async move {
|
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(),
|
state.cert_der.clone(),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
@@ -548,19 +568,25 @@ async fn handle_ws(mut socket: WebSocket, state: Arc<WsState>, peer_ip: IpAddr)
|
|||||||
match authed {
|
match authed {
|
||||||
AuthResult::Ok => {
|
AuthResult::Ok => {
|
||||||
log::info!("[remote-input] 配对成功,进入录音会话");
|
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 => {
|
AuthResult::BadPin => {
|
||||||
log::warn!("[remote-input] 配对码错误,已拒绝");
|
log::warn!("[remote-input] 配对码错误,已拒绝");
|
||||||
let _ = socket
|
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;
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
AuthResult::Locked => {
|
AuthResult::Locked => {
|
||||||
log::warn!("[remote-input] 配对已锁定(连续错误过多),已拒绝");
|
log::warn!("[remote-input] 配对已锁定(连续错误过多),已拒绝");
|
||||||
let _ = socket
|
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;
|
.await;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -666,7 +692,9 @@ async fn handle_control(txt: &str, state: &Arc<WsState>, socket: &mut WebSocket)
|
|||||||
Err(reason) => {
|
Err(reason) => {
|
||||||
log::warn!("[remote-input] 开始录音被拒:{reason}");
|
log::warn!("[remote-input] 开始录音被拒:{reason}");
|
||||||
let _ = socket
|
let _ = socket
|
||||||
.send(send_json(&serde_json::json!({"type":"busy","reason":reason})))
|
.send(send_json(
|
||||||
|
&serde_json::json!({"type":"busy","reason":reason}),
|
||||||
|
))
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,22 @@
|
|||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -636,6 +652,18 @@ pub struct UserPreferences {
|
|||||||
/// 热键 2:快取用键(选中→Claude→回插)。默认 `None`(用户自配)。
|
/// 热键 2:快取用键(选中→Claude→回插)。默认 `None`(用户自配)。
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub coding_agent_quick_hotkey: Option<ShortcutBinding>,
|
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")。
|
/// 本地 Qwen3-ASR 当前激活的模型 id("qwen3-asr-0.6b" / "qwen3-asr-1.7b")。
|
||||||
/// 仅在 active_asr_provider == "local-qwen3" 时有意义。
|
/// 仅在 active_asr_provider == "local-qwen3" 时有意义。
|
||||||
#[serde(default = "default_local_asr_model")]
|
#[serde(default = "default_local_asr_model")]
|
||||||
@@ -749,19 +777,28 @@ pub struct UserPreferences {
|
|||||||
/// 上传 / 点赞需要带这个 header;空时上传被后端 401。
|
/// 上传 / 点赞需要带这个 header;空时上传被后端 401。
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub marketplace_dev_login: String,
|
pub marketplace_dev_login: String,
|
||||||
/// ── 远程输入(局域网手机录音)────────────────────────────────
|
/// Android: text insertion strategy for cross-app dictation results.
|
||||||
/// 是否启用远程输入 HTTPS+WS 服务。默认 false(关闭,按需手动开启)。
|
#[serde(default = "default_android_insert_strategy")]
|
||||||
#[serde(default)]
|
pub android_insert_strategy: AndroidInsertStrategy,
|
||||||
pub remote_input_enabled: bool,
|
/// Android: when to show the floating overlay control.
|
||||||
/// 远程输入服务监听端口(HTTPS)。默认 8443。
|
#[serde(default = "default_android_overlay_trigger")]
|
||||||
#[serde(default = "default_remote_input_port")]
|
pub android_overlay_trigger: AndroidOverlayTrigger,
|
||||||
pub remote_input_port: u16,
|
/// Android: how the floating overlay enters the armed interaction state.
|
||||||
/// 远程输入配对码(6 位数字)。空 = server 首次启动时随机生成并回写。
|
#[serde(default = "default_android_overlay_activation_mode")]
|
||||||
#[serde(default)]
|
pub android_overlay_activation_mode: AndroidOverlayActivationMode,
|
||||||
pub remote_input_pin: String,
|
/// Android: action performed by left swiping while the overlay is armed.
|
||||||
/// 手机录音页默认交互方式:"toggle"(点击切换)/ "hold"(按住说话)。
|
#[serde(default = "default_android_overlay_left_swipe_action")]
|
||||||
#[serde(default = "default_remote_input_mode")]
|
pub android_overlay_left_swipe_action: AndroidOverlayLeftSwipeAction,
|
||||||
pub remote_input_default_mode: String,
|
/// 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 {
|
fn default_remote_input_port() -> u16 {
|
||||||
@@ -772,10 +809,6 @@ fn default_remote_input_mode() -> String {
|
|||||||
"toggle".into()
|
"toggle".into()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_local_asr_model() -> String {
|
|
||||||
"qwen3-asr-0.6b".into()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn default_history_retention_days() -> u32 {
|
fn default_history_retention_days() -> u32 {
|
||||||
7
|
7
|
||||||
}
|
}
|
||||||
@@ -871,6 +904,14 @@ struct UserPreferencesWire {
|
|||||||
coding_agent_panel_hotkey: Option<ShortcutBinding>,
|
coding_agent_panel_hotkey: Option<ShortcutBinding>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
coding_agent_quick_hotkey: Option<ShortcutBinding>,
|
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")]
|
#[serde(default = "default_local_asr_model")]
|
||||||
local_asr_active_model: String,
|
local_asr_active_model: String,
|
||||||
#[serde(default = "default_local_asr_mirror")]
|
#[serde(default = "default_local_asr_mirror")]
|
||||||
@@ -919,14 +960,18 @@ struct UserPreferencesWire {
|
|||||||
marketplace_base_url: String,
|
marketplace_base_url: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
marketplace_dev_login: String,
|
marketplace_dev_login: String,
|
||||||
#[serde(default)]
|
#[serde(default = "default_android_insert_strategy")]
|
||||||
remote_input_enabled: bool,
|
android_insert_strategy: AndroidInsertStrategy,
|
||||||
#[serde(default = "default_remote_input_port")]
|
#[serde(default = "default_android_overlay_trigger")]
|
||||||
remote_input_port: u16,
|
android_overlay_trigger: AndroidOverlayTrigger,
|
||||||
#[serde(default)]
|
#[serde(default = "default_android_overlay_activation_mode")]
|
||||||
remote_input_pin: String,
|
android_overlay_activation_mode: AndroidOverlayActivationMode,
|
||||||
#[serde(default = "default_remote_input_mode")]
|
#[serde(default = "default_android_overlay_left_swipe_action")]
|
||||||
remote_input_default_mode: String,
|
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 {
|
impl Default for UserPreferencesWire {
|
||||||
@@ -970,6 +1015,10 @@ impl Default for UserPreferencesWire {
|
|||||||
coding_agent_voice_hotkey: prefs.coding_agent_voice_hotkey,
|
coding_agent_voice_hotkey: prefs.coding_agent_voice_hotkey,
|
||||||
coding_agent_panel_hotkey: prefs.coding_agent_panel_hotkey,
|
coding_agent_panel_hotkey: prefs.coding_agent_panel_hotkey,
|
||||||
coding_agent_quick_hotkey: prefs.coding_agent_quick_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_active_model: prefs.local_asr_active_model,
|
||||||
local_asr_mirror: prefs.local_asr_mirror,
|
local_asr_mirror: prefs.local_asr_mirror,
|
||||||
local_asr_keep_loaded_secs: prefs.local_asr_keep_loaded_secs,
|
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,
|
audio_recording_max_entries: prefs.audio_recording_max_entries,
|
||||||
marketplace_base_url: prefs.marketplace_base_url,
|
marketplace_base_url: prefs.marketplace_base_url,
|
||||||
marketplace_dev_login: prefs.marketplace_dev_login,
|
marketplace_dev_login: prefs.marketplace_dev_login,
|
||||||
remote_input_enabled: prefs.remote_input_enabled,
|
android_insert_strategy: prefs.android_insert_strategy,
|
||||||
remote_input_port: prefs.remote_input_port,
|
android_overlay_trigger: prefs.android_overlay_trigger,
|
||||||
remote_input_pin: prefs.remote_input_pin,
|
android_overlay_activation_mode: prefs.android_overlay_activation_mode,
|
||||||
remote_input_default_mode: prefs.remote_input_default_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_voice_hotkey: wire.coding_agent_voice_hotkey,
|
||||||
coding_agent_panel_hotkey: wire.coding_agent_panel_hotkey,
|
coding_agent_panel_hotkey: wire.coding_agent_panel_hotkey,
|
||||||
coding_agent_quick_hotkey: wire.coding_agent_quick_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,
|
custom_combo_hotkey: wire.custom_combo_hotkey,
|
||||||
translation_hotkey: wire
|
translation_hotkey: wire
|
||||||
.translation_hotkey
|
.translation_hotkey
|
||||||
@@ -1094,10 +1149,16 @@ impl<'de> Deserialize<'de> for UserPreferences {
|
|||||||
audio_recording_max_entries: wire.audio_recording_max_entries,
|
audio_recording_max_entries: wire.audio_recording_max_entries,
|
||||||
marketplace_base_url: wire.marketplace_base_url,
|
marketplace_base_url: wire.marketplace_base_url,
|
||||||
marketplace_dev_login: wire.marketplace_dev_login,
|
marketplace_dev_login: wire.marketplace_dev_login,
|
||||||
remote_input_enabled: wire.remote_input_enabled,
|
android_insert_strategy: normalize_android_insert_strategy(
|
||||||
remote_input_port: wire.remote_input_port,
|
wire.android_insert_strategy,
|
||||||
remote_input_pin: wire.remote_input_pin,
|
),
|
||||||
remote_input_default_mode: wire.remote_input_default_mode,
|
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 纠错、
|
/// 自带 # 角色 + {{HOTWORDS}} + 八节主体(结构化判断、双层格式、首行收尾、ASR 纠错、
|
||||||
/// 原样保留、禁止事项、输出),因此 Structured 模式跳过标准 ROLE_BLOCK / COMMON_RULES /
|
/// 原样保留、禁止事项、输出),因此 Structured 模式跳过标准 ROLE_BLOCK / COMMON_RULES /
|
||||||
/// OUTPUT_BLOCK wrapper,避免与 v2 内的同名段落重复。
|
/// OUTPUT_BLOCK wrapper,避免与 v2 内的同名段落重复。
|
||||||
const STRUCTURED_BUILTIN_PROMPT: &str = r#"# ⚡ 第一指令(高于一切,先执行再看细则)
|
const STRUCTURED_BUILTIN_PROMPT: &str = r#"# 角色
|
||||||
|
|
||||||
先数原文里有几件「可区分的事项」:
|
|
||||||
- **≥2 件 → 必须输出编号清单**(行首 1. 2. 3.),**禁止**把多件事揉成一整段。≥3 件还要按主题归类、子项另起一行用 (a) (b)。
|
|
||||||
- 恰好 1 件 → 才输出连贯段落。
|
|
||||||
|
|
||||||
判断依据是「事项数」,**不是**原文有没有标点 / 换行 / 已经编号。只要有 2 件以上事项却揉进一段话 = 直接失败。最终形态照本文末尾「# 示例」里的样子输出。
|
|
||||||
|
|
||||||
# 角色
|
|
||||||
|
|
||||||
你是「清晰结构」整理器。用户输入来自语音识别(ASR),常带错别字、同音字、英文术语音译、断句缺失、语序混乱、口语化表达等问题。
|
你是「清晰结构」整理器。用户输入来自语音识别(ASR),常带错别字、同音字、英文术语音译、断句缺失、语序混乱、口语化表达等问题。
|
||||||
|
|
||||||
@@ -1802,6 +1855,10 @@ impl Default for UserPreferences {
|
|||||||
coding_agent_voice_hotkey: default_coding_agent_voice_hotkey(),
|
coding_agent_voice_hotkey: default_coding_agent_voice_hotkey(),
|
||||||
coding_agent_panel_hotkey: default_coding_agent_panel_hotkey(),
|
coding_agent_panel_hotkey: default_coding_agent_panel_hotkey(),
|
||||||
coding_agent_quick_hotkey: None,
|
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_active_model: default_local_asr_model(),
|
||||||
local_asr_mirror: default_local_asr_mirror(),
|
local_asr_mirror: default_local_asr_mirror(),
|
||||||
local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
|
local_asr_keep_loaded_secs: default_local_asr_keep_loaded_secs(),
|
||||||
@@ -1826,10 +1883,13 @@ impl Default for UserPreferences {
|
|||||||
audio_recording_max_entries: None,
|
audio_recording_max_entries: None,
|
||||||
marketplace_base_url: String::new(),
|
marketplace_base_url: String::new(),
|
||||||
marketplace_dev_login: String::new(),
|
marketplace_dev_login: String::new(),
|
||||||
remote_input_enabled: false,
|
android_insert_strategy: default_android_insert_strategy(),
|
||||||
remote_input_port: default_remote_input_port(),
|
android_overlay_trigger: default_android_overlay_trigger(),
|
||||||
remote_input_pin: String::new(),
|
android_overlay_activation_mode: default_android_overlay_activation_mode(),
|
||||||
remote_input_default_mode: default_remote_input_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,
|
MacEventTap,
|
||||||
WindowsLowLevel,
|
WindowsLowLevel,
|
||||||
Fcitx5,
|
Fcitx5,
|
||||||
|
/// Mobile platforms do not expose desktop global hotkey adapters.
|
||||||
|
Unavailable,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HotkeyAdapterKind {
|
impl HotkeyAdapterKind {
|
||||||
@@ -2046,6 +2108,7 @@ impl HotkeyAdapterKind {
|
|||||||
HotkeyAdapterKind::MacEventTap => "macOS Event Tap",
|
HotkeyAdapterKind::MacEventTap => "macOS Event Tap",
|
||||||
HotkeyAdapterKind::WindowsLowLevel => "Windows 低层键盘 hook",
|
HotkeyAdapterKind::WindowsLowLevel => "Windows 低层键盘 hook",
|
||||||
HotkeyAdapterKind::Fcitx5 => "fcitx5 输入法插件",
|
HotkeyAdapterKind::Fcitx5 => "fcitx5 输入法插件",
|
||||||
|
HotkeyAdapterKind::Unavailable => "不可用",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2201,6 +2264,21 @@ pub struct HotkeyCapability {
|
|||||||
|
|
||||||
impl HotkeyCapability {
|
impl HotkeyCapability {
|
||||||
pub fn current() -> Self {
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
Self {
|
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 {
|
Self {
|
||||||
adapter: HotkeyAdapterKind::Fcitx5,
|
adapter: HotkeyAdapterKind::Fcitx5,
|
||||||
@@ -2307,6 +2385,68 @@ pub struct WindowsImeStatus {
|
|||||||
pub dll_path: Option<String>,
|
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)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub enum HotkeyStatusState {
|
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",
|
"infoPlist": "Info.plist",
|
||||||
"entitlements": "Entitlements.plist"
|
"entitlements": "Entitlements.plist"
|
||||||
},
|
},
|
||||||
|
"android": {
|
||||||
|
"minSdkVersion": 26
|
||||||
|
},
|
||||||
"windows": {
|
"windows": {
|
||||||
"nsis": {
|
"nsis": {
|
||||||
"installMode": "perMachine",
|
"installMode": "perMachine",
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ import {
|
|||||||
checkMicrophonePermission,
|
checkMicrophonePermission,
|
||||||
getHotkeyStatus,
|
getHotkeyStatus,
|
||||||
getSettings,
|
getSettings,
|
||||||
|
getPlatformCapabilities,
|
||||||
handleWindowHotkeyEvent,
|
handleWindowHotkeyEvent,
|
||||||
isTauri,
|
isTauri,
|
||||||
|
qaWindowDismiss,
|
||||||
} from './lib/ipc';
|
} from './lib/ipc';
|
||||||
|
import type { PlatformCapabilities } from './lib/types';
|
||||||
import {
|
import {
|
||||||
isWindowHotkeyKeyboardCandidate,
|
isWindowHotkeyKeyboardCandidate,
|
||||||
windowMouseHotkeyCode,
|
windowMouseHotkeyCode,
|
||||||
@@ -30,6 +33,7 @@ interface AppProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Gate = 'onboarding' | 'ready';
|
type Gate = 'onboarding' | 'ready';
|
||||||
|
const ANDROID_SETUP_WIZARD_COMPLETE_KEY = 'openless.androidSetupWizardComplete';
|
||||||
|
|
||||||
export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, forcedOs }: AppProps) {
|
export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, forcedOs }: AppProps) {
|
||||||
if (isCapsule) {
|
if (isCapsule) {
|
||||||
@@ -48,6 +52,65 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force
|
|||||||
const os = forcedOs ?? detectOS();
|
const os = forcedOs ?? detectOS();
|
||||||
// Windows 启动不应被权限探测阻塞首屏。
|
// Windows 启动不应被权限探测阻塞首屏。
|
||||||
const [gate, setGate] = useState<Gate>('ready');
|
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(() => {
|
useEffect(() => {
|
||||||
if (!isTauri) return;
|
if (!isTauri) return;
|
||||||
@@ -94,13 +157,30 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force
|
|||||||
if (!isTauri) return;
|
if (!isTauri) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
if (os === 'win') {
|
void (async () => {
|
||||||
// 超时保护:50 次 × 200ms = 10s。hotkey hook 永远 starting(被反作弊 / EDR
|
const caps = await getPlatformCapabilities();
|
||||||
// / UAC 拦)时不让 UI 死锁灰屏,过 10s 强 setGate('ready') 让用户进
|
if (cancelled) return;
|
||||||
// Permissions 页看 hotkey_status.lastError 处理。详见 issue #163。
|
|
||||||
const POLL_INTERVAL_MS = 200;
|
if (caps.platform === 'android') {
|
||||||
const POLL_MAX_ATTEMPTS = 50;
|
if (localStorage.getItem(ANDROID_SETUP_WIZARD_COMPLETE_KEY) !== '1') {
|
||||||
const pollHotkeyStatus = async () => {
|
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;
|
let attempts = 0;
|
||||||
while (!cancelled && attempts < POLL_MAX_ATTEMPTS) {
|
while (!cancelled && attempts < POLL_MAX_ATTEMPTS) {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
@@ -118,19 +198,9 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force
|
|||||||
);
|
);
|
||||||
setGate('ready');
|
setGate('ready');
|
||||||
}
|
}
|
||||||
};
|
return;
|
||||||
void pollHotkeyStatus().catch(error => {
|
}
|
||||||
console.warn('[startup] hotkey status polling failed', error);
|
|
||||||
if (!cancelled) {
|
|
||||||
setGate('ready');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
const [a, m] = await Promise.all([
|
const [a, m] = await Promise.all([
|
||||||
checkAccessibilityPermission(),
|
checkAccessibilityPermission(),
|
||||||
checkMicrophonePermission(),
|
checkMicrophonePermission(),
|
||||||
@@ -139,7 +209,13 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force
|
|||||||
const aOk = a === 'granted' || a === 'notApplicable';
|
const aOk = a === 'granted' || a === 'notApplicable';
|
||||||
const mOk = m === 'granted' || m === 'notApplicable';
|
const mOk = m === 'granted' || m === 'notApplicable';
|
||||||
setGate(aOk && mOk ? 'ready' : 'onboarding');
|
setGate(aOk && mOk ? 'ready' : 'onboarding');
|
||||||
})();
|
})().catch(error => {
|
||||||
|
console.warn('[startup] permission gate failed', error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setGate('ready');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
@@ -180,8 +256,25 @@ export function App({ isCapsule, isQa, isLessComputer, isLessComputerGlow, force
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<HotkeySettingsProvider>
|
<HotkeySettingsProvider>
|
||||||
{gate === 'onboarding' ? <Onboarding onComplete={() => setGate('ready')} /> : <FloatingShell os={os} />}
|
{platformCaps?.platform === 'android' && (
|
||||||
{gate === 'ready' && <AutoUpdateGate />}
|
<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>
|
</HotkeySettingsProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
// 录音提示音:监听 capsule:state 事件,在"开始录音"边沿播放合成提示音。
|
// 录音提示音:监听 capsule:state 事件,在"开始录音"边沿播放合成提示音。
|
||||||
// 独立组件,不依赖胶囊窗口显示——Linux 上胶囊隐藏也能正常工作。
|
// 独立组件,不依赖胶囊窗口显示——Linux 上胶囊隐藏也能正常工作。
|
||||||
// 全平台通用,在 FloatingShellBody 中渲染。
|
// Android Web Audio 输出会触发部分设备的录音输入路由切换,移动端禁用。
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { isTauri } from '../lib/ipc';
|
import { isAndroid, isTauri } from '../lib/ipc';
|
||||||
import { playRecordStartCue, primeAudioCue, stopAudioCue } from '../lib/audioCue';
|
import { playRecordStartCue, primeAudioCue, stopAudioCue } from '../lib/audioCue';
|
||||||
import type { CapsuleState, UserPreferences } from '../lib/types';
|
import type { CapsuleState, UserPreferences } from '../lib/types';
|
||||||
|
|
||||||
@@ -18,10 +18,11 @@ interface CapsulePayload {
|
|||||||
export function AudioCueListener() {
|
export function AudioCueListener() {
|
||||||
const audioCueEnabledRef = useRef<boolean>(true);
|
const audioCueEnabledRef = useRef<boolean>(true);
|
||||||
const prevStateRef = useRef<CapsuleState>('idle' as CapsuleState);
|
const prevStateRef = useRef<CapsuleState>('idle' as CapsuleState);
|
||||||
|
const audioCueRuntimeEnabled = !isAndroid();
|
||||||
|
|
||||||
// 读取设置(默认开启)
|
// 读取设置(默认开启)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri) return;
|
if (!isTauri || !audioCueRuntimeEnabled) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -33,11 +34,11 @@ export function AudioCueListener() {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, [audioCueRuntimeEnabled]);
|
||||||
|
|
||||||
// 监听设置变更
|
// 监听设置变更
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri) return;
|
if (!isTauri || !audioCueRuntimeEnabled) return;
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -48,17 +49,17 @@ export function AudioCueListener() {
|
|||||||
}).then(fn => { if (!cancelled) unlisten = fn; }).catch(() => {});
|
}).then(fn => { if (!cancelled) unlisten = fn; }).catch(() => {});
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; unlisten?.(); };
|
return () => { cancelled = true; unlisten?.(); };
|
||||||
}, []);
|
}, [audioCueRuntimeEnabled]);
|
||||||
|
|
||||||
// 预热 AudioContext
|
// 预热 AudioContext
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri) return;
|
if (!isTauri || !audioCueRuntimeEnabled) return;
|
||||||
primeAudioCue();
|
primeAudioCue();
|
||||||
}, []);
|
}, [audioCueRuntimeEnabled]);
|
||||||
|
|
||||||
// 监听 capsule 状态边沿
|
// 监听 capsule 状态边沿
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isTauri) return;
|
if (!isTauri || !audioCueRuntimeEnabled) return;
|
||||||
let unlisten: (() => void) | undefined;
|
let unlisten: (() => void) | undefined;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -75,7 +76,7 @@ export function AudioCueListener() {
|
|||||||
}).then(fn => { if (!cancelled) unlisten = fn; }).catch(() => {});
|
}).then(fn => { if (!cancelled) unlisten = fn; }).catch(() => {});
|
||||||
})();
|
})();
|
||||||
return () => { cancelled = true; unlisten?.(); };
|
return () => { cancelled = true; unlisten?.(); };
|
||||||
}, []);
|
}, [audioCueRuntimeEnabled]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -2,30 +2,21 @@
|
|||||||
// 状态机 + 对话框 UI。两边各自调用 useAutoUpdate(),dialog 渲染条件相同。
|
// 状态机 + 对话框 UI。两边各自调用 useAutoUpdate(),dialog 渲染条件相同。
|
||||||
//
|
//
|
||||||
// 渠道感知:check 不再走 plugin-updater 的 JS check()(它只看 tauri.conf 配的
|
// 渠道感知: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 直接
|
// Rust 那边按 prefs.update_channel 决定 manifest URL;返回的 metadata 直接
|
||||||
// `new Update(metadata)` 复用 plugin 的 download / install / close 实现,
|
// `new Update(metadata)` 复用 plugin 的 download / install / close 实现,
|
||||||
// 我们不重复造下载和签名校验。
|
// 我们不重复造下载和签名校验。
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
import type { DownloadEvent } from '@tauri-apps/plugin-updater';
|
import type { DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||||
import { Update } from '@tauri-apps/plugin-updater';
|
import { Update } from '@tauri-apps/plugin-updater';
|
||||||
import { useTranslation } from 'react-i18next';
|
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';
|
import { Btn } from '../pages/_atoms';
|
||||||
|
|
||||||
const UPDATE_CHECK_TIMEOUT_MS = 15_000;
|
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 =
|
export type UpdateStatus =
|
||||||
| 'idle'
|
| 'idle'
|
||||||
| 'checking'
|
| 'checking'
|
||||||
@@ -102,10 +93,10 @@ export function useAutoUpdate(): UseAutoUpdate {
|
|||||||
}
|
}
|
||||||
// Rust 侧按 update_channel 拼 manifest URL:Stable → tauri.conf 默认;
|
// Rust 侧按 update_channel 拼 manifest URL:Stable → tauri.conf 默认;
|
||||||
// Beta → fetch_latest_beta_release 拼出 -beta manifest URL 后再 check。
|
// Beta → fetch_latest_beta_release 拼出 -beta manifest URL 后再 check。
|
||||||
const metadata = await invoke<AppUpdateMetadata | null>('app_check_update_with_channel', {
|
const metadata = await appCheckUpdateWithChannel(
|
||||||
timeoutMs: UPDATE_CHECK_TIMEOUT_MS,
|
UPDATE_CHECK_TIMEOUT_MS,
|
||||||
channel: channel ?? null,
|
channel ?? null,
|
||||||
});
|
);
|
||||||
if (!metadata) {
|
if (!metadata) {
|
||||||
setStatus('none');
|
setStatus('none');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
// 受 prefs.autoUpdateCheck 开关控制;关闭时只走 Settings → 关于 的手动按钮。
|
// 受 prefs.autoUpdateCheck 开关控制;关闭时只走 Settings → 关于 的手动按钮。
|
||||||
// 找到新版本时直接挂 UpdateDialog;不弹自定义通知,沿用既有 dialog 视觉。
|
// 找到新版本时直接挂 UpdateDialog;不弹自定义通知,沿用既有 dialog 视觉。
|
||||||
|
|
||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate';
|
import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate';
|
||||||
|
import { getPlatformCapabilities } from '../lib/ipc';
|
||||||
|
import type { PlatformCapabilities } from '../lib/types';
|
||||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||||
|
|
||||||
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
|
||||||
@@ -12,7 +14,12 @@ const STARTUP_DELAY_MS = 4_000;
|
|||||||
export function AutoUpdateGate() {
|
export function AutoUpdateGate() {
|
||||||
const { prefs } = useHotkeySettings();
|
const { prefs } = useHotkeySettings();
|
||||||
const u = useAutoUpdate();
|
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 返回值。
|
// 用 ref 保持 tick 闭包始终读到最新的 useAutoUpdate 返回值。
|
||||||
// 之前直接捕获 `u` 会让 60min interval 触发时读旧 status 闭包——例如用户已经
|
// 之前直接捕获 `u` 会让 60min interval 触发时读旧 status 闭包——例如用户已经
|
||||||
@@ -43,6 +50,8 @@ export function AutoUpdateGate() {
|
|||||||
};
|
};
|
||||||
}, [enabled]);
|
}, [enabled]);
|
||||||
|
|
||||||
|
if (platformCaps?.supportsAutoUpdate !== true) return null;
|
||||||
|
|
||||||
if (!isDialogStatus(u.status)) return null;
|
if (!isDialogStatus(u.status)) return null;
|
||||||
return (
|
return (
|
||||||
<UpdateDialog
|
<UpdateDialog
|
||||||
|
|||||||
@@ -26,13 +26,14 @@ import {
|
|||||||
shouldShowHotkeyModeMigrationPrompt,
|
shouldShowHotkeyModeMigrationPrompt,
|
||||||
} from '../lib/hotkeyMigration';
|
} from '../lib/hotkeyMigration';
|
||||||
import { applyFontScale, readFontScale } from '../lib/fontScale';
|
import { applyFontScale, readFontScale } from '../lib/fontScale';
|
||||||
import { getCredentials } from '../lib/ipc';
|
import { getCredentials, getPlatformCapabilities } from '../lib/ipc';
|
||||||
import {
|
import {
|
||||||
PROVIDER_SETUP_PROMPT_DEFERRED_KEY,
|
PROVIDER_SETUP_PROMPT_DEFERRED_KEY,
|
||||||
shouldShowProviderSetupPrompt,
|
shouldShowProviderSetupPrompt,
|
||||||
} from '../lib/providerSetup';
|
} from '../lib/providerSetup';
|
||||||
import { type SettingsSectionId } from './SettingsModal';
|
import { type SettingsSectionId } from './SettingsModal';
|
||||||
import { useAppState, type AppTab } from '../state/useAppState';
|
import { useAppState, type AppTab } from '../state/useAppState';
|
||||||
|
import { useMobileLayout } from '../lib/useMobileLayout';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
id: AppTab;
|
id: AppTab;
|
||||||
@@ -71,6 +72,7 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
|
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionId | undefined>();
|
||||||
const [providerPromptOpen, setProviderPromptOpen] = useState(false);
|
const [providerPromptOpen, setProviderPromptOpen] = useState(false);
|
||||||
const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false);
|
const [hotkeyModePromptOpen, setHotkeyModePromptOpen] = useState(false);
|
||||||
|
const mobileLayout = useMobileLayout();
|
||||||
|
|
||||||
// tab 切换的 cross-fade:旧页 blur+fade out(180ms),结束后挂载新页(走 ol-page-slide enter)。
|
// tab 切换的 cross-fade:旧页 blur+fade out(180ms),结束后挂载新页(走 ol-page-slide enter)。
|
||||||
// displayTab 是实际渲染的 tab,currentTab 是用户点中的目标 tab。
|
// displayTab 是实际渲染的 tab,currentTab 是用户点中的目标 tab。
|
||||||
@@ -96,12 +98,17 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
[t],
|
[t],
|
||||||
);
|
);
|
||||||
const Page = (NAV.find((n) => n.id === displayTab) ?? NAV[0]).cmp;
|
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,
|
// sidebar nav 滑动指示器:测量当前 active button 的 offsetTop / height,
|
||||||
// 用一个 absolute pill 平滑滑过去,而不是每个按钮各自瞬切背景色。
|
// 用一个 absolute pill 平滑滑过去,而不是每个按钮各自瞬切背景色。
|
||||||
const navItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
const navItemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
|
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
if (mobileLayout) {
|
||||||
|
setPillRect(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (settingsOpen) {
|
if (settingsOpen) {
|
||||||
setPillRect(null);
|
setPillRect(null);
|
||||||
return;
|
return;
|
||||||
@@ -114,11 +121,13 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
const el = navItemRefs.current[idx];
|
const el = navItemRefs.current[idx];
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
|
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
|
||||||
}, [currentTab, settingsOpen, NAV]);
|
}, [currentTab, settingsOpen, NAV, mobileLayout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
|
const caps = await getPlatformCapabilities();
|
||||||
|
if (cancelled || caps.platform === 'android') return;
|
||||||
const credentials = await getCredentials();
|
const credentials = await getCredentials();
|
||||||
const promptDeferredValue = window.sessionStorage.getItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY);
|
const promptDeferredValue = window.sessionStorage.getItem(PROVIDER_SETUP_PROMPT_DEFERRED_KEY);
|
||||||
if (!cancelled && shouldShowProviderSetupPrompt(credentials, promptDeferredValue)) {
|
if (!cancelled && shouldShowProviderSetupPrompt(credentials, promptDeferredValue)) {
|
||||||
@@ -131,11 +140,18 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const acknowledgedValue = window.localStorage.getItem(HOTKEY_MODE_MIGRATION_ACK_KEY);
|
let cancelled = false;
|
||||||
const deferredValue = window.sessionStorage.getItem(HOTKEY_MODE_MIGRATION_DEFERRED_KEY);
|
void getPlatformCapabilities().then((caps) => {
|
||||||
if (shouldShowHotkeyModeMigrationPrompt(acknowledgedValue, deferredValue)) {
|
if (cancelled || caps.platform === 'android') return;
|
||||||
setHotkeyModePromptOpen(true);
|
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 已下线,
|
// 之前监听的 NAVIGATE_LOCAL_ASR_EVENT 已无意义——「模型设置」独立 tab 已下线,
|
||||||
@@ -181,37 +197,68 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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). */}
|
{/* Main shell — flush with the frosted backplate (no separate float). */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
flex: 1, minHeight: 0,
|
flex: 1, minHeight: 0,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
flexDirection: mobileLayout ? 'column' : 'row',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{/* Sidebar — 透明地坐在外层磨砂底板上,让 LOGO/导航/快捷键/BETA/footer 共用同一片磨砂玻璃 */}
|
{mobileLayout ? (
|
||||||
<aside
|
<div className="ol-aura-mobile-topbar">
|
||||||
style={{
|
<div className="ol-aura-mobile-brand">
|
||||||
width: 188,
|
<img
|
||||||
flexShrink: 0,
|
className="ol-aura-mobile-brand-mark"
|
||||||
display: 'flex', flexDirection: 'column',
|
src="AppIcon.png"
|
||||||
background: 'transparent',
|
alt="OpenLess"
|
||||||
padding: '10px 10px 12px',
|
/>
|
||||||
}}>
|
<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 */}
|
{/* brand */}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 9, padding: '2px 8px 12px' }}>
|
<div className="ol-aura-sidebar-brand">
|
||||||
<img
|
<img
|
||||||
|
className="ol-aura-sidebar-brand-mark"
|
||||||
src="AppIcon.png"
|
src="AppIcon.png"
|
||||||
alt="OpenLess"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* nav — 滑动指示器:active pill 是 absolute 元素,currentTab 改变时 top/height
|
{/* 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 }}>
|
<nav style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{pillRect && (
|
{pillRect && (
|
||||||
<div
|
<div
|
||||||
|
className="ol-aura-sidebar-pill"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -227,9 +275,6 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
right: 0,
|
right: 0,
|
||||||
top: pillRect.top,
|
top: pillRect.top,
|
||||||
height: pillRect.height,
|
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)',
|
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
@@ -243,18 +288,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
key={n.id}
|
key={n.id}
|
||||||
ref={el => { navItemRefs.current[i] = el; }}
|
ref={el => { navItemRefs.current[i] = el; }}
|
||||||
onClick={() => setCurrentTab(n.id)}
|
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={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
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} />
|
<Icon name={n.icon} size={14} />
|
||||||
@@ -269,31 +305,10 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
{/* 底部两行:上行 = 版本 chip(含 BETA 标),下行 = 设置按钮。
|
{/* 底部两行:上行 = 版本 chip(含 BETA 标),下行 = 设置按钮。
|
||||||
单行布局在窄 sidebar 下会把「设置」挤成两行竖字 + 版本糊一起;
|
单行布局在窄 sidebar 下会把「设置」挤成两行竖字 + 版本糊一起;
|
||||||
翻回两行同时把顺序反过来:设置真正落到最底,版本在它上面。 */}
|
翻回两行同时把顺序反过来:设置真正落到最底,版本在它上面。 */}
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, paddingTop: 10 }}>
|
<div className="ol-aura-sidebar-footer">
|
||||||
<div
|
<div className="ol-aura-sidebar-version">
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 8,
|
|
||||||
flexWrap: 'wrap',
|
|
||||||
padding: '0 10px',
|
|
||||||
fontFamily: 'var(--ol-font-sans)',
|
|
||||||
fontSize: 11,
|
|
||||||
color: 'var(--ol-ink-4)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{IS_BETA_BUILD && (
|
{IS_BETA_BUILD && (
|
||||||
<span style={{
|
<span className="ol-aura-beta-tag">{t('shell.betaTag')}</span>
|
||||||
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>{t('shell.footer.version', { version: APP_VERSION_LABEL })}</span>
|
<span>{t('shell.footer.version', { version: APP_VERSION_LABEL })}</span>
|
||||||
@@ -301,39 +316,33 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => openSettings()}
|
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={{
|
style={{
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
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} />
|
<Icon name="settings" size={14} />
|
||||||
<span style={{ flex: 1 }}>{t('shell.footer.settings')}</span>
|
<span style={{ flex: 1 }}>{t('shell.footer.settings')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main content — Linux 禁用透明窗口后使用不透明面;其他平台保留玻璃层。
|
{/* Main content — Linux 禁用透明窗口后使用不透明面;其他平台保留玻璃层。 */}
|
||||||
悬浮台到右边 / 下边的间距相等(都 8px),左侧贴 sidebar(0)。 */}
|
<div
|
||||||
<div style={{ flex: 1, minWidth: 0, padding: '4px 8px 8px 0', display: 'flex' }}>
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
minHeight: 0,
|
||||||
|
padding: mobileLayout ? '0' : '4px 8px 6px 0',
|
||||||
|
display: 'flex',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<main
|
<main
|
||||||
className="ol-console-main"
|
className="ol-console-main ol-aura-panel ol-aura-console-main"
|
||||||
style={{
|
style={{
|
||||||
flex: 1, minWidth: 0,
|
flex: 1, minWidth: 0,
|
||||||
overflow: 'hidden',
|
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',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}}
|
}}
|
||||||
@@ -358,7 +367,9 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
style={{
|
style={{
|
||||||
flex: 1, minHeight: 0,
|
flex: 1, minHeight: 0,
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
padding: '24px 28px 32px',
|
padding: mobileLayout
|
||||||
|
? '16px 14px 18px'
|
||||||
|
: '24px 28px 32px',
|
||||||
// position:relative 让页面里的"已保存"toast 用 absolute top:16 right:16
|
// position:relative 让页面里的"已保存"toast 用 absolute top:16 right:16
|
||||||
// 锚到这块控制台卡的右上角,而不是横在页头变成长横幅。
|
// 锚到这块控制台卡的右上角,而不是横在页头变成长横幅。
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -379,6 +390,25 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Settings modal — rendered inside this window */}
|
{/* Settings modal — rendered inside this window */}
|
||||||
@@ -416,12 +446,205 @@ function FloatingShellBody({ os, initialTab, initialSettings }: { os: OS; initia
|
|||||||
color: var(--ol-ink-3);
|
color: var(--ol-ink-3);
|
||||||
font-weight: 500;
|
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 {
|
.ol-nav-btn.ol-nav-btn-active {
|
||||||
color: var(--ol-ink);
|
color: var(--ol-ink);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.ol-nav-btn:not(.ol-nav-btn-active):hover {
|
.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);
|
color: var(--ol-ink);
|
||||||
}
|
}
|
||||||
@keyframes ol-page-slide {
|
@keyframes ol-page-slide {
|
||||||
@@ -457,7 +680,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void;
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 28,
|
padding: 28,
|
||||||
background: 'rgba(15,17,22,0.28)',
|
background: 'var(--ol-overlay-bg)',
|
||||||
backdropFilter: 'blur(6px) saturate(140%)',
|
backdropFilter: 'blur(6px) saturate(140%)',
|
||||||
WebkitBackdropFilter: 'blur(6px) saturate(140%)',
|
WebkitBackdropFilter: 'blur(6px) saturate(140%)',
|
||||||
animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)',
|
animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)',
|
||||||
@@ -522,7 +745,7 @@ function ProviderSetupPrompt({ onLater, onOpenSettings }: { onLater: () => void;
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: 0,
|
border: 0,
|
||||||
background: 'var(--ol-ink)',
|
background: 'var(--ol-ink)',
|
||||||
color: '#fff',
|
color: 'var(--ol-on-accent)',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
@@ -550,7 +773,7 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () =>
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
padding: 28,
|
padding: 28,
|
||||||
background: 'rgba(15,17,22,0.28)',
|
background: 'var(--ol-overlay-bg)',
|
||||||
backdropFilter: 'blur(6px) saturate(140%)',
|
backdropFilter: 'blur(6px) saturate(140%)',
|
||||||
WebkitBackdropFilter: 'blur(6px) saturate(140%)',
|
WebkitBackdropFilter: 'blur(6px) saturate(140%)',
|
||||||
animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)',
|
animation: 'ol-prompt-fade 0.2s var(--ol-motion-soft)',
|
||||||
@@ -615,7 +838,7 @@ function HotkeyModeMigrationPrompt({ onLater, onOpenSettings }: { onLater: () =>
|
|||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: 0,
|
border: 0,
|
||||||
background: 'var(--ol-ink)',
|
background: 'var(--ol-ink)',
|
||||||
color: '#fff',
|
color: 'var(--ol-on-accent)',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
fontSize: 12.5,
|
fontSize: 12.5,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
|
|||||||
@@ -1,26 +1,280 @@
|
|||||||
// Onboarding.tsx — 首次运行权限引导。
|
// Onboarding.tsx — first-run permission and service setup.
|
||||||
//
|
|
||||||
// 触发条件:App.tsx 启动检查 accessibility + microphone,任一未授权则渲染本组件而非主 Shell。
|
|
||||||
// 与 Swift `Sources/OpenLessApp/Onboarding/` 同语义,但简化为单页三步。
|
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AndroidPermissionsPanel } from '@android/components/AndroidPermissionsPanel';
|
||||||
|
import { checkAndroidMicrophoneAccess, requestAndroidMicrophoneAccess } from '@android/lib/androidMicrophonePermission';
|
||||||
import {
|
import {
|
||||||
checkAccessibilityPermission,
|
checkAccessibilityPermission,
|
||||||
checkMicrophonePermission,
|
checkMicrophonePermission,
|
||||||
|
getPlatformCapabilities,
|
||||||
openSystemSettings,
|
openSystemSettings,
|
||||||
requestAccessibilityPermission,
|
requestAccessibilityPermission,
|
||||||
requestMicrophonePermission,
|
requestMicrophonePermission,
|
||||||
} from '../lib/ipc';
|
} from '../lib/ipc';
|
||||||
import { getHotkeyTriggerLabel } from '../lib/hotkey';
|
import { getHotkeyTriggerLabel } from '../lib/hotkey';
|
||||||
import type { PermissionStatus } from '../lib/types';
|
import type { PermissionStatus, PlatformCapabilities } from '../lib/types';
|
||||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||||
|
import { ProvidersSection } from '../pages/settings/ProvidersSection';
|
||||||
|
|
||||||
interface OnboardingProps {
|
interface OnboardingProps {
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AndroidStepId =
|
||||||
|
| 'microphone'
|
||||||
|
| 'accessibility'
|
||||||
|
| 'overlayPermission'
|
||||||
|
| 'overlayConfig'
|
||||||
|
| 'asr'
|
||||||
|
| 'llm';
|
||||||
|
|
||||||
export function Onboarding({ onComplete }: OnboardingProps) {
|
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 { t } = useTranslation();
|
||||||
const [accessibility, setAccessibility] = useState<PermissionStatus>('notDetermined');
|
const [accessibility, setAccessibility] = useState<PermissionStatus>('notDetermined');
|
||||||
const [microphone, setMicrophone] = 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 refreshTimeoutRef = useRef<number | null>(null);
|
||||||
const { capability } = useHotkeySettings();
|
const { capability } = useHotkeySettings();
|
||||||
|
|
||||||
|
const requiresAccessibility = !!capability?.requiresAccessibilityPermission;
|
||||||
|
|
||||||
const refresh = async () => {
|
const refresh = async () => {
|
||||||
const [a, m] = await Promise.all([
|
const [a, m] = await Promise.all([
|
||||||
checkAccessibilityPermission(),
|
checkAccessibilityPermission(),
|
||||||
@@ -35,7 +291,9 @@ export function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
]);
|
]);
|
||||||
setAccessibility(a);
|
setAccessibility(a);
|
||||||
setMicrophone(m);
|
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();
|
onComplete();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -43,7 +301,6 @@ export function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh();
|
refresh();
|
||||||
const id = window.setInterval(refresh, 1000);
|
const id = window.setInterval(refresh, 1000);
|
||||||
// 用户从系统设置切回来时立刻刷新
|
|
||||||
const onFocus = () => refresh();
|
const onFocus = () => refresh();
|
||||||
window.addEventListener('focus', onFocus);
|
window.addEventListener('focus', onFocus);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -51,7 +308,7 @@ export function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
window.removeEventListener('focus', onFocus);
|
window.removeEventListener('focus', onFocus);
|
||||||
if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current);
|
if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [requiresAccessibility]);
|
||||||
|
|
||||||
const onGrantAccessibility = async () => {
|
const onGrantAccessibility = async () => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -83,74 +340,45 @@ export function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<OnboardingSurface>
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 40,
|
|
||||||
fontFamily: 'var(--ol-font-sans)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 520,
|
width: 'min(520px, 100%)',
|
||||||
padding: 32,
|
padding: 32,
|
||||||
|
boxSizing: 'border-box',
|
||||||
background: 'var(--ol-surface)',
|
background: 'var(--ol-surface)',
|
||||||
borderRadius: 14,
|
borderRadius: 14,
|
||||||
border: '0.5px solid var(--ol-line)',
|
border: '0.5px solid var(--ol-line)',
|
||||||
boxShadow: 'var(--ol-shadow-lg)',
|
boxShadow: 'var(--ol-shadow-lg)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 18 }}>
|
<BrandHeader title={t('onboarding.welcome')} desc={t('onboarding.intro')} />
|
||||||
<div
|
|
||||||
style={{
|
{requiresAccessibility && (
|
||||||
width: 52,
|
<PermissionStep
|
||||||
height: 52,
|
index={1}
|
||||||
borderRadius: 13,
|
title={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityTitle') : t('onboarding.hotkeyTitle')}
|
||||||
background: 'linear-gradient(135deg, #0a0a0b 0%, #2563eb 100%)',
|
desc={capability?.requiresAccessibilityPermission
|
||||||
color: '#fff',
|
? t('onboarding.accessibilityDesc', { trigger: getHotkeyTriggerLabel(capability.availableTriggers[0]) })
|
||||||
fontSize: 22,
|
: capability?.statusHint ?? t('onboarding.hotkeyDesc')}
|
||||||
fontWeight: 700,
|
status={accessibility}
|
||||||
display: 'flex',
|
actionLabel={
|
||||||
alignItems: 'center',
|
!capability?.requiresAccessibilityPermission || accessibility === 'notApplicable'
|
||||||
justifyContent: 'center',
|
? t('onboarding.actionNotApplicable')
|
||||||
}}
|
: accessibility === 'granted'
|
||||||
>
|
? t('onboarding.actionGranted')
|
||||||
OL
|
: accessibility === 'denied'
|
||||||
</div>
|
? t('onboarding.actionOpenSystem')
|
||||||
<div>
|
: t('onboarding.actionGrant')
|
||||||
<div style={{ fontSize: 18, fontWeight: 600 }}>{t('onboarding.welcome')}</div>
|
}
|
||||||
<div style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', marginTop: 2 }}>
|
onAction={onGrantAccessibility}
|
||||||
{t('onboarding.intro')}
|
disabled={busy || !capability?.requiresAccessibilityPermission || accessibility === 'granted' || accessibility === 'notApplicable'}
|
||||||
</div>
|
hint={capability?.requiresAccessibilityPermission ? t('onboarding.accessibilityHint') : undefined}
|
||||||
</div>
|
/>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<PermissionStep
|
<PermissionStep
|
||||||
index={1}
|
index={requiresAccessibility ? 2 : 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}
|
|
||||||
title={t('onboarding.micTitle')}
|
title={t('onboarding.micTitle')}
|
||||||
desc={t('onboarding.micDesc')}
|
desc={t('onboarding.micDesc')}
|
||||||
status={microphone}
|
status={microphone}
|
||||||
@@ -165,24 +393,112 @@ export function Onboarding({ onComplete }: OnboardingProps) {
|
|||||||
disabled={busy || microphone === 'granted'}
|
disabled={busy || microphone === 'granted'}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div style={footerHintStyle}>
|
||||||
style={{
|
|
||||||
marginTop: 18,
|
|
||||||
padding: '12px 14px',
|
|
||||||
borderRadius: 8,
|
|
||||||
background: 'var(--ol-surface-2)',
|
|
||||||
fontSize: 11.5,
|
|
||||||
color: 'var(--ol-ink-3)',
|
|
||||||
lineHeight: 1.6,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('onboarding.footerHint')}
|
{t('onboarding.footerHint')}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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 {
|
interface StepProps {
|
||||||
index: number;
|
index: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -255,3 +571,54 @@ function PermissionStep({ index, title, desc, status, actionLabel, onAction, dis
|
|||||||
</div>
|
</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):原本是「外层弹窗侧栏 + 设置页内层侧栏」双层嵌套,用户点
|
// 重构(2026-05):原本是「外层弹窗侧栏 + 设置页内层侧栏」双层嵌套,用户点
|
||||||
// 「设置」还要再面对第二个侧栏。现在拍平成单层 —— 通用 / 服务 / 隐私 / 高级 /
|
// 「设置」还要再面对第二个侧栏。现在拍平成单层 —— 通用 / 服务 / 隐私 / 高级 /
|
||||||
// 个性化 / 关于 六个 tab + 帮助外链组。每个 tab 的内容见 pages/settings/。
|
// 个性化 / 关于 六个 tab。每个 tab 的内容见 pages/settings/。
|
||||||
//
|
//
|
||||||
// 设计原则:每个可见控件都必须可用。没有后端支撑的占位(账号 / 主题切换 等)
|
// 设计原则:每个可见控件都必须可用。没有后端支撑的占位(账号 / 主题切换 等)
|
||||||
// 不在此弹窗出现。
|
// 不在此弹窗出现。
|
||||||
|
|
||||||
import { useLayoutEffect, useRef, useState } from 'react';
|
import { useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import { SavedToast } from './SavedToast';
|
import { SavedToast } from './SavedToast';
|
||||||
import { useSavedToastListener } from '../lib/savedEvent';
|
import { useSavedToastListener } from '../lib/savedEvent';
|
||||||
import { openExternal } from '../lib/ipc';
|
|
||||||
import type { OS } from './WindowChrome';
|
import type { OS } from './WindowChrome';
|
||||||
import { GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs';
|
import { GeneralTab, ServicesTab, PrivacyTab, AdvancedTab } from '../pages/settings/tabs';
|
||||||
import { AboutSection } from '../pages/settings/AboutSection';
|
import { AboutSection } from '../pages/settings/AboutSection';
|
||||||
|
import { useMobileLayout } from '../lib/useMobileLayout';
|
||||||
|
|
||||||
// 稳定 tab ID(与 i18n key `modal.sections.*` 一致)。
|
// 稳定 tab ID(与 i18n key `modal.sections.*` 一致)。
|
||||||
export type SettingsSectionId =
|
export type SettingsSectionId =
|
||||||
@@ -35,14 +34,8 @@ interface SettingsModalProps {
|
|||||||
interface ModalNavItem {
|
interface ModalNavItem {
|
||||||
id: string;
|
id: string;
|
||||||
icon: 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[] = [
|
const TAB_ITEMS: ModalNavItem[] = [
|
||||||
{ id: 'general', icon: 'settings' },
|
{ id: 'general', icon: 'settings' },
|
||||||
{ id: 'services', icon: 'cloud' },
|
{ id: 'services', icon: 'cloud' },
|
||||||
@@ -50,71 +43,82 @@ const TAB_ITEMS: ModalNavItem[] = [
|
|||||||
{ id: 'advanced', icon: 'bolt' },
|
{ id: 'advanced', icon: 'bolt' },
|
||||||
{ id: 'about', icon: 'info' },
|
{ 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) {
|
export function SettingsModal({ os: _os, onClose, initialSettingsSection }: SettingsModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [section, setSection] = useState<SettingsSectionId>(initialSettingsSection ?? 'general');
|
const [section, setSection] = useState<SettingsSectionId>(initialSettingsSection ?? 'general');
|
||||||
const savedToast = useSavedToastListener();
|
const savedToast = useSavedToastListener();
|
||||||
|
const mobile = useMobileLayout();
|
||||||
|
|
||||||
// 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill。
|
// 与 sidebar nav 一致的滑动指示器:仅 tab 组有 pill;外链组永远不画 pill。
|
||||||
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
|
const [pillRect, setPillRect] = useState<{ top: number; height: number } | null>(null);
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
if (mobile) {
|
||||||
|
setPillRect(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const idx = TAB_ITEMS.findIndex(it => it.id === section);
|
const idx = TAB_ITEMS.findIndex(it => it.id === section);
|
||||||
const el = tabRefs.current[idx];
|
const el = tabRefs.current[idx];
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
|
setPillRect({ top: el.offsetTop, height: el.offsetHeight });
|
||||||
}, [section]);
|
}, [section, mobile]);
|
||||||
|
|
||||||
// issue #580:用 Portal 渲染到 document.body,脱离页面 overflow:hidden 容器的
|
return (
|
||||||
// stacking context。否则 WebKitGTK(Debian/KDE Wayland)下页面自绘滚动条
|
|
||||||
// (.ol-thinscroll) 不创建独立合成层,z-index 无法隔离,滚动时会盖在弹窗之上。
|
|
||||||
// 配合 position:fixed 覆盖整窗。
|
|
||||||
return createPortal(
|
|
||||||
<div
|
<div
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
position: 'fixed', inset: 0,
|
position: 'absolute', inset: 0,
|
||||||
background: 'rgba(15,17,22,0.32)',
|
background: 'var(--ol-overlay-bg)',
|
||||||
backdropFilter: 'blur(8px) saturate(140%)',
|
backdropFilter: 'blur(8px) saturate(140%)',
|
||||||
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
|
WebkitBackdropFilter: 'blur(8px) saturate(140%)',
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
padding: 28,
|
padding: mobile ? 0 : 28,
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
|
animation: 'ol-modal-backdrop-in 0.18s var(--ol-motion-soft)',
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
className="ol-aura-settings"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
width: '100%', maxWidth: 880, height: '100%', maxHeight: 600,
|
width: '100%',
|
||||||
background: 'var(--ol-surface)',
|
maxWidth: 920,
|
||||||
borderRadius: 14,
|
height: '100%',
|
||||||
border: '0.5px solid rgba(0,0,0,.08)',
|
maxHeight: mobile ? 'none' : 620,
|
||||||
boxShadow: '0 30px 80px -20px rgba(15,17,22,.35), 0 0 0 0.5px rgba(0,0,0,.06)',
|
display: 'flex',
|
||||||
display: 'flex', overflow: 'hidden',
|
flexDirection: mobile ? 'column' : 'row',
|
||||||
|
overflow: 'hidden',
|
||||||
animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)',
|
animation: 'ol-modal-card-in 0.24s var(--ol-motion-spring)',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{/* ─── 单层侧栏 ────────────────────────────────────────────── */}
|
{/* ─── 单层侧栏 ────────────────────────────────────────────── */}
|
||||||
<aside
|
<aside
|
||||||
|
className="ol-aura-settings-rail"
|
||||||
style={{
|
style={{
|
||||||
width: 200, flexShrink: 0,
|
width: mobile ? '100%' : 214,
|
||||||
background: 'rgba(247,247,250,0.7)',
|
flexShrink: 0,
|
||||||
borderRight: '0.5px solid var(--ol-line-soft)',
|
display: 'flex',
|
||||||
padding: '18px 12px',
|
flexDirection: mobile ? 'row' : 'column',
|
||||||
display: 'flex', flexDirection: 'column', gap: 14,
|
|
||||||
}}>
|
}}>
|
||||||
|
|
||||||
{/* tab 组 */}
|
{/* 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 && (
|
{pillRect && (
|
||||||
<div
|
<div
|
||||||
|
className="ol-aura-settings-pill"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -122,9 +126,6 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
|||||||
right: 0,
|
right: 0,
|
||||||
top: pillRect.top,
|
top: pillRect.top,
|
||||||
height: pillRect.height,
|
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)',
|
transition: 'top 0.36s var(--ol-motion-spring), height 0.36s var(--ol-motion-spring)',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
zIndex: 0,
|
zIndex: 0,
|
||||||
@@ -138,35 +139,30 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
|||||||
key={it.id}
|
key={it.id}
|
||||||
ref={el => { tabRefs.current[idx] = el; }}
|
ref={el => { tabRefs.current[idx] = el; }}
|
||||||
onClick={() => setSection(it.id as SettingsSectionId)}
|
onClick={() => setSection(it.id as SettingsSectionId)}
|
||||||
className={active ? 'ol-nav-btn ol-nav-btn-active' : 'ol-nav-btn'}
|
className={active ? 'ol-nav-btn ol-nav-btn-active ol-aura-settings-nav-btn' : 'ol-nav-btn ol-aura-settings-nav-btn'}
|
||||||
style={navBtnStyle}>
|
style={{ ...navBtnStyle, flexShrink: 0 }}>
|
||||||
<Icon name={it.icon} size={14} />
|
<Icon name={it.icon} size={14} />
|
||||||
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
|
<span style={{ flex: 1 }}>{t(`modal.sections.${it.id}`)}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</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>
|
</aside>
|
||||||
|
|
||||||
{/* ─── 内容区 ──────────────────────────────────────────────
|
{/* ─── 内容区 ──────────────────────────────────────────────
|
||||||
父容器 overflow:hidden + 列向 flex;关闭按钮、section 标题固定在头部,
|
父容器 overflow:hidden + 列向 flex;关闭按钮、section 标题固定在头部,
|
||||||
只有最里层的 scroll wrapper 真正滚动。 */}
|
只有最里层的 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。 */}
|
{/* "已保存" toast:right:54 避开 28×28 关闭按钮 + 12px gap。 */}
|
||||||
<SavedToast
|
<SavedToast
|
||||||
saveState={savedToast.state}
|
saveState={savedToast.state}
|
||||||
@@ -175,28 +171,32 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
|||||||
offsetStyle={{ position: 'absolute', top: 16, right: 54 }}
|
offsetStyle={{ position: 'absolute', top: 16, right: 54 }}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
|
className="ol-aura-settings-close"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute', top: 14, right: 14, zIndex: 2,
|
position: 'absolute', top: 14, right: 14, zIndex: 2,
|
||||||
width: 28, height: 28, border: 0, borderRadius: 999,
|
width: 28, height: 28, border: 0, borderRadius: 999,
|
||||||
background: 'transparent', color: 'var(--ol-ink-3)',
|
|
||||||
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
|
||||||
cursor: 'default',
|
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')}>
|
title={t('common.close')}>
|
||||||
<Icon name="close" size={14} />
|
<Icon name="close" size={14} />
|
||||||
</button>
|
</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}`)}
|
{t(`modal.sections.${section}`)}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="ol-thinscroll"
|
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 轻微淡入。 */}
|
{/* key=section 让切 tab 时整块重挂载,ol-tab-fade 轻微淡入。 */}
|
||||||
<div
|
<div
|
||||||
key={section}
|
key={section}
|
||||||
@@ -210,19 +210,77 @@ export function SettingsModal({ os: _os, onClose, initialSettingsSection }: Sett
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
<style>{`
|
||||||
document.body,
|
.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 = {
|
const navBtnStyle = {
|
||||||
display: 'flex', alignItems: 'center', gap: 10,
|
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';
|
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 {
|
export function detectOS(): OS {
|
||||||
if (typeof navigator === 'undefined') return 'mac';
|
if (typeof navigator === 'undefined') return 'mac';
|
||||||
@@ -9,6 +9,7 @@ export function detectOS(): OS {
|
|||||||
).userAgentData?.platform ?? '';
|
).userAgentData?.platform ?? '';
|
||||||
const hints = `${navigator.userAgent || ''} ${navigator.platform || ''} ${uaDataPlatform}`;
|
const hints = `${navigator.userAgent || ''} ${navigator.platform || ''} ${uaDataPlatform}`;
|
||||||
if (/Mac|iPhone|iPad|iPod/.test(hints)) return 'mac';
|
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 (/Windows|Win32|Win64/.test(hints)) return 'win';
|
||||||
if (/Linux|X11|Wayland/.test(hints)) return 'linux';
|
if (/Linux|X11|Wayland/.test(hints)) return 'linux';
|
||||||
return 'mac';
|
return 'mac';
|
||||||
@@ -33,8 +34,8 @@ export function WindowChrome({
|
|||||||
}: WindowChromeProps) {
|
}: WindowChromeProps) {
|
||||||
// Windows: decorations:true 时外层不画圆角/边框/阴影/标题栏,避免与原生窗口重叠。
|
// Windows: decorations:true 时外层不画圆角/边框/阴影/标题栏,避免与原生窗口重叠。
|
||||||
// Linux: decorations:false 时外层画 14px 圆角 + 自定义标题栏。
|
// Linux: decorations:false 时外层画 14px 圆角 + 自定义标题栏。
|
||||||
const shellRadius = os === 'mac' ? 0 : os === 'win' ? 0 : 14;
|
const shellRadius = os === 'mac' ? 0 : os === 'win' || os === 'android' ? 0 : 14;
|
||||||
const consoleRadius = os === 'mac' ? 20 : os === 'win' ? WIN_CONSOLE_RADIUS : 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;
|
const titlebarHeight = os === 'mac' ? MAC_TITLEBAR_HEIGHT : os === 'linux' ? LINUX_TITLEBAR_HEIGHT : 0;
|
||||||
|
|
||||||
// macOS / Windows 共用半透明玻璃 background + backdropFilter。
|
// macOS / Windows 共用半透明玻璃 background + backdropFilter。
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Row — two-column row used in the Settings modal sub-sections.
|
// Row — two-column row used in the Settings modal sub-sections.
|
||||||
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { useMobileLayout } from '../../lib/useMobileLayout';
|
||||||
|
|
||||||
interface RowProps {
|
interface RowProps {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -9,13 +10,14 @@ interface RowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Row({ label, desc, children }: RowProps) {
|
export function Row({ label, desc, children }: RowProps) {
|
||||||
|
const mobile = useMobileLayout();
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '180px 1fr', gap: 16, padding: '12px 0', borderTop: '0.5px solid var(--ol-line-soft)' }}>
|
<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>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 13, fontWeight: 500, color: 'var(--ol-ink)' }}>{label}</div>
|
<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>}
|
{desc && <div style={{ fontSize: 11.5, color: 'var(--ol-ink-4)', marginTop: 4, lineHeight: 1.5 }}>{desc}</div>}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Icon } from '../Icon';
|
import { Icon } from '../Icon';
|
||||||
|
import { useMobileLayout } from '../../lib/useMobileLayout';
|
||||||
|
|
||||||
export interface SelectOption {
|
export interface SelectOption {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -81,6 +82,7 @@ export function SelectLite({
|
|||||||
ariaLabel,
|
ariaLabel,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
}: SelectLiteProps) {
|
}: SelectLiteProps) {
|
||||||
|
const mobile = useMobileLayout();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
// leaving 让 popover 在卸载前播完 exit keyframe(用户报"没有收缩动画"——之前直接 unmount)
|
// leaving 让 popover 在卸载前播完 exit keyframe(用户报"没有收缩动画"——之前直接 unmount)
|
||||||
const [leaving, setLeaving] = useState(false);
|
const [leaving, setLeaving] = useState(false);
|
||||||
@@ -264,6 +266,14 @@ export function SelectLite({
|
|||||||
const triggerStyle: CSSProperties = {
|
const triggerStyle: CSSProperties = {
|
||||||
...DEFAULT_TRIGGER_STYLE,
|
...DEFAULT_TRIGGER_STYLE,
|
||||||
...style,
|
...style,
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
...(mobile
|
||||||
|
? {
|
||||||
|
width: style?.width ?? '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: '100%',
|
||||||
|
}
|
||||||
|
: null),
|
||||||
opacity: disabled ? 0.5 : 1,
|
opacity: disabled ? 0.5 : 1,
|
||||||
cursor: disabled ? 'not-allowed' : 'default',
|
cursor: disabled ? 'not-allowed' : 'default',
|
||||||
};
|
};
|
||||||
|
|||||||