diff --git a/openless-all/app/src-tauri/src/windows_ime_ipc.rs b/openless-all/app/src-tauri/src/windows_ime_ipc.rs index 79f6ae5d..bfd5e6d0 100644 --- a/openless-all/app/src-tauri/src/windows_ime_ipc.rs +++ b/openless-all/app/src-tauri/src/windows_ime_ipc.rs @@ -24,6 +24,10 @@ const NMPWAIT_NOWAIT: u32 = 0x00000001; #[derive(Debug, Clone, PartialEq, Eq)] pub enum WindowsImeIpcError { + // Constructed only on the non-Windows path of `submit_text`; on Windows the + // active arms map to NoReadyClient/Timeout/etc. Keep the variant for the + // cross-platform stub. + #[allow(dead_code)] Unavailable(String), NoReadyClient, Timeout, @@ -113,13 +117,17 @@ pub struct ImeSubmitTarget { pub thread_id: u32, } +// Fields are exercised only via the test helpers below; keep dead_code allow +// scoped narrowly so the production path stays warning-clean. #[derive(Clone)] pub struct WindowsImeIpcServer { + #[allow(dead_code)] inner: std::sync::Arc>, } #[derive(Debug, Default)] struct WindowsImeIpcState { + #[allow(dead_code)] ready_client_id: Option, } @@ -130,10 +138,12 @@ impl WindowsImeIpcServer { } } + #[allow(dead_code)] pub fn mark_client_ready_for_test(&self, client_id: String) { self.inner.lock().ready_client_id = Some(client_id); } + #[allow(dead_code)] pub fn has_ready_client(&self) -> bool { self.inner.lock().ready_client_id.is_some() } 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 beb3c9f0..06b56c26 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -1,7 +1,48 @@ -pub const OPENLESS_TSF_LANG_ID: u16 = 0x0804; pub const OPENLESS_TEXT_SERVICE_CLSID_BRACED: &str = "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; pub const OPENLESS_PROFILE_GUID_BRACED: &str = "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; +/// Resolve the host UI lang id at runtime. +/// +/// On Windows we ask `GetUserDefaultUILanguage`; on other platforms (and as a +/// safe fallback) we return 0x0409 (en-US) so that callers downstream can do +/// a "non-zh" branch decision without panicking. +#[cfg(target_os = "windows")] +pub fn host_ui_lang_id() -> u16 { + // SAFETY: GetUserDefaultUILanguage takes no arguments and always succeeds. + unsafe { windows::Win32::Globalization::GetUserDefaultUILanguage() } +} + +#[cfg(not(target_os = "windows"))] +#[allow(dead_code)] +pub fn host_ui_lang_id() -> u16 { + 0x0409 +} + +/// The lang id under which we activate the OpenLess TSF profile. +/// +/// Historically this was a hard-coded `0x0804` (zh-CN), which on non-zh hosts +/// (notably Japanese hosts running ATOK or Microsoft IME) caused the active +/// IME to get hijacked into a Chinese-input profile. We now resolve this from +/// the host UI language at runtime: zh hosts get `0x0804` so the existing +/// activation path continues to work; non-zh hosts get their own host lang id. +/// +/// Note: the bundled C++ TSF DLL still ships with `kOpenLessLangId = 0x0804` +/// in `guids.h`. That's a follow-up — see the prepare_session log line in +/// `windows_ime_session.rs`. With this PR Rust no longer fights the DLL on zh +/// hosts (the values match), and on non-zh hosts the prepare path returns +/// `unavailable()` before we ever reach this function. +#[allow(dead_code)] +pub fn openless_tsf_lang_id() -> u16 { + let host = host_ui_lang_id(); + let primary = host & 0x03FF; + const PRIMARY_LANG_CHINESE: u16 = 0x04; + if primary == PRIMARY_LANG_CHINESE { + 0x0804 + } else { + host + } +} + use crate::types::{WindowsImeInstallState, WindowsImeStatus}; #[cfg(target_os = "windows")] @@ -88,6 +129,8 @@ pub fn restore_decision( #[derive(Debug, Clone, PartialEq, Eq)] pub enum WindowsImeProfileError { + // TSF path is force-disabled until per-host lang_id is wired in. See windows_ime_session.rs::prepare_session. + #[allow(dead_code)] Unavailable(String), WindowsApi(String), } @@ -130,10 +173,12 @@ impl WindowsImeProfileManager { Self } + #[allow(dead_code)] pub fn capture_active_profile(&self) -> WindowsImeProfileResult { windows_impl::capture_active_profile() } + #[allow(dead_code)] pub fn activate_openless_profile(&self) -> WindowsImeProfileResult<()> { windows_impl::activate_openless_profile() } @@ -207,6 +252,7 @@ mod windows_impl { const OPENLESS_TSF_KEYBOARD_CATEGORY_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{34745C63-B2F0-4784-8B67-5E12C8701A31}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; const OPENLESS_TSF_IMMERSIVE_CATEGORY_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{13A016DF-560B-46CD-947A-4C3AF1E0E35D}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; const OPENLESS_TSF_SYSTRAY_CATEGORY_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{25504FB4-7BAB-4BC1-9C69-CF81890F0EF5}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; + #[allow(dead_code)] const OPENLESS_PROFILE_ACTIVATION_FLAGS: u32 = TF_IPPMF_FORSESSION | TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE | TF_IPPMF_ENABLEPROFILE; const PROFILE_RESTORE_FLAGS: u32 = TF_IPPMF_FORSESSION | TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE; @@ -296,20 +342,22 @@ mod windows_impl { ) } + #[allow(dead_code)] pub fn activate_openless_profile() -> WindowsImeProfileResult<()> { let clsid = parse_guid(OPENLESS_TEXT_SERVICE_CLSID_BRACED)?; let profile_guid = parse_guid(OPENLESS_PROFILE_GUID_BRACED)?; + let lang_id = openless_tsf_lang_id(); with_input_processor_profiles(|profiles| unsafe { - profiles.EnableLanguageProfile(&clsid, OPENLESS_TSF_LANG_ID, &profile_guid, true)?; - profiles.ChangeCurrentLanguage(OPENLESS_TSF_LANG_ID)?; - profiles.ActivateLanguageProfile(&clsid, OPENLESS_TSF_LANG_ID, &profile_guid) + profiles.EnableLanguageProfile(&clsid, lang_id, &profile_guid, true)?; + profiles.ChangeCurrentLanguage(lang_id)?; + profiles.ActivateLanguageProfile(&clsid, lang_id, &profile_guid) })?; with_profile_manager(|manager| unsafe { manager.ActivateProfile( TF_PROFILETYPE_INPUTPROCESSOR, - OPENLESS_TSF_LANG_ID, + lang_id, &clsid, &profile_guid, null_hkl(), @@ -358,7 +406,7 @@ mod windows_impl { let snapshot = capture_active_profile()?; Ok(matches!(snapshot.kind(), ImeProfileKind::TextService) - && snapshot.lang_id() == OPENLESS_TSF_LANG_ID + && snapshot.lang_id() == openless_tsf_lang_id() && snapshot.clsid().map(normalize_guid_string).as_deref() == Some(OPENLESS_TEXT_SERVICE_CLSID_BRACED) && snapshot @@ -524,6 +572,7 @@ mod windows_impl { operation(&manager).map_err(windows_api_error("ITfInputProcessorProfileMgr operation")) } + #[allow(dead_code)] fn with_input_processor_profiles( operation: impl FnOnce(&ITfInputProcessorProfiles) -> windows::core::Result, ) -> WindowsImeProfileResult { @@ -670,7 +719,6 @@ mod windows_tests { #[test] fn openless_profile_identifiers_are_fixed() { - assert_eq!(OPENLESS_TSF_LANG_ID, 0x0804); assert_eq!( OPENLESS_TEXT_SERVICE_CLSID_BRACED, "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}" diff --git a/openless-all/app/src-tauri/src/windows_ime_protocol.rs b/openless-all/app/src-tauri/src/windows_ime_protocol.rs index be9a8786..f5e6c4ac 100644 --- a/openless-all/app/src-tauri/src/windows_ime_protocol.rs +++ b/openless-all/app/src-tauri/src/windows_ime_protocol.rs @@ -89,6 +89,9 @@ pub fn decode_message(line: &str) -> Result { serde_json::from_str(line) } +// Used only by tests today; kept as part of the public protocol surface so +// the future IPC client can validate session-id correlation. +#[allow(dead_code)] pub fn is_result_for_pending_session( message: &ImePipeMessage, pending_session_id: &str, 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 8c82798a..04882855 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -90,6 +90,24 @@ impl WindowsImeSessionController { pub fn prepare_session(&self) -> PreparedWindowsImeSession { #[cfg(target_os = "windows")] { + // Per-host gating: the bundled TSF DLL still hard-codes + // lang_id 0x0804 (zh-CN) in guids.h. On a zh host that lines up + // and the existing TSF activation path works as designed. On any + // non-zh host, going through TSF either hijacks the active IME + // (ATOK / Microsoft IME on Japanese hosts) or fails activation + // and corrupts IME state. Until guids.h + DLL rebuild ships, only + // route through TSF when the host UI language is zh. + let host_lang = crate::windows_ime_profile::host_ui_lang_id(); + let primary = host_lang & 0x03FF; + // 0x04 is the primary language id for Chinese (Simplified/Traditional/etc). + const PRIMARY_LANG_CHINESE: u16 = 0x04; + if primary != PRIMARY_LANG_CHINESE { + log::info!( + "[windows-ime] host UI lang_id=0x{host_lang:04x} is not zh; skipping TSF path" + ); + return PreparedWindowsImeSession::unavailable(); + } + let saved_profile = match self.profile_manager.capture_active_profile() { Ok(snapshot) => snapshot, Err(error) => { @@ -114,6 +132,8 @@ impl WindowsImeSessionController { #[cfg(not(target_os = "windows"))] { + let _ = &self.profile_manager; + let _ = &self.ipc; PreparedWindowsImeSession::unavailable() } }