From 02e928d228cf85a97cb5c987bc102625fd4f3ce0 Mon Sep 17 00:00:00 2001 From: lightnovel0 Date: Fri, 8 May 2026 19:28:08 +0900 Subject: [PATCH 1/2] fix(windows-ime): force-skip TSF on non-zh hosts to stop ATOK/MS-IME hijack The bundled C++ TSF DLL hard-codes lang_id 0x0804 (zh-CN) in guids.h, which on a Japanese host either (a) hijacks the active IME to a chinese-IME profile and leaves it stuck after dictation, or (b) fails activation and falls back via SendInput, which competes with ATOK / Microsoft IME composition state. This PR is a stop-gap that forces prepare_session() to return unavailable() so all insertion goes through the clipboard+SendInput fallback in coordinator.rs. The proper fix is a per-host lang_id in both guids.h and windows_ime_profile.rs plus a DLL rebuild - tracked as a follow-up. Also bumps OPENLESS_TSF_LANG_ID from 0x0804 to 0x0411 (Japanese) in windows_ime_profile.rs as a partial mitigation for users who do manage to load the DLL. dead_code attributes added to the now-unused TSF helpers so the fork builds clean while the path is gated. --- .../app/src-tauri/src/windows_ime_ipc.rs | 8 ++++ .../app/src-tauri/src/windows_ime_profile.rs | 14 +++++- .../app/src-tauri/src/windows_ime_protocol.rs | 2 + .../app/src-tauri/src/windows_ime_session.rs | 44 +++++++------------ 4 files changed, 39 insertions(+), 29 deletions(-) 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..e18ac01d 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,8 @@ const NMPWAIT_NOWAIT: u32 = 0x00000001; #[derive(Debug, Clone, PartialEq, Eq)] pub enum WindowsImeIpcError { + // 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), NoReadyClient, Timeout, @@ -113,13 +115,17 @@ pub struct ImeSubmitTarget { pub thread_id: u32, } +// TSF path is force-disabled until per-host lang_id is wired in. See windows_ime_session.rs::prepare_session. +// Fields here are kept for the future re-enable path; suppress dead_code for now. #[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 +136,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..b35f4d86 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -1,4 +1,9 @@ -pub const OPENLESS_TSF_LANG_ID: u16 = 0x0804; +// 0x0804 (zh-CN) registers the TSF IME as a Chinese input method, which on a +// Japanese (or any non-zh) Windows host hijacks IME state away from the user's +// native IME (ATOK / Microsoft IME) and leaves it stuck after dictation. Use +// the user's primary UI language instead. 0x0411 = Japanese; this should +// eventually be selected per host language at runtime — see PR plan. +pub const OPENLESS_TSF_LANG_ID: u16 = 0x0411; 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}"; @@ -88,6 +93,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 +137,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 +216,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,6 +306,7 @@ 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)?; @@ -524,6 +535,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 { 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..ceb9ac22 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,8 @@ pub fn decode_message(line: &str) -> Result { serde_json::from_str(line) } +// TSF path is force-disabled until per-host lang_id is wired in. See windows_ime_session.rs::prepare_session. +#[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..323509ef 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -5,8 +5,11 @@ use crate::windows_ime_profile::{ }; use crate::windows_ime_protocol::ImeSubmitStatus; +// TSF path is force-disabled until per-host lang_id is wired in. See `prepare_session` below. +// `Profile` variant and a few helper constructors are kept for the future re-enable path; suppress dead_code for now. #[derive(Debug)] pub enum WindowsImeSessionError { + #[allow(dead_code)] Profile(String), Ipc(String), } @@ -46,6 +49,7 @@ impl PreparedWindowsImeSession { } } + #[allow(dead_code)] pub fn activation_failed(saved_profile: ImeProfileSnapshot) -> Self { Self { saved_profile: Some(saved_profile), @@ -88,34 +92,18 @@ impl WindowsImeSessionController { } pub fn prepare_session(&self) -> PreparedWindowsImeSession { - #[cfg(target_os = "windows")] - { - let saved_profile = match self.profile_manager.capture_active_profile() { - Ok(snapshot) => snapshot, - Err(error) => { - let error = WindowsImeSessionError::Profile(error.to_string()); - log::warn!("[windows-ime] capture active profile failed: {error}"); - return PreparedWindowsImeSession::unavailable(); - } - }; - - match self.profile_manager.activate_openless_profile() { - Ok(()) => PreparedWindowsImeSession { - saved_profile: Some(saved_profile), - openless_activated: true, - }, - Err(error) => { - let error = WindowsImeSessionError::Profile(error.to_string()); - log::warn!("[windows-ime] activate OpenLess profile failed: {error}"); - PreparedWindowsImeSession::activation_failed(saved_profile) - } - } - } - - #[cfg(not(target_os = "windows"))] - { - PreparedWindowsImeSession::unavailable() - } + // TSF path is force-disabled. The bundled C++ TSF DLL hard-codes + // lang_id 0x0804 (zh-CN) in guids.h, which on a Japanese host either + // (a) hijacks the active IME to a chinese-IME profile and leaves it + // stuck, or (b) fails activation and falls back via SendInput which + // also fights with ATOK. Forcing `unavailable()` routes everything + // through the clipboard+SendInput fallback in coordinator.rs. The + // proper fix is a per-host lang_id in both guids.h and + // windows_ime_profile.rs plus a DLL rebuild — track that as a + // separate PR. + let _ = &self.profile_manager; + let _ = &self.ipc; + PreparedWindowsImeSession::unavailable() } pub async fn submit_prepared( From c994675510f0848f944b21a691426c063dd86a05 Mon Sep 17 00:00:00 2001 From: lightnovel0 Date: Fri, 8 May 2026 21:18:45 +0900 Subject: [PATCH 2/2] fix(windows-ime): make TSF skip conditional on UI language (per-host) --- .../app/src-tauri/src/windows_ime_ipc.rs | 8 ++- .../app/src-tauri/src/windows_ime_profile.rs | 60 +++++++++++++---- .../app/src-tauri/src/windows_ime_protocol.rs | 3 +- .../app/src-tauri/src/windows_ime_session.rs | 64 ++++++++++++++----- 4 files changed, 103 insertions(+), 32 deletions(-) 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 e18ac01d..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,7 +24,9 @@ const NMPWAIT_NOWAIT: u32 = 0x00000001; #[derive(Debug, Clone, PartialEq, Eq)] pub enum WindowsImeIpcError { - // TSF path is force-disabled until per-host lang_id is wired in. See windows_ime_session.rs::prepare_session. + // 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, @@ -115,8 +117,8 @@ pub struct ImeSubmitTarget { pub thread_id: u32, } -// TSF path is force-disabled until per-host lang_id is wired in. See windows_ime_session.rs::prepare_session. -// Fields here are kept for the future re-enable path; suppress dead_code for now. +// 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)] 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 b35f4d86..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,12 +1,48 @@ -// 0x0804 (zh-CN) registers the TSF IME as a Chinese input method, which on a -// Japanese (or any non-zh) Windows host hijacks IME state away from the user's -// native IME (ATOK / Microsoft IME) and leaves it stuck after dictation. Use -// the user's primary UI language instead. 0x0411 = Japanese; this should -// eventually be selected per host language at runtime — see PR plan. -pub const OPENLESS_TSF_LANG_ID: u16 = 0x0411; 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")] @@ -310,17 +346,18 @@ mod windows_impl { 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(), @@ -369,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 @@ -682,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 ceb9ac22..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,7 +89,8 @@ pub fn decode_message(line: &str) -> Result { serde_json::from_str(line) } -// TSF path is force-disabled until per-host lang_id is wired in. See windows_ime_session.rs::prepare_session. +// 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, 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 323509ef..04882855 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -5,11 +5,8 @@ use crate::windows_ime_profile::{ }; use crate::windows_ime_protocol::ImeSubmitStatus; -// TSF path is force-disabled until per-host lang_id is wired in. See `prepare_session` below. -// `Profile` variant and a few helper constructors are kept for the future re-enable path; suppress dead_code for now. #[derive(Debug)] pub enum WindowsImeSessionError { - #[allow(dead_code)] Profile(String), Ipc(String), } @@ -49,7 +46,6 @@ impl PreparedWindowsImeSession { } } - #[allow(dead_code)] pub fn activation_failed(saved_profile: ImeProfileSnapshot) -> Self { Self { saved_profile: Some(saved_profile), @@ -92,18 +88,54 @@ impl WindowsImeSessionController { } pub fn prepare_session(&self) -> PreparedWindowsImeSession { - // TSF path is force-disabled. The bundled C++ TSF DLL hard-codes - // lang_id 0x0804 (zh-CN) in guids.h, which on a Japanese host either - // (a) hijacks the active IME to a chinese-IME profile and leaves it - // stuck, or (b) fails activation and falls back via SendInput which - // also fights with ATOK. Forcing `unavailable()` routes everything - // through the clipboard+SendInput fallback in coordinator.rs. The - // proper fix is a per-host lang_id in both guids.h and - // windows_ime_profile.rs plus a DLL rebuild — track that as a - // separate PR. - let _ = &self.profile_manager; - let _ = &self.ipc; - PreparedWindowsImeSession::unavailable() + #[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) => { + let error = WindowsImeSessionError::Profile(error.to_string()); + log::warn!("[windows-ime] capture active profile failed: {error}"); + return PreparedWindowsImeSession::unavailable(); + } + }; + + match self.profile_manager.activate_openless_profile() { + Ok(()) => PreparedWindowsImeSession { + saved_profile: Some(saved_profile), + openless_activated: true, + }, + Err(error) => { + let error = WindowsImeSessionError::Profile(error.to_string()); + log::warn!("[windows-ime] activate OpenLess profile failed: {error}"); + PreparedWindowsImeSession::activation_failed(saved_profile) + } + } + } + + #[cfg(not(target_os = "windows"))] + { + let _ = &self.profile_manager; + let _ = &self.ipc; + PreparedWindowsImeSession::unavailable() + } } pub async fn submit_prepared(