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() } 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 { 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, 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(); + } } 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/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, 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 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()