diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index a4156250..ad0d3995 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -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< "$RUNNER_TEMP/android-release-files.txt" + echo "files<> "$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__arm64-v8a.apk`(arm64 设备)。 + - **模拟器**:x86_64 模拟器用 `OpenLess__x86_64.apk`。 + - 首次安装需在系统设置中允许「安装未知应用」;应用内更新会拉起系统安装器。 + - adb 调试:`adb install -r OpenLess__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 }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1358fc74..7c83483b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/.gitignore b/.gitignore index e61d5498..c62e3761 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 4c58c8c3..f9f0bd68 100644 --- a/README.md +++ b/README.md @@ -215,12 +215,14 @@ Go to [Releases](../../releases) and download: xattr -cr /Applications/OpenLess.app ``` - **Windows**: `OpenLess__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__arm64-v8a.apk` + - **Older 32-bit ARM**: `OpenLess__armeabi-v7a.apk` + - **64-bit emulator**: `OpenLess__x86_64.apk` + - **32-bit emulator**: `OpenLess__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 diff --git a/README.zh.md b/README.zh.md index 84e21b10..9f836fb8 100644 --- a/README.zh.md +++ b/README.zh.md @@ -215,6 +215,14 @@ OpenLess 只做一件事:**把语音变成可用的书面文字(尤其是 AI 提 xattr -cr /Applications/OpenLess.app ``` - **Windows**:`OpenLess__x64-setup.exe`——运行安装程序。 +- **Android**(GitHub Release 提供 release APK;Actions artifact 提供 debug 冒烟包): + - **真机推荐**:`OpenLess__arm64-v8a.apk` + - **旧款 32 位 ARM**:`OpenLess__armeabi-v7a.apk` + - **64 位模拟器**:`OpenLess__x86_64.apk` + - **32 位模拟器**:`OpenLess__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-beta-tauri` 1. 通过 PR 评审把改动落到 `beta` 分支。 2. **在 `beta` 上**推送标签:`git tag v-beta-tauri && git push origin 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-tauri` 1. 在 Beta 发布充分沉淀后,将 `beta → main` 合并(或直接运行一次最终的双平台冒烟构建)。 2. **在 `main` 上**推送标签:`git tag v-tauri && git push origin 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 端点均可访问。 ## 致谢 diff --git a/docs/android-mobile-apk-overlay-plan.md b/docs/android-mobile-apk-overlay-plan.md index 28fcfd9b..51d369c6 100644 --- a/docs/android-mobile-apk-overlay-plan.md +++ b/docs/android-mobile-apk-overlay-plan.md @@ -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-.apk` (e.g. `OpenLess-android-debug-v1.0.0-tauri.apk`) - - Manual dispatch: `OpenLess-android-debug-run-.apk` (not branch name) +### APK naming + +| ABI | Release (tag) | Debug (dispatch) | +|---|---|---| +| arm64-v8a | `OpenLess__arm64-v8a.apk` | `OpenLess-android-debug-arm64-v8a-run-.apk` | +| armeabi-v7a | `OpenLess__armeabi-v7a.apk` | `OpenLess-android-debug-armeabi-v7a-run-.apk` | +| x86 | `OpenLess__x86.apk` | `OpenLess-android-debug-x86-run-.apk` | +| x86_64 | `OpenLess__x86_64.apk` | `OpenLess-android-debug-x86_64-run-.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 +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 --- diff --git a/openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt b/openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt new file mode 100644 index 00000000..0dc77502 --- /dev/null +++ b/openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt @@ -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 + } +} diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 2f62ee13..167b0cc1 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -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", diff --git a/openless-all/app/scripts/configure-android-release-signing.mjs b/openless-all/app/scripts/configure-android-release-signing.mjs new file mode 100644 index 00000000..d12e7c1c --- /dev/null +++ b/openless-all/app/scripts/configure-android-release-signing.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(); diff --git a/openless-all/app/scripts/copy-android-scaffolding.mjs b/openless-all/app/scripts/copy-android-scaffolding.mjs index d785d2e1..7c093469 100644 --- a/openless-all/app/scripts/copy-android-scaffolding.mjs +++ b/openless-all/app/scripts/copy-android-scaffolding.mjs @@ -25,6 +25,7 @@ const KOTLIN_FILES = [ 'OpenLessAccessibilityService.kt', 'OpenLessAccessibilityCommandReceiver.kt', 'OverlayPermissionActivity.kt', + 'OpenLessUpdateInstaller.kt', ]; const XML_FILES = [ diff --git a/openless-all/app/scripts/merge-android-updater-manifest.mjs b/openless-all/app/scripts/merge-android-updater-manifest.mjs new file mode 100644 index 00000000..76036d1f --- /dev/null +++ b/openless-all/app/scripts/merge-android-updater-manifest.mjs @@ -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 = ` + + + `; + +const FILE_PATHS_XML = ` + + + + +`; + +function permissionExists(manifestXml, permissionName) { + const escaped = permissionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`]*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 = ` `; + const applicationIdx = content.indexOf(''); + 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 = ''; + const idx = manifestXml.lastIndexOf(closeTag); + if (idx === -1) throw new Error('Target manifest is missing '); + 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(); diff --git a/openless-all/app/scripts/sign-android-apks.mjs b/openless-all/app/scripts/sign-android-apks.mjs new file mode 100644 index 00000000..0dda9f89 --- /dev/null +++ b/openless-all/app/scripts/sign-android-apks.mjs @@ -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 [ ...] + */ +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 [ ...]'); + process.exit(1); + } + for (const apk of apks) { + signApk(apk); + } +} + +main(); diff --git a/openless-all/app/scripts/write-updater-manifest.mjs b/openless-all/app/scripts/write-updater-manifest.mjs index 0803fbb1..92cecbaa 100755 --- a/openless-all/app/scripts/write-updater-manifest.mjs +++ b/openless-all/app/scripts/write-updater-manifest.mjs @@ -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/(GitHub 自动重定向到最新非-prerelease) -// Beta: releases/download//(指定具体 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}`); diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index 897bbfb7..bb1a2840 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -3840,6 +3840,7 @@ dependencies = [ "libc", "local-ip-address", "log", + "minisign-verify", "ndk-context", "objc2 0.5.2", "objc2-app-kit 0.2.2", diff --git a/openless-all/app/src-tauri/Cargo.toml b/openless-all/app/src-tauri/Cargo.toml index 080f3b90..e675dd0f 100644 --- a/openless-all/app/src-tauri/Cargo.toml +++ b/openless-all/app/src-tauri/Cargo.toml @@ -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" diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index dcaeae8a..72e9e1e4 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -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, 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 { + 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)], + ) + } } diff --git a/openless-all/app/src-tauri/src/android/mod.rs b/openless-all/app/src-tauri/src/android/mod.rs index 0f58039e..62eff3e1 100644 --- a/openless-all/app/src-tauri/src/android/mod.rs +++ b/openless-all/app/src-tauri/src/android/mod.rs @@ -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; diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs new file mode 100644 index 00000000..29e7f447 --- /dev/null +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -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, + url: String, + signature: String, + #[serde(default)] + notes: Option, + } + + #[derive(Clone, serde::Serialize)] + #[serde(rename_all = "camelCase")] + pub struct AndroidUpdateProgress { + pub downloaded: u64, + pub content_length: Option, + 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 { + 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 { + 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::() + .await + .map_err(|e| format!("parse manifest: {e}")) + } + + async fn resolve_stable_manifest_urls(arch: &str) -> Vec { + 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, 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, 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, ¤t) { + 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 { + 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 { + parse_latest_beta_from_atom(body) + } +} + +#[cfg(target_os = "android")] +pub use android_impl::*; diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index f5771de5..855eddea 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -364,15 +364,16 @@ fn extract_between(haystack: &str, open: &str, close: &str) -> Option { #[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, pub body: Option, - /// 原始 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, 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, + channel: Option, +) -> Result, 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()) + } +} diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index b6ee0846..bd086fa6 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -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, ] }; diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 8794081e..847bcef5 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -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, } } diff --git a/openless-all/app/src-tauri/tauri.conf.json b/openless-all/app/src-tauri/tauri.conf.json index a347227a..db638a8f 100644 --- a/openless-all/app/src-tauri/tauri.conf.json +++ b/openless-all/app/src-tauri/tauri.conf.json @@ -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" diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index 0d0cfa0e..ac9a008e 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -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; - /** 用户在对话框里确认 → 下载 + 安装。完成后状态变为 'downloaded',等用户点重启。 */ installUpdate: () => Promise; - /** 关闭对话框(仅在非 busy 状态可用)。 */ dismissDialog: () => Promise; } +type AndroidUpdatePayload = { + url: string; + signature: string; + version: string; +}; + export function useAutoUpdate(): UseAutoUpdate { const updateRef = useRef(null); + const androidUpdateRef = useRef(null); const [status, setStatus] = useState('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 (
{t(`settings.about.updateDialog.${status}.title`)}
- {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 })}
{(downloading || installing || status === 'downloaded') && (
@@ -224,7 +290,7 @@ export function UpdateDialog({ {status === 'available' && {t('settings.about.updateDialog.install')}} {(downloading || installing) && {installing ? t('settings.about.updateDialog.installingLabel') : t('settings.about.updateDialog.downloadingLabel')}} {status === 'downloaded' && {t('settings.about.updateDialog.later')}} - {status === 'downloaded' && {t('settings.about.updateDialog.restartNow')}} + {status === 'downloaded' && !androidInstalled && {t('settings.about.updateDialog.restartNow')}}
diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 95c67847..7c3625a3 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -695,6 +695,23 @@ export function appCheckUpdateWithChannel( }) } +export function appDownloadAndInstallAndroidUpdate(args: { + url: string + signature: string + version: string +}): Promise { + 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 { return platformCapabilities().then((caps) => { if (!caps.supportsDesktopHotkey) { diff --git a/openless-all/app/src/lib/platform.ts b/openless-all/app/src/lib/platform.ts index 03badcdb..cd4462a0 100644 --- a/openless-all/app/src/lib/platform.ts +++ b/openless-all/app/src/lib/platform.ts @@ -68,7 +68,7 @@ export function inferPlatformCapabilities(): PlatformCapabilities { supportsImeInput: false, supportsLocalAsr: false, supportsInAppDictation: true, - supportsAutoUpdate: false, + supportsAutoUpdate: true, }; }