From c3b59329cd59d7a536d303f30f4e1cd36f368332 Mon Sep 17 00:00:00 2001 From: millionart Date: Fri, 1 May 2026 23:17:04 +0800 Subject: [PATCH] fix: avoid clipboard fallback for Windows voice insertion --- .../windows-real-asr-insertion-smoke.ps1 | 73 +++++++++++++++-- .../app/scripts/windows-smoke-suite.ps1 | 4 +- openless-all/app/src-tauri/src/coordinator.rs | 78 +++++++++++++++++-- openless-all/app/src-tauri/src/hotkey.rs | 25 ++++-- openless-all/app/src-tauri/src/insertion.rs | 56 +++++++++++++ .../app/src-tauri/src/windows_ime_ipc.rs | 30 +++++-- .../app/src-tauri/src/windows_ime_profile.rs | 38 +++++++-- .../app/src-tauri/src/windows_ime_protocol.rs | 14 +++- .../app/src-tauri/src/windows_ime_session.rs | 5 +- .../app/windows-ime/src/ipc_client.cpp | 33 +++++++- openless-all/app/windows-ime/src/ipc_client.h | 1 + openless-all/app/windows-ime/src/registry.cpp | 33 ++++++++ 12 files changed, 345 insertions(+), 45 deletions(-) diff --git a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 index 0ce1dcd6..c30ebcd8 100644 --- a/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 +++ b/openless-all/app/scripts/windows-real-asr-insertion-smoke.ps1 @@ -1,10 +1,12 @@ param( [string]$ExePath = "", - [ValidateSet("notepad", "browser")] + [ValidateSet("notepad", "browser", "win32edit")] [string]$Target = "notepad", [string]$Phrase = "OpenLess Windows real regression", [int]$TimeoutSeconds = 120, [int]$VirtualKey = 0xA3, + [switch]$AllowClipboardFallback, + [switch]$RequireJsonCredentials, [switch]$DebugHotkeyEvents ) @@ -263,6 +265,48 @@ function New-BrowserInputFixture { return $path } +function New-Win32EditHost { + $sourcePath = Join-Path $env:TEMP "OpenLessWin32EditHost.cs" + $exePath = Join-Path $env:TEMP "OpenLessWin32EditHost.exe" + $source = @" +using System; +using System.Windows.Forms; + +public static class OpenLessWin32EditHost { + [STAThread] + public static void Main() { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + var form = new Form(); + form.Text = "OpenLess Win32 Edit Host"; + form.Width = 820; + form.Height = 320; + var box = new TextBox(); + box.Multiline = true; + box.AcceptsReturn = true; + box.AcceptsTab = true; + box.Dock = DockStyle.Fill; + box.Font = new System.Drawing.Font("Consolas", 18); + form.Controls.Add(box); + form.Shown += (sender, args) => box.Focus(); + Application.Run(form); + } +} +"@ + $needsBuild = $true + if ((Test-Path $exePath) -and (Test-Path $sourcePath)) { + $needsBuild = (Get-Item $sourcePath).LastWriteTimeUtc -gt (Get-Item $exePath).LastWriteTimeUtc + } + if ($needsBuild) { + [System.IO.File]::WriteAllText($sourcePath, $source, [System.Text.UTF8Encoding]::new($false)) + Add-Type -TypeDefinition $source ` + -ReferencedAssemblies @("System.Windows.Forms", "System.Drawing") ` + -OutputAssembly $exePath ` + -OutputType WindowsApplication + } + return $exePath +} + function Stop-BrowserProfileProcesses($ProfilePath) { if ([string]::IsNullOrWhiteSpace($ProfilePath)) { return @@ -285,6 +329,15 @@ function Start-InputTarget($TargetName) { } return [pscustomobject]@{ Process = $process; FixturePath = $null; ProfilePath = $null } } + if ($TargetName -eq "win32edit") { + $hostExe = New-Win32EditHost + Start-Process -FilePath $hostExe | Out-Null + $process = Wait-ProcessWindow "OpenLessWin32EditHost" $startedAt 15 + if (-not (Focus-Window $process)) { + throw "Win32 edit host window could not be focused." + } + return [pscustomobject]@{ Process = $process; FixturePath = $null; ProfilePath = $null } + } $browserPath = Resolve-BrowserPath $fixture = New-BrowserInputFixture @@ -327,9 +380,12 @@ function Speak-TestPhrase($Text) { } $credentialStatus = Get-OpenLessCredentialStatus -if (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured) { +if ($RequireJsonCredentials -and (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured)) { throw "Real ASR regression requires configured Volcengine ASR and Ark LLM credentials." } +if (-not $credentialStatus.VolcengineConfigured -or -not $credentialStatus.ArkConfigured) { + Write-Warning "Legacy credentials.json is incomplete; continuing because the app may use the OS credential vault." +} $logPath = Join-Path $env:LOCALAPPDATA "OpenLess\Logs\openless.log" $historyPath = Join-Path $env:APPDATA "OpenLess\history.json" @@ -340,7 +396,7 @@ $previousPreferences = Set-HoldHotkeyPreference $preferencesPath Get-Process openless -ErrorAction SilentlyContinue | Stop-Process -Force Remove-Item -LiteralPath $logPath -Force -ErrorAction SilentlyContinue -Write-Host "== Real ASR + insertion fallback smoke ($Target) ==" +Write-Host "== Real ASR + direct insertion smoke ($Target) ==" $env:OPENLESS_SHOW_MAIN_ON_START = "1" $env:OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS = "1" if ($DebugHotkeyEvents) { @@ -356,7 +412,7 @@ try { $inputTarget = $null try { - if (-not (Wait-LogPattern $logPath "WH_KEYBOARD_LL installed" 20)) { + if (-not (Wait-LogPattern $logPath "hotkey listener installed" 20)) { throw "Windows low-level keyboard hook was not installed." } @@ -388,8 +444,11 @@ try { if ([string]::IsNullOrWhiteSpace($latest.rawTranscript) -or [string]::IsNullOrWhiteSpace($latest.finalText)) { throw "Latest history item is missing rawTranscript or finalText." } - if (@("copiedFallback", "pasteSent") -notcontains $latest.insertStatus) { - throw "Expected Windows insertStatus copiedFallback or pasteSent, got '$($latest.insertStatus)'." + if ($latest.insertStatus -ne "inserted") { + if (-not $AllowClipboardFallback -or @("copiedFallback", "pasteSent") -notcontains $latest.insertStatus) { + throw "Expected Windows insertStatus inserted, got '$($latest.insertStatus)'." + } + Write-Warning "Clipboard fallback was allowed for this run. insertStatus=$($latest.insertStatus)" } Focus-Window $inputTarget.Process | Out-Null @@ -430,4 +489,4 @@ try { } } -Write-Host "Real ASR + insertion fallback smoke ($Target) passed." +Write-Host "Real ASR + direct insertion smoke ($Target) passed." diff --git a/openless-all/app/scripts/windows-smoke-suite.ps1 b/openless-all/app/scripts/windows-smoke-suite.ps1 index 9e37be51..55797fed 100644 --- a/openless-all/app/scripts/windows-smoke-suite.ps1 +++ b/openless-all/app/scripts/windows-smoke-suite.ps1 @@ -1,7 +1,7 @@ param( [string]$ExePath = "", [string]$Phrase = "OpenLess Windows regression suite phrase. OpenLess Windows regression suite phrase.", - [ValidateSet("notepad", "browser")] + [ValidateSet("notepad", "browser", "win32edit")] [string[]]$Targets = @("notepad", "browser"), [switch]$Build, [switch]$SkipRuntime, @@ -105,7 +105,7 @@ try { if (-not $SkipRealAsr) { foreach ($target in $Targets) { - Invoke-Step "Real ASR insertion fallback: $target" { + Invoke-Step "Real ASR direct insertion: $target" { $parameters = @{ ExePath = $ExePath Target = $target diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 7a396e2c..5af7f550 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -32,6 +32,8 @@ use crate::types::{ HotkeyStatusState, InsertStatus, PolishMode, }; #[cfg(target_os = "windows")] +use crate::windows_ime_ipc::ImeSubmitTarget; +#[cfg(target_os = "windows")] use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -492,7 +494,9 @@ async fn begin_session(inner: &Arc) -> Result<(), String> { }); } // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 - inner.translation_modifier_seen.store(false, Ordering::SeqCst); + inner + .translation_modifier_seen + .store(false, Ordering::SeqCst); #[cfg(any(debug_assertions, test))] if hotkey_injection_dry_run_enabled() { @@ -952,8 +956,8 @@ async fn end_session(inner: &Arc) -> Result<(), String> { let hotword_strs = enabled_phrases(inner); let working_languages = prefs.working_languages.clone(); let translation_target = prefs.translation_target_language.trim().to_string(); - let translation_active = inner.translation_modifier_seen.load(Ordering::SeqCst) - && !translation_target.is_empty(); + let translation_active = + inner.translation_modifier_seen.load(Ordering::SeqCst) && !translation_target.is_empty(); let (polished, polish_error) = if translation_active { log::info!( "[coord] translation mode → target=\u{300C}{}\u{300D} working={:?}", @@ -992,9 +996,16 @@ async fn end_session(inner: &Arc) -> Result<(), String> { restore_focus_target_if_possible(focus_target); let restore_clipboard = inner.prefs.get().restore_clipboard_after_paste; #[cfg(target_os = "windows")] - let status = - insert_with_windows_ime_first(inner, current_session_id, &polished, restore_clipboard) - .await; + let ime_target = capture_ime_submit_target(); + #[cfg(target_os = "windows")] + let status = insert_with_windows_ime_first( + inner, + current_session_id, + &polished, + restore_clipboard, + ime_target, + ) + .await; #[cfg(not(target_os = "windows"))] let status = inner.inserter.insert(&polished, restore_clipboard); let inserted_chars = polished.chars().count() as u32; @@ -1151,6 +1162,7 @@ async fn insert_with_windows_ime_first( session_id: u64, polished: &str, restore_clipboard: bool, + ime_target: Option, ) -> InsertStatus { let prepared = { let mut slot = inner.prepared_windows_ime_session.lock(); @@ -1166,12 +1178,15 @@ async fn insert_with_windows_ime_first( session_id: Uuid::new_v4().to_string(), text: polished.to_string(), created_at: Utc::now().to_rfc3339(), + target: ime_target, }; let ime_status = match inner.windows_ime.submit_prepared(&prepared, request).await { Ok(status) => status, Err(error) => { - log::warn!("[windows-ime] TSF submit failed, falling back to clipboard: {error}"); + log::warn!( + "[windows-ime] TSF submit failed, falling back to non-TSF insertion: {error}" + ); InsertStatus::CopiedFallback } }; @@ -1179,6 +1194,9 @@ async fn insert_with_windows_ime_first( if ime_status == InsertStatus::Inserted { ime_status + } else if inner.inserter.insert_via_unicode_keystrokes(polished) == InsertStatus::Inserted { + log::info!("[windows-ime] TSF unavailable; inserted via Unicode SendInput"); + InsertStatus::Inserted } else { inner .inserter @@ -1293,7 +1311,9 @@ async fn polish_text( let config = OpenAICompatibleConfig::new("ark", "Doubao Ark", base_url, api_key, model); let provider = OpenAICompatibleLLMProvider::new(config); - Ok(provider.polish(raw, mode, hotwords, working_languages).await?) + Ok(provider + .polish(raw, mode, hotwords, working_languages) + .await?) } /// 翻译路径——和 polish 一样失败时返回原文 + 失败原因,避免"不丢字"约定被违反(CLAUDE.md)。 @@ -1684,6 +1704,48 @@ fn restore_focus_target_if_possible(target: Option) { #[cfg(not(target_os = "windows"))] fn restore_focus_target_if_possible(_target: Option) {} +#[cfg(target_os = "windows")] +fn capture_ime_submit_target() -> Option { + use windows::Win32::UI::WindowsAndMessaging::{ + GetForegroundWindow, GetGUIThreadInfo, GetWindowThreadProcessId, GUITHREADINFO, + }; + + let foreground = unsafe { GetForegroundWindow() }; + if foreground.0.is_null() { + return None; + } + + let mut foreground_process_id = 0; + let foreground_thread_id = + unsafe { GetWindowThreadProcessId(foreground, Some(&mut foreground_process_id)) }; + if foreground_thread_id == 0 { + return None; + } + + let mut gui_info = GUITHREADINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }; + let target_window = if unsafe { GetGUIThreadInfo(foreground_thread_id, &mut gui_info).is_ok() } + && !gui_info.hwndFocus.0.is_null() + { + gui_info.hwndFocus + } else { + foreground + }; + + let mut process_id = 0; + let thread_id = unsafe { GetWindowThreadProcessId(target_window, Some(&mut process_id)) }; + if process_id == 0 || thread_id == 0 { + return None; + } + + Some(ImeSubmitTarget { + process_id, + thread_id, + }) +} + #[cfg(target_os = "windows")] fn show_capsule_window_no_activate() -> bool { use std::iter::once; diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 8eb27b25..8cb87703 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -349,10 +349,14 @@ mod platform { let shift_active = (flags & FLAG_MASK_SHIFT) != 0; let shift_was_held = ctx.shared.translation_modifier_held.load(Ordering::SeqCst); if shift_active && !shift_was_held { - ctx.shared.translation_modifier_held.store(true, Ordering::SeqCst); + ctx.shared + .translation_modifier_held + .store(true, Ordering::SeqCst); send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed); } else if !shift_active && shift_was_held { - ctx.shared.translation_modifier_held.store(false, Ordering::SeqCst); + ctx.shared + .translation_modifier_held + .store(false, Ordering::SeqCst); } let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; @@ -582,13 +586,18 @@ mod platform { if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) { match message { WM_KEYDOWN | WM_SYSKEYDOWN => { - let was_held = ctx.shared.translation_modifier_held.swap(true, Ordering::SeqCst); + let was_held = ctx + .shared + .translation_modifier_held + .swap(true, Ordering::SeqCst); if !was_held { send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed); } } WM_KEYUP | WM_SYSKEYUP => { - ctx.shared.translation_modifier_held.store(false, Ordering::SeqCst); + ctx.shared + .translation_modifier_held + .store(false, Ordering::SeqCst); } _ => {} } @@ -724,7 +733,9 @@ mod platform { } // Shift(任一侧)= 翻译模式修饰键。详见 issue #4。 if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - let was_held = shared.translation_modifier_held.swap(true, Ordering::SeqCst); + let was_held = shared + .translation_modifier_held + .swap(true, Ordering::SeqCst); if !was_held { let _ = tx.send(HotkeyEvent::TranslationModifierPressed); } @@ -739,7 +750,9 @@ mod platform { } EventType::KeyRelease(key) => { if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - shared.translation_modifier_held.store(false, Ordering::SeqCst); + shared + .translation_modifier_held + .store(false, Ordering::SeqCst); return; } if key == trigger_to_rdev_key(trigger) { diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 014636af..63688e4c 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -44,6 +44,20 @@ impl TextInserter { self.insert(text, restore_clipboard_after_paste) } + #[cfg(target_os = "windows")] + pub fn insert_via_unicode_keystrokes(&self, text: &str) -> InsertStatus { + if text.is_empty() { + return InsertStatus::CopiedFallback; + } + match windows_unicode::send_text(text) { + Ok(()) => InsertStatus::Inserted, + Err(err) => { + log::warn!("[insertion] Unicode SendInput failed: {err}"); + InsertStatus::CopiedFallback + } + } + } + /// Insert `text` at the current cursor position. #[cfg(target_os = "macos")] pub fn insert(&self, text: &str, _restore_clipboard_after_paste: bool) -> InsertStatus { @@ -218,6 +232,48 @@ fn insertion_success_status() -> InsertStatus { InsertStatus::PasteSent } +#[cfg(target_os = "windows")] +mod windows_unicode { + use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, + KEYEVENTF_UNICODE, VIRTUAL_KEY, + }; + + pub fn send_text(text: &str) -> Result<(), String> { + for unit in text.encode_utf16() { + send_utf16_unit(unit, false)?; + send_utf16_unit(unit, true)?; + } + Ok(()) + } + + fn send_utf16_unit(unit: u16, key_up: bool) -> Result<(), String> { + let flags = if key_up { + KEYEVENTF_UNICODE | KEYEVENTF_KEYUP + } else { + KEYEVENTF_UNICODE + }; + let input = INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: VIRTUAL_KEY(0), + wScan: unit, + dwFlags: KEYBD_EVENT_FLAGS(flags.0), + time: 0, + dwExtraInfo: 0, + }, + }, + }; + let sent = unsafe { SendInput(&[input], std::mem::size_of::() as i32) }; + if sent == 1 { + Ok(()) + } else { + Err(std::io::Error::last_os_error().to_string()) + } + } +} + // ─────────────────────────── macOS native CGEvent paste ─────────────────────────── #[cfg(target_os = "macos")] 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 ac7d4c06..1a206dad 100644 --- a/openless-all/app/src-tauri/src/windows_ime_ipc.rs +++ b/openless-all/app/src-tauri/src/windows_ime_ipc.rs @@ -96,6 +96,13 @@ pub struct ImeSubmitRequest { pub session_id: String, pub text: String, pub created_at: String, + pub target: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ImeSubmitTarget { + pub process_id: u32, + pub thread_id: u32, } #[derive(Clone)] @@ -171,7 +178,7 @@ mod windows_pipe { IME_CLIENT_WAIT_TIMEOUT, IME_PIPE_RETRY_INTERVAL, IME_SUBMIT_TIMEOUT, }; use crate::windows_ime_protocol::{ - decode_message, encode_message, ImePipeMessage, OPENLESS_IME_PIPE_NAME, + decode_message, encode_message, ime_pipe_name_for_target, ImePipeMessage, OPENLESS_IME_PROTOCOL_VERSION, }; @@ -182,8 +189,10 @@ mod windows_pipe { pub async fn submit_text_over_pipe( request: ImeSubmitRequest, ) -> WindowsImeIpcResult { + let target = request.target.ok_or(WindowsImeIpcError::NoReadyClient)?; let mut pending = PendingImeSubmit::new(request.session_id.clone()); - let pipe = open_pipe_with_retry().await?; + let pipe_name = ime_pipe_name_for_target(target.process_id, target.thread_id); + let pipe = open_pipe_with_retry(&pipe_name).await?; let (read_half, mut write_half) = tokio::io::split(pipe); let mut reader = BufReader::new(read_half); @@ -230,8 +239,13 @@ mod windows_pipe { protocol_version, session_id, status, - .. + error_code, } if protocol_version == OPENLESS_IME_PROTOCOL_VERSION => { + if status != crate::windows_ime_protocol::ImeSubmitStatus::Committed { + log::warn!( + "[windows-ime] submit result status={status:?} error_code={error_code:?}" + ); + } pending.accept_result(&session_id, status) } ImePipeMessage::SubmitResult { @@ -245,12 +259,12 @@ mod windows_pipe { } } - async fn open_pipe_with_retry() -> WindowsImeIpcResult { + async fn open_pipe_with_retry(pipe_name: &str) -> WindowsImeIpcResult { let deadline = Instant::now() + IME_CLIENT_WAIT_TIMEOUT; loop { - let retry_error = match wait_for_pipe_client() { - Ok(()) => match ClientOptions::new().open(OPENLESS_IME_PIPE_NAME) { + let retry_error = match wait_for_pipe_client(pipe_name) { + Ok(()) => match ClientOptions::new().open(pipe_name) { Ok(pipe) => return Ok(pipe), Err(error) => { let error_code = error.raw_os_error().map(|code| code as u32); @@ -275,8 +289,8 @@ mod windows_pipe { } } - fn wait_for_pipe_client() -> WindowsImeIpcResult<()> { - let pipe_name = OsStr::new(OPENLESS_IME_PIPE_NAME) + fn wait_for_pipe_client(pipe_name: &str) -> WindowsImeIpcResult<()> { + let pipe_name = OsStr::new(pipe_name) .encode_wide() .chain(std::iter::once(0)) .collect::>(); 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 ed1430f0..1d7eac82 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -191,9 +191,10 @@ mod windows_impl { }; use windows::Win32::UI::Input::KeyboardAndMouse::HKL; use windows::Win32::UI::TextServices::{ - CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, GUID_TFCAT_TIP_KEYBOARD, - TF_INPUTPROCESSORPROFILE, TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE, TF_IPPMF_FORSESSION, - TF_PROFILETYPE_INPUTPROCESSOR, TF_PROFILETYPE_KEYBOARDLAYOUT, + 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, }; use winreg::enums::{HKEY_LOCAL_MACHINE, KEY_READ, KEY_WOW64_64KEY}; use winreg::RegKey; @@ -201,8 +202,9 @@ mod windows_impl { 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 PROFILE_ACTIVATION_FLAGS: u32 = - TF_IPPMF_FORSESSION | TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE; + 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; struct ComApartment; @@ -250,6 +252,12 @@ mod windows_impl { 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, @@ -257,7 +265,7 @@ mod windows_impl { &clsid, &profile_guid, null_hkl(), - PROFILE_ACTIVATION_FLAGS, + OPENLESS_PROFILE_ACTIVATION_FLAGS, ) }) } @@ -276,7 +284,7 @@ mod windows_impl { &clsid, &profile_guid, null_hkl(), - PROFILE_ACTIVATION_FLAGS, + PROFILE_RESTORE_FLAGS, ) }) } @@ -291,7 +299,7 @@ mod windows_impl { &zero_guid, &zero_guid, hkl, - PROFILE_ACTIVATION_FLAGS, + PROFILE_RESTORE_FLAGS, ) }) } @@ -409,6 +417,20 @@ mod windows_impl { operation(&manager).map_err(windows_api_error("ITfInputProcessorProfileMgr operation")) } + fn with_input_processor_profiles( + operation: impl FnOnce(&ITfInputProcessorProfiles) -> windows::core::Result, + ) -> WindowsImeProfileResult { + let _com = ComApartment::initialize()?; + let profiles: ITfInputProcessorProfiles = unsafe { + CoCreateInstance(&CLSID_TF_InputProcessorProfiles, None, CLSCTX_INPROC_SERVER) + } + .map_err(windows_api_error( + "CoCreateInstance ITfInputProcessorProfiles", + ))?; + + 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")) 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 adf6c93b..86f3f282 100644 --- a/openless-all/app/src-tauri/src/windows_ime_protocol.rs +++ b/openless-all/app/src-tauri/src/windows_ime_protocol.rs @@ -1,7 +1,11 @@ use serde::{Deserialize, Serialize}; pub const OPENLESS_IME_PROTOCOL_VERSION: u32 = 1; -pub const OPENLESS_IME_PIPE_NAME: &str = r"\\.\pipe\OpenLessImeSubmit"; +pub const OPENLESS_IME_PIPE_NAME_PREFIX: &str = r"\\.\pipe\OpenLessImeSubmit"; + +pub fn ime_pipe_name_for_target(process_id: u32, thread_id: u32) -> String { + format!("{OPENLESS_IME_PIPE_NAME_PREFIX}-{process_id}-{thread_id}") +} #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde( @@ -93,6 +97,14 @@ mod tests { assert_eq!(decoded, message); } + #[test] + fn ime_pipe_name_includes_target_process_and_thread() { + assert_eq!( + ime_pipe_name_for_target(1234, 5678), + r"\\.\pipe\OpenLessImeSubmit-1234-5678" + ); + } + #[test] fn stale_submit_result_is_rejected() { let result = ImePipeMessage::SubmitResult { 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 544fcff8..e43dafb3 100644 --- a/openless-all/app/src-tauri/src/windows_ime_session.rs +++ b/openless-all/app/src-tauri/src/windows_ime_session.rs @@ -124,7 +124,9 @@ impl WindowsImeSessionController { .await .map_err(|error| WindowsImeSessionError::Ipc(error.to_string()))?; if should_fallback_after_ime_result(status) { - log::warn!("[windows-ime] TSF submit returned {status:?}; falling back to clipboard"); + log::warn!( + "[windows-ime] TSF submit returned {status:?}; falling back to non-TSF insertion" + ); } Ok(map_ime_status_to_insert_status(status)) } @@ -198,6 +200,7 @@ mod tests { session_id: "session-1".to_string(), text: "hello".to_string(), created_at: "2026-05-01T12:00:00Z".to_string(), + target: None, }, ) .await; diff --git a/openless-all/app/windows-ime/src/ipc_client.cpp b/openless-all/app/windows-ime/src/ipc_client.cpp index d34b528e..55c42d15 100644 --- a/openless-all/app/windows-ime/src/ipc_client.cpp +++ b/openless-all/app/windows-ime/src/ipc_client.cpp @@ -7,7 +7,7 @@ namespace { -constexpr wchar_t kPipeName[] = L"\\\\.\\pipe\\OpenLessImeSubmit"; +constexpr wchar_t kPipeNamePrefix[] = L"\\\\.\\pipe\\OpenLessImeSubmit"; constexpr DWORD kPipeBufferSize = 4096; constexpr size_t kMaxJsonLineBytes = 64 * 1024; @@ -22,6 +22,25 @@ struct SubmitMessage { 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) { @@ -329,6 +348,7 @@ void OpenLessPipeServer::Start(OpenLessTextService* service) { } stop_requested_.store(false); + pipe_name_ = PipeNameForCurrentThread(); service_ = service; service_->AddRef(); thread_ = std::thread(&OpenLessPipeServer::Run, this); @@ -349,9 +369,10 @@ void OpenLessPipeServer::Stop() { } void OpenLessPipeServer::Run() { + const std::wstring pipe_name = pipe_name_; while (!stop_requested_.load()) { HANDLE pipe = CreateNamedPipeW( - kPipeName, PIPE_ACCESS_DUPLEX, + pipe_name.c_str(), PIPE_ACCESS_DUPLEX, PIPE_TYPE_MESSAGE | PIPE_READMODE_BYTE | PIPE_WAIT, 1, kPipeBufferSize, kPipeBufferSize, 0, nullptr); if (pipe == INVALID_HANDLE_VALUE) { @@ -441,7 +462,8 @@ void OpenLessPipeServer::HandleSubmitLine(HANDLE pipe, const std::string& line) if (SUCCEEDED(hr)) { WriteResult(pipe, message.session_id, L"committed", nullptr); } else { - WriteResult(pipe, message.session_id, L"rejected", L"commitRejected"); + const std::wstring error_code = HResultErrorCode(hr); + WriteResult(pipe, message.session_id, L"rejected", error_code.c_str()); } } @@ -477,8 +499,11 @@ bool OpenLessPipeServer::WriteResult(HANDLE pipe, } void OpenLessPipeServer::WakePipe() { + if (pipe_name_.empty()) { + return; + } HANDLE pipe = - CreateFileW(kPipeName, GENERIC_READ | GENERIC_WRITE, 0, nullptr, + CreateFileW(pipe_name_.c_str(), GENERIC_READ | GENERIC_WRITE, 0, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); if (pipe != INVALID_HANDLE_VALUE) { CloseHandle(pipe); diff --git a/openless-all/app/windows-ime/src/ipc_client.h b/openless-all/app/windows-ime/src/ipc_client.h index e0c0e799..909be569 100644 --- a/openless-all/app/windows-ime/src/ipc_client.h +++ b/openless-all/app/windows-ime/src/ipc_client.h @@ -32,5 +32,6 @@ class OpenLessPipeServer { std::thread thread_; std::mutex pipe_mutex_; HANDLE pipe_handle_ = INVALID_HANDLE_VALUE; + 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 e403a0c9..ba250f89 100644 --- a/openless-all/app/windows-ime/src/registry.cpp +++ b/openless-all/app/windows-ime/src/registry.cpp @@ -103,6 +103,13 @@ HRESULT CreateProfiles(ITfInputProcessorProfiles** profiles) { reinterpret_cast(profiles)); } +HRESULT CreateProfileManager(ITfInputProcessorProfileMgr** manager) { + return CoCreateInstance(CLSID_TF_InputProcessorProfiles, nullptr, + CLSCTX_INPROC_SERVER, + IID_ITfInputProcessorProfileMgr, + reinterpret_cast(manager)); +} + HRESULT CreateCategoryManager(ITfCategoryMgr** category_mgr) { return CoCreateInstance(CLSID_TF_CategoryMgr, nullptr, CLSCTX_INPROC_SERVER, IID_ITfCategoryMgr, @@ -138,6 +145,24 @@ HRESULT RegisterLanguageProfile() { } profiles->Release(); + + if (FAILED(hr)) { + return hr; + } + + ITfInputProcessorProfileMgr* manager = nullptr; + hr = CreateProfileManager(&manager); + if (FAILED(hr)) { + return hr; + } + + manager->UnregisterProfile(CLSID_OpenLessTextService, kOpenLessLangId, + GUID_OpenLessProfile, 0); + hr = manager->RegisterProfile( + CLSID_OpenLessTextService, kOpenLessLangId, GUID_OpenLessProfile, + kOpenLessImeName, static_cast(ARRAYSIZE(kOpenLessImeName) - 1), + nullptr, 0, 0, nullptr, 0, TRUE, 0); + manager->Release(); return hr; } @@ -178,6 +203,14 @@ HRESULT UnregisterLanguageProfile() { return hr; } + ITfInputProcessorProfileMgr* manager = nullptr; + hr = CreateProfileManager(&manager); + if (SUCCEEDED(hr)) { + manager->UnregisterProfile(CLSID_OpenLessTextService, kOpenLessLangId, + GUID_OpenLessProfile, 0); + manager->Release(); + } + hr = profiles->Unregister(CLSID_OpenLessTextService); profiles->Release(); return hr;