diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a0a86d00..e137b2cb 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2026,16 +2026,107 @@ fn should_try_non_tsf_insertion_fallback( fn insert_via_non_tsf_fallback( inner: &Arc, polished: &str, - restore_clipboard: bool, - paste_shortcut: PasteShortcut, + _restore_clipboard: bool, + _paste_shortcut: PasteShortcut, ) -> InsertStatus { - 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 - .insert_via_clipboard_fallback(polished, restore_clipboard, paste_shortcut) + let status = finish_non_tsf_insertion_fallback( + || inner.inserter.insert_via_unicode_keystrokes(polished), + || inner.inserter.copy_fallback(polished), + ); + + match status { + InsertStatus::Inserted => { + log::warn!( + "[windows-ime] TSF unavailable; inserted via paced Unicode SendInput fallback" + ); + } + InsertStatus::CopiedFallback => { + log::warn!( + "[windows-ime] TSF unavailable; Unicode SendInput failed, left text on clipboard" + ); + } + InsertStatus::PasteSent | InsertStatus::Failed => { + log::warn!( + "[windows-ime] TSF unavailable; Unicode SendInput fallback failed and copy fallback failed" + ); + } + } + + status +} + +#[cfg(any(target_os = "windows", test))] +fn finish_non_tsf_insertion_fallback( + mut unicode_fallback: U, + mut copy_only_fallback: C, +) -> InsertStatus +where + U: FnMut() -> InsertStatus, + C: FnMut() -> InsertStatus, +{ + match unicode_fallback() { + InsertStatus::Inserted => InsertStatus::Inserted, + InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => { + match copy_only_fallback() { + InsertStatus::CopiedFallback => InsertStatus::CopiedFallback, + // TextInserter::copy_fallback is copy-only: success is CopiedFallback. + // Treat any other status as failure so this helper never invents an insert. + InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => { + InsertStatus::Failed + } + } + } + } +} + +#[cfg(test)] +mod non_tsf_fallback_tests { + use super::finish_non_tsf_insertion_fallback; + use crate::types::InsertStatus; + + #[test] + fn unicode_fallback_runs_before_copy_fallback() { + let mut copy_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::Inserted, + || { + copy_called = true; + InsertStatus::CopiedFallback + }, + ); + + assert_eq!(status, InsertStatus::Inserted); + assert!(!copy_called); + } + + #[test] + fn copy_fallback_runs_after_unicode_failure() { + let mut copy_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::Failed, + || { + copy_called = true; + InsertStatus::CopiedFallback + }, + ); + + assert_eq!(status, InsertStatus::CopiedFallback); + assert!(copy_called); + } + + #[test] + fn double_failure_does_not_pretend_text_was_copied() { + let mut copy_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::Failed, + || { + copy_called = true; + InsertStatus::Failed + }, + ); + + assert_eq!(status, InsertStatus::Failed); + assert!(copy_called); } } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 1f399825..2c138897 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -114,39 +114,7 @@ async fn run_streaming_polish( // from what the user actually sees\"。 let (tx, rx) = std::sync::mpsc::channel::(); let typer_handle = tokio::task::spawn_blocking(move || { - let mut typed_text = String::new(); - let mut first_failure: Option = None; - let mut pending = String::new(); - while let Ok(delta) = rx.recv() { - pending.push_str(&delta); - let flush_at = std::time::Instant::now() + STREAMING_INSERT_FLUSH_INTERVAL; - loop { - let now = std::time::Instant::now(); - if now >= flush_at { - break; - } - match rx.recv_timeout(flush_at.duration_since(now)) { - Ok(delta) => pending.push_str(&delta), - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break, - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - first_failure = - flush_streaming_insert_buffer(&mut pending, &mut typed_text); - return (typed_text, first_failure); - } - } - } - first_failure = flush_streaming_insert_buffer(&mut pending, &mut typed_text); - if first_failure.is_some() { - // 一旦类型链路出错(如 Secure Input 启用),后续 delta 全部丢弃,但仍 - // 把 mpsc drain 完,避免发送端阻塞。 - while rx.recv().is_ok() {} - break; - } - } - if first_failure.is_none() { - first_failure = flush_streaming_insert_buffer(&mut pending, &mut typed_text); - } - (typed_text, first_failure) + drain_streaming_insert_deltas(rx, STREAMING_INSERT_FLUSH_INTERVAL) }); // 3. 调流式润色,on_delta 塞 mpsc;should_cancel 检查 dictation 取消旗。 @@ -283,13 +251,77 @@ async fn run_streaming_polish( } } +fn drain_streaming_insert_deltas( + rx: std::sync::mpsc::Receiver, + flush_interval: std::time::Duration, +) -> (String, Option) { + drain_streaming_insert_deltas_with(rx, flush_interval, flush_streaming_insert_buffer) +} + +fn drain_streaming_insert_deltas_with( + rx: std::sync::mpsc::Receiver, + flush_interval: std::time::Duration, + mut flush_pending: F, +) -> (String, Option) +where + F: FnMut(&mut String, &mut String) -> Option, +{ + let mut typed_text = String::new(); + let mut first_failure: Option = None; + let mut pending = String::new(); + while let Ok(delta) = rx.recv() { + pending.push_str(&delta); + let flush_at = std::time::Instant::now() + flush_interval; + loop { + let now = std::time::Instant::now(); + if now >= flush_at { + break; + } + match rx.recv_timeout(flush_at.duration_since(now)) { + Ok(delta) => pending.push_str(&delta), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + first_failure = flush_pending(&mut pending, &mut typed_text); + return (typed_text, first_failure); + } + } + } + first_failure = flush_pending(&mut pending, &mut typed_text); + if first_failure.is_some() { + // 一旦类型链路出错(如 Secure Input 启用),后续 delta 全部丢弃,但仍 + // 把 mpsc drain 完,避免发送端阻塞。 + while rx.recv().is_ok() {} + break; + } + } + if first_failure.is_none() { + first_failure = flush_pending(&mut pending, &mut typed_text); + } + (typed_text, first_failure) +} + fn flush_streaming_insert_buffer(pending: &mut String, typed_text: &mut String) -> Option { + flush_streaming_insert_buffer_with( + pending, + typed_text, + crate::unicode_keystroke::type_unicode_chunk, + ) +} + +fn flush_streaming_insert_buffer_with( + pending: &mut String, + typed_text: &mut String, + mut type_chunk: F, +) -> Option +where + F: FnMut(&str) -> Result, +{ if pending.is_empty() { return None; } let delta = std::mem::take(pending); let delta_chars = delta.chars().count(); - match crate::unicode_keystroke::type_unicode_chunk(&delta) { + match type_chunk(&delta) { Ok(typed_chars) => { let appended = append_typed_prefix(typed_text, &delta, typed_chars); if appended < delta_chars { @@ -362,9 +394,7 @@ fn streaming_insert_eligible( mode: PolishMode, raw_uses_llm: bool, ) -> bool { - streaming_insert_enabled - && !translation_active - && (mode != PolishMode::Raw || raw_uses_llm) + streaming_insert_enabled && !translation_active && (mode != PolishMode::Raw || raw_uses_llm) } fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option { @@ -1728,8 +1758,8 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> #[cfg(test)] mod tests { use super::{ - append_typed_prefix, default_done_message, dictation_error_code, - finalize_polished_text, streaming_insert_eligible, + append_typed_prefix, default_done_message, drain_streaming_insert_deltas_with, + finalize_polished_text, flush_streaming_insert_buffer_with, streaming_insert_eligible, }; use crate::types::{ChineseScriptPreference, CorrectionRule, InsertStatus, PolishMode}; @@ -1836,4 +1866,61 @@ mod tests { Some("润色失败,已插入原文".to_string()) ); } + + #[test] + fn streaming_insert_batches_queued_deltas_before_flush() { + let (tx, rx) = std::sync::mpsc::channel(); + tx.send("你".to_string()).unwrap(); + tx.send("好".to_string()).unwrap(); + tx.send("🙂".to_string()).unwrap(); + drop(tx); + + let mut flushed = Vec::new(); + let (typed, failure) = drain_streaming_insert_deltas_with( + rx, + std::time::Duration::from_millis(50), + |pending, typed_text| { + flushed.push(pending.clone()); + typed_text.push_str(pending); + pending.clear(); + None + }, + ); + + assert_eq!(flushed, vec!["你好🙂".to_string()]); + assert_eq!(typed, "你好🙂"); + assert_eq!(failure, None); + } + + #[test] + fn flush_streaming_insert_buffer_keeps_partial_unicode_prefix() { + let mut pending = "a你🙂b".to_string(); + let mut typed = String::new(); + + let failure = flush_streaming_insert_buffer_with(&mut pending, &mut typed, |_| { + Err(crate::unicode_keystroke::TypeError::Partial { + typed_chars: 3, + source: Box::new(platform_type_error()), + }) + }); + + assert_eq!(typed, "a你🙂"); + assert!(pending.is_empty()); + assert!(failure.is_some()); + } + + #[cfg(target_os = "macos")] + fn platform_type_error() -> crate::unicode_keystroke::TypeError { + crate::unicode_keystroke::TypeError::EventAllocFailed + } + + #[cfg(target_os = "windows")] + fn platform_type_error() -> crate::unicode_keystroke::TypeError { + crate::unicode_keystroke::TypeError::SendInputFailed("fail".into()) + } + + #[cfg(target_os = "linux")] + fn platform_type_error() -> crate::unicode_keystroke::TypeError { + crate::unicode_keystroke::TypeError::EnigoText("fail".into()) + } } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 9b1d6567..b5ca39b4 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -386,15 +386,30 @@ fn insertion_success_status() -> InsertStatus { #[cfg(target_os = "windows")] mod windows_unicode { + use std::time::Duration; + use windows::Win32::UI::Input::KeyboardAndMouse::{ SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, KEYEVENTF_UNICODE, VIRTUAL_KEY, }; + const SENDINPUT_CHUNK_CHARS: usize = 16; + const SENDINPUT_CHUNK_DELAY: Duration = Duration::from_millis(12); + pub fn send_text(text: &str) -> Result<(), String> { - for unit in text.encode_utf16() { - send_utf16_unit(unit, false)?; - send_utf16_unit(unit, true)?; + let mut sent_in_chunk = 0usize; + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + let mut buf = [0u16; 2]; + for unit in ch.encode_utf16(&mut buf) { + send_utf16_unit(*unit, false)?; + send_utf16_unit(*unit, true)?; + } + sent_in_chunk += 1; + if sent_in_chunk >= SENDINPUT_CHUNK_CHARS && chars.peek().is_some() { + std::thread::sleep(SENDINPUT_CHUNK_DELAY); + sent_in_chunk = 0; + } } Ok(()) } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 0b8b3752..0feb78de 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -544,7 +544,8 @@ pub struct UserPreferences { /// 行为一致,不破坏既有用户。 #[serde(default)] pub paste_shortcut: PasteShortcut, - /// Windows: 是否允许 TSF 失败后继续使用 SendInput / 粘贴类非 TSF 兜底。 + /// Windows: 是否允许 TSF 失败后继续使用分批 Unicode SendInput / 剪贴板兜底。 + /// Unicode SendInput 失败时才复制到剪贴板,避免文本丢失。 /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 #[serde(default = "default_true")] pub allow_non_tsf_insertion_fallback: bool, diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 3b177f83..225a4537 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -546,7 +546,7 @@ export const en: typeof zhCN = { comboClear: 'Clear', comboConflict: 'This shortcut combination is not available', allowNonTsfFallbackLabel: 'Allow non-TSF fallback', - allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, allow Unicode SendInput / shortcut paste.', + allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.', historyGroupTitle: 'History & context', historyRetentionLabel: 'History retention (days)', historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index d7479197..760baa83 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -548,7 +548,7 @@ export const ja: typeof zhCN = { comboClear: 'クリア', comboConflict: 'このショートカットの組み合わせは使用できません', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', - allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時に Unicode SendInput / ショートカットペーストへの切替を許可。', + allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', historyGroupTitle: '履歴とコンテキスト', historyRetentionLabel: '履歴保持期間(日)', historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index dbd068b1..c6d4de87 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -548,7 +548,7 @@ export const ko: typeof zhCN = { comboClear: '지우기', comboConflict: '이 단축키 조합은 사용할 수 없습니다', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', - allowNonTsfFallbackDesc: 'Windows: TSF 입력 실패 시 Unicode SendInput / 단축키 붙여넣기로 전환 허용.', + allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', historyGroupTitle: '기록 및 컨텍스트', historyRetentionLabel: '기록 보관 기간(일)', historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 089cbbbe..b32d04b2 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -544,7 +544,7 @@ export const zhCN = { comboClear: '清除', comboConflict: '该快捷键组合不可用', allowNonTsfFallbackLabel: '允许非 TSF 兜底', - allowNonTsfFallbackDesc: 'Windows:TSF 失败时改用 Unicode SendInput / 快捷键粘贴。', + allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', historyGroupTitle: '历史与上下文', historyRetentionLabel: '历史保留天数', historyRetentionDesc: '超过保留天数的历史在写入新条目时被清理;0 = 不按时间清理。', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 34a11160..8f31b52e 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -546,7 +546,7 @@ export const zhTW: typeof zhCN = { pasteShortcutCtrlShiftV: 'Ctrl+Shift+V(kitty / alacritty / wezterm / 多數終端)', pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', allowNonTsfFallbackLabel: '允許非 TSF 兜底', - allowNonTsfFallbackDesc: 'Windows:TSF 失敗時改用 Unicode SendInput / 快捷鍵粘貼。', + allowNonTsfFallbackDesc: 'Windows:TSF 失敗時使用分批 Unicode SendInput;如果仍失敗,再複製到剪貼簿。', historyGroupTitle: '歷史與上下文', historyRetentionLabel: '歷史保留天數', historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 67ff09e5..21a6a722 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -230,7 +230,7 @@ export interface UserPreferences { * 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩在剪贴板里。 * macOS 走 AX 直写不受影响。默认 'ctrlV' 与历史行为一致。 */ pasteShortcut: PasteShortcut; - /** Windows:TSF 失败后是否允许 SendInput / 粘贴类非 TSF 兜底。关闭后可验证是否真实 TSF 上屏。 */ + /** Windows:TSF 失败后是否允许快捷键粘贴 / 剪贴板兜底。仅在剪贴板写失败时才再试 SendInput。关闭后可验证是否真实 TSF 上屏。 */ allowNonTsfInsertionFallback: boolean; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ workingLanguages: string[];