Merge pull request #658 from HKLHaoBin/feat/android-release-pipeline

feat(android): 对齐移动端构建与发布流水线
This commit is contained in:
Chris233 authored and GitHub committed 2026-06-14 14:12:03 +08:00
commit 8e8f5fdeb5
25 files changed
+1063 -93

No files matched your search

+166 -21
View File
@@ -1,10 +1,10 @@
name: Android APK (debug)
# Triggers:
# - push v*-tauri tag → build debug APK, upload artifact + attach to GitHub Release
# - workflow_dispatch → build debug APK, upload artifact only (no release)
# - push v*-tauri tag → release APK (signed), updater manifest, attach to GitHub Release
# - workflow_dispatch → debug APK only, upload artifact (no release)
#
# Scope: full overlay/accessibility APK for ADB testing (v1 RECORD_AUDIO + overlay manifest merge).
# Scope: full overlay/accessibility APK for ADB testing and tag releases.
on:
push:
@@ -19,11 +19,58 @@ jobs:
runs-on: ubuntu-latest
env:
CI: true
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
OPENLESS_RELEASE_CHANNEL: ${{ endsWith(github.ref_name, '-beta-tauri') && 'beta' || 'stable' }}
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Detect build mode
id: mode
shell: bash
run: |
if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then
echo "mode=release" >> "$GITHUB_OUTPUT"
echo "label=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
else
echo "mode=debug" >> "$GITHUB_OUTPUT"
echo "label=run-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
fi
- name: Check updater signing availability (tag release)
if: steps.mode.outputs.mode == 'release'
shell: bash
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
run: |
if [ -z "${TAURI_SIGNING_PRIVATE_KEY:-}" ]; then
echo "::error::TAURI_SIGNING_PRIVATE_KEY is required for signed Android release artifacts."
exit 1
fi
- name: Check Android keystore secrets (tag release)
if: steps.mode.outputs.mode == 'release'
shell: bash
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
missing=()
for name in ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do
if [ -z "${!name:-}" ]; then
missing+=("$name")
fi
done
if [ "${#missing[@]}" -gt 0 ]; then
echo "::error::Android release signing secrets are required for tag builds: ${missing[*]}"
echo "::error::Configure ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD in repo secrets."
exit 1
fi
- name: Setup Java 17 (Zulu)
uses: actions/setup-java@v4
with:
@@ -101,6 +148,20 @@ jobs:
working-directory: openless-all/app
run: node scripts/merge-android-overlay-manifest.mjs
- name: Merge updater / install manifest
working-directory: openless-all/app
run: node scripts/merge-android-updater-manifest.mjs
- name: Configure Android release signing
if: steps.mode.outputs.mode == 'release'
working-directory: openless-all/app
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: node scripts/configure-android-release-signing.mjs
- name: Prime Gradle wrapper
working-directory: openless-all/app
shell: bash
@@ -117,8 +178,14 @@ jobs:
exit 1
- name: Build Android debug APK
if: steps.mode.outputs.mode == 'debug'
working-directory: openless-all/app
run: npm run tauri:android:build
run: npm run tauri:android:build:debug
- name: Build Android release APK
if: steps.mode.outputs.mode == 'release'
working-directory: openless-all/app
run: npm run tauri:android:build:release
- name: Free disk before artifact upload
shell: bash
@@ -129,19 +196,17 @@ jobs:
rm -rf ~/.cargo/registry ~/.cargo/git ~/.gradle/caches
df -h
- name: Collect split debug APKs
- name: Collect split APKs
id: apk
shell: bash
working-directory: openless-all/app
env:
OPENLESS_APK_MODE: ${{ steps.mode.outputs.mode }}
OPENLESS_APK_LABEL: ${{ steps.mode.outputs.label }}
run: |
set -euo pipefail
if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then
label="${{ github.ref_name }}"
else
label="run-${{ github.run_number }}"
fi
export OPENLESS_APK_LABEL="$label"
python - <<'PY'
import json
import os
import shutil
import sys
@@ -154,9 +219,17 @@ jobs:
"x86": "x86",
"x86_64": "x86_64",
}
arch_map = {
"arm64-v8a": "aarch64",
"armeabi-v7a": "armv7",
"x86": "i686",
"x86_64": "x86_64",
}
root = Path("src-tauri/gen/android")
mode = os.environ["OPENLESS_APK_MODE"]
label = os.environ["OPENLESS_APK_LABEL"]
out_dir = Path(os.environ["RUNNER_TEMP"]) / "openless-android-debug-split"
version = json.loads(Path("package.json").read_text(encoding="utf-8"))["version"]
out_dir = Path(os.environ["RUNNER_TEMP"]) / f"openless-android-{mode}-split"
out_dir.mkdir(parents=True, exist_ok=True)
found = {}
candidates = [
@@ -185,7 +258,10 @@ jobs:
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"
if mode == "release":
dest = out_dir / f"OpenLess_{version}_{abi}.apk"
else:
dest = out_dir / f"OpenLess-android-debug-{abi}-{label}.apk"
shutil.copy2(apk, dest)
found[abi] = dest
print(f"Collected {abi}: {apk} -> {dest}")
@@ -196,46 +272,115 @@ jobs:
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
for abi, key in expected.items():
output.write(f"{key}_path={found[abi]}\n")
output.write(f"{key}_arch={arch_map[abi]}\n")
output.write(f"out_dir={out_dir}\n")
output.write("release_files<<EOF\n")
for abi in expected:
output.write(f"{found[abi]}\n")
output.write("EOF\n")
PY
- name: Sign release APKs (minisign)
if: steps.mode.outputs.mode == 'release'
working-directory: openless-all/app
env:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: |
set -euo pipefail
node scripts/sign-android-apks.mjs \
"${{ steps.apk.outputs.arm64_v8a_path }}" \
"${{ steps.apk.outputs.armeabi_v7a_path }}" \
"${{ steps.apk.outputs.x86_path }}" \
"${{ steps.apk.outputs.x86_64_path }}"
- name: Write Android updater manifests
if: steps.mode.outputs.mode == 'release'
working-directory: openless-all/app
env:
OPENLESS_UPDATE_APK_DIR: ${{ steps.apk.outputs.out_dir }}
OPENLESS_UPDATE_REPO: appergb/openless
OPENLESS_UPDATE_MIRROR_BASE_URL: https://fastgit.cc/https://github.com
OPENLESS_RELEASE_CHANNEL: ${{ env.OPENLESS_RELEASE_CHANNEL }}
OPENLESS_RELEASE_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
for arch in aarch64 armv7 i686 x86_64; do
OPENLESS_UPDATE_TARGET=android OPENLESS_UPDATE_ARCH="$arch" \
node scripts/write-updater-manifest.mjs
done
- name: Append release files (manifests + signatures)
if: steps.mode.outputs.mode == 'release'
id: release_assets
shell: bash
run: |
set -euo pipefail
out_dir="${{ steps.apk.outputs.out_dir }}"
{
echo "${{ steps.apk.outputs.release_files }}"
for f in "$out_dir"/*.apk.sig "$out_dir"/latest-android-*.json; do
[ -e "$f" ] && echo "$f"
done
} > "$RUNNER_TEMP/android-release-files.txt"
echo "files<<EOF" >> "$GITHUB_OUTPUT"
cat "$RUNNER_TEMP/android-release-files.txt" >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
- name: Upload Android APK artifact (arm64-v8a)
uses: actions/upload-artifact@v4
with:
name: openless-android-debug-arm64-v8a
name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-arm64-v8a' || 'openless-android-debug-arm64-v8a' }}
path: ${{ steps.apk.outputs.arm64_v8a_path }}
if-no-files-found: error
- name: Upload Android APK artifact (armeabi-v7a)
uses: actions/upload-artifact@v4
with:
name: openless-android-debug-armeabi-v7a
name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-armeabi-v7a' || 'openless-android-debug-armeabi-v7a' }}
path: ${{ steps.apk.outputs.armeabi_v7a_path }}
if-no-files-found: error
- name: Upload Android APK artifact (x86)
uses: actions/upload-artifact@v4
with:
name: openless-android-debug-x86
name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-x86' || 'openless-android-debug-x86' }}
path: ${{ steps.apk.outputs.x86_path }}
if-no-files-found: error
- name: Upload Android APK artifact (x86_64)
uses: actions/upload-artifact@v4
with:
name: openless-android-debug-x86_64
name: ${{ steps.mode.outputs.mode == 'release' && 'openless-android-release-x86_64' || 'openless-android-debug-x86_64' }}
path: ${{ steps.apk.outputs.x86_64_path }}
if-no-files-found: error
- name: Attach APK to GitHub Release
if: startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-tauri')
- name: Prepare Android release body
if: steps.mode.outputs.mode == 'release'
shell: bash
run: |
cat > "$RUNNER_TEMP/android-release-body.md" << 'EOF'
### Android 安装说明
- **推荐**:真机下载 `OpenLess_<version>_arm64-v8a.apk`(arm64 设备)。
- **模拟器**:x86_64 模拟器用 `OpenLess_<version>_x86_64.apk`。
- 首次安装需在系统设置中允许「安装未知应用」;应用内更新会拉起系统安装器。
- adb 调试:`adb install -r OpenLess_<version>_arm64-v8a.apk`
### Android 应用内更新
- Stable 用户检查 `latest-android-aarch64.json`(及 mirror 变体)。
- Beta 用户(设置 → 高级 → 加入 Beta)检查带 `-beta` 后缀的 manifest。
EOF
- name: Attach Android assets to GitHub Release
if: steps.mode.outputs.mode == 'release'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
name: OpenLess ${{ github.ref_name }}
draft: false
prerelease: ${{ endsWith(github.ref_name, '-beta-tauri') }}
files: ${{ steps.apk.outputs.release_files }}
prerelease: ${{ env.OPENLESS_RELEASE_CHANNEL == 'beta' }}
append_body: true
body_path: ${{ runner.temp }}/android-release-body.md
files: ${{ steps.release_assets.outputs.files }}
+63
View File
@@ -14,6 +14,69 @@ on:
workflow_dispatch:
jobs:
android-check:
name: Android cargo check
runs-on: ubuntu-latest
defaults:
run:
working-directory: openless-all/app
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Android SDK + NDK
uses: android-actions/setup-android@v3
with:
packages: platform-tools
- name: Install NDK
shell: bash
run: |
set -euo pipefail
sdkmanager "ndk;26.1.10909125"
ndk_dir="$ANDROID_HOME/ndk/26.1.10909125"
echo "ANDROID_NDK_HOME=$ndk_dir" >> "$GITHUB_ENV"
echo "NDK_HOME=$ndk_dir" >> "$GITHUB_ENV"
set +o pipefail
yes | sdkmanager --licenses
set -o pipefail
- name: Configure NDK toolchain for cargo
shell: bash
run: |
set -euo pipefail
prebuilt="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64"
echo "CC_aarch64_linux_android=$prebuilt/bin/aarch64-linux-android24-clang" >> "$GITHUB_ENV"
echo "CXX_aarch64_linux_android=$prebuilt/bin/aarch64-linux-android24-clang++" >> "$GITHUB_ENV"
echo "AR_aarch64_linux_android=$prebuilt/bin/llvm-ar" >> "$GITHUB_ENV"
echo "CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=$prebuilt/bin/aarch64-linux-android24-clang" >> "$GITHUB_ENV"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: openless-all/app/package-lock.json
- uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-linux-android
- name: Install Linux check dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential curl file libssl-dev wget
- name: Install frontend dependencies
run: npm ci
- name: Build frontend (tsc + vite)
# generate_context! requires frontendDist (../dist) to exist on Android too.
run: npm run build
- name: Check Tauri backend (Android target)
run: cargo check --manifest-path src-tauri/Cargo.toml --target aarch64-linux-android
cross-platform:
name: ${{ matrix.label }} checks
strategy:
+1
View File
@@ -11,6 +11,7 @@ ChatGPT Image*.jpg
# Local config / secrets
*.env
config.local.json
.local-secrets/
# Tauri updater 签名私钥(绝不上传 — 公钥在 tauri.conf.json,私钥放 GitHub Secret)
*.key
*.key.pub
+8 -6
View File
@@ -215,12 +215,14 @@ Go to [Releases](../../releases) and download:
xattr -cr /Applications/OpenLess.app
```
- **Windows**: `OpenLess_<version>_x64-setup.exe` — run the installer.
- **Android debug APK**: choose exactly one split APK for your device:
- `OpenLess-android-debug-arm64-v8a-*.apk` for most modern Android phones.
- `OpenLess-android-debug-armeabi-v7a-*.apk` for older 32-bit ARM phones.
- `OpenLess-android-debug-x86_64-*.apk` for most 64-bit Android emulator images.
- `OpenLess-android-debug-x86-*.apk` for older 32-bit x86 emulator images.
- If unsure, run `adb shell getprop ro.product.cpu.abi` and download the APK matching that ABI. Do not install the x86 builds on a normal ARM phone.
- **Android** (release APKs on GitHub Release; debug APKs via Actions artifacts):
- **Phones (recommended)**: `OpenLess_<version>_arm64-v8a.apk`
- **Older 32-bit ARM**: `OpenLess_<version>_armeabi-v7a.apk`
- **64-bit emulator**: `OpenLess_<version>_x86_64.apk`
- **32-bit emulator**: `OpenLess_<version>_x86.apk`
- In-app updates (Settings → About) use `latest-android-{arch}.json` manifests; Beta users join Beta in Advanced settings.
- Debug smoke builds: `OpenLess-android-debug-{abi}-*.apk` from workflow_dispatch artifacts.
- If unsure, run `adb shell getprop ro.product.cpu.abi` and pick the matching APK.
- **macOS (Homebrew)**:
```bash
brew tap appergb/openless https://github.com/appergb/openless
+18 -3
View File
@@ -215,6 +215,14 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提
xattr -cr /Applications/OpenLess.app
```
- **Windows**:`OpenLess_<version>_x64-setup.exe`——运行安装程序。
- **Android**(GitHub Release 提供 release APK;Actions artifact 提供 debug 冒烟包):
- **真机推荐**:`OpenLess_<version>_arm64-v8a.apk`
- **旧款 32 位 ARM**:`OpenLess_<version>_armeabi-v7a.apk`
- **64 位模拟器**:`OpenLess_<version>_x86_64.apk`
- **32 位模拟器**:`OpenLess_<version>_x86.apk`
- 应用内更新(设置 → 关于)读取 `latest-android-{arch}.json`;Beta 用户在高级设置加入 Beta 渠道。
- 调试包:`OpenLess-android-debug-{abi}-*.apk`(workflow_dispatch 产物)。
- 不确定时执行 `adb shell getprop ro.product.cpu.abi`,下载对应 ABI 的包。
- **macOS(Homebrew)**:
```bash
brew tap appergb/openless https://github.com/appergb/openless
@@ -391,23 +399,30 @@ OpenLess 提供两个发布频道。分支名即频道名(见[贡献流程](#贡
- 运行 `INSTALL=0 ./scripts/build-mac.sh`,确认 `.app` 能启动。
- 在干净的机器上做冒烟测试:权限流程、快捷键、录音、ASR、润色、插入,以及剪贴板回退。
- 确认 `TAURI_SIGNING_PRIVATE_KEY` 以及(macOS 所需的)Apple 签名 / 公证密钥已在仓库中配置。
- Android tag 发版还需配置:`ANDROID_KEYSTORE_BASE64`、`ANDROID_KEYSTORE_PASSWORD`、`ANDROID_KEY_ALIAS`、`ANDROID_KEY_PASSWORD`(release APK 签名;minisign 仍用 `TAURI_SIGNING_PRIVATE_KEY`)。
### Beta 频道 — `v<v>-beta-tauri`
1. 通过 PR 评审把改动落到 `beta` 分支。
2. **在 `beta` 上**推送标签:`git tag v<v>-beta-tauri && git push origin v<v>-beta-tauri`。
3. CI 会把该 GitHub Release 标记为 `Pre-release`,并仅上传 `latest-{tgt}-{arch}-beta.json` 更新清单。Stable 用户的 `releases/latest` 跳转不受影响。
3. CI 会把该 GitHub Release 标记为 `Pre-release`,并上传 `latest-{tgt}-{arch}-beta.json` 与 `latest-android-{arch}-beta.json` 更新清单。Stable 用户的 `releases/latest` 跳转不受影响。
4. 在合适的渠道(issue 讨论串、QQ 群)公告:可选加入的 Beta 用户可从 设置 → 关于 → 加入 Beta 频道 获取。
### Stable 频道 — `v<v>-tauri`
1. 在 Beta 发布充分沉淀后,将 `beta → main` 合并(或直接运行一次最终的双平台冒烟构建)。
2. **在 `main` 上**推送标签:`git tag v<v>-tauri && git push origin v<v>-tauri`。
3. CI 发布一个正常的 GitHub Release,并上传 `latest-{tgt}-{arch}.json`(无 `-beta` 后缀)。所有 Stable 用户都会通过应用内更新器获得更新。
3. CI 发布一个正常的 GitHub Release,并上传 `latest-{tgt}-{arch}.json` 与 `latest-android-{arch}.json`(无 `-beta` 后缀)。所有 Stable 用户都会通过应用内更新器获得更新。
### 发布后验证(始终执行)
执行 [`CLAUDE.md` → Branch & release-channel workflow → Channel distribution](CLAUDE.md) 中的 5 步清单:页面状态(pre-release 标记)、资产文件名的频道正确性、Stable 用户流程、Beta 可选加入流程,以及原始端点的合理性检查。
执行 [`CLAUDE.md` → Branch & release-channel workflow → Channel distribution](CLAUDE.md) 中的 5 步清单,并额外核对 Android:
1. Release 页面含 `latest-android-aarch64.json`(Stable 无 `-beta` 后缀混用)。
2. Beta pre-release 含 `latest-android-aarch64-beta.json`,URL 指向具体 tag。
3. 真机:设置 → 关于 → 检查更新 → 下载 → 系统安装器 → 版本号递增。
4. Beta 开关:高级 → 加入 Beta → 手动/自动检查 Beta manifest。
5. 原始端点与 mirror 端点均可访问。
## 致谢
+36 -26
View File
@@ -224,48 +224,58 @@ GitHub Actions workflow: [`.github/workflows/android-apk.yml`](../.github/workfl
### 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).
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. In-app updates use a custom Rust updater (`src-tauri/src/android/updater.rs`) because `tauri-plugin-updater` does not support Android.
### Triggers
### Triggers & channels
| 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 |
| Trigger | Build mode | Behavior |
|---|---|---|
| `workflow_dispatch` | **debug** | Build 4 split debug APKs → upload Actions artifacts only |
| Push tag `v*-tauri` / `v*-beta-tauri` | **release** | Signed release APKs + minisign `.sig` + `latest-android-{arch}[-beta].json` → attach to GitHub Release |
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.
`OPENLESS_RELEASE_CHANNEL` matches desktop `release-tauri.yml`: `-beta-tauri` → beta (prerelease manifests); otherwise stable.
### Debug APK policy
Tag releases require secrets: `TAURI_SIGNING_PRIVATE_KEY` (minisign), `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` (APK signing).
- 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)
### APK naming
| ABI | Release (tag) | Debug (dispatch) |
|---|---|---|
| arm64-v8a | `OpenLess_<version>_arm64-v8a.apk` | `OpenLess-android-debug-arm64-v8a-run-<n>.apk` |
| armeabi-v7a | `OpenLess_<version>_armeabi-v7a.apk` | `OpenLess-android-debug-armeabi-v7a-run-<n>.apk` |
| x86 | `OpenLess_<version>_x86.apk` | `OpenLess-android-debug-x86-run-<n>.apk` |
| x86_64 | `OpenLess_<version>_x86_64.apk` | `OpenLess-android-debug-x86_64-run-<n>.apk` |
Actions artifact names: `openless-android-release-{abi}` (tag) or `openless-android-debug-{abi}` (dispatch).
Updater manifests: `latest-android-aarch64.json` (arm64), `latest-android-armv7.json`, `latest-android-i686.json`, `latest-android-x86_64.json` (+ `-beta` / `-mirror` variants).
### Command chain (CI)
```bash
cd openless-all/app
npm ci && npm run build
CI=true npm run tauri -- android init --ci
npm run tauri -- android init --ci
node scripts/copy-android-scaffolding.mjs
node scripts/merge-android-v1-manifest.mjs
CI=true npm run tauri:android:build
node scripts/merge-android-overlay-manifest.mjs
node scripts/merge-android-updater-manifest.mjs
# tag only:
node scripts/configure-android-release-signing.mjs
npm run tauri:android:build:debug # dispatch
npm run tauri:android:build:release # tag
# tag only:
node scripts/sign-android-apks.mjs <apks...>
OPENLESS_UPDATE_APK_DIR=... OPENLESS_UPDATE_TARGET=android OPENLESS_UPDATE_ARCH=aarch64 node scripts/write-updater-manifest.mjs
```
Local equivalent (after Android SDK/NDK setup):
`ci.yml` also runs `cargo check --target aarch64-linux-android` as a lightweight mobile cfg gate.
```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
### 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.
- `merge-android-v1-manifest.mjs` — `RECORD_AUDIO` (v1 in-app dictation)
- `merge-android-overlay-manifest.mjs` — overlay + accessibility service
- `merge-android-updater-manifest.mjs` — `REQUEST_INSTALL_PACKAGES` + `FileProvider` for APK install
---
@@ -0,0 +1,37 @@
package com.openless.app
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.FileProvider
import java.io.File
/**
* Triggers system package installer for a downloaded APK via FileProvider.
*/
object OpenLessUpdateInstaller {
fun installApk(context: Context, apkPath: String): Boolean {
val apkFile = File(apkPath)
if (!apkFile.exists()) {
return false
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !context.packageManager.canRequestPackageInstalls()) {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
return false
}
val authority = "${context.packageName}.fileprovider"
val uri: Uri = FileProvider.getUriForFile(context, authority, apkFile)
val install = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(install)
return true
}
}
+2
View File
@@ -11,6 +11,8 @@
"tauri:android:init": "tauri android init",
"tauri:android:dev": "tauri android dev",
"tauri:android:build": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi",
"tauri:android:build:debug": "tauri android build --apk --debug --target aarch64 armv7 i686 x86_64 --split-per-abi",
"tauri:android:build:release": "tauri android build --apk --target aarch64 armv7 i686 x86_64 --split-per-abi",
"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",
@@ -0,0 +1,83 @@
#!/usr/bin/env node
/**
* Decode ANDROID_KEYSTORE_* env vars and patch gen/android signing for release APK builds.
*/
import { existsSync, mkdirSync, readFileSync, 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 gradlePath = join(appRoot, 'src-tauri/gen/android/app/build.gradle.kts');
const keystorePath = join(appRoot, 'src-tauri/gen/android/openless-release.keystore');
function requireEnv(name) {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required for Android release signing`);
}
return value;
}
function main() {
const base64 = requireEnv('ANDROID_KEYSTORE_BASE64');
const storePassword = requireEnv('ANDROID_KEYSTORE_PASSWORD');
const keyAlias = requireEnv('ANDROID_KEY_ALIAS');
const keyPassword = requireEnv('ANDROID_KEY_PASSWORD');
if (!existsSync(gradlePath)) {
throw new Error(`Gradle file not found: ${gradlePath} (run tauri android init first)`);
}
mkdirSync(dirname(keystorePath), { recursive: true });
writeFileSync(keystorePath, Buffer.from(base64, 'base64'));
const escapedStore = storePassword.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const escapedKey = keyPassword.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const escapedAlias = keyAlias.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const escapedKeystore = keystorePath.replace(/\\/g, '/');
let content = readFileSync(gradlePath, 'utf8');
if (content.includes('openlessRelease')) {
console.log('Release signing already configured in build.gradle.kts');
return;
}
const signingConfigsBlock = `
signingConfigs {
create("openlessRelease") {
storeFile = file("${escapedKeystore}")
storePassword = "${escapedStore}"
keyAlias = "${escapedAlias}"
keyPassword = "${escapedKey}"
}
}`;
if (!/signingConfigs\s*\{/.test(content)) {
content = content.replace(/android\s*\{/, `android {${signingConfigsBlock}`);
}
if (/getByName\("release"\)/.test(content)) {
if (!/signingConfig\s*=\s*signingConfigs\.getByName\("openlessRelease"\)/.test(content)) {
content = content.replace(
/getByName\("release"\)\s*\{/,
'getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")',
);
}
} else if (/buildTypes\s*\{/.test(content)) {
content = content.replace(
/buildTypes\s*\{/,
`buildTypes {\n getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")\n }`,
);
} else {
content = content.replace(
/android\s*\{/,
`android {${signingConfigsBlock}\n buildTypes {\n getByName("release") {\n signingConfig = signingConfigs.getByName("openlessRelease")\n }\n }`,
);
}
writeFileSync(gradlePath, content);
console.log(`Configured release signing in ${gradlePath}`);
}
main();
@@ -25,6 +25,7 @@ const KOTLIN_FILES = [
'OpenLessAccessibilityService.kt',
'OpenLessAccessibilityCommandReceiver.kt',
'OverlayPermissionActivity.kt',
'OpenLessUpdateInstaller.kt',
];
const XML_FILES = [
@@ -0,0 +1,98 @@
#!/usr/bin/env node
/**
* Merge REQUEST_INSTALL_PACKAGES + FileProvider for in-app APK updates.
*/
import { existsSync, mkdirSync, 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 resXmlPath = fileURLToPath(
new URL('../src-tauri/gen/android/app/src/main/res/xml/openless_file_paths.xml', import.meta.url),
);
const PERMISSIONS = [
'android.permission.REQUEST_INSTALL_PACKAGES',
];
const PROVIDER_SNIPPET = `
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="\${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/openless_file_paths" />
</provider>`;
const FILE_PATHS_XML = `<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="updates" path="updates/" />
<external-cache-path name="external_updates" path="updates/" />
</paths>
`;
function permissionExists(manifestXml, permissionName) {
const escaped = permissionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`<uses-permission[^>]*android:name="${escaped}"[^>]*\\/?>`).test(manifestXml);
}
function providerExists(manifestXml) {
return /androidx\.core\.content\.FileProvider/.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 mergeProvider(manifestXml) {
if (providerExists(manifestXml)) {
return { content: manifestXml, changed: false };
}
const closeTag = '</application>';
const idx = manifestXml.lastIndexOf(closeTag);
if (idx === -1) throw new Error('Target manifest is missing </application>');
const content = `${manifestXml.slice(0, idx)}${PROVIDER_SNIPPET}\n ${manifestXml.slice(idx)}`;
return { content, changed: true };
}
function main() {
if (!existsSync(targetPath)) {
throw new Error(`Target manifest not found: ${targetPath}`);
}
let content = readFileSync(targetPath, 'utf8');
const perm = mergePermissions(content);
content = perm.content;
const prov = mergeProvider(content);
content = prov.content;
if (perm.changed || prov.changed) {
writeFileSync(targetPath, content);
console.log(`Updated ${targetPath}`);
} else {
console.log(`No manifest changes needed: ${targetPath}`);
}
const resDir = resXmlPath.replace(/[/\\][^/\\]+$/, '');
if (!existsSync(resDir)) {
mkdirSync(resDir, { recursive: true });
}
if (!existsSync(resXmlPath)) {
writeFileSync(resXmlPath, FILE_PATHS_XML);
console.log(`Wrote ${resXmlPath}`);
}
}
main();
@@ -0,0 +1,61 @@
#!/usr/bin/env node
/**
* Sign release APK files with minisign (same key as desktop updater).
* Expects TAURI_SIGNING_PRIVATE_KEY (+ optional password) in env.
*
* Usage: node scripts/sign-android-apks.mjs <apk> [<apk> ...]
*/
import { existsSync, readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import process from 'node:process';
function requireSigningKey() {
if (!process.env.TAURI_SIGNING_PRIVATE_KEY) {
throw new Error('TAURI_SIGNING_PRIVATE_KEY is required to sign Android APKs');
}
}
function signApk(apkPath) {
if (!existsSync(apkPath)) {
throw new Error(`APK not found: ${apkPath}`);
}
const sigPath = `${apkPath}.sig`;
if (existsSync(sigPath)) {
console.log(`Already signed: ${sigPath}`);
return sigPath;
}
// Prefer `tauri signer sign` (bundled minisign wrapper used by desktop release).
const tauriBin = process.platform === 'win32' ? 'npx.cmd' : 'npx';
const result = spawnSync(
tauriBin,
['tauri', 'signer', 'sign', apkPath],
{
stdio: 'inherit',
env: process.env,
shell: process.platform === 'win32',
},
);
if (result.status !== 0) {
throw new Error(`tauri signer sign failed for ${apkPath} (exit ${result.status ?? 'unknown'})`);
}
if (!existsSync(sigPath)) {
throw new Error(`Signature file missing after sign: ${sigPath}`);
}
console.log(`Signed ${apkPath} -> ${sigPath}`);
return sigPath;
}
function main() {
requireSigningKey();
const apks = process.argv.slice(2);
if (apks.length === 0) {
console.error('Usage: node scripts/sign-android-apks.mjs <apk> [<apk> ...]');
process.exit(1);
}
for (const apk of apks) {
signApk(apk);
}
}
main();
@@ -8,17 +8,11 @@ const target = process.env.OPENLESS_UPDATE_TARGET;
const arch = process.env.OPENLESS_UPDATE_ARCH;
const repo = process.env.OPENLESS_UPDATE_REPO || 'appergb/openless';
const mirrorBaseUrl = process.env.OPENLESS_UPDATE_MIRROR_BASE_URL || 'https://fastgit.cc/https://github.com';
// 渠道决定 manifest 文件名后缀:stable → 旧文件名(向后兼容);beta → 加 -beta 后缀,
// 让 stable 用户的 endpoint 永远拿不到 beta 包。空 / 未设置 = stable。
const rawChannel = (process.env.OPENLESS_RELEASE_CHANNEL || 'stable').toLowerCase();
if (rawChannel !== 'stable' && rawChannel !== 'beta') {
throw new Error(`Invalid OPENLESS_RELEASE_CHANNEL: "${rawChannel}" (expected "stable" or "beta")`);
}
const channelSuffix = rawChannel === 'beta' ? '-beta' : '';
// Beta 渠道里 manifest.url 必须指向具体 tag 路径,不能用 releases/latest——后者
// 永远是最新非-prerelease(即 Stable),Beta 用户按 url 下载会拉到 Stable 包,
// 文件名碰巧重名时下载错版本,文件名带版本号时直接 404。
// Stable 渠道沿用 releases/latest,没问题。
const releaseTag = process.env.OPENLESS_RELEASE_TAG || '';
if (rawChannel === 'beta' && !releaseTag) {
throw new Error('OPENLESS_RELEASE_TAG is required when OPENLESS_RELEASE_CHANNEL=beta');
@@ -29,7 +23,27 @@ if (!target || !arch) {
}
const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
const bundleDir = fileURLToPath(new URL('../src-tauri/target/release/bundle/', import.meta.url));
/** Map Gradle ABI folder names to updater manifest arch (Tauri {{arch}} convention). */
const ANDROID_ABI_TO_ARCH = {
'arm64-v8a': 'aarch64',
'armeabi-v7a': 'armv7',
x86: 'i686',
x86_64: 'x86_64',
};
function resolveBundleDir() {
if (target === 'android') {
const apkDir = process.env.OPENLESS_UPDATE_APK_DIR;
if (!apkDir) {
throw new Error('OPENLESS_UPDATE_APK_DIR is required when OPENLESS_UPDATE_TARGET=android');
}
return apkDir;
}
return fileURLToPath(new URL('../src-tauri/target/release/bundle/', import.meta.url));
}
const bundleDir = resolveBundleDir();
const candidatesByTarget = {
darwin: [
@@ -38,8 +52,30 @@ const candidatesByTarget = {
],
windows: ['nsis/OpenLess_*_x64-setup.exe', 'nsis/OpenLess*_x64-setup.exe'],
linux: ['appimage/OpenLess_*.AppImage', 'appimage/OpenLess*.AppImage'],
android: [],
};
function findAndroidApk() {
const version = packageJson.version;
const abiEntry = Object.entries(ANDROID_ABI_TO_ARCH).find(([, manifestArch]) => manifestArch === arch);
if (!abiEntry) {
throw new Error(`Unknown android updater arch: ${arch}`);
}
const [abi] = abiEntry;
const expectedName = `OpenLess_${version}_${abi}.apk`;
const direct = join(bundleDir, expectedName);
if (existsSync(direct)) {
return direct;
}
const match = readdirSync(bundleDir)
.filter((name) => name.endsWith('.apk') && name.includes(abi))
.sort()[0];
if (match) {
return join(bundleDir, match);
}
throw new Error(`No Android APK found for arch ${arch} (abi ${abi}) in ${bundleDir}`);
}
function findFirst(patterns) {
for (const pattern of patterns) {
if (!pattern.includes('*')) {
@@ -59,7 +95,7 @@ function findFirst(patterns) {
}
}
const artifact = findFirst(candidatesByTarget[target] || []);
const artifact = target === 'android' ? findAndroidApk() : findFirst(candidatesByTarget[target] || []);
if (!artifact) {
throw new Error(`No updater artifact found for ${target} in ${bundleDir}`);
}
@@ -72,8 +108,6 @@ if (!existsSync(signaturePath)) {
const assetName = basename(artifact);
const manifestName = `latest-${target}-${arch}${channelSuffix}.json`;
const mirrorManifestName = `latest-${target}-${arch}${channelSuffix}-mirror.json`;
// Stable: releases/latest/download/<asset>(GitHub 自动重定向到最新非-prerelease)
// Beta: releases/download/<tag>/<asset>(指定具体 tag,不被 prerelease 折叠影响)
const downloadPath = rawChannel === 'beta'
? `releases/download/${releaseTag}/${assetName}`
: `releases/latest/download/${assetName}`;
@@ -90,6 +124,7 @@ const mirrorManifest = {
url: mirrorAssetUrl,
};
writeFileSync(join(bundleDir, manifestName), `${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(join(bundleDir, mirrorManifestName), `${JSON.stringify(mirrorManifest, null, 2)}\n`);
const outDir = target === 'android' ? bundleDir : bundleDir;
writeFileSync(join(outDir, manifestName), `${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(join(outDir, mirrorManifestName), `${JSON.stringify(mirrorManifest, null, 2)}\n`);
console.log(`Wrote ${manifestName} and ${mirrorManifestName} for ${assetName}`);
+1
View File
@@ -3840,6 +3840,7 @@ dependencies = [
"libc",
"local-ip-address",
"log",
"minisign-verify",
"ndk-context",
"objc2 0.5.2",
"objc2-app-kit 0.2.2",
+1
View File
@@ -98,6 +98,7 @@ features = ["linux-native-sync-persistent", "crypto-rust"]
jni = "0.21"
ndk-context = "0.1"
tao = "0.35"
minisign-verify = "0.2"
[target.'cfg(target_os = "macos")'.dependencies]
block2 = "0.5"
+17 -2
View File
@@ -72,7 +72,7 @@ pub mod android {
Ok(())
}
fn call_static_bool_with_context_class<'local>(
pub(crate) fn call_static_bool_with_context_class<'local>(
env: &mut JNIEnv<'local>,
context: &JObject<'local>,
class_name: &str,
@@ -94,7 +94,7 @@ pub mod android {
.map_err(|error| format!("create jstring: {error}"))
}
fn jobject_str<'local>(
pub(crate) fn jobject_str<'local>(
env: &mut JNIEnv<'local>,
value: &str,
) -> Result<JObject<'local>, String> {
@@ -507,4 +507,19 @@ pub mod android {
0
}
}
pub(crate) fn install_apk_from_path<'local>(
env: &mut JNIEnv<'local>,
context: &JObject<'local>,
path_obj: &JObject<'local>,
) -> Result<bool, String> {
call_static_bool_with_context_class(
env,
context,
"com.openless.app.OpenLessUpdateInstaller",
"installApk",
"(Landroid/content/Context;Ljava/lang/String;)Z",
&[JValue::Object(context), JValue::Object(path_obj)],
)
}
}
@@ -3,6 +3,8 @@
pub mod accessibility;
#[cfg(target_os = "android")]
pub mod insert;
#[cfg(target_os = "android")]
pub mod updater;
pub mod jni;
pub mod native_bridge;
pub mod overlay;
@@ -0,0 +1,271 @@
//! Android in-app updater: manifest fetch, minisign verify, system APK install.
#[cfg(target_os = "android")]
mod android_impl {
use std::path::PathBuf;
use minisign_verify::{PublicKey, Signature};
use serde::Deserialize;
use tauri::{AppHandle, Emitter};
use crate::commands::{
fetch_latest_beta_release, parse_latest_beta_from_atom, AppUpdateMetadata,
};
use crate::net;
use crate::types::UpdateChannel;
const PUBKEY_B64: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFERUFBODAzNTY0QzMyM0YKUldRL01reFdBNmpxSGE1K0JadlpONXNWTzhJcGZCRGxjUVdIWExNNFJpeUNsSGZwazdlQThhemkK";
const MIRROR_BASE: &str = "https://fastgit.cc/https://github.com/appergb/openless";
const DIRECT_BASE: &str = "https://github.com/appergb/openless";
#[derive(Debug, Deserialize)]
struct UpdaterManifest {
version: String,
#[serde(default)]
pub_date: Option<String>,
url: String,
signature: String,
#[serde(default)]
notes: Option<String>,
}
#[derive(Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AndroidUpdateProgress {
pub downloaded: u64,
pub content_length: Option<u64>,
pub phase: String,
}
fn device_arch() -> Result<&'static str, String> {
crate::android::jni::android::with_android_env(|env, _context| {
let abis_obj = env
.call_static_method(
"android/os/Build",
"SUPPORTED_ABIS",
"()[Ljava/lang/String;",
&[],
)
.and_then(|value| value.l())
.map_err(|e| format!("read SUPPORTED_ABIS: {e}"))?;
let abis_array = jni::objects::JObjectArray::from(abis_obj);
let len = env
.get_array_length(&abis_array)
.map_err(|e| format!("SUPPORTED_ABIS length: {e}"))?;
if len == 0 {
return Err("SUPPORTED_ABIS is empty".to_string());
}
let first = env
.get_object_array_element(&abis_array, 0)
.map_err(|e| format!("SUPPORTED_ABIS[0]: {e}"))?;
let abi = env
.get_string(&jni::objects::JString::from(first))
.map_err(|e| format!("read abi string: {e}"))?
.to_string_lossy()
.into_owned();
Ok(map_abi_to_arch(&abi))
})
}
fn map_abi_to_arch(abi: &str) -> &'static str {
match abi {
"arm64-v8a" => "aarch64",
"armeabi-v7a" => "armv7",
"x86" => "i686",
"x86_64" => "x86_64",
_ => "aarch64",
}
}
fn version_is_newer(remote: &str, current: &str) -> bool {
fn parts(v: &str) -> Vec<u32> {
v.split(|c| c == '.' || c == '-')
.filter_map(|p| p.parse().ok())
.collect()
}
let remote_parts = parts(remote);
let current_parts = parts(current);
let max = remote_parts.len().max(current_parts.len());
for i in 0..max {
let r = remote_parts.get(i).copied().unwrap_or(0);
let c = current_parts.get(i).copied().unwrap_or(0);
if r > c {
return true;
}
if r < c {
return false;
}
}
false
}
async fn fetch_manifest(url: &str) -> Result<UpdaterManifest, String> {
let resp = net::send_with_retry(|| {
net::http()
.get(url)
.timeout(std::time::Duration::from_secs(15))
})
.await
.map_err(|e| format!("fetch manifest: {e}"))?;
if !resp.status().is_success() {
return Err(format!("manifest status {}", resp.status()));
}
resp.json::<UpdaterManifest>()
.await
.map_err(|e| format!("parse manifest: {e}"))
}
async fn resolve_stable_manifest_urls(arch: &str) -> Vec<String> {
vec![
format!("{MIRROR_BASE}/releases/latest/download/latest-android-{arch}-mirror.json"),
format!("{DIRECT_BASE}/releases/latest/download/latest-android-{arch}.json"),
]
}
async fn resolve_beta_manifest_urls(arch: &str) -> Result<Vec<String>, String> {
let Some(latest) = fetch_latest_beta_release().await? else {
return Err("尚未发布过 Beta 版本".to_string());
};
let tag = latest.tag_name;
Ok(vec![
format!("{MIRROR_BASE}/releases/download/{tag}/latest-android-{arch}-beta-mirror.json"),
format!("{DIRECT_BASE}/releases/download/{tag}/latest-android-{arch}-beta.json"),
])
}
pub async fn check_update(channel: UpdateChannel) -> Result<Option<AppUpdateMetadata>, String> {
let arch = device_arch()?;
let urls = match channel {
UpdateChannel::Stable => resolve_stable_manifest_urls(arch).await,
UpdateChannel::Beta => resolve_beta_manifest_urls(arch).await?,
};
let current = env!("CARGO_PKG_VERSION").to_string();
let mut last_err = String::new();
for url in urls {
match fetch_manifest(&url).await {
Ok(manifest) => {
if !version_is_newer(&manifest.version, &current) {
return Ok(None);
}
let raw_json = serde_json::json!({
"version": manifest.version,
"url": manifest.url,
"signature": manifest.signature,
"pubDate": manifest.pub_date,
"notes": manifest.notes,
});
return Ok(Some(AppUpdateMetadata {
rid: 0,
current_version: current,
version: manifest.version,
date: manifest.pub_date,
body: manifest.notes,
raw_json,
}));
}
Err(err) => last_err = err,
}
}
Err(if last_err.is_empty() {
"无法获取更新清单".to_string()
} else {
last_err
})
}
fn verify_signature(apk_bytes: &[u8], signature_b64: &str) -> Result<(), String> {
let public_key =
PublicKey::from_base64(PUBKEY_B64).map_err(|e| format!("parse updater pubkey: {e}"))?;
let signature =
Signature::decode(signature_b64.trim()).map_err(|e| format!("decode signature: {e}"))?;
public_key
.verify(apk_bytes, &signature, false)
.map_err(|e| format!("signature verify failed: {e}"))?;
Ok(())
}
fn updates_cache_dir() -> Result<PathBuf, String> {
crate::android::jni::android::with_android_env(|env, context| {
let cache = env
.call_method(context, "getCacheDir", "()Ljava/io/File;", &[])
.and_then(|value| value.l())
.map_err(|e| format!("getCacheDir: {e}"))?;
let path = env
.call_method(&cache, "getAbsolutePath", "()Ljava/lang/String;", &[])
.and_then(|value| value.l())
.map_err(|e| format!("cache path: {e}"))?;
let text = env
.get_string(&jni::objects::JString::from(path))
.map_err(|e| format!("read cache path: {e}"))?
.to_string_lossy()
.into_owned();
Ok(PathBuf::from(text).join("updates"))
})
}
pub async fn download_and_install(
app: AppHandle,
url: String,
signature: String,
version: String,
) -> Result<(), String> {
let cache_dir = updates_cache_dir()?;
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("create cache dir: {e}"))?;
let dest = cache_dir.join(format!("OpenLess_{version}.apk"));
let resp = net::send_with_retry(|| net::http().get(&url))
.await
.map_err(|e| format!("download apk: {e}"))?;
if !resp.status().is_success() {
return Err(format!("download status {}", resp.status()));
}
let total = resp.content_length();
let mut downloaded: u64 = 0;
let mut bytes = Vec::new();
let mut stream = resp.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("download chunk: {e}"))?;
downloaded += chunk.len() as u64;
bytes.extend_from_slice(&chunk);
let _ = app.emit(
"android-update:progress",
AndroidUpdateProgress {
downloaded,
content_length: total,
phase: "downloading".to_string(),
},
);
}
verify_signature(&bytes, &signature)?;
std::fs::write(&dest, &bytes).map_err(|e| format!("write apk: {e}"))?;
let _ = app.emit(
"android-update:progress",
AndroidUpdateProgress {
downloaded,
content_length: total,
phase: "installing".to_string(),
},
);
let path = dest
.to_str()
.ok_or_else(|| "apk path is not UTF-8".to_string())?
.to_string();
crate::android::jni::android::with_android_env(|env, context| {
let path_obj = crate::android::jni::android::jobject_str(env, &path)?;
crate::android::jni::android::install_apk_from_path(env, context, &path_obj)
})?;
Ok(())
}
#[allow(dead_code)]
pub fn parse_beta_atom(body: &str) -> Option<crate::commands::LatestBetaRelease> {
parse_latest_beta_from_atom(body)
}
}
#[cfg(target_os = "android")]
pub use android_impl::*;
@@ -364,15 +364,16 @@ fn extract_between(haystack: &str, open: &str, close: &str) -> Option<String> {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
#[cfg(not(mobile))]
pub struct AppUpdateMetadata {
#[cfg(not(mobile))]
pub rid: tauri::ResourceId,
#[cfg(mobile)]
pub rid: u32,
pub current_version: String,
pub version: String,
pub date: Option<String>,
pub body: Option<String>,
/// 原始 manifest JSON——`new Update(metadata)` 在 JS 那边会校验它存在;
/// 我们透传 plugin 自己 check 时拿到的字段。
/// 原始 manifest JSON——桌面 `new Update(metadata)` / Android 自定义安装路径共用。
pub raw_json: serde_json::Value,
}
@@ -444,3 +445,41 @@ async fn resolve_beta_manifest_endpoints() -> Result<Vec<url::Url>, String> {
let direct_url = url::Url::parse(&direct).map_err(|e| format!("parse beta direct url: {e}"))?;
Ok(vec![mirror_url, direct_url])
}
#[cfg(mobile)]
#[tauri::command]
pub async fn app_check_update_with_channel(
coord: CoordinatorState<'_>,
_timeout_ms: Option<u64>,
channel: Option<UpdateChannel>,
) -> Result<Option<AppUpdateMetadata>, String> {
#[cfg(target_os = "android")]
{
let channel = channel.unwrap_or_else(|| coord.prefs().get().update_channel);
return crate::android::updater::check_update(channel).await;
}
#[cfg(not(target_os = "android"))]
{
let _ = (coord, channel);
Err("应用内更新仅支持 Android".to_string())
}
}
#[cfg(mobile)]
#[tauri::command]
pub async fn app_download_and_install_android_update(
app: AppHandle,
url: String,
signature: String,
version: String,
) -> Result<(), String> {
#[cfg(target_os = "android")]
{
return crate::android::updater::download_and_install(app, url, signature, version).await;
}
#[cfg(not(target_os = "android"))]
{
let _ = (app, url, signature, version);
Err("应用内更新仅支持 Android".to_string())
}
}
+5
View File
@@ -365,6 +365,11 @@ macro_rules! app_invoke_handler_mobile {
$crate::commands::open_system_settings,
$crate::commands::trigger_microphone_prompt,
$crate::commands::export_error_log,
$crate::commands::get_update_channel,
$crate::commands::set_update_channel,
$crate::commands::fetch_latest_beta_release,
$crate::commands::app_check_update_with_channel,
$crate::commands::app_download_and_install_android_update,
$crate::restart_app,
]
};
+1 -1
View File
@@ -2410,7 +2410,7 @@ impl PlatformCapabilities {
supports_tray: false,
supports_local_asr: false,
supports_in_app_dictation: true,
supports_auto_update: false,
supports_auto_update: true,
}
}
+1 -1
View File
@@ -149,7 +149,7 @@
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFERUFBODAzNTY0QzMyM0YKUldRL01reFdBNmpxSGE1K0JadlpONXNWTzhJcGZCRGxjUVdIWExNNFJpeUNsSGZwazdlQThhemkK",
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZGNEI1OTk0RjMzMzk0QTkKUldTcGxEUHpsRmxMYjRnMFdZNkFyaVozaHN2SUNwZ01mMDlFS0RMWnNQNisrWjF6czZQQk1RQysK",
"endpoints": [
"https://fastgit.cc/https://github.com/appergb/openless/releases/latest/download/latest-{{target}}-{{arch}}-mirror.json",
"https://github.com/appergb/openless/releases/latest/download/latest-{{target}}-{{arch}}.json"
+83 -17
View File
@@ -1,18 +1,24 @@
// 自动更新共用模块 — Settings 的"关于"section 和 footer 按钮共用同一套
// 状态机 + 对话框 UI。两边各自调用 useAutoUpdate(),dialog 渲染条件相同。
//
// 渠道感知:check 不再走 plugin-updater 的 JS check()(它只看 tauri.conf 配的
// Stable manifest URL),改为 appCheckUpdateWithChannel()(ipc 层按
// supportsAutoUpdate 在 Android 上 no-op)。
// Rust 那边按 prefs.update_channel 决定 manifest URL;返回的 metadata 直接
// `new Update(metadata)` 复用 plugin 的 download / install / close 实现,
// 我们不重复造下载和签名校验。
// 渠道感知:check 走 appCheckUpdateWithChannel()(Rust 按渠道拼 manifest URL)。
// 桌面:download/install 复用 plugin-updater 的 Update 类。
// Android:download/install 走 appDownloadAndInstallAndroidUpdate(minisign + 系统安装器)。
import { useEffect, useRef, useState } from 'react';
import type { DownloadEvent } from '@tauri-apps/plugin-updater';
import { Update } from '@tauri-apps/plugin-updater';
import { listen } from '@tauri-apps/api/event';
import { useTranslation } from 'react-i18next';
import { appCheckUpdateWithChannel, isTauri, restartApp, type AppUpdateMetadata, type UpdateChannel } from '../lib/ipc';
import {
appCheckUpdateWithChannel,
appDownloadAndInstallAndroidUpdate,
isAndroid,
isTauri,
restartApp,
type AppUpdateMetadata,
type UpdateChannel,
} from '../lib/ipc';
import { Btn } from '../pages/_atoms';
const UPDATE_CHECK_TIMEOUT_MS = 15_000;
@@ -36,17 +42,20 @@ export interface UseAutoUpdate {
checking: boolean;
busy: boolean;
errorMessage: string | null;
/** 触发"检查更新"。如果发现新版本,状态变为 'available',需要 caller 渲染对话框让用户确认下载。
* `channel` 显式指定查哪个渠道;省略时由 Rust 端回落到 prefs.update_channel。 */
checkForUpdates: (channel?: UpdateChannel) => Promise<void>;
/** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */
installUpdate: () => Promise<void>;
/** 关闭对话框(仅在非 busy 状态可用)。 */
dismissDialog: () => Promise<void>;
}
type AndroidUpdatePayload = {
url: string;
signature: string;
version: string;
};
export function useAutoUpdate(): UseAutoUpdate {
const updateRef = useRef<Update | null>(null);
const androidUpdateRef = useRef<AndroidUpdatePayload | null>(null);
const [status, setStatus] = useState<UpdateStatus>('idle');
const [version, setVersion] = useState('');
const [downloaded, setDownloaded] = useState(0);
@@ -62,6 +71,7 @@ export function useAutoUpdate(): UseAutoUpdate {
const closeUpdate = async () => {
const current = updateRef.current;
updateRef.current = null;
androidUpdateRef.current = null;
if (current) {
try {
await current.close();
@@ -75,11 +85,44 @@ export function useAutoUpdate(): UseAutoUpdate {
return () => { void closeUpdate(); };
}, []);
useEffect(() => {
if (!isTauri || !isAndroid()) return;
let unlisten: (() => void) | undefined;
void listen<{
downloaded: number;
contentLength: number | null;
phase: string;
}>('android-update:progress', (event) => {
setDownloaded(event.payload.downloaded);
setContentLength(event.payload.contentLength);
if (event.payload.phase === 'installing') {
setStatus('installing');
} else {
setStatus('downloading');
}
}).then((fn) => {
unlisten = fn;
});
return () => {
unlisten?.();
};
}, []);
const resetProgress = () => {
setDownloaded(0);
setContentLength(null);
};
const storeAndroidMetadata = (metadata: AppUpdateMetadata) => {
const raw = metadata.rawJson ?? {};
const url = typeof raw.url === 'string' ? raw.url : '';
const signature = typeof raw.signature === 'string' ? raw.signature : '';
if (!url || !signature) {
throw new Error('更新清单缺少 url 或 signature');
}
androidUpdateRef.current = { url, signature, version: metadata.version };
};
const checkForUpdates = async (channel?: UpdateChannel) => {
setStatus('checking');
setVersion('');
@@ -91,8 +134,6 @@ export function useAutoUpdate(): UseAutoUpdate {
setStatus('none');
return;
}
// Rust 侧按 update_channel 拼 manifest URL:Stable → tauri.conf 默认;
// Beta → fetch_latest_beta_release 拼出 -beta manifest URL 后再 check。
const metadata = await appCheckUpdateWithChannel(
UPDATE_CHECK_TIMEOUT_MS,
channel ?? null,
@@ -101,8 +142,12 @@ export function useAutoUpdate(): UseAutoUpdate {
setStatus('none');
return;
}
// metadata 形状跟 plugin 自己 check 返回的 UpdateMetadata 完全一致;
// new Update(metadata) 直接复用 plugin 的 download/install/close 实现。
if (isAndroid()) {
storeAndroidMetadata(metadata);
setVersion(metadata.version);
setStatus('available');
return;
}
const next = new Update({
rid: metadata.rid,
currentVersion: metadata.currentVersion,
@@ -123,6 +168,24 @@ export function useAutoUpdate(): UseAutoUpdate {
};
const installUpdate = async () => {
if (isAndroid()) {
const payload = androidUpdateRef.current;
if (!payload) return;
resetProgress();
setStatus('downloading');
try {
await appDownloadAndInstallAndroidUpdate(payload);
androidUpdateRef.current = null;
setStatus('downloaded');
} catch (error) {
console.error('[updater] failed to install android update', error);
const msg = error instanceof Error ? error.message : String(error);
setErrorMessage(msg);
setStatus('error');
}
return;
}
const update = updateRef.current;
if (!update) return;
resetProgress();
@@ -198,12 +261,15 @@ export function UpdateDialog({
const { t } = useTranslation();
const downloading = status === 'downloading';
const installing = status === 'installing';
const androidInstalled = isAndroid() && status === 'downloaded';
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.18)', display: 'grid', placeItems: 'center', zIndex: 40 }}>
<div style={{ width: 360, borderRadius: 16, background: 'var(--ol-surface)', border: '0.5px solid var(--ol-line-strong)', boxShadow: '0 18px 42px rgba(0,0,0,0.18)', padding: 18 }}>
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>{t(`settings.about.updateDialog.${status}.title`)}</div>
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 14 }}>
{t(`settings.about.updateDialog.${status}.desc`, { version })}
{androidInstalled
? t('settings.about.updateDialog.androidInstalled.desc', { version, defaultValue: '系统安装器已打开,请按提示完成安装。安装后重新打开 OpenLess 即可使用 {{version}}。' })
: t(`settings.about.updateDialog.${status}.desc`, { version })}
</div>
{(downloading || installing || status === 'downloaded') && (
<div style={{ marginBottom: 14 }}>
@@ -224,7 +290,7 @@ export function UpdateDialog({
{status === 'available' && <Btn variant="blue" size="sm" onClick={onInstall}>{t('settings.about.updateDialog.install')}</Btn>}
{(downloading || installing) && <Btn size="sm" disabled>{installing ? t('settings.about.updateDialog.installingLabel') : t('settings.about.updateDialog.downloadingLabel')}</Btn>}
{status === 'downloaded' && <Btn size="sm" onClick={onClose}>{t('settings.about.updateDialog.later')}</Btn>}
{status === 'downloaded' && <Btn variant="blue" size="sm" onClick={restartApp}>{t('settings.about.updateDialog.restartNow')}</Btn>}
{status === 'downloaded' && !androidInstalled && <Btn variant="blue" size="sm" onClick={restartApp}>{t('settings.about.updateDialog.restartNow')}</Btn>}
</div>
</div>
</div>
+17
View File
@@ -695,6 +695,23 @@ export function appCheckUpdateWithChannel(
})
}
export function appDownloadAndInstallAndroidUpdate(args: {
url: string
signature: string
version: string
}): Promise<void> {
return platformCapabilities().then((caps) => {
if (caps.platform !== "android") {
return Promise.reject(new Error("Android-only update install"))
}
return invokeOrMock(
"app_download_and_install_android_update",
args,
() => undefined,
)
})
}
export function getHotkeyStatus(): Promise<HotkeyStatus> {
return platformCapabilities().then((caps) => {
if (!caps.supportsDesktopHotkey) {
+1 -1
View File
@@ -68,7 +68,7 @@ export function inferPlatformCapabilities(): PlatformCapabilities {
supportsImeInput: false,
supportsLocalAsr: false,
supportsInAppDictation: true,
supportsAutoUpdate: false,
supportsAutoUpdate: true,
};
}