From 3a6f7f3c92317b2e4bb8071f8ecb9754dea6bb66 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 19:53:51 +0800 Subject: [PATCH] feat: route Windows insertion through temporary TSF IME --- openless-all/app/src-tauri/src/coordinator.rs | 80 +++++++++ openless-all/app/src-tauri/src/insertion.rs | 9 + openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src-tauri/src/windows_ime_session.rs | 169 ++++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 openless-all/app/src-tauri/src/windows_ime_session.rs diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a6f46d93..93937a0e 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -31,6 +31,8 @@ use crate::types::{ CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus, HotkeyStatusState, InsertStatus, PolishMode, }; +#[cfg(target_os = "windows")] +use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SessionPhase { @@ -89,6 +91,10 @@ struct Inner { prefs: PreferencesStore, vocab: DictionaryStore, inserter: TextInserter, + #[cfg(target_os = "windows")] + windows_ime: WindowsImeSessionController, + #[cfg(target_os = "windows")] + prepared_windows_ime_session: Arc>>, state: Mutex, asr: Mutex>, recorder: Mutex>, @@ -118,6 +124,10 @@ impl Coordinator { prefs, vocab, inserter: TextInserter::new(), + #[cfg(target_os = "windows")] + windows_ime: WindowsImeSessionController::new(), + #[cfg(target_os = "windows")] + prepared_windows_ime_session: Arc::new(Mutex::new(None)), state: Mutex::new(SessionState::default()), asr: Mutex::new(None), recorder: Mutex::new(None), @@ -465,6 +475,11 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { // 如果迟到错误到达时 id 已不匹配就 drop,不会误中止后续 session。 state.session_id = state.session_id.wrapping_add(1); } + #[cfg(target_os = "windows")] + { + let prepared = inner.windows_ime.prepare_session(); + *inner.prepared_windows_ime_session.lock() = Some(prepared); + } // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 inner.translation_modifier_seen.store(false, Ordering::SeqCst); @@ -486,6 +501,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { Some(message.clone()), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; return Err(message); } @@ -500,6 +516,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { Some(message.clone()), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(message); @@ -536,6 +553,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { } } if cancel_raced_during_starting(inner) { + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } @@ -547,6 +565,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { Some(format!("ASR 连接失败: {e}")), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -560,6 +579,7 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { if let Some(rec) = inner.recorder.lock().take() { rec.stop(); } + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } @@ -637,6 +657,7 @@ fn start_recorder_for_starting( Some(format!("录音启动失败: {e}")), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -697,6 +718,7 @@ fn abort_recording_with_error(inner: &Arc, message: String) { ActiveAsr::Whisper(w) => w.cancel(), } } + restore_prepared_windows_ime_session(inner); emit_capsule( inner, @@ -749,6 +771,7 @@ async fn finish_starting_session(inner: &Arc) { ActiveAsr::Whisper(w) => w.cancel(), } } + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; } BeginOutcome::Started | BeginOutcome::PendingStop => { @@ -781,6 +804,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { let asr = match asr_opt { Some(a) => a, None => { + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } @@ -803,6 +827,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("识别失败: {e}")), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -821,6 +846,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { Some(format!("识别失败: {e}")), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err(e.to_string()); @@ -832,6 +858,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { // 优先级高于 empty 检查 — 用户取消 → 静默丢弃,不写失败历史也不弹错误胶囊。 if inner.state.lock().cancelled { log::info!("[coord] cancel detected after ASR — discarding transcript"); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; return Ok(()); } @@ -863,6 +890,7 @@ async fn end_session(inner: &Arc) -> Result<(), String> { Some("ASR returned empty transcript".to_string()), None, ); + restore_prepared_windows_ime_session(inner); inner.state.lock().phase = SessionPhase::Idle; schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); return Err("ASR returned empty transcript".to_string()); @@ -907,12 +935,16 @@ async fn end_session(inner: &Arc) -> Result<(), String> { "[coord] cancel detected before insert — discarding output (chars={})", polished.chars().count() ); + restore_prepared_windows_ime_session(inner); return Ok(()); } let focus_target = inner.state.lock().focus_target; restore_focus_target_if_possible(focus_target); let restore_clipboard = inner.prefs.get().restore_clipboard_after_paste; + #[cfg(target_os = "windows")] + let status = insert_with_windows_ime_first(inner, &polished, restore_clipboard).await; + #[cfg(not(target_os = "windows"))] let status = inner.inserter.insert(&polished, restore_clipboard); let inserted_chars = polished.chars().count() as u32; @@ -1016,6 +1048,7 @@ fn cancel_session(inner: &Arc) { ActiveAsr::Whisper(w) => w.cancel(), } } + restore_prepared_windows_ime_session(inner); // Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾; // 其他阶段直接转 Idle。 if phase != SessionPhase::Processing { @@ -1028,6 +1061,53 @@ fn cancel_session(inner: &Arc) { schedule_capsule_idle(inner, CAPSULE_AUTO_HIDE_DELAY_MS); } +#[cfg(target_os = "windows")] +fn restore_prepared_windows_ime_session(inner: &Arc) { + if let Some(prepared) = inner.prepared_windows_ime_session.lock().take() { + inner.windows_ime.restore_session(prepared); + } +} + +#[cfg(not(target_os = "windows"))] +fn restore_prepared_windows_ime_session(_inner: &Arc) {} + +#[cfg(target_os = "windows")] +async fn insert_with_windows_ime_first( + inner: &Arc, + polished: &str, + restore_clipboard: bool, +) -> InsertStatus { + let prepared = inner.prepared_windows_ime_session.lock().take(); + let Some(prepared) = prepared else { + return inner + .inserter + .insert_via_clipboard_fallback(polished, restore_clipboard); + }; + + let request = crate::windows_ime_ipc::ImeSubmitRequest { + session_id: Uuid::new_v4().to_string(), + text: polished.to_string(), + created_at: Utc::now().to_rfc3339(), + }; + + let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await { + Ok(status) => status, + Err(error) => { + log::warn!("[windows-ime] TSF submit failed, falling back to clipboard: {error}"); + InsertStatus::CopiedFallback + } + }; + inner.windows_ime.restore_session(prepared); + + if ime_status == InsertStatus::Inserted { + ime_status + } else { + inner + .inserter + .insert_via_clipboard_fallback(polished, restore_clipboard) + } +} + // ─────────────────────────── helpers ─────────────────────────── #[cfg(any(debug_assertions, test))] diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index bbb84f14..014636af 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -35,6 +35,15 @@ impl TextInserter { insert_with_clipboard_restore(text, restore_clipboard_after_paste) } + #[cfg(not(target_os = "macos"))] + pub fn insert_via_clipboard_fallback( + &self, + text: &str, + restore_clipboard_after_paste: bool, + ) -> InsertStatus { + self.insert(text, restore_clipboard_after_paste) + } + /// Insert `text` at the current cursor position. #[cfg(target_os = "macos")] pub fn insert(&self, text: &str, _restore_clipboard_after_paste: bool) -> InsertStatus { diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 9bc92e98..a41a30e9 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ mod types; mod windows_ime_ipc; mod windows_ime_profile; mod windows_ime_protocol; +mod windows_ime_session; #[cfg(target_os = "macos")] use std::sync::mpsc; diff --git a/openless-all/app/src-tauri/src/windows_ime_session.rs b/openless-all/app/src-tauri/src/windows_ime_session.rs new file mode 100644 index 00000000..b7e70027 --- /dev/null +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -0,0 +1,169 @@ +use crate::types::InsertStatus; +use crate::windows_ime_ipc::{ImeSubmitRequest, WindowsImeIpcServer}; +use crate::windows_ime_profile::{ + restore_decision, ImeProfileSnapshot, ProfileRestoreDecision, WindowsImeProfileManager, +}; +use crate::windows_ime_protocol::ImeSubmitStatus; + +#[derive(Debug)] +pub enum WindowsImeSessionError { + Profile(String), + Ipc(String), +} + +impl std::fmt::Display for WindowsImeSessionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Profile(message) | Self::Ipc(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for WindowsImeSessionError {} + +pub fn map_ime_status_to_insert_status(status: ImeSubmitStatus) -> InsertStatus { + match status { + ImeSubmitStatus::Committed => InsertStatus::Inserted, + ImeSubmitStatus::Rejected | ImeSubmitStatus::Failed => InsertStatus::CopiedFallback, + } +} + +pub fn should_fallback_after_ime_result(status: ImeSubmitStatus) -> bool { + !matches!(status, ImeSubmitStatus::Committed) +} + +#[derive(Debug)] +pub struct PreparedWindowsImeSession { + saved_profile: Option, + openless_activated: bool, +} + +impl PreparedWindowsImeSession { + pub fn unavailable() -> Self { + Self { + saved_profile: None, + openless_activated: false, + } + } + + pub fn is_ready_for_tsf_submit(&self) -> bool { + self.saved_profile.is_some() && self.openless_activated + } +} + +pub struct WindowsImeSessionController { + profile_manager: WindowsImeProfileManager, + ipc_server: WindowsImeIpcServer, +} + +impl WindowsImeSessionController { + pub fn new() -> Self { + Self { + profile_manager: WindowsImeProfileManager::new(), + ipc_server: WindowsImeIpcServer::new(), + } + } + + pub fn prepare_session(&self) -> PreparedWindowsImeSession { + #[cfg(target_os = "windows")] + { + let saved_profile = match self.profile_manager.capture_active_profile() { + Ok(snapshot) => snapshot, + Err(error) => { + let error = WindowsImeSessionError::Profile(error.to_string()); + log::warn!("[windows-ime] capture active profile failed: {error}"); + return PreparedWindowsImeSession::unavailable(); + } + }; + + match self.profile_manager.activate_openless_profile() { + Ok(()) => PreparedWindowsImeSession { + saved_profile: Some(saved_profile), + openless_activated: true, + }, + Err(error) => { + let error = WindowsImeSessionError::Profile(error.to_string()); + log::warn!("[windows-ime] activate OpenLess profile failed: {error}"); + PreparedWindowsImeSession::unavailable() + } + } + } + + #[cfg(not(target_os = "windows"))] + { + PreparedWindowsImeSession::unavailable() + } + } + + pub async fn submit_prepared( + &self, + prepared: &PreparedWindowsImeSession, + request: ImeSubmitRequest, + ) -> Result { + if !prepared.is_ready_for_tsf_submit() { + return Ok(InsertStatus::CopiedFallback); + } + + let status = self + .ipc_server + .submit_text(request) + .await + .map_err(|error| WindowsImeSessionError::Ipc(error.to_string()))?; + if should_fallback_after_ime_result(status) { + log::warn!("[windows-ime] TSF submit returned {status:?}; falling back to clipboard"); + } + Ok(map_ime_status_to_insert_status(status)) + } + + pub fn restore_session(&self, prepared: PreparedWindowsImeSession) { + let should_restore = match self.profile_manager.is_openless_profile_active() { + Ok(openless_active) => { + restore_decision(prepared.saved_profile.as_ref(), openless_active) + } + Err(error) => { + log::warn!("[windows-ime] check active profile before restore failed: {error}"); + ProfileRestoreDecision::KeepCurrentProfile + } + }; + + if should_restore != ProfileRestoreDecision::RestoreSavedProfile { + return; + } + + let Some(saved_profile) = prepared.saved_profile.as_ref() else { + return; + }; + + if let Err(error) = self.profile_manager.restore_profile(saved_profile) { + log::warn!("[windows-ime] restore saved profile failed: {error}"); + } + } +} + +impl Default for WindowsImeSessionController { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn committed_ime_result_maps_to_inserted() { + assert_eq!( + map_ime_status_to_insert_status(ImeSubmitStatus::Committed), + InsertStatus::Inserted + ); + } + + #[test] + fn rejected_ime_result_requests_fallback() { + assert!(should_fallback_after_ime_result(ImeSubmitStatus::Rejected)); + assert!(should_fallback_after_ime_result(ImeSubmitStatus::Failed)); + assert!(!should_fallback_after_ime_result( + ImeSubmitStatus::Committed + )); + } +}