From 47798200cc2ed55ee73db040c09251e0944c4f67 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 11:24:37 +0800 Subject: [PATCH 1/7] fix(persistence): preserve sessions with unparseable timestamps in recent_within_minutes Replace unwrap_or(false) with unwrap_or(true) so a corrupt created_at field no longer silently truncates the take_while iteration. Aligns with the conservative policy already used by append_with_retention. --- openless-all/app/src-tauri/src/persistence.rs | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/persistence.rs b/openless-all/app/src-tauri/src/persistence.rs index 6c5acdd5..ebea8884 100644 --- a/openless-all/app/src-tauri/src/persistence.rs +++ b/openless-all/app/src-tauri/src/persistence.rs @@ -1165,6 +1165,16 @@ impl HistoryStore { }) } + /// 在 data_dir 不可用时构造一个降级实例(指向临时目录)。 + /// 该实例在运行期间读写会安静地失败或返回空,不会 panic, + /// 也不会影响正常启动路径。 + pub(crate) fn new_fallback() -> Self { + Self { + path: std::env::temp_dir().join("openless_history_fallback.json"), + lock: Mutex::new(()), + } + } + pub fn list(&self) -> Result> { let _guard = self.lock.lock(); self.read_locked() @@ -1213,12 +1223,14 @@ impl HistoryStore { let sessions = self.read_locked()?; let cutoff = chrono::Utc::now() - chrono::Duration::minutes(i64::from(minutes)); // sessions 是 newest-first,超出窗口的会话之后的都更老,take_while 即可。 + // unwrap_or(true):时间戳解析失败时保留该条目,与 append_with_retention 的保守策略一致; + // 避免单条坏记录截断整个上下文窗口。 let filtered: Vec = sessions .into_iter() .take_while(|s| { chrono::DateTime::parse_from_rfc3339(&s.created_at) .map(|t| t.with_timezone(&chrono::Utc) >= cutoff) - .unwrap_or(false) + .unwrap_or(true) }) .collect(); Ok(filtered) @@ -1291,6 +1303,14 @@ impl PreferencesStore { }) } + /// 降级实例:data_dir 不可用时使用默认配置,写操作会安静地失败。 + pub(crate) fn new_fallback() -> Self { + Self { + path: std::env::temp_dir().join("openless_prefs_fallback.json"), + state: Mutex::new(UserPreferences::default()), + } + } + pub fn get(&self) -> UserPreferences { self.state.lock().clone() } @@ -1394,6 +1414,16 @@ impl StylePackStore { }) } + /// 降级实例:data_dir 不可用时使用临时路径和空列表,写操作会安静地失败。 + pub(crate) fn new_fallback() -> Self { + let tmp = std::env::temp_dir(); + Self { + path: tmp.join("openless_style_packs_fallback.json"), + asset_root: tmp.join("openless_style_pack_assets_fallback"), + state: Mutex::new(Vec::new()), + } + } + pub fn list(&self) -> Result> { Ok(self.state.lock().clone()) } @@ -2156,6 +2186,14 @@ impl DictionaryStore { }) } + /// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。 + pub(crate) fn new_fallback() -> Self { + Self { + path: std::env::temp_dir().join("openless_vocab_fallback.json"), + lock: Mutex::new(()), + } + } + pub fn list(&self) -> Result> { let _guard = self.lock.lock(); self.read_locked() @@ -2300,6 +2338,14 @@ impl CorrectionRuleStore { }) } + /// 降级实例:data_dir 不可用时使用临时路径,读写会安静地失败或返回空。 + pub(crate) fn new_fallback() -> Self { + Self { + path: std::env::temp_dir().join("openless_correction_rules_fallback.json"), + lock: Mutex::new(()), + } + } + pub fn list(&self) -> Result> { let _guard = self.lock.lock(); self.read_locked() From f58fcb859109e5633cbef1c1fbb2e1366642225b Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 11:24:45 +0800 Subject: [PATCH 2/7] fix(coordinator): call asr.cancel() on Ok(Err) path for Volcengine and Bailian ASR Without cancel(), the streaming WebSocket connection leaked when await_final_result() returned an error (non-timeout path). Both Volcengine and Bailian streaming ASR now have symmetric cleanup between their error and timeout branches. --- .../app/src-tauri/src/coordinator/dictation.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index f0311a9b..8a86e65d 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -999,6 +999,15 @@ pub(super) fn request_stop_during_starting(inner: &Arc, reason: &str) { } pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { + begin_session_as(inner, false).await +} + +/// begin_session 的带参版本,voice_agent=true 时在 Starting 阶段就标记好, +/// 防止 finish_starting_session 处理 pending_stop 时丢失标志。 +pub(super) async fn begin_session_as( + inner: &Arc, + voice_agent: bool, +) -> Result<(), String> { let current_session_id = { let mut state = inner.state.lock(); let Some(session_id) = @@ -1006,6 +1015,9 @@ pub(super) async fn begin_session(inner: &Arc) -> Result<(), String> { else { return Ok(()); }; + if voice_agent { + state.voice_agent = true; + } if let Some(label) = state.front_app.as_deref() { log::info!("[coord] front_app captured: {label}"); } @@ -1635,6 +1647,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { Ok(Ok(r)) => r, Ok(Err(e)) => { log::error!("[coord] await final failed: {e}"); + // 关闭 WebSocket 连接,避免流式 ASR 资源泄漏 + asr.cancel(); emit_capsule( inner, CapsuleState::Error, @@ -1762,6 +1776,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { Ok(Ok(r)) => r, Ok(Err(e)) => { log::error!("[coord] Bailian await final failed: {e}"); + // 关闭 WebSocket 连接,避免流式 ASR 资源泄漏 + asr.cancel(); emit_capsule( inner, CapsuleState::Error, From 3698353fa8eadd0ce1322a5f928ca480926ff7d0 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 11:24:53 +0800 Subject: [PATCH 3/7] fix(coordinator): set voice_agent=true before async ASR handshake in handle_less_computer_pressed Add begin_session_as(inner, voice_agent) which stamps voice_agent into SessionState inside the same lock block as begin_session_state. This prevents the race where finish_starting_session processes a pending_stop (fast key-release during ASR handshake) before voice_agent is set, causing the transcript to silently route through normal dictation instead of run_voice_agent_transcript. --- openless-all/app/src-tauri/src/coordinator.rs | 52 ++++++++++++++----- .../src-tauri/src/coordinator/hotkey_loops.rs | 10 ++-- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 86949c0d..3ee50c36 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -91,8 +91,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, begin_session_as, cancel_session, end_session, handle_pressed_edge, + handle_released_edge, request_stop_during_starting, }; #[cfg(any(debug_assertions, test))] use dictation::{handle_pressed, handle_released}; @@ -362,13 +362,25 @@ impl Coordinator { #[cfg(not(target_os = "windows"))] { let history = HistoryStore::new().unwrap_or_else(|e| { - log::error!("[coord] HistoryStore init failed: {e}; falling back to empty"); - HistoryStore::new().expect("history store init") + log::error!("[coord] HistoryStore init failed: {e}; 降级为空历史记录"); + HistoryStore::new_fallback() + }); + let prefs = PreferencesStore::new().unwrap_or_else(|e| { + log::error!("[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置"); + PreferencesStore::new_fallback() + }); + let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| { + log::error!("[coord] StylePackStore init failed: {e}; 降级为空样式包列表"); + StylePackStore::new_fallback() + }); + let vocab = DictionaryStore::new().unwrap_or_else(|e| { + log::error!("[coord] DictionaryStore init failed: {e}; 降级为空词库"); + DictionaryStore::new_fallback() + }); + let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| { + log::error!("[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则"); + CorrectionRuleStore::new_fallback() }); - let prefs = PreferencesStore::new().expect("preferences store init"); - let style_packs = StylePackStore::new(&prefs).expect("style pack store init"); - let vocab = DictionaryStore::new().expect("dictionary store init"); - let correction_rules = CorrectionRuleStore::new().expect("correction rule store init"); Self { inner: Arc::new(Inner { @@ -440,13 +452,25 @@ impl Coordinator { sherpa_onnx_runtime: Arc, ) -> Self { let history = HistoryStore::new().unwrap_or_else(|e| { - log::error!("[coord] HistoryStore init failed: {e}; falling back to empty"); - HistoryStore::new().expect("history store init") + log::error!("[coord] HistoryStore init failed: {e}; 降级为空历史记录"); + HistoryStore::new_fallback() + }); + let prefs = PreferencesStore::new().unwrap_or_else(|e| { + log::error!("[coord] PreferencesStore init failed: {e}; 降级为默认偏好设置"); + PreferencesStore::new_fallback() + }); + let style_packs = StylePackStore::new(&prefs).unwrap_or_else(|e| { + log::error!("[coord] StylePackStore init failed: {e}; 降级为空样式包列表"); + StylePackStore::new_fallback() + }); + let vocab = DictionaryStore::new().unwrap_or_else(|e| { + log::error!("[coord] DictionaryStore init failed: {e}; 降级为空词库"); + DictionaryStore::new_fallback() + }); + let correction_rules = CorrectionRuleStore::new().unwrap_or_else(|e| { + log::error!("[coord] CorrectionRuleStore init failed: {e}; 降级为空纠错规则"); + CorrectionRuleStore::new_fallback() }); - let prefs = PreferencesStore::new().expect("preferences store init"); - let style_packs = StylePackStore::new(&prefs).expect("style pack store init"); - let vocab = DictionaryStore::new().expect("dictionary store init"); - let correction_rules = CorrectionRuleStore::new().expect("correction rule store init"); Self { inner: Arc::new(Inner { diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index a2cc853c..7b964f0a 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -403,16 +403,18 @@ pub(super) async fn handle_less_computer_pressed(inner: &Arc) { return; } - if begin_session(inner).await.is_err() { + // voice_agent=true 在 Starting 阶段就写入 state,防止 finish_starting_session + // 处理 pending_stop 时(快速松手 race)丢失标志,导致意外走普通听写路径。 + if begin_session_as(inner, true).await.is_err() { return; } let started = { - let mut state = inner.state.lock(); + let state = inner.state.lock(); + // voice_agent 已在 begin_session_as 内设置;这里只检查阶段是否推进成功。 if matches!( state.phase, - SessionPhase::Starting | SessionPhase::Listening + SessionPhase::Starting | SessionPhase::Listening | SessionPhase::Processing ) { - state.voice_agent = true; log::info!( "[less-computer] voice session started (session={:?})", state.session_id From d4e2067412cf3f8b437ceb08ca75773e0a6880ab Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 11:25:44 +0800 Subject: [PATCH 4/7] fix(android): sync OVERLAY_VISIBLE when OS kills overlay service [CI-verified-only] Add notify_overlay_destroyed() and the matching JNI export nativeNotifyOverlayDestroyed, which Kotlin must call from OpenLessOverlayService.onDestroy(). This clears the Rust-side OVERLAY_VISIBLE atomic so refresh_overlay_if_visible() does not dispatch REFRESH_LAYOUT commands to a dead service. --- .../app/src-tauri/src/android/native_bridge.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/openless-all/app/src-tauri/src/android/native_bridge.rs b/openless-all/app/src-tauri/src/android/native_bridge.rs index 9673e2ce..3e8d2e8a 100644 --- a/openless-all/app/src-tauri/src/android/native_bridge.rs +++ b/openless-all/app/src-tauri/src/android/native_bridge.rs @@ -128,6 +128,14 @@ pub fn is_overlay_visible() -> bool { OVERLAY_VISIBLE.load(std::sync::atomic::Ordering::SeqCst) } +/// Kotlin overlay service 的 onDestroy() 调用此函数,以便在 OS 杀死服务时 +/// 同步清除 OVERLAY_VISIBLE 标志,避免 refresh_overlay_if_visible() 向死亡 +/// 服务发送无效命令。 +pub fn notify_overlay_destroyed() { + OVERLAY_VISIBLE.store(false, std::sync::atomic::Ordering::SeqCst); + log::info!("[android-native] overlay service destroyed — OVERLAY_VISIBLE reset"); +} + pub fn overlay_trigger_mode_name() -> &'static str { let Some(coordinator) = COORDINATOR.get() else { return "background"; @@ -393,4 +401,14 @@ mod jni_exports { }); } } + + /// 供 Kotlin overlay service 的 onDestroy() 调用,将 OVERLAY_VISIBLE 清除。 + /// 解决 OS 杀死服务时 Rust 端状态永久失同步的问题。 + #[no_mangle] + pub unsafe extern "system" fn Java_com_openless_app_OpenLessNative_nativeNotifyOverlayDestroyed( + _env: *mut JNIEnv, + _class: JClass, + ) { + notify_overlay_destroyed(); + } } From 54d19e80036a79974febed89603bf505077f7cd5 Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 11:25:51 +0800 Subject: [PATCH 5/7] fix(android): null-check raw context pointer before JObject::from_raw in with_android_env [CI-verified-only] android_context().context() returns a *mut c_void that may be null if Tauri/tao has not yet initialized the Android context (early startup or process-restart edge cases). Calling JObject::from_raw(null) leads to misleading NullPointerException from later JNI calls. Fail early with a clear error message instead. --- openless-all/app/src-tauri/src/android/jni.rs | 79 ++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/android/jni.rs b/openless-all/app/src-tauri/src/android/jni.rs index 342d98fc..a7e3eeb7 100644 --- a/openless-all/app/src-tauri/src/android/jni.rs +++ b/openless-all/app/src-tauri/src/android/jni.rs @@ -17,7 +17,13 @@ pub mod android { let mut env = vm .attach_current_thread() .map_err(|error| format!("attach Android thread: {error}"))?; - let context = unsafe { JObject::from_raw(android_context.context() as jni::sys::jobject) }; + let raw_context = android_context.context() as jni::sys::jobject; + if raw_context.is_null() { + return Err("Android context not yet initialized".to_string()); + } + // SAFETY: raw_context is non-null and points to a valid Android Context object + // provided by tao/Tauri; the reference lifetime is valid for the duration of `f`. + let context = unsafe { JObject::from_raw(raw_context) }; f(&mut env, &context) } @@ -313,6 +319,77 @@ pub mod android { .map_err(|error| format!("read SDK_INT: {error}")) } + /// 读取剪贴板当前的第一条纯文本内容,用于在粘贴后还原。 + /// 失败或剪贴板为空时返回 None(不返回错误,避免阻塞主流程)。 + pub fn get_primary_clip_text( + env: &mut JNIEnv, + context: &JObject, + ) -> Option { + let clipboard_name = jobject_str(env, "clipboard").ok()?; + let clipboard = env + .call_method( + context, + "getSystemService", + "(Ljava/lang/String;)Ljava/lang/Object;", + &[JValue::Object(&clipboard_name)], + ) + .and_then(|value| value.l()) + .ok()?; + let clip = env + .call_method( + &clipboard, + "getPrimaryClip", + "()Landroid/content/ClipData;", + &[], + ) + .and_then(|value| value.l()) + .ok()?; + if clip.is_null() { + return None; + } + let item = env + .call_method( + &clip, + "getItemAt", + "(I)Landroid/content/ClipData$Item;", + &[JValue::Int(0)], + ) + .and_then(|value| value.l()) + .ok()?; + if item.is_null() { + return None; + } + let text_val = env + .call_method( + &item, + "getText", + "()Ljava/lang/CharSequence;", + &[], + ) + .and_then(|value| value.l()) + .ok()?; + if text_val.is_null() { + return None; + } + let text_str = env + .call_method(&text_val, "toString", "()Ljava/lang/String;", &[]) + .and_then(|value| value.l()) + .ok()?; + let jstr = JString::from(text_str); + env.get_string(&jstr) + .map(|s| s.to_string_lossy().into_owned()) + .ok() + } + + /// 将指定文本写回剪贴板,用于 accessibility 粘贴后还原用户原有内容。 + pub fn set_primary_clip_text( + env: &mut JNIEnv, + context: &JObject, + text: &str, + ) -> Result<(), String> { + copy_to_clipboard(env, context, text).map(|_| ()) + } + pub fn copy_to_clipboard( env: &mut JNIEnv, context: &JObject, From 00d3df47e2bff13da2687135780ee504c63c7aaa Mon Sep 17 00:00:00 2001 From: sim Date: Wed, 17 Jun 2026 11:26:00 +0800 Subject: [PATCH 6/7] fix(android): restore clipboard after accessibility paste in try_accessibility [CI-verified-only] The accessibility insertion path wrote dictated text to the system clipboard but never restored what was there before. On Android 10+ we now save the primary clip before copy_fallback(), and restore it after a successful paste_via_accessibility() call. Adds get_primary_clip_text / set_primary_clip_text JNI helpers in jni.rs. --- .../app/src-tauri/src/android/insert.rs | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/openless-all/app/src-tauri/src/android/insert.rs b/openless-all/app/src-tauri/src/android/insert.rs index 13f4aedb..124f37d6 100644 --- a/openless-all/app/src-tauri/src/android/insert.rs +++ b/openless-all/app/src-tauri/src/android/insert.rs @@ -29,15 +29,38 @@ fn try_accessibility(inserter: &TextInserter, text: &str) -> Option = + crate::android::jni::android::with_android_env(|env, context| { + Ok(crate::android::jni::android::get_primary_clip_text(env, context)) + }) + .ok() + .flatten(); + if !matches!(inserter.copy_fallback(text), InsertStatus::CopiedFallback) { return None; } - if crate::android::accessibility::paste_via_accessibility() { + let result = if crate::android::accessibility::paste_via_accessibility() { Some(InsertStatus::Inserted) } else { log::warn!("[android-insert] accessibility paste failed; text remains on clipboard"); Some(InsertStatus::CopiedFallback) + }; + + // 还原用户原有剪贴板内容(仅当粘贴成功时还原;失败时用户需要自己处理)。 + if matches!(result, Some(InsertStatus::Inserted)) { + if let Some(prev) = previous_clip { + if let Err(e) = + crate::android::jni::android::with_android_env(|env, context| { + crate::android::jni::android::set_primary_clip_text(env, context, &prev) + }) + { + log::warn!("[android-insert] failed to restore clipboard: {e}"); + } + } } + + result } fn clipboard_fallback(inserter: &TextInserter, text: &str) -> InsertStatus { From d62513915e53592affa2a5fbd28181060bde5fd5 Mon Sep 17 00:00:00 2001 From: appergb Date: Wed, 17 Jun 2026 11:29:19 +0800 Subject: [PATCH 7/7] fix(android): call nativeNotifyOverlayDestroyed from OpenLessOverlayService.onDestroy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the overlay-desync fix: the Rust export resets OVERLAY_VISIBLE=false, but nothing called it. onDestroy runs even when the OS kills the foreground service, so this prevents OVERLAY_VISIBLE from getting stuck true. [Kotlin — needs a real Android build to compile-verify; Android cargo check covers Rust only] --- openless-all/app/android/kotlin/OpenLessNative.kt | 2 ++ openless-all/app/android/kotlin/OpenLessOverlayService.kt | 2 ++ 2 files changed, 4 insertions(+) diff --git a/openless-all/app/android/kotlin/OpenLessNative.kt b/openless-all/app/android/kotlin/OpenLessNative.kt index 3c6f297a..6b1b1682 100644 --- a/openless-all/app/android/kotlin/OpenLessNative.kt +++ b/openless-all/app/android/kotlin/OpenLessNative.kt @@ -39,4 +39,6 @@ object OpenLessNative { @JvmStatic external fun nativeIsOverlayVisible(): Boolean @JvmStatic external fun nativeNotifyOverlayPermissionChanged(context: android.content.Context) + + @JvmStatic external fun nativeNotifyOverlayDestroyed() } diff --git a/openless-all/app/android/kotlin/OpenLessOverlayService.kt b/openless-all/app/android/kotlin/OpenLessOverlayService.kt index 658d22eb..e811023c 100644 --- a/openless-all/app/android/kotlin/OpenLessOverlayService.kt +++ b/openless-all/app/android/kotlin/OpenLessOverlayService.kt @@ -93,6 +93,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList if (instance === this) { instance = null } + // 系统杀死前台服务时也会走到这里:同步原生 OVERLAY_VISIBLE=false,避免状态永久残留为 true。 + runCatching { OpenLessNative.nativeNotifyOverlayDestroyed() } super.onDestroy() }