From 94816e3545598c781c5034a2d6c022aa3e403bba Mon Sep 17 00:00:00 2001 From: DepengWang <2818245+DepengWang@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:47:54 +0800 Subject: [PATCH] fix(windows-ime): stay active as a speech TIP instead of switching IMEs In TSF mode every dictation switched the session's input profile to the OpenLess keyboard TIP and back. Switching back makes TSF re-activate the user's own IME on every TSF thread of every process. With Weasel (Rime) that re-activation does blocking named-pipe IPC ending in FlushFileBuffers, which sometimes never returns and freezes the host. Observed on Windows 11 with Weasel 0.17.4: explorer.exe AppHang shortly after a dictation, with the desktop thread stuck in explorer message loop -> msctf -> weasel.dll -> FlushFileBuffers while every WeaselServer worker sat idle in ReadFile. Stopping WeaselServer released the thread at once. This matches #665 and #954 (both Weasel users) and is also the source of the restore glitches in #1033 / #1032. Register the text service under GUID_TFCAT_TIP_SPEECH for all languages (0xFFFF), like the system speech text service. TSF keeps such a TIP active next to the keyboard IME, so nothing has to be switched: - DllRegisterServer registers the speech category and removes the keyboard category and zh-CN profile left by earlier releases, so an upgrade migrates in place. - The per-dictation capture / activate / restore of the input profile is removed (windows_ime_restore.rs, most of windows_ime_profile.rs, the prepared-session plumbing in core_adapters.rs). A dictation is now a single submit to the IME window in the target app. - A speech profile is only activated while enabled, so the "show in keyboard list" preference no longer disables it. The text service no longer appears in the keyboard list at all; the preference is now inert on Windows and left in place for a follow-up decision. - Registration checks, the install smoke script and the settings blurb follow the new registration. Because the DLL is now active in every TSF-enabled process all the time, it also stops running a worker thread and a named pipe per TSF thread: - OpenLess sends WM_COPYDATA (token + UTF-16 text) to the message-only window the DLL already owned on the TSF thread. The DLL acknowledges, posts a message to itself and commits from the top of the host message loop, as before; the result is polled with the same token, which also covers hosts that only grant an async edit session (Word). - Activate()/Deactivate() no longer start, signal or join anything. Deactivate() used to join the worker on the host UI thread, which is unbounded if it runs with the loader lock held. - The window does not opt in to WM_COPYDATA from lower-integrity senders, so a non-elevated process still cannot inject text into an elevated host. - Timeout semantics are unchanged: anything after the submit was delivered stays OutcomeUnknown and never triggers a second insertion. The lifecycle contract test now asserts that the DLL contains no threads, pipes or blocking waits and that the protocol constants match between C++ and Rust. Co-Authored-By: Claude Opus 5.5 --- .../app/scripts/windows-ime-install-smoke.ps1 | 12 +- .../windows-ime-lifecycle-contract.test.mjs | 107 ++- .../app/scripts/windows-package-msvc.test.mjs | 16 +- .../app/src-tauri/src/core_adapters.rs | 36 +- openless-all/app/src-tauri/src/lib.rs | 1 - .../app/src-tauri/src/windows_ime_ipc.rs | 671 +++++++++--------- .../app/src-tauri/src/windows_ime_profile.rs | 632 +---------------- .../app/src-tauri/src/windows_ime_protocol.rs | 191 ++--- .../app/src-tauri/src/windows_ime_restore.rs | 237 ------- .../app/src-tauri/src/windows_ime_session.rs | 225 +----- openless-all/app/src/i18n/de.ts | 2 +- openless-all/app/src/i18n/en.ts | 2 +- openless-all/app/src/i18n/es.ts | 2 +- openless-all/app/src/i18n/fr.ts | 2 +- openless-all/app/src/i18n/ja.ts | 2 +- openless-all/app/src/i18n/ko.ts | 2 +- openless-all/app/src/i18n/zh-CN.ts | 3 +- openless-all/app/src/i18n/zh-TW.ts | 3 +- .../app/windows-ime/OpenLessIme.vcxproj | 2 - .../app/windows-ime/src/edit_session.cpp | 38 +- .../app/windows-ime/src/edit_session.h | 20 +- openless-all/app/windows-ime/src/guids.h | 7 +- .../app/windows-ime/src/ipc_client.cpp | 653 ----------------- openless-all/app/windows-ime/src/ipc_client.h | 45 -- openless-all/app/windows-ime/src/registry.cpp | 14 +- .../app/windows-ime/src/text_service.cpp | 365 +++++----- .../app/windows-ime/src/text_service.h | 31 +- 27 files changed, 746 insertions(+), 2575 deletions(-) delete mode 100644 openless-all/app/src-tauri/src/windows_ime_restore.rs delete mode 100644 openless-all/app/windows-ime/src/ipc_client.cpp delete mode 100644 openless-all/app/windows-ime/src/ipc_client.h diff --git a/openless-all/app/scripts/windows-ime-install-smoke.ps1 b/openless-all/app/scripts/windows-ime-install-smoke.ps1 index a3b25de71..96a150ce9 100644 --- a/openless-all/app/scripts/windows-ime-install-smoke.ps1 +++ b/openless-all/app/scripts/windows-ime-install-smoke.ps1 @@ -11,18 +11,18 @@ $ErrorActionPreference = "Stop" $TextServiceClsid = "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}" $ProfileGuid = "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}" -$LangId = "0x00000804" -$KeyboardCategoryGuid = "{34745C63-B2F0-4784-8B67-5E12C8701A31}" +$LangId = "0x0000ffff" +$SpeechCategoryGuid = "{B5A73CD1-8355-426B-A161-259808F26B14}" $ImmersiveCategoryGuid = "{13A016DF-560B-46CD-947A-4C3AF1E0E35D}" $SystrayCategoryGuid = "{25504FB4-7BAB-4BC1-9C69-CF81890F0EF5}" # Keep this script aligned with the backend status check and the TSF IPC path -# used by OpenLessImeSubmit-* named pipes. +# used by OpenLessImeMessageWindow (WM_COPYDATA). $ExpectedBackendKeys = @( "Software\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32", "Software\WOW6432Node\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32", - "Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x00000804\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}", - "Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{34745C63-B2F0-4784-8B67-5E12C8701A31}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}", + "Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x0000ffff\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}", + "Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{B5A73CD1-8355-426B-A161-259808F26B14}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}", "Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{13A016DF-560B-46CD-947A-4C3AF1E0E35D}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}", "Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{25504FB4-7BAB-4BC1-9C69-CF81890F0EF5}\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}" ) @@ -153,7 +153,7 @@ function Assert-OpenLessImeInstalled { } Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\LanguageProfile\$LangId\$ProfileGuid" -Label "TSF language profile" - Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$KeyboardCategoryGuid\$TextServiceClsid" -Label "TSF keyboard category" + Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$SpeechCategoryGuid\$TextServiceClsid" -Label "TSF speech category" Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$ImmersiveCategoryGuid\$TextServiceClsid" -Label "TSF immersive category" Assert-RegistryKey -View Registry64 -SubKey "Software\Microsoft\CTF\TIP\$TextServiceClsid\Category\Category\$SystrayCategoryGuid\$TextServiceClsid" -Label "TSF systray category" diff --git a/openless-all/app/scripts/windows-ime-lifecycle-contract.test.mjs b/openless-all/app/scripts/windows-ime-lifecycle-contract.test.mjs index 332d89e0c..e4fb89ad2 100644 --- a/openless-all/app/scripts/windows-ime-lifecycle-contract.test.mjs +++ b/openless-all/app/scripts/windows-ime-lifecycle-contract.test.mjs @@ -1,65 +1,94 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const imeRoot = join(appRoot, 'windows-ime', 'src'); -const ipcClient = readFileSync(join(imeRoot, 'ipc_client.cpp'), 'utf8'); -const ipcHeader = readFileSync(join(imeRoot, 'ipc_client.h'), 'utf8'); const textService = readFileSync(join(imeRoot, 'text_service.cpp'), 'utf8'); const editSession = readFileSync(join(imeRoot, 'edit_session.cpp'), 'utf8'); +const protocol = readFileSync(join(appRoot, 'src-tauri', 'src', 'windows_ime_protocol.rs'), 'utf8'); -assert.doesNotMatch( - ipcClient, - /FlushFileBuffers\(/, - 'IME shutdown must not block the host UI thread waiting for the pipe client', -); -assert.match( - ipcClient, - /WaitForClientDisconnect/, - 'IME replies should wait for client disconnect through cancelable overlapped I/O', -); -assert.match( - ipcHeader, - /HRESULT Start\(/, - 'IME activation should report pipe server startup failures', -); -assert.match( - ipcClient, - /WaitForSingleObject\(startup_event_/, - "IME activation must wait for the worker's first named-pipe creation result", +// The IME runs inside every host process. It must not own threads or block the +// host UI thread: joining a worker from Deactivate deadlocked hosts whenever +// the calling thread held the loader lock. +const imeSources = readdirSync(imeRoot) + .filter((name) => /\.(cpp|h)$/.test(name)) + .map((name) => [name, readFileSync(join(imeRoot, name), 'utf8')]); +for (const [name, source] of imeSources) { + assert.doesNotMatch( + source, + /std::thread|_beginthreadex|CreateThread\(/, + `${name}: IME must not create threads inside host processes`, + ); + assert.doesNotMatch( + source, + /CreateNamedPipeW|ConnectNamedPipe/, + `${name}: IME must not serve named pipes inside host processes`, + ); + assert.doesNotMatch( + source, + /WaitForSingleObject|WaitForMultipleObjects|\.join\(\)|SendMessageTimeoutW\(|SendMessageW\(/, + `${name}: IME must not block the host UI thread on waits or synchronous sends`, + ); +} +assert.ok( + !existsSync(join(imeRoot, 'ipc_client.cpp')), + 'the in-host pipe server must stay removed', ); + assert.match( - ipcClient, - /CreateNamedPipeW[\s\S]*ReportStartupResult/, - 'IME worker must report the first CreateNamedPipeW result to activation', + textService, + /message == WM_COPYDATA/, + 'IME should receive submissions as WM_COPYDATA on its message window', ); assert.match( - ipcHeader, - /void Run\(\) noexcept/, - 'IME worker exceptions must not terminate the host process', + textService, + /PostMessageW\(message_window_, kRunSubmitMessage/, + 'IME should commit from a posted message at the top of the host message loop', ); - assert.doesNotMatch( textService, - /SendMessageTimeoutW\(/, - 'IME worker must not synchronously send a stack request to the owner thread', + /ChangeWindowMessageFilter/, + 'IME must not let lower-integrity processes inject text into elevated hosts', ); assert.match( textService, - /PostMessageW\(/, - 'IME worker should post an owned request to the owner thread', + /STDMETHODIMP OpenLessTextService::Deactivate\(\) \{\s*(?:OpenLessTraceScope trace\(L"Deactivate"\);\s*)?CancelPendingSubmit\(\);\s*DestroyMessageWindow\(\);/, + 'IME deactivation should only cancel the pending submit and destroy its window', ); assert.match( textService, - /WaitForMultipleObjects\(/, - 'IME owner-thread and async edit waits should be cancelable during shutdown', + /service->AddRef\(\);[\s\S]*service->Release\(\);/, + 'IME message handling should keep the service alive across re-entrant deactivation', ); -assert.match( - textService, - /PeekMessageW[\s\S]*PM_REMOVE/, - 'IME shutdown should release queued submit requests before destroying the message window', + +// The message protocol is defined twice (C++ and Rust); keep the copies equal. +const sharedConstants = [ + ['kCopyDataSubmit', 'IME_COPYDATA_SUBMIT'], + ['kCopyDataQuery', 'IME_COPYDATA_QUERY'], + ['kStatusAccepted', 'IME_STATUS_ACCEPTED'], + ['kStatusPending', 'IME_STATUS_PENDING'], + ['kStatusCommitted', 'IME_STATUS_COMMITTED'], + ['kStatusUnknownToken', 'IME_STATUS_UNKNOWN_TOKEN'], + ['kStatusBadRequest', 'IME_STATUS_BAD_REQUEST'], +]; +for (const [nativeName, rustName] of sharedConstants) { + const nativeValue = textService.match(new RegExp(`${nativeName} = (0x[0-9A-Fa-f]+)`)); + const rustValue = protocol.match(new RegExp(`${rustName}: \\w+ = (0x[0-9A-Fa-f_]+)`)); + assert.ok(nativeValue, `${nativeName} should be defined in text_service.cpp`); + assert.ok(rustValue, `${rustName} should be defined in windows_ime_protocol.rs`); + assert.equal( + Number(rustValue[1].replaceAll('_', '')), + Number(nativeValue[1]), + `${nativeName} and ${rustName} must match`, + ); +} +const windowClass = textService.match(/kMessageWindowClassName\[\] = L"([^"]+)"/); +assert.ok(windowClass, 'IME message window class should be defined'); +assert.ok( + protocol.includes(`OPENLESS_IME_MESSAGE_WINDOW_CLASS: &str = "${windowClass[1]}"`), + 'IME message window class must match between C++ and Rust', ); assert.match( diff --git a/openless-all/app/scripts/windows-package-msvc.test.mjs b/openless-all/app/scripts/windows-package-msvc.test.mjs index f29d893df..26398e980 100644 --- a/openless-all/app/scripts/windows-package-msvc.test.mjs +++ b/openless-all/app/scripts/windows-package-msvc.test.mjs @@ -242,13 +242,13 @@ assert.match( ); assert.match( imeTextService, - /WaitForSingleObject/, - 'IME pipe submit should wait for async edit-session completion', + /async_edit_->completed/, + 'IME submit queries should report async edit-session completion', ); assert.match( imeEditSession, - /SetEvent/, - 'IME edit session should signal async completion back to the pipe submitter', + /async_state_->completed = true/, + 'IME edit session should record async completion for the submit query', ); assert.match( imeEditSession, @@ -443,7 +443,7 @@ assert.match( ); assert.match( imeInstallSmoke, - /OpenLessImeSubmit/, + /OpenLessImeMessageWindow/, 'install smoke should preserve TSF backend context', ); assert.match( @@ -463,13 +463,13 @@ assert.match( ); assert.match( imeInstallSmoke, - /LanguageProfile\\0x00000804\\\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E\}/, + /LanguageProfile\\0x0000ffff\\\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E\}/, 'install smoke should check the TSF language profile', ); assert.match( imeInstallSmoke, - /Category\\Category\\\{34745C63-B2F0-4784-8B67-5E12C8701A31\}/, - 'install smoke should check the keyboard TSF category', + /Category\\Category\\\{B5A73CD1-8355-426B-A161-259808F26B14\}/, + 'install smoke should check the speech TSF category', ); assert.match( imeInstallSmoke, diff --git a/openless-all/app/src-tauri/src/core_adapters.rs b/openless-all/app/src-tauri/src/core_adapters.rs index dd900c4ac..af5c82e25 100644 --- a/openless-all/app/src-tauri/src/core_adapters.rs +++ b/openless-all/app/src-tauri/src/core_adapters.rs @@ -2978,24 +2978,6 @@ impl CoreTextInserter for TauriTextInserter { // whichever application happens to be focused at the end. let insertion_target = insertion_target .unwrap_or_else(crate::selection::capture_selection_insertion_target); - #[cfg(target_os = "windows")] - let prepared = if context.insertion.windows_insertion_mode - == openless_core::shared_types::WindowsInsertionMode::Tsf - { - let controller = Arc::clone(&windows_ime); - Some( - tauri::async_runtime::spawn_blocking(move || controller.prepare_session()) - .await - .map_err(|error| { - BackendError::new( - BackendErrorCode::Internal, - format!("join Windows IME prepare task: {error}"), - ) - })?, - ) - } else { - None - }; #[cfg(target_os = "macos")] let (app_handle, mut previous_input_source, streaming_ready) = { let app_handle = app.lock().clone().ok_or_else(|| { @@ -3058,8 +3040,6 @@ impl CoreTextInserter for TauriTextInserter { finished: Arc::new(AtomicBool::new(false)), #[cfg(target_os = "windows")] windows_ime, - #[cfg(target_os = "windows")] - prepared: Arc::new(Mutex::new(prepared)), #[cfg(target_os = "macos")] app: app_handle, #[cfg(target_os = "macos")] @@ -3086,8 +3066,6 @@ struct TauriTextInsertionSession { finished: Arc, #[cfg(target_os = "windows")] windows_ime: Arc, - #[cfg(target_os = "windows")] - prepared: Arc>>, #[cfg(target_os = "macos")] app: AppHandle, #[cfg(target_os = "macos")] @@ -3190,23 +3168,16 @@ impl TauriTextInsertionSession { { let status = match self.context.insertion.windows_insertion_mode { openless_core::shared_types::WindowsInsertionMode::Tsf => { - let prepared = self.prepared.lock().take().ok_or_else(|| { - BackendError::new( - BackendErrorCode::InvalidState, - "prepared Windows IME session is unavailable", - ) - })?; let request = crate::windows_ime_ipc::ImeSubmitRequest { session_id: self.session_id.to_string(), text: text.clone(), created_at: chrono::Utc::now().to_rfc3339(), target: crate::windows_ime_target::capture_ime_submit_target(), }; - let status = match self.windows_ime.submit_prepared(&prepared, request).await { + let status = match self.windows_ime.submit(request).await { Ok(status) => status, Err(error) if error.is_outcome_unknown() => { log::warn!("[core-adapter] TSF outcome is unknown: {error}"); - self.windows_ime.restore_session(prepared); return Err(BackendError::new( BackendErrorCode::OutcomeUnknown, error.to_string(), @@ -3217,7 +3188,6 @@ impl TauriTextInsertionSession { crate::types::InsertStatus::Failed } }; - self.windows_ime.restore_session(prepared); if status == crate::types::InsertStatus::Failed && self.context.insertion.allow_non_tsf_fallback { @@ -3302,10 +3272,6 @@ impl TauriTextInsertionSession { } async fn restore_platform_state(&self) -> Result<(), BackendError> { - #[cfg(target_os = "windows")] - if let Some(prepared) = self.prepared.lock().take() { - self.windows_ime.restore_session(prepared); - } #[cfg(target_os = "macos")] { let previous_input_source = self.previous_input_source.lock().take(); diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 264e7063e..9b6b78b85 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -114,7 +114,6 @@ 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; #[cfg(target_os = "windows")] 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 c497afc07..c9b15e13a 100644 --- a/openless-all/app/src-tauri/src/windows_ime_ipc.rs +++ b/openless-all/app/src-tauri/src/windows_ime_ipc.rs @@ -1,29 +1,27 @@ #![allow(dead_code, unused_imports, unused_variables)] +use std::sync::atomic::{AtomicU32, Ordering}; use std::time::Duration; -use crate::windows_ime_protocol::ImeSubmitStatus; +use crate::windows_ime_protocol::{ + is_failed_hresult, ImeSubmitStatus, IME_STATUS_ACCEPTED, IME_STATUS_BAD_REQUEST, + IME_STATUS_COMMITTED, IME_STATUS_PENDING, +}; pub const IME_CLIENT_WAIT_TIMEOUT: Duration = Duration::from_millis(700); -const IME_OWNER_THREAD_MESSAGE_TIMEOUT_MS: u64 = 2000; -const IME_ASYNC_EDIT_SESSION_TIMEOUT_MS: u64 = 2000; -const IME_SUBMIT_TIMEOUT_MARGIN_MS: u64 = 1000; -const IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS: u64 = - IME_OWNER_THREAD_MESSAGE_TIMEOUT_MS + IME_ASYNC_EDIT_SESSION_TIMEOUT_MS; - -// The DLL posts to its owner thread with PostMessageW and then waits on an -// event; async TSF edits have a second wait. A timed-out wait only requests -// cancellation: an edit already inside COM can still commit afterwards. -// Every post-dispatch timeout therefore remains OutcomeUnknown, never a -// definite failure that authorizes another insertion attempt. -pub const IME_SUBMIT_TIMEOUT: Duration = - Duration::from_millis(IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS + IME_SUBMIT_TIMEOUT_MARGIN_MS); -const IME_PIPE_RETRY_INTERVAL: Duration = Duration::from_millis(25); - -const ERROR_FILE_NOT_FOUND: u32 = 2; -const ERROR_PATH_NOT_FOUND: u32 = 3; -const ERROR_SEM_TIMEOUT: u32 = 121; -const ERROR_PIPE_BUSY: u32 = 231; -const NMPWAIT_NOWAIT: u32 = 0x00000001; + +// The DLL commits from a message it posts to the host's TSF thread, and some +// hosts only grant an async edit session later. Once the submit message has +// been delivered, a timeout proves nothing: the edit can still commit +// afterwards. Every post-dispatch timeout therefore remains OutcomeUnknown, +// never a definite failure that authorizes another insertion attempt. +pub const IME_SUBMIT_TIMEOUT: Duration = Duration::from_millis(5000); +const IME_SEND_TIMEOUT_MS: u32 = 2000; +const IME_QUERY_SEND_TIMEOUT_MS: u32 = 500; +const IME_QUERY_INTERVAL: Duration = Duration::from_millis(10); +const IME_WINDOW_RETRY_INTERVAL: Duration = Duration::from_millis(25); + +const HRESULT_TIMEOUT: u32 = 0x8007_05B4; +const HRESULT_CANCELLED: u32 = 0x8007_04C7; #[derive(Debug, Clone, PartialEq, Eq)] pub enum WindowsImeIpcError { @@ -60,111 +58,84 @@ impl WindowsImeIpcError { pub type WindowsImeIpcResult = Result; -fn classify_dispatched_submit_response( - response: &str, - pending: &mut PendingImeSubmit, -) -> WindowsImeIpcResult { - use crate::windows_ime_protocol::{ - decode_message, ImePipeMessage, OPENLESS_IME_PROTOCOL_VERSION, - }; - match decode_message(response.trim_end()).map_err(|error| { - WindowsImeIpcError::OutcomeUnknown(format!("invalid post-dispatch response: {error}")) - })? { - ImePipeMessage::SubmitResult { - protocol_version, - session_id, - status, - error_code, - } if protocol_version == OPENLESS_IME_PROTOCOL_VERSION => { - if status != ImeSubmitStatus::Committed { - log::warn!( - "[windows-ime] submit result status={status:?} error_code={error_code:?}" - ); - } - let status = pending - .accept_result(&session_id, status) - .map_err(|error| { - WindowsImeIpcError::OutcomeUnknown(format!( - "ambiguous post-dispatch result: {error}" - )) - })?; - if status != ImeSubmitStatus::Committed - && matches!( - error_code.as_deref(), - Some("hresult:0x800705B4" | "hresult:0x800704C7") - ) - { - // Keep the 1.x classification: a native timeout/cancel reply - // does not prove InsertTextAtSelection never committed. R01's - // definitive rejection fallback must not replay this text. - return Err(WindowsImeIpcError::OutcomeUnknown(format!( - "native IME submission may still complete after {}", - error_code.as_deref().unwrap_or("cancellation") - ))); - } - Ok(status) - } - ImePipeMessage::SubmitResult { - protocol_version, .. - } => Err(WindowsImeIpcError::OutcomeUnknown(format!( - "unsupported IME protocol version {protocol_version}" - ))), - _ => Err(WindowsImeIpcError::OutcomeUnknown( - "message is not a submit result".into(), +/// Reply to the submit message itself. Any error here is definite: the DLL +/// did not queue the text, so the caller may fall back to another insertion. +fn classify_submit_reply(reply: u32) -> WindowsImeIpcResult<()> { + match reply { + IME_STATUS_ACCEPTED => Ok(()), + 0 => Err(WindowsImeIpcError::Protocol( + "IME window ignored the submit message; the installed OpenLessIme.dll predates the message protocol" + .to_string(), )), + IME_STATUS_BAD_REQUEST => Err(WindowsImeIpcError::Protocol( + "IME rejected the submit payload".to_string(), + )), + code if is_failed_hresult(code) => Err(WindowsImeIpcError::Io(format!( + "IME could not queue the submit: hresult:0x{code:08X}" + ))), + other => Err(WindowsImeIpcError::Protocol(format!( + "unexpected IME submit reply 0x{other:08X}" + ))), } } -fn map_wait_named_pipe_error(error_code: Option) -> WindowsImeIpcError { - match error_code { - Some(ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND | ERROR_PIPE_BUSY) => { - WindowsImeIpcError::NoReadyClient +/// Reply to a query sent after the submit was accepted. `Ok(None)` means the +/// commit is still pending. Anything that is not a clear result stays +/// OutcomeUnknown, because the text may already be in the document. +fn classify_query_reply(reply: u32) -> WindowsImeIpcResult> { + match reply { + IME_STATUS_PENDING => Ok(None), + IME_STATUS_COMMITTED => Ok(Some(ImeSubmitStatus::Committed)), + HRESULT_TIMEOUT | HRESULT_CANCELLED => { + // Keep the 1.x classification: a native timeout/cancel reply + // does not prove InsertTextAtSelection never committed. R01's + // definitive rejection fallback must not replay this text. + Err(WindowsImeIpcError::OutcomeUnknown(format!( + "native IME submission may still complete after hresult:0x{reply:08X}" + ))) + } + code if is_failed_hresult(code) => { + log::warn!( + "[windows-ime] submit result status=Rejected error_code=hresult:0x{code:08X}" + ); + Ok(Some(ImeSubmitStatus::Rejected)) } - Some(ERROR_SEM_TIMEOUT) => WindowsImeIpcError::Timeout, - Some(code) => WindowsImeIpcError::Io(format!("WaitNamedPipeW failed with OS error {code}")), - None => WindowsImeIpcError::Io("WaitNamedPipeW failed without OS error".to_string()), + other => Err(WindowsImeIpcError::OutcomeUnknown(format!( + "ambiguous post-dispatch reply 0x{other:08X}" + ))), } } -fn is_retryable_pipe_error(error_code: Option) -> bool { - matches!( - error_code, - Some(ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND | ERROR_PIPE_BUSY | ERROR_SEM_TIMEOUT) - ) +/// Tokens only have to differ between consecutive submits to one IME window, +/// including across an OpenLess restart; zero is reserved by the DLL. +fn next_submit_token() -> u32 { + static SEQUENCE: AtomicU32 = AtomicU32::new(1); + let sequence = SEQUENCE.fetch_add(1, Ordering::Relaxed) & 0xFFFF; + (((std::process::id() & 0xFFFF) << 16) | sequence).max(1) } -#[derive(Debug)] -pub struct PendingImeSubmit { - session_id: String, - completed: bool, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ImeWindow { + hwnd: isize, + process_id: u32, + thread_id: u32, } -impl PendingImeSubmit { - pub fn new(session_id: String) -> Self { - Self { - session_id, - completed: false, - } - } - - pub fn accept_result( - &mut self, - session_id: &str, - status: ImeSubmitStatus, - ) -> WindowsImeIpcResult { - if self.completed { - return Err(WindowsImeIpcError::Protocol( - "submit result arrived after completion".to_string(), - )); - } - if self.session_id != session_id { - return Err(WindowsImeIpcError::Protocol( - "submit result belongs to a different session".to_string(), - )); - } - self.completed = true; - Ok(status) - } +/// Prefer the IME window on the target thread; otherwise use another one in +/// the same process (the focused control can live on a different thread than +/// the one TSF activated the IME on). +fn select_ime_window(target: ImeSubmitTarget, windows: &[ImeWindow]) -> Option { + windows + .iter() + .find(|window| { + window.process_id == target.process_id && window.thread_id == target.thread_id + }) + .or_else(|| { + windows + .iter() + .find(|window| window.process_id == target.process_id) + }) + .copied() } #[derive(Debug, Clone)] @@ -237,293 +208,337 @@ impl Default for WindowsImeIpcServer { async fn submit_text_to_platform( request: ImeSubmitRequest, ) -> WindowsImeIpcResult { - windows_pipe::submit_text_over_pipe(request).await + // Sending and polling block on the host's message loop; keep that off the + // async runtime workers. + tokio::task::spawn_blocking(move || windows_message::submit_text_to_window(request)) + .await + .map_err(|error| { + WindowsImeIpcError::OutcomeUnknown(format!("IME submit task failed: {error}")) + })? } #[cfg(target_os = "windows")] -mod windows_pipe { - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; +mod windows_message { + use std::ffi::c_void; use std::time::Instant; - use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - use tokio::net::windows::named_pipe::{ClientOptions, NamedPipeClient}; - use super::{ - ImeSubmitRequest, PendingImeSubmit, WindowsImeIpcError, WindowsImeIpcResult, - IME_CLIENT_WAIT_TIMEOUT, IME_PIPE_RETRY_INTERVAL, IME_SUBMIT_TIMEOUT, + classify_query_reply, classify_submit_reply, next_submit_token, select_ime_window, + ImeSubmitRequest, ImeSubmitTarget, ImeWindow, WindowsImeIpcError, WindowsImeIpcResult, + IME_CLIENT_WAIT_TIMEOUT, IME_QUERY_INTERVAL, IME_QUERY_SEND_TIMEOUT_MS, + IME_SEND_TIMEOUT_MS, IME_SUBMIT_TIMEOUT, IME_WINDOW_RETRY_INTERVAL, }; use crate::windows_ime_protocol::{ - decode_message, encode_message, ime_pipe_candidate_names_for_target, - ime_pipe_name_for_target, ImePipeMessage, OPENLESS_IME_PROTOCOL_VERSION, + encode_query_payload, encode_submit_payload, ImeSubmitStatus, IME_COPYDATA_QUERY, + IME_COPYDATA_SUBMIT, IME_MAX_SUBMIT_BYTES, OPENLESS_IME_MESSAGE_WINDOW_CLASS, }; + const HWND_MESSAGE: isize = -3; + const WM_COPYDATA: u32 = 0x004A; + const SMTO_ABORTIFHUNG: u32 = 0x0002; + const ERROR_ACCESS_DENIED: u32 = 5; + const ERROR_TIMEOUT: u32 = 1460; + const MAX_ENUMERATED_WINDOWS: usize = 4096; + + #[repr(C)] + struct CopyDataStruct { + dw_data: usize, + cb_data: u32, + lp_data: *const c_void, + } + + #[link(name = "user32")] extern "system" { - fn WaitNamedPipeW(lpNamedPipeName: *const u16, nTimeOut: u32) -> i32; + fn FindWindowExW( + hWndParent: isize, + hWndChildAfter: isize, + lpszClass: *const u16, + lpszWindow: *const u16, + ) -> isize; + fn GetWindowThreadProcessId(hWnd: isize, lpdwProcessId: *mut u32) -> u32; + fn SendMessageTimeoutW( + hWnd: isize, + Msg: u32, + wParam: usize, + lParam: isize, + fuFlags: u32, + uTimeout: u32, + lpdwResult: *mut usize, + ) -> isize; + } + + enum SendFailure { + /// The host did not answer in time; the message may still be handled. + TimedOut, + /// The message never reached the window (OS error code). + NotDelivered(u32), } - pub async fn submit_text_over_pipe( + pub fn submit_text_to_window( request: ImeSubmitRequest, - ) -> WindowsImeIpcResult { + ) -> WindowsImeIpcResult { let target = request.target.ok_or(WindowsImeIpcError::NoReadyClient)?; - let mut pending = PendingImeSubmit::new(request.session_id.clone()); - let (pipe_name, pipe) = open_pipe_with_retry(target).await?; - let (read_half, mut write_half) = tokio::io::split(pipe); - let mut reader = BufReader::new(read_half); - - let message = ImePipeMessage::SubmitText { - protocol_version: OPENLESS_IME_PROTOCOL_VERSION, - session_id: request.session_id, - text: request.text, - created_at: request.created_at, - }; - let line = encode_message(&message) - .map_err(|error| WindowsImeIpcError::Protocol(error.to_string()))?; - - let response = tokio::time::timeout(IME_SUBMIT_TIMEOUT, async { - log::debug!("[windows-ime] submitting text over pipe {pipe_name}"); - write_half - .write_all(line.as_bytes()) - .await - .map_err(|error| WindowsImeIpcError::OutcomeUnknown(error.to_string()))?; - write_half - .flush() - .await - .map_err(|error| WindowsImeIpcError::OutcomeUnknown(error.to_string()))?; - - let mut response = String::new(); - let bytes_read = reader - .read_line(&mut response) - .await - .map_err(|error| WindowsImeIpcError::OutcomeUnknown(error.to_string()))?; - - if bytes_read == 0 { - return Err(WindowsImeIpcError::OutcomeUnknown( - "IME pipe closed before submit result".to_string(), - )); - } - - Ok(response) - }) - .await - .map_err(|_| { - WindowsImeIpcError::OutcomeUnknown( - "OpenLess IME IPC timed out after submit dispatch".to_string(), - ) - })??; + let token = next_submit_token(); + let payload = encode_submit_payload(token, &request.text); + if payload.len() > IME_MAX_SUBMIT_BYTES { + return Err(WindowsImeIpcError::Protocol(format!( + "text is too large for one IME submit ({} bytes)", + payload.len() + ))); + } - super::classify_dispatched_submit_response(&response, &mut pending) - } + let window = find_ime_window_with_retry(target)?; + log::debug!( + "[windows-ime] submitting text to IME window pid={} tid={}", + window.process_id, + window.thread_id + ); - async fn open_pipe_with_retry( - target: super::ImeSubmitTarget, - ) -> WindowsImeIpcResult<(String, NamedPipeClient)> { - let deadline = Instant::now() + IME_CLIENT_WAIT_TIMEOUT; - let exact_pipe_name = ime_pipe_name_for_target(target.process_id, target.thread_id); + let reply = send_copy_data( + window.hwnd, + IME_COPYDATA_SUBMIT, + &payload, + IME_SEND_TIMEOUT_MS, + ) + .map_err(|failure| match failure { + SendFailure::TimedOut => WindowsImeIpcError::OutcomeUnknown( + "OpenLess IME did not acknowledge the submit in time".to_string(), + ), + SendFailure::NotDelivered(ERROR_ACCESS_DENIED) => WindowsImeIpcError::Io( + "IME window refused the message (target runs at a higher integrity level)" + .to_string(), + ), + SendFailure::NotDelivered(code) => { + WindowsImeIpcError::Io(format!("sending to the IME window failed: OS error {code}")) + } + })?; + classify_submit_reply(reply)?; + let deadline = Instant::now() + IME_SUBMIT_TIMEOUT; + let query = encode_query_payload(token); loop { - let mut retry_error = WindowsImeIpcError::NoReadyClient; - - for pipe_name in pipe_names_for_target(target) { - retry_error = match wait_for_pipe_client(&pipe_name) { - Ok(()) => match ClientOptions::new().open(&pipe_name) { - Ok(pipe) => { - if pipe_name != exact_pipe_name { - log::info!( - "[windows-ime] exact target pipe {exact_pipe_name} was not ready; using same-process pipe {pipe_name}" - ); - } - return Ok((pipe_name, pipe)); - } - Err(error) => { - let error_code = error.raw_os_error().map(|code| code as u32); - if !super::is_retryable_pipe_error(error_code) { - return Err(WindowsImeIpcError::Io(error.to_string())); - } - super::map_wait_named_pipe_error(error_code) - } - }, - Err(error) => { - if !is_retryable_wait_error(&error) { - return Err(error); - } - error + std::thread::sleep(IME_QUERY_INTERVAL); + match send_copy_data( + window.hwnd, + IME_COPYDATA_QUERY, + &query, + IME_QUERY_SEND_TIMEOUT_MS, + ) { + Ok(reply) => { + if let Some(status) = classify_query_reply(reply)? { + return Ok(status); } - }; + } + // The host is busy, possibly inside the commit itself. + Err(SendFailure::TimedOut) => {} + Err(SendFailure::NotDelivered(code)) => { + return Err(WindowsImeIpcError::OutcomeUnknown(format!( + "IME window went away after submit dispatch: OS error {code}" + ))); + } } - if Instant::now() >= deadline { - return Err(retry_error); + return Err(WindowsImeIpcError::OutcomeUnknown( + "OpenLess IME IPC timed out after submit dispatch".to_string(), + )); } - tokio::time::sleep(next_retry_delay(deadline)).await; } } - fn pipe_names_for_target(target: super::ImeSubmitTarget) -> Vec { - ime_pipe_candidate_names_for_target( - target.process_id, - target.thread_id, - available_pipe_names(), - ) - } - - fn available_pipe_names() -> Vec { - let Ok(entries) = std::fs::read_dir(r"\\.\pipe\") else { - return Vec::new(); - }; + fn find_ime_window_with_retry(target: ImeSubmitTarget) -> WindowsImeIpcResult { + let deadline = Instant::now() + IME_CLIENT_WAIT_TIMEOUT; + loop { + if let Some(window) = select_ime_window(target, &enumerate_ime_windows()) { + if window.thread_id != target.thread_id { + log::info!( + "[windows-ime] no IME window on target thread {}; using same-process thread {}", + target.thread_id, + window.thread_id + ); + } + return Ok(window); + } - entries - .filter_map(Result::ok) - .map(|entry| format!(r"\\.\pipe\{}", entry.file_name().to_string_lossy())) - .collect() + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(WindowsImeIpcError::NoReadyClient); + } + std::thread::sleep(remaining.min(IME_WINDOW_RETRY_INTERVAL)); + } } - fn wait_for_pipe_client(pipe_name: &str) -> WindowsImeIpcResult<()> { - let pipe_name = OsStr::new(pipe_name) - .encode_wide() + fn enumerate_ime_windows() -> Vec { + let class_name = OPENLESS_IME_MESSAGE_WINDOW_CLASS + .encode_utf16() .chain(std::iter::once(0)) .collect::>(); - let is_ready = unsafe { WaitNamedPipeW(pipe_name.as_ptr(), super::NMPWAIT_NOWAIT) }; - if is_ready != 0 { - return Ok(()); + let mut windows = Vec::new(); + let mut previous = 0isize; + for _ in 0..MAX_ENUMERATED_WINDOWS { + let hwnd = unsafe { + FindWindowExW( + HWND_MESSAGE, + previous, + class_name.as_ptr(), + std::ptr::null(), + ) + }; + if hwnd == 0 { + break; + } + let mut process_id = 0u32; + let thread_id = unsafe { GetWindowThreadProcessId(hwnd, &mut process_id) }; + if thread_id != 0 && process_id != 0 { + windows.push(ImeWindow { + hwnd, + process_id, + thread_id, + }); + } + previous = hwnd; } - - Err(super::map_wait_named_pipe_error( - std::io::Error::last_os_error() - .raw_os_error() - .map(|code| code as u32), - )) + windows } - fn is_retryable_wait_error(error: &WindowsImeIpcError) -> bool { - matches!( - error, - WindowsImeIpcError::NoReadyClient | WindowsImeIpcError::Timeout - ) - } + fn send_copy_data( + hwnd: isize, + kind: usize, + payload: &[u8], + timeout_ms: u32, + ) -> Result { + let data = CopyDataStruct { + dw_data: kind, + cb_data: payload.len() as u32, + lp_data: payload.as_ptr().cast(), + }; + let mut reply = 0usize; + let delivered = unsafe { + SendMessageTimeoutW( + hwnd, + WM_COPYDATA, + 0, + &data as *const CopyDataStruct as isize, + SMTO_ABORTIFHUNG, + timeout_ms, + &mut reply, + ) + }; + if delivered != 0 { + // The DLL's replies fit in 32 bits; a 32-bit host sign-extends them. + return Ok(reply as u32); + } - fn next_retry_delay(deadline: Instant) -> std::time::Duration { - deadline - .saturating_duration_since(Instant::now()) - .min(IME_PIPE_RETRY_INTERVAL) + match std::io::Error::last_os_error() + .raw_os_error() + .map(|code| code as u32) + { + None | Some(0) | Some(ERROR_TIMEOUT) => Err(SendFailure::TimedOut), + Some(code) => Err(SendFailure::NotDelivered(code)), + } } } #[cfg(test)] mod tests { use super::*; + use crate::windows_ime_protocol::IME_STATUS_UNKNOWN_TOKEN; - #[test] - fn pending_submit_accepts_only_matching_session() { - let mut pending = PendingImeSubmit::new("session-1".to_string()); - assert!(pending - .accept_result("session-2", ImeSubmitStatus::Committed) - .is_err()); - assert_eq!( - pending.accept_result("session-1", ImeSubmitStatus::Committed), - Ok(ImeSubmitStatus::Committed) - ); + fn window(hwnd: isize, process_id: u32, thread_id: u32) -> ImeWindow { + ImeWindow { + hwnd, + process_id, + thread_id, + } } #[test] - fn pending_submit_rejects_second_result_after_completion() { - let mut pending = PendingImeSubmit::new("session-1".to_string()); - assert_eq!( - pending.accept_result("session-1", ImeSubmitStatus::Committed), - Ok(ImeSubmitStatus::Committed) - ); - assert!(pending - .accept_result("session-1", ImeSubmitStatus::Committed) - .is_err()); + fn submit_timeout_stays_within_followup_stall_budget() { + assert_eq!(IME_SUBMIT_TIMEOUT, Duration::from_millis(5000)); } #[test] - fn submit_timeout_covers_native_async_commit_path() { - assert!(IME_SUBMIT_TIMEOUT > Duration::from_millis(IME_NATIVE_ASYNC_COMMIT_TIMEOUT_MS)); + fn only_post_dispatch_failures_have_unknown_outcomes() { + assert!(WindowsImeIpcError::OutcomeUnknown("fixture".to_string()).is_outcome_unknown()); + assert!(!WindowsImeIpcError::Timeout.is_outcome_unknown()); + assert!(!WindowsImeIpcError::NoReadyClient.is_outcome_unknown()); } #[test] - fn submit_timeout_stays_within_followup_stall_budget() { - assert_eq!(IME_SUBMIT_TIMEOUT, Duration::from_millis(5000)); + fn submit_tokens_are_nonzero_and_change_between_submits() { + let first = next_submit_token(); + let second = next_submit_token(); + assert_ne!(first, 0); + assert_ne!(second, 0); + assert_ne!(first, second); } #[test] - fn wait_pipe_error_mapping_treats_missing_or_busy_pipe_as_no_ready_client() { - assert_eq!( - map_wait_named_pipe_error(Some(2)), - WindowsImeIpcError::NoReadyClient - ); - assert_eq!( - map_wait_named_pipe_error(Some(231)), - WindowsImeIpcError::NoReadyClient - ); + fn exact_thread_window_wins_over_same_process_window() { + let target = ImeSubmitTarget { + process_id: 1234, + thread_id: 5678, + }; + let windows = [ + window(1, 4321, 1111), + window(2, 1234, 9999), + window(3, 1234, 5678), + ]; + assert_eq!(select_ime_window(target, &windows), Some(windows[2])); } #[test] - fn wait_pipe_error_mapping_treats_wait_timeout_as_timeout() { - assert_eq!( - map_wait_named_pipe_error(Some(121)), - WindowsImeIpcError::Timeout - ); + fn same_process_window_is_used_when_target_thread_has_none() { + let target = ImeSubmitTarget { + process_id: 1234, + thread_id: 5678, + }; + let windows = [window(1, 4321, 5678), window(2, 1234, 9999)]; + assert_eq!(select_ime_window(target, &windows), Some(windows[1])); + assert_eq!(select_ime_window(target, &windows[..1]), None); } #[test] - fn only_post_dispatch_failures_have_unknown_outcomes() { - assert!(WindowsImeIpcError::OutcomeUnknown("fixture".to_string()).is_outcome_unknown()); - assert!(!WindowsImeIpcError::Timeout.is_outcome_unknown()); - assert!(!WindowsImeIpcError::NoReadyClient.is_outcome_unknown()); + fn submit_reply_failures_are_definite_and_allow_fallback() { + assert_eq!(classify_submit_reply(IME_STATUS_ACCEPTED), Ok(())); + for reply in [0, IME_STATUS_BAD_REQUEST, IME_STATUS_COMMITTED, 0x8007_000E] { + let error = classify_submit_reply(reply).unwrap_err(); + assert!(!error.is_outcome_unknown(), "reply 0x{reply:08X}"); + } } #[test] - fn native_timeout_and_cancel_responses_are_not_safe_to_retry() { - for status in ["rejected", "failed"] { - for code in ["hresult:0x800705B4", "hresult:0x800704C7"] { - let response = serde_json::json!({ - "type": "submitResult", "protocolVersion": 1, - "sessionId": "session-1", "status": status, "errorCode": code, - }) - .to_string(); - let mut pending = PendingImeSubmit::new("session-1".into()); - assert!(matches!( - classify_dispatched_submit_response(&response, &mut pending), - Err(WindowsImeIpcError::OutcomeUnknown(_)) - )); - } - } + fn query_reply_reports_pending_committed_and_definite_rejection() { + assert_eq!(classify_query_reply(IME_STATUS_PENDING), Ok(None)); + assert_eq!( + classify_query_reply(IME_STATUS_COMMITTED), + Ok(Some(ImeSubmitStatus::Committed)) + ); + assert_eq!( + classify_query_reply(0x8000_4005), + Ok(Some(ImeSubmitStatus::Rejected)) + ); } #[test] - fn dispatched_responses_validate_ownership_before_allowing_definite_fallback() { - for response in [ - "{", - r#"{"type":"ping","protocolVersion":1}"#, - r#"{"type":"submitResult","protocolVersion":2,"sessionId":"session-1","status":"committed"}"#, - r#"{"type":"submitResult","protocolVersion":1,"sessionId":"other","status":"committed"}"#, - ] { - let mut pending = PendingImeSubmit::new("session-1".into()); + fn native_timeout_and_cancel_responses_are_not_safe_to_retry() { + for reply in [HRESULT_TIMEOUT, HRESULT_CANCELLED] { assert!(matches!( - classify_dispatched_submit_response(response, &mut pending), + classify_query_reply(reply), Err(WindowsImeIpcError::OutcomeUnknown(_)) )); } - let mut pending = PendingImeSubmit::new("session-1".into()); - assert_eq!( - classify_dispatched_submit_response( - r#"{"type":"submitResult","protocolVersion":1,"sessionId":"session-1","status":"rejected","errorCode":"hresult:0x80004005"}"#, - &mut pending - ), - Ok(ImeSubmitStatus::Rejected) - ); } #[test] - fn missing_busy_and_timeout_pipe_errors_are_retryable_before_deadline() { - assert!(is_retryable_pipe_error(Some(2))); - assert!(is_retryable_pipe_error(Some(3))); - assert!(is_retryable_pipe_error(Some(121))); - assert!(is_retryable_pipe_error(Some(231))); - assert!(!is_retryable_pipe_error(Some(5))); - assert!(!is_retryable_pipe_error(None)); + fn ambiguous_query_replies_never_allow_definite_fallback() { + // An unhandled message, a superseded token or a stray ack all mean the + // dispatched text can no longer be accounted for. + for reply in [0, IME_STATUS_UNKNOWN_TOKEN, IME_STATUS_ACCEPTED, 0x1234] { + assert!(matches!( + classify_query_reply(reply), + Err(WindowsImeIpcError::OutcomeUnknown(_)) + )); + } } } 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 b0114cef7..561379a58 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -1,5 +1,8 @@ #![allow(dead_code, unused_imports, unused_variables)] -pub const OPENLESS_TSF_LANG_ID: u16 = 0x0804; +/// All languages (`0xFFFF`). The text service is registered under the TSF speech +/// category, which TSF keeps active next to the user's keyboard IME, so dictation +/// never switches the keyboard IME away and back. +pub const OPENLESS_TSF_LANG_ID: u16 = 0xFFFF; 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}"; @@ -12,135 +15,6 @@ fn parse_guid(value: &str) -> WindowsImeProfileResult { .map_err(|err| WindowsImeProfileError::WindowsApi(format!("invalid GUID {value}: {err}"))) } -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum ImeProfileKind { - KeyboardLayout, - TextService, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ImeProfileSnapshot { - kind: ImeProfileKind, - lang_id: u16, - clsid: Option, - profile_guid: Option, - hkl: Option, -} - -impl ImeProfileSnapshot { - pub fn text_service(lang_id: u16, clsid: String, profile_guid: String) -> Self { - Self { - kind: ImeProfileKind::TextService, - lang_id, - clsid: Some(clsid), - profile_guid: Some(profile_guid), - hkl: None, - } - } - - pub fn keyboard_layout(lang_id: u16, hkl: isize) -> Self { - Self { - kind: ImeProfileKind::KeyboardLayout, - lang_id, - clsid: None, - profile_guid: None, - hkl: Some(hkl), - } - } - - pub fn kind(&self) -> &ImeProfileKind { - &self.kind - } - - pub fn lang_id(&self) -> u16 { - self.lang_id - } - - pub fn clsid(&self) -> Option<&str> { - self.clsid.as_deref() - } - - pub fn profile_guid(&self) -> Option<&str> { - self.profile_guid.as_deref() - } - - pub fn hkl(&self) -> Option { - self.hkl - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProfileRestoreDecision { - RestoreSavedProfile, - KeepCurrentProfile, -} - -/// Whether the snapshot is OpenLess's own TSF profile. -/// -/// Sticky-state protection: if the last session's restore failed and OpenLess is still the -/// current IME, the next `prepare_session` would capture OpenLess itself as the "original -/// IME"; restore must then be skipped, otherwise OpenLess gets persisted as the original -/// IME (the self-sticky failure state of 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) -} - -/// Test-only: build a TSF snapshot of OpenLess itself. -/// -/// Identifiers derive from the production constants (lowercased to exercise the GUID -/// normalization path), so test literals cannot drift from production constants — if a -/// constant changes, the test follows and validates the new value. -#[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}}}") - } -} - -/// Decide whether to restore the original IME based on session state. -/// -/// - The session really activated OpenLess (`openless_was_activated`) → restore; -/// - Activation failed but the original snapshot was captured (`openless_activation_failed`) -/// → still restore, cleaning up a half-finished activation's leftover state; -/// - Neither activated nor a failure snapshot (original IME never captured / non-Windows) -/// → keep the current profile. -/// -/// Note: this **no longer** takes the result of `is_openless_profile_active()`. That probe -/// runs on a background thread of OpenLess's own process, while OpenLess IME activation -/// happens in the target app's process; `GetActiveProfile` may return a thread-local default -/// profile, misjudged as "the user already switched away" and skipping the restore -/// (issue #852). The restore decision must rely only on activation facts we already know. -pub fn restore_decision( - saved: Option<&ImeProfileSnapshot>, - openless_was_activated: bool, - openless_activation_failed: bool, -) -> ProfileRestoreDecision { - if saved.is_some() && (openless_was_activated || openless_activation_failed) { - ProfileRestoreDecision::RestoreSavedProfile - } else { - ProfileRestoreDecision::KeepCurrentProfile - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub enum WindowsImeProfileError { Unavailable(String), @@ -208,14 +82,9 @@ pub fn is_openless_language_profile_enabled() -> WindowsImeProfileResult { /// "not installed" branch is testable on any platform (`apply_windows_openless_keyboard_list` /// depends on the Windows registry; macOS/CI can't reach its internal branches). /// -/// Key semantics: with the TSF IME not installed, the keyboard list has no OpenLess entry at all — -/// - `desired == false` (hide) is already satisfied, a natural no-op; -/// - `desired == true` (show) can only be a no-op too (nothing to enable). -/// -/// Both branches must be `Ok(())`. Previously "hide + not installed" wrongly returned `Err`, -/// which propagated through the settings.rs transaction and rolled back the whole settings -/// save (with a non-TSF insertion method and the TSF IME not installed, checking "don't show -/// in the keyboard list" made any later setting impossible to save). +/// Both branches must be `Ok(())`: with the TSF IME not installed there is nothing to enable or +/// hide. Returning `Err` here propagated through the settings.rs transaction and rolled back the +/// whole settings save. fn keyboard_list_pref_short_circuit( install_state: WindowsImeInstallState, _desired: bool, @@ -227,7 +96,12 @@ fn keyboard_list_pref_short_circuit( } } -/// Sync the keyboard list target state already decided by Core into the current user's TSF language profile. +/// Keep the current user's TSF language profile enabled. +/// +/// The text service is a speech-category profile: it never appears in the keyboard +/// list, and TSF only activates it while the profile is enabled. The "show in +/// keyboard list" preference therefore no longer disables it; `desired` is kept +/// so the not-installed short-circuit and the callers stay unchanged. pub fn apply_windows_openless_keyboard_list(desired: bool) -> Result<(), String> { #[cfg(target_os = "windows")] { @@ -235,9 +109,9 @@ pub fn apply_windows_openless_keyboard_list(desired: bool) -> Result<(), String> if let Some(result) = keyboard_list_pref_short_circuit(status.state, desired) { return result; } - set_openless_language_profile_enabled(desired).map_err(|err| { + set_openless_language_profile_enabled(true).map_err(|err| { let message = err.to_string(); - log::warn!("[windows-ime] apply keyboard list visibility pref failed: {message}"); + log::warn!("[windows-ime] enabling the TSF language profile failed: {message}"); message }) } @@ -248,118 +122,29 @@ pub fn apply_windows_openless_keyboard_list(desired: bool) -> Result<(), String> } } -#[cfg(target_os = "windows")] -pub struct WindowsImeProfileManager; - -#[cfg(target_os = "windows")] -impl WindowsImeProfileManager { - pub fn new() -> Self { - Self - } - - pub fn capture_active_profile(&self) -> WindowsImeProfileResult { - windows_impl::capture_active_profile() - } - - pub fn activate_openless_profile(&self) -> WindowsImeProfileResult<()> { - windows_impl::activate_openless_profile() - } - - pub fn restore_profile(&self, snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> { - windows_impl::restore_profile(snapshot) - } - - pub fn is_openless_profile_active(&self) -> WindowsImeProfileResult { - windows_impl::is_openless_profile_active() - } -} - -#[cfg(not(target_os = "windows"))] -pub struct WindowsImeProfileManager; - -#[cfg(not(target_os = "windows"))] -impl WindowsImeProfileManager { - pub fn new() -> Self { - Self - } - - pub fn capture_active_profile(&self) -> WindowsImeProfileResult { - Err(WindowsImeProfileError::Unavailable( - "Windows TSF profiles are only available on Windows".to_string(), - )) - } - - pub fn activate_openless_profile(&self) -> WindowsImeProfileResult<()> { - Err(WindowsImeProfileError::Unavailable( - "Windows TSF profiles are only available on Windows".to_string(), - )) - } - - pub fn restore_profile(&self, _snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> { - Err(WindowsImeProfileError::Unavailable( - "Windows TSF profiles are only available on Windows".to_string(), - )) - } - - pub fn is_openless_profile_active(&self) -> WindowsImeProfileResult { - Ok(false) - } -} - -/// Combine the legacy and modern restore paths' results: either succeeding counts as overall -/// success; only both failing fails, with each failure reason logged. -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::*; - use std::ffi::c_void; use std::path::Path; - use std::ptr; - use windows::core::{GUID, HRESULT}; + use windows::core::HRESULT; use windows::Win32::Foundation::BOOL; use windows::Win32::Foundation::RPC_E_CHANGED_MODE; use windows::Win32::System::Com::{ CoCreateInstance, CoInitializeEx, CoUninitialize, CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, }; - use windows::Win32::UI::Input::KeyboardAndMouse::HKL; use windows::Win32::UI::TextServices::{ - CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, ITfInputProcessorProfiles, - GUID_TFCAT_TIP_KEYBOARD, TF_INPUTPROCESSORPROFILE, TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE, - TF_IPPMF_ENABLEPROFILE, TF_IPPMF_FORSESSION, TF_PROFILETYPE_INPUTPROCESSOR, - TF_PROFILETYPE_KEYBOARDLAYOUT, + CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfiles, }; use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_32KEY, KEY_WOW64_64KEY}; use winreg::RegKey; const OPENLESS_COM_INPROC_KEY: &str = r"Software\Classes\CLSID\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\InprocServer32"; - const OPENLESS_TSF_PROFILE_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x00000804\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; - 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_PROFILE_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\LanguageProfile\0x0000ffff\{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; + const OPENLESS_TSF_SPEECH_CATEGORY_KEY: &str = r"Software\Microsoft\CTF\TIP\{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}\Category\Category\{B5A73CD1-8355-426B-A161-259808F26B14}\{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}"; - 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; pub(super) struct ComInitializeOwnership { pub(super) should_uninitialize: bool, @@ -408,146 +193,6 @@ mod windows_impl { } } - pub fn capture_active_profile() -> WindowsImeProfileResult { - let profile = with_profile_manager(|manager| { - let mut profile = TF_INPUTPROCESSORPROFILE::default(); - unsafe { - manager.GetActiveProfile(&active_profile_category_guid(), &mut profile)?; - } - - Ok(profile) - })?; - - if profile.dwProfileType == TF_PROFILETYPE_INPUTPROCESSOR { - Ok(ImeProfileSnapshot::text_service( - profile.langid, - guid_to_braced_string(profile.clsid), - guid_to_braced_string(profile.guidProfile), - )) - } else { - keyboard_layout_snapshot_from_tsf(profile.langid, profile.hkl) - } - } - - pub(super) fn guid_to_braced_string(guid: GUID) -> String { - format!( - "{{{:08X}-{:04X}-{:04X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}}}", - guid.data1, - guid.data2, - guid.data3, - guid.data4[0], - guid.data4[1], - guid.data4[2], - guid.data4[3], - guid.data4[4], - guid.data4[5], - guid.data4[6], - guid.data4[7], - ) - } - - pub fn activate_openless_profile() -> WindowsImeProfileResult<()> { - let clsid = parse_guid(OPENLESS_TEXT_SERVICE_CLSID_BRACED)?; - let profile_guid = parse_guid(OPENLESS_PROFILE_GUID_BRACED)?; - - 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) - })?; - - with_profile_manager(|manager| unsafe { - manager.ActivateProfile( - TF_PROFILETYPE_INPUTPROCESSOR, - OPENLESS_TSF_LANG_ID, - &clsid, - &profile_guid, - null_hkl(), - OPENLESS_PROFILE_ACTIVATION_FLAGS, - ) - }) - } - - pub fn restore_profile(snapshot: &ImeProfileSnapshot) -> WindowsImeProfileResult<()> { - // Must stay symmetric with the activate_openless_profile path: activation calls both the - // legacy ITfInputProcessorProfiles ChangeCurrentLanguage + ActivateLanguageProfile and - // the modern ITfInputProcessorProfileMgr::ActivateProfile; calling only the modern one - // doesn't update the legacy current language / active profile state, so the OS still - // treats OpenLess as the current IME and the user's IME can't be switched back. issue #469. - // - // #852 hardening: legacy and modern run independently with results recorded separately; - // a legacy failure no longer short-circuits the modern call (a legacy `?` previously - // meant the modern ActivateProfile never ran and the whole restore failed). Either - // succeeding counts as success: legacy success → the OS visual layer (language - // indicator, keyboard event routing) switched back; modern success → session-level - // activation switched back. Only both failing fails. - let lang_id = snapshot.lang_id(); - - // legacy and modern share the same resolved arguments (TextService: CLSID + profile GUID; - // KeyboardLayout: HKL). GUID parse failure fails the whole restore, as before. - let args = resolve_restore_args(snapshot)?; - - // legacy steps: switch the language first, then activate the specific profile for a - // TextService (KeyboardLayout has no 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) - } - - /// Resolved arguments for a single restore (shared by the legacy and modern paths). - struct RestoreArgs { - profile_type: u32, - clsid: GUID, - profile_guid: GUID, - hkl: HKL, - } - - /// Resolve restore arguments: TextService uses CLSID + profile GUID, KeyboardLayout uses 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(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); - 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(is_openless_profile_snapshot(&snapshot)) - } - pub fn set_openless_language_profile_enabled(enabled: bool) -> WindowsImeProfileResult<()> { let clsid = parse_guid(OPENLESS_TEXT_SERVICE_CLSID_BRACED)?; let profile_guid = parse_guid(OPENLESS_PROFILE_GUID_BRACED)?; @@ -610,11 +255,8 @@ mod windows_impl { let tip_key_exists = hklm .open_subkey_with_flags(OPENLESS_TSF_PROFILE_KEY, KEY_READ | KEY_WOW64_64KEY) .is_ok(); - let keyboard_category_exists = hklm - .open_subkey_with_flags( - OPENLESS_TSF_KEYBOARD_CATEGORY_KEY, - KEY_READ | KEY_WOW64_64KEY, - ) + let speech_category_exists = hklm + .open_subkey_with_flags(OPENLESS_TSF_SPEECH_CATEGORY_KEY, KEY_READ | KEY_WOW64_64KEY) .is_ok(); let immersive_category_exists = hklm .open_subkey_with_flags( @@ -629,7 +271,7 @@ mod windows_impl { ) .is_ok(); - if com_key.is_err() && !tip_key_exists && !keyboard_category_exists { + if com_key.is_err() && !tip_key_exists && !speech_category_exists { return RegistrationInspection::NotInstalled; } @@ -683,10 +325,13 @@ mod windows_impl { }; } - if !keyboard_category_exists { + if !speech_category_exists { + // Also the state right after upgrading the app while a DLL from a + // release that registered a keyboard profile is still in place. return RegistrationInspection::Broken { dll_path: Some(dll_path), - reason: "OpenLess TSF keyboard category registration is missing".to_string(), + reason: "OpenLess TSF speech category registration is missing; reinstall the IME" + .to_string(), }; } @@ -711,20 +356,6 @@ mod windows_impl { } } - fn with_profile_manager( - operation: impl FnOnce(&ITfInputProcessorProfileMgr) -> windows::core::Result, - ) -> WindowsImeProfileResult { - let _com = ComApartment::initialize()?; - let manager: ITfInputProcessorProfileMgr = unsafe { - CoCreateInstance(&CLSID_TF_InputProcessorProfiles, None, CLSCTX_INPROC_SERVER) - } - .map_err(windows_api_error( - "CoCreateInstance ITfInputProcessorProfileMgr", - ))?; - - operation(&manager).map_err(windows_api_error("ITfInputProcessorProfileMgr operation")) - } - fn with_input_processor_profiles( operation: impl FnOnce(&ITfInputProcessorProfiles) -> windows::core::Result, ) -> WindowsImeProfileResult { @@ -739,38 +370,6 @@ mod windows_impl { operation(&profiles).map_err(windows_api_error("ITfInputProcessorProfiles operation")) } - fn parse_required_guid(label: &str, value: Option<&str>) -> WindowsImeProfileResult { - parse_guid(value.ok_or_else(|| { - WindowsImeProfileError::WindowsApi(format!("missing {label} in saved IME profile")) - })?) - } - - pub(super) fn active_profile_category_guid() -> GUID { - GUID_TFCAT_TIP_KEYBOARD - } - - pub(super) fn keyboard_layout_snapshot_from_tsf( - lang_id: u16, - hkl: HKL, - ) -> WindowsImeProfileResult { - let hkl_value = hkl_to_isize(hkl); - if hkl_value == 0 { - return Err(WindowsImeProfileError::WindowsApi( - "active keyboard layout profile has no HKL".to_string(), - )); - } - - Ok(ImeProfileSnapshot::keyboard_layout(lang_id, hkl_value)) - } - - fn hkl_to_isize(hkl: HKL) -> isize { - hkl.0 as isize - } - - fn null_hkl() -> HKL { - HKL(ptr::null_mut()) - } - fn windows_api_error( context: &'static str, ) -> impl FnOnce(windows::core::Error) -> WindowsImeProfileError { @@ -782,95 +381,13 @@ mod windows_impl { mod tests { use super::*; - fn text_service_snapshot() -> ImeProfileSnapshot { - ImeProfileSnapshot::text_service( - 0x0804, - "{11111111-1111-1111-1111-111111111111}".to_string(), - "{22222222-2222-2222-2222-222222222222}".to_string(), - ) - } - - #[test] - fn text_service_constructor_sets_required_profile_data() { - let snapshot = text_service_snapshot(); - - assert_eq!(snapshot.kind(), &ImeProfileKind::TextService); - assert_eq!(snapshot.lang_id(), 0x0804); - assert_eq!( - snapshot.clsid(), - Some("{11111111-1111-1111-1111-111111111111}") - ); - assert_eq!( - snapshot.profile_guid(), - Some("{22222222-2222-2222-2222-222222222222}") - ); - assert_eq!(snapshot.hkl(), None); - } - - #[test] - fn keyboard_layout_constructor_sets_required_hkl_data() { - let snapshot = ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409); - - assert_eq!(snapshot.kind(), &ImeProfileKind::KeyboardLayout); - assert_eq!(snapshot.lang_id(), 0x0409); - assert_eq!(snapshot.clsid(), None); - assert_eq!(snapshot.profile_guid(), None); - assert_eq!(snapshot.hkl(), Some(0x0409_0409)); - } - - #[test] - fn restore_is_required_when_openless_was_activated() { - assert_eq!( - restore_decision(Some(&text_service_snapshot()), true, false), - ProfileRestoreDecision::RestoreSavedProfile - ); - } - - #[test] - fn restore_is_required_after_activation_failure_with_snapshot() { - assert_eq!( - restore_decision(Some(&text_service_snapshot()), false, true), - ProfileRestoreDecision::RestoreSavedProfile - ); - } - - #[test] - fn restore_is_skipped_when_snapshot_is_missing() { - assert_eq!( - restore_decision(None, true, true), - ProfileRestoreDecision::KeepCurrentProfile - ); - } - - #[test] - 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() { - // GUIDs differing in case/braces must also be recognized as OpenLess after normalization (sticky-state protection). - 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)); - } - - // ── Fix: the "keyboard list visibility" preference must not error when the TSF IME is not installed ── - // "hide" (`desired == false`) + not installed previously returned Err, which propagated - // through settings.rs's apply_keyboard_list(&prefs)? and rolled back the whole settings save - // transaction. Regression guard. + // ── The "keyboard list visibility" preference must not error when the TSF IME is not installed ── + // A not-installed state previously returned Err, which propagated through settings.rs's + // apply_keyboard_list(&prefs)? and rolled back the whole settings save transaction. + // Regression guard. #[test] fn uninstalled_hide_request_is_noop_ok() { - // The user wants OpenLess hidden from the keyboard list but the TSF IME isn't installed → already satisfied → Ok(()). assert_eq!( keyboard_list_pref_short_circuit(WindowsImeInstallState::NotInstalled, false), Some(Ok(())) @@ -879,7 +396,6 @@ mod tests { #[test] fn uninstalled_show_request_is_noop_ok() { - // The user wants it shown but it isn't installed → only a no-op is possible (nothing to enable) → Ok(()). assert_eq!( keyboard_list_pref_short_circuit(WindowsImeInstallState::NotInstalled, true), Some(Ok(())) @@ -888,7 +404,6 @@ mod tests { #[test] fn broken_registration_short_circuits_ok_for_both_desired_values() { - // Broken registration also means "no trusted entry in the list" → both branches short-circuit to Ok(()), never Err. assert_eq!( keyboard_list_pref_short_circuit(WindowsImeInstallState::RegistrationBroken, false), Some(Ok(())) @@ -909,7 +424,6 @@ mod tests { #[test] fn installed_state_proceeds_to_real_profile_mutation() { - // Installed → no short-circuit; hand off to the real EnableLanguageProfile change. assert_eq!( keyboard_list_pref_short_circuit(WindowsImeInstallState::Installed, false), None @@ -919,58 +433,16 @@ 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"))] mod windows_tests { use super::*; - use std::ffi::c_void; - use std::ptr; use windows::Win32::Foundation::RPC_E_CHANGED_MODE; - use windows::Win32::UI::Input::KeyboardAndMouse::HKL; - use windows::Win32::UI::TextServices::GUID_TFCAT_TIP_KEYBOARD; #[test] fn openless_profile_identifiers_are_fixed() { - assert_eq!(OPENLESS_TSF_LANG_ID, 0x0804); + assert_eq!(OPENLESS_TSF_LANG_ID, 0xFFFF); assert_eq!( OPENLESS_TEXT_SERVICE_CLSID_BRACED, "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}" @@ -981,46 +453,6 @@ mod windows_tests { ); } - #[test] - fn active_profile_capture_uses_keyboard_tip_category() { - assert_eq!( - windows_impl::active_profile_category_guid(), - GUID_TFCAT_TIP_KEYBOARD - ); - } - - #[test] - fn keyboard_layout_snapshot_uses_tsf_profile_values() { - let snapshot = windows_impl::keyboard_layout_snapshot_from_tsf( - 0x0411, - HKL(0x0411_0411usize as *mut c_void), - ) - .unwrap(); - - assert_eq!(snapshot.kind(), &ImeProfileKind::KeyboardLayout); - assert_eq!(snapshot.lang_id(), 0x0411); - assert_eq!(snapshot.hkl(), Some(0x0411_0411)); - } - - #[test] - fn keyboard_layout_snapshot_rejects_missing_hkl() { - let err = windows_impl::keyboard_layout_snapshot_from_tsf(0x0409, HKL(ptr::null_mut())) - .unwrap_err(); - - assert!(err - .to_string() - .contains("active keyboard layout profile has no HKL")); - } - - #[test] - fn guid_snapshot_strings_are_canonical_and_parseable() { - let guid = windows::core::GUID::from_u128(0x6b9f3f4f_5ee7_42d6_9c61_9f80b03a5d7d); - let formatted = windows_impl::guid_to_braced_string(guid); - - assert_eq!(formatted, "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"); - assert!(parse_guid(&formatted).is_ok()); - } - #[test] fn com_changed_mode_is_accepted_without_uninitializing() { let ownership = windows_impl::coinitialize_result_ownership(RPC_E_CHANGED_MODE).unwrap(); 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 15c8f397d..40d86afa6 100644 --- a/openless-all/app/src-tauri/src/windows_ime_protocol.rs +++ b/openless-all/app/src-tauri/src/windows_ime_protocol.rs @@ -1,76 +1,26 @@ #![allow(dead_code, unused_imports, unused_variables)] +//! Wire format shared with `windows-ime/src/text_service.cpp`. +//! +//! OpenLess sends `WM_COPYDATA` to the message-only window the IME creates on +//! the host's TSF thread. A submit carries a token plus the text and is only +//! acknowledged; the commit result is then polled with query messages carrying +//! the same token. Replies travel back as the message result. use serde::{Deserialize, Serialize}; -pub const OPENLESS_IME_PROTOCOL_VERSION: u32 = 1; -pub const OPENLESS_IME_PIPE_NAME_PREFIX: &str = r"\\.\pipe\OpenLessImeSubmit"; +pub const OPENLESS_IME_MESSAGE_WINDOW_CLASS: &str = "OpenLessImeMessageWindow"; -pub fn ime_pipe_name_for_target(process_id: u32, thread_id: u32) -> String { - format!("{OPENLESS_IME_PIPE_NAME_PREFIX}-{process_id}-{thread_id}") -} - -pub fn ime_pipe_candidate_names_for_target( - process_id: u32, - thread_id: u32, - available_pipe_names: I, -) -> Vec -where - I: IntoIterator, -{ - let exact_pipe_name = ime_pipe_name_for_target(process_id, thread_id); - let process_pipe_prefix = format!("{OPENLESS_IME_PIPE_NAME_PREFIX}-{process_id}-"); - let mut candidates = vec![exact_pipe_name.clone()]; - let mut same_process_pipe_names = available_pipe_names - .into_iter() - .filter(|pipe_name| pipe_name != &exact_pipe_name) - .filter(|pipe_name| { - pipe_name - .strip_prefix(&process_pipe_prefix) - .is_some_and(|thread_suffix| { - !thread_suffix.is_empty() - && thread_suffix.bytes().all(|byte| byte.is_ascii_digit()) - }) - }) - .collect::>(); +/// `COPYDATASTRUCT::dwData` tags: "OLS1" (token + UTF-16LE text) and "OLQ1" (token). +pub const IME_COPYDATA_SUBMIT: usize = 0x4F4C_5331; +pub const IME_COPYDATA_QUERY: usize = 0x4F4C_5131; +pub const IME_MAX_SUBMIT_BYTES: usize = 1024 * 1024; - same_process_pipe_names.sort(); - same_process_pipe_names.dedup(); - candidates.extend(same_process_pipe_names); - candidates -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde( - tag = "type", - rename_all = "camelCase", - rename_all_fields = "camelCase" -)] -pub enum ImePipeMessage { - ClientReady { - protocol_version: u32, - client_id: String, - process_id: u32, - thread_id: u32, - }, - SubmitText { - protocol_version: u32, - session_id: String, - text: String, - created_at: String, - }, - SubmitResult { - protocol_version: u32, - session_id: String, - status: ImeSubmitStatus, - error_code: Option, - }, - CancelSession { - protocol_version: u32, - session_id: String, - }, - Ping { - protocol_version: u32, - }, -} +/// Replies are nonzero so they differ from an unhandled message (0). A failed +/// commit is reported as its HRESULT, which always has the high bit set. +pub const IME_STATUS_ACCEPTED: u32 = 0x4F4C_0001; +pub const IME_STATUS_PENDING: u32 = 0x4F4C_0002; +pub const IME_STATUS_COMMITTED: u32 = 0x4F4C_0003; +pub const IME_STATUS_UNKNOWN_TOKEN: u32 = 0x4F4C_0004; +pub const IME_STATUS_BAD_REQUEST: u32 = 0x4F4C_0005; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -80,27 +30,21 @@ pub enum ImeSubmitStatus { Failed, } -pub fn encode_message(message: &ImePipeMessage) -> Result { - let mut line = serde_json::to_string(message)?; - line.push('\n'); - Ok(line) +pub fn is_failed_hresult(reply: u32) -> bool { + reply & 0x8000_0000 != 0 } -pub fn decode_message(line: &str) -> Result { - serde_json::from_str(line) +pub fn encode_submit_payload(token: u32, text: &str) -> Vec { + let mut payload = Vec::with_capacity(4 + text.len() * 2); + payload.extend_from_slice(&token.to_le_bytes()); + for unit in text.encode_utf16() { + payload.extend_from_slice(&unit.to_le_bytes()); + } + payload } -pub fn is_result_for_pending_session( - message: &ImePipeMessage, - pending_session_id: &str, -) -> Result<(), &'static str> { - match message { - ImePipeMessage::SubmitResult { session_id, .. } if session_id == pending_session_id => { - Ok(()) - } - ImePipeMessage::SubmitResult { .. } => Err("submit result belongs to a different session"), - _ => Err("message is not a submit result"), - } +pub fn encode_query_payload(token: u32) -> [u8; 4] { + token.to_le_bytes() } #[cfg(test)] @@ -108,67 +52,38 @@ mod tests { use super::*; #[test] - fn submit_text_roundtrips_as_camel_case_json() { - let message = ImePipeMessage::SubmitText { - protocol_version: OPENLESS_IME_PROTOCOL_VERSION, - session_id: "session-1".to_string(), - text: "\u{4f60}\u{597d} OpenLess".to_string(), - created_at: "2026-05-01T12:00:00Z".to_string(), - }; - - let json = encode_message(&message).expect("encode"); - assert!(json.contains("\"submitText\"")); - assert!(json.contains("\"sessionId\"")); - assert!(json.contains("\"createdAt\"")); - assert!(!json.contains("\"session_id\"")); - assert!(!json.contains("\"created_at\"")); - assert!(json.ends_with('\n')); - - let decoded = decode_message(json.trim_end()).expect("decode"); - assert_eq!(decoded, message); - } - - #[test] - fn ime_pipe_name_includes_target_process_and_thread() { + fn submit_payload_is_token_followed_by_utf16le_text() { assert_eq!( - ime_pipe_name_for_target(1234, 5678), - r"\\.\pipe\OpenLessImeSubmit-1234-5678" + encode_submit_payload(0x0403_0201, "A\u{4f60}"), + vec![0x01, 0x02, 0x03, 0x04, 0x41, 0x00, 0x60, 0x4f] ); } #[test] - fn ime_pipe_candidates_include_same_process_clients_after_exact_target() { - let available = vec![ - r"\\.\pipe\OtherPipe".to_string(), - r"\\.\pipe\OpenLessImeSubmit-4321-1111".to_string(), - r"\\.\pipe\OpenLessImeSubmit-1234-9999".to_string(), - r"\\.\pipe\OpenLessImeSubmit-1234-5678".to_string(), - r"\\.\pipe\OpenLessImeSubmit-1234-bad".to_string(), - ]; - - assert_eq!( - ime_pipe_candidate_names_for_target(1234, 5678, available), - vec![ - r"\\.\pipe\OpenLessImeSubmit-1234-5678".to_string(), - r"\\.\pipe\OpenLessImeSubmit-1234-9999".to_string(), - ] - ); + fn submit_payload_keeps_surrogate_pairs() { + let payload = encode_submit_payload(1, "\u{1F600}"); + assert_eq!(payload.len(), 4 + 4); + assert_eq!(&payload[4..], &[0x3D, 0xD8, 0x00, 0xDE]); } #[test] - fn stale_submit_result_is_rejected() { - let result = ImePipeMessage::SubmitResult { - protocol_version: OPENLESS_IME_PROTOCOL_VERSION, - session_id: "old-session".to_string(), - status: ImeSubmitStatus::Committed, - error_code: Some("ime-busy".to_string()), - }; - - let json = encode_message(&result).expect("encode"); - assert!(json.contains("\"errorCode\"")); - assert!(!json.contains("\"error_code\"")); + fn empty_text_still_carries_the_token() { + assert_eq!(encode_submit_payload(7, ""), vec![7, 0, 0, 0]); + assert_eq!(encode_query_payload(7), [7, 0, 0, 0]); + } - assert!(is_result_for_pending_session(&result, "current-session").is_err()); - assert!(is_result_for_pending_session(&result, "old-session").is_ok()); + #[test] + fn status_codes_never_look_like_failed_hresults() { + for status in [ + IME_STATUS_ACCEPTED, + IME_STATUS_PENDING, + IME_STATUS_COMMITTED, + IME_STATUS_UNKNOWN_TOKEN, + IME_STATUS_BAD_REQUEST, + ] { + assert_ne!(status, 0); + assert!(!is_failed_hresult(status)); + } + assert!(is_failed_hresult(0x8000_4005)); } } diff --git a/openless-all/app/src-tauri/src/windows_ime_restore.rs b/openless-all/app/src-tauri/src/windows_ime_restore.rs deleted file mode 100644 index 1f0292e9f..000000000 --- a/openless-all/app/src-tauri/src/windows_ime_restore.rs +++ /dev/null @@ -1,237 +0,0 @@ -#![allow(dead_code, unused_imports, unused_variables)] - -use crate::windows_ime_profile::{ - is_openless_profile_snapshot, ImeProfileSnapshot, WindowsImeProfileResult, -}; - -/// Wait before retrying after `restore_profile` fails (both legacy and modern paths failed). -pub const RESTORE_RETRY_DELAY_MS: u64 = 250; - -/// Wait for retry: on a multi-threaded tokio runtime, use `block_in_place` to yield the worker thread so -/// other tasks aren't blocked; in other contexts (current-thread runtime, non-runtime threads), sleep -/// directly to avoid a `block_in_place` panic on a current-thread runtime. -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 { - /// The saved snapshot is itself OpenLess (previous session likely failed to restore) → skip restore. - SkippedSticky, - /// restore_profile returned Ok (first attempt or after retry). - Verified, - /// Both restore_profile attempts failed. - FailedAfterRetry, -} - -/// Full restore flow: sticky-state guard → restore → retry on failure. -/// -/// Retry decisions come from the `restore_profile` return value (Err only when both legacy and modern -/// fail), never from the `is_openless_active` probe: that probe (`GetActiveProfile`) runs on an OpenLess -/// background thread and can disagree with the target app's thread TSF state (issue #852). It stays as a -/// diagnostic log of whether OpenLess is still active after restore, never as control flow. -/// `restore_profile` / `is_openless_active` are injected so any platform can unit-test this logic -/// (production provides the implementations via `WindowsImeProfileManager`). -/// -/// Known limitation: restore is unconditional — even if the user manually switched IMEs mid-session, -/// the pre-session snapshot is restored at the end (the `GetActiveProfile` probe the old version relied -/// on is unreliable on an OpenLess background thread and cannot drive control flow, 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 { - // Sticky-state guard: the saved profile is itself OpenLess (previous session likely failed to restore) - // → don't hard-code OpenLess as the original IME; skip restore and leave a diagnostic log. - 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; - } - - // First restore + one retry on failure: TSF session-level switches fail sporadically; retry once after a - // short wait. Success is decided by the restore_profile return value; the probe is diagnostic only. - 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 -} - -/// Post-restore diagnostic probe (logging only): records whether OpenLess is still the current profile. -/// -/// The probe is decoupled from decisions/retries — `GetActiveProfile` runs on an OpenLess background -/// thread and can disagree with the target app's thread TSF state (issue #852), so the result never -/// drives control flow. -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() { - // Sticky-state guard: saved is OpenLess → skip restore, restore is never called (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() { - // A probe still reporting OpenLess does not trigger a retry: success is decided by the restore return value (#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() { - // Probe errors are logged only; they don't affect the restore success verdict. - 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() { - // First restore fails → one retry → success. - 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() { - // Both restores fail → overall failure. - 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 2a76069cb..f5f791e63 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -1,16 +1,10 @@ #![allow(dead_code, unused_imports, unused_variables)] 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, -}; use crate::windows_ime_protocol::ImeSubmitStatus; -use crate::windows_ime_restore::{run_restore_flow, RESTORE_RETRY_DELAY_MS}; #[derive(Debug)] pub enum WindowsImeSessionError { - Profile(String), Ipc(String), OutcomeUnknown(String), } @@ -18,7 +12,7 @@ pub enum WindowsImeSessionError { impl std::fmt::Display for WindowsImeSessionError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::Profile(message) | Self::Ipc(message) | Self::OutcomeUnknown(message) => { + Self::Ipc(message) | Self::OutcomeUnknown(message) => { write!(f, "{message}") } } @@ -48,119 +42,28 @@ 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, - openless_activated: bool, -} - -impl PreparedWindowsImeSession { - pub fn unavailable() -> Self { - Self { - saved_profile: None, - openless_activated: false, - } - } - - pub fn activation_failed(saved_profile: ImeProfileSnapshot) -> Self { - Self { - saved_profile: Some(saved_profile), - openless_activated: false, - } - } - - pub fn is_ready_for_tsf_submit(&self) -> bool { - self.has_saved_profile() && self.openless_was_activated() - } - - pub fn has_saved_profile(&self) -> bool { - self.saved_profile.is_some() - } - - pub fn openless_was_activated(&self) -> bool { - self.openless_activated - } - - pub fn activation_failed_with_saved_profile(&self) -> bool { - self.has_saved_profile() && !self.openless_was_activated() - } -} - +/// Submits dictated text to the OpenLess text service inside the target app. +/// +/// The text service is registered under the TSF speech category, which TSF keeps +/// active next to the user's keyboard IME. There is no per-dictation session to +/// prepare or restore: switching the keyboard IME away and back made third-party +/// IMEs re-activate on every TSF thread, and that re-activation could block the +/// host forever (explorer hangs with Weasel, #665 / #954). pub struct WindowsImeSessionController { - profile_manager: WindowsImeProfileManager, ipc: WindowsImeIpcServer, } impl WindowsImeSessionController { pub fn new() -> Self { Self { - profile_manager: WindowsImeProfileManager::new(), ipc: WindowsImeIpcServer::new(), } } - pub fn prepare_session(&self) -> PreparedWindowsImeSession { - #[cfg(target_os = "windows")] - { - let saved_profile = match self.profile_manager.capture_active_profile() { - Ok(snapshot) => snapshot, - Err(error) => { - let error = WindowsImeSessionError::Profile(error.to_string()); - log::warn!("[windows-ime] capture active profile failed: {error}"); - return PreparedWindowsImeSession::unavailable(); - } - }; - - // Diagnostic: OpenLess was already the current IME at session start -> the previous - // session's restore likely failed. Still activate as usual (idempotent); - // restore_session's sticky-state guard will skip "restore", avoiding hardcoding - // OpenLess as the original IME (the failure self-sticking from 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), - 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() - } - } - - pub async fn submit_prepared( + pub async fn submit( &self, - prepared: &PreparedWindowsImeSession, request: ImeSubmitRequest, ) -> Result { - if !prepared.is_ready_for_tsf_submit() { - return Err(WindowsImeSessionError::Ipc( - "OpenLess IME session is not active".to_string(), - )); - } - let status = self.ipc.submit_text(request).await.map_err(|error| { if error.is_outcome_unknown() { WindowsImeSessionError::OutcomeUnknown(error.to_string()) @@ -175,54 +78,6 @@ impl WindowsImeSessionController { } Ok(map_ime_status_to_insert_status(status)) } - - /// Restore the IME from before the session. - /// - /// Known limitation: restore is unconditional — an IME the user manually switched to - /// mid-session is also overwritten with the pre-session snapshot at the end - /// (`GetActiveProfile` probing is unreliable from a background thread in the OpenLess - /// process and cannot drive control flow, 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(); - let activation_failed = prepared.activation_failed_with_saved_profile(); - - // Diagnostic: log the decision basis + the profile probed as current before restore - // (does not affect the decision). The issue #852 restore decision relies only on the - // session's known activation facts, not on this probe. - 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 decision != ProfileRestoreDecision::RestoreSavedProfile { - return; - } - - let Some(saved_profile) = saved_profile else { - return; - }; - - // Restore flow (sticky guard / retry / diagnostics) lives in windows_ime_restore so it - // can be unit-tested cross-platform. - // The outcome only adds a debug diagnostic; success/failure/skip details are already - // logged inside the flow. - 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:?}"); - } } impl Default for WindowsImeSessionController { @@ -269,63 +124,17 @@ mod tests { } #[tokio::test] - async fn submit_prepared_reports_unavailable_session() { + async fn submit_without_a_target_is_a_definite_failure() { let controller = WindowsImeSessionController::new(); let result = controller - .submit_prepared( - &PreparedWindowsImeSession::unavailable(), - ImeSubmitRequest { - session_id: "session-1".to_string(), - text: "hello".to_string(), - created_at: "2026-05-01T12:00:00Z".to_string(), - target: None, - }, - ) + .submit(ImeSubmitRequest { + session_id: "session-1".to_string(), + text: "hello".to_string(), + created_at: "2026-05-01T12:00:00Z".to_string(), + target: None, + }) .await; - assert!( - matches!(result, Err(WindowsImeSessionError::Ipc(message)) if message == "OpenLess IME session is not active") - ); - } - - #[test] - fn restore_decision_uses_confirmed_activation_state_only() { - // Activated and a pre-session snapshot exists -> restore (the decision no longer relies - // on profile-current probing, issue #852). - let activated = PreparedWindowsImeSession { - saved_profile: Some(ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409)), - openless_activated: true, - }; - assert_eq!( - restore_decision( - activated.saved_profile.as_ref(), - activated.openless_was_activated(), - activated.activation_failed_with_saved_profile(), - ), - ProfileRestoreDecision::RestoreSavedProfile - ); - - // Never activated (unavailable) -> keep as is. - 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] - fn activation_failed_session_keeps_snapshot_but_cannot_submit() { - let prepared = PreparedWindowsImeSession::activation_failed( - ImeProfileSnapshot::keyboard_layout(0x0409, 0x0409_0409), - ); - - assert!(prepared.has_saved_profile()); - assert!(!prepared.openless_was_activated()); - assert!(!prepared.is_ready_for_tsf_submit()); - assert!(prepared.activation_failed_with_saved_profile()); + assert!(matches!(result, Err(WindowsImeSessionError::Ipc(_)))); } } diff --git a/openless-all/app/src/i18n/de.ts b/openless-all/app/src/i18n/de.ts index ed9c03e2e..f80487d41 100644 --- a/openless-all/app/src/i18n/de.ts +++ b/openless-all/app/src/i18n/de.ts @@ -1948,7 +1948,7 @@ export const de: typeof zhCN = { hotkeyFailed: 'Kurzbefehlüberwachung fehlgeschlagen', windowsImeLabel: 'Windows-Eingabemethode', windowsImeDesc: - 'Wechselt während Sprachsitzungen vorübergehend zur OpenLess-TSF-Eingabemethode, um Einschränkungen der Zwischenablage zu umgehen.', + 'Fügt diktierten Text über den OpenLess-TSF-Textdienst ein, der neben Ihrer Eingabemethode aktiv bleibt, um Einschränkungen der Zwischenablage zu umgehen.', windowsImeInstalled: 'Installiert', windowsImeUnavailable: 'Nicht verfügbar', androidImeLabel: 'Eingabemethode (IME)', diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 970ab81bc..39ee21c8b 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -1907,7 +1907,7 @@ export const en: typeof zhCN = { hotkeyFailed: 'Listener failed', windowsImeLabel: 'Windows input method backend', windowsImeDesc: - 'Temporarily switches to the OpenLess TSF IME during voice sessions to avoid clipboard insertion limits.', + 'Inserts dictated text through the OpenLess TSF text service, which stays active alongside your input method, to avoid clipboard insertion limits.', windowsImeInstalled: 'Installed', windowsImeUnavailable: 'Unavailable', androidImeLabel: 'Input method (IME)', diff --git a/openless-all/app/src/i18n/es.ts b/openless-all/app/src/i18n/es.ts index c3ecec806..c49c18f4b 100644 --- a/openless-all/app/src/i18n/es.ts +++ b/openless-all/app/src/i18n/es.ts @@ -1939,7 +1939,7 @@ export const es: typeof zhCN = { hotkeyFailed: 'El detector falló', windowsImeLabel: 'Motor del método de entrada de Windows', windowsImeDesc: - 'Cambia temporalmente al IME TSF de OpenLess durante las sesiones de voz para evitar las limitaciones del portapapeles.', + 'Inserta el texto dictado mediante el servicio de texto TSF de OpenLess, que permanece activo junto a tu método de entrada, para evitar las limitaciones del portapapeles.', windowsImeInstalled: 'Instalado', windowsImeUnavailable: 'No disponible', androidImeLabel: 'Método de entrada (IME)', diff --git a/openless-all/app/src/i18n/fr.ts b/openless-all/app/src/i18n/fr.ts index c9b2bc894..a20e062cf 100644 --- a/openless-all/app/src/i18n/fr.ts +++ b/openless-all/app/src/i18n/fr.ts @@ -1968,7 +1968,7 @@ export const fr: typeof zhCN = { hotkeyFailed: 'Échec du détecteur', windowsImeLabel: 'Moteur de saisie Windows', windowsImeDesc: - 'Passe temporairement à l’IME TSF d’OpenLess pendant les sessions vocales pour éviter les limites du presse-papiers.', + 'Insère le texte dicté via le service de texte TSF d’OpenLess, qui reste actif à côté de votre méthode de saisie, pour éviter les limites du presse-papiers.', windowsImeInstalled: 'Installé', windowsImeUnavailable: 'Indisponible', androidImeLabel: 'Méthode de saisie (IME)', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 64e9e0363..19bbd4372 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -1891,7 +1891,7 @@ export const ja: typeof zhCN = { hotkeyFailed: '監視失敗', windowsImeLabel: 'Windows 入力メソッドバックエンド', windowsImeDesc: - '音声セッション中に OpenLess TSF IME へ一時的に切り替え、クリップボード入力の制限を回避します。', + '入力方式と併存する OpenLess TSF テキストサービス経由で文字を挿入し、IME を切り替えずにクリップボード入力の制限を回避します。', windowsImeInstalled: 'インストール済み', windowsImeUnavailable: '利用不可', androidImeLabel: '入力メソッド (IME)', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 371cc7e2d..3ef69e91b 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -1877,7 +1877,7 @@ export const ko: typeof zhCN = { hotkeyFailed: '감지 실패', windowsImeLabel: 'Windows 입력기 백엔드', windowsImeDesc: - '음성 세션 동안 OpenLess TSF 입력기로 일시적으로 전환하여 클립보드 입력 제한을 회피하기 위해 사용.', + '입력기와 함께 동작하는 OpenLess TSF 텍스트 서비스로 텍스트를 삽입하여, 입력기를 전환하지 않고 클립보드 입력 제한을 회피합니다.', windowsImeInstalled: '설치됨', windowsImeUnavailable: '사용 불가', androidImeLabel: '입력기 (IME)', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index ee582d872..57a1b3e4d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -1799,7 +1799,8 @@ export const zhCN = { hotkeyStarting: '安装中…', hotkeyFailed: '监听失败', windowsImeLabel: 'Windows 输入法后端', - windowsImeDesc: '语音输入时临时切到 OpenLess TSF,绕过剪贴板限制。', + windowsImeDesc: + '通过与输入法并存的 OpenLess TSF 文本服务插入文字,不切换输入法,绕过剪贴板限制。', windowsImeInstalled: '已安装', windowsImeUnavailable: '不可用', androidImeLabel: '输入法 (IME)', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 5b7cc7b3e..388fcb57f 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -1800,7 +1800,8 @@ export const zhTW: typeof zhCN = { hotkeyStarting: '安裝中…', hotkeyFailed: '監聽失敗', windowsImeLabel: 'Windows 輸入法後端', - windowsImeDesc: '用於在語音會話期間臨時切換到 OpenLess TSF 輸入法,避免剪貼簿插入限制。', + windowsImeDesc: + '透過與輸入法並存的 OpenLess TSF 文字服務插入文字,不切換輸入法,避免剪貼簿插入限制。', windowsImeInstalled: '已安裝', windowsImeUnavailable: '不可用', androidImeLabel: '輸入法 (IME)', diff --git a/openless-all/app/windows-ime/OpenLessIme.vcxproj b/openless-all/app/windows-ime/OpenLessIme.vcxproj index ffce3b53d..b6b45706d 100644 --- a/openless-all/app/windows-ime/OpenLessIme.vcxproj +++ b/openless-all/app/windows-ime/OpenLessIme.vcxproj @@ -157,7 +157,6 @@ - @@ -165,7 +164,6 @@ - diff --git a/openless-all/app/windows-ime/src/edit_session.cpp b/openless-all/app/windows-ime/src/edit_session.cpp index 73ae63ac8..3ad0f15a1 100644 --- a/openless-all/app/windows-ime/src/edit_session.cpp +++ b/openless-all/app/windows-ime/src/edit_session.cpp @@ -4,27 +4,9 @@ extern LONG g_object_count; -OpenLessAsyncEditState::OpenLessAsyncEditState() - : event(CreateEventW(nullptr, TRUE, FALSE, nullptr)) { - if (event == nullptr) { - create_error = GetLastError(); - } -} - -OpenLessAsyncEditState::~OpenLessAsyncEditState() { - if (event != nullptr) { - CloseHandle(event); - event = nullptr; - } -} - -bool OpenLessAsyncEditState::IsValid() const { return event != nullptr; } - OpenLessEditSession::OpenLessEditSession(ITfContext *context, std::wstring text, - std::shared_ptr async_state, - std::shared_ptr> cancellation) - : context_(context), text_(std::move(text)), async_state_(std::move(async_state)), - cancellation_(std::move(cancellation)) { + std::shared_ptr async_state) + : context_(context), text_(std::move(text)), async_state_(std::move(async_state)) { InterlockedIncrement(&g_object_count); if (context_ != nullptr) { context_->AddRef(); @@ -67,13 +49,11 @@ STDMETHODIMP_(ULONG) OpenLessEditSession::Release() { } STDMETHODIMP OpenLessEditSession::DoEditSession(TfEditCookie edit_cookie) { - const HRESULT hr = cancellation_ && cancellation_->load() ? HRESULT_FROM_WIN32(ERROR_CANCELLED) - : InsertText(edit_cookie); + const HRESULT hr = async_state_ && async_state_->cancelled ? HRESULT_FROM_WIN32(ERROR_CANCELLED) + : InsertText(edit_cookie); if (async_state_) { async_state_->result = hr; - if (async_state_->event != nullptr) { - SetEvent(async_state_->event); - } + async_state_->completed = true; } return hr; } @@ -82,9 +62,6 @@ HRESULT OpenLessEditSession::InsertText(TfEditCookie edit_cookie) { if (context_ == nullptr) { return E_UNEXPECTED; } - if (cancellation_ && cancellation_->load()) { - return HRESULT_FROM_WIN32(ERROR_CANCELLED); - } ITfInsertAtSelection *insert_at_selection = nullptr; HRESULT hr = context_->QueryInterface(IID_ITfInsertAtSelection, @@ -93,11 +70,6 @@ HRESULT OpenLessEditSession::InsertText(TfEditCookie edit_cookie) { return hr; } - if (cancellation_ && cancellation_->load()) { - insert_at_selection->Release(); - return HRESULT_FROM_WIN32(ERROR_CANCELLED); - } - // Commit in a single call. Some Chromium-backed text stores surface a // full-text QUERYONLY preflight as an edit and then duplicate the real commit. ITfRange *committed_range = nullptr; diff --git a/openless-all/app/windows-ime/src/edit_session.h b/openless-all/app/windows-ime/src/edit_session.h index 4a10cce7f..dcc20494a 100644 --- a/openless-all/app/windows-ime/src/edit_session.h +++ b/openless-all/app/windows-ime/src/edit_session.h @@ -1,29 +1,22 @@ #pragma once #include -#include #include #include #include +// Shared between the text service and an async edit session. Both only touch +// it on the TSF owner thread, so plain fields are enough. struct OpenLessAsyncEditState { - OpenLessAsyncEditState(); - OpenLessAsyncEditState(const OpenLessAsyncEditState &) = delete; - OpenLessAsyncEditState &operator=(const OpenLessAsyncEditState &) = delete; - ~OpenLessAsyncEditState(); - - bool IsValid() const; - - HANDLE event = nullptr; - DWORD create_error = ERROR_SUCCESS; - HRESULT result = E_UNEXPECTED; + bool cancelled = false; + bool completed = false; + HRESULT result = E_PENDING; }; class OpenLessEditSession final : public ITfEditSession { public: OpenLessEditSession(ITfContext *context, std::wstring text, - std::shared_ptr async_state = nullptr, - std::shared_ptr> cancellation = nullptr); + std::shared_ptr async_state = nullptr); OpenLessEditSession(const OpenLessEditSession &) = delete; OpenLessEditSession &operator=(const OpenLessEditSession &) = delete; ~OpenLessEditSession(); @@ -40,5 +33,4 @@ class OpenLessEditSession final : public ITfEditSession { ITfContext *context_ = nullptr; std::wstring text_; std::shared_ptr async_state_; - std::shared_ptr> cancellation_; }; diff --git a/openless-all/app/windows-ime/src/guids.h b/openless-all/app/windows-ime/src/guids.h index a22782bfa..8b4a1e565 100644 --- a/openless-all/app/windows-ime/src/guids.h +++ b/openless-all/app/windows-ime/src/guids.h @@ -18,4 +18,9 @@ inline constexpr GUID GUID_OpenLessProfile = { }; inline constexpr wchar_t kOpenLessImeName[] = L"OpenLess Voice Input"; -inline constexpr LANGID kOpenLessLangId = 0x0804; +// Registered for every language (like the system speech text service) under the +// speech category, so TSF keeps it active next to the user's keyboard IME and +// dictation never has to switch the keyboard IME away and back. +inline constexpr LANGID kOpenLessLangId = 0xFFFF; +// Releases up to 2.0.0-Beta.4 registered a zh-CN keyboard profile instead. +inline constexpr LANGID kOpenLessLegacyKeyboardLangId = 0x0804; diff --git a/openless-all/app/windows-ime/src/ipc_client.cpp b/openless-all/app/windows-ime/src/ipc_client.cpp deleted file mode 100644 index fd42d2bbf..000000000 --- a/openless-all/app/windows-ime/src/ipc_client.cpp +++ /dev/null @@ -1,653 +0,0 @@ -#include "ipc_client.h" - -#include -#include -#include -#include -#include - -#include "text_service.h" - -namespace { - -constexpr wchar_t kPipeNamePrefix[] = L"\\\\.\\pipe\\OpenLessImeSubmit"; -constexpr DWORD kPipeBufferSize = 4096; -constexpr size_t kMaxJsonLineBytes = 64 * 1024; -constexpr DWORD kPipeStartupTimeoutMs = 2000; - -struct SubmitMessage { - std::wstring type; - std::wstring session_id; - std::wstring text; - int protocol_version = 0; - bool has_type = false; - bool has_session_id = false; - bool has_text = false; - bool has_protocol_version = false; -}; - -std::wstring HResultErrorCode(HRESULT hr) { - constexpr wchar_t kHexDigits[] = L"0123456789ABCDEF"; - auto value = static_cast(hr); - std::wstring code = L"hresult:0x"; - for (int shift = 28; shift >= 0; shift -= 4) { - code.push_back(kHexDigits[(value >> shift) & 0xF]); - } - return code; -} - -std::wstring PipeNameForCurrentThread() { - std::wstring name = kPipeNamePrefix; - name += L"-"; - name += std::to_wstring(GetCurrentProcessId()); - name += L"-"; - name += std::to_wstring(GetCurrentThreadId()); - return name; -} - -bool AppendUtf8AsWide(const char *data, int length, std::wstring *output) { - if (length == 0) { - return true; - } - - const int required = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, data, length, nullptr, 0); - if (required <= 0) { - return false; - } - - const size_t old_size = output->size(); - output->resize(old_size + static_cast(required)); - return MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, data, length, output->data() + old_size, - required) == required; -} - -bool WideToUtf8(const std::wstring &value, std::string *output) { - output->clear(); - if (value.empty()) { - return true; - } - - const int required = - WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.c_str(), - static_cast(value.size()), nullptr, 0, nullptr, nullptr); - if (required <= 0) { - return false; - } - - output->resize(static_cast(required)); - return WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value.c_str(), - static_cast(value.size()), output->data(), required, nullptr, - nullptr) == required; -} - -void SkipWhitespace(const std::string &json, size_t *pos) { - while (*pos < json.size()) { - const char c = json[*pos]; - if (c != ' ' && c != '\t' && c != '\r' && c != '\n') { - return; - } - ++(*pos); - } -} - -int HexDigit(char c) { - if (c >= '0' && c <= '9') { - return c - '0'; - } - if (c >= 'a' && c <= 'f') { - return c - 'a' + 10; - } - if (c >= 'A' && c <= 'F') { - return c - 'A' + 10; - } - return -1; -} - -bool ParseJsonString(const std::string &json, size_t *pos, std::wstring *value) { - value->clear(); - if (*pos >= json.size() || json[*pos] != '"') { - return false; - } - ++(*pos); - - size_t segment_start = *pos; - while (*pos < json.size()) { - const char c = json[*pos]; - if (static_cast(c) < 0x20) { - return false; - } - - if (c == '"') { - if (!AppendUtf8AsWide(json.data() + segment_start, static_cast(*pos - segment_start), - value)) { - return false; - } - ++(*pos); - return true; - } - - if (c != '\\') { - ++(*pos); - continue; - } - - if (!AppendUtf8AsWide(json.data() + segment_start, static_cast(*pos - segment_start), - value)) { - return false; - } - - ++(*pos); - if (*pos >= json.size()) { - return false; - } - - const char escaped = json[*pos]; - switch (escaped) { - case '"': - case '\\': - case '/': - value->push_back(static_cast(escaped)); - ++(*pos); - break; - case 'b': - value->push_back(L'\b'); - ++(*pos); - break; - case 'f': - value->push_back(L'\f'); - ++(*pos); - break; - case 'n': - value->push_back(L'\n'); - ++(*pos); - break; - case 'r': - value->push_back(L'\r'); - ++(*pos); - break; - case 't': - value->push_back(L'\t'); - ++(*pos); - break; - case 'u': { - if (*pos + 4 >= json.size()) { - return false; - } - uint32_t code_unit = 0; - for (int i = 1; i <= 4; ++i) { - const int digit = HexDigit(json[*pos + static_cast(i)]); - if (digit < 0) { - return false; - } - code_unit = (code_unit << 4) | static_cast(digit); - } - value->push_back(static_cast(code_unit)); - *pos += 5; - break; - } - default: - return false; - } - - segment_start = *pos; - } - - return false; -} - -bool ParseJsonInteger(const std::string &json, size_t *pos, int *value) { - if (*pos >= json.size() || json[*pos] < '0' || json[*pos] > '9') { - return false; - } - - int parsed = 0; - while (*pos < json.size() && json[*pos] >= '0' && json[*pos] <= '9') { - const int digit = json[*pos] - '0'; - if (parsed > ((std::numeric_limits::max)() - digit) / 10) { - return false; - } - parsed = parsed * 10 + digit; - ++(*pos); - } - - *value = parsed; - return true; -} - -bool SkipJsonValue(const std::string &json, size_t *pos) { - std::wstring ignored; - if (*pos >= json.size()) { - return false; - } - if (json[*pos] == '"') { - return ParseJsonString(json, pos, &ignored); - } - while (*pos < json.size() && json[*pos] != ',' && json[*pos] != '}') { - ++(*pos); - } - return true; -} - -bool ParseSubmitMessage(const std::string &json, SubmitMessage *message) { - size_t pos = 0; - SkipWhitespace(json, &pos); - if (pos >= json.size() || json[pos] != '{') { - return false; - } - ++pos; - - while (true) { - SkipWhitespace(json, &pos); - if (pos < json.size() && json[pos] == '}') { - ++pos; - break; - } - - std::wstring key; - if (!ParseJsonString(json, &pos, &key)) { - return false; - } - - SkipWhitespace(json, &pos); - if (pos >= json.size() || json[pos] != ':') { - return false; - } - ++pos; - SkipWhitespace(json, &pos); - - if (key == L"type") { - message->has_type = ParseJsonString(json, &pos, &message->type); - if (!message->has_type) { - return false; - } - } else if (key == L"sessionId") { - message->has_session_id = ParseJsonString(json, &pos, &message->session_id); - if (!message->has_session_id) { - return false; - } - } else if (key == L"text") { - message->has_text = ParseJsonString(json, &pos, &message->text); - if (!message->has_text) { - return false; - } - } else if (key == L"protocolVersion") { - message->has_protocol_version = ParseJsonInteger(json, &pos, &message->protocol_version); - if (!message->has_protocol_version) { - return false; - } - } else if (!SkipJsonValue(json, &pos)) { - return false; - } - - SkipWhitespace(json, &pos); - if (pos < json.size() && json[pos] == ',') { - ++pos; - continue; - } - if (pos < json.size() && json[pos] == '}') { - ++pos; - break; - } - return false; - } - - SkipWhitespace(json, &pos); - return pos == json.size(); -} - -std::wstring EscapeJsonString(const std::wstring &value) { - std::wstring escaped; - for (wchar_t ch : value) { - switch (ch) { - case L'"': - escaped += L"\\\""; - break; - case L'\\': - escaped += L"\\\\"; - break; - case L'\b': - escaped += L"\\b"; - break; - case L'\f': - escaped += L"\\f"; - break; - case L'\n': - escaped += L"\\n"; - break; - case L'\r': - escaped += L"\\r"; - break; - case L'\t': - escaped += L"\\t"; - break; - default: - if (ch < 0x20) { - wchar_t buffer[7] = {}; - swprintf_s(buffer, L"\\u%04X", static_cast(ch)); - escaped += buffer; - } else { - escaped.push_back(ch); - } - break; - } - } - return escaped; -} - -} // namespace - -OpenLessPipeServer::OpenLessPipeServer() = default; - -OpenLessPipeServer::~OpenLessPipeServer() { Stop(); } - -HRESULT OpenLessPipeServer::Start(OpenLessTextService *service) { - if (service == nullptr) { - return E_INVALIDARG; - } - if (thread_.joinable()) { - return HRESULT_FROM_WIN32(ERROR_ALREADY_INITIALIZED); - } - - stop_requested_.store(false); - - // Manual-reset events: startup_event_ returns the first CreateNamedPipeW - // result to Activate; stop_event_ wakes every overlapped wait the moment - // Stop() runs; io_event_ is reused by worker I/O. - startup_result_ = E_PENDING; - startup_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); - stop_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); - io_event_ = CreateEventW(nullptr, TRUE, FALSE, nullptr); - if (startup_event_ == nullptr || stop_event_ == nullptr || io_event_ == nullptr) { - const DWORD error = GetLastError(); - ResetServerState(); - return HRESULT_FROM_WIN32(error != ERROR_SUCCESS ? error : ERROR_NOT_ENOUGH_MEMORY); - } - - try { - pipe_name_ = PipeNameForCurrentThread(); - service_ = service; - service_->AddRef(); - thread_ = std::thread(&OpenLessPipeServer::Run, this); - } catch (const std::bad_alloc &) { - ResetServerState(); - return E_OUTOFMEMORY; - } catch (const std::system_error &) { - ResetServerState(); - return E_FAIL; - } catch (...) { - ResetServerState(); - return E_UNEXPECTED; - } - - const DWORD startup_wait = WaitForSingleObject(startup_event_, kPipeStartupTimeoutMs); - if (startup_wait != WAIT_OBJECT_0) { - const DWORD error = startup_wait == WAIT_TIMEOUT ? ERROR_TIMEOUT : GetLastError(); - Stop(); - return HRESULT_FROM_WIN32(error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE); - } - - const HRESULT startup_result = startup_result_; - CloseHandle(startup_event_); - startup_event_ = nullptr; - if (FAILED(startup_result)) { - Stop(); - return startup_result; - } - return S_OK; -} - -void OpenLessPipeServer::Stop() { - stop_requested_.store(true); - if (stop_event_ != nullptr) { - SetEvent(stop_event_); - } - - // Every worker wait includes stop_event_, so joining cannot leave the host UI - // thread blocked on a pipe client or a synchronous pipe operation. - if (thread_.joinable()) { - thread_.join(); - } - - ResetServerState(); -} - -void OpenLessPipeServer::ResetServerState() { - if (startup_event_ != nullptr) { - CloseHandle(startup_event_); - startup_event_ = nullptr; - } - if (io_event_ != nullptr) { - CloseHandle(io_event_); - io_event_ = nullptr; - } - if (stop_event_ != nullptr) { - CloseHandle(stop_event_); - stop_event_ = nullptr; - } - - if (service_ != nullptr) { - service_->Release(); - service_ = nullptr; - } - pipe_name_.clear(); -} - -void OpenLessPipeServer::Run() noexcept { - bool startup_reported = false; - try { - RunLoop(&startup_reported); - } catch (const std::bad_alloc &) { - if (!startup_reported) { - ReportStartupResult(E_OUTOFMEMORY); - } - } catch (const std::system_error &) { - if (!startup_reported) { - ReportStartupResult(E_FAIL); - } - } catch (...) { - // Never let an allocation or STL exception terminate the host process. - if (!startup_reported) { - ReportStartupResult(E_UNEXPECTED); - } - } -} - -void OpenLessPipeServer::ReportStartupResult(HRESULT result) noexcept { - startup_result_ = result; - if (startup_event_ != nullptr) { - SetEvent(startup_event_); - } -} - -void OpenLessPipeServer::RunLoop(bool *startup_reported) { - const std::wstring pipe_name = pipe_name_; - while (!stop_requested_.load()) { - HANDLE pipe = CreateNamedPipeW(pipe_name.c_str(), PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED, - PIPE_TYPE_MESSAGE | PIPE_READMODE_BYTE | PIPE_WAIT, 1, - kPipeBufferSize, kPipeBufferSize, 0, nullptr); - if (pipe == INVALID_HANDLE_VALUE) { - if (!*startup_reported) { - const DWORD error = GetLastError(); - *startup_reported = true; - ReportStartupResult(HRESULT_FROM_WIN32(error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE)); - } - return; - } - - if (!*startup_reported) { - *startup_reported = true; - ReportStartupResult(S_OK); - } - - if (WaitForClient(pipe) && !stop_requested_.load()) { - std::string line; - if (ReadJsonLine(pipe, &line)) { - HandleSubmitLine(pipe, line); - } - WaitForClientDisconnect(pipe); - } - - DisconnectNamedPipe(pipe); - CloseHandle(pipe); - } - - if (!*startup_reported) { - *startup_reported = true; - ReportStartupResult(HRESULT_FROM_WIN32(ERROR_CANCELLED)); - } -} - -bool OpenLessPipeServer::WaitForClient(HANDLE pipe) { - OVERLAPPED overlapped = {}; - overlapped.hEvent = io_event_; - ResetEvent(io_event_); - - if (ConnectNamedPipe(pipe, &overlapped)) { - // Overlapped ConnectNamedPipe is not expected to return TRUE, but if it - // does the client is already connected. - return true; - } - - const DWORD error = GetLastError(); - if (error == ERROR_PIPE_CONNECTED) { - return true; // A client connected before ConnectNamedPipe was called. - } - if (error != ERROR_IO_PENDING) { - return false; // Genuine failure establishing the connection. - } - - const HANDLE wait_handles[2] = {io_event_, stop_event_}; - const DWORD wait = WaitForMultipleObjects(2, wait_handles, FALSE, INFINITE); - if (wait == WAIT_OBJECT_0) { - DWORD transferred = 0; - return GetOverlappedResult(pipe, &overlapped, &transferred, FALSE) != 0; - } - - // Stop requested (or the wait failed): cancel the pending connect and block - // until the kernel is finished with the stack OVERLAPPED before returning. - CancelIoEx(pipe, &overlapped); - DWORD transferred = 0; - GetOverlappedResult(pipe, &overlapped, &transferred, TRUE); - return false; -} - -void OpenLessPipeServer::WaitForClientDisconnect(HANDLE pipe) { - char buffer[256] = {}; - while (!stop_requested_.load()) { - DWORD bytes_read = 0; - if (!RunOverlapped(pipe, /*is_write=*/false, buffer, sizeof(buffer), &bytes_read) || - bytes_read == 0) { - return; - } - } -} - -bool OpenLessPipeServer::RunOverlapped(HANDLE pipe, bool is_write, void *buffer, DWORD length, - DWORD *bytes) { - *bytes = 0; - - OVERLAPPED overlapped = {}; - overlapped.hEvent = io_event_; - ResetEvent(io_event_); - - const BOOL started = is_write ? WriteFile(pipe, buffer, length, nullptr, &overlapped) - : ReadFile(pipe, buffer, length, nullptr, &overlapped); - if (!started) { - const DWORD error = GetLastError(); - if (error != ERROR_IO_PENDING) { - return false; // e.g. ERROR_BROKEN_PIPE once the client disconnects. - } - - const HANDLE wait_handles[2] = {io_event_, stop_event_}; - const DWORD wait = WaitForMultipleObjects(2, wait_handles, FALSE, INFINITE); - if (wait != WAIT_OBJECT_0) { - // Stop requested (or the wait failed): cancel and drain before the stack - // OVERLAPPED and caller buffer go out of scope. - CancelIoEx(pipe, &overlapped); - DWORD drained = 0; - GetOverlappedResult(pipe, &overlapped, &drained, TRUE); - return false; - } - } - - return GetOverlappedResult(pipe, &overlapped, bytes, FALSE) != 0; -} - -bool OpenLessPipeServer::ReadJsonLine(HANDLE pipe, std::string *line) { - line->clear(); - char buffer[1024] = {}; - - while (!stop_requested_.load() && line->size() < kMaxJsonLineBytes) { - DWORD bytes_read = 0; - if (!RunOverlapped(pipe, /*is_write=*/false, buffer, sizeof(buffer), &bytes_read)) { - return !line->empty(); - } - - if (bytes_read == 0) { - return !line->empty(); - } - - for (DWORD i = 0; i < bytes_read; ++i) { - if (buffer[i] == '\n') { - return true; - } - line->push_back(buffer[i]); - if (line->size() >= kMaxJsonLineBytes) { - return true; - } - } - } - - return !line->empty(); -} - -void OpenLessPipeServer::HandleSubmitLine(HANDLE pipe, const std::string &line) { - SubmitMessage message; - if (!ParseSubmitMessage(line, &message) || !message.has_type || !message.has_protocol_version || - !message.has_session_id || !message.has_text || message.protocol_version != 1 || - message.type != L"submitText") { - WriteResult(pipe, message.session_id, L"failed", L"protocolError"); - return; - } - - if (service_ == nullptr) { - WriteResult(pipe, message.session_id, L"failed", L"serviceUnavailable"); - return; - } - - const HRESULT hr = service_->SubmitTextFromPipe(message.session_id, message.text, stop_event_); - if (SUCCEEDED(hr)) { - WriteResult(pipe, message.session_id, L"committed", nullptr); - } else { - const std::wstring error_code = HResultErrorCode(hr); - WriteResult(pipe, message.session_id, L"rejected", error_code.c_str()); - } -} - -bool OpenLessPipeServer::WriteResult(HANDLE pipe, const std::wstring &session_id, - const wchar_t *status, const wchar_t *error_code) { - std::wstring response = L"{\"type\":\"submitResult\",\"protocolVersion\":1,"; - response += L"\"sessionId\":\""; - response += EscapeJsonString(session_id); - response += L"\",\"status\":\""; - response += status; - response += L"\",\"errorCode\":"; - if (error_code == nullptr) { - response += L"null"; - } else { - response += L"\""; - response += error_code; - response += L"\""; - } - response += L"}\n"; - - std::string utf8_response; - if (!WideToUtf8(response, &utf8_response)) { - return false; - } - - DWORD bytes_written = 0; - return RunOverlapped(pipe, /*is_write=*/true, utf8_response.data(), - static_cast(utf8_response.size()), &bytes_written) && - bytes_written == utf8_response.size(); -} diff --git a/openless-all/app/windows-ime/src/ipc_client.h b/openless-all/app/windows-ime/src/ipc_client.h deleted file mode 100644 index 4ae2c1d31..000000000 --- a/openless-all/app/windows-ime/src/ipc_client.h +++ /dev/null @@ -1,45 +0,0 @@ -#pragma once - -#include -#include -#include -#include - -class OpenLessTextService; - -class OpenLessPipeServer { -public: - OpenLessPipeServer(); - OpenLessPipeServer(const OpenLessPipeServer &) = delete; - OpenLessPipeServer &operator=(const OpenLessPipeServer &) = delete; - ~OpenLessPipeServer(); - - HRESULT Start(OpenLessTextService *service); - void Stop(); - -private: - void Run() noexcept; - void RunLoop(bool *startup_reported); - void ReportStartupResult(HRESULT result) noexcept; - bool WaitForClient(HANDLE pipe); - void WaitForClientDisconnect(HANDLE pipe); - bool ReadJsonLine(HANDLE pipe, std::string *line); - void HandleSubmitLine(HANDLE pipe, const std::string &line); - bool WriteResult(HANDLE pipe, const std::wstring &session_id, const wchar_t *status, - const wchar_t *error_code); - // Issues one overlapped read or write and waits until it completes or the - // stop event is signaled. Returns true only on successful completion, with - // the transferred byte count stored in *bytes. Overlapped I/O keeps every - // pipe wait cancelable so Stop() never blocks the host process UI thread. - bool RunOverlapped(HANDLE pipe, bool is_write, void *buffer, DWORD length, DWORD *bytes); - void ResetServerState(); - - std::atomic stop_requested_{false}; - std::thread thread_; - HANDLE startup_event_ = nullptr; - HANDLE stop_event_ = nullptr; - HANDLE io_event_ = nullptr; - HRESULT startup_result_ = E_PENDING; - std::wstring pipe_name_; - OpenLessTextService *service_ = nullptr; -}; diff --git a/openless-all/app/windows-ime/src/registry.cpp b/openless-all/app/windows-ime/src/registry.cpp index fcad80384..2033396ff 100644 --- a/openless-all/app/windows-ime/src/registry.cpp +++ b/openless-all/app/windows-ime/src/registry.cpp @@ -150,6 +150,8 @@ HRESULT RegisterLanguageProfile() { return hr; } + manager->UnregisterProfile(CLSID_OpenLessTextService, kOpenLessLegacyKeyboardLangId, + GUID_OpenLessProfile, 0); manager->UnregisterProfile(CLSID_OpenLessTextService, kOpenLessLangId, GUID_OpenLessProfile, 0); hr = manager->RegisterProfile( CLSID_OpenLessTextService, kOpenLessLangId, GUID_OpenLessProfile, kOpenLessImeName, @@ -172,14 +174,18 @@ HRESULT RegisterKeyboardCategory() { return hr; } + // Drop the keyboard category older releases registered, so an upgrade moves + // the text service out of the keyboard list. category_mgr->UnregisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIP_KEYBOARD, CLSID_OpenLessTextService); + category_mgr->UnregisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIP_SPEECH, + CLSID_OpenLessTextService); category_mgr->UnregisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIPCAP_IMMERSIVESUPPORT, CLSID_OpenLessTextService); category_mgr->UnregisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIPCAP_SYSTRAYSUPPORT, CLSID_OpenLessTextService); - hr = category_mgr->RegisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIP_KEYBOARD, + hr = category_mgr->RegisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIP_SPEECH, CLSID_OpenLessTextService); if (SUCCEEDED(hr)) { hr = category_mgr->RegisterCategory( @@ -209,6 +215,8 @@ HRESULT UnregisterLanguageProfile() { ITfInputProcessorProfileMgr *manager = nullptr; hr = CreateProfileManager(&manager); if (SUCCEEDED(hr)) { + manager->UnregisterProfile(CLSID_OpenLessTextService, kOpenLessLegacyKeyboardLangId, + GUID_OpenLessProfile, 0); manager->UnregisterProfile(CLSID_OpenLessTextService, kOpenLessLangId, GUID_OpenLessProfile, 0); manager->Release(); } @@ -231,7 +239,9 @@ HRESULT UnregisterKeyboardCategory() { return hr; } - hr = category_mgr->UnregisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIP_KEYBOARD, + category_mgr->UnregisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIP_KEYBOARD, + CLSID_OpenLessTextService); + hr = category_mgr->UnregisterCategory(CLSID_OpenLessTextService, GUID_TFCAT_TIP_SPEECH, CLSID_OpenLessTextService); if (SUCCEEDED(hr)) { hr = category_mgr->UnregisterCategory( diff --git a/openless-all/app/windows-ime/src/text_service.cpp b/openless-all/app/windows-ime/src/text_service.cpp index d0b6d9cf0..60e63dbb9 100644 --- a/openless-all/app/windows-ime/src/text_service.cpp +++ b/openless-all/app/windows-ime/src/text_service.cpp @@ -1,7 +1,8 @@ #include "text_service.h" -#include +#include #include +#include #include "edit_session.h" @@ -11,80 +12,35 @@ extern HINSTANCE g_module; namespace { constexpr wchar_t kMessageWindowClassName[] = L"OpenLessImeMessageWindow"; -constexpr UINT kSubmitTextMessage = WM_APP + 1; -constexpr UINT kSubmitTextTimeoutMs = 2000; - -struct SubmitTextRequest { - SubmitTextRequest() - : cancellation(std::make_shared>(false)), - completion_event(CreateEventW(nullptr, TRUE, FALSE, nullptr)) { - if (completion_event == nullptr) { - create_error = GetLastError(); - } - } - - ~SubmitTextRequest() { - if (completion_event != nullptr) { - CloseHandle(completion_event); - completion_event = nullptr; - } - } - - bool IsValid() const { return completion_event != nullptr; } - - std::wstring session_id; - std::wstring text; - std::shared_ptr async_completion; - bool wait_for_async_completion = false; - HRESULT result = E_UNEXPECTED; - std::shared_ptr> cancellation; - HANDLE completion_event = nullptr; - DWORD create_error = ERROR_SUCCESS; -}; - -using PostedSubmitRequest = std::shared_ptr; - -HRESULT WaitForCompletionOrCancellation(HANDLE completion_event, HANDLE cancellation_event, - const std::shared_ptr> &cancellation) { - if (completion_event == nullptr) { - return HRESULT_FROM_WIN32(ERROR_INVALID_HANDLE); - } - if (cancellation_event != nullptr && - WaitForSingleObject(cancellation_event, 0) == WAIT_OBJECT_0) { - cancellation->store(true); - return HRESULT_FROM_WIN32(ERROR_CANCELLED); - } - - const HANDLE wait_handles[2] = {completion_event, cancellation_event}; - const DWORD wait_count = cancellation_event != nullptr ? 2 : 1; - const DWORD wait_result = - WaitForMultipleObjects(wait_count, wait_handles, FALSE, kSubmitTextTimeoutMs); - if (wait_result == WAIT_OBJECT_0) { - return S_OK; - } - - cancellation->store(true); - if (wait_count == 2 && wait_result == WAIT_OBJECT_0 + 1) { - return HRESULT_FROM_WIN32(ERROR_CANCELLED); - } - if (wait_result == WAIT_TIMEOUT) { - return HRESULT_FROM_WIN32(ERROR_TIMEOUT); - } - const DWORD error = GetLastError(); - return HRESULT_FROM_WIN32(error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE); +constexpr UINT kRunSubmitMessage = WM_APP + 1; + +// WM_COPYDATA protocol, mirrored in src-tauri/src/windows_ime_protocol.rs. +// +// UIPI is left in place on purpose: the window never opts in to WM_COPYDATA +// from lower-integrity senders, so a non-elevated process cannot inject text +// into an elevated host through this DLL. +constexpr ULONG_PTR kCopyDataSubmit = 0x4F4C5331; // "OLS1": uint32 token + UTF-16LE text +constexpr ULONG_PTR kCopyDataQuery = 0x4F4C5131; // "OLQ1": uint32 token +constexpr DWORD kMaxSubmitBytes = 1024 * 1024; + +// Replies are nonzero so they differ from an unhandled message (0). A failed +// commit is reported as its HRESULT, which always has the high bit set. +constexpr LRESULT kStatusAccepted = 0x4F4C0001; +constexpr LRESULT kStatusPending = 0x4F4C0002; +constexpr LRESULT kStatusCommitted = 0x4F4C0003; +constexpr LRESULT kStatusUnknownToken = 0x4F4C0004; +constexpr LRESULT kStatusBadRequest = 0x4F4C0005; + +LRESULT StatusFromHResult(HRESULT hr) { + return SUCCEEDED(hr) ? kStatusCommitted : static_cast(hr); } -HRESULT WaitForAsyncEditCompletion(const std::shared_ptr &completion, - HANDLE cancellation_event, - const std::shared_ptr> &cancellation) { - if (!completion || !completion->IsValid()) { - return HRESULT_FROM_WIN32(completion && completion->create_error != ERROR_SUCCESS - ? completion->create_error - : ERROR_INVALID_HANDLE); - } - const HRESULT wait_result = - WaitForCompletionOrCancellation(completion->event, cancellation_event, cancellation); - return FAILED(wait_result) ? wait_result : completion->result; +bool ReadToken(const COPYDATASTRUCT *copy_data, uint32_t *token) { + if (copy_data->lpData == nullptr || copy_data->cbData < sizeof(uint32_t)) { + return false; + } + std::memcpy(token, copy_data->lpData, sizeof(uint32_t)); + return *token != 0; } } // namespace @@ -138,19 +94,11 @@ STDMETHODIMP OpenLessTextService::ActivateEx(ITfThreadMgr *thread_mgr, TfClientI Deactivate(); - owner_thread_id_ = GetCurrentThreadId(); - thread_mgr_ = thread_mgr; thread_mgr_->AddRef(); client_id_ = client_id; - HRESULT hr = EnsureMessageWindow(); - if (FAILED(hr)) { - Deactivate(); - return hr; - } - - hr = StartIpcServer(); + const HRESULT hr = EnsureMessageWindow(); if (FAILED(hr)) { Deactivate(); return hr; @@ -159,8 +107,10 @@ STDMETHODIMP OpenLessTextService::ActivateEx(ITfThreadMgr *thread_mgr, TfClientI return S_OK; } +// Must stay wait-free: TSF can call this while the host thread is being torn +// down, where blocking on another thread deadlocks the whole host process. STDMETHODIMP OpenLessTextService::Deactivate() { - StopIpcServer(); + CancelPendingSubmit(); DestroyMessageWindow(); if (thread_mgr_ != nullptr) { @@ -168,66 +118,10 @@ STDMETHODIMP OpenLessTextService::Deactivate() { thread_mgr_ = nullptr; } client_id_ = TF_CLIENTID_NULL; - owner_thread_id_ = 0; return S_OK; } -HRESULT OpenLessTextService::SubmitTextFromPipe(const std::wstring &session_id, - const std::wstring &text, - HANDLE cancellation_event) { - try { - if (GetCurrentThreadId() == owner_thread_id_) { - auto cancellation = std::make_shared>(false); - return CommitTextOnOwnerThread(session_id, text, nullptr, nullptr, cancellation); - } - - if (message_window_ == nullptr) { - return E_UNEXPECTED; - } - - auto request = std::make_shared(); - if (!request->IsValid()) { - return HRESULT_FROM_WIN32(request->create_error != ERROR_SUCCESS ? request->create_error - : ERROR_INVALID_HANDLE); - } - request->session_id = session_id; - request->text = text; - - auto *posted_request = new (std::nothrow) PostedSubmitRequest(request); - if (posted_request == nullptr) { - return E_OUTOFMEMORY; - } - - if (!PostMessageW(message_window_, kSubmitTextMessage, 0, - reinterpret_cast(posted_request))) { - const DWORD error = GetLastError(); - delete posted_request; - return HRESULT_FROM_WIN32(error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE); - } - - const HRESULT wait_result = WaitForCompletionOrCancellation( - request->completion_event, cancellation_event, request->cancellation); - if (FAILED(wait_result)) { - return wait_result; - } - - if (request->wait_for_async_completion) { - return WaitForAsyncEditCompletion(request->async_completion, cancellation_event, - request->cancellation); - } - return request->result; - } catch (const std::bad_alloc &) { - return E_OUTOFMEMORY; - } catch (...) { - return E_UNEXPECTED; - } -} - -HRESULT OpenLessTextService::StartIpcServer() { return pipe_server_.Start(this); } - -void OpenLessTextService::StopIpcServer() { pipe_server_.Stop(); } - HRESULT OpenLessTextService::EnsureMessageWindow() { if (message_window_ != nullptr) { return S_OK; @@ -256,28 +150,117 @@ HRESULT OpenLessTextService::EnsureMessageWindow() { void OpenLessTextService::DestroyMessageWindow() { if (message_window_ != nullptr) { - MSG message = {}; - while (PeekMessageW(&message, message_window_, kSubmitTextMessage, kSubmitTextMessage, - PM_REMOVE)) { - delete reinterpret_cast(message.lParam); - } - DestroyWindow(message_window_); + const HWND window = message_window_; message_window_ = nullptr; + SetWindowLongPtrW(window, GWLP_USERDATA, 0); + DestroyWindow(window); } } -HRESULT OpenLessTextService::CommitTextOnOwnerThread( - const std::wstring &session_id, const std::wstring &text, - std::shared_ptr *async_completion, bool *wait_for_async_completion, - const std::shared_ptr> &cancellation) { - UNREFERENCED_PARAMETER(session_id); +LRESULT OpenLessTextService::HandleCopyData(const COPYDATASTRUCT *copy_data) { + if (copy_data == nullptr) { + return kStatusBadRequest; + } + if (copy_data->dwData == kCopyDataSubmit) { + return AcceptSubmit(copy_data); + } + if (copy_data->dwData == kCopyDataQuery) { + return QuerySubmit(copy_data); + } + return kStatusBadRequest; +} +LRESULT OpenLessTextService::AcceptSubmit(const COPYDATASTRUCT *copy_data) { + uint32_t token = 0; + if (!ReadToken(copy_data, &token) || copy_data->cbData > kMaxSubmitBytes || + (copy_data->cbData - sizeof(uint32_t)) % sizeof(wchar_t) != 0) { + return kStatusBadRequest; + } + + CancelPendingSubmit(); + + const size_t text_bytes = copy_data->cbData - sizeof(uint32_t); + submit_text_.assign(text_bytes / sizeof(wchar_t), L'\0'); + std::memcpy(submit_text_.data(), static_cast(copy_data->lpData) + sizeof(uint32_t), + text_bytes); + submit_token_ = token; + submit_status_ = kStatusPending; + + // The sender's SendMessage can be dispatched while the host is in the middle + // of something else. Commit from a posted message instead, so the edit + // session is requested from the top of the host message loop. + if (!PostMessageW(message_window_, kRunSubmitMessage, token, 0)) { + const DWORD error = GetLastError(); + CancelPendingSubmit(); + return StatusFromHResult(HRESULT_FROM_WIN32(error != ERROR_SUCCESS ? error : ERROR_GEN_FAILURE)); + } + return kStatusAccepted; +} + +LRESULT OpenLessTextService::QuerySubmit(const COPYDATASTRUCT *copy_data) { + uint32_t token = 0; + if (!ReadToken(copy_data, &token)) { + return kStatusBadRequest; + } + if (token != submit_token_) { + return kStatusUnknownToken; + } + + if (async_edit_ && async_edit_->completed) { + submit_status_ = StatusFromHResult(async_edit_->result); + async_edit_.reset(); + } + return submit_status_; +} + +void OpenLessTextService::RunPendingSubmit(uint32_t token) { + if (token != submit_token_ || submit_status_ != kStatusPending || async_edit_) { + return; // Superseded, cancelled, or already running. + } + + std::shared_ptr async_edit; + HRESULT hr = E_UNEXPECTED; + try { + const std::wstring text = std::move(submit_text_); + submit_text_.clear(); + hr = CommitTextOnOwnerThread(text, &async_edit); + } catch (const std::bad_alloc &) { + hr = E_OUTOFMEMORY; + } catch (...) { + hr = E_UNEXPECTED; + } + + // The edit session can re-enter the message loop, so a newer submit or + // Deactivate may have replaced this one in the meantime. + if (token != submit_token_) { + if (async_edit) { + async_edit->cancelled = true; + } + return; + } + + if (SUCCEEDED(hr) && async_edit) { + async_edit_ = std::move(async_edit); // QuerySubmit reports it once TSF runs the session. + return; + } + submit_status_ = StatusFromHResult(hr); +} + +void OpenLessTextService::CancelPendingSubmit() { + if (async_edit_) { + async_edit_->cancelled = true; + async_edit_.reset(); + } + submit_text_.clear(); + submit_token_ = 0; + submit_status_ = 0; +} + +HRESULT OpenLessTextService::CommitTextOnOwnerThread( + const std::wstring &text, std::shared_ptr *async_edit) { if (thread_mgr_ == nullptr || client_id_ == TF_CLIENTID_NULL) { return E_UNEXPECTED; } - if (cancellation && cancellation->load()) { - return HRESULT_FROM_WIN32(ERROR_CANCELLED); - } ITfDocumentMgr *document_mgr = nullptr; HRESULT hr = thread_mgr_->GetFocus(&document_mgr); @@ -299,14 +282,15 @@ HRESULT OpenLessTextService::CommitTextOnOwnerThread( return E_FAIL; } - auto *session = new (std::nothrow) OpenLessEditSession(context, text, nullptr, cancellation); + const TfClientId client_id = client_id_; + auto *session = new (std::nothrow) OpenLessEditSession(context, text); if (session == nullptr) { context->Release(); return E_OUTOFMEMORY; } HRESULT edit_result = S_OK; - hr = context->RequestEditSession(client_id_, session, TF_ES_SYNC | TF_ES_READWRITE, &edit_result); + hr = context->RequestEditSession(client_id, session, TF_ES_SYNC | TF_ES_READWRITE, &edit_result); session->Release(); const bool synchronous_rejected = @@ -319,35 +303,17 @@ HRESULT OpenLessTextService::CommitTextOnOwnerThread( return edit_result; } - if (async_completion == nullptr || wait_for_async_completion == nullptr) { - context->Release(); - if (FAILED(hr)) { - return hr; - } - return edit_result; - } - - if (cancellation && cancellation->load()) { - context->Release(); - return HRESULT_FROM_WIN32(ERROR_CANCELLED); - } - + // Hosts such as Word refuse a synchronous lock; queue the edit instead and + // let the caller poll for its completion. auto completion = std::make_shared(); - if (!completion->IsValid()) { - context->Release(); - return HRESULT_FROM_WIN32(completion->create_error != ERROR_SUCCESS ? completion->create_error - : ERROR_INVALID_HANDLE); - } - - auto *async_session = - new (std::nothrow) OpenLessEditSession(context, text, completion, cancellation); + auto *async_session = new (std::nothrow) OpenLessEditSession(context, text, completion); if (async_session == nullptr) { context->Release(); return E_OUTOFMEMORY; } HRESULT async_edit_result = S_OK; - hr = context->RequestEditSession(client_id_, async_session, TF_ES_ASYNC | TF_ES_READWRITE, + hr = context->RequestEditSession(client_id, async_session, TF_ES_ASYNC | TF_ES_READWRITE, &async_edit_result); async_session->Release(); context->Release(); @@ -359,15 +325,12 @@ HRESULT OpenLessTextService::CommitTextOnOwnerThread( return async_edit_result; } - *async_completion = std::move(completion); - *wait_for_async_completion = true; + *async_edit = std::move(completion); return S_OK; } LRESULT CALLBACK OpenLessTextService::MessageWindowProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { - UNREFERENCED_PARAMETER(wparam); - if (message == WM_NCCREATE) { const auto *create = reinterpret_cast(lparam); SetWindowLongPtrW(window, GWLP_USERDATA, reinterpret_cast(create->lpCreateParams)); @@ -375,30 +338,26 @@ LRESULT CALLBACK OpenLessTextService::MessageWindowProc(HWND window, UINT messag } auto *service = reinterpret_cast(GetWindowLongPtrW(window, GWLP_USERDATA)); - if (message == kSubmitTextMessage && service != nullptr) { - std::unique_ptr posted_request( - reinterpret_cast(lparam)); - if (!posted_request || !*posted_request) { - return 0; - } + if (service == nullptr || (message != WM_COPYDATA && message != kRunSubmitMessage)) { + return DefWindowProcW(window, message, wparam, lparam); + } - const auto request = *posted_request; - if (request->cancellation->load()) { - request->result = HRESULT_FROM_WIN32(ERROR_CANCELLED); + // Keep the service alive: committing can re-enter and let TSF deactivate and + // release it before this call returns. + service->AddRef(); + LRESULT result = 0; + try { + if (message == WM_COPYDATA) { + result = service->HandleCopyData(reinterpret_cast(lparam)); } else { - try { - request->result = service->CommitTextOnOwnerThread( - request->session_id, request->text, &request->async_completion, - &request->wait_for_async_completion, request->cancellation); - } catch (const std::bad_alloc &) { - request->result = E_OUTOFMEMORY; - } catch (...) { - request->result = E_UNEXPECTED; - } + service->RunPendingSubmit(static_cast(wparam)); } - SetEvent(request->completion_event); - return 1; + } catch (const std::bad_alloc &) { + // Never let an allocation or STL exception terminate the host process. + result = StatusFromHResult(E_OUTOFMEMORY); + } catch (...) { + result = StatusFromHResult(E_UNEXPECTED); } - - return DefWindowProcW(window, message, wparam, lparam); + service->Release(); + return result; } diff --git a/openless-all/app/windows-ime/src/text_service.h b/openless-all/app/windows-ime/src/text_service.h index becb0f9e0..25ec0060c 100644 --- a/openless-all/app/windows-ime/src/text_service.h +++ b/openless-all/app/windows-ime/src/text_service.h @@ -1,15 +1,16 @@ #pragma once #include -#include +#include #include #include #include -#include "ipc_client.h" - struct OpenLessAsyncEditState; +// The text service owns no threads. OpenLess submits text by sending +// WM_COPYDATA to the message-only window created on the TSF owner thread, so +// Activate/Deactivate never wait on anything inside the host process. class OpenLessTextService final : public ITfTextInputProcessorEx { public: OpenLessTextService(); @@ -25,18 +26,16 @@ class OpenLessTextService final : public ITfTextInputProcessorEx { STDMETHODIMP Deactivate() override; STDMETHODIMP ActivateEx(ITfThreadMgr *thread_mgr, TfClientId client_id, DWORD flags) override; - HRESULT SubmitTextFromPipe(const std::wstring &session_id, const std::wstring &text, - HANDLE cancellation_event = nullptr); - private: - HRESULT StartIpcServer(); - void StopIpcServer(); HRESULT EnsureMessageWindow(); void DestroyMessageWindow(); - HRESULT CommitTextOnOwnerThread(const std::wstring &session_id, const std::wstring &text, - std::shared_ptr *async_completion, - bool *wait_for_async_completion, - const std::shared_ptr> &cancellation); + LRESULT HandleCopyData(const COPYDATASTRUCT *copy_data); + LRESULT AcceptSubmit(const COPYDATASTRUCT *copy_data); + LRESULT QuerySubmit(const COPYDATASTRUCT *copy_data); + void RunPendingSubmit(uint32_t token); + void CancelPendingSubmit(); + HRESULT CommitTextOnOwnerThread(const std::wstring &text, + std::shared_ptr *async_edit); static LRESULT CALLBACK MessageWindowProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); @@ -44,7 +43,11 @@ class OpenLessTextService final : public ITfTextInputProcessorEx { LONG ref_count_ = 1; ITfThreadMgr *thread_mgr_ = nullptr; TfClientId client_id_ = TF_CLIENTID_NULL; - DWORD owner_thread_id_ = 0; HWND message_window_ = nullptr; - OpenLessPipeServer pipe_server_; + + // At most one submit is tracked; a newer one supersedes it. Owner thread only. + uint32_t submit_token_ = 0; + LRESULT submit_status_ = 0; + std::wstring submit_text_; + std::shared_ptr async_edit_; };