From 21789583b4fa1b97bbe6078f1faa2ae46ce224f7 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 4 Aug 2026 19:12:14 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(windows-ime):=20TSF=20=E6=8F=92?= =?UTF-8?q?=E5=85=A5=E5=90=8E=E8=BE=93=E5=85=A5=E6=B3=95=E6=9C=AA=E5=88=87?= =?UTF-8?q?=E5=9B=9E=E5=8E=9F=E8=BE=93=E5=85=A5=E6=B3=95=EF=BC=88#852?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restore_decision 改为依据会话已知的激活状态(openless_was_activated / activation_failed),不再依赖 GetActiveProfile 探测结果,避免后台线程误判导致跳过恢复 - restore_profile 中 legacy 失败不再短路现代 ActivateProfile,任一成功即视为整体成功 - restore_session 增加恢复后校验与一次重试;saved 本身是 OpenLess 时跳过(粘滞态防护) - prepare/restore 补充诊断日志,便于区分未走恢复/恢复被跳过/恢复失败 --- .../app/src-tauri/src/windows_ime_profile.rs | 175 +++++++++++++----- .../app/src-tauri/src/windows_ime_session.rs | 137 ++++++++++---- 2 files changed, 232 insertions(+), 80 deletions(-) diff --git a/openless-all/app/src-tauri/src/windows_ime_profile.rs b/openless-all/app/src-tauri/src/windows_ime_profile.rs index 3313f0fb6..e76028d37 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -75,12 +75,49 @@ pub enum ProfileRestoreDecision { KeepCurrentProfile, } +/// 判断快照是否就是 OpenLess 自己的 TSF 配置文件。 +/// +/// 用于粘滞态防护:若上次会话恢复失败,OpenLess 仍是当前输入法,下一次 +/// `prepare_session` 会把 OpenLess 本身捕获为"原输入法";此时应跳过恢复, +/// 避免把 OpenLess 当原输入法写死(issue #852 的失败状态自粘)。 +pub fn is_openless_profile_snapshot(snapshot: &ImeProfileSnapshot) -> bool { + matches!(snapshot.kind(), ImeProfileKind::TextService) + && snapshot.lang_id() == OPENLESS_TSF_LANG_ID + && snapshot.clsid().map(normalize_guid_string).as_deref() + == Some(OPENLESS_TEXT_SERVICE_CLSID_BRACED) + && snapshot + .profile_guid() + .map(normalize_guid_string) + .as_deref() + == Some(OPENLESS_PROFILE_GUID_BRACED) +} + +fn normalize_guid_string(value: &str) -> String { + let upper = value.trim().to_ascii_uppercase(); + if upper.starts_with('{') && upper.ends_with('}') { + upper + } else { + format!("{{{upper}}}") + } +} + +/// 根据会话状态决定是否恢复原输入法。 +/// +/// - 会话确实激活过 OpenLess(`openless_was_activated`)→ 恢复; +/// - 激活失败但捕获到了原快照(`openless_activation_failed`)→ 仍恢复, +/// 覆盖"激活半途而废"的残留状态; +/// - 既没激活、也没有失败快照(未捕获到原输入法 / 非 Windows)→ 保持现状。 +/// +/// 注意:这里**不再**接收 `is_openless_profile_active()` 的探测结果。该探测运行在 +/// OpenLess 自己进程的后台线程上,而 OpenLess IME 激活发生在目标 App 进程, +/// `GetActiveProfile` 可能返回线程本地的默认配置,误判为"用户已切走"而跳过恢复 +/// (issue #852)。恢复决定只应依赖我们已知的激活事实。 pub fn restore_decision( saved: Option<&ImeProfileSnapshot>, - openless_profile_is_current: bool, + openless_was_activated: bool, openless_activation_failed: bool, ) -> ProfileRestoreDecision { - if saved.is_some() && (openless_profile_is_current || openless_activation_failed) { + if saved.is_some() && (openless_was_activated || openless_activation_failed) { ProfileRestoreDecision::RestoreSavedProfile } else { ProfileRestoreDecision::KeepCurrentProfile @@ -406,11 +443,10 @@ mod windows_impl { // current language / active profile 状态,OS 仍认 OpenLess 是当前输入法 → // 用户的输入法切不回去。issue #469。 // - // 现代 ActivateProfile 失败降级为 warn:legacy 两步成功后,OS 视觉层已经把用户 - // 原 IME 切回(语言指示器、键盘事件路由都走 legacy 视图);现代 API 失败只是内部 - // bookkeeping 不同步,不会让用户看到"还停在 OpenLess"。所以这一步降级为 warn, - // 不让 caller 把"已经切回了但 bookkeeping 慢"误判成"切回完全失败"。pr_agent - // partial-restore 关注点回应。 + // #852 加固:legacy 与现代各自独立执行并分别记录结果,legacy 失败不再短路 + // 现代调用(此前 legacy `?` 传播会让现代 ActivateProfile 根本不执行,恢复 + // 整体失败)。任一成功即视为整体成功:legacy 成功 → OS 视觉层(语言指示器、 + // 键盘事件路由)已切回;现代成功 → 会话级激活已切回。两者都失败才算失败。 match snapshot.kind() { ImeProfileKind::TextService => { let clsid = parse_required_guid("text service CLSID", snapshot.clsid())?; @@ -418,11 +454,10 @@ mod windows_impl { parse_required_guid("text service profile GUID", snapshot.profile_guid())?; let lang_id = snapshot.lang_id(); - with_input_processor_profiles(|profiles| unsafe { + let legacy_result = with_input_processor_profiles(|profiles| unsafe { profiles.ChangeCurrentLanguage(lang_id)?; profiles.ActivateLanguageProfile(&clsid, lang_id, &profile_guid) - })?; - + }); let modern_result = with_profile_manager(|manager| unsafe { manager.ActivateProfile( TF_PROFILETYPE_INPUTPROCESSOR, @@ -433,22 +468,16 @@ mod windows_impl { PROFILE_RESTORE_FLAGS, ) }); - if let Err(err) = modern_result { - log::warn!( - "[windows-ime] legacy restore OK but modern ActivateProfile failed: {err}" - ); - } - Ok(()) + report_restore_step_results(legacy_result, modern_result) } ImeProfileKind::KeyboardLayout => { let hkl = HKL(snapshot.hkl().unwrap_or_default() as *mut c_void); let zero_guid = GUID::zeroed(); let lang_id = snapshot.lang_id(); - with_input_processor_profiles(|profiles| unsafe { + let legacy_result = with_input_processor_profiles(|profiles| unsafe { profiles.ChangeCurrentLanguage(lang_id) - })?; - + }); let modern_result = with_profile_manager(|manager| unsafe { manager.ActivateProfile( TF_PROFILETYPE_KEYBOARDLAYOUT, @@ -459,28 +488,36 @@ mod windows_impl { PROFILE_RESTORE_FLAGS, ) }); - if let Err(err) = modern_result { - log::warn!( - "[windows-ime] legacy restore OK but modern ActivateProfile (keyboard) failed: {err}" - ); - } - Ok(()) + report_restore_step_results(legacy_result, modern_result) } } } + pub(super) fn report_restore_step_results( + legacy_result: WindowsImeProfileResult<()>, + modern_result: WindowsImeProfileResult<()>, + ) -> WindowsImeProfileResult<()> { + if let Err(error) = &legacy_result { + log::warn!( + "[windows-ime] legacy restore failed (ChangeCurrentLanguage/ActivateLanguageProfile): {error}" + ); + } + if let Err(error) = &modern_result { + log::warn!("[windows-ime] modern ActivateProfile failed: {error}"); + } + match (legacy_result, modern_result) { + (Ok(()), _) | (_, Ok(())) => Ok(()), + (Err(legacy_error), Err(modern_error)) => Err(WindowsImeProfileError::WindowsApi( + format!( + "both legacy and modern restore failed: legacy={legacy_error}; modern={modern_error}" + ), + )), + } + } + pub fn is_openless_profile_active() -> WindowsImeProfileResult { let snapshot = capture_active_profile()?; - - Ok(matches!(snapshot.kind(), ImeProfileKind::TextService) - && snapshot.lang_id() == OPENLESS_TSF_LANG_ID - && snapshot.clsid().map(normalize_guid_string).as_deref() - == Some(OPENLESS_TEXT_SERVICE_CLSID_BRACED) - && snapshot - .profile_guid() - .map(normalize_guid_string) - .as_deref() - == Some(OPENLESS_PROFILE_GUID_BRACED)) + Ok(is_openless_profile_snapshot(&snapshot)) } pub fn set_openless_language_profile_enabled(enabled: bool) -> WindowsImeProfileResult<()> { @@ -706,15 +743,6 @@ mod windows_impl { Ok(ImeProfileSnapshot::keyboard_layout(lang_id, hkl_value)) } - fn normalize_guid_string(value: &str) -> String { - let upper = value.trim().to_ascii_uppercase(); - if upper.starts_with('{') && upper.ends_with('}') { - upper - } else { - format!("{{{upper}}}") - } - } - fn hkl_to_isize(hkl: HKL) -> isize { hkl.0 as isize } @@ -771,7 +799,7 @@ mod tests { } #[test] - fn restore_is_required_when_openless_is_active_and_snapshot_exists() { + fn restore_is_required_when_openless_was_activated() { assert_eq!( restore_decision(Some(&text_service_snapshot()), true, false), ProfileRestoreDecision::RestoreSavedProfile @@ -795,13 +823,30 @@ mod tests { } #[test] - fn restore_is_skipped_when_user_already_changed_away_from_openless() { + fn restore_is_skipped_when_session_never_activated() { assert_eq!( restore_decision(Some(&text_service_snapshot()), false, false), ProfileRestoreDecision::KeepCurrentProfile ); } + #[test] + fn openless_snapshot_detection_matches_exact_profile_identifiers() { + // 大小写与花括号不同的 GUID 也应被归一化后识别为 OpenLess(粘滞态防护)。 + let openless = ImeProfileSnapshot::text_service( + 0x0804, + "{6b9f3f4f-5ee7-42d6-9c61-9f80b03a5d7d}".to_string(), + "{9b5f5e04-23f6-47da-9a26-d221f6c3f02e}".to_string(), + ); + assert!(is_openless_profile_snapshot(&openless)); + + let other_ime = text_service_snapshot(); + assert!(!is_openless_profile_snapshot(&other_ime)); + + let keyboard = ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409); + assert!(!is_openless_profile_snapshot(&keyboard)); + } + #[test] fn desired_openless_language_profile_enabled_follows_sendinput_and_visibility_pref() { let tsf_only = UserPreferences { @@ -951,4 +996,42 @@ mod windows_tests { assert!(!ownership.should_uninitialize); } + + #[test] + fn restore_step_results_ok_when_modern_succeeds_after_legacy_failure() { + let result = windows_impl::report_restore_step_results( + Err(WindowsImeProfileError::WindowsApi( + "legacy failed".to_string(), + )), + Ok(()), + ); + assert!(result.is_ok()); + } + + #[test] + fn restore_step_results_ok_when_legacy_succeeds_and_modern_fails() { + let result = windows_impl::report_restore_step_results( + Ok(()), + Err(WindowsImeProfileError::WindowsApi( + "modern failed".to_string(), + )), + ); + assert!(result.is_ok()); + } + + #[test] + fn restore_step_results_err_only_when_both_fail() { + let result = windows_impl::report_restore_step_results( + Err(WindowsImeProfileError::WindowsApi( + "legacy failed".to_string(), + )), + Err(WindowsImeProfileError::WindowsApi( + "modern failed".to_string(), + )), + ); + let err = result.unwrap_err(); + assert!(err + .to_string() + .contains("both legacy and modern restore failed")); + } } diff --git a/openless-all/app/src-tauri/src/windows_ime_session.rs b/openless-all/app/src-tauri/src/windows_ime_session.rs index e3aa6412e..496e4349e 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -2,7 +2,8 @@ use crate::types::InsertStatus; use crate::windows_ime_ipc::{ImeSubmitRequest, WindowsImeIpcServer}; use crate::windows_ime_profile::{ - restore_decision, ImeProfileSnapshot, ProfileRestoreDecision, WindowsImeProfileManager, + is_openless_profile_snapshot, restore_decision, ImeProfileSnapshot, ProfileRestoreDecision, + WindowsImeProfileManager, }; use crate::windows_ime_protocol::ImeSubmitStatus; @@ -33,6 +34,16 @@ pub fn should_fallback_after_ime_result(status: ImeSubmitStatus) -> bool { !matches!(status, ImeSubmitStatus::Committed) } +fn describe_snapshot(snapshot: &ImeProfileSnapshot) -> String { + format!( + "kind={:?} lang=0x{:04X} clsid={} profile={}", + snapshot.kind(), + snapshot.lang_id(), + snapshot.clsid().unwrap_or("none"), + snapshot.profile_guid().unwrap_or("none"), + ) +} + #[derive(Debug)] pub struct PreparedWindowsImeSession { saved_profile: Option, @@ -66,10 +77,6 @@ impl PreparedWindowsImeSession { self.openless_activated } - pub fn should_restore_when_active_profile_check_fails(&self) -> bool { - self.has_saved_profile() - } - pub fn activation_failed_with_saved_profile(&self) -> bool { self.has_saved_profile() && !self.openless_was_activated() } @@ -100,6 +107,15 @@ impl WindowsImeSessionController { } }; + // 诊断:会话开始时 OpenLess 已是当前输入法 → 上次会话疑似恢复失败。 + // 此时仍照常激活(幂等),restore_session 的粘滞态防护会跳过"恢复", + // 避免把 OpenLess 当原输入法写死(issue #852 的失败状态自粘)。 + if is_openless_profile_snapshot(&saved_profile) { + log::warn!( + "[windows-ime] session began while OpenLess IME was already the active profile — previous session likely failed to restore" + ); + } + match self.profile_manager.activate_openless_profile() { Ok(()) => PreparedWindowsImeSession { saved_profile: Some(saved_profile), @@ -144,36 +160,77 @@ impl WindowsImeSessionController { } 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, - prepared.activation_failed_with_saved_profile(), - ), - Err(error) => { - if prepared.should_restore_when_active_profile_check_fails() { - log::warn!( - "[windows-ime] check active profile before restore failed: {error}; attempting restore" - ); - ProfileRestoreDecision::RestoreSavedProfile - } else { - log::warn!("[windows-ime] check active profile before restore failed: {error}"); - ProfileRestoreDecision::KeepCurrentProfile - } - } + let saved_profile = prepared.saved_profile.as_ref(); + let openless_was_activated = prepared.openless_was_activated(); + let activation_failed = prepared.activation_failed_with_saved_profile(); + + // 诊断:记录决策依据 + 恢复前探测到的当前 profile(不影响决策)。 + // issue #852 的恢复决策只依赖会话已知的激活事实,不依赖该探测结果。 + let active_profile_desc = match self.profile_manager.capture_active_profile() { + Ok(snapshot) => describe_snapshot(&snapshot), + Err(error) => format!("unavailable: {error}"), + }; + let saved_desc = match prepared.saved_profile.as_ref() { + Some(snapshot) => describe_snapshot(snapshot), + None => "none".to_string(), }; + let decision = restore_decision(saved_profile, openless_was_activated, activation_failed); + log::info!( + "[windows-ime] restore decision={decision:?} saved_profile={saved_desc} openless_was_activated={openless_was_activated} activation_failed={activation_failed} active_profile={active_profile_desc}" + ); - if should_restore != ProfileRestoreDecision::RestoreSavedProfile { + if decision != ProfileRestoreDecision::RestoreSavedProfile { return; } - let Some(saved_profile) = prepared.saved_profile.as_ref() else { + let Some(saved_profile) = saved_profile else { return; }; - if let Err(error) = self.profile_manager.restore_profile(saved_profile) { - log::warn!("[windows-ime] restore saved profile failed: {error}"); + // 粘滞态防护:saved 本身就是 OpenLess(上次会话疑似未恢复)→ 不把 OpenLess + // 当原输入法写死,跳过恢复并留下诊断日志。 + if is_openless_profile_snapshot(saved_profile) { + log::warn!( + "[windows-ime] saved profile is OpenLess itself — previous session likely failed to restore; skipping restore" + ); + return; } + + // 第一次恢复 + 校验 + 一次重试:TSF 会话级切换偶发不生效时,短等待后重试一次。 + for attempt in 0..2 { + if attempt > 0 { + log::info!( + "[windows-ime] restore did not take effect; retrying (attempt {attempt})" + ); + std::thread::sleep(std::time::Duration::from_millis(250)); + } + if let Err(error) = self.profile_manager.restore_profile(saved_profile) { + log::warn!( + "[windows-ime] restore saved profile failed (attempt {attempt}): {error}" + ); + } + match self.profile_manager.is_openless_profile_active() { + Ok(false) => { + log::info!( + "[windows-ime] restore verified: OpenLess is no longer the active profile" + ); + return; + } + Ok(true) => { + log::warn!( + "[windows-ime] restore verification: OpenLess is still active (attempt {attempt})" + ); + } + Err(error) => { + log::warn!( + "[windows-ime] restore verification check failed (attempt {attempt}): {error}" + ); + } + } + } + log::error!( + "[windows-ime] restore did not take effect after retry — IME may remain on OpenLess" + ); } } @@ -225,19 +282,31 @@ mod tests { } #[test] - fn active_profile_check_failure_restores_any_session_with_saved_profile() { - let prepared = PreparedWindowsImeSession { + fn restore_decision_uses_confirmed_activation_state_only() { + // 激活成功且有原快照 → 恢复(决策不再依赖 profile-current 探测,issue #852)。 + let activated = PreparedWindowsImeSession { saved_profile: Some(ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409)), openless_activated: true, }; - let activation_failed = PreparedWindowsImeSession::activation_failed( - ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + assert_eq!( + restore_decision( + activated.saved_profile.as_ref(), + activated.openless_was_activated(), + activated.activation_failed_with_saved_profile(), + ), + ProfileRestoreDecision::RestoreSavedProfile ); - assert!(prepared.should_restore_when_active_profile_check_fails()); - assert!(activation_failed.should_restore_when_active_profile_check_fails()); - assert!(!PreparedWindowsImeSession::unavailable() - .should_restore_when_active_profile_check_fails()); + // 从未激活(unavailable)→ 保持现状。 + let unavailable = PreparedWindowsImeSession::unavailable(); + assert_eq!( + restore_decision( + unavailable.saved_profile.as_ref(), + unavailable.openless_was_activated(), + unavailable.activation_failed_with_saved_profile(), + ), + ProfileRestoreDecision::KeepCurrentProfile + ); } #[test] From 49dbd0bed4b0888eb651c842f61aa37a9d024e5f Mon Sep 17 00:00:00 2001 From: Chris233 Date: Tue, 4 Aug 2026 23:48:04 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(windows-ime):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E6=8A=BD=E7=A6=BB=E5=8F=AF=E6=B5=8B=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=20+=20=E9=87=8D=E8=AF=95=E7=AD=89=E5=BE=85=E8=AE=A9?= =?UTF-8?q?=E5=87=BA=20runtime=20=E7=BA=BF=E7=A8=8B=EF=BC=88#852=20?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=E8=B7=9F=E8=BF=9B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restore_profile 合并 TextService/KeyboardLayout 同形分支,差异收敛为参数 - 恢复重试等待在 tokio runtime 上改用 block_in_place 让出工作线程 - 粘滞防护/校验/重试抽为 run_restore_flow,可跨平台注入测试 - 新增 5 个恢复流程测试(粘滞跳过/一次成功/重试/restore 报错仍校验/探测报错) --- .../app/src-tauri/src/windows_ime_profile.rs | 77 +++--- .../app/src-tauri/src/windows_ime_session.rs | 221 ++++++++++++++---- 2 files changed, 220 insertions(+), 78 deletions(-) diff --git a/openless-all/app/src-tauri/src/windows_ime_profile.rs b/openless-all/app/src-tauri/src/windows_ime_profile.rs index e76028d37..4a28f5b11 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -447,48 +447,57 @@ mod windows_impl { // 现代调用(此前 legacy `?` 传播会让现代 ActivateProfile 根本不执行,恢复 // 整体失败)。任一成功即视为整体成功:legacy 成功 → OS 视觉层(语言指示器、 // 键盘事件路由)已切回;现代成功 → 会话级激活已切回。两者都失败才算失败。 + let lang_id = snapshot.lang_id(); + + // legacy 与现代共用同一组解析后的参数(TextService 为 CLSID + profile GUID, + // KeyboardLayout 为 HKL)。GUID 解析失败直接整体失败,与旧行为一致。 + let (profile_type, clsid, profile_guid, hkl) = modern_restore_args(snapshot)?; + + // legacy 步骤:先切语言,TextService 再激活具体 profile(KeyboardLayout 无 profile)。 + let legacy_result = with_input_processor_profiles(|profiles| unsafe { + profiles.ChangeCurrentLanguage(lang_id)?; + if profile_type == TF_PROFILETYPE_INPUTPROCESSOR { + profiles.ActivateLanguageProfile(&clsid, lang_id, &profile_guid)?; + } + Ok(()) + }); + let modern_result = with_profile_manager(|manager| unsafe { + manager.ActivateProfile( + profile_type, + lang_id, + &clsid, + &profile_guid, + hkl, + PROFILE_RESTORE_FLAGS, + ) + }); + report_restore_step_results(legacy_result, modern_result) + } + + /// modern ActivateProfile 的参数:TextService 用 CLSID + profile GUID,KeyboardLayout 用 HKL。 + fn modern_restore_args( + snapshot: &ImeProfileSnapshot, + ) -> WindowsImeProfileResult<(u32, GUID, GUID, HKL)> { match snapshot.kind() { ImeProfileKind::TextService => { let clsid = parse_required_guid("text service CLSID", snapshot.clsid())?; let profile_guid = parse_required_guid("text service profile GUID", snapshot.profile_guid())?; - let lang_id = snapshot.lang_id(); - - let legacy_result = with_input_processor_profiles(|profiles| unsafe { - profiles.ChangeCurrentLanguage(lang_id)?; - profiles.ActivateLanguageProfile(&clsid, lang_id, &profile_guid) - }); - let modern_result = with_profile_manager(|manager| unsafe { - manager.ActivateProfile( - TF_PROFILETYPE_INPUTPROCESSOR, - lang_id, - &clsid, - &profile_guid, - null_hkl(), - PROFILE_RESTORE_FLAGS, - ) - }); - report_restore_step_results(legacy_result, modern_result) + Ok(( + TF_PROFILETYPE_INPUTPROCESSOR, + clsid, + profile_guid, + null_hkl(), + )) } ImeProfileKind::KeyboardLayout => { let hkl = HKL(snapshot.hkl().unwrap_or_default() as *mut c_void); - let zero_guid = GUID::zeroed(); - let lang_id = snapshot.lang_id(); - - let legacy_result = with_input_processor_profiles(|profiles| unsafe { - profiles.ChangeCurrentLanguage(lang_id) - }); - let modern_result = with_profile_manager(|manager| unsafe { - manager.ActivateProfile( - TF_PROFILETYPE_KEYBOARDLAYOUT, - lang_id, - &zero_guid, - &zero_guid, - hkl, - PROFILE_RESTORE_FLAGS, - ) - }); - report_restore_step_results(legacy_result, modern_result) + Ok(( + TF_PROFILETYPE_KEYBOARDLAYOUT, + GUID::zeroed(), + GUID::zeroed(), + hkl, + )) } } } diff --git a/openless-all/app/src-tauri/src/windows_ime_session.rs b/openless-all/app/src-tauri/src/windows_ime_session.rs index 496e4349e..ced79b3ba 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -3,10 +3,13 @@ use crate::types::InsertStatus; use crate::windows_ime_ipc::{ImeSubmitRequest, WindowsImeIpcServer}; use crate::windows_ime_profile::{ is_openless_profile_snapshot, restore_decision, ImeProfileSnapshot, ProfileRestoreDecision, - WindowsImeProfileManager, + WindowsImeProfileError, WindowsImeProfileManager, WindowsImeProfileResult, }; use crate::windows_ime_protocol::ImeSubmitStatus; +/// 恢复后校验未生效时,重试前的等待时长。 +const RESTORE_RETRY_DELAY_MS: u64 = 250; + #[derive(Debug)] pub enum WindowsImeSessionError { Profile(String), @@ -44,6 +47,79 @@ fn describe_snapshot(snapshot: &ImeProfileSnapshot) -> String { ) } +/// 等待重试:在 tokio runtime 线程上执行时用 `block_in_place` 让出工作线程, +/// 避免阻塞 runtime 上其它任务;非 runtime 上下文(如纯同步调用链)直接 sleep。 +fn sleep_restore_retry(retry_delay: std::time::Duration) { + if tokio::runtime::Handle::try_current().is_ok() { + tokio::task::block_in_place(move || std::thread::sleep(retry_delay)); + } else { + std::thread::sleep(retry_delay); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RestoreOutcome { + /// saved 快照本身是 OpenLess(上次会话疑似未恢复)→ 跳过恢复。 + SkippedSticky, + /// 校验确认 OpenLess 已不再激活。 + Verified, + /// 两次尝试后 OpenLess 仍激活(或校验无法确认)。 + FailedAfterRetry, +} + +/// 恢复阶段完整流程:粘滞态防护 → 恢复 → 校验 → 一次重试。 +/// +/// 通过注入 `restore_profile` / `is_openless_active` 让校验-重试逻辑可在任意 +/// 平台被单元测试覆盖(生产路径由 `WindowsImeProfileManager` 提供实现)。 +fn run_restore_flow( + saved_profile: &ImeProfileSnapshot, + mut restore_profile: impl FnMut(&ImeProfileSnapshot) -> WindowsImeProfileResult<()>, + mut is_openless_active: impl FnMut() -> WindowsImeProfileResult, + retry_delay: std::time::Duration, +) -> RestoreOutcome { + // 粘滞态防护:saved 本身就是 OpenLess(上次会话疑似未恢复)→ 不把 OpenLess + // 当原输入法写死,跳过恢复并留下诊断日志。 + if is_openless_profile_snapshot(saved_profile) { + log::warn!( + "[windows-ime] saved profile is OpenLess itself — previous session likely failed to restore; skipping restore" + ); + return RestoreOutcome::SkippedSticky; + } + + // 第一次恢复 + 校验 + 一次重试:TSF 会话级切换偶发不生效时,短等待后重试一次。 + for attempt in 0..2 { + if attempt > 0 { + log::info!("[windows-ime] restore did not take effect; retrying (attempt {attempt})"); + sleep_restore_retry(retry_delay); + } + if let Err(error) = restore_profile(saved_profile) { + log::warn!("[windows-ime] restore saved profile failed (attempt {attempt}): {error}"); + } + match is_openless_active() { + Ok(false) => { + log::info!( + "[windows-ime] restore verified: OpenLess is no longer the active profile" + ); + return RestoreOutcome::Verified; + } + Ok(true) => { + log::warn!( + "[windows-ime] restore verification: OpenLess is still active (attempt {attempt})" + ); + } + Err(error) => { + log::warn!( + "[windows-ime] restore verification check failed (attempt {attempt}): {error}" + ); + } + } + } + log::error!( + "[windows-ime] restore did not take effect after retry — IME may remain on OpenLess" + ); + RestoreOutcome::FailedAfterRetry +} + #[derive(Debug)] pub struct PreparedWindowsImeSession { saved_profile: Option, @@ -187,49 +263,11 @@ impl WindowsImeSessionController { return; }; - // 粘滞态防护:saved 本身就是 OpenLess(上次会话疑似未恢复)→ 不把 OpenLess - // 当原输入法写死,跳过恢复并留下诊断日志。 - if is_openless_profile_snapshot(saved_profile) { - log::warn!( - "[windows-ime] saved profile is OpenLess itself — previous session likely failed to restore; skipping restore" - ); - return; - } - - // 第一次恢复 + 校验 + 一次重试:TSF 会话级切换偶发不生效时,短等待后重试一次。 - for attempt in 0..2 { - if attempt > 0 { - log::info!( - "[windows-ime] restore did not take effect; retrying (attempt {attempt})" - ); - std::thread::sleep(std::time::Duration::from_millis(250)); - } - if let Err(error) = self.profile_manager.restore_profile(saved_profile) { - log::warn!( - "[windows-ime] restore saved profile failed (attempt {attempt}): {error}" - ); - } - match self.profile_manager.is_openless_profile_active() { - Ok(false) => { - log::info!( - "[windows-ime] restore verified: OpenLess is no longer the active profile" - ); - return; - } - Ok(true) => { - log::warn!( - "[windows-ime] restore verification: OpenLess is still active (attempt {attempt})" - ); - } - Err(error) => { - log::warn!( - "[windows-ime] restore verification check failed (attempt {attempt}): {error}" - ); - } - } - } - log::error!( - "[windows-ime] restore did not take effect after retry — IME may remain on OpenLess" + run_restore_flow( + saved_profile, + |snapshot| self.profile_manager.restore_profile(snapshot), + || self.profile_manager.is_openless_profile_active(), + std::time::Duration::from_millis(RESTORE_RETRY_DELAY_MS), ); } } @@ -320,4 +358,99 @@ mod tests { assert!(!prepared.is_ready_for_tsf_submit()); assert!(prepared.activation_failed_with_saved_profile()); } + + #[test] + fn restore_flow_skips_when_saved_profile_is_openless_itself() { + // 粘滞态防护:saved 是 OpenLess → 跳过恢复,restore 不被调用(issue #852)。 + let mut restore_calls = 0; + let outcome = run_restore_flow( + &ImeProfileSnapshot::text_service( + 0x0804, + "{6b9f3f4f-5ee7-42d6-9c61-9f80b03a5d7d}".to_string(), + "{9b5f5e04-23f6-47da-9a26-d221f6c3f02e}".to_string(), + ), + |_| { + restore_calls += 1; + Ok(()) + }, + || Ok(false), + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::SkippedSticky); + assert_eq!(restore_calls, 0); + } + + #[test] + fn restore_flow_verifies_without_retry_when_openless_no_longer_active() { + let mut restore_calls = 0; + let outcome = run_restore_flow( + &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + |_| { + restore_calls += 1; + Ok(()) + }, + || Ok(false), + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::Verified); + assert_eq!(restore_calls, 1); + } + + #[test] + fn restore_flow_retries_once_when_openless_stays_active() { + let mut restore_calls = 0; + let outcome = run_restore_flow( + &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + |_| { + restore_calls += 1; + Ok(()) + }, + || Ok(true), + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::FailedAfterRetry); + assert_eq!(restore_calls, 2); + } + + #[test] + fn restore_flow_treats_restore_error_with_verified_profile_as_success() { + // legacy 已生效但 API 报错时,校验确认切走仍算成功(任一成功即整体成功)。 + let outcome = run_restore_flow( + &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + |_| { + Err(WindowsImeProfileError::WindowsApi( + "legacy failed".to_string(), + )) + }, + || Ok(false), + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::Verified); + } + + #[test] + fn restore_flow_retries_when_verification_check_errors() { + // 校验探测报错不能视为成功:重试一次后仍失败。 + let mut restore_calls = 0; + let outcome = run_restore_flow( + &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + |_| { + restore_calls += 1; + Ok(()) + }, + || { + Err(WindowsImeProfileError::WindowsApi( + "probe failed".to_string(), + )) + }, + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::FailedAfterRetry); + assert_eq!(restore_calls, 2); + } } From dbaea91beca150e0c047d287b30ee5e74b8cf9c6 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 5 Aug 2026 11:22:50 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(windows-ime):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E9=99=8D=E7=BA=A7=E4=B8=BA=E8=AF=8A=E6=96=AD?= =?UTF-8?q?=E6=97=A5=E5=BF=97=EF=BC=8C=E9=87=8D=E8=AF=95=E4=BE=9D=E6=8D=AE?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20restore=20=E8=BF=94=E5=9B=9E=E5=80=BC?= =?UTF-8?q?=EF=BC=88#852=20=E5=AE=A1=E6=9F=A5=E8=B7=9F=E8=BF=9B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run_restore_flow 重试条件改为 restore_profile 返回值(legacy+modern 均失败才重试) - is_openless_profile_active 探测降级为恢复后诊断日志,不再参与控制流(线程局限见 #852) - 更新恢复流程测试覆盖新语义(6 个用例,47/47 通过) --- .../app/src-tauri/src/windows_ime_session.rs | 122 +++++++++++++----- 1 file changed, 87 insertions(+), 35 deletions(-) diff --git a/openless-all/app/src-tauri/src/windows_ime_session.rs b/openless-all/app/src-tauri/src/windows_ime_session.rs index ced79b3ba..6debb967e 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -61,16 +61,20 @@ fn sleep_restore_retry(retry_delay: std::time::Duration) { pub(super) enum RestoreOutcome { /// saved 快照本身是 OpenLess(上次会话疑似未恢复)→ 跳过恢复。 SkippedSticky, - /// 校验确认 OpenLess 已不再激活。 + /// restore_profile 返回 Ok(首次或重试后)。 Verified, - /// 两次尝试后 OpenLess 仍激活(或校验无法确认)。 + /// 两次 restore_profile 均失败。 FailedAfterRetry, } -/// 恢复阶段完整流程:粘滞态防护 → 恢复 → 校验 → 一次重试。 +/// 恢复阶段完整流程:粘滞态防护 → 恢复 → 失败重试。 /// -/// 通过注入 `restore_profile` / `is_openless_active` 让校验-重试逻辑可在任意 -/// 平台被单元测试覆盖(生产路径由 `WindowsImeProfileManager` 提供实现)。 +/// 重试依据是 `restore_profile` 的返回值(legacy 与现代均失败才为 Err), +/// 不依赖 `is_openless_active` 探测:该探测(`GetActiveProfile`)运行在 +/// OpenLess 进程后台线程,与目标 App 线程的 TSF 状态可能不一致(issue #852), +/// 因此只保留为诊断日志,记录恢复后 OpenLess 是否仍激活,不参与控制流。 +/// 通过注入 `restore_profile` / `is_openless_active` 让该逻辑可在任意平台被 +/// 单元测试覆盖(生产路径由 `WindowsImeProfileManager` 提供实现)。 fn run_restore_flow( saved_profile: &ImeProfileSnapshot, mut restore_profile: impl FnMut(&ImeProfileSnapshot) -> WindowsImeProfileResult<()>, @@ -86,40 +90,60 @@ fn run_restore_flow( return RestoreOutcome::SkippedSticky; } - // 第一次恢复 + 校验 + 一次重试:TSF 会话级切换偶发不生效时,短等待后重试一次。 + // 第一次恢复 + 失败重试一次:TSF 会话级切换偶发失败时,短等待后重试一次。 + // 成功与否以 restore_profile 返回值为准;探测仅作诊断日志。 for attempt in 0..2 { if attempt > 0 { - log::info!("[windows-ime] restore did not take effect; retrying (attempt {attempt})"); + log::info!("[windows-ime] restore failed; retrying (attempt {attempt})"); sleep_restore_retry(retry_delay); } - if let Err(error) = restore_profile(saved_profile) { - log::warn!("[windows-ime] restore saved profile failed (attempt {attempt}): {error}"); - } - match is_openless_active() { - Ok(false) => { - log::info!( - "[windows-ime] restore verified: OpenLess is no longer the active profile" - ); + match restore_profile(saved_profile) { + Ok(()) => { + log::info!("[windows-ime] restore succeeded (attempt {attempt})"); + log_restore_verification(&mut is_openless_active, attempt); return RestoreOutcome::Verified; } - Ok(true) => { - log::warn!( - "[windows-ime] restore verification: OpenLess is still active (attempt {attempt})" - ); - } Err(error) => { log::warn!( - "[windows-ime] restore verification check failed (attempt {attempt}): {error}" + "[windows-ime] restore saved profile failed (attempt {attempt}): {error}" ); + log_restore_verification(&mut is_openless_active, attempt); } } } log::error!( - "[windows-ime] restore did not take effect after retry — IME may remain on OpenLess" + "[windows-ime] restore failed after retry — IME may remain on OpenLess" ); RestoreOutcome::FailedAfterRetry } +/// 恢复后的诊断探测(仅日志):记录 OpenLess 是否仍是当前 profile。 +/// +/// 该探测与决策/重试解耦——`GetActiveProfile` 运行在 OpenLess 进程后台线程, +/// 与目标 App 线程的 TSF 状态可能不一致(issue #852),结果不可作为控制流依据。 +fn log_restore_verification( + is_openless_active: &mut impl FnMut() -> WindowsImeProfileResult, + attempt: i32, +) { + match is_openless_active() { + Ok(false) => { + log::info!( + "[windows-ime] restore verification: OpenLess is no longer the active profile (attempt {attempt})" + ); + } + Ok(true) => { + log::warn!( + "[windows-ime] restore verification: OpenLess is still the active profile (attempt {attempt})" + ); + } + Err(error) => { + log::warn!( + "[windows-ime] restore verification check failed (attempt {attempt}): {error}" + ); + } + } +} + #[derive(Debug)] pub struct PreparedWindowsImeSession { saved_profile: Option, @@ -382,7 +406,7 @@ mod tests { } #[test] - fn restore_flow_verifies_without_retry_when_openless_no_longer_active() { + fn restore_flow_succeeds_without_retry_when_restore_returns_ok() { let mut restore_calls = 0; let outcome = run_restore_flow( &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), @@ -399,7 +423,8 @@ mod tests { } #[test] - fn restore_flow_retries_once_when_openless_stays_active() { + fn restore_flow_succeeds_even_when_probe_still_reports_openless() { + // 探测显示 OpenLess 仍激活不触发重试:成功与否以 restore 返回值为准(#852)。 let mut restore_calls = 0; let outcome = run_restore_flow( &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), @@ -411,42 +436,69 @@ mod tests { std::time::Duration::ZERO, ); - assert_eq!(outcome, RestoreOutcome::FailedAfterRetry); - assert_eq!(restore_calls, 2); + assert_eq!(outcome, RestoreOutcome::Verified); + assert_eq!(restore_calls, 1); } #[test] - fn restore_flow_treats_restore_error_with_verified_profile_as_success() { - // legacy 已生效但 API 报错时,校验确认切走仍算成功(任一成功即整体成功)。 + fn restore_flow_probe_errors_do_not_affect_outcome() { + // 探测报错仅记日志,不影响恢复成功判定。 + let mut restore_calls = 0; let outcome = run_restore_flow( &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), |_| { + restore_calls += 1; + Ok(()) + }, + || { Err(WindowsImeProfileError::WindowsApi( - "legacy failed".to_string(), + "probe failed".to_string(), )) }, - || Ok(false), std::time::Duration::ZERO, ); assert_eq!(outcome, RestoreOutcome::Verified); + assert_eq!(restore_calls, 1); } #[test] - fn restore_flow_retries_when_verification_check_errors() { - // 校验探测报错不能视为成功:重试一次后仍失败。 + fn restore_flow_retries_when_restore_fails_then_succeeds() { + // 首次 restore 失败 → 重试一次 → 成功。 let mut restore_calls = 0; let outcome = run_restore_flow( &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), |_| { restore_calls += 1; - Ok(()) + if restore_calls == 1 { + Err(WindowsImeProfileError::WindowsApi( + "transient failure".to_string(), + )) + } else { + Ok(()) + } }, - || { + || Ok(false), + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::Verified); + assert_eq!(restore_calls, 2); + } + + #[test] + fn restore_flow_fails_after_two_restore_errors() { + // 两次 restore 都失败 → 整体失败。 + let mut restore_calls = 0; + let outcome = run_restore_flow( + &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + |_| { + restore_calls += 1; Err(WindowsImeProfileError::WindowsApi( - "probe failed".to_string(), + "restore failed".to_string(), )) }, + || Ok(false), std::time::Duration::ZERO, ); From 7aa08a9e521a8f55369a0d62f79226005bab339b Mon Sep 17 00:00:00 2001 From: Chris233 Date: Wed, 5 Aug 2026 20:26:39 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor(windows-ime):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E7=AD=96=E7=95=A5=E5=B1=82=E8=BF=81=E5=85=A5=E8=B7=A8=E5=B9=B3?= =?UTF-8?q?=E5=8F=B0=E6=A8=A1=E5=9D=97=E5=B9=B6=E7=BA=B3=E5=85=A5=20CI=20?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=EF=BC=88#852=20=E5=AE=A1=E6=9F=A5=E8=B7=9F?= =?UTF-8?q?=E8=BF=9B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 windows_ime_restore 模块:run_restore_flow/RestoreOutcome/重试等待 迁出 windows_ime_session;sleep_restore_retry 增加 MultiThread runtime 守卫 - report_restore_step_results 提升为无门控顶层函数,测试可在任意平台执行 - restore_profile 参数收敛为 RestoreArgs 结构体(resolve_restore_args) - 测试夹具 openless_snapshot_for_test 由生产常量派生,替换重复 GUID 字面量 - restore_session 消费 RestoreOutcome 补 debug 诊断日志 - backend-tests 纳入 windows_ime_profile/windows_ime_restore(tokio/winreg/features) - 新增 9 个单测现已在 macOS/Linux cargo test --lib 与 Windows backend-tests 执行 --- .../app/src-tauri/backend-tests/Cargo.lock | 87 +++++++ .../app/src-tauri/backend-tests/Cargo.toml | 13 + .../backend-tests/tests/backend_rust.rs | 4 + openless-all/app/src-tauri/src/lib.rs | 1 + .../app/src-tauri/src/windows_ime_profile.rs | 187 +++++++------- .../app/src-tauri/src/windows_ime_restore.rs | 238 ++++++++++++++++++ .../app/src-tauri/src/windows_ime_session.rs | 236 +---------------- 7 files changed, 456 insertions(+), 310 deletions(-) create mode 100644 openless-all/app/src-tauri/src/windows_ime_restore.rs diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.lock b/openless-all/app/src-tauri/backend-tests/Cargo.lock index 186b3180b..08ea57d4c 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.lock +++ b/openless-all/app/src-tauri/backend-tests/Cargo.lock @@ -1103,8 +1103,10 @@ dependencies = [ "serde", "serde_json", "thiserror 1.0.69", + "tokio", "uuid", "windows 0.58.0", + "winreg", ] [[package]] @@ -1454,6 +1456,15 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -1830,6 +1841,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -1872,6 +1892,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -1911,6 +1946,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -1929,6 +1970,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -1947,6 +1994,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -1977,6 +2030,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -1995,6 +2054,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2013,6 +2078,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2031,6 +2102,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2052,6 +2129,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.toml b/openless-all/app/src-tauri/backend-tests/Cargo.toml index 357505224..fc4085e70 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.toml +++ b/openless-all/app/src-tauri/backend-tests/Cargo.toml @@ -22,13 +22,26 @@ rdev = "0.5" serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "1" +tokio = { version = "1", features = ["rt-multi-thread"] } uuid = { version = "1", features = ["v4", "serde"] } [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.58", features = [ "Win32_Foundation", + "Win32_Globalization", + "Win32_Graphics_Dwm", + "Win32_Graphics_Gdi", + "Win32_Media_Audio", + "Win32_Media_Audio_Endpoints", "Win32_Storage_FileSystem", + "Win32_System_Com", + "Win32_System_Ole", + "Win32_System_Registry", "Win32_System_Threading", + "Win32_UI_HiDpi", "Win32_UI_Input_KeyboardAndMouse", + "Win32_UI_Shell", + "Win32_UI_TextServices", "Win32_UI_WindowsAndMessaging", ] } +winreg = "0.52" diff --git a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs index 78c8f5ce0..6ce3580d2 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs @@ -67,3 +67,7 @@ mod types; #[cfg(target_os = "windows")] #[path = "../../src/unicode_keystroke.rs"] mod unicode_keystroke; +#[path = "../../src/windows_ime_profile.rs"] +mod windows_ime_profile; +#[path = "../../src/windows_ime_restore.rs"] +mod windows_ime_restore; diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 085ec111d..39d1507a1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -87,6 +87,7 @@ mod windows_ime_ipc; mod windows_ime_profile; #[cfg(target_os = "windows")] mod windows_ime_protocol; +mod windows_ime_restore; #[cfg(target_os = "windows")] mod windows_ime_session; diff --git a/openless-all/app/src-tauri/src/windows_ime_profile.rs b/openless-all/app/src-tauri/src/windows_ime_profile.rs index 4a28f5b11..548f3a901 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -92,6 +92,19 @@ pub fn is_openless_profile_snapshot(snapshot: &ImeProfileSnapshot) -> bool { == Some(OPENLESS_PROFILE_GUID_BRACED) } +/// 测试专用:构造 OpenLess 自己的 TSF 快照。 +/// +/// 标识由生产常量派生(转小写以覆盖 GUID 归一化路径),避免测试字面量与 +/// 生产常量漂移——若常量变更,测试仍会跟随验证新值。 +#[cfg(test)] +pub(crate) fn openless_snapshot_for_test() -> ImeProfileSnapshot { + ImeProfileSnapshot::text_service( + OPENLESS_TSF_LANG_ID, + OPENLESS_TEXT_SERVICE_CLSID_BRACED.to_ascii_lowercase(), + OPENLESS_PROFILE_GUID_BRACED.to_ascii_lowercase(), + ) +} + fn normalize_guid_string(value: &str) -> String { let upper = value.trim().to_ascii_uppercase(); if upper.starts_with('{') && upper.ends_with('}') { @@ -296,6 +309,30 @@ impl WindowsImeProfileManager { } } +/// 汇总 legacy 与现代两条恢复路径的结果:任一成功即视为整体成功, +/// 两者都失败才算失败,并分别记录失败原因。 +pub(super) fn report_restore_step_results( + legacy_result: WindowsImeProfileResult<()>, + modern_result: WindowsImeProfileResult<()>, +) -> WindowsImeProfileResult<()> { + if let Err(error) = &legacy_result { + log::warn!( + "[windows-ime] legacy restore failed (ChangeCurrentLanguage/ActivateLanguageProfile): {error}" + ); + } + if let Err(error) = &modern_result { + log::warn!("[windows-ime] modern ActivateProfile failed: {error}"); + } + match (legacy_result, modern_result) { + (Ok(()), _) | (_, Ok(())) => Ok(()), + (Err(legacy_error), Err(modern_error)) => Err(WindowsImeProfileError::WindowsApi( + format!( + "both legacy and modern restore failed: legacy={legacy_error}; modern={modern_error}" + ), + )), + } +} + #[cfg(target_os = "windows")] mod windows_impl { use super::*; @@ -451,79 +488,63 @@ mod windows_impl { // legacy 与现代共用同一组解析后的参数(TextService 为 CLSID + profile GUID, // KeyboardLayout 为 HKL)。GUID 解析失败直接整体失败,与旧行为一致。 - let (profile_type, clsid, profile_guid, hkl) = modern_restore_args(snapshot)?; + let args = resolve_restore_args(snapshot)?; // legacy 步骤:先切语言,TextService 再激活具体 profile(KeyboardLayout 无 profile)。 let legacy_result = with_input_processor_profiles(|profiles| unsafe { profiles.ChangeCurrentLanguage(lang_id)?; - if profile_type == TF_PROFILETYPE_INPUTPROCESSOR { - profiles.ActivateLanguageProfile(&clsid, lang_id, &profile_guid)?; + if args.profile_type == TF_PROFILETYPE_INPUTPROCESSOR { + profiles.ActivateLanguageProfile(&args.clsid, lang_id, &args.profile_guid)?; } Ok(()) }); let modern_result = with_profile_manager(|manager| unsafe { manager.ActivateProfile( - profile_type, + args.profile_type, lang_id, - &clsid, - &profile_guid, - hkl, + &args.clsid, + &args.profile_guid, + args.hkl, PROFILE_RESTORE_FLAGS, ) }); report_restore_step_results(legacy_result, modern_result) } - /// modern ActivateProfile 的参数:TextService 用 CLSID + profile GUID,KeyboardLayout 用 HKL。 - fn modern_restore_args( - snapshot: &ImeProfileSnapshot, - ) -> WindowsImeProfileResult<(u32, GUID, GUID, HKL)> { + /// 单次 restore 所需的解析后参数(legacy 与现代路径共用)。 + struct RestoreArgs { + profile_type: u32, + clsid: GUID, + profile_guid: GUID, + hkl: HKL, + } + + /// 解析 restore 参数:TextService 用 CLSID + profile GUID,KeyboardLayout 用 HKL。 + fn resolve_restore_args(snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult { match snapshot.kind() { ImeProfileKind::TextService => { let clsid = parse_required_guid("text service CLSID", snapshot.clsid())?; let profile_guid = parse_required_guid("text service profile GUID", snapshot.profile_guid())?; - Ok(( - TF_PROFILETYPE_INPUTPROCESSOR, + Ok(RestoreArgs { + profile_type: TF_PROFILETYPE_INPUTPROCESSOR, clsid, profile_guid, - null_hkl(), - )) + hkl: null_hkl(), + }) } ImeProfileKind::KeyboardLayout => { let hkl = HKL(snapshot.hkl().unwrap_or_default() as *mut c_void); - Ok(( - TF_PROFILETYPE_KEYBOARDLAYOUT, - GUID::zeroed(), - GUID::zeroed(), + Ok(RestoreArgs { + profile_type: TF_PROFILETYPE_KEYBOARDLAYOUT, + clsid: GUID::zeroed(), + profile_guid: GUID::zeroed(), hkl, - )) + }) } } } - pub(super) fn report_restore_step_results( - legacy_result: WindowsImeProfileResult<()>, - modern_result: WindowsImeProfileResult<()>, - ) -> WindowsImeProfileResult<()> { - if let Err(error) = &legacy_result { - log::warn!( - "[windows-ime] legacy restore failed (ChangeCurrentLanguage/ActivateLanguageProfile): {error}" - ); - } - if let Err(error) = &modern_result { - log::warn!("[windows-ime] modern ActivateProfile failed: {error}"); - } - match (legacy_result, modern_result) { - (Ok(()), _) | (_, Ok(())) => Ok(()), - (Err(legacy_error), Err(modern_error)) => Err(WindowsImeProfileError::WindowsApi( - format!( - "both legacy and modern restore failed: legacy={legacy_error}; modern={modern_error}" - ), - )), - } - } - pub fn is_openless_profile_active() -> WindowsImeProfileResult { let snapshot = capture_active_profile()?; Ok(is_openless_profile_snapshot(&snapshot)) @@ -842,11 +863,7 @@ mod tests { #[test] fn openless_snapshot_detection_matches_exact_profile_identifiers() { // 大小写与花括号不同的 GUID 也应被归一化后识别为 OpenLess(粘滞态防护)。 - let openless = ImeProfileSnapshot::text_service( - 0x0804, - "{6b9f3f4f-5ee7-42d6-9c61-9f80b03a5d7d}".to_string(), - "{9b5f5e04-23f6-47da-9a26-d221f6c3f02e}".to_string(), - ); + let openless = openless_snapshot_for_test(); assert!(is_openless_profile_snapshot(&openless)); let other_ime = text_service_snapshot(); @@ -935,6 +952,44 @@ mod tests { None ); } + + #[test] + fn restore_step_results_ok_when_modern_succeeds_after_legacy_failure() { + let result = report_restore_step_results( + Err(WindowsImeProfileError::WindowsApi( + "legacy failed".to_string(), + )), + Ok(()), + ); + assert!(result.is_ok()); + } + + #[test] + fn restore_step_results_ok_when_legacy_succeeds_and_modern_fails() { + let result = report_restore_step_results( + Ok(()), + Err(WindowsImeProfileError::WindowsApi( + "modern failed".to_string(), + )), + ); + assert!(result.is_ok()); + } + + #[test] + fn restore_step_results_err_only_when_both_fail() { + let result = report_restore_step_results( + Err(WindowsImeProfileError::WindowsApi( + "legacy failed".to_string(), + )), + Err(WindowsImeProfileError::WindowsApi( + "modern failed".to_string(), + )), + ); + let err = result.unwrap_err(); + assert!(err + .to_string() + .contains("both legacy and modern restore failed")); + } } #[cfg(all(test, target_os = "windows"))] @@ -1005,42 +1060,4 @@ mod windows_tests { assert!(!ownership.should_uninitialize); } - - #[test] - fn restore_step_results_ok_when_modern_succeeds_after_legacy_failure() { - let result = windows_impl::report_restore_step_results( - Err(WindowsImeProfileError::WindowsApi( - "legacy failed".to_string(), - )), - Ok(()), - ); - assert!(result.is_ok()); - } - - #[test] - fn restore_step_results_ok_when_legacy_succeeds_and_modern_fails() { - let result = windows_impl::report_restore_step_results( - Ok(()), - Err(WindowsImeProfileError::WindowsApi( - "modern failed".to_string(), - )), - ); - assert!(result.is_ok()); - } - - #[test] - fn restore_step_results_err_only_when_both_fail() { - let result = windows_impl::report_restore_step_results( - Err(WindowsImeProfileError::WindowsApi( - "legacy failed".to_string(), - )), - Err(WindowsImeProfileError::WindowsApi( - "modern failed".to_string(), - )), - ); - let err = result.unwrap_err(); - assert!(err - .to_string() - .contains("both legacy and modern restore failed")); - } } diff --git a/openless-all/app/src-tauri/src/windows_ime_restore.rs b/openless-all/app/src-tauri/src/windows_ime_restore.rs new file mode 100644 index 000000000..1082de7ff --- /dev/null +++ b/openless-all/app/src-tauri/src/windows_ime_restore.rs @@ -0,0 +1,238 @@ +#![allow(dead_code, unused_imports, unused_variables)] + +use crate::windows_ime_profile::{ + is_openless_profile_snapshot, ImeProfileSnapshot, WindowsImeProfileResult, +}; + +/// `restore_profile` 返回失败(legacy 与现代均失败)后,重试前的等待时长。 +pub const RESTORE_RETRY_DELAY_MS: u64 = 250; + +/// 等待重试:在多线程 tokio runtime 上执行时用 `block_in_place` 让出工作线程, +/// 避免阻塞 runtime 上其它任务;其它上下文(current-thread runtime、非 runtime +/// 线程)直接 sleep,避免 current-thread runtime 下 `block_in_place` panic。 +fn sleep_restore_retry(retry_delay: std::time::Duration) { + let on_multi_thread_runtime = tokio::runtime::Handle::try_current() + .map(|handle| handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread) + .unwrap_or(false); + if on_multi_thread_runtime { + tokio::task::block_in_place(move || std::thread::sleep(retry_delay)); + } else { + std::thread::sleep(retry_delay); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RestoreOutcome { + /// saved 快照本身是 OpenLess(上次会话疑似未恢复)→ 跳过恢复。 + SkippedSticky, + /// restore_profile 返回 Ok(首次或重试后)。 + Verified, + /// 两次 restore_profile 均失败。 + FailedAfterRetry, +} + +/// 恢复阶段完整流程:粘滞态防护 → 恢复 → 失败重试。 +/// +/// 重试依据是 `restore_profile` 的返回值(legacy 与现代均失败才为 Err), +/// 不依赖 `is_openless_active` 探测:该探测(`GetActiveProfile`)运行在 +/// OpenLess 进程后台线程,与目标 App 线程的 TSF 状态可能不一致(issue #852), +/// 因此只保留为诊断日志,记录恢复后 OpenLess 是否仍激活,不参与控制流。 +/// 通过注入 `restore_profile` / `is_openless_active` 让该逻辑可在任意平台被 +/// 单元测试覆盖(生产路径由 `WindowsImeProfileManager` 提供实现)。 +/// +/// 已知限制:恢复是无条件的——即使会话中途用户手动切走了输入法,结束时仍会 +/// 恢复到会话前快照(旧版依赖的 `GetActiveProfile` 探测在 OpenLess 进程后台 +/// 线程下不可靠,不能作为控制流依据,issue #852)。 +pub(super) fn run_restore_flow( + saved_profile: &ImeProfileSnapshot, + mut restore_profile: impl FnMut(&ImeProfileSnapshot) -> WindowsImeProfileResult<()>, + mut is_openless_active: impl FnMut() -> WindowsImeProfileResult, + retry_delay: std::time::Duration, +) -> RestoreOutcome { + // 粘滞态防护:saved 本身就是 OpenLess(上次会话疑似未恢复)→ 不把 OpenLess + // 当原输入法写死,跳过恢复并留下诊断日志。 + if is_openless_profile_snapshot(saved_profile) { + log::warn!( + "[windows-ime] saved profile is OpenLess itself — previous session likely failed to restore; skipping restore" + ); + return RestoreOutcome::SkippedSticky; + } + + // 第一次恢复 + 失败重试一次:TSF 会话级切换偶发失败时,短等待后重试一次。 + // 成功与否以 restore_profile 返回值为准;探测仅作诊断日志。 + for attempt in 0..2 { + if attempt > 0 { + log::info!("[windows-ime] restore failed; retrying (attempt {attempt})"); + sleep_restore_retry(retry_delay); + } + match restore_profile(saved_profile) { + Ok(()) => { + log::info!("[windows-ime] restore succeeded (attempt {attempt})"); + log_restore_verification(&mut is_openless_active, attempt); + return RestoreOutcome::Verified; + } + Err(error) => { + log::warn!( + "[windows-ime] restore saved profile failed (attempt {attempt}): {error}" + ); + log_restore_verification(&mut is_openless_active, attempt); + } + } + } + log::error!( + "[windows-ime] restore failed after retry — IME may remain on OpenLess" + ); + RestoreOutcome::FailedAfterRetry +} + +/// 恢复后的诊断探测(仅日志):记录 OpenLess 是否仍是当前 profile。 +/// +/// 该探测与决策/重试解耦——`GetActiveProfile` 运行在 OpenLess 进程后台线程, +/// 与目标 App 线程的 TSF 状态可能不一致(issue #852),结果不可作为控制流依据。 +fn log_restore_verification( + is_openless_active: &mut impl FnMut() -> WindowsImeProfileResult, + attempt: i32, +) { + match is_openless_active() { + Ok(false) => { + log::info!( + "[windows-ime] restore verification: OpenLess is no longer the active profile (attempt {attempt})" + ); + } + Ok(true) => { + log::warn!( + "[windows-ime] restore verification: OpenLess is still the active profile (attempt {attempt})" + ); + } + Err(error) => { + log::warn!( + "[windows-ime] restore verification check failed (attempt {attempt}): {error}" + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::windows_ime_profile::{openless_snapshot_for_test, WindowsImeProfileError}; + + #[test] + fn restore_flow_skips_when_saved_profile_is_openless_itself() { + // 粘滞态防护:saved 是 OpenLess → 跳过恢复,restore 不被调用(issue #852)。 + let mut restore_calls = 0; + let outcome = run_restore_flow( + &openless_snapshot_for_test(), + |_| { + restore_calls += 1; + Ok(()) + }, + || Ok(false), + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::SkippedSticky); + assert_eq!(restore_calls, 0); + } + + #[test] + fn restore_flow_succeeds_without_retry_when_restore_returns_ok() { + let mut restore_calls = 0; + let outcome = run_restore_flow( + &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + |_| { + restore_calls += 1; + Ok(()) + }, + || Ok(false), + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::Verified); + assert_eq!(restore_calls, 1); + } + + #[test] + fn restore_flow_succeeds_even_when_probe_still_reports_openless() { + // 探测显示 OpenLess 仍激活不触发重试:成功与否以 restore 返回值为准(#852)。 + let mut restore_calls = 0; + let outcome = run_restore_flow( + &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + |_| { + restore_calls += 1; + Ok(()) + }, + || Ok(true), + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::Verified); + assert_eq!(restore_calls, 1); + } + + #[test] + fn restore_flow_probe_errors_do_not_affect_outcome() { + // 探测报错仅记日志,不影响恢复成功判定。 + let mut restore_calls = 0; + let outcome = run_restore_flow( + &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + |_| { + restore_calls += 1; + Ok(()) + }, + || { + Err(WindowsImeProfileError::WindowsApi( + "probe failed".to_string(), + )) + }, + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::Verified); + assert_eq!(restore_calls, 1); + } + + #[test] + fn restore_flow_retries_when_restore_fails_then_succeeds() { + // 首次 restore 失败 → 重试一次 → 成功。 + let mut restore_calls = 0; + let outcome = run_restore_flow( + &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + |_| { + restore_calls += 1; + if restore_calls == 1 { + Err(WindowsImeProfileError::WindowsApi( + "transient failure".to_string(), + )) + } else { + Ok(()) + } + }, + || Ok(false), + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::Verified); + assert_eq!(restore_calls, 2); + } + + #[test] + fn restore_flow_fails_after_two_restore_errors() { + // 两次 restore 都失败 → 整体失败。 + let mut restore_calls = 0; + let outcome = run_restore_flow( + &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), + |_| { + restore_calls += 1; + Err(WindowsImeProfileError::WindowsApi( + "restore failed".to_string(), + )) + }, + || Ok(false), + std::time::Duration::ZERO, + ); + + assert_eq!(outcome, RestoreOutcome::FailedAfterRetry); + assert_eq!(restore_calls, 2); + } +} diff --git a/openless-all/app/src-tauri/src/windows_ime_session.rs b/openless-all/app/src-tauri/src/windows_ime_session.rs index 6debb967e..a0104ec7e 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -3,12 +3,10 @@ use crate::types::InsertStatus; use crate::windows_ime_ipc::{ImeSubmitRequest, WindowsImeIpcServer}; use crate::windows_ime_profile::{ is_openless_profile_snapshot, restore_decision, ImeProfileSnapshot, ProfileRestoreDecision, - WindowsImeProfileError, WindowsImeProfileManager, WindowsImeProfileResult, + WindowsImeProfileManager, }; use crate::windows_ime_protocol::ImeSubmitStatus; - -/// 恢复后校验未生效时,重试前的等待时长。 -const RESTORE_RETRY_DELAY_MS: u64 = 250; +use crate::windows_ime_restore::{run_restore_flow, RESTORE_RETRY_DELAY_MS}; #[derive(Debug)] pub enum WindowsImeSessionError { @@ -47,103 +45,6 @@ fn describe_snapshot(snapshot: &ImeProfileSnapshot) -> String { ) } -/// 等待重试:在 tokio runtime 线程上执行时用 `block_in_place` 让出工作线程, -/// 避免阻塞 runtime 上其它任务;非 runtime 上下文(如纯同步调用链)直接 sleep。 -fn sleep_restore_retry(retry_delay: std::time::Duration) { - if tokio::runtime::Handle::try_current().is_ok() { - tokio::task::block_in_place(move || std::thread::sleep(retry_delay)); - } else { - std::thread::sleep(retry_delay); - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum RestoreOutcome { - /// saved 快照本身是 OpenLess(上次会话疑似未恢复)→ 跳过恢复。 - SkippedSticky, - /// restore_profile 返回 Ok(首次或重试后)。 - Verified, - /// 两次 restore_profile 均失败。 - FailedAfterRetry, -} - -/// 恢复阶段完整流程:粘滞态防护 → 恢复 → 失败重试。 -/// -/// 重试依据是 `restore_profile` 的返回值(legacy 与现代均失败才为 Err), -/// 不依赖 `is_openless_active` 探测:该探测(`GetActiveProfile`)运行在 -/// OpenLess 进程后台线程,与目标 App 线程的 TSF 状态可能不一致(issue #852), -/// 因此只保留为诊断日志,记录恢复后 OpenLess 是否仍激活,不参与控制流。 -/// 通过注入 `restore_profile` / `is_openless_active` 让该逻辑可在任意平台被 -/// 单元测试覆盖(生产路径由 `WindowsImeProfileManager` 提供实现)。 -fn run_restore_flow( - saved_profile: &ImeProfileSnapshot, - mut restore_profile: impl FnMut(&ImeProfileSnapshot) -> WindowsImeProfileResult<()>, - mut is_openless_active: impl FnMut() -> WindowsImeProfileResult, - retry_delay: std::time::Duration, -) -> RestoreOutcome { - // 粘滞态防护:saved 本身就是 OpenLess(上次会话疑似未恢复)→ 不把 OpenLess - // 当原输入法写死,跳过恢复并留下诊断日志。 - if is_openless_profile_snapshot(saved_profile) { - log::warn!( - "[windows-ime] saved profile is OpenLess itself — previous session likely failed to restore; skipping restore" - ); - return RestoreOutcome::SkippedSticky; - } - - // 第一次恢复 + 失败重试一次:TSF 会话级切换偶发失败时,短等待后重试一次。 - // 成功与否以 restore_profile 返回值为准;探测仅作诊断日志。 - for attempt in 0..2 { - if attempt > 0 { - log::info!("[windows-ime] restore failed; retrying (attempt {attempt})"); - sleep_restore_retry(retry_delay); - } - match restore_profile(saved_profile) { - Ok(()) => { - log::info!("[windows-ime] restore succeeded (attempt {attempt})"); - log_restore_verification(&mut is_openless_active, attempt); - return RestoreOutcome::Verified; - } - Err(error) => { - log::warn!( - "[windows-ime] restore saved profile failed (attempt {attempt}): {error}" - ); - log_restore_verification(&mut is_openless_active, attempt); - } - } - } - log::error!( - "[windows-ime] restore failed after retry — IME may remain on OpenLess" - ); - RestoreOutcome::FailedAfterRetry -} - -/// 恢复后的诊断探测(仅日志):记录 OpenLess 是否仍是当前 profile。 -/// -/// 该探测与决策/重试解耦——`GetActiveProfile` 运行在 OpenLess 进程后台线程, -/// 与目标 App 线程的 TSF 状态可能不一致(issue #852),结果不可作为控制流依据。 -fn log_restore_verification( - is_openless_active: &mut impl FnMut() -> WindowsImeProfileResult, - attempt: i32, -) { - match is_openless_active() { - Ok(false) => { - log::info!( - "[windows-ime] restore verification: OpenLess is no longer the active profile (attempt {attempt})" - ); - } - Ok(true) => { - log::warn!( - "[windows-ime] restore verification: OpenLess is still the active profile (attempt {attempt})" - ); - } - Err(error) => { - log::warn!( - "[windows-ime] restore verification check failed (attempt {attempt}): {error}" - ); - } - } -} - #[derive(Debug)] pub struct PreparedWindowsImeSession { saved_profile: Option, @@ -259,6 +160,11 @@ impl WindowsImeSessionController { Ok(map_ime_status_to_insert_status(status)) } + /// 恢复会话前的输入法。 + /// + /// 已知限制:恢复是无条件的——会话中途用户手动切走的输入法也会在结束时被 + /// 覆盖为会话前快照(`GetActiveProfile` 探测在 OpenLess 进程后台线程下不可靠, + /// 不能作为控制流依据,issue #852)。 pub fn restore_session(&self, prepared: PreparedWindowsImeSession) { let saved_profile = prepared.saved_profile.as_ref(); let openless_was_activated = prepared.openless_was_activated(); @@ -287,12 +193,15 @@ impl WindowsImeSessionController { return; }; - run_restore_flow( + // 恢复流程(粘滞防护/重试/诊断)实现在 windows_ime_restore,可跨平台单测。 + // outcome 仅补一条 debug 诊断;成功/失败/跳过的详情已由流程内部日志输出。 + let outcome = run_restore_flow( saved_profile, |snapshot| self.profile_manager.restore_profile(snapshot), || self.profile_manager.is_openless_profile_active(), std::time::Duration::from_millis(RESTORE_RETRY_DELAY_MS), ); + log::debug!("[windows-ime] restore outcome: {outcome:?}"); } } @@ -382,127 +291,4 @@ mod tests { assert!(!prepared.is_ready_for_tsf_submit()); assert!(prepared.activation_failed_with_saved_profile()); } - - #[test] - fn restore_flow_skips_when_saved_profile_is_openless_itself() { - // 粘滞态防护:saved 是 OpenLess → 跳过恢复,restore 不被调用(issue #852)。 - let mut restore_calls = 0; - let outcome = run_restore_flow( - &ImeProfileSnapshot::text_service( - 0x0804, - "{6b9f3f4f-5ee7-42d6-9c61-9f80b03a5d7d}".to_string(), - "{9b5f5e04-23f6-47da-9a26-d221f6c3f02e}".to_string(), - ), - |_| { - restore_calls += 1; - Ok(()) - }, - || Ok(false), - std::time::Duration::ZERO, - ); - - assert_eq!(outcome, RestoreOutcome::SkippedSticky); - assert_eq!(restore_calls, 0); - } - - #[test] - fn restore_flow_succeeds_without_retry_when_restore_returns_ok() { - let mut restore_calls = 0; - let outcome = run_restore_flow( - &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), - |_| { - restore_calls += 1; - Ok(()) - }, - || Ok(false), - std::time::Duration::ZERO, - ); - - assert_eq!(outcome, RestoreOutcome::Verified); - assert_eq!(restore_calls, 1); - } - - #[test] - fn restore_flow_succeeds_even_when_probe_still_reports_openless() { - // 探测显示 OpenLess 仍激活不触发重试:成功与否以 restore 返回值为准(#852)。 - let mut restore_calls = 0; - let outcome = run_restore_flow( - &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), - |_| { - restore_calls += 1; - Ok(()) - }, - || Ok(true), - std::time::Duration::ZERO, - ); - - assert_eq!(outcome, RestoreOutcome::Verified); - assert_eq!(restore_calls, 1); - } - - #[test] - fn restore_flow_probe_errors_do_not_affect_outcome() { - // 探测报错仅记日志,不影响恢复成功判定。 - let mut restore_calls = 0; - let outcome = run_restore_flow( - &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), - |_| { - restore_calls += 1; - Ok(()) - }, - || { - Err(WindowsImeProfileError::WindowsApi( - "probe failed".to_string(), - )) - }, - std::time::Duration::ZERO, - ); - - assert_eq!(outcome, RestoreOutcome::Verified); - assert_eq!(restore_calls, 1); - } - - #[test] - fn restore_flow_retries_when_restore_fails_then_succeeds() { - // 首次 restore 失败 → 重试一次 → 成功。 - let mut restore_calls = 0; - let outcome = run_restore_flow( - &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), - |_| { - restore_calls += 1; - if restore_calls == 1 { - Err(WindowsImeProfileError::WindowsApi( - "transient failure".to_string(), - )) - } else { - Ok(()) - } - }, - || Ok(false), - std::time::Duration::ZERO, - ); - - assert_eq!(outcome, RestoreOutcome::Verified); - assert_eq!(restore_calls, 2); - } - - #[test] - fn restore_flow_fails_after_two_restore_errors() { - // 两次 restore 都失败 → 整体失败。 - let mut restore_calls = 0; - let outcome = run_restore_flow( - &ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), - |_| { - restore_calls += 1; - Err(WindowsImeProfileError::WindowsApi( - "restore failed".to_string(), - )) - }, - || Ok(false), - std::time::Duration::ZERO, - ); - - assert_eq!(outcome, RestoreOutcome::FailedAfterRetry); - assert_eq!(restore_calls, 2); - } }