From a46c613ebe31d1a128a0ec511114abb3502e68c2 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 14 Jun 2026 20:58:15 +0800 Subject: [PATCH 01/11] feat(android): auto-check and download updates with stable/beta channels Align updateChannel prefs with background AutoUpdateGate, add symmetric manual stable/beta check buttons, Android auto-download after check, and settings toggle; extract updater_logic helpers with unit tests. Co-authored-by: Cursor --- docs/android-mobile-apk-overlay-plan.md | 24 ++++ openless-all/app/android/README.md | 2 + openless-all/app/src-tauri/src/android/mod.rs | 1 + .../app/src-tauri/src/android/updater.rs | 67 ++------- .../src-tauri/src/android/updater_logic.rs | 98 +++++++++++++ openless-all/app/src-tauri/src/types.rs | 20 ++- .../app/src/components/AutoUpdate.tsx | 134 +++++++++++------- .../app/src/components/AutoUpdateGate.tsx | 15 +- openless-all/app/src/i18n/en.ts | 12 +- openless-all/app/src/i18n/ja.ts | 12 +- openless-all/app/src/i18n/ko.ts | 12 +- openless-all/app/src/i18n/zh-CN.ts | 12 +- openless-all/app/src/i18n/zh-TW.ts | 12 +- openless-all/app/src/lib/types.ts | 14 +- .../src/pages/settings/AutoUpdateSection.tsx | 36 +++++ .../src/pages/settings/BetaChannelSection.tsx | 23 ++- .../src/pages/settings/CheckUpdateButton.tsx | 14 +- openless-all/app/src/pages/settings/tabs.tsx | 2 + 18 files changed, 349 insertions(+), 161 deletions(-) create mode 100644 openless-all/app/src-tauri/src/android/updater_logic.rs create mode 100644 openless-all/app/src/pages/settings/AutoUpdateSection.tsx diff --git a/docs/android-mobile-apk-overlay-plan.md b/docs/android-mobile-apk-overlay-plan.md index 51d369c6..694006f4 100644 --- a/docs/android-mobile-apk-overlay-plan.md +++ b/docs/android-mobile-apk-overlay-plan.md @@ -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. 验收标准 diff --git a/openless-all/app/android/README.md b/openless-all/app/android/README.md index 30b04abc..b790327d 100644 --- a/openless-all/app/android/README.md +++ b/openless-all/app/android/README.md @@ -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。 diff --git a/openless-all/app/src-tauri/src/android/mod.rs b/openless-all/app/src-tauri/src/android/mod.rs index 62eff3e1..c8907e3e 100644 --- a/openless-all/app/src-tauri/src/android/mod.rs +++ b/openless-all/app/src-tauri/src/android/mod.rs @@ -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; diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs index 29e7f447..f74ac68d 100644 --- a/openless-all/app/src-tauri/src/android/updater.rs +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -8,6 +8,10 @@ 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, + version_is_newer, + }; use crate::commands::{ fetch_latest_beta_release, parse_latest_beta_from_atom, AppUpdateMetadata, }; @@ -15,8 +19,6 @@ mod android_impl { 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 { @@ -67,38 +69,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 { - 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() @@ -108,36 +78,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::() .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?, + 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(); diff --git a/openless-all/app/src-tauri/src/android/updater_logic.rs b/openless-all/app/src-tauri/src/android/updater_logic.rs new file mode 100644 index 00000000..78a19eeb --- /dev/null +++ b/openless-all/app/src-tauri/src/android/updater_logic.rs @@ -0,0 +1,98 @@ +//! 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"; + +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 { + 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 { + 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 { + 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"); + } +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 7bb50435..1851ee1b 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -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 { @@ -712,8 +709,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 天。 @@ -762,8 +759,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 条硬上限; diff --git a/openless-all/app/src/components/AutoUpdate.tsx b/openless-all/app/src/components/AutoUpdate.tsx index ac9a008e..55ac9916 100644 --- a/openless-all/app/src/components/AutoUpdate.tsx +++ b/openless-all/app/src/components/AutoUpdate.tsx @@ -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'; @@ -33,6 +34,11 @@ export type UpdateStatus = | 'downloaded' | 'error'; +export type CheckUpdateOptions = { + /** Android only: skip confirmation dialog and download + open system installer. */ + autoInstallAndroid?: boolean; +}; + export interface UseAutoUpdate { status: UpdateStatus; version: string; @@ -42,7 +48,7 @@ export interface UseAutoUpdate { checking: boolean; busy: boolean; errorMessage: string | null; - checkForUpdates: (channel?: UpdateChannel) => Promise; + checkForUpdates: (channel?: UpdateChannel, options?: CheckUpdateOptions) => Promise; installUpdate: () => Promise; dismissDialog: () => Promise; } @@ -123,7 +129,59 @@ export function useAutoUpdate(): UseAutoUpdate { androidUpdateRef.current = { url, signature, version: metadata.version }; }; - const checkForUpdates = async (channel?: UpdateChannel) => { + const installAndroidUpdate = async () => { + 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'); + throw error; + } + }; + + const installUpdate = async () => { + if (isAndroid()) { + await installAndroidUpdate(); + return; + } + + const update = updateRef.current; + if (!update) return; + resetProgress(); + setStatus('downloading'); + try { + await update.download((event: DownloadEvent) => { + if (event.event === 'Started') { + resetProgress(); + setContentLength(event.data.contentLength ?? null); + } else if (event.event === 'Progress') { + setDownloaded(value => value + event.data.chunkLength); + } else if (event.event === 'Finished') { + setStatus('installing'); + } + }); + setStatus('installing'); + await update.install(); + await closeUpdate(); + setStatus('downloaded'); + } catch (error) { + console.error('[updater] failed to install update', error); + const msg = error instanceof Error ? error.message : String(error); + setErrorMessage(msg); + await closeUpdate(); + setStatus('error'); + } + }; + + const checkForUpdates = async (channel?: UpdateChannel, options?: CheckUpdateOptions) => { setStatus('checking'); setVersion(''); setErrorMessage(null); @@ -145,6 +203,14 @@ export function useAutoUpdate(): UseAutoUpdate { 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; } @@ -167,53 +233,6 @@ 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(); - setStatus('downloading'); - try { - await update.download((event: DownloadEvent) => { - if (event.event === 'Started') { - resetProgress(); - setContentLength(event.data.contentLength ?? null); - } else if (event.event === 'Progress') { - setDownloaded(value => value + event.data.chunkLength); - } else if (event.event === 'Finished') { - setStatus('installing'); - } - }); - setStatus('installing'); - await update.install(); - await closeUpdate(); - setStatus('downloaded'); - } catch (error) { - console.error('[updater] failed to install update', error); - const msg = error instanceof Error ? error.message : String(error); - setErrorMessage(msg); - await closeUpdate(); - setStatus('error'); - } - }; - const dismissDialog = async () => { if (busy) return; await closeUpdate(); @@ -262,13 +281,20 @@ export function UpdateDialog({ const downloading = status === 'downloading'; const installing = status === 'installing'; const androidInstalled = isAndroid() && status === 'downloaded'; + const installLabel = isAndroid() + ? t('settings.about.updateDialog.androidInstall') + : t('settings.about.updateDialog.install'); return (
-
{t(`settings.about.updateDialog.${status}.title`)}
+
+ {androidInstalled + ? t('settings.about.updateDialog.androidInstalled.title') + : t(`settings.about.updateDialog.${status}.title`)} +
{androidInstalled - ? t('settings.about.updateDialog.androidInstalled.desc', { version, defaultValue: '系统安装器已打开,请按提示完成安装。安装后重新打开 OpenLess 即可使用 {{version}}。' }) + ? t('settings.about.updateDialog.androidInstalled.desc', { version }) : t(`settings.about.updateDialog.${status}.desc`, { version })}
{(downloading || installing || status === 'downloaded') && ( @@ -287,7 +313,7 @@ export function UpdateDialog({ )}
{status === 'available' && {t('common.cancel')}} - {status === 'available' && {t('settings.about.updateDialog.install')}} + {status === 'available' && {installLabel}} {(downloading || installing) && {installing ? t('settings.about.updateDialog.installingLabel') : t('settings.about.updateDialog.downloadingLabel')}} {status === 'downloaded' && {t('settings.about.updateDialog.later')}} {status === 'downloaded' && !androidInstalled && {t('settings.about.updateDialog.restartNow')}} diff --git a/openless-all/app/src/components/AutoUpdateGate.tsx b/openless-all/app/src/components/AutoUpdateGate.tsx index 7ff1b6b7..3aa8dda9 100644 --- a/openless-all/app/src/components/AutoUpdateGate.tsx +++ b/openless-all/app/src/components/AutoUpdateGate.tsx @@ -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); }); }; diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index fcf71ca3..acc5b28d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -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,10 @@ export const en: typeof zhCN = { localFirst: 'Local-first', linksTitle: 'Documentation', betaChannelLabel: 'Join Beta channel', - betaChannelDesc: 'Early access to new features', + 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,6 +1040,11 @@ 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', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 257bfb7f..87a94797 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -957,6 +957,8 @@ export const ja: typeof zhCN = { tagline: '自然に話し、きれいに書く', checkUpdate: 'アップデート確認', checkUpdateBtn: '確認', + checkStableUpdateBtn: '正式版を確認', + checkBetaUpdateBtn: 'Beta を確認', checkingUpdate: '確認中…', upToDate: '現在最新バージョンです。', updateError: '確認またはアップデートに失敗しました。後で再試行してください。', @@ -973,7 +975,10 @@ export const ja: typeof zhCN = { localFirst: 'ローカル優先', linksTitle: 'ドキュメント', betaChannelLabel: 'Beta チャンネルに参加', - betaChannelDesc: '新機能をいち早く体験', + betaChannelDesc: 'オンにするとバックグラウンド自動更新が Beta に従います。オフで正式版に戻ります。下のボタンでいつでも Beta を手動確認できます。', + autoUpdateSectionTitle: '自動更新', + autoUpdateCheckLabelAndroid: '自動確認してダウンロード', + autoUpdateCheckDescAndroid: '起動時と 60 分ごとに確認。更新があれば自動ダウンロードしシステムインストーラを開きます。チャンネルは上の Beta スイッチに従います。', betaChannelFetching: '最新 Beta 版を取得中…', betaChannelFetchBtn: '最新 Beta を確認', betaChannelLatestPrefix: '最新 Beta:', @@ -1003,6 +1008,11 @@ export const ja: typeof zhCN = { desc: 'OpenLess {{version}} をインストール中です。アプリを開いたままにしてください。', }, install: '今すぐ更新', + androidInstall: 'ダウンロードしてインストーラを開く', + androidInstalled: { + title: 'システムインストーラを開きました', + desc: '画面の指示に従ってインストールしてください。完了後 OpenLess を再度開くと {{version}} が使えます。', + }, downloadingLabel: 'ダウンロード中…', installingLabel: 'インストール中…', later: '後で手動再起動', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 395f92e1..494262c4 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -957,6 +957,8 @@ export const ko: typeof zhCN = { tagline: '자연스럽게 말하고, 정확하게 작성하세요', checkUpdate: '업데이트 확인', checkUpdateBtn: '확인', + checkStableUpdateBtn: '정식판 확인', + checkBetaUpdateBtn: 'Beta 확인', checkingUpdate: '확인 중…', upToDate: '현재 최신 버전입니다.', updateError: '확인 또는 업데이트에 실패했습니다. 잠시 후 다시 시도하세요.', @@ -973,7 +975,10 @@ export const ko: typeof zhCN = { localFirst: '로컬 우선', linksTitle: '문서 링크', betaChannelLabel: 'Beta 채널 참여', - betaChannelDesc: '새 기능 미리 체험', + betaChannelDesc: '켜면 백그라운드 자동 업데이트가 Beta를 따릅니다. 끄면 정식판으로 돌아갑니다. 아래 버튼으로 언제든 Beta를 수동 확인할 수 있습니다.', + autoUpdateSectionTitle: '자동 업데이트', + autoUpdateCheckLabelAndroid: '자동 확인 및 다운로드', + autoUpdateCheckDescAndroid: '시작 시 및 60분마다 확인합니다. 업데이트가 있으면 자동 다운로드 후 시스템 설치 프로그램을 엽니다. 채널은 위 Beta 스위치를 따릅니다.', betaChannelFetching: '최신 Beta 버전을 가져오는 중…', betaChannelFetchBtn: '최신 Beta 확인', betaChannelLatestPrefix: '최신 Beta:', @@ -1003,6 +1008,11 @@ export const ko: typeof zhCN = { desc: 'OpenLess {{version}} 을(를) 설치 중입니다. 앱을 열어 두세요.', }, install: '지금 업데이트', + androidInstall: '다운로드 후 설치 프로그램 열기', + androidInstalled: { + title: '시스템 설치 프로그램이 열렸습니다', + desc: '안내에 따라 설치를 완료하세요. 설치 후 OpenLess를 다시 열면 {{version}}을 사용할 수 있습니다.', + }, downloadingLabel: '다운로드 중…', installingLabel: '설치 중…', later: '나중에 수동 재시작', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 3147b416..e53fbd90 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -987,6 +987,8 @@ export const zhCN = { tagline: '自然说话,完美书写', checkUpdate: '检查更新', checkUpdateBtn: '检查', + checkStableUpdateBtn: '检查正式版更新', + checkBetaUpdateBtn: '检查 Beta 更新', checkingUpdate: '检查中…', upToDate: '当前已是最新版本。', updateError: '检查或更新失败,请稍后重试。', @@ -1003,7 +1005,10 @@ export const zhCN = { localFirst: '本地优先', linksTitle: '文档链接', betaChannelLabel: '加入 Beta 渠道', - betaChannelDesc: '抢先体验新功能', + betaChannelDesc: '开启后,后台自动更新将跟随 Beta 渠道;关闭则回到正式版。下方按钮可随时手动检查 Beta 更新。', + autoUpdateSectionTitle: '自动更新', + autoUpdateCheckLabelAndroid: '自动检查并下载更新', + autoUpdateCheckDescAndroid: '启动后及每 60 分钟自动检查更新;发现新版本后自动下载并打开系统安装器。渠道跟随上方 Beta 开关。', betaChannelFetching: '正在获取最新 Beta 版本…', betaChannelFetchBtn: '查询最新 Beta', betaChannelLatestPrefix: '最新 Beta:', @@ -1033,6 +1038,11 @@ export const zhCN = { desc: '正在安装 OpenLess {{version}},请保持应用打开。', }, install: '现在更新', + androidInstall: '下载并打开安装器', + androidInstalled: { + title: '系统安装器已打开', + desc: '请按系统提示完成安装。安装后重新打开 OpenLess 即可使用 {{version}}。', + }, downloadingLabel: '下载中…', installingLabel: '安装中…', later: '稍后手动重启', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 2fe58825..677bc3ed 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -955,6 +955,8 @@ export const zhTW: typeof zhCN = { tagline: '自然說話,完美書寫', checkUpdate: '檢查更新', checkUpdateBtn: '檢查', + checkStableUpdateBtn: '檢查正式版更新', + checkBetaUpdateBtn: '檢查 Beta 更新', checkingUpdate: '檢查中…', upToDate: '當前已是最新版本。', updateError: '檢查或更新失敗,請稍後重試。', @@ -971,7 +973,10 @@ export const zhTW: typeof zhCN = { localFirst: '本地優先', linksTitle: '文件連結', betaChannelLabel: '加入 Beta 渠道', - betaChannelDesc: '搶先體驗新功能', + betaChannelDesc: '開啟後,背景自動更新將跟隨 Beta 渠道;關閉則回到正式版。下方按鈕可隨時手動檢查 Beta 更新。', + autoUpdateSectionTitle: '自動更新', + autoUpdateCheckLabelAndroid: '自動檢查並下載更新', + autoUpdateCheckDescAndroid: '啟動後及每 60 分鐘自動檢查更新;發現新版本後自動下載並開啟系統安裝器。渠道跟隨上方 Beta 開關。', betaChannelFetching: '正在獲取最新 Beta 版本…', betaChannelFetchBtn: '查詢最新 Beta', betaChannelLatestPrefix: '最新 Beta:', @@ -1001,6 +1006,11 @@ export const zhTW: typeof zhCN = { desc: '正在安裝 OpenLess {{version}},請保持應用打開。', }, install: '現在更新', + androidInstall: '下載並開啟安裝器', + androidInstalled: { + title: '系統安裝器已開啟', + desc: '請依系統提示完成安裝。安裝後重新開啟 OpenLess 即可使用 {{version}}。', + }, downloadingLabel: '下載中…', installingLabel: '安裝中…', later: '稍後手動重啓', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index fe8c8baa..17b52bd7 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -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; diff --git a/openless-all/app/src/pages/settings/AutoUpdateSection.tsx b/openless-all/app/src/pages/settings/AutoUpdateSection.tsx new file mode 100644 index 00000000..2cd08c39 --- /dev/null +++ b/openless-all/app/src/pages/settings/AutoUpdateSection.tsx @@ -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(null); + + useEffect(() => { + void getPlatformCapabilities().then(setPlatformCaps); + }, []); + + if (platformCaps?.supportsAutoUpdate !== true || !isAndroid() || !prefs) return null; + + const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) => + savePrefs({ ...prefs, autoUpdateCheck }); + + return ( + + {t('settings.about.autoUpdateSectionTitle')} +

+ {t('settings.about.autoUpdateCheckDescAndroid')} +

+ + + +
+ ); +} diff --git a/openless-all/app/src/pages/settings/BetaChannelSection.tsx b/openless-all/app/src/pages/settings/BetaChannelSection.tsx index 5ed3b150..a79af491 100644 --- a/openless-all/app/src/pages/settings/BetaChannelSection.tsx +++ b/openless-all/app/src/pages/settings/BetaChannelSection.tsx @@ -1,8 +1,6 @@ -// 高级 → 加入 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'; @@ -35,7 +33,6 @@ export function BetaChannelSection() { try { await setUpdateChannel(target); } catch { - // 写入失败时回滚 UI,免得用户以为切成功了。 setChannel(target === 'beta' ? 'stable' : 'beta'); } }; @@ -45,14 +42,16 @@ export function BetaChannelSection() { return ( {t('settings.about.betaChannelLabel')} -
- - {t('settings.about.betaChannelDesc')} - -
- {channel === 'beta' && } +
+
+ + {t('settings.about.betaChannelDesc')} +
+
+ +
); diff --git a/openless-all/app/src/pages/settings/CheckUpdateButton.tsx b/openless-all/app/src/pages/settings/CheckUpdateButton.tsx index 19106fd2..6fce590c 100644 --- a/openless-all/app/src/pages/settings/CheckUpdateButton.tsx +++ b/openless-all/app/src/pages/settings/CheckUpdateButton.tsx @@ -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 ( <> @@ -69,4 +66,3 @@ export function CheckUpdateButton({ channel }: { channel: UpdateChannel }) { ); } - diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 161fa61d..2bb92503 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -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'; @@ -100,6 +101,7 @@ export function AdvancedTab() { {showDesktopAdvanced && } {showDesktopAdvanced && os !== 'win' && } {showDesktopAdvanced && os !== 'win' && } + {platformCaps?.supportsAutoUpdate === true && } {platformCaps?.supportsAutoUpdate === true && } ); From 3c7100b215a42c969809646eef9574cfaba9c6d4 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 14 Jun 2026 21:21:21 +0800 Subject: [PATCH 02/11] fix(android): read Build.SUPPORTED_ABIS as static field in updater call_static_method caused NoSuchMethodError on Thread-8 during AutoUpdateGate background check, killing the app ~4s after launch. Co-authored-by: Cursor --- openless-all/app/src-tauri/src/android/updater.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs index f74ac68d..1969676c 100644 --- a/openless-all/app/src-tauri/src/android/updater.rs +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -41,13 +41,9 @@ 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 - .call_static_method( - "android/os/Build", - "SUPPORTED_ABIS", - "()[Ljava/lang/String;", - &[], - ) + .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); From df397736e985b7fe9000eab14acb688de4ee1ce4 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 14 Jun 2026 21:45:25 +0800 Subject: [PATCH 03/11] ci(android): use release signing on dispatch when keystore secrets exist Manual workflow_dispatch builds signed release APKs when ANDROID_KEYSTORE_* is configured (overlay install, data preserved); otherwise falls back to unsigned debug with job summary notice. Tag releases still require all secrets; minisign/manifest/GitHub Release remain tag-only. Co-authored-by: Cursor --- .github/workflows/android-apk.yml | 74 +++++++++++++++++++++---- docs/android-mobile-apk-overlay-plan.md | 4 +- 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/.github/workflows/android-apk.yml b/.github/workflows/android-apk.yml index ad0d3995..efd01a9e 100644 --- a/.github/workflows/android-apk.yml +++ b/.github/workflows/android-apk.yml @@ -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 diff --git a/docs/android-mobile-apk-overlay-plan.md b/docs/android-mobile-apk-overlay-plan.md index 694006f4..cf4229d7 100644 --- a/docs/android-mobile-apk-overlay-plan.md +++ b/docs/android-mobile-apk-overlay-plan.md @@ -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. From 8472fa47c27b2f6f67b507414a458d4d620998f2 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 14 Jun 2026 21:53:16 +0800 Subject: [PATCH 04/11] fix(android): align updater pubkey, installer bool, settings layout Sync UPDATER_PUBKEY_B64 with tauri.conf.json; fail download when installApk returns false; put Beta channel above auto-update toggle; add pubkey CI check script. Co-authored-by: Cursor --- .github/workflows/ci.yml | 3 ++ openless-all/app/package.json | 3 +- .../scripts/check-android-updater-pubkey.mjs | 37 +++++++++++++++++++ .../app/src-tauri/src/android/updater.rs | 11 +++--- .../src-tauri/src/android/updater_logic.rs | 25 +++++++++++++ openless-all/app/src/pages/settings/tabs.tsx | 2 +- 6 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 openless-all/app/scripts/check-android-updater-pubkey.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c83483b..b42c473a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 167b0cc1..0b335f55 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -18,7 +18,8 @@ "copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs", "check:aura-skin": "node scripts/aura-skin-contract.test.mjs", "check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs", - "check:hotkey-injection": "node scripts/check-hotkey-injection.mjs" + "check:hotkey-injection": "node scripts/check-hotkey-injection.mjs", + "check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs" }, "dependencies": { "@formkit/auto-animate": "^0.9.0", diff --git a/openless-all/app/scripts/check-android-updater-pubkey.mjs b/openless-all/app/scripts/check-android-updater-pubkey.mjs new file mode 100644 index 00000000..7493e866 --- /dev/null +++ b/openless-all/app/scripts/check-android-updater-pubkey.mjs @@ -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'); diff --git a/openless-all/app/src-tauri/src/android/updater.rs b/openless-all/app/src-tauri/src/android/updater.rs index 1969676c..7c9117d3 100644 --- a/openless-all/app/src-tauri/src/android/updater.rs +++ b/openless-all/app/src-tauri/src/android/updater.rs @@ -10,7 +10,7 @@ mod android_impl { use crate::android::updater_logic::{ beta_manifest_urls, format_manifest_error, map_abi_to_arch, stable_manifest_urls, - version_is_newer, + UPDATER_PUBKEY_B64, INSTALLER_NOT_OPENED_MSG, version_is_newer, }; use crate::commands::{ fetch_latest_beta_release, parse_latest_beta_from_atom, AppUpdateMetadata, @@ -18,8 +18,6 @@ mod android_impl { use crate::net; use crate::types::UpdateChannel; - const PUBKEY_B64: &str = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDFERUFBODAzNTY0QzMyM0YKUldRL01reFdBNmpxSGE1K0JadlpONXNWTzhJcGZCRGxjUVdIWExNNFJpeUNsSGZwazdlQThhemkK"; - #[derive(Debug, Deserialize)] struct UpdaterManifest { version: String, @@ -128,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 @@ -207,10 +205,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(()) } diff --git a/openless-all/app/src-tauri/src/android/updater_logic.rs b/openless-all/app/src-tauri/src/android/updater_logic.rs index 78a19eeb..c2687265 100644 --- a/openless-all/app/src-tauri/src/android/updater_logic.rs +++ b/openless-all/app/src-tauri/src/android/updater_logic.rs @@ -3,6 +3,14 @@ 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 = + "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDZGNEI1OTk0RjMzMzk0QTkKUldTcGxEUHpsRmxMYjRnMFdZNkFyaVozaHN2SUNwZ01mMDlFS0RMWnNQNisrWjF6czZQQk1RQysK"; + +/// 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", @@ -95,4 +103,21 @@ mod tests { 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)); + 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); + } } diff --git a/openless-all/app/src/pages/settings/tabs.tsx b/openless-all/app/src/pages/settings/tabs.tsx index 2bb92503..866399af 100644 --- a/openless-all/app/src/pages/settings/tabs.tsx +++ b/openless-all/app/src/pages/settings/tabs.tsx @@ -101,8 +101,8 @@ export function AdvancedTab() { {showDesktopAdvanced && } {showDesktopAdvanced && os !== 'win' && } {showDesktopAdvanced && os !== 'win' && } - {platformCaps?.supportsAutoUpdate === true && } {platformCaps?.supportsAutoUpdate === true && } + {platformCaps?.supportsAutoUpdate === true && } ); } From a47225647f3bd16a575481ba8554e60ff479a3ef Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 14 Jun 2026 22:12:55 +0800 Subject: [PATCH 05/11] fix(ui): move Beta channel toggle to its own SettingRow Avoid squeezing the toggle beside long description text on narrow mobile layouts; align with AutoUpdateSection pattern. Co-authored-by: Cursor --- openless-all/app/src/i18n/en.ts | 1 + openless-all/app/src/i18n/ja.ts | 1 + openless-all/app/src/i18n/ko.ts | 1 + openless-all/app/src/i18n/zh-CN.ts | 1 + openless-all/app/src/i18n/zh-TW.ts | 1 + .../src/pages/settings/BetaChannelSection.tsx | 20 +++++++++---------- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index acc5b28d..f0433394 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1007,6 +1007,7 @@ export const en: typeof zhCN = { localFirst: 'Local-first', linksTitle: 'Documentation', betaChannelLabel: 'Join Beta channel', + 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', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 87a94797..828c8c88 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -975,6 +975,7 @@ export const ja: typeof zhCN = { localFirst: 'ローカル優先', linksTitle: 'ドキュメント', betaChannelLabel: 'Beta チャンネルに参加', + betaChannelToggleLabel: 'Beta チャンネルを有効化', betaChannelDesc: 'オンにするとバックグラウンド自動更新が Beta に従います。オフで正式版に戻ります。下のボタンでいつでも Beta を手動確認できます。', autoUpdateSectionTitle: '自動更新', autoUpdateCheckLabelAndroid: '自動確認してダウンロード', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 494262c4..e2c7e321 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -975,6 +975,7 @@ export const ko: typeof zhCN = { localFirst: '로컬 우선', linksTitle: '문서 링크', betaChannelLabel: 'Beta 채널 참여', + betaChannelToggleLabel: 'Beta 채널 사용', betaChannelDesc: '켜면 백그라운드 자동 업데이트가 Beta를 따릅니다. 끄면 정식판으로 돌아갑니다. 아래 버튼으로 언제든 Beta를 수동 확인할 수 있습니다.', autoUpdateSectionTitle: '자동 업데이트', autoUpdateCheckLabelAndroid: '자동 확인 및 다운로드', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index e53fbd90..00de6e15 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1005,6 +1005,7 @@ export const zhCN = { localFirst: '本地优先', linksTitle: '文档链接', betaChannelLabel: '加入 Beta 渠道', + betaChannelToggleLabel: '启用 Beta 渠道', betaChannelDesc: '开启后,后台自动更新将跟随 Beta 渠道;关闭则回到正式版。下方按钮可随时手动检查 Beta 更新。', autoUpdateSectionTitle: '自动更新', autoUpdateCheckLabelAndroid: '自动检查并下载更新', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 677bc3ed..0ca7fd0c 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -973,6 +973,7 @@ export const zhTW: typeof zhCN = { localFirst: '本地優先', linksTitle: '文件連結', betaChannelLabel: '加入 Beta 渠道', + betaChannelToggleLabel: '啟用 Beta 渠道', betaChannelDesc: '開啟後,背景自動更新將跟隨 Beta 渠道;關閉則回到正式版。下方按鈕可隨時手動檢查 Beta 更新。', autoUpdateSectionTitle: '自動更新', autoUpdateCheckLabelAndroid: '自動檢查並下載更新', diff --git a/openless-all/app/src/pages/settings/BetaChannelSection.tsx b/openless-all/app/src/pages/settings/BetaChannelSection.tsx index a79af491..02f341e8 100644 --- a/openless-all/app/src/pages/settings/BetaChannelSection.tsx +++ b/openless-all/app/src/pages/settings/BetaChannelSection.tsx @@ -7,7 +7,7 @@ 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() { @@ -42,16 +42,14 @@ export function BetaChannelSection() { return ( {t('settings.about.betaChannelLabel')} -
-
- - {t('settings.about.betaChannelDesc')} - - -
-
- -
+

+ {t('settings.about.betaChannelDesc')} +

+ + + +
+
); From a064d36c8ebefdc3a1353e3d3b90c3943c6dde74 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 14 Jun 2026 22:24:07 +0800 Subject: [PATCH 06/11] fix(test): use Path display in updater pubkey test panic Co-authored-by: Cursor --- openless-all/app/src-tauri/src/android/updater_logic.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/android/updater_logic.rs b/openless-all/app/src-tauri/src/android/updater_logic.rs index c2687265..525320e7 100644 --- a/openless-all/app/src-tauri/src/android/updater_logic.rs +++ b/openless-all/app/src-tauri/src/android/updater_logic.rs @@ -109,7 +109,7 @@ mod tests { 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)); + .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 From 22dddf2b1c1fb362da0fa0789cee21d873da8427 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 14 Jun 2026 22:54:34 +0800 Subject: [PATCH 07/11] chore: ignore UI smoke artifacts and refresh Cargo.lock Exclude Playwright/ui-check-screenshots outputs from version control and update the lockfile after tauri-nspanel resolution. Co-authored-by: Cursor --- .gitignore | 7 +++ openless-all/app/src-tauri/Cargo.lock | 90 +++++++++++++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/.gitignore b/.gitignore index c62e3761..b98a52e4 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/openless-all/app/src-tauri/Cargo.lock b/openless-all/app/src-tauri/Cargo.lock index bb1a2840..122b265c 100644 --- a/openless-all/app/src-tauri/Cargo.lock +++ b/openless-all/app/src-tauri/Cargo.lock @@ -464,6 +464,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -827,6 +833,35 @@ dependencies = [ "error-code", ] +[[package]] +name = "cocoa" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad36507aeb7e16159dfe68db81ccc27571c3ccd4b76fb2fb72fc59e7a4b1b64c" +dependencies = [ + "bitflags 2.13.0", + "block", + "cocoa-foundation", + "core-foundation 0.10.1", + "core-graphics 0.24.0", + "foreign-types 0.5.0", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" +dependencies = [ + "bitflags 2.13.0", + "block", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "objc", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -3060,6 +3095,15 @@ dependencies = [ "libc", ] +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + [[package]] name = "markup5ever" version = "0.38.0" @@ -3459,6 +3503,26 @@ dependencies = [ "libc", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -3761,6 +3825,15 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "oboe" version = "0.6.1" @@ -3861,6 +3934,7 @@ dependencies = [ "tar", "tauri", "tauri-build", + "tauri-nspanel", "tauri-plugin-autostart", "tauri-plugin-dialog", "tauri-plugin-shell", @@ -5719,6 +5793,22 @@ dependencies = [ "tauri-utils", ] +[[package]] +name = "tauri-nspanel" +version = "2.0.1" +source = "git+https://github.com/ahkohd/tauri-nspanel?branch=v2#18ffb9a201fbf6fedfaa382fd4b92315ea30ab1a" +dependencies = [ + "bitflags 2.13.0", + "block", + "cocoa", + "core-foundation 0.10.1", + "core-graphics 0.25.0", + "objc", + "objc-foundation", + "objc_id", + "tauri", +] + [[package]] name = "tauri-plugin" version = "2.6.2" From dc0731732be4568751897b3ecbc6e439c138d340 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 14 Jun 2026 23:04:16 +0800 Subject: [PATCH 08/11] fix(android): read accessibility state via Settings.Secure JNI Avoid NoSuchMethodError when installed APK dex lacks OpenLessAccessibilityService.isEnabled static bridge; query enabled services and heartbeat prefs directly. Co-authored-by: Cursor --- openless-all/app/src-tauri/src/android/jni.rs | 146 ++++++++++++++++-- 1 file changed, 130 insertions(+), 16 deletions(-) diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index 72e9e1e4..2ca74704 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -419,32 +419,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, 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, 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 { + 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, 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 { + 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 { - 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 { - 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( From 0e7745e6ae80e619b2ace2136f22b603309abba1 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Sun, 14 Jun 2026 23:43:12 +0800 Subject: [PATCH 09/11] fix(android): keep JNI Kotlin entry points in release APK Add @Keep and @JvmStatic on Rust-invoked bridge methods; default overlay service starts to startService and only START_RECORDING uses startForegroundService on API 26+; abort recording when foreground promotion fails. Co-authored-by: Cursor --- .../kotlin/OpenLessAccessibilityService.kt | 3 + .../android/kotlin/OpenLessOverlayBridge.kt | 4 ++ .../android/kotlin/OpenLessOverlayService.kt | 55 +++++++++++++------ .../kotlin/OpenLessPermissionBridge.kt | 3 + .../android/kotlin/OpenLessUpdateInstaller.kt | 4 ++ openless-all/app/src-tauri/src/android/jni.rs | 13 ++--- 6 files changed, 57 insertions(+), 25 deletions(-) diff --git a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt index 8a843049..5f592b4e 100644 --- a/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt +++ b/openless-all/app/android/kotlin/OpenLessAccessibilityService.kt @@ -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() } diff --git a/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt b/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt index 92406730..213642bb 100644 --- a/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt +++ b/openless-all/app/android/kotlin/OpenLessOverlayBridge.kt @@ -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 { diff --git a/openless-all/app/android/kotlin/OpenLessOverlayService.kt b/openless-all/app/android/kotlin/OpenLessOverlayService.kt index 658d22eb..f9f3c798 100644 --- a/openless-all/app/android/kotlin/OpenLessOverlayService.kt +++ b/openless-all/app/android/kotlin/OpenLessOverlayService.kt @@ -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() @@ -634,26 +638,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) diff --git a/openless-all/app/android/kotlin/OpenLessPermissionBridge.kt b/openless-all/app/android/kotlin/OpenLessPermissionBridge.kt index 9fb437fd..4793db45 100644 --- a/openless-all/app/android/kotlin/OpenLessPermissionBridge.kt +++ b/openless-all/app/android/kotlin/OpenLessPermissionBridge.kt @@ -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) { diff --git a/openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt b/openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt index 0dc77502..1605511b 100644 --- a/openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt +++ b/openless-all/app/android/kotlin/OpenLessUpdateInstaller.kt @@ -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()) { diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index 2ca74704..9c0c4f69 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -182,13 +182,12 @@ pub mod android { &[JValue::Object(&action_obj)], ) .map_err(|error| format!("set service action: {error}"))?; - let start_method = if action.ends_with(".HIDE") || action.ends_with(".SHOW") { - "startService" - } else if android_sdk_int(env)? >= 26 { - "startForegroundService" - } else { - "startService" - }; + let start_method = + if action.ends_with(".START_RECORDING") && android_sdk_int(env)? >= 26 { + "startForegroundService" + } else { + "startService" + }; env.call_method( context, start_method, From e824e62a4e386b7d0e5fb7cf6a6dd862ae907c81 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 15 Jun 2026 10:11:47 +0800 Subject: [PATCH 10/11] feat(history): detect polish unchanged and add repolish retry (#653) Add opt-in polishUnchanged detection, history-level repolish IPC, and UI; exclude translation history; preserve insert error codes on retry. Co-authored-by: Cursor --- .../app/src-tauri/src/commands/history.rs | 9 + openless-all/app/src-tauri/src/coordinator.rs | 119 +++++++- .../src-tauri/src/coordinator/dictation.rs | 271 +++++++++++++++++- openless-all/app/src-tauri/src/lib.rs | 2 + openless-all/app/src-tauri/src/types.rs | 8 + openless-all/app/src/i18n/en.ts | 6 + openless-all/app/src/i18n/ja.ts | 6 + openless-all/app/src/i18n/ko.ts | 6 + openless-all/app/src/i18n/zh-CN.ts | 6 + openless-all/app/src/i18n/zh-TW.ts | 6 + openless-all/app/src/lib/ipc.ts | 18 ++ openless-all/app/src/lib/stylePrefs.test.ts | 5 +- openless-all/app/src/lib/types.ts | 2 + openless-all/app/src/pages/History.tsx | 53 +++- .../src/pages/settings/DataStorageSection.tsx | 11 +- 15 files changed, 505 insertions(+), 23 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands/history.rs b/openless-all/app/src-tauri/src/commands/history.rs index ce6998d5..6ad8d6e9 100644 --- a/openless-all/app/src-tauri/src/commands/history.rs +++ b/openless-all/app/src-tauri/src/commands/history.rs @@ -108,3 +108,12 @@ pub async fn retranscribe_recording( } Ok(entry) } + +/// 对一条历史条目按原风格包(或 mode 内置 fallback)重新润色并原地回写(issue #653)。 +#[tauri::command] +pub async fn repolish_history_entry( + coord: CoordinatorState<'_>, + session_id: String, +) -> Result { + coord.repolish_history_entry(session_id).await +} diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 85e3787f..0022cff4 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -54,8 +54,9 @@ use crate::selection::capture_selection; #[cfg(target_os = "windows")] use crate::types::PasteShortcut; use crate::types::{ - CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability, - HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode, + builtin_style_pack_for_mode, CapsulePayload, CapsuleState, ChineseScriptPreference, + DictationSession, HotkeyCapability, HotkeyStatus, HotkeyStatusState, InsertStatus, + OutputLanguagePreference, PolishMode, StylePack, }; #[cfg(target_os = "windows")] use crate::windows_ime_ipc::ImeSubmitTarget; @@ -80,8 +81,8 @@ pub(super) fn qa_event_target() -> &'static str { #[cfg(test)] use dictation::dictation_error_code; use dictation::{ - begin_session, cancel_session, end_session, handle_pressed_edge, handle_released_edge, - request_stop_during_starting, + begin_session, cancel_session, end_session, finalize_polished_text, handle_pressed_edge, + handle_released_edge, request_stop_during_starting, resolve_repolish_history_error_code, }; #[cfg(any(debug_assertions, test))] use dictation::{handle_pressed, handle_released}; @@ -1388,13 +1389,29 @@ impl Coordinator { } pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result { - let hotwords = enabled_phrases(&self.inner); let prefs = self.inner.prefs.get(); let pack = self .inner .style_packs .get_or_default_active(&prefs.active_style_pack_id) .map_err(|e| e.to_string())?; + log::info!( + "[style-pack] repolish dispatch active_pack={} kind={:?} effective_mode={:?} legacy_mode={:?}", + pack.id, + pack.kind, + pack.base_mode, + mode, + ); + self.repolish_with_style_pack(&raw_text, &pack).await + } + + pub async fn repolish_with_style_pack( + &self, + raw_text: &str, + pack: &StylePack, + ) -> Result { + let hotwords = enabled_phrases(&self.inner); + let prefs = self.inner.prefs.get(); let style_system_prompt = pack.prompt.clone(); let working_languages = prefs.working_languages; let chinese_script_preference = prefs.chinese_script_preference; @@ -1402,30 +1419,25 @@ impl Coordinator { let llm_thinking_enabled = prefs.llm_thinking_enabled; let effective_mode = pack.base_mode; log::info!( - "[style-pack] repolish dispatch active_pack={} kind={:?} effective_mode={:?} legacy_mode={:?} raw_chars={} prompt_chars={} hotwords={} thinking={}", + "[style-pack] repolish_with_style_pack pack={} kind={:?} effective_mode={:?} raw_chars={} prompt_chars={} hotwords={} thinking={}", pack.id, pack.kind, effective_mode, - mode, raw_text.chars().count(), style_system_prompt.chars().count(), hotwords.len(), llm_thinking_enabled ); - if effective_mode == PolishMode::Raw && !raw_style_pack_uses_llm(&pack) { + if effective_mode == PolishMode::Raw && !raw_style_pack_uses_llm(pack) { log::info!( "[style-pack] repolish bypass llm active_pack={} reason=default_builtin_raw", pack.id ); - return Ok(raw_text); + return Ok(raw_text.to_string()); } - // repolish 是历史记录里手动重新润色,不再绑定原 session 的前台 app; - // 当下用户调起的 app 才是相关上下文(如果可拿)。 let front_app = capture_frontmost_app(); - // repolish 是用户主动对单条历史"重新润色",不应该被对话感知上下文影响—— - // 用户改的就是这一条本身,不要把别的会话拿进来。所以始终走单轮路径。 polish_text( - &raw_text, + raw_text, effective_mode, &hotwords, &style_system_prompt, @@ -1440,6 +1452,73 @@ impl Coordinator { .map_err(|e| e.to_string()) } + pub async fn repolish_history_entry( + &self, + session_id: String, + ) -> Result { + let prefs = self.inner.prefs.get(); + if !prefs.polish_unchanged_enabled { + return Err("polish unchanged feature is disabled".into()); + } + + let mut entry = self + .history() + .list() + .map_err(|e| e.to_string())? + .into_iter() + .find(|s| s.id == session_id) + .ok_or_else(|| "history entry not found".to_string())?; + + if entry.mode == PolishMode::Raw { + return Err("cannot repolish raw mode history entry".into()); + } + if entry.translation_active { + return Err("cannot repolish translation history entry".into()); + } + + let pack = resolve_style_pack_for_history_entry(&self.inner.style_packs, &entry); + let polished = self + .repolish_with_style_pack(&entry.raw_transcript, &pack) + .await?; + let correction_rules = self + .inner + .correction_rules + .list() + .map_err(|e| e.to_string())?; + let polished = finalize_polished_text( + polished, + entry.translation_active, + raw_style_pack_uses_llm(&pack), + pack.base_mode, + &None, + prefs.chinese_script_preference, + &correction_rules, + false, + ); + + entry.final_text = polished.clone(); + entry.style_pack_id = Some(pack.id.clone()); + let prior_error_code = entry.error_code.clone(); + entry.error_code = resolve_repolish_history_error_code( + prefs.polish_unchanged_enabled, + prior_error_code, + pack.base_mode, + entry.translation_active, + &None, + &entry.raw_transcript, + &polished, + ); + + let updated = self + .history() + .update_entry(entry.clone()) + .map_err(|e| e.to_string())?; + if !updated { + return Err("history entry not found".into()); + } + Ok(entry) + } + pub async fn retranscribe_pcm(&self, pcm: Vec) -> Result { let inner = &self.inner; let active_asr = CredentialsVault::get_active_asr(); @@ -6156,6 +6235,18 @@ mod tests { } } +fn resolve_style_pack_for_history_entry( + style_packs: &StylePackStore, + entry: &DictationSession, +) -> StylePack { + if let Some(id) = entry.style_pack_id.as_deref() { + if let Ok(pack) = style_packs.get(id) { + return pack; + } + } + builtin_style_pack_for_mode(entry.mode) +} + fn enabled_phrases(inner: &Arc) -> Vec { inner .vocab diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index cd5e263c..50916a14 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -393,7 +393,7 @@ where } } -fn finalize_polished_text( +pub(super) fn finalize_polished_text( polished: String, translation_active: bool, _raw_uses_llm: bool, @@ -2364,20 +2364,29 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // polish 失败时在 history 里标记 polishFailed,让用户能在历史详情看到为什么这次输出 // 不是预期的 mode 风格。即使失败也不丢词 — final_text 仍是原文(保留"用户的话不丢"语义)。 - let error_code = dictation_error_code( + let mut error_code = dictation_error_code( status, polish_error.is_some(), focus_ready_for_paste, allow_non_tsf_insertion_fallback, ) .map(str::to_string); + let prefs_snapshot = inner.prefs.get(); + error_code = resolve_polish_unchanged_error_code( + prefs_snapshot.polish_unchanged_enabled, + error_code, + mode, + translation_active, + &polish_error, + &raw.text, + &polished, + ); let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); // 与 coordinator 内部 SessionId 对齐:方便 recorder 旁路写盘的 `.wav` // 跟 history 这条 DictationSession.id 同名,前端凭 id 就能找到对应录音文件。 let history_session_id = current_session_id.to_string(); let history_created_at = Utc::now().to_rfc3339(); - let prefs_snapshot = inner.prefs.get(); let session = DictationSession { id: history_session_id.clone(), created_at: history_created_at.clone(), @@ -2408,6 +2417,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { } let done_message = if tsf_required_insert_failed { Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) + } else if error_code.as_deref() == Some(POLISH_UNCHANGED_ERROR_CODE) { + Some("本次润色未产生变化,可在历史中重新润色".to_string()) } else { default_done_message(status, polish_error.is_some()) }; @@ -2438,6 +2449,89 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { Ok(()) } +pub(super) const POLISH_UNCHANGED_ERROR_CODE: &str = "polishUnchanged"; + +fn is_zero_width_char(ch: char) -> bool { + matches!(ch, '\u{200B}' | '\u{200C}' | '\u{200D}' | '\u{FEFF}' | '\u{2060}') +} + +pub(super) fn normalize_for_polish_compare(text: &str) -> String { + let mut out = String::new(); + let mut last_was_space = false; + for ch in text.trim().chars() { + if is_zero_width_char(ch) { + continue; + } + if ch.is_whitespace() { + if !last_was_space && !out.is_empty() { + out.push(' '); + last_was_space = true; + } + } else { + out.push(ch); + last_was_space = false; + } + } + out +} + +pub(super) fn is_polish_unchanged(raw: &str, final_text: &str) -> bool { + normalize_for_polish_compare(raw) == normalize_for_polish_compare(final_text) +} + +pub(super) fn resolve_polish_unchanged_error_code( + feature_enabled: bool, + existing: Option, + mode: PolishMode, + translation_active: bool, + polish_error: &Option, + raw: &str, + final_text: &str, +) -> Option { + if existing.is_some() { + return existing; + } + if !feature_enabled + || mode == PolishMode::Raw + || translation_active + || polish_error.is_some() + || !is_polish_unchanged(raw, final_text) + { + return None; + } + Some(POLISH_UNCHANGED_ERROR_CODE.to_string()) +} + +/// 历史重新润色写回 `error_code` 时:插入失败等非润色错误保留;`None` / +/// `polishUnchanged` / `polishFailed` 由本次重试结果重新计算。 +pub(super) fn is_repolish_mutable_error_code(code: Option<&str>) -> bool { + matches!(code, None | Some(POLISH_UNCHANGED_ERROR_CODE) | Some("polishFailed")) +} + +pub(super) fn resolve_repolish_history_error_code( + feature_enabled: bool, + existing: Option, + mode: PolishMode, + translation_active: bool, + polish_error: &Option, + raw: &str, + final_text: &str, +) -> Option { + if let Some(code) = existing.as_deref().filter(|code| !is_repolish_mutable_error_code(Some(code))) + { + return Some(code.to_string()); + } + resolve_polish_unchanged_error_code( + feature_enabled, + None, + mode, + translation_active, + polish_error, + raw, + final_text, + ) +} + pub(super) fn dictation_error_code( status: InsertStatus, polish_failed: bool, @@ -2541,7 +2635,9 @@ mod tests { use super::{ append_typed_prefix, batch_asr_chunk_limit_ms, default_done_message, drain_streaming_insert_deltas_with, eligible_polish_context_turns, finalize_polished_text, - flush_streaming_insert_buffer_with, streaming_insert_eligible, + flush_streaming_insert_buffer_with, is_polish_unchanged, normalize_for_polish_compare, + resolve_polish_unchanged_error_code, resolve_repolish_history_error_code, + streaming_insert_eligible, POLISH_UNCHANGED_ERROR_CODE, }; use crate::types::{ ChineseScriptPreference, CorrectionRule, DictationSession, InsertStatus, PolishMode, @@ -2819,6 +2915,173 @@ mod tests { assert!(failure.is_some()); } + #[test] + fn normalize_for_polish_compare_collapses_whitespace_and_zero_width() { + assert_eq!( + normalize_for_polish_compare(" hello\u{200B} world \n"), + "hello world" + ); + } + + #[test] + fn is_polish_unchanged_detects_equivalent_text() { + assert!(is_polish_unchanged("你好世界", "你好世界")); + assert!(is_polish_unchanged(" hello ", "hello")); + assert!(!is_polish_unchanged("你好", "您好")); + } + + #[test] + fn resolve_polish_unchanged_respects_feature_toggle_and_priority() { + assert_eq!( + resolve_polish_unchanged_error_code( + false, + None, + PolishMode::Structured, + false, + &None, + "same", + "same", + ), + None + ); + assert_eq!( + resolve_polish_unchanged_error_code( + true, + Some("polishFailed".into()), + PolishMode::Structured, + false, + &None, + "same", + "same", + ), + Some("polishFailed".into()) + ); + assert_eq!( + resolve_polish_unchanged_error_code( + true, + None, + PolishMode::Raw, + false, + &None, + "same", + "same", + ), + None + ); + assert_eq!( + resolve_polish_unchanged_error_code( + true, + None, + PolishMode::Structured, + false, + &None, + "hello", + "world", + ), + None + ); + assert_eq!( + resolve_polish_unchanged_error_code( + true, + None, + PolishMode::Structured, + false, + &None, + "same text", + "same text", + ) + .as_deref(), + Some(POLISH_UNCHANGED_ERROR_CODE) + ); + } + + #[test] + fn resolve_repolish_history_preserves_insert_errors() { + assert_eq!( + resolve_repolish_history_error_code( + true, + Some("focusRestoreFailed".into()), + PolishMode::Structured, + false, + &None, + "same", + "changed output", + ) + .as_deref(), + Some("focusRestoreFailed") + ); + assert_eq!( + resolve_repolish_history_error_code( + true, + Some("windowsImeTsfRequired".into()), + PolishMode::Light, + false, + &None, + "same", + "changed output", + ) + .as_deref(), + Some("windowsImeTsfRequired") + ); + } + + #[test] + fn resolve_repolish_history_recalculates_polish_related_codes() { + assert_eq!( + resolve_repolish_history_error_code( + true, + Some(POLISH_UNCHANGED_ERROR_CODE.into()), + PolishMode::Structured, + false, + &None, + "same", + "changed output", + ), + None + ); + assert_eq!( + resolve_repolish_history_error_code( + true, + Some("polishFailed".into()), + PolishMode::Structured, + false, + &None, + "same", + "changed output", + ), + None + ); + assert_eq!( + resolve_repolish_history_error_code( + true, + Some(POLISH_UNCHANGED_ERROR_CODE.into()), + PolishMode::Structured, + false, + &None, + "same text", + "same text", + ) + .as_deref(), + Some(POLISH_UNCHANGED_ERROR_CODE) + ); + } + + #[test] + fn resolve_repolish_history_rejects_translation_via_mutable_path() { + assert_eq!( + resolve_repolish_history_error_code( + true, + None, + PolishMode::Structured, + true, + &None, + "same", + "same", + ), + None + ); + } + #[cfg(target_os = "macos")] fn platform_type_error() -> crate::unicode_keystroke::TypeError { crate::unicode_keystroke::TypeError::EventAllocFailed diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index bd086fa6..0b98727e 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -156,6 +156,7 @@ macro_rules! app_invoke_handler_desktop { commands::clear_history, commands::read_audio_recording, commands::retranscribe_recording, + commands::repolish_history_entry, commands::marketplace_list, commands::marketplace_detail, commands::marketplace_install, @@ -318,6 +319,7 @@ macro_rules! app_invoke_handler_mobile { $crate::commands::clear_history, $crate::commands::read_audio_recording, $crate::commands::retranscribe_recording, + $crate::commands::repolish_history_entry, $crate::commands::marketplace_list, $crate::commands::marketplace_detail, $crate::commands::marketplace_install, diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 1851ee1b..810ca05b 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -773,6 +773,9 @@ pub struct UserPreferences { /// 受 `history_retention_days` 同样的清理策略约束。 #[serde(default)] pub record_audio_for_debug: bool, + /// 润色未变化检测与历史重新润色。默认 false:用户需在设置中手动开启。 + #[serde(default)] + pub polish_unchanged_enabled: bool, /// `recordings/` 里保留的最近 wav 文件数(按 mtime 倒序保留最新的)。 /// `None` = 跟随 `HISTORY_CAP` (200);`Some(n)` 时 clamp 到 1..=200。 /// 调用点:每次开新会话前裁旧。让用户在「文本历史保留 200 条但 wav 只留最近 5 条」 @@ -967,6 +970,8 @@ struct UserPreferencesWire { #[serde(default)] record_audio_for_debug: bool, #[serde(default)] + polish_unchanged_enabled: bool, + #[serde(default)] audio_recording_max_entries: Option, #[serde(default)] marketplace_base_url: String, @@ -1053,6 +1058,7 @@ impl Default for UserPreferencesWire { auto_update_check: prefs.auto_update_check, history_max_entries: prefs.history_max_entries, record_audio_for_debug: prefs.record_audio_for_debug, + polish_unchanged_enabled: prefs.polish_unchanged_enabled, audio_recording_max_entries: prefs.audio_recording_max_entries, marketplace_base_url: prefs.marketplace_base_url, marketplace_dev_login: prefs.marketplace_dev_login, @@ -1160,6 +1166,7 @@ impl<'de> Deserialize<'de> for UserPreferences { auto_update_check: wire.auto_update_check, history_max_entries: wire.history_max_entries, record_audio_for_debug: wire.record_audio_for_debug, + polish_unchanged_enabled: wire.polish_unchanged_enabled, audio_recording_max_entries: wire.audio_recording_max_entries, marketplace_base_url: wire.marketplace_base_url, marketplace_dev_login: wire.marketplace_dev_login, @@ -1895,6 +1902,7 @@ impl Default for UserPreferences { auto_update_check: true, history_max_entries: None, record_audio_for_debug: false, + polish_unchanged_enabled: false, audio_recording_max_entries: None, marketplace_base_url: String::new(), marketplace_dev_login: String::new(), diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index f0433394..b58e6664 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -322,6 +322,10 @@ export const en: typeof zhCN = { retranscribe: 'Retranscribe', retranscribing: 'Transcribing…', retranscribeFailed: 'Retranscribe failed: {{err}}', + polishUnchanged: 'Polish unchanged', + repolish: 'Re-polish', + repolishing: 'Re-polishing…', + repolishFailed: 'Re-polish failed: {{err}}', rawLabel: 'Raw', rawEmpty: '(empty)', selectHint: 'Select an entry on the left to see details.', @@ -562,6 +566,8 @@ export const en: typeof zhCN = { dataStorage: { title: 'Data storage', desc: 'Conversation history and context kept on this device.', + polishUnchangedEnabledLabel: 'Detect unchanged polish', + polishUnchangedEnabledDesc: 'When enabled, non-raw polish that matches the raw transcript is marked as unchanged, and you can repolish from History.', }, codingConsole: { title: 'Claude Console', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 828c8c88..9f16c3a0 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -324,6 +324,10 @@ export const ja: typeof zhCN = { retranscribe: '再認識', retranscribing: '認識中…', retranscribeFailed: '再認識に失敗:{{err}}', + polishUnchanged: '潤色未変化', + repolish: '再潤色', + repolishing: '潤色中…', + repolishFailed: '再潤色に失敗:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側から 1 件選択して詳細を表示。', @@ -564,6 +568,8 @@ export const ja: typeof zhCN = { dataStorage: { title: 'データ保存', desc: 'この端末に保存される会話履歴とコンテキスト。', + polishUnchangedEnabledLabel: '潤色未変化の検出', + polishUnchangedEnabledDesc: '有効にすると、原文モード以外で潤色結果が原文と同じ場合に「潤色未変化」とマークし、履歴から再潤色できます。', }, codingConsole: { title: 'Claude コンソール', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index e2c7e321..dda6807b 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -324,6 +324,10 @@ export const ko: typeof zhCN = { retranscribe: '다시 인식', retranscribing: '인식 중…', retranscribeFailed: '다시 인식 실패: {{err}}', + polishUnchanged: '다듬기 미변화', + repolish: '다시 다듬기', + repolishing: '다듬는 중…', + repolishFailed: '다시 다듬기 실패: {{err}}', rawLabel: '원문', rawEmpty: '(비어 있음)', selectHint: '왼쪽에서 하나를 선택하여 자세히 보기.', @@ -564,6 +568,8 @@ export const ko: typeof zhCN = { dataStorage: { title: '데이터 저장', desc: '이 기기에 보관되는 대화 기록과 컨텍스트.', + polishUnchangedEnabledLabel: '다듬기 미변화 감지', + polishUnchangedEnabledDesc: '켜면 원문 모드가 아닐 때 다듬기 결과가 원문과 같으면 「다듬기 미변화」로 표시하고 기록에서 다시 다듬을 수 있습니다.', }, codingConsole: { title: 'Claude 콘솔', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 00de6e15..a736cfa9 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -320,6 +320,10 @@ export const zhCN = { retranscribe: '重新转录', retranscribing: '转录中…', retranscribeFailed: '重新转录失败:{{err}}', + polishUnchanged: '润色未变化', + repolish: '重新润色', + repolishing: '润色中…', + repolishFailed: '重新润色失败:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左侧选一条查看详情。', @@ -560,6 +564,8 @@ export const zhCN = { dataStorage: { title: '数据存储', desc: '本机保留的历史会话与对话上下文。', + polishUnchangedEnabledLabel: '润色未变化检测', + polishUnchangedEnabledDesc: '开启后,非原文模式下若润色结果与原文一致会标记「润色未变化」,并可在历史中重新润色。', }, codingConsole: { title: 'Claude 控制台', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 0ca7fd0c..e96f4ec4 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -322,6 +322,10 @@ export const zhTW: typeof zhCN = { retranscribe: '重新轉錄', retranscribing: '轉錄中…', retranscribeFailed: '重新轉錄失敗:{{err}}', + polishUnchanged: '潤色未變化', + repolish: '重新潤色', + repolishing: '潤色中…', + repolishFailed: '重新潤色失敗:{{err}}', rawLabel: '原文', rawEmpty: '(空)', selectHint: '左側選一條查看詳情。', @@ -562,6 +566,8 @@ export const zhTW: typeof zhCN = { dataStorage: { title: '資料儲存', desc: '本機保留的歷史會話與對話上下文。', + polishUnchangedEnabledLabel: '潤色未變化檢測', + polishUnchangedEnabledDesc: '開啟後,非原文模式下若潤色結果與原文一致會標記「潤色未變化」,並可在歷史中重新潤色。', }, codingConsole: { title: 'Claude 主控台', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 571df3d2..35ece088 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -184,6 +184,7 @@ let mockSettings: UserPreferences = { autoUpdateCheck: true, historyMaxEntries: null, recordAudioForDebug: false, + polishUnchangedEnabled: false, audioRecordingMaxEntries: null, marketplaceBaseUrl: "https://apic.openless.top", marketplaceDevLogin: "", @@ -884,6 +885,23 @@ export function retranscribeRecording(sessionId: string): Promise } +/** 对一条历史条目按原风格包重新润色并原地回写(issue #653)。需开启 polishUnchangedEnabled。 */ +export function repolishHistoryEntry(sessionId: string): Promise { + return invokeOrMock( + "repolish_history_entry", + { sessionId }, + () => { + const entry = mockHistory.find((s) => s.id === sessionId) ?? mockHistory[0] + if (!entry) throw new Error("history entry not found") + return { + ...entry, + finalText: `${entry.finalText} (repolished)`, + errorCode: null, + } + }, + ) as Promise +} + // ── Vocab ────────────────────────────────────────────────────────────── export function listVocab(): Promise { return invokeOrMock("list_vocab", undefined, () => mockVocab) diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index d049a4a5..89d339a5 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -77,8 +77,9 @@ const previousPrefs: UserPreferences = { streamingInsertSaveClipboard: true, autoUpdateCheck: true, historyMaxEntries: null, - recordAudioForDebug: false, - audioRecordingMaxEntries: null, + recordAudioForDebug: false, + polishUnchangedEnabled: false, + audioRecordingMaxEntries: null, marketplaceBaseUrl: '', marketplaceDevLogin: '', remoteInputEnabled: false, diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 17b52bd7..8a85e127 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -357,6 +357,8 @@ export interface UserPreferences { /** 是否为每次会话保留原始麦克风音频文件(wav),用于排查 ASR 误识别 / 麦克风灵敏度。 * 默认 false。开启后会占磁盘空间,受 historyRetentionDays 同样的清理策略约束。 */ recordAudioForDebug: boolean; + /** 润色未变化检测 + 历史重新润色。默认 false。 */ + polishUnchangedEnabled: boolean; /** recordings/ 里保留的最近 wav 文件数。null = 跟随 200 硬上限;1..=200 之间为用户自定义。 * 跟 historyMaxEntries 解耦——「文本档案多但 wav 只留最近 5 条」是合法组合。 */ audioRecordingMaxEntries: number | null; diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index e10d0ea2..b42b33ed 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; import { formatComboLabel } from '../lib/hotkey'; -import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording } from '../lib/ipc'; +import { clearHistory, deleteHistoryEntry, listHistory, readAudioRecording, repolishHistoryEntry } from '../lib/ipc'; import { useMobileLayout } from '../lib/useMobileLayout'; import type { DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -63,6 +63,8 @@ export function History() { const { prefs } = useHotkeySettings(); const mobile = useMobileLayout(); const [mobileDetailOpen, setMobileDetailOpen] = useState(false); + const [repolishingId, setRepolishingId] = useState(null); + const polishUnchangedFeatureEnabled = prefs?.polishUnchangedEnabled === true; const refresh = useCallback(async () => { setLoading(true); @@ -167,6 +169,21 @@ export function History() { } }; + const onRepolish = async () => { + if (!item || item.mode === 'raw' || item.translationActive || !polishUnchangedFeatureEnabled) return; + setRepolishingId(item.id); + setActionError(null); + try { + const updated = await repolishHistoryEntry(item.id); + setItems(prev => prev.map(s => (s.id === updated.id ? updated : s))); + } catch (error) { + console.error('[history] failed to repolish history entry', error); + setActionError(t('history.repolishFailed', { err: errorMessage(error) })); + } finally { + setRepolishingId(null); + } + }; + return (
{s.finalText.split('\n')[0]}
-
{MODE_LABEL[s.mode]}
+
+ {MODE_LABEL[s.mode]} + {polishUnchangedFeatureEnabled && s.errorCode === 'polishUnchanged' && ( + {t('history.polishUnchanged')} + )} +
))}
@@ -293,6 +315,33 @@ export function History() { key={item.id} /> )} + {polishUnchangedFeatureEnabled && item.errorCode === 'polishUnchanged' && ( +
+ {t('history.polishUnchanged')} +
+ )} + {polishUnchangedFeatureEnabled && item.mode !== 'raw' && !item.translationActive && ( +
+ void onRepolish()} + disabled={repolishingId === item.id} + > + {repolishingId === item.id ? t('history.repolishing') : t('history.repolish')} + +
+ )}
{t('history.rawLabel')} diff --git a/openless-all/app/src/pages/settings/DataStorageSection.tsx b/openless-all/app/src/pages/settings/DataStorageSection.tsx index 5727a747..aeea8917 100644 --- a/openless-all/app/src/pages/settings/DataStorageSection.tsx +++ b/openless-all/app/src/pages/settings/DataStorageSection.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { Card } from '../_atoms'; -import { SettingRow, SectionTitle, inputStyle } from './shared'; +import { SettingRow, SectionTitle, Toggle, inputStyle } from './shared'; // 范围限制:retention 0-365 天,context window 0-60 分钟(再大对实际对话场景没意义且白烧 token)。 const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); @@ -79,6 +79,15 @@ export function DataStorageSection() { style={{ ...inputStyle, width: 80, textAlign: 'right' }} /> + + void savePrefs({ ...prefs, polishUnchangedEnabled: next })} + /> + ); } From 8769dea5b71cbd0e078506c3769376e9d4cb0d14 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 15 Jun 2026 10:18:26 +0800 Subject: [PATCH 11/11] fix(dictation): compute done_message before moving error_code into session Co-authored-by: Cursor --- .../app/src-tauri/src/coordinator/dictation.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 50916a14..549115f4 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -2387,6 +2387,13 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { // 跟 history 这条 DictationSession.id 同名,前端凭 id 就能找到对应录音文件。 let history_session_id = current_session_id.to_string(); let history_created_at = Utc::now().to_rfc3339(); + let done_message = if tsf_required_insert_failed { + Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) + } else if error_code.as_deref() == Some(POLISH_UNCHANGED_ERROR_CODE) { + Some("本次润色未产生变化,可在历史中重新润色".to_string()) + } else { + default_done_message(status, polish_error.is_some()) + }; let session = DictationSession { id: history_session_id.clone(), created_at: history_created_at.clone(), @@ -2415,13 +2422,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { ) { log::error!("[coord] history append failed: {e}"); } - let done_message = if tsf_required_insert_failed { - Some("TSF 未上屏,已禁止非 TSF 兜底".to_string()) - } else if error_code.as_deref() == Some(POLISH_UNCHANGED_ERROR_CODE) { - Some("本次润色未产生变化,可在历史中重新润色".to_string()) - } else { - default_done_message(status, polish_error.is_some()) - }; emit_capsule( inner,