Skip to content

Commit e2d915e

Browse files
authored
Merge pull request #503 from H-Chris233/issue-491-windows-sendinput-pacing
fix(windows): reduce SendInput pressure after dictation
2 parents 370eaa8 + 96a0ee4 commit e2d915e

10 files changed

Lines changed: 252 additions & 58 deletions

File tree

openless-all/app/src-tauri/src/coordinator.rs

Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2026,16 +2026,107 @@ fn should_try_non_tsf_insertion_fallback(
20262026
fn insert_via_non_tsf_fallback(
20272027
inner: &Arc<Inner>,
20282028
polished: &str,
2029-
restore_clipboard: bool,
2030-
paste_shortcut: PasteShortcut,
2029+
_restore_clipboard: bool,
2030+
_paste_shortcut: PasteShortcut,
20312031
) -> InsertStatus {
2032-
if inner.inserter.insert_via_unicode_keystrokes(polished) == InsertStatus::Inserted {
2033-
log::info!("[windows-ime] TSF unavailable; inserted via Unicode SendInput");
2034-
InsertStatus::Inserted
2035-
} else {
2036-
inner
2037-
.inserter
2038-
.insert_via_clipboard_fallback(polished, restore_clipboard, paste_shortcut)
2032+
let status = finish_non_tsf_insertion_fallback(
2033+
|| inner.inserter.insert_via_unicode_keystrokes(polished),
2034+
|| inner.inserter.copy_fallback(polished),
2035+
);
2036+
2037+
match status {
2038+
InsertStatus::Inserted => {
2039+
log::warn!(
2040+
"[windows-ime] TSF unavailable; inserted via paced Unicode SendInput fallback"
2041+
);
2042+
}
2043+
InsertStatus::CopiedFallback => {
2044+
log::warn!(
2045+
"[windows-ime] TSF unavailable; Unicode SendInput failed, left text on clipboard"
2046+
);
2047+
}
2048+
InsertStatus::PasteSent | InsertStatus::Failed => {
2049+
log::warn!(
2050+
"[windows-ime] TSF unavailable; Unicode SendInput fallback failed and copy fallback failed"
2051+
);
2052+
}
2053+
}
2054+
2055+
status
2056+
}
2057+
2058+
#[cfg(any(target_os = "windows", test))]
2059+
fn finish_non_tsf_insertion_fallback<U, C>(
2060+
mut unicode_fallback: U,
2061+
mut copy_only_fallback: C,
2062+
) -> InsertStatus
2063+
where
2064+
U: FnMut() -> InsertStatus,
2065+
C: FnMut() -> InsertStatus,
2066+
{
2067+
match unicode_fallback() {
2068+
InsertStatus::Inserted => InsertStatus::Inserted,
2069+
InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => {
2070+
match copy_only_fallback() {
2071+
InsertStatus::CopiedFallback => InsertStatus::CopiedFallback,
2072+
// TextInserter::copy_fallback is copy-only: success is CopiedFallback.
2073+
// Treat any other status as failure so this helper never invents an insert.
2074+
InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => {
2075+
InsertStatus::Failed
2076+
}
2077+
}
2078+
}
2079+
}
2080+
}
2081+
2082+
#[cfg(test)]
2083+
mod non_tsf_fallback_tests {
2084+
use super::finish_non_tsf_insertion_fallback;
2085+
use crate::types::InsertStatus;
2086+
2087+
#[test]
2088+
fn unicode_fallback_runs_before_copy_fallback() {
2089+
let mut copy_called = false;
2090+
let status = finish_non_tsf_insertion_fallback(
2091+
|| InsertStatus::Inserted,
2092+
|| {
2093+
copy_called = true;
2094+
InsertStatus::CopiedFallback
2095+
},
2096+
);
2097+
2098+
assert_eq!(status, InsertStatus::Inserted);
2099+
assert!(!copy_called);
2100+
}
2101+
2102+
#[test]
2103+
fn copy_fallback_runs_after_unicode_failure() {
2104+
let mut copy_called = false;
2105+
let status = finish_non_tsf_insertion_fallback(
2106+
|| InsertStatus::Failed,
2107+
|| {
2108+
copy_called = true;
2109+
InsertStatus::CopiedFallback
2110+
},
2111+
);
2112+
2113+
assert_eq!(status, InsertStatus::CopiedFallback);
2114+
assert!(copy_called);
2115+
}
2116+
2117+
#[test]
2118+
fn double_failure_does_not_pretend_text_was_copied() {
2119+
let mut copy_called = false;
2120+
let status = finish_non_tsf_insertion_fallback(
2121+
|| InsertStatus::Failed,
2122+
|| {
2123+
copy_called = true;
2124+
InsertStatus::Failed
2125+
},
2126+
);
2127+
2128+
assert_eq!(status, InsertStatus::Failed);
2129+
assert!(copy_called);
20392130
}
20402131
}
20412132

openless-all/app/src-tauri/src/coordinator/dictation.rs

Lines changed: 126 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -114,39 +114,7 @@ async fn run_streaming_polish(
114114
// from what the user actually sees\"。
115115
let (tx, rx) = std::sync::mpsc::channel::<String>();
116116
let typer_handle = tokio::task::spawn_blocking(move || {
117-
let mut typed_text = String::new();
118-
let mut first_failure: Option<String> = None;
119-
let mut pending = String::new();
120-
while let Ok(delta) = rx.recv() {
121-
pending.push_str(&delta);
122-
let flush_at = std::time::Instant::now() + STREAMING_INSERT_FLUSH_INTERVAL;
123-
loop {
124-
let now = std::time::Instant::now();
125-
if now >= flush_at {
126-
break;
127-
}
128-
match rx.recv_timeout(flush_at.duration_since(now)) {
129-
Ok(delta) => pending.push_str(&delta),
130-
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break,
131-
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
132-
first_failure =
133-
flush_streaming_insert_buffer(&mut pending, &mut typed_text);
134-
return (typed_text, first_failure);
135-
}
136-
}
137-
}
138-
first_failure = flush_streaming_insert_buffer(&mut pending, &mut typed_text);
139-
if first_failure.is_some() {
140-
// 一旦类型链路出错(如 Secure Input 启用),后续 delta 全部丢弃,但仍
141-
// 把 mpsc drain 完,避免发送端阻塞。
142-
while rx.recv().is_ok() {}
143-
break;
144-
}
145-
}
146-
if first_failure.is_none() {
147-
first_failure = flush_streaming_insert_buffer(&mut pending, &mut typed_text);
148-
}
149-
(typed_text, first_failure)
117+
drain_streaming_insert_deltas(rx, STREAMING_INSERT_FLUSH_INTERVAL)
150118
});
151119

152120
// 3. 调流式润色,on_delta 塞 mpsc;should_cancel 检查 dictation 取消旗。
@@ -283,13 +251,77 @@ async fn run_streaming_polish(
283251
}
284252
}
285253

254+
fn drain_streaming_insert_deltas(
255+
rx: std::sync::mpsc::Receiver<String>,
256+
flush_interval: std::time::Duration,
257+
) -> (String, Option<String>) {
258+
drain_streaming_insert_deltas_with(rx, flush_interval, flush_streaming_insert_buffer)
259+
}
260+
261+
fn drain_streaming_insert_deltas_with<F>(
262+
rx: std::sync::mpsc::Receiver<String>,
263+
flush_interval: std::time::Duration,
264+
mut flush_pending: F,
265+
) -> (String, Option<String>)
266+
where
267+
F: FnMut(&mut String, &mut String) -> Option<String>,
268+
{
269+
let mut typed_text = String::new();
270+
let mut first_failure: Option<String> = None;
271+
let mut pending = String::new();
272+
while let Ok(delta) = rx.recv() {
273+
pending.push_str(&delta);
274+
let flush_at = std::time::Instant::now() + flush_interval;
275+
loop {
276+
let now = std::time::Instant::now();
277+
if now >= flush_at {
278+
break;
279+
}
280+
match rx.recv_timeout(flush_at.duration_since(now)) {
281+
Ok(delta) => pending.push_str(&delta),
282+
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break,
283+
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
284+
first_failure = flush_pending(&mut pending, &mut typed_text);
285+
return (typed_text, first_failure);
286+
}
287+
}
288+
}
289+
first_failure = flush_pending(&mut pending, &mut typed_text);
290+
if first_failure.is_some() {
291+
// 一旦类型链路出错(如 Secure Input 启用),后续 delta 全部丢弃,但仍
292+
// 把 mpsc drain 完,避免发送端阻塞。
293+
while rx.recv().is_ok() {}
294+
break;
295+
}
296+
}
297+
if first_failure.is_none() {
298+
first_failure = flush_pending(&mut pending, &mut typed_text);
299+
}
300+
(typed_text, first_failure)
301+
}
302+
286303
fn flush_streaming_insert_buffer(pending: &mut String, typed_text: &mut String) -> Option<String> {
304+
flush_streaming_insert_buffer_with(
305+
pending,
306+
typed_text,
307+
crate::unicode_keystroke::type_unicode_chunk,
308+
)
309+
}
310+
311+
fn flush_streaming_insert_buffer_with<F>(
312+
pending: &mut String,
313+
typed_text: &mut String,
314+
mut type_chunk: F,
315+
) -> Option<String>
316+
where
317+
F: FnMut(&str) -> Result<usize, crate::unicode_keystroke::TypeError>,
318+
{
287319
if pending.is_empty() {
288320
return None;
289321
}
290322
let delta = std::mem::take(pending);
291323
let delta_chars = delta.chars().count();
292-
match crate::unicode_keystroke::type_unicode_chunk(&delta) {
324+
match type_chunk(&delta) {
293325
Ok(typed_chars) => {
294326
let appended = append_typed_prefix(typed_text, &delta, typed_chars);
295327
if appended < delta_chars {
@@ -362,9 +394,7 @@ fn streaming_insert_eligible(
362394
mode: PolishMode,
363395
raw_uses_llm: bool,
364396
) -> bool {
365-
streaming_insert_enabled
366-
&& !translation_active
367-
&& (mode != PolishMode::Raw || raw_uses_llm)
397+
streaming_insert_enabled && !translation_active && (mode != PolishMode::Raw || raw_uses_llm)
368398
}
369399

370400
fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option<String> {
@@ -1728,8 +1758,8 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) ->
17281758
#[cfg(test)]
17291759
mod tests {
17301760
use super::{
1731-
append_typed_prefix, default_done_message, dictation_error_code,
1732-
finalize_polished_text, streaming_insert_eligible,
1761+
append_typed_prefix, default_done_message, drain_streaming_insert_deltas_with,
1762+
finalize_polished_text, flush_streaming_insert_buffer_with, streaming_insert_eligible,
17331763
};
17341764
use crate::types::{ChineseScriptPreference, CorrectionRule, InsertStatus, PolishMode};
17351765

@@ -1836,4 +1866,61 @@ mod tests {
18361866
Some("润色失败,已插入原文".to_string())
18371867
);
18381868
}
1869+
1870+
#[test]
1871+
fn streaming_insert_batches_queued_deltas_before_flush() {
1872+
let (tx, rx) = std::sync::mpsc::channel();
1873+
tx.send("你".to_string()).unwrap();
1874+
tx.send("好".to_string()).unwrap();
1875+
tx.send("🙂".to_string()).unwrap();
1876+
drop(tx);
1877+
1878+
let mut flushed = Vec::new();
1879+
let (typed, failure) = drain_streaming_insert_deltas_with(
1880+
rx,
1881+
std::time::Duration::from_millis(50),
1882+
|pending, typed_text| {
1883+
flushed.push(pending.clone());
1884+
typed_text.push_str(pending);
1885+
pending.clear();
1886+
None
1887+
},
1888+
);
1889+
1890+
assert_eq!(flushed, vec!["你好🙂".to_string()]);
1891+
assert_eq!(typed, "你好🙂");
1892+
assert_eq!(failure, None);
1893+
}
1894+
1895+
#[test]
1896+
fn flush_streaming_insert_buffer_keeps_partial_unicode_prefix() {
1897+
let mut pending = "a你🙂b".to_string();
1898+
let mut typed = String::new();
1899+
1900+
let failure = flush_streaming_insert_buffer_with(&mut pending, &mut typed, |_| {
1901+
Err(crate::unicode_keystroke::TypeError::Partial {
1902+
typed_chars: 3,
1903+
source: Box::new(platform_type_error()),
1904+
})
1905+
});
1906+
1907+
assert_eq!(typed, "a你🙂");
1908+
assert!(pending.is_empty());
1909+
assert!(failure.is_some());
1910+
}
1911+
1912+
#[cfg(target_os = "macos")]
1913+
fn platform_type_error() -> crate::unicode_keystroke::TypeError {
1914+
crate::unicode_keystroke::TypeError::EventAllocFailed
1915+
}
1916+
1917+
#[cfg(target_os = "windows")]
1918+
fn platform_type_error() -> crate::unicode_keystroke::TypeError {
1919+
crate::unicode_keystroke::TypeError::SendInputFailed("fail".into())
1920+
}
1921+
1922+
#[cfg(target_os = "linux")]
1923+
fn platform_type_error() -> crate::unicode_keystroke::TypeError {
1924+
crate::unicode_keystroke::TypeError::EnigoText("fail".into())
1925+
}
18391926
}

openless-all/app/src-tauri/src/insertion.rs

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -386,15 +386,30 @@ fn insertion_success_status() -> InsertStatus {
386386

387387
#[cfg(target_os = "windows")]
388388
mod windows_unicode {
389+
use std::time::Duration;
390+
389391
use windows::Win32::UI::Input::KeyboardAndMouse::{
390392
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP,
391393
KEYEVENTF_UNICODE, VIRTUAL_KEY,
392394
};
393395

396+
const SENDINPUT_CHUNK_CHARS: usize = 16;
397+
const SENDINPUT_CHUNK_DELAY: Duration = Duration::from_millis(12);
398+
394399
pub fn send_text(text: &str) -> Result<(), String> {
395-
for unit in text.encode_utf16() {
396-
send_utf16_unit(unit, false)?;
397-
send_utf16_unit(unit, true)?;
400+
let mut sent_in_chunk = 0usize;
401+
let mut chars = text.chars().peekable();
402+
while let Some(ch) = chars.next() {
403+
let mut buf = [0u16; 2];
404+
for unit in ch.encode_utf16(&mut buf) {
405+
send_utf16_unit(*unit, false)?;
406+
send_utf16_unit(*unit, true)?;
407+
}
408+
sent_in_chunk += 1;
409+
if sent_in_chunk >= SENDINPUT_CHUNK_CHARS && chars.peek().is_some() {
410+
std::thread::sleep(SENDINPUT_CHUNK_DELAY);
411+
sent_in_chunk = 0;
412+
}
398413
}
399414
Ok(())
400415
}

openless-all/app/src-tauri/src/types.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -544,7 +544,8 @@ pub struct UserPreferences {
544544
/// 行为一致,不破坏既有用户。
545545
#[serde(default)]
546546
pub paste_shortcut: PasteShortcut,
547-
/// Windows: 是否允许 TSF 失败后继续使用 SendInput / 粘贴类非 TSF 兜底。
547+
/// Windows: 是否允许 TSF 失败后继续使用分批 Unicode SendInput / 剪贴板兜底。
548+
/// Unicode SendInput 失败时才复制到剪贴板,避免文本丢失。
548549
/// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。
549550
#[serde(default = "default_true")]
550551
pub allow_non_tsf_insertion_fallback: bool,

openless-all/app/src/i18n/en.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ export const en: typeof zhCN = {
546546
comboClear: 'Clear',
547547
comboConflict: 'This shortcut combination is not available',
548548
allowNonTsfFallbackLabel: 'Allow non-TSF fallback',
549-
allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, allow Unicode SendInput / shortcut paste.',
549+
allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.',
550550
historyGroupTitle: 'History & context',
551551
historyRetentionLabel: 'History retention (days)',
552552
historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.',

openless-all/app/src/i18n/ja.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ export const ja: typeof zhCN = {
548548
comboClear: 'クリア',
549549
comboConflict: 'このショートカットの組み合わせは使用できません',
550550
allowNonTsfFallbackLabel: '非 TSF フォールバックを許可',
551-
allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時に Unicode SendInput / ショートカットペーストへの切替を許可。',
551+
allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。',
552552
historyGroupTitle: '履歴とコンテキスト',
553553
historyRetentionLabel: '履歴保持期間(日)',
554554
historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。',

openless-all/app/src/i18n/ko.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ export const ko: typeof zhCN = {
548548
comboClear: '지우기',
549549
comboConflict: '이 단축키 조합은 사용할 수 없습니다',
550550
allowNonTsfFallbackLabel: '비 TSF 폴백 허용',
551-
allowNonTsfFallbackDesc: 'Windows: TSF 입력 실패 시 Unicode SendInput / 단축키 붙여넣기로 전환 허용.',
551+
allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.',
552552
historyGroupTitle: '기록 및 컨텍스트',
553553
historyRetentionLabel: '기록 보관 기간(일)',
554554
historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.',

0 commit comments

Comments
 (0)