fix: avoid clipboard fallback for Windows voice insertion
This commit is contained in:
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user