Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions openless-all/app/android/kotlin/OpenLessNative.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,6 @@ object OpenLessNative {
@JvmStatic external fun nativeIsOverlayVisible(): Boolean

@JvmStatic external fun nativeNotifyOverlayPermissionChanged(context: android.content.Context)

@JvmStatic external fun nativeNotifyOverlayDestroyed()
}
2 changes: 2 additions & 0 deletions openless-all/app/android/kotlin/OpenLessOverlayService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ class OpenLessOverlayService : Service(), OpenLessOverlayBridge.OverlayStateList
if (instance === this) {
instance = null
}
// 系统杀死前台服务时也会走到这里:同步原生 OVERLAY_VISIBLE=false,避免状态永久残留为 true。
runCatching { OpenLessNative.nativeNotifyOverlayDestroyed() }
super.onDestroy()
}

Expand Down
25 changes: 24 additions & 1 deletion openless-all/app/src-tauri/src/android/insert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,38 @@ fn try_accessibility(inserter: &TextInserter, text: &str) -> Option<InsertStatus
log::info!("[android-insert] accessibility service not enabled");
return None;
}
// 保存粘贴前的剪贴板内容,粘贴完成后还原,避免静默覆盖用户剪贴板。
let previous_clip: Option<String> =
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 {
Expand Down
79 changes: 78 additions & 1 deletion openless-all/app/src-tauri/src/android/jni.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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<String> {
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,
Expand Down
18 changes: 18 additions & 0 deletions openless-all/app/src-tauri/src/android/native_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
}
}
52 changes: 38 additions & 14 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -440,13 +452,25 @@ impl Coordinator {
sherpa_onnx_runtime: Arc<SherpaOnnxRuntime>,
) -> 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 {
Expand Down
16 changes: 16 additions & 0 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -999,13 +999,25 @@ pub(super) fn request_stop_during_starting(inner: &Arc<Inner>, reason: &str) {
}

pub(super) async fn begin_session(inner: &Arc<Inner>) -> 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<Inner>,
voice_agent: bool,
) -> Result<(), String> {
let current_session_id = {
let mut state = inner.state.lock();
let Some(session_id) =
begin_session_state(&mut state, capture_focus_target(), capture_frontmost_app())
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}");
}
Expand Down Expand Up @@ -1635,6 +1647,8 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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,
Expand Down Expand Up @@ -1762,6 +1776,8 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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,
Expand Down
10 changes: 6 additions & 4 deletions openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,16 +403,18 @@ pub(super) async fn handle_less_computer_pressed(inner: &Arc<Inner>) {
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
Expand Down
Loading
Loading