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 3313f0fb6..548f3a901 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,62 @@ 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) +} + +/// 测试专用:构造 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('}') { + 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 @@ -259,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::*; @@ -406,81 +480,74 @@ 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 视觉层(语言指示器、 + // 键盘事件路由)已切回;现代成功 → 会话级激活已切回。两者都失败才算失败。 + let lang_id = snapshot.lang_id(); + + // legacy 与现代共用同一组解析后的参数(TextService 为 CLSID + profile GUID, + // KeyboardLayout 为 HKL)。GUID 解析失败直接整体失败,与旧行为一致。 + 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 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( + args.profile_type, + lang_id, + &args.clsid, + &args.profile_guid, + args.hkl, + PROFILE_RESTORE_FLAGS, + ) + }); + report_restore_step_results(legacy_result, modern_result) + } + + /// 单次 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())?; - let lang_id = snapshot.lang_id(); - - 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, - ) - }); - if let Err(err) = modern_result { - log::warn!( - "[windows-ime] legacy restore OK but modern ActivateProfile failed: {err}" - ); - } - Ok(()) + Ok(RestoreArgs { + profile_type: TF_PROFILETYPE_INPUTPROCESSOR, + clsid, + profile_guid, + hkl: 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(); - - 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, - ) - }); - if let Err(err) = modern_result { - log::warn!( - "[windows-ime] legacy restore OK but modern ActivateProfile (keyboard) failed: {err}" - ); - } - Ok(()) + Ok(RestoreArgs { + profile_type: TF_PROFILETYPE_KEYBOARDLAYOUT, + clsid: GUID::zeroed(), + profile_guid: GUID::zeroed(), + hkl, + }) } } } 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 +773,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 +829,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 +853,26 @@ 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 = openless_snapshot_for_test(); + 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 { @@ -881,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"))] 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 e3aa6412e..a0104ec7e 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -2,9 +2,11 @@ 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; +use crate::windows_ime_restore::{run_restore_flow, RESTORE_RETRY_DELAY_MS}; #[derive(Debug)] pub enum WindowsImeSessionError { @@ -33,6 +35,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 +78,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 +108,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), @@ -143,37 +160,48 @@ impl WindowsImeSessionController { Ok(map_ime_status_to_insert_status(status)) } + /// 恢复会话前的输入法。 + /// + /// 已知限制:恢复是无条件的——会话中途用户手动切走的输入法也会在结束时被 + /// 覆盖为会话前快照(`GetActiveProfile` 探测在 OpenLess 进程后台线程下不可靠, + /// 不能作为控制流依据,issue #852)。 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}"); - } + // 恢复流程(粘滞防护/重试/诊断)实现在 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:?}"); } } @@ -225,19 +253,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]