fix: avoid clipboard fallback for Windows voice insertion

This commit is contained in:
millionart committed 2026-05-01 23:17:04 +08:00
1 parent 0e4747df8e
commit c3b59329cd
12 files changed
+344 -44

No files matched your search

@@ -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."
@@ -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
+69 -7
View File
@@ -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<Inner>) -> 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<Inner>) -> 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,8 +996,15 @@ async fn end_session(inner: &Arc<Inner>) -> 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)
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);
@@ -1151,6 +1162,7 @@ async fn insert_with_windows_ime_first(
session_id: u64,
polished: &str,
restore_clipboard: bool,
ime_target: Option<ImeSubmitTarget>,
) -> 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<usize>) {
#[cfg(not(target_os = "windows"))]
fn restore_focus_target_if_possible(_target: Option<usize>) {}
#[cfg(target_os = "windows")]
fn capture_ime_submit_target() -> Option<ImeSubmitTarget> {
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::<GUITHREADINFO>() 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;
+19 -6
View File
@@ -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) {
@@ -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::<INPUT>() as i32) };
if sent == 1 {
Ok(())
} else {
Err(std::io::Error::last_os_error().to_string())
}
}
}
// ─────────────────────────── macOS native CGEvent paste ───────────────────────────
#[cfg(target_os = "macos")]
@@ -96,6 +96,13 @@ pub struct ImeSubmitRequest {
pub session_id: String,
pub text: String,
pub created_at: String,
pub target: Option<ImeSubmitTarget>,
}
#[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<crate::windows_ime_protocol::ImeSubmitStatus> {
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<NamedPipeClient> {
async fn open_pipe_with_retry(pipe_name: &str) -> WindowsImeIpcResult<NamedPipeClient> {
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::<Vec<u16>>();
@@ -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<T>(
operation: impl FnOnce(&ITfInputProcessorProfiles) -> windows::core::Result<T>,
) -> WindowsImeProfileResult<T> {
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<GUID> {
parse_guid(value.ok_or_else(|| {
WindowsImeProfileError::WindowsApi(format!("missing {label} in saved IME profile"))
@@ -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 {
@@ -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;
@@ -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<unsigned long>(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);
@@ -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;
};
@@ -103,6 +103,13 @@ HRESULT CreateProfiles(ITfInputProcessorProfiles** profiles) {
reinterpret_cast<void**>(profiles));
}
HRESULT CreateProfileManager(ITfInputProcessorProfileMgr** manager) {
return CoCreateInstance(CLSID_TF_InputProcessorProfiles, nullptr,
CLSCTX_INPROC_SERVER,
IID_ITfInputProcessorProfileMgr,
reinterpret_cast<void**>(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<ULONG>(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;