合并 #704(安卓应用内自动更新 + JNI 修复)并把 PC updater 修复同步到安卓:①安卓公钥回退 6F4B5994→1DEAA803(同步#705)②installError 安装失败兜底+去 throw(同步#708)③还原被自动合并误删的桌面 logClientError。桌面机制零变更。CI 全绿 + @claude 审核通过。取代 #704 及 #694 安卓部分。
This commit is contained in:
32 files changed
+779
-239
No files matched your search
@@ -1,8 +1,9 @@
|
||||
name: Android APK (debug)
|
||||
|
||||
# Triggers:
|
||||
# - push v*-tauri tag → release APK (signed), updater manifest, attach to GitHub Release
|
||||
# - workflow_dispatch → debug APK only, upload artifact (no release)
|
||||
# - push v*-tauri tag → signed release APK + minisign + updater manifest → GitHub Release
|
||||
# - workflow_dispatch → signed release APK when ANDROID_KEYSTORE_* secrets exist
|
||||
# (overlay install + user data preserved); otherwise unsigned debug APK (annotated, non-blocking)
|
||||
#
|
||||
# Scope: full overlay/accessibility APK for ADB testing and tag releases.
|
||||
|
||||
@@ -30,17 +31,47 @@ jobs:
|
||||
- name: Detect build mode
|
||||
id: mode
|
||||
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: |
|
||||
set -euo pipefail
|
||||
is_tag=false
|
||||
if [[ "${{ github.ref }}" == refs/tags/v* ]] && [[ "${{ github.ref_name }}" == *-tauri ]]; then
|
||||
is_tag=true
|
||||
fi
|
||||
echo "is_tag_release=$is_tag" >> "$GITHUB_OUTPUT"
|
||||
|
||||
has_keystore=true
|
||||
for name in ANDROID_KEYSTORE_BASE64 ANDROID_KEYSTORE_PASSWORD ANDROID_KEY_ALIAS ANDROID_KEY_PASSWORD; do
|
||||
if [ -z "${!name:-}" ]; then
|
||||
has_keystore=false
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if $is_tag; then
|
||||
echo "mode=release" >> "$GITHUB_OUTPUT"
|
||||
echo "label=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||
echo "signing=tag-release" >> "$GITHUB_OUTPUT"
|
||||
echo "signing_label=Tag release with release keystore" >> "$GITHUB_OUTPUT"
|
||||
elif $has_keystore; then
|
||||
echo "mode=release" >> "$GITHUB_OUTPUT"
|
||||
echo "label=dispatch-run-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
|
||||
echo "signing=dispatch-release" >> "$GITHUB_OUTPUT"
|
||||
echo "signing_label=Manual dispatch with release keystore (overlay install OK)" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "mode=debug" >> "$GITHUB_OUTPUT"
|
||||
echo "label=run-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
|
||||
echo "label=unsigned-run-${{ github.run_number }}" >> "$GITHUB_OUTPUT"
|
||||
echo "signing=debug-fallback" >> "$GITHUB_OUTPUT"
|
||||
echo "signing_label=Unsigned debug fallback (no keystore secrets)" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice title=Unsigned debug APK::ANDROID_KEYSTORE_* secrets not configured. Building debug APK without release signing. Overlay install and user data preservation require the release keystore; uninstall before installing if replacing a signed build."
|
||||
fi
|
||||
|
||||
- name: Check updater signing availability (tag release)
|
||||
if: steps.mode.outputs.mode == 'release'
|
||||
if: steps.mode.outputs.is_tag_release == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -51,7 +82,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Check Android keystore secrets (tag release)
|
||||
if: steps.mode.outputs.mode == 'release'
|
||||
if: steps.mode.outputs.is_tag_release == 'true'
|
||||
shell: bash
|
||||
env:
|
||||
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
|
||||
@@ -281,7 +312,7 @@ jobs:
|
||||
PY
|
||||
|
||||
- name: Sign release APKs (minisign)
|
||||
if: steps.mode.outputs.mode == 'release'
|
||||
if: steps.mode.outputs.is_tag_release == 'true'
|
||||
working-directory: openless-all/app
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
@@ -295,7 +326,7 @@ jobs:
|
||||
"${{ steps.apk.outputs.x86_64_path }}"
|
||||
|
||||
- name: Write Android updater manifests
|
||||
if: steps.mode.outputs.mode == 'release'
|
||||
if: steps.mode.outputs.is_tag_release == 'true'
|
||||
working-directory: openless-all/app
|
||||
env:
|
||||
OPENLESS_UPDATE_APK_DIR: ${{ steps.apk.outputs.out_dir }}
|
||||
@@ -311,7 +342,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Append release files (manifests + signatures)
|
||||
if: steps.mode.outputs.mode == 'release'
|
||||
if: steps.mode.outputs.is_tag_release == 'true'
|
||||
id: release_assets
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -356,7 +387,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Prepare Android release body
|
||||
if: steps.mode.outputs.mode == 'release'
|
||||
if: steps.mode.outputs.is_tag_release == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
cat > "$RUNNER_TEMP/android-release-body.md" << 'EOF'
|
||||
@@ -374,7 +405,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Attach Android assets to GitHub Release
|
||||
if: steps.mode.outputs.mode == 'release'
|
||||
if: steps.mode.outputs.is_tag_release == 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
@@ -384,3 +415,26 @@ jobs:
|
||||
append_body: true
|
||||
body_path: ${{ runner.temp }}/android-release-body.md
|
||||
files: ${{ steps.release_assets.outputs.files }}
|
||||
|
||||
- name: Write build summary
|
||||
if: always() && steps.mode.outputs.signing != ''
|
||||
run: |
|
||||
cat >> "$GITHUB_STEP_SUMMARY" << EOF
|
||||
### Android APK build
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Gradle mode | \`${{ steps.mode.outputs.mode }}\` |
|
||||
| Signing | ${{ steps.mode.outputs.signing_label }} |
|
||||
| Artifact label | \`${{ steps.mode.outputs.label }}\` |
|
||||
|
||||
EOF
|
||||
if [ "${{ steps.mode.outputs.signing }}" = "debug-fallback" ]; then
|
||||
cat >> "$GITHUB_STEP_SUMMARY" << 'EOF'
|
||||
> **Unsigned debug APK.** Debug builds use a CI-only signature. Use `adb install -r` only when replacing the same debug build. To upgrade from a signed release without losing user data, configure `ANDROID_KEYSTORE_*` repo secrets and re-run this workflow.
|
||||
EOF
|
||||
elif [ "${{ steps.mode.outputs.signing }}" = "dispatch-release" ]; then
|
||||
cat >> "$GITHUB_STEP_SUMMARY" << 'EOF'
|
||||
> **Signed release APK** (manual dispatch). Same keystore as tag releases — supports overlay install and preserves user data when upgrading from an existing signed install.
|
||||
EOF
|
||||
fi
|
||||
@@ -74,6 +74,9 @@ jobs:
|
||||
# generate_context! requires frontendDist (../dist) to exist on Android too.
|
||||
run: npm run build
|
||||
|
||||
- name: Check Android updater pubkey matches tauri.conf.json
|
||||
run: npm run check:android-updater-pubkey
|
||||
|
||||
- name: Check Tauri backend (Android target)
|
||||
run: cargo check --manifest-path src-tauri/Cargo.toml --target aarch64-linux-android
|
||||
|
||||
|
||||
@@ -38,6 +38,13 @@ target/
|
||||
ci-artifacts/
|
||||
*.apk
|
||||
|
||||
# UI smoke / Playwright 截图与报告(本地校验用,非源码)
|
||||
openless-all/ui-check-screenshots/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
blob-report/
|
||||
|
||||
# Android 真机调试产物(日志 / 截图 / adb 导出 / 数据备份 / 临时还原目录)
|
||||
diagnostics/
|
||||
android-logs/
|
||||
|
||||
@@ -230,8 +230,8 @@ Desktop permissions live in `capabilities/default.json` with `"platforms": ["mac
|
||||
|
||||
| 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 |
|
||||
| `workflow_dispatch` | **release** if `ANDROID_KEYSTORE_*` secrets configured; else **debug (unsigned)** | Upload Actions artifacts; non-blocking fallback with job summary notice when unsigned |
|
||||
| Push tag `v*-tauri` / `v*-beta-tauri` | **release** (required secrets) | Signed release APKs + minisign `.sig` + `latest-android-{arch}[-beta].json` → attach to GitHub Release |
|
||||
|
||||
`OPENLESS_RELEASE_CHANNEL` matches desktop `release-tauri.yml`: `-beta-tauri` → beta (prerelease manifests); otherwise stable.
|
||||
|
||||
@@ -277,6 +277,30 @@ OPENLESS_UPDATE_APK_DIR=... OPENLESS_UPDATE_TARGET=android OPENLESS_UPDATE_ARCH=
|
||||
- `merge-android-overlay-manifest.mjs` — overlay + accessibility service
|
||||
- `merge-android-updater-manifest.mjs` — `REQUEST_INSTALL_PACKAGES` + `FileProvider` for APK install
|
||||
|
||||
### In-app updater (Android)
|
||||
|
||||
Custom Rust module [`openless-all/app/src-tauri/src/android/updater.rs`](../openless-all/app/src-tauri/src/android/updater.rs) + shared helpers [`updater_logic.rs`](../openless-all/app/src-tauri/src/android/updater_logic.rs). Desktop continues to use `tauri-plugin-updater`.
|
||||
|
||||
**Manifest URLs** (generated by [`write-updater-manifest.mjs`](../openless-all/app/scripts/write-updater-manifest.mjs)):
|
||||
|
||||
| Channel | Filename | GitHub path |
|
||||
|---|---|---|
|
||||
| Stable | `latest-android-{arch}.json` | `releases/latest/download/...` |
|
||||
| Beta | `latest-android-{arch}-beta.json` | `releases/download/{v*-beta-tauri tag}/...` |
|
||||
|
||||
Client tries mirror URL first (`-mirror.json`), then direct GitHub. Beta tag is resolved from `releases.atom` (first `-beta-tauri` entry).
|
||||
|
||||
**User-facing behavior**:
|
||||
|
||||
- **Settings → About**: manual “Check stable update” always fetches stable manifest.
|
||||
- **Settings → Advanced**: Beta toggle sets `prefs.updateChannel` for background checks; “Check Beta update” always fetches beta manifest (independent of toggle).
|
||||
- **Settings → Advanced → Auto-update** (Android): `autoUpdateCheck` toggle — when on, `AutoUpdateGate` checks on launch (+4s) and every 60 minutes using `updateChannel`, then **automatically downloads**, minisign-verifies, and opens the **system APK installer**. When off, only manual buttons run.
|
||||
- **Desktop**: `autoUpdateCheck` only auto-checks; user confirms in `UpdateDialog` before download/install/restart.
|
||||
|
||||
**Install flow**: download to app cache → minisign verify → JNI `install_apk_from_path` → Android system installer. Over-the-air replace is **not** implemented in-app; the user completes install via the system UI. Requires matching APK signature for upgrade.
|
||||
|
||||
**Prefs**: `updateChannel` (`stable` | `beta`) = background auto-update channel only; manual buttons pass explicit channel and ignore this pref.
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收标准
|
||||
|
||||
@@ -22,6 +22,8 @@ src-tauri/src/android/ # Rust 运行时模块(crate::android)
|
||||
| `overlay.rs` | 悬浮窗权限与 show/hide |
|
||||
| `accessibility.rs` | 无障碍服务状态与 paste |
|
||||
| `insert.rs` | 跨 App 文本插入策略 |
|
||||
| `updater.rs` | 应用内更新(manifest 拉取、minisign 校验、系统安装器) |
|
||||
| `updater_logic.rs` | 更新 URL / 版本比较纯函数(全平台可测) |
|
||||
| `types.rs` | Android 偏好与状态类型 |
|
||||
|
||||
主 crate 通过 `mod android;` 引入,常用 API 经 `crate::android::` 扁平 re-export。
|
||||
|
||||
@@ -14,6 +14,7 @@ import android.util.Log
|
||||
import android.view.accessibility.AccessibilityEvent
|
||||
import android.view.accessibility.AccessibilityNodeInfo
|
||||
import android.view.accessibility.AccessibilityWindowInfo
|
||||
import androidx.annotation.Keep
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -288,12 +289,14 @@ class OpenLessAccessibilityService : AccessibilityService() {
|
||||
private set
|
||||
|
||||
@JvmStatic
|
||||
@Keep
|
||||
fun pasteToFocusedField(): Boolean {
|
||||
instance?.let { return it.performPasteToFocusedField() }
|
||||
return sendPasteRequestToAccessibilityProcess()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Keep
|
||||
fun captureSelectedText(): String {
|
||||
return instance?.captureSelectedTextFromFocusedNode().orEmpty()
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@ package com.openless.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.annotation.Keep
|
||||
|
||||
/**
|
||||
* Rust calls back into this object to refresh overlay UI state.
|
||||
*/
|
||||
@Keep
|
||||
object OpenLessOverlayBridge {
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
@@ -16,6 +18,7 @@ object OpenLessOverlayBridge {
|
||||
fun onCapsuleStateChanged(state: String, message: String?)
|
||||
}
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
fun onCapsuleStateChanged(state: String, message: String?) {
|
||||
mainHandler.post {
|
||||
@@ -23,6 +26,7 @@ object OpenLessOverlayBridge {
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
fun showToast(message: String) {
|
||||
mainHandler.post {
|
||||
|
||||
@@ -65,7 +65,11 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
|
||||
ACTION_SHOW -> showOverlay()
|
||||
ACTION_START_RECORDING -> {
|
||||
showOverlay()
|
||||
startRecordingFromOverlay()
|
||||
if (!tryPromoteRecordingForeground()) {
|
||||
abortRecordingStart(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
beginDictationFromOverlay()
|
||||
}
|
||||
ACTION_HIDE -> {
|
||||
hideOverlay()
|
||||
@@ -636,26 +640,41 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
|
||||
}
|
||||
}
|
||||
|
||||
private fun abortRecordingStart(startId: Int) {
|
||||
recording = false
|
||||
processing = false
|
||||
try {
|
||||
OpenLessNative.nativeCancelDictation()
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "cancel dictation after foreground failure", error)
|
||||
}
|
||||
stopSelf(startId)
|
||||
}
|
||||
|
||||
private fun beginDictationFromOverlay(translation: Boolean = false) {
|
||||
try {
|
||||
if (translation) {
|
||||
OpenLessNative.nativeStartDictationWithTranslation(true)
|
||||
} else {
|
||||
OpenLessNative.nativeStartDictation()
|
||||
}
|
||||
recording = true
|
||||
processing = false
|
||||
setArmed(false)
|
||||
applyVisualState(OverlayVisualState.Recording)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "start dictation bridge unavailable", error)
|
||||
recording = false
|
||||
processing = false
|
||||
applyVisualState(OverlayVisualState.Error)
|
||||
showToast("语音服务未就绪,请打开 OpenLess 后重试")
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRecordingFromOverlay(translation: Boolean = false) {
|
||||
showOverlay()
|
||||
if (tryPromoteRecordingForeground()) {
|
||||
try {
|
||||
if (translation) {
|
||||
OpenLessNative.nativeStartDictationWithTranslation(true)
|
||||
} else {
|
||||
OpenLessNative.nativeStartDictation()
|
||||
}
|
||||
recording = true
|
||||
processing = false
|
||||
setArmed(false)
|
||||
applyVisualState(OverlayVisualState.Recording)
|
||||
} catch (error: Throwable) {
|
||||
Log.w(TAG, "start dictation bridge unavailable", error)
|
||||
recording = false
|
||||
processing = false
|
||||
applyVisualState(OverlayVisualState.Error)
|
||||
showToast("语音服务未就绪,请打开 OpenLess 后重试")
|
||||
}
|
||||
beginDictationFromOverlay(translation)
|
||||
return
|
||||
}
|
||||
applyVisualState(OverlayVisualState.Error)
|
||||
|
||||
@@ -6,13 +6,16 @@ import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.Keep
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
@Keep
|
||||
object OpenLessPermissionBridge {
|
||||
private const val TAG = "OpenLessPermissionBridge"
|
||||
|
||||
private val requestInFlight = AtomicBoolean(false)
|
||||
|
||||
@Keep
|
||||
@JvmStatic
|
||||
fun requestRecordAudioPermission(context: Context): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {
|
||||
|
||||
@@ -5,13 +5,17 @@ import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.Keep
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Triggers system package installer for a downloaded APK via FileProvider.
|
||||
*/
|
||||
@Keep
|
||||
object OpenLessUpdateInstaller {
|
||||
@Keep
|
||||
@JvmStatic
|
||||
fun installApk(context: Context, apkPath: String): Boolean {
|
||||
val apkFile = File(apkPath)
|
||||
if (!apkFile.exists()) {
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs",
|
||||
"check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs",
|
||||
"check:hotkey-injection": "node scripts/check-hotkey-injection.mjs",
|
||||
"check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs"
|
||||
"check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs",
|
||||
"check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formkit/auto-animate": "^0.9.0",
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Ensure Android updater minisign pubkey matches tauri.conf.json plugins.updater.pubkey.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
const confPath = join(appRoot, 'src-tauri/tauri.conf.json');
|
||||
const logicPath = join(appRoot, 'src-tauri/src/android/updater_logic.rs');
|
||||
|
||||
const conf = JSON.parse(readFileSync(confPath, 'utf8'));
|
||||
const confPubkey = conf?.plugins?.updater?.pubkey;
|
||||
if (!confPubkey) {
|
||||
console.error('check-android-updater-pubkey: missing plugins.updater.pubkey in tauri.conf.json');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const logicSource = readFileSync(logicPath, 'utf8');
|
||||
const match = logicSource.match(
|
||||
/pub const UPDATER_PUBKEY_B64: &str\s*=\s*"([^"]+)"/,
|
||||
);
|
||||
if (!match) {
|
||||
console.error('check-android-updater-pubkey: UPDATER_PUBKEY_B64 not found in updater_logic.rs');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const rustPubkey = match[1];
|
||||
if (rustPubkey !== confPubkey) {
|
||||
console.error('check-android-updater-pubkey: pubkey mismatch');
|
||||
console.error(' tauri.conf.json:', confPubkey);
|
||||
console.error(' updater_logic.rs:', rustPubkey);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('check-android-updater-pubkey: OK');
|
||||
@@ -188,17 +188,8 @@ pub mod android {
|
||||
&[JValue::Object(&action_obj)],
|
||||
)
|
||||
.map_err(|error| format!("set service action: {error}"))?;
|
||||
// REPLACE_OVERLAY 和 REFRESH_LAYOUT 与 SHOW/HIDE 一样,发送到已在运行的服务,
|
||||
// 不应使用 startForegroundService(Android 12+ 在后台调用会抛
|
||||
// ForegroundServiceStartNotAllowedException)。
|
||||
let start_method =
|
||||
if action.ends_with(".HIDE")
|
||||
|| action.ends_with(".SHOW")
|
||||
|| action.ends_with(".REPLACE_OVERLAY")
|
||||
|| action.ends_with(".REFRESH_LAYOUT")
|
||||
{
|
||||
"startService"
|
||||
} else if android_sdk_int(env)? >= 26 {
|
||||
if action.ends_with(".START_RECORDING") && android_sdk_int(env)? >= 26 {
|
||||
"startForegroundService"
|
||||
} else {
|
||||
"startService"
|
||||
@@ -504,32 +495,146 @@ pub mod android {
|
||||
}
|
||||
}
|
||||
|
||||
const ACCESSIBILITY_SERVICE_CLASS: &str = "com.openless.app.OpenLessAccessibilityService";
|
||||
const ACCESSIBILITY_PREFS_NAME: &str = "openless_accessibility";
|
||||
const ACCESSIBILITY_HEARTBEAT_KEY: &str = "last_heartbeat";
|
||||
const ACCESSIBILITY_HEARTBEAT_STALE_MS: i64 = 15_000;
|
||||
|
||||
fn content_resolver<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
context: &JObject<'local>,
|
||||
) -> Result<JObject<'local>, String> {
|
||||
env.call_method(context, "getContentResolver", "()Landroid/content/ContentResolver;", &[])
|
||||
.and_then(|value| value.l())
|
||||
.map_err(|error| format!("Context.getContentResolver: {error}"))
|
||||
}
|
||||
|
||||
fn jstring_object_to_option<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
value: JObject<'local>,
|
||||
) -> Result<Option<String>, String> {
|
||||
if value.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let text = env
|
||||
.get_string(&JString::from(value))
|
||||
.map_err(|error| format!("read jstring: {error}"))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
Ok(Some(text))
|
||||
}
|
||||
|
||||
fn settings_secure_get_int<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
context: &JObject<'local>,
|
||||
key: &str,
|
||||
default: i32,
|
||||
) -> Result<i32, String> {
|
||||
let resolver = content_resolver(env, context)?;
|
||||
let key_obj = jobject_str(env, key)?;
|
||||
env.call_static_method(
|
||||
"android/provider/Settings$Secure",
|
||||
"getInt",
|
||||
"(Landroid/content/ContentResolver;Ljava/lang/String;I)I",
|
||||
&[
|
||||
JValue::Object(&resolver),
|
||||
JValue::Object(&key_obj),
|
||||
JValue::Int(default),
|
||||
],
|
||||
)
|
||||
.and_then(|value| value.i())
|
||||
.map_err(|error| format!("Settings.Secure.getInt({key}): {error}"))
|
||||
}
|
||||
|
||||
fn settings_secure_get_string<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
context: &JObject<'local>,
|
||||
key: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
let resolver = content_resolver(env, context)?;
|
||||
let key_obj = jobject_str(env, key)?;
|
||||
let value = env
|
||||
.call_static_method(
|
||||
"android/provider/Settings$Secure",
|
||||
"getString",
|
||||
"(Landroid/content/ContentResolver;Ljava/lang/String;)Ljava/lang/String;",
|
||||
&[JValue::Object(&resolver), JValue::Object(&key_obj)],
|
||||
)
|
||||
.and_then(|value| value.l())
|
||||
.map_err(|error| format!("Settings.Secure.getString({key}): {error}"))?;
|
||||
jstring_object_to_option(env, value)
|
||||
}
|
||||
|
||||
fn accessibility_service_component_id<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
context: &JObject<'local>,
|
||||
) -> Result<String, String> {
|
||||
let package_name = env
|
||||
.call_method(context, "getPackageName", "()Ljava/lang/String;", &[])
|
||||
.and_then(|value| value.l())
|
||||
.map_err(|error| format!("Context.getPackageName: {error}"))?;
|
||||
let package = env
|
||||
.get_string(&JString::from(package_name))
|
||||
.map_err(|error| format!("read package name: {error}"))?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
Ok(format!("{package}/{ACCESSIBILITY_SERVICE_CLASS}"))
|
||||
}
|
||||
|
||||
pub fn accessibility_enabled<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
context: &JObject<'local>,
|
||||
) -> Result<bool, String> {
|
||||
call_static_bool_with_context_class(
|
||||
env,
|
||||
context,
|
||||
"com.openless.app.OpenLessAccessibilityService",
|
||||
"isEnabled",
|
||||
"(Landroid/content/Context;)Z",
|
||||
&[JValue::Object(context)],
|
||||
)
|
||||
// Read Settings.Secure directly — avoids Kotlin @JvmStatic drift on older APK dex.
|
||||
if settings_secure_get_int(env, context, "accessibility_enabled", 0)? != 1 {
|
||||
return Ok(false);
|
||||
}
|
||||
let services = settings_secure_get_string(env, context, "enabled_accessibility_services")?
|
||||
.unwrap_or_default();
|
||||
let component_id = accessibility_service_component_id(env, context)?;
|
||||
Ok(services.contains(&component_id))
|
||||
}
|
||||
|
||||
pub fn accessibility_operational<'local>(
|
||||
env: &mut JNIEnv<'local>,
|
||||
context: &JObject<'local>,
|
||||
) -> Result<bool, String> {
|
||||
call_static_bool_with_context_class(
|
||||
env,
|
||||
context,
|
||||
"com.openless.app.OpenLessAccessibilityService",
|
||||
"isOperational",
|
||||
"(Landroid/content/Context;)Z",
|
||||
&[JValue::Object(context)],
|
||||
)
|
||||
if !accessibility_enabled(env, context)? {
|
||||
return Ok(false);
|
||||
}
|
||||
let prefs_name = jobject_str(env, ACCESSIBILITY_PREFS_NAME)?;
|
||||
let prefs = env
|
||||
.call_method(
|
||||
context,
|
||||
"getSharedPreferences",
|
||||
"(Ljava/lang/String;I)Landroid/content/SharedPreferences;",
|
||||
&[JValue::Object(&prefs_name), JValue::Int(0)],
|
||||
)
|
||||
.and_then(|value| value.l())
|
||||
.map_err(|error| format!("Context.getSharedPreferences: {error}"))?;
|
||||
let heartbeat_key = jobject_str(env, ACCESSIBILITY_HEARTBEAT_KEY)?;
|
||||
let last_heartbeat = env
|
||||
.call_method(
|
||||
&prefs,
|
||||
"getLong",
|
||||
"(Ljava/lang/String;J)J",
|
||||
&[JValue::Object(&heartbeat_key), JValue::Long(0)],
|
||||
)
|
||||
.and_then(|value| value.j())
|
||||
.map_err(|error| format!("SharedPreferences.getLong: {error}"))?;
|
||||
if last_heartbeat <= 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let now = env
|
||||
.call_static_method(
|
||||
"java/lang/System",
|
||||
"currentTimeMillis",
|
||||
"()J",
|
||||
&[],
|
||||
)
|
||||
.and_then(|value| value.j())
|
||||
.map_err(|error| format!("System.currentTimeMillis: {error}"))?;
|
||||
Ok(now.saturating_sub(last_heartbeat) <= ACCESSIBILITY_HEARTBEAT_STALE_MS)
|
||||
}
|
||||
|
||||
pub fn launch_accessibility_settings(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
pub mod accessibility;
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod insert;
|
||||
pub mod updater_logic;
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod updater;
|
||||
pub mod jni;
|
||||
|
||||
@@ -8,16 +8,16 @@ mod android_impl {
|
||||
use serde::Deserialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::android::updater_logic::{
|
||||
beta_manifest_urls, format_manifest_error, map_abi_to_arch, stable_manifest_urls,
|
||||
UPDATER_PUBKEY_B64, INSTALLER_NOT_OPENED_MSG, version_is_newer,
|
||||
};
|
||||
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,
|
||||
@@ -39,13 +39,10 @@ mod android_impl {
|
||||
|
||||
fn device_arch() -> Result<&'static str, String> {
|
||||
crate::android::jni::android::with_android_env(|env, _context| {
|
||||
// SUPPORTED_ABIS is a static field, not a method — call_static_method crashes.
|
||||
let abis_obj = env
|
||||
.get_static_field(
|
||||
"android/os/Build",
|
||||
"SUPPORTED_ABIS",
|
||||
"[Ljava/lang/String;",
|
||||
)
|
||||
.and_then(|v| v.l())
|
||||
.get_static_field("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
|
||||
@@ -66,38 +63,6 @@ mod android_impl {
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -107,36 +72,23 @@ mod android_impl {
|
||||
.await
|
||||
.map_err(|e| format!("fetch manifest: {e}"))?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("manifest status {}", resp.status()));
|
||||
return Err(format_manifest_error(resp.status().as_u16(), url));
|
||||
}
|
||||
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?,
|
||||
UpdateChannel::Stable => stable_manifest_urls(arch),
|
||||
UpdateChannel::Beta => {
|
||||
let Some(latest) = fetch_latest_beta_release().await? else {
|
||||
return Err("尚未发布过 Beta 版本".to_string());
|
||||
};
|
||||
beta_manifest_urls(arch, &latest.tag_name)
|
||||
}
|
||||
};
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
let mut last_err = String::new();
|
||||
@@ -174,7 +126,7 @@ mod android_impl {
|
||||
|
||||
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}"))?;
|
||||
PublicKey::from_base64(UPDATER_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
|
||||
@@ -267,10 +219,13 @@ mod android_impl {
|
||||
.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 opened = 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)
|
||||
})?;
|
||||
if !opened {
|
||||
return Err(INSTALLER_NOT_OPENED_MSG.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
//! Pure Android updater helpers (manifest URLs, version compare). Testable on all targets.
|
||||
|
||||
pub const MIRROR_BASE: &str = "https://fastgit.cc/https://github.com/appergb/openless";
|
||||
pub const DIRECT_BASE: &str = "https://github.com/appergb/openless";
|
||||
|
||||
/// Must match `plugins.updater.pubkey` in `tauri.conf.json` (TAURI_SIGNING_PRIVATE_KEY pair).
|
||||
pub const UPDATER_PUBKEY_B64: &str =
|
||||
"dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFERUFBODAzNTY0QzMyM0YKUldRL01reFdBNmpxSGE1K0JadlpONXNWTzhJcGZCRGxjUVdIWExNNFJpeUNsSGZwazdlQThhemkK";
|
||||
|
||||
/// Returned when Kotlin `installApk` cannot open the system installer (e.g. missing install permission).
|
||||
pub const INSTALLER_NOT_OPENED_MSG: &str =
|
||||
"无法打开系统安装器:请先在系统设置中允许 OpenLess 安装未知应用,然后重新点击更新";
|
||||
|
||||
pub fn map_abi_to_arch(abi: &str) -> &'static str {
|
||||
match abi {
|
||||
"arm64-v8a" => "aarch64",
|
||||
"armeabi-v7a" => "armv7",
|
||||
"x86" => "i686",
|
||||
"x86_64" => "x86_64",
|
||||
_ => "aarch64",
|
||||
}
|
||||
}
|
||||
|
||||
pub 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
|
||||
}
|
||||
|
||||
pub fn 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"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn beta_manifest_urls(arch: &str, tag: &str) -> Vec<String> {
|
||||
vec![
|
||||
format!("{MIRROR_BASE}/releases/download/{tag}/latest-android-{arch}-beta-mirror.json"),
|
||||
format!("{DIRECT_BASE}/releases/download/{tag}/latest-android-{arch}-beta.json"),
|
||||
]
|
||||
}
|
||||
|
||||
/// Human-readable manifest fetch failure for UI tooltips.
|
||||
pub fn format_manifest_error(status: u16, url: &str) -> String {
|
||||
if status == 404 {
|
||||
format!("更新清单不存在 (404): {url}")
|
||||
} else {
|
||||
format!("无法获取更新清单 (HTTP {status}): {url}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_is_newer_detects_patch_bump() {
|
||||
assert!(version_is_newer("1.3.9", "1.3.8"));
|
||||
assert!(!version_is_newer("1.3.8", "1.3.9"));
|
||||
assert!(!version_is_newer("1.3.8", "1.3.8"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_is_newer_handles_beta_suffix() {
|
||||
assert!(version_is_newer("1.3.8-1", "1.3.8"));
|
||||
assert!(!version_is_newer("1.3.8", "1.3.8-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_manifest_urls_use_latest_download_path() {
|
||||
let urls = stable_manifest_urls("aarch64");
|
||||
assert_eq!(urls.len(), 2);
|
||||
assert!(urls[0].contains("latest-android-aarch64-mirror.json"));
|
||||
assert!(urls[1].ends_with("latest-android-aarch64.json"));
|
||||
assert!(!urls[1].contains("-beta"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beta_manifest_urls_include_tag_and_beta_suffix() {
|
||||
let urls = beta_manifest_urls("aarch64", "v1.3.8-1-beta-tauri");
|
||||
assert!(urls[0].contains("/releases/download/v1.3.8-1-beta-tauri/"));
|
||||
assert!(urls[1].ends_with("latest-android-aarch64-beta.json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_abi_to_arch_maps_arm64() {
|
||||
assert_eq!(map_abi_to_arch("arm64-v8a"), "aarch64");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updater_pubkey_matches_tauri_conf() {
|
||||
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let conf_path = manifest_dir.join("tauri.conf.json");
|
||||
let conf_text = std::fs::read_to_string(&conf_path)
|
||||
.unwrap_or_else(|e| panic!("read {}: {e}", conf_path.display()));
|
||||
let conf: serde_json::Value =
|
||||
serde_json::from_str(&conf_text).expect("parse tauri.conf.json");
|
||||
let conf_pubkey = conf
|
||||
.get("plugins")
|
||||
.and_then(|p| p.get("updater"))
|
||||
.and_then(|u| u.get("pubkey"))
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("plugins.updater.pubkey in tauri.conf.json");
|
||||
assert_eq!(conf_pubkey, UPDATER_PUBKEY_B64);
|
||||
}
|
||||
}
|
||||
@@ -288,6 +288,7 @@ macro_rules! app_invoke_handler_desktop {
|
||||
commands::sherpa_onnx_asr_reveal_model_dir,
|
||||
commands::export_error_log,
|
||||
restart_app,
|
||||
log_client_error,
|
||||
set_windows_caption_theme,
|
||||
]
|
||||
};
|
||||
@@ -378,6 +379,7 @@ macro_rules! app_invoke_handler_mobile {
|
||||
$crate::commands::app_check_update_with_channel,
|
||||
$crate::commands::app_download_and_install_android_update,
|
||||
$crate::restart_app,
|
||||
$crate::log_client_error,
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -1140,6 +1142,26 @@ fn restart_app(app: AppHandle) {
|
||||
app.restart();
|
||||
}
|
||||
|
||||
/// 把前端的关键错误(如自动更新 install 失败)转发到 Rust 文件日志(openless.log)。
|
||||
/// webview 的 console.error 不会进 openless.log,单独留一个 IPC,便于用户「导出日志」
|
||||
/// 后我们拿到自动更新失败的真实原因。
|
||||
#[tauri::command]
|
||||
fn log_client_error(message: String) {
|
||||
// message 由前端 webview 可控,可能很长或含换行(伪造日志行)。先把换行折成空格、
|
||||
// 再按 UTF-8 字符边界截断,避免单条日志过大或污染日志格式。
|
||||
const MAX_LEN: usize = 2048;
|
||||
let mut sanitized = message.replace(['\n', '\r'], " ");
|
||||
if sanitized.len() > MAX_LEN {
|
||||
let mut end = MAX_LEN;
|
||||
while !sanitized.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
sanitized.truncate(end);
|
||||
sanitized.push_str("…(truncated)");
|
||||
}
|
||||
log::error!("[client] {sanitized}");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn reset_tcc_for_beta_restart() {
|
||||
if !is_beta_build() {
|
||||
|
||||
@@ -77,13 +77,10 @@ pub enum PasteShortcut {
|
||||
ShiftInsert,
|
||||
}
|
||||
|
||||
/// Auto-update 渠道。决定 Settings → 关于 里展示哪一类版本信息。
|
||||
/// `Stable` 沿用 `tauri-plugin-updater` 的默认 endpoints(即 `tauri.conf.json`
|
||||
/// 里的 `latest-{{target}}-{{arch}}.json`),与发版 pipeline 对齐。
|
||||
/// `Beta` 不动 plugin endpoints —— 只解锁 Settings 里"手动下载最新 Beta"的入口
|
||||
/// (fetch GitHub `prerelease` + 跳浏览器),物理隔离 Beta 包不会通过 auto-update
|
||||
/// 推到正式版用户。详见 README 的"Contributing workflow"和 CLAUDE.md 的
|
||||
/// `Branch & release-channel workflow` 段落。
|
||||
/// Auto-update 渠道。决定后台 AutoUpdateGate 拉哪条 manifest。
|
||||
/// `Stable` = `latest-android-{arch}.json`(或桌面 plugin-updater 正式版 endpoints)。
|
||||
/// `Beta` = `latest-android-{arch}-beta.json`(或桌面 beta endpoints)。
|
||||
/// Settings 里手动「检查正式版 / 检查 Beta」按钮显式传 channel,不受此 pref 影响。
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum UpdateChannel {
|
||||
@@ -708,8 +705,8 @@ pub struct UserPreferences {
|
||||
/// foundry/qwen3 一致。
|
||||
#[serde(default = "default_local_asr_keep_loaded_secs")]
|
||||
pub sherpa_onnx_keep_loaded_secs: u32,
|
||||
/// Auto-update 渠道偏好。stable = 跟正式版(默认);beta = Settings 里多
|
||||
/// 一个手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。
|
||||
/// Auto-update 渠道。stable = 后台自动更新查正式版 manifest;beta = 查 Beta manifest。
|
||||
/// 手动检查按钮显式指定 channel,与此 pref 解耦。
|
||||
#[serde(default)]
|
||||
pub update_channel: UpdateChannel,
|
||||
/// 历史记录保留天数。0 = 不按时间清理(仅受 200 条上限)。默认 7 天。
|
||||
@@ -758,8 +755,9 @@ pub struct UserPreferences {
|
||||
/// 默认 true(更接近用户习惯)。
|
||||
#[serde(default = "default_true")]
|
||||
pub streaming_insert_save_clipboard: bool,
|
||||
/// 主窗口启动 + 后台每 60 分钟自动检查云端新版本。默认 true。
|
||||
/// 用户在 Settings → 关于 里可关。关闭后仅手动「检查更新」按钮可用。
|
||||
/// 主窗口启动 + 后台每 60 分钟自动检查更新。默认 true。
|
||||
/// Android 开启后自动检查并下载,校验后打开系统安装器;桌面仅自动检查 + 用户确认安装。
|
||||
/// 关闭后仅 Settings 手动「检查更新」按钮可用。
|
||||
#[serde(default = "default_true")]
|
||||
pub auto_update_check: bool,
|
||||
/// 历史记录上限(条数)。`None` = 使用代码内 200 条硬上限;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// 自动更新共用模块 — Settings 的"关于"section 和 footer 按钮共用同一套
|
||||
// 状态机 + 对话框 UI。两边各自调用 useAutoUpdate(),dialog 渲染条件相同。
|
||||
// 自动更新共用模块 — Settings 的"关于"section 和 AutoUpdateGate 共用同一套
|
||||
// 状态机 + 对话框 UI。各自调用 useAutoUpdate(),dialog 渲染条件相同。
|
||||
//
|
||||
// 渠道感知:check 走 appCheckUpdateWithChannel()(Rust 按渠道拼 manifest URL)。
|
||||
// 桌面:download/install 复用 plugin-updater 的 Update 类。
|
||||
// Android:download/install 走 appDownloadAndInstallAndroidUpdate(minisign + 系统安装器)。
|
||||
// Android 后台 Gate:发现更新后 autoInstallAndroid 跳过确认,直接下载并打开系统安装器。
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||
@@ -15,6 +16,8 @@ import {
|
||||
appDownloadAndInstallAndroidUpdate,
|
||||
isAndroid,
|
||||
isTauri,
|
||||
logClientError,
|
||||
openExternal,
|
||||
restartApp,
|
||||
type AppUpdateMetadata,
|
||||
type UpdateChannel,
|
||||
@@ -23,6 +26,9 @@ import { Btn } from '../pages/_atoms';
|
||||
|
||||
const UPDATE_CHECK_TIMEOUT_MS = 15_000;
|
||||
|
||||
// 自动更新失败时的手动下载兜底:直达 GitHub Releases(与「关于」页 RELEASE_NOTES_URL 一致)。
|
||||
const RELEASE_DOWNLOAD_URL = 'https://github.com/appergb/openless/releases';
|
||||
|
||||
export type UpdateStatus =
|
||||
| 'idle'
|
||||
| 'checking'
|
||||
@@ -31,7 +37,16 @@ export type UpdateStatus =
|
||||
| 'downloading'
|
||||
| 'installing'
|
||||
| 'downloaded'
|
||||
| 'error';
|
||||
| 'error'
|
||||
// installError:下载/安装这一步失败(区别于 'error' 的「检查失败」)。
|
||||
// 检查失败沿用 'error',在 CheckUpdateButton 里只做按钮内轻提示、不弹框,
|
||||
// 后台自动检查失败也不弹框;只有 installError 才让弹框留在原地显示错误 + 手动下载兜底。
|
||||
| 'installError';
|
||||
|
||||
export type CheckUpdateOptions = {
|
||||
/** Android only: skip confirmation dialog and download + open system installer. */
|
||||
autoInstallAndroid?: boolean;
|
||||
};
|
||||
|
||||
export interface UseAutoUpdate {
|
||||
status: UpdateStatus;
|
||||
@@ -42,7 +57,7 @@ export interface UseAutoUpdate {
|
||||
checking: boolean;
|
||||
busy: boolean;
|
||||
errorMessage: string | null;
|
||||
checkForUpdates: (channel?: UpdateChannel) => Promise<void>;
|
||||
checkForUpdates: (channel?: UpdateChannel, options?: CheckUpdateOptions) => Promise<void>;
|
||||
installUpdate: () => Promise<void>;
|
||||
dismissDialog: () => Promise<void>;
|
||||
}
|
||||
@@ -56,6 +71,8 @@ type AndroidUpdatePayload = {
|
||||
export function useAutoUpdate(): UseAutoUpdate {
|
||||
const updateRef = useRef<Update | null>(null);
|
||||
const androidUpdateRef = useRef<AndroidUpdatePayload | null>(null);
|
||||
/** True only while this hook instance initiated an Android download/install. */
|
||||
const androidDownloadActiveRef = useRef(false);
|
||||
const [status, setStatus] = useState<UpdateStatus>('idle');
|
||||
const [version, setVersion] = useState('');
|
||||
const [downloaded, setDownloaded] = useState(0);
|
||||
@@ -93,6 +110,7 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
contentLength: number | null;
|
||||
phase: string;
|
||||
}>('android-update:progress', (event) => {
|
||||
if (!androidDownloadActiveRef.current) return;
|
||||
setDownloaded(event.payload.downloaded);
|
||||
setContentLength(event.payload.contentLength);
|
||||
if (event.payload.phase === 'installing') {
|
||||
@@ -123,66 +141,30 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
androidUpdateRef.current = { url, signature, version: metadata.version };
|
||||
};
|
||||
|
||||
const checkForUpdates = async (channel?: UpdateChannel) => {
|
||||
setStatus('checking');
|
||||
setVersion('');
|
||||
setErrorMessage(null);
|
||||
const installAndroidUpdate = async () => {
|
||||
const payload = androidUpdateRef.current;
|
||||
if (!payload) return;
|
||||
resetProgress();
|
||||
await closeUpdate();
|
||||
setStatus('downloading');
|
||||
androidDownloadActiveRef.current = true;
|
||||
try {
|
||||
if (!isTauri) {
|
||||
setStatus('none');
|
||||
return;
|
||||
}
|
||||
const metadata = await appCheckUpdateWithChannel(
|
||||
UPDATE_CHECK_TIMEOUT_MS,
|
||||
channel ?? null,
|
||||
);
|
||||
if (!metadata) {
|
||||
setStatus('none');
|
||||
return;
|
||||
}
|
||||
if (isAndroid()) {
|
||||
storeAndroidMetadata(metadata);
|
||||
setVersion(metadata.version);
|
||||
setStatus('available');
|
||||
return;
|
||||
}
|
||||
const next = new Update({
|
||||
rid: metadata.rid,
|
||||
currentVersion: metadata.currentVersion,
|
||||
version: metadata.version,
|
||||
date: metadata.date ?? undefined,
|
||||
body: metadata.body ?? undefined,
|
||||
rawJson: metadata.rawJson,
|
||||
});
|
||||
updateRef.current = next;
|
||||
setVersion(next.version);
|
||||
setStatus('available');
|
||||
await appDownloadAndInstallAndroidUpdate(payload);
|
||||
androidUpdateRef.current = null;
|
||||
setStatus('downloaded');
|
||||
} catch (error) {
|
||||
console.error('[updater] failed to check update', error);
|
||||
console.error('[updater] failed to install android update', error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
void logClientError(`[updater] android install failed (v${payload.version}): ${msg}`);
|
||||
setErrorMessage(msg);
|
||||
setStatus('error');
|
||||
setStatus('installError');
|
||||
} finally {
|
||||
androidDownloadActiveRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
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');
|
||||
}
|
||||
await installAndroidUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -208,8 +190,62 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
} catch (error) {
|
||||
console.error('[updater] failed to install update', error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
void logClientError(`[updater] install failed (v${update.version}): ${msg}`);
|
||||
setErrorMessage(msg);
|
||||
await closeUpdate();
|
||||
setStatus('installError');
|
||||
}
|
||||
};
|
||||
|
||||
const checkForUpdates = async (channel?: UpdateChannel, options?: CheckUpdateOptions) => {
|
||||
setStatus('checking');
|
||||
setVersion('');
|
||||
setErrorMessage(null);
|
||||
resetProgress();
|
||||
await closeUpdate();
|
||||
try {
|
||||
if (!isTauri) {
|
||||
setStatus('none');
|
||||
return;
|
||||
}
|
||||
const metadata = await appCheckUpdateWithChannel(
|
||||
UPDATE_CHECK_TIMEOUT_MS,
|
||||
channel ?? null,
|
||||
);
|
||||
if (!metadata) {
|
||||
setStatus('none');
|
||||
return;
|
||||
}
|
||||
if (isAndroid()) {
|
||||
storeAndroidMetadata(metadata);
|
||||
setVersion(metadata.version);
|
||||
if (options?.autoInstallAndroid) {
|
||||
try {
|
||||
await installAndroidUpdate();
|
||||
} catch (error) {
|
||||
console.warn('[auto-update] android auto-install failed', error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setStatus('available');
|
||||
return;
|
||||
}
|
||||
const next = new Update({
|
||||
rid: metadata.rid,
|
||||
currentVersion: metadata.currentVersion,
|
||||
version: metadata.version,
|
||||
date: metadata.date ?? undefined,
|
||||
body: metadata.body ?? undefined,
|
||||
rawJson: metadata.rawJson,
|
||||
});
|
||||
updateRef.current = next;
|
||||
setVersion(next.version);
|
||||
setStatus('available');
|
||||
} catch (error) {
|
||||
console.error('[updater] failed to check update', error);
|
||||
const msg = error instanceof Error ? error.message : String(error);
|
||||
void logClientError(`[updater] check failed: ${msg}`);
|
||||
setErrorMessage(msg);
|
||||
setStatus('error');
|
||||
}
|
||||
};
|
||||
@@ -237,8 +273,8 @@ export function useAutoUpdate(): UseAutoUpdate {
|
||||
};
|
||||
}
|
||||
|
||||
export function isDialogStatus(status: UpdateStatus): status is 'available' | 'downloading' | 'installing' | 'downloaded' {
|
||||
return status === 'available' || status === 'downloading' || status === 'installing' || status === 'downloaded';
|
||||
export function isDialogStatus(status: UpdateStatus): status is 'available' | 'downloading' | 'installing' | 'downloaded' | 'installError' {
|
||||
return status === 'available' || status === 'downloading' || status === 'installing' || status === 'downloaded' || status === 'installError';
|
||||
}
|
||||
|
||||
export function UpdateDialog({
|
||||
@@ -247,29 +283,41 @@ export function UpdateDialog({
|
||||
progress,
|
||||
downloaded,
|
||||
contentLength,
|
||||
errorMessage,
|
||||
onInstall,
|
||||
onClose,
|
||||
}: {
|
||||
status: 'available' | 'downloading' | 'installing' | 'downloaded';
|
||||
status: 'available' | 'downloading' | 'installing' | 'downloaded' | 'installError';
|
||||
version: string;
|
||||
progress: number | null;
|
||||
downloaded: number;
|
||||
contentLength: number | null;
|
||||
errorMessage?: string | null;
|
||||
onInstall: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const downloading = status === 'downloading';
|
||||
const installing = status === 'installing';
|
||||
const installError = status === 'installError';
|
||||
const androidInstalled = isAndroid() && status === 'downloaded';
|
||||
const installLabel = isAndroid()
|
||||
? t('settings.about.updateDialog.androidInstall')
|
||||
: t('settings.about.updateDialog.install');
|
||||
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 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 650, marginBottom: 8 }}>
|
||||
{androidInstalled
|
||||
? t('settings.about.updateDialog.androidInstalled.title')
|
||||
: t(`settings.about.updateDialog.${status}.title`)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--ol-ink-3)', lineHeight: 1.6, marginBottom: 14, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{androidInstalled
|
||||
? t('settings.about.updateDialog.androidInstalled.desc', { version, defaultValue: '系统安装器已打开,请按提示完成安装。安装后重新打开 OpenLess 即可使用 {{version}}。' })
|
||||
: t(`settings.about.updateDialog.${status}.desc`, { version })}
|
||||
: installError
|
||||
? t('settings.about.updateDialog.installError.desc', { error: errorMessage || t('settings.about.updateError') })
|
||||
: t(`settings.about.updateDialog.${status}.desc`, { version })}
|
||||
</div>
|
||||
{(downloading || installing || status === 'downloaded') && (
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
@@ -287,10 +335,12 @@ export function UpdateDialog({
|
||||
)}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
{status === 'available' && <Btn size="sm" onClick={onClose}>{t('common.cancel')}</Btn>}
|
||||
{status === 'available' && <Btn variant="blue" size="sm" onClick={onInstall}>{t('settings.about.updateDialog.install')}</Btn>}
|
||||
{status === 'available' && <Btn variant="blue" size="sm" onClick={onInstall}>{installLabel}</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' && !androidInstalled && <Btn variant="blue" size="sm" onClick={restartApp}>{t('settings.about.updateDialog.restartNow')}</Btn>}
|
||||
{installError && <Btn size="sm" onClick={onClose}>{t('common.cancel')}</Btn>}
|
||||
{installError && <Btn variant="blue" size="sm" onClick={() => void openExternal(RELEASE_DOWNLOAD_URL)}>{t('settings.about.updateDialog.manualDownload')}</Btn>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
// 主窗口启动 + 后台每 60 分钟自动调一次 plugin-updater check。
|
||||
// 受 prefs.autoUpdateCheck 开关控制;关闭时只走 Settings → 关于 的手动按钮。
|
||||
// 找到新版本时直接挂 UpdateDialog;不弹自定义通知,沿用既有 dialog 视觉。
|
||||
// 主窗口启动 + 后台每 60 分钟自动检查更新。
|
||||
// 受 prefs.autoUpdateCheck 开关控制;关闭时只走 Settings 手动按钮。
|
||||
// 桌面:发现新版本弹 UpdateDialog 等用户确认。
|
||||
// Android:发现新版本后自动下载、校验并打开系统安装器(进度仍走 UpdateDialog)。
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { isDialogStatus, UpdateDialog, useAutoUpdate } from './AutoUpdate';
|
||||
import { getPlatformCapabilities } from '../lib/ipc';
|
||||
import { getPlatformCapabilities, isAndroid } from '../lib/ipc';
|
||||
import type { PlatformCapabilities } from '../lib/types';
|
||||
import { useHotkeySettings } from '../state/HotkeySettingsContext';
|
||||
|
||||
@@ -21,10 +22,6 @@ export function AutoUpdateGate() {
|
||||
void getPlatformCapabilities().then(setPlatformCaps);
|
||||
}, []);
|
||||
|
||||
// 用 ref 保持 tick 闭包始终读到最新的 useAutoUpdate 返回值。
|
||||
// 之前直接捕获 `u` 会让 60min interval 触发时读旧 status 闭包——例如用户已经
|
||||
// 手动打开 UpdateDialog 后,tick 仍可能错过 busy 检查触发并发 check。
|
||||
// 修 pr_agent "Stale closure" 反馈。
|
||||
const uRef = useRef(u);
|
||||
uRef.current = u;
|
||||
|
||||
@@ -36,7 +33,7 @@ export function AutoUpdateGate() {
|
||||
if (cancelled) return;
|
||||
const current = uRef.current;
|
||||
if (current.checking || current.busy || isDialogStatus(current.status)) return;
|
||||
void current.checkForUpdates().catch(error => {
|
||||
void current.checkForUpdates(undefined, { autoInstallAndroid: isAndroid() }).catch(error => {
|
||||
console.warn('[auto-update] background check failed', error);
|
||||
});
|
||||
};
|
||||
@@ -60,6 +57,7 @@ export function AutoUpdateGate() {
|
||||
progress={u.progress}
|
||||
downloaded={u.downloaded}
|
||||
contentLength={u.contentLength}
|
||||
errorMessage={u.errorMessage}
|
||||
onInstall={u.installUpdate}
|
||||
onClose={u.dismissDialog}
|
||||
/>
|
||||
|
||||
@@ -989,6 +989,8 @@ export const en: typeof zhCN = {
|
||||
tagline: 'Speak naturally, write perfectly',
|
||||
checkUpdate: 'Check for updates',
|
||||
checkUpdateBtn: 'Check',
|
||||
checkStableUpdateBtn: 'Check stable update',
|
||||
checkBetaUpdateBtn: 'Check Beta update',
|
||||
checkingUpdate: 'Checking…',
|
||||
upToDate: 'You are already on the latest version.',
|
||||
updateError: 'Update check or install failed. Please try again later.',
|
||||
@@ -1005,7 +1007,11 @@ export const en: typeof zhCN = {
|
||||
localFirst: 'Local-first',
|
||||
linksTitle: 'Documentation',
|
||||
betaChannelLabel: 'Join Beta channel',
|
||||
betaChannelDesc: 'Early access to new features',
|
||||
betaChannelToggleLabel: 'Enable Beta channel',
|
||||
betaChannelDesc: 'When on, background auto-update follows Beta; when off, it uses stable. Use the button below to manually check Beta anytime.',
|
||||
autoUpdateSectionTitle: 'Auto-update',
|
||||
autoUpdateCheckLabelAndroid: 'Auto-check and download updates',
|
||||
autoUpdateCheckDescAndroid: 'Checks on launch and every 60 minutes. When an update is found, downloads and opens the system installer. Channel follows the Beta toggle above.',
|
||||
betaChannelFetching: 'Fetching the latest Beta…',
|
||||
betaChannelFetchBtn: 'Look up latest Beta',
|
||||
betaChannelLatestPrefix: 'Latest Beta:',
|
||||
@@ -1035,12 +1041,22 @@ export const en: typeof zhCN = {
|
||||
desc: 'Installing OpenLess {{version}}. Keep the app open.',
|
||||
},
|
||||
install: 'Update now',
|
||||
androidInstall: 'Download and open installer',
|
||||
androidInstalled: {
|
||||
title: 'System installer opened',
|
||||
desc: 'Follow the system prompts to finish installing. Reopen OpenLess to use {{version}}.',
|
||||
},
|
||||
downloadingLabel: 'Downloading…',
|
||||
installingLabel: 'Installing…',
|
||||
later: 'Restart manually later',
|
||||
restartNow: 'Restart now',
|
||||
progress: '{{progress}}% · {{downloaded}} / {{total}}',
|
||||
progressUnknown: '{{downloaded}} downloaded',
|
||||
installError: {
|
||||
title: 'Update failed',
|
||||
desc: "The automatic update couldn't finish: {{error}}. You can download and install the latest version manually.",
|
||||
},
|
||||
manualDownload: 'Download manually',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -957,6 +957,8 @@ export const ja: typeof zhCN = {
|
||||
tagline: '自然に話し、きれいに書く',
|
||||
checkUpdate: 'アップデート確認',
|
||||
checkUpdateBtn: '確認',
|
||||
checkStableUpdateBtn: '正式版を確認',
|
||||
checkBetaUpdateBtn: 'Beta を確認',
|
||||
checkingUpdate: '確認中…',
|
||||
upToDate: '現在最新バージョンです。',
|
||||
updateError: '確認またはアップデートに失敗しました。後で再試行してください。',
|
||||
@@ -973,7 +975,11 @@ export const ja: typeof zhCN = {
|
||||
localFirst: 'ローカル優先',
|
||||
linksTitle: 'ドキュメント',
|
||||
betaChannelLabel: 'Beta チャンネルに参加',
|
||||
betaChannelDesc: '新機能をいち早く体験',
|
||||
betaChannelToggleLabel: 'Beta チャンネルを有効化',
|
||||
betaChannelDesc: 'オンにするとバックグラウンド自動更新が Beta に従います。オフで正式版に戻ります。下のボタンでいつでも Beta を手動確認できます。',
|
||||
autoUpdateSectionTitle: '自動更新',
|
||||
autoUpdateCheckLabelAndroid: '自動確認してダウンロード',
|
||||
autoUpdateCheckDescAndroid: '起動時と 60 分ごとに確認。更新があれば自動ダウンロードしシステムインストーラを開きます。チャンネルは上の Beta スイッチに従います。',
|
||||
betaChannelFetching: '最新 Beta 版を取得中…',
|
||||
betaChannelFetchBtn: '最新 Beta を確認',
|
||||
betaChannelLatestPrefix: '最新 Beta:',
|
||||
@@ -1003,12 +1009,22 @@ export const ja: typeof zhCN = {
|
||||
desc: 'OpenLess {{version}} をインストール中です。アプリを開いたままにしてください。',
|
||||
},
|
||||
install: '今すぐ更新',
|
||||
androidInstall: 'ダウンロードしてインストーラを開く',
|
||||
androidInstalled: {
|
||||
title: 'システムインストーラを開きました',
|
||||
desc: '画面の指示に従ってインストールしてください。完了後 OpenLess を再度開くと {{version}} が使えます。',
|
||||
},
|
||||
downloadingLabel: 'ダウンロード中…',
|
||||
installingLabel: 'インストール中…',
|
||||
later: '後で手動再起動',
|
||||
restartNow: '今すぐ再起動',
|
||||
progress: '{{progress}}% · {{downloaded}} / {{total}}',
|
||||
progressUnknown: 'ダウンロード済み {{downloaded}}',
|
||||
installError: {
|
||||
title: '更新に失敗しました',
|
||||
desc: '自動更新を完了できませんでした:{{error}}。ダウンロードページから手動で最新版を入手できます。',
|
||||
},
|
||||
manualDownload: '手動でダウンロード',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -957,6 +957,8 @@ export const ko: typeof zhCN = {
|
||||
tagline: '자연스럽게 말하고, 정확하게 작성하세요',
|
||||
checkUpdate: '업데이트 확인',
|
||||
checkUpdateBtn: '확인',
|
||||
checkStableUpdateBtn: '정식판 확인',
|
||||
checkBetaUpdateBtn: 'Beta 확인',
|
||||
checkingUpdate: '확인 중…',
|
||||
upToDate: '현재 최신 버전입니다.',
|
||||
updateError: '확인 또는 업데이트에 실패했습니다. 잠시 후 다시 시도하세요.',
|
||||
@@ -973,7 +975,11 @@ export const ko: typeof zhCN = {
|
||||
localFirst: '로컬 우선',
|
||||
linksTitle: '문서 링크',
|
||||
betaChannelLabel: 'Beta 채널 참여',
|
||||
betaChannelDesc: '새 기능 미리 체험',
|
||||
betaChannelToggleLabel: 'Beta 채널 사용',
|
||||
betaChannelDesc: '켜면 백그라운드 자동 업데이트가 Beta를 따릅니다. 끄면 정식판으로 돌아갑니다. 아래 버튼으로 언제든 Beta를 수동 확인할 수 있습니다.',
|
||||
autoUpdateSectionTitle: '자동 업데이트',
|
||||
autoUpdateCheckLabelAndroid: '자동 확인 및 다운로드',
|
||||
autoUpdateCheckDescAndroid: '시작 시 및 60분마다 확인합니다. 업데이트가 있으면 자동 다운로드 후 시스템 설치 프로그램을 엽니다. 채널은 위 Beta 스위치를 따릅니다.',
|
||||
betaChannelFetching: '최신 Beta 버전을 가져오는 중…',
|
||||
betaChannelFetchBtn: '최신 Beta 확인',
|
||||
betaChannelLatestPrefix: '최신 Beta:',
|
||||
@@ -1003,12 +1009,22 @@ export const ko: typeof zhCN = {
|
||||
desc: 'OpenLess {{version}} 을(를) 설치 중입니다. 앱을 열어 두세요.',
|
||||
},
|
||||
install: '지금 업데이트',
|
||||
androidInstall: '다운로드 후 설치 프로그램 열기',
|
||||
androidInstalled: {
|
||||
title: '시스템 설치 프로그램이 열렸습니다',
|
||||
desc: '안내에 따라 설치를 완료하세요. 설치 후 OpenLess를 다시 열면 {{version}}을 사용할 수 있습니다.',
|
||||
},
|
||||
downloadingLabel: '다운로드 중…',
|
||||
installingLabel: '설치 중…',
|
||||
later: '나중에 수동 재시작',
|
||||
restartNow: '지금 재시작',
|
||||
progress: '{{progress}}% · {{downloaded}} / {{total}}',
|
||||
progressUnknown: '다운로드됨 {{downloaded}}',
|
||||
installError: {
|
||||
title: '업데이트 실패',
|
||||
desc: '자동 업데이트를 완료하지 못했습니다: {{error}}. 다운로드 페이지에서 최신 버전을 직접 받아 설치할 수 있습니다.',
|
||||
},
|
||||
manualDownload: '수동 다운로드',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -987,6 +987,8 @@ export const zhCN = {
|
||||
tagline: '自然说话,完美书写',
|
||||
checkUpdate: '检查更新',
|
||||
checkUpdateBtn: '检查',
|
||||
checkStableUpdateBtn: '检查正式版更新',
|
||||
checkBetaUpdateBtn: '检查 Beta 更新',
|
||||
checkingUpdate: '检查中…',
|
||||
upToDate: '当前已是最新版本。',
|
||||
updateError: '检查或更新失败,请稍后重试。',
|
||||
@@ -1003,7 +1005,11 @@ export const zhCN = {
|
||||
localFirst: '本地优先',
|
||||
linksTitle: '文档链接',
|
||||
betaChannelLabel: '加入 Beta 渠道',
|
||||
betaChannelDesc: '抢先体验新功能',
|
||||
betaChannelToggleLabel: '启用 Beta 渠道',
|
||||
betaChannelDesc: '开启后,后台自动更新将跟随 Beta 渠道;关闭则回到正式版。下方按钮可随时手动检查 Beta 更新。',
|
||||
autoUpdateSectionTitle: '自动更新',
|
||||
autoUpdateCheckLabelAndroid: '自动检查并下载更新',
|
||||
autoUpdateCheckDescAndroid: '启动后及每 60 分钟自动检查更新;发现新版本后自动下载并打开系统安装器。渠道跟随上方 Beta 开关。',
|
||||
betaChannelFetching: '正在获取最新 Beta 版本…',
|
||||
betaChannelFetchBtn: '查询最新 Beta',
|
||||
betaChannelLatestPrefix: '最新 Beta:',
|
||||
@@ -1033,12 +1039,22 @@ export const zhCN = {
|
||||
desc: '正在安装 OpenLess {{version}},请保持应用打开。',
|
||||
},
|
||||
install: '现在更新',
|
||||
androidInstall: '下载并打开安装器',
|
||||
androidInstalled: {
|
||||
title: '系统安装器已打开',
|
||||
desc: '请按系统提示完成安装。安装后重新打开 OpenLess 即可使用 {{version}}。',
|
||||
},
|
||||
downloadingLabel: '下载中…',
|
||||
installingLabel: '安装中…',
|
||||
later: '稍后手动重启',
|
||||
restartNow: '现在重启',
|
||||
progress: '{{progress}}% · {{downloaded}} / {{total}}',
|
||||
progressUnknown: '已下载 {{downloaded}}',
|
||||
installError: {
|
||||
title: '更新失败',
|
||||
desc: '自动更新没能完成:{{error}}。你可以前往下载页手动下载安装最新版本。',
|
||||
},
|
||||
manualDownload: '手动下载',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -955,6 +955,8 @@ export const zhTW: typeof zhCN = {
|
||||
tagline: '自然說話,完美書寫',
|
||||
checkUpdate: '檢查更新',
|
||||
checkUpdateBtn: '檢查',
|
||||
checkStableUpdateBtn: '檢查正式版更新',
|
||||
checkBetaUpdateBtn: '檢查 Beta 更新',
|
||||
checkingUpdate: '檢查中…',
|
||||
upToDate: '當前已是最新版本。',
|
||||
updateError: '檢查或更新失敗,請稍後重試。',
|
||||
@@ -971,7 +973,11 @@ export const zhTW: typeof zhCN = {
|
||||
localFirst: '本地優先',
|
||||
linksTitle: '文件連結',
|
||||
betaChannelLabel: '加入 Beta 渠道',
|
||||
betaChannelDesc: '搶先體驗新功能',
|
||||
betaChannelToggleLabel: '啟用 Beta 渠道',
|
||||
betaChannelDesc: '開啟後,背景自動更新將跟隨 Beta 渠道;關閉則回到正式版。下方按鈕可隨時手動檢查 Beta 更新。',
|
||||
autoUpdateSectionTitle: '自動更新',
|
||||
autoUpdateCheckLabelAndroid: '自動檢查並下載更新',
|
||||
autoUpdateCheckDescAndroid: '啟動後及每 60 分鐘自動檢查更新;發現新版本後自動下載並開啟系統安裝器。渠道跟隨上方 Beta 開關。',
|
||||
betaChannelFetching: '正在獲取最新 Beta 版本…',
|
||||
betaChannelFetchBtn: '查詢最新 Beta',
|
||||
betaChannelLatestPrefix: '最新 Beta:',
|
||||
@@ -1001,12 +1007,22 @@ export const zhTW: typeof zhCN = {
|
||||
desc: '正在安裝 OpenLess {{version}},請保持應用打開。',
|
||||
},
|
||||
install: '現在更新',
|
||||
androidInstall: '下載並開啟安裝器',
|
||||
androidInstalled: {
|
||||
title: '系統安裝器已開啟',
|
||||
desc: '請依系統提示完成安裝。安裝後重新開啟 OpenLess 即可使用 {{version}}。',
|
||||
},
|
||||
downloadingLabel: '下載中…',
|
||||
installingLabel: '安裝中…',
|
||||
later: '稍後手動重啓',
|
||||
restartNow: '現在重啓',
|
||||
progress: '{{progress}}% · {{downloaded}} / {{total}}',
|
||||
progressUnknown: '已下載 {{downloaded}}',
|
||||
installError: {
|
||||
title: '更新失敗',
|
||||
desc: '自動更新未能完成:{{error}}。你可以前往下載頁手動下載安裝最新版本。',
|
||||
},
|
||||
manualDownload: '手動下載',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -195,4 +195,4 @@ export {
|
||||
} from "./marketplace-cache"
|
||||
|
||||
// utils
|
||||
export { openExternal, exportErrorLog } from "./utils"
|
||||
export { openExternal, exportErrorLog, logClientError } from "./utils"
|
||||
@@ -47,3 +47,16 @@ export async function exportErrorLog(
|
||||
)
|
||||
return target
|
||||
}
|
||||
|
||||
/**
|
||||
* 把前端关键错误(如自动更新 install 失败)转发到 Rust 文件日志(openless.log)。
|
||||
* webview 的 console.error 不会落进 openless.log,单独走 IPC,便于用户「导出日志」
|
||||
* 后我们拿到失败的真实原因。永不抛错——日志失败不应再影响调用方的错误处理。
|
||||
*/
|
||||
export async function logClientError(message: string): Promise<void> {
|
||||
try {
|
||||
await invokeOrMock<void>("log_client_error", { message }, () => undefined)
|
||||
} catch (error) {
|
||||
console.warn("[log-client-error] failed to forward error to app log", error)
|
||||
}
|
||||
}
|
||||
@@ -164,8 +164,8 @@ export interface WindowsImeStatus {
|
||||
dllPath: string | null;
|
||||
}
|
||||
|
||||
/** Auto-update 渠道偏好。stable = 跟正式版(默认);beta = Settings 里多一个
|
||||
* 手动下载 Beta 的入口。不影响 plugin-updater 的自动检查路径。 */
|
||||
/** 后台自动更新渠道。stable = 查正式版 manifest(默认);beta = 查
|
||||
* latest-android-{arch}-beta.json。手动「检查正式版/Beta 更新」按钮不受此字段影响。 */
|
||||
export type UpdateChannel = 'stable' | 'beta';
|
||||
|
||||
export type ThemeMode = 'system' | 'light' | 'dark';
|
||||
@@ -334,8 +334,8 @@ export interface UserPreferences {
|
||||
startMinimized: boolean;
|
||||
/** UI theme preference: follow OS, light, or dark. */
|
||||
themeMode: ThemeMode;
|
||||
/** 自动更新渠道。'stable'(默认)= plugin-updater 仅检查正式版;
|
||||
* 'beta' = Settings → About 出现手动下载 Beta 的入口。 */
|
||||
/** 后台自动更新渠道。stable(默认)= AutoUpdateGate 查正式版 manifest;
|
||||
* beta = 查 Beta manifest。About / Advanced 的手动检查按钮各自固定 stable/beta。 */
|
||||
updateChannel: UpdateChannel;
|
||||
/** 流式输入:润色 SSE 一边到达一边逐字模拟键盘事件输出到当前焦点。开启后用户感知到
|
||||
* 的处理时延显著降低。v1 限定 macOS + OpenAI-compatible provider,其他配置自动回落
|
||||
@@ -347,8 +347,10 @@ export interface UserPreferences {
|
||||
/** 流式输入成功后是否把最终润色文本写回剪贴板。开启后 Cmd+V 还能重复粘贴该次输出,
|
||||
* 与一次性路径行为对齐。默认 true。 */
|
||||
streamingInsertSaveClipboard: boolean;
|
||||
/** 主窗口启动 + 后台每 60 分钟自动检查云端新版本。默认 true。
|
||||
* 关闭后仅 Settings → 关于 的「检查更新」手动按钮可用。 */
|
||||
/** 主窗口启动 + 后台每 60 分钟自动检查更新。默认 true。
|
||||
* Android:开启后自动检查并下载,校验后打开系统安装器。
|
||||
* 桌面:开启后自动检查,发现更新弹窗由用户确认安装。
|
||||
* 关闭后仅 Settings 手动「检查更新」按钮可用。 */
|
||||
autoUpdateCheck: boolean;
|
||||
/** 历史记录上限(条数)。null = 走默认 200;5..=200 之间为用户自定义。 */
|
||||
historyMaxEntries: number | null;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// Android 设置:自动检查并下载更新开关(桌面同类开关在 RecordingInputSection 启动组)。
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getPlatformCapabilities, isAndroid } from '../../lib/ipc';
|
||||
import type { PlatformCapabilities } from '../../lib/types';
|
||||
import { useHotkeySettings } from '../../state/HotkeySettingsContext';
|
||||
import { Card } from '../_atoms';
|
||||
import { SectionTitle, SettingRow, Toggle } from './shared';
|
||||
|
||||
export function AutoUpdateSection() {
|
||||
const { t } = useTranslation();
|
||||
const { prefs, updatePrefs: savePrefs } = useHotkeySettings();
|
||||
const [platformCaps, setPlatformCaps] = useState<PlatformCapabilities | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void getPlatformCapabilities().then(setPlatformCaps);
|
||||
}, []);
|
||||
|
||||
if (platformCaps?.supportsAutoUpdate !== true || !isAndroid() || !prefs) return null;
|
||||
|
||||
const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) =>
|
||||
savePrefs({ ...prefs, autoUpdateCheck });
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<SectionTitle>{t('settings.about.autoUpdateSectionTitle')}</SectionTitle>
|
||||
<p style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.55, margin: '0 0 4px' }}>
|
||||
{t('settings.about.autoUpdateCheckDescAndroid')}
|
||||
</p>
|
||||
<SettingRow label={t('settings.about.autoUpdateCheckLabelAndroid')}>
|
||||
<Toggle on={prefs.autoUpdateCheck} onToggle={onAutoUpdateCheckChange} />
|
||||
</SettingRow>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
// 高级 → 加入 Beta 渠道。单独成一节,固定放在「高级」页最下面。
|
||||
//
|
||||
// 打开后写 prefs.update_channel='beta':后台 AutoUpdateGate 自动更新随之走 Beta,
|
||||
// 同时本节出现「检查更新」按钮 —— 手动查测试版更新(CheckUpdateButton channel='beta')。
|
||||
// 关于页的检查更新按钮固定查正式版(channel='stable'),两者互不影响。
|
||||
// 高级 → Beta 渠道。Toggle 控制后台 AutoUpdateGate 跟随 stable/beta;
|
||||
// 「检查 Beta 更新」按钮始终可用,与 Toggle 状态无关。
|
||||
// 关于页的「检查正式版更新」固定查 stable,两者互不影响。
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getPlatformCapabilities, getUpdateChannel, setUpdateChannel, type UpdateChannel } from '../../lib/ipc';
|
||||
import type { PlatformCapabilities } from '../../lib/types';
|
||||
import { Card } from '../_atoms';
|
||||
import { SectionTitle, Toggle } from './shared';
|
||||
import { SectionTitle, SettingRow, Toggle } from './shared';
|
||||
import { CheckUpdateButton } from './CheckUpdateButton';
|
||||
|
||||
export function BetaChannelSection() {
|
||||
@@ -35,7 +33,6 @@ export function BetaChannelSection() {
|
||||
try {
|
||||
await setUpdateChannel(target);
|
||||
} catch {
|
||||
// 写入失败时回滚 UI,免得用户以为切成功了。
|
||||
setChannel(target === 'beta' ? 'stable' : 'beta');
|
||||
}
|
||||
};
|
||||
@@ -45,14 +42,14 @@ export function BetaChannelSection() {
|
||||
return (
|
||||
<Card>
|
||||
<SectionTitle>{t('settings.about.betaChannelLabel')}</SectionTitle>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12, paddingTop: 2 }}>
|
||||
<span style={{ fontSize: 12.5, color: 'var(--ol-ink-3)' }}>
|
||||
{t('settings.about.betaChannelDesc')}
|
||||
</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, flexShrink: 0 }}>
|
||||
{channel === 'beta' && <CheckUpdateButton channel="beta" />}
|
||||
<Toggle on={channel === 'beta'} onToggle={onToggle} />
|
||||
</div>
|
||||
<p style={{ fontSize: 12.5, color: 'var(--ol-ink-3)', lineHeight: 1.55, margin: '0 0 4px' }}>
|
||||
{t('settings.about.betaChannelDesc')}
|
||||
</p>
|
||||
<SettingRow label={t('settings.about.betaChannelToggleLabel')}>
|
||||
<Toggle on={channel === 'beta'} onToggle={onToggle} />
|
||||
</SettingRow>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', paddingTop: 4 }}>
|
||||
<CheckUpdateButton channel="beta" />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
// 检查更新按钮 —— 关于页查正式版(channel='stable')、高级页 Beta 区查测试版
|
||||
// (channel='beta'),共用此组件。
|
||||
//
|
||||
// 检查中:按钮内图标转圈。结果(已是最新 / 失败)只在按钮内以图标 + 颜色短暂
|
||||
// 呈现 2.5s 后自动回到 idle,绝不另起文字块、不改变所在卡片高度 —— 杜绝
|
||||
// 「渲染框突然变大 / 抽搐」。发现新版则弹出固定定位的 UpdateDialog。
|
||||
// (channel='beta'),共用此组件。channel 显式传入,不受 prefs.updateChannel 影响。
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { btnGhostStyle } from './shared';
|
||||
@@ -23,8 +19,6 @@ export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
return undefined;
|
||||
// 只按 status 触发:useAutoUpdate 每次渲染都返回新 updater 对象,把它放进
|
||||
// 依赖会让父组件每次重渲染都把 2.5s 自动收起计时器清掉重置。
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status]);
|
||||
|
||||
@@ -32,7 +26,10 @@ export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
|
||||
const failed = status === 'error';
|
||||
const iconName = upToDate ? 'check' : 'refresh';
|
||||
const color = upToDate ? 'var(--ol-ok)' : failed ? 'var(--ol-err)' : 'var(--ol-ink-2)';
|
||||
const label = checking ? t('settings.about.checkingUpdate') : t('settings.about.checkUpdateBtn');
|
||||
const labelKey = channel === 'beta'
|
||||
? 'settings.about.checkBetaUpdateBtn'
|
||||
: 'settings.about.checkStableUpdateBtn';
|
||||
const label = checking ? t('settings.about.checkingUpdate') : t(labelKey);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -62,6 +59,7 @@ export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
|
||||
progress={updater.progress}
|
||||
downloaded={updater.downloaded}
|
||||
contentLength={updater.contentLength}
|
||||
errorMessage={updater.errorMessage}
|
||||
onInstall={() => void updater.installUpdate()}
|
||||
onClose={() => void updater.dismissDialog()}
|
||||
/>
|
||||
@@ -69,4 +67,3 @@ export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { DebugToolsSection } from './DebugToolsSection';
|
||||
import { CodingAgentSection } from './CodingAgentSection';
|
||||
import { ClaudeConsoleSection } from './ClaudeConsoleSection';
|
||||
import { BetaChannelSection } from './BetaChannelSection';
|
||||
import { AutoUpdateSection } from './AutoUpdateSection';
|
||||
import { detectOS } from '../../components/WindowChrome';
|
||||
import { getPlatformCapabilities } from '../../lib/platform';
|
||||
import type { PlatformCapabilities } from '../../lib/types';
|
||||
@@ -101,6 +102,7 @@ export function AdvancedTab() {
|
||||
{showDesktopAdvanced && os !== 'win' && <CodingAgentSection />}
|
||||
{showDesktopAdvanced && os !== 'win' && <ClaudeConsoleSection />}
|
||||
{platformCaps?.supportsAutoUpdate === true && <BetaChannelSection />}
|
||||
{platformCaps?.supportsAutoUpdate === true && <AutoUpdateSection />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user