From 6ae3bfc45272bcfe4f4df6a00acbdd522490ddbb Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 20 May 2026 10:43:29 +0800 Subject: [PATCH 1/5] fix(windows): reduce SendInput pressure during insert --- openless-all/app/src-tauri/src/coordinator.rs | 117 ++++++++++++- .../src-tauri/src/coordinator/dictation.rs | 165 +++++++++++++----- openless-all/app/src-tauri/src/types.rs | 3 +- openless-all/app/src/i18n/en.ts | 2 +- openless-all/app/src/i18n/ja.ts | 2 +- openless-all/app/src/i18n/ko.ts | 2 +- openless-all/app/src/i18n/zh-CN.ts | 2 +- openless-all/app/src/i18n/zh-TW.ts | 2 +- openless-all/app/src/lib/types.ts | 2 +- 9 files changed, 245 insertions(+), 52 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a0a86d00..c3a8ca22 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2029,13 +2029,118 @@ fn insert_via_non_tsf_fallback( 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 + let mut retried_unicode = false; + let status = finish_non_tsf_insertion_fallback( + || { + inner.inserter.insert_via_clipboard_fallback( + polished, + restore_clipboard, + paste_shortcut, + ) + }, + || { + retried_unicode = true; + inner.inserter.insert_via_unicode_keystrokes(polished) + }, + ); + + if retried_unicode { + if status == InsertStatus::Inserted { + log::warn!( + "[windows-ime] TSF unavailable; clipboard write failed, inserted via Unicode SendInput" + ); + } else { + log::warn!( + "[windows-ime] TSF unavailable; clipboard write failed and Unicode SendInput fallback also failed" + ); + } } else { - inner - .inserter - .insert_via_clipboard_fallback(polished, restore_clipboard, paste_shortcut) + match status { + InsertStatus::PasteSent => { + log::info!("[windows-ime] TSF unavailable; attempted shortcut paste fallback"); + } + InsertStatus::CopiedFallback => { + log::warn!( + "[windows-ime] TSF unavailable; left text on clipboard, skipped Unicode SendInput retry" + ); + } + InsertStatus::Inserted | InsertStatus::Failed => {} + } + } + + status +} + +#[cfg(any(target_os = "windows", test))] +fn finish_non_tsf_insertion_fallback( + mut clipboard_fallback: C, + mut unicode_fallback: U, +) -> InsertStatus +where + C: FnMut() -> InsertStatus, + U: FnMut() -> InsertStatus, +{ + match clipboard_fallback() { + InsertStatus::Failed => match unicode_fallback() { + InsertStatus::Inserted => InsertStatus::Inserted, + // 这里的 Unicode fallback 没有经过剪贴板复制;若它失败,不能再伪装成 + // "已复制,请 Ctrl+V"。 + InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => { + InsertStatus::Failed + } + }, + status => status, + } +} + +#[cfg(test)] +mod non_tsf_fallback_tests { + use super::finish_non_tsf_insertion_fallback; + use crate::types::InsertStatus; + + #[test] + fn clipboard_copy_stops_before_unicode_retry() { + let mut unicode_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::CopiedFallback, + || { + unicode_called = true; + InsertStatus::Inserted + }, + ); + + assert_eq!(status, InsertStatus::CopiedFallback); + assert!(!unicode_called); + } + + #[test] + fn unicode_retry_runs_only_after_clipboard_failure() { + let mut unicode_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::Failed, + || { + unicode_called = true; + InsertStatus::Inserted + }, + ); + + assert_eq!(status, InsertStatus::Inserted); + assert!(unicode_called); + } + + #[test] + fn double_failure_does_not_pretend_text_was_copied() { + let mut unicode_called = false; + let status = finish_non_tsf_insertion_fallback( + || InsertStatus::Failed, + || { + unicode_called = true; + InsertStatus::CopiedFallback + }, + ); + + assert_eq!(status, InsertStatus::Failed); + assert!(unicode_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/types.rs b/openless-all/app/src-tauri/src/types.rs index 0b8b3752..737c479a 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,避免长文本注入卡顿。 /// 默认开启以保持可用性;关闭后可验证文本是否真正由 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..943f9880 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, try shortcut paste first; only fall back to Unicode SendInput if clipboard write fails.', 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..fa688a09 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..50304f3d 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..11b42ead 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..40e3334a 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[]; From db6c4bdbda6886a87f6a88280cf521288847395f Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 20 May 2026 12:01:03 +0800 Subject: [PATCH 2/5] fix(windows): add Unicode fallback mode --- openless-all/app/src-tauri/src/coordinator.rs | 124 +++++++++++++++++- .../src-tauri/src/coordinator/dictation.rs | 3 + openless-all/app/src-tauri/src/types.rs | 54 +++++++- openless-all/app/src/i18n/en.ts | 6 +- openless-all/app/src/i18n/ja.ts | 6 +- openless-all/app/src/i18n/ko.ts | 6 +- openless-all/app/src/i18n/zh-CN.ts | 6 +- openless-all/app/src/i18n/zh-TW.ts | 6 +- openless-all/app/src/lib/ipc.ts | 1 + openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 5 +- openless-all/app/src/pages/Settings.tsx | 26 ++++ 12 files changed, 231 insertions(+), 13 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c3a8ca22..5aaffecc 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -44,13 +44,13 @@ use crate::polish::{ use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; use crate::selection::capture_selection; -#[cfg(target_os = "windows")] -use crate::types::PasteShortcut; use crate::types::{ CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability, HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode, }; #[cfg(target_os = "windows")] +use crate::types::{PasteShortcut, WindowsNonTsfFallbackMode}; +#[cfg(target_os = "windows")] use crate::windows_ime_ipc::ImeSubmitTarget; #[cfg(target_os = "windows")] use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; @@ -1970,6 +1970,7 @@ async fn insert_with_windows_ime_first( restore_clipboard: bool, allow_non_tsf_insertion_fallback: bool, paste_shortcut: PasteShortcut, + fallback_mode: WindowsNonTsfFallbackMode, ime_target: Option, ) -> InsertStatus { let prepared = { @@ -1982,7 +1983,13 @@ async fn insert_with_windows_ime_first( allow_non_tsf_insertion_fallback, InsertStatus::Failed, ) { - return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut); + return insert_via_non_tsf_fallback( + inner, + polished, + restore_clipboard, + paste_shortcut, + fallback_mode, + ); } log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert"); return InsertStatus::Failed; @@ -2007,7 +2014,13 @@ async fn insert_with_windows_ime_first( if ime_status == InsertStatus::Inserted { ime_status } else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) { - insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut) + insert_via_non_tsf_fallback( + inner, + polished, + restore_clipboard, + paste_shortcut, + fallback_mode, + ) } else { log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled"); InsertStatus::Failed @@ -2028,6 +2041,27 @@ fn insert_via_non_tsf_fallback( polished: &str, restore_clipboard: bool, paste_shortcut: PasteShortcut, + fallback_mode: WindowsNonTsfFallbackMode, +) -> InsertStatus { + match fallback_mode { + WindowsNonTsfFallbackMode::ClipboardPaste => insert_via_clipboard_first_non_tsf_fallback( + inner, + polished, + restore_clipboard, + paste_shortcut, + ), + WindowsNonTsfFallbackMode::UnicodeKeystrokes => { + insert_via_unicode_first_non_tsf_fallback(inner, polished) + } + } +} + +#[cfg(target_os = "windows")] +fn insert_via_clipboard_first_non_tsf_fallback( + inner: &Arc, + polished: &str, + restore_clipboard: bool, + paste_shortcut: PasteShortcut, ) -> InsertStatus { let mut retried_unicode = false; let status = finish_non_tsf_insertion_fallback( @@ -2071,6 +2105,32 @@ fn insert_via_non_tsf_fallback( status } +#[cfg(target_os = "windows")] +fn insert_via_unicode_first_non_tsf_fallback(inner: &Arc, polished: &str) -> InsertStatus { + let status = finish_unicode_first_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 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 clipboard_fallback: C, @@ -2093,9 +2153,33 @@ where } } +#[cfg(any(target_os = "windows", test))] +fn finish_unicode_first_non_tsf_insertion_fallback( + mut unicode_fallback: U, + mut copy_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_fallback() { + InsertStatus::CopiedFallback => InsertStatus::CopiedFallback, + InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => { + InsertStatus::Failed + } + } + } + } +} + #[cfg(test)] mod non_tsf_fallback_tests { - use super::finish_non_tsf_insertion_fallback; + use super::{ + finish_non_tsf_insertion_fallback, finish_unicode_first_non_tsf_insertion_fallback, + }; use crate::types::InsertStatus; #[test] @@ -2142,6 +2226,36 @@ mod non_tsf_fallback_tests { assert_eq!(status, InsertStatus::Failed); assert!(unicode_called); } + + #[test] + fn unicode_first_mode_copies_text_when_unicode_fails() { + let mut copy_called = false; + let status = finish_unicode_first_non_tsf_insertion_fallback( + || InsertStatus::Failed, + || { + copy_called = true; + InsertStatus::CopiedFallback + }, + ); + + assert_eq!(status, InsertStatus::CopiedFallback); + assert!(copy_called); + } + + #[test] + fn unicode_first_mode_does_not_copy_after_success() { + let mut copy_called = false; + let status = finish_unicode_first_non_tsf_insertion_fallback( + || InsertStatus::Inserted, + || { + copy_called = true; + InsertStatus::CopiedFallback + }, + ); + + assert_eq!(status, InsertStatus::Inserted); + assert!(!copy_called); + } } // ─────────────────────────── helpers ─────────────────────────── diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 2c138897..648789d7 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1561,6 +1561,8 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let restore_clipboard = prefs.restore_clipboard_after_paste; let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; let paste_shortcut = prefs.paste_shortcut; + #[cfg(target_os = "windows")] + let windows_non_tsf_fallback_mode = prefs.windows_non_tsf_fallback_mode; // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 let status = if already_streamed { log::info!( @@ -1580,6 +1582,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { restore_clipboard, allow_non_tsf_insertion_fallback, paste_shortcut, + windows_non_tsf_fallback_mode, ime_target, ) .await diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 737c479a..9a405b06 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -60,6 +60,17 @@ pub enum PasteShortcut { ShiftInsert, } +/// Windows TSF 不可用时的非 TSF 插入策略。 +/// `ClipboardPaste` 是默认安全路径:避免长文本逐字 SendInput 压垮目标应用; +/// `UnicodeKeystrokes` 保留给会吞掉粘贴快捷键、但接受合成字符输入的应用。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum WindowsNonTsfFallbackMode { + #[default] + ClipboardPaste, + UnicodeKeystrokes, +} + /// Auto-update 渠道。决定 Settings → 关于 里展示哪一类版本信息。 /// `Stable` 沿用 `tauri-plugin-updater` 的默认 endpoints(即 `tauri.conf.json` /// 里的 `latest-{{target}}-{{arch}}.json`),与发版 pipeline 对齐。 @@ -544,11 +555,15 @@ pub struct UserPreferences { /// 行为一致,不破坏既有用户。 #[serde(default)] pub paste_shortcut: PasteShortcut, - /// Windows: 是否允许 TSF 失败后继续使用快捷键粘贴 / 剪贴板兜底。 - /// 仅在剪贴板写入失败时才再试 Unicode SendInput,避免长文本注入卡顿。 + /// Windows: 是否允许 TSF 失败后继续使用非 TSF 方式兜底。 + /// 具体方式由 `windows_non_tsf_fallback_mode` 决定。 /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 #[serde(default = "default_true")] pub allow_non_tsf_insertion_fallback: bool, + /// Windows: TSF 失败后的非 TSF 插入方式。默认剪贴板粘贴,避免 #491 的长文本 + /// SendInput 压力;少数会吞粘贴快捷键的应用可手动切到 Unicode SendInput。 + #[serde(default)] + pub windows_non_tsf_fallback_mode: WindowsNonTsfFallbackMode, /// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部, /// 让模型知道该用户在哪些语言间工作。详见 issue #4。 #[serde(default = "default_working_languages")] @@ -755,6 +770,8 @@ struct UserPreferencesWire { #[serde(default)] paste_shortcut: PasteShortcut, allow_non_tsf_insertion_fallback: bool, + #[serde(default)] + windows_non_tsf_fallback_mode: WindowsNonTsfFallbackMode, working_languages: Vec, translation_target_language: String, chinese_script_preference: ChineseScriptPreference, @@ -829,6 +846,7 @@ impl Default for UserPreferencesWire { restore_clipboard_after_paste: prefs.restore_clipboard_after_paste, paste_shortcut: prefs.paste_shortcut, allow_non_tsf_insertion_fallback: prefs.allow_non_tsf_insertion_fallback, + windows_non_tsf_fallback_mode: prefs.windows_non_tsf_fallback_mode, working_languages: prefs.working_languages, translation_target_language: prefs.translation_target_language, chinese_script_preference: prefs.chinese_script_preference, @@ -904,6 +922,7 @@ impl<'de> Deserialize<'de> for UserPreferences { restore_clipboard_after_paste: wire.restore_clipboard_after_paste, paste_shortcut: wire.paste_shortcut, allow_non_tsf_insertion_fallback: wire.allow_non_tsf_insertion_fallback, + windows_non_tsf_fallback_mode: wire.windows_non_tsf_fallback_mode, working_languages: wire.working_languages, translation_target_language: wire.translation_target_language, chinese_script_preference: wire.chinese_script_preference, @@ -1591,6 +1610,7 @@ impl Default for UserPreferences { restore_clipboard_after_paste: true, paste_shortcut: PasteShortcut::default(), allow_non_tsf_insertion_fallback: true, + windows_non_tsf_fallback_mode: WindowsNonTsfFallbackMode::default(), working_languages: default_working_languages(), translation_target_language: String::new(), chinese_script_preference: ChineseScriptPreference::Auto, @@ -2360,6 +2380,36 @@ mod tests { } } + #[test] + fn windows_non_tsf_fallback_mode_defaults_to_clipboard_paste() { + let prefs = UserPreferences::default(); + assert_eq!( + prefs.windows_non_tsf_fallback_mode, + WindowsNonTsfFallbackMode::ClipboardPaste + ); + + let from_empty: UserPreferences = serde_json::from_str("{}").unwrap(); + assert_eq!( + from_empty.windows_non_tsf_fallback_mode, + WindowsNonTsfFallbackMode::ClipboardPaste + ); + } + + #[test] + fn windows_non_tsf_fallback_mode_round_trips_explicit_values() { + for (raw, expected) in [ + ("clipboardPaste", WindowsNonTsfFallbackMode::ClipboardPaste), + ( + "unicodeKeystrokes", + WindowsNonTsfFallbackMode::UnicodeKeystrokes, + ), + ] { + let json = format!(r#"{{ "windowsNonTsfFallbackMode": "{raw}" }}"#); + let prefs: UserPreferences = serde_json::from_str(&json).unwrap(); + assert_eq!(prefs.windows_non_tsf_fallback_mode, expected, "raw={raw}"); + } + } + #[test] fn legacy_custom_hotkey_without_custom_binding_is_rejected() { let result = serde_json::from_str::( diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 943f9880..d96e61d4 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -546,7 +546,11 @@ 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, try shortcut paste first; only fall back to Unicode SendInput if clipboard write fails.', + allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, allow clipboard paste or Unicode SendInput as a fallback.', + windowsNonTsfFallbackModeLabel: 'Non-TSF fallback method', + windowsNonTsfFallbackModeDesc: 'Default to clipboard paste to avoid long-text stalls; switch to Unicode SendInput for apps that swallow paste shortcuts.', + windowsNonTsfFallbackModeClipboardPaste: 'Clipboard + paste shortcut (default)', + windowsNonTsfFallbackModeUnicodeKeystrokes: 'Unicode SendInput (for paste-blocking apps)', 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 fa688a09..5841b41e 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -548,7 +548,11 @@ export const ja: typeof zhCN = { comboClear: 'クリア', comboConflict: 'このショートカットの組み合わせは使用できません', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', - allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は先にショートカット貼り付けを試し、クリップボード書き込みも失敗した時だけ Unicode SendInput に切り替えます。', + allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時に、クリップボード貼り付けまたは Unicode SendInput でフォールバックします。', + windowsNonTsfFallbackModeLabel: '非 TSF フォールバック方式', + windowsNonTsfFallbackModeDesc: '既定では長文の停止を避けるためクリップボード貼り付けを使います。貼り付けショートカットを無視するアプリでは Unicode SendInput に切り替えてください。', + windowsNonTsfFallbackModeClipboardPaste: 'クリップボード + 貼り付けショートカット(既定)', + windowsNonTsfFallbackModeUnicodeKeystrokes: '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 50304f3d..3db4bd6e 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -548,7 +548,11 @@ export const ko: typeof zhCN = { comboClear: '지우기', comboConflict: '이 단축키 조합은 사용할 수 없습니다', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', - allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 먼저 단축키 붙여넣기를 시도하고, 클립보드 쓰기까지 실패한 경우에만 Unicode SendInput으로 전환.', + allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 클립보드 붙여넣기 또는 Unicode SendInput으로 폴백합니다.', + windowsNonTsfFallbackModeLabel: '비 TSF 폴백 방식', + windowsNonTsfFallbackModeDesc: '기본값은 긴 텍스트 지연을 피하기 위해 클립보드 붙여넣기를 사용합니다. 붙여넣기 단축키를 무시하는 앱은 Unicode SendInput으로 전환하세요.', + windowsNonTsfFallbackModeClipboardPaste: '클립보드 + 붙여넣기 단축키 (기본값)', + windowsNonTsfFallbackModeUnicodeKeystrokes: '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 11b42ead..edb18bb5 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -544,7 +544,11 @@ export const zhCN = { comboClear: '清除', comboConflict: '该快捷键组合不可用', allowNonTsfFallbackLabel: '允许非 TSF 兜底', - allowNonTsfFallbackDesc: 'Windows:TSF 失败时先改用快捷键粘贴;剪贴板写入失败时才退到 Unicode SendInput。', + allowNonTsfFallbackDesc: 'Windows:TSF 失败后允许继续用剪贴板粘贴或 Unicode SendInput 兜底。', + windowsNonTsfFallbackModeLabel: '非 TSF 兜底方式', + windowsNonTsfFallbackModeDesc: '默认用剪贴板粘贴,避免长文本卡顿;如果目标应用会吞粘贴快捷键,可改用 Unicode SendInput。', + windowsNonTsfFallbackModeClipboardPaste: '剪贴板 + 粘贴快捷键(默认)', + windowsNonTsfFallbackModeUnicodeKeystrokes: '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 40e3334a..1cdfe2f7 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -546,7 +546,11 @@ 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 兜底。', + windowsNonTsfFallbackModeLabel: '非 TSF 兜底方式', + windowsNonTsfFallbackModeDesc: '默認用剪貼簿貼上,避免長文本卡頓;如果目標應用會吞貼上快捷鍵,可改用 Unicode SendInput。', + windowsNonTsfFallbackModeClipboardPaste: '剪貼簿 + 貼上快捷鍵(默認)', + windowsNonTsfFallbackModeUnicodeKeystrokes: 'Unicode SendInput(兼容吞貼上的應用)', historyGroupTitle: '歷史與上下文', historyRetentionLabel: '歷史保留天數', historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index fb551d5d..32a7c7a4 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -77,6 +77,7 @@ let mockSettings: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, + windowsNonTsfFallbackMode: 'clipboardPaste', workingLanguages: ['简体中文'], translationTargetLanguage: '', qaHotkey: defaultQaShortcut(), diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 3f93ca38..d0683428 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -38,6 +38,7 @@ const previousPrefs: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, + windowsNonTsfFallbackMode: 'clipboardPaste', workingLanguages: ['简体中文'], translationTargetLanguage: '', chineseScriptPreference: 'auto', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 21a6a722..b293f3cb 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -119,6 +119,7 @@ export type ComboBinding = ShortcutBinding; * - shiftInsert : xterm / urxvt 等老派 X11 终端 * 详见 issue #360。 */ export type PasteShortcut = 'ctrlV' | 'ctrlShiftV' | 'shiftInsert'; +export type WindowsNonTsfFallbackMode = 'clipboardPaste' | 'unicodeKeystrokes'; export type WindowsImeInstallState = | 'installed' @@ -230,8 +231,10 @@ export interface UserPreferences { * 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩在剪贴板里。 * macOS 走 AX 直写不受影响。默认 'ctrlV' 与历史行为一致。 */ pasteShortcut: PasteShortcut; - /** Windows:TSF 失败后是否允许快捷键粘贴 / 剪贴板兜底。仅在剪贴板写失败时才再试 SendInput。关闭后可验证是否真实 TSF 上屏。 */ + /** Windows:TSF 失败后是否允许非 TSF 兜底。关闭后可验证是否真实 TSF 上屏。 */ allowNonTsfInsertionFallback: boolean; + /** Windows:非 TSF 兜底方式。默认剪贴板粘贴;少数吞粘贴快捷键的应用可切到 SendInput。 */ + windowsNonTsfFallbackMode: WindowsNonTsfFallbackMode; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ workingLanguages: string[]; /** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */ diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 111a40e6..25b70dfb 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -34,6 +34,7 @@ import type { HotkeyTrigger, MicrophoneDevice, PasteShortcut, + WindowsNonTsfFallbackMode, } from '../lib/types'; import { emitSaved } from '../lib/savedEvent'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -281,6 +282,8 @@ function RecordingSection() { savePrefs({ ...prefs, pasteShortcut }); const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => savePrefs({ ...prefs, allowNonTsfInsertionFallback }); + const onWindowsNonTsfFallbackModeChange = (windowsNonTsfFallbackMode: WindowsNonTsfFallbackMode) => + savePrefs({ ...prefs, windowsNonTsfFallbackMode }); // 历史保留 / 对话感知 polish 上下文窗口都用裸 number input;空字符串时回滚到默认值。 // 范围限制:retention 0-365 天,context window 0-60 分钟(再大的值对实际对话场景没意义且白烧 token)。 const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); @@ -507,6 +510,29 @@ function RecordingSection() { /> )} + {capability.adapter === 'windowsLowLevel' && prefs.allowNonTsfInsertionFallback && ( + + onWindowsNonTsfFallbackModeChange(next as WindowsNonTsfFallbackMode)} + options={[ + { + value: 'clipboardPaste', + label: t('settings.recording.windowsNonTsfFallbackModeClipboardPaste'), + }, + { + value: 'unicodeKeystrokes', + label: t('settings.recording.windowsNonTsfFallbackModeUnicodeKeystrokes'), + }, + ]} + ariaLabel={t('settings.recording.windowsNonTsfFallbackModeLabel')} + style={{ ...inputStyle, maxWidth: 260 }} + /> + + )} {/* ─── 历史与上下文(折叠) ────────────────────────────────── */} From 5bf197c359265b535ec2f76cc91615ba7c5fd411 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 20 May 2026 13:03:43 +0800 Subject: [PATCH 3/5] Revert "fix(windows): add Unicode fallback mode" This reverts commit db6c4bdbda6886a87f6a88280cf521288847395f. --- openless-all/app/src-tauri/src/coordinator.rs | 124 +----------------- .../src-tauri/src/coordinator/dictation.rs | 3 - openless-all/app/src-tauri/src/types.rs | 54 +------- openless-all/app/src/i18n/en.ts | 6 +- openless-all/app/src/i18n/ja.ts | 6 +- openless-all/app/src/i18n/ko.ts | 6 +- openless-all/app/src/i18n/zh-CN.ts | 6 +- openless-all/app/src/i18n/zh-TW.ts | 6 +- openless-all/app/src/lib/ipc.ts | 1 - openless-all/app/src/lib/stylePrefs.test.ts | 1 - openless-all/app/src/lib/types.ts | 5 +- openless-all/app/src/pages/Settings.tsx | 26 ---- 12 files changed, 13 insertions(+), 231 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 5aaffecc..c3a8ca22 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -44,13 +44,13 @@ use crate::polish::{ use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; use crate::selection::capture_selection; +#[cfg(target_os = "windows")] +use crate::types::PasteShortcut; use crate::types::{ CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability, HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode, }; #[cfg(target_os = "windows")] -use crate::types::{PasteShortcut, WindowsNonTsfFallbackMode}; -#[cfg(target_os = "windows")] use crate::windows_ime_ipc::ImeSubmitTarget; #[cfg(target_os = "windows")] use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController}; @@ -1970,7 +1970,6 @@ async fn insert_with_windows_ime_first( restore_clipboard: bool, allow_non_tsf_insertion_fallback: bool, paste_shortcut: PasteShortcut, - fallback_mode: WindowsNonTsfFallbackMode, ime_target: Option, ) -> InsertStatus { let prepared = { @@ -1983,13 +1982,7 @@ async fn insert_with_windows_ime_first( allow_non_tsf_insertion_fallback, InsertStatus::Failed, ) { - return insert_via_non_tsf_fallback( - inner, - polished, - restore_clipboard, - paste_shortcut, - fallback_mode, - ); + return insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut); } log::warn!("[windows-ime] non-TSF insertion fallback is disabled; failing insert"); return InsertStatus::Failed; @@ -2014,13 +2007,7 @@ async fn insert_with_windows_ime_first( if ime_status == InsertStatus::Inserted { ime_status } else if should_try_non_tsf_insertion_fallback(allow_non_tsf_insertion_fallback, ime_status) { - insert_via_non_tsf_fallback( - inner, - polished, - restore_clipboard, - paste_shortcut, - fallback_mode, - ) + insert_via_non_tsf_fallback(inner, polished, restore_clipboard, paste_shortcut) } else { log::warn!("[windows-ime] TSF did not insert; non-TSF insertion fallback is disabled"); InsertStatus::Failed @@ -2041,27 +2028,6 @@ fn insert_via_non_tsf_fallback( polished: &str, restore_clipboard: bool, paste_shortcut: PasteShortcut, - fallback_mode: WindowsNonTsfFallbackMode, -) -> InsertStatus { - match fallback_mode { - WindowsNonTsfFallbackMode::ClipboardPaste => insert_via_clipboard_first_non_tsf_fallback( - inner, - polished, - restore_clipboard, - paste_shortcut, - ), - WindowsNonTsfFallbackMode::UnicodeKeystrokes => { - insert_via_unicode_first_non_tsf_fallback(inner, polished) - } - } -} - -#[cfg(target_os = "windows")] -fn insert_via_clipboard_first_non_tsf_fallback( - inner: &Arc, - polished: &str, - restore_clipboard: bool, - paste_shortcut: PasteShortcut, ) -> InsertStatus { let mut retried_unicode = false; let status = finish_non_tsf_insertion_fallback( @@ -2105,32 +2071,6 @@ fn insert_via_clipboard_first_non_tsf_fallback( status } -#[cfg(target_os = "windows")] -fn insert_via_unicode_first_non_tsf_fallback(inner: &Arc, polished: &str) -> InsertStatus { - let status = finish_unicode_first_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 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 clipboard_fallback: C, @@ -2153,33 +2093,9 @@ where } } -#[cfg(any(target_os = "windows", test))] -fn finish_unicode_first_non_tsf_insertion_fallback( - mut unicode_fallback: U, - mut copy_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_fallback() { - InsertStatus::CopiedFallback => InsertStatus::CopiedFallback, - InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => { - InsertStatus::Failed - } - } - } - } -} - #[cfg(test)] mod non_tsf_fallback_tests { - use super::{ - finish_non_tsf_insertion_fallback, finish_unicode_first_non_tsf_insertion_fallback, - }; + use super::finish_non_tsf_insertion_fallback; use crate::types::InsertStatus; #[test] @@ -2226,36 +2142,6 @@ mod non_tsf_fallback_tests { assert_eq!(status, InsertStatus::Failed); assert!(unicode_called); } - - #[test] - fn unicode_first_mode_copies_text_when_unicode_fails() { - let mut copy_called = false; - let status = finish_unicode_first_non_tsf_insertion_fallback( - || InsertStatus::Failed, - || { - copy_called = true; - InsertStatus::CopiedFallback - }, - ); - - assert_eq!(status, InsertStatus::CopiedFallback); - assert!(copy_called); - } - - #[test] - fn unicode_first_mode_does_not_copy_after_success() { - let mut copy_called = false; - let status = finish_unicode_first_non_tsf_insertion_fallback( - || InsertStatus::Inserted, - || { - copy_called = true; - InsertStatus::CopiedFallback - }, - ); - - assert_eq!(status, InsertStatus::Inserted); - assert!(!copy_called); - } } // ─────────────────────────── helpers ─────────────────────────── diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 648789d7..2c138897 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1561,8 +1561,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let restore_clipboard = prefs.restore_clipboard_after_paste; let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; let paste_shortcut = prefs.paste_shortcut; - #[cfg(target_os = "windows")] - let windows_non_tsf_fallback_mode = prefs.windows_non_tsf_fallback_mode; // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 let status = if already_streamed { log::info!( @@ -1582,7 +1580,6 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { restore_clipboard, allow_non_tsf_insertion_fallback, paste_shortcut, - windows_non_tsf_fallback_mode, ime_target, ) .await diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 9a405b06..737c479a 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -60,17 +60,6 @@ pub enum PasteShortcut { ShiftInsert, } -/// Windows TSF 不可用时的非 TSF 插入策略。 -/// `ClipboardPaste` 是默认安全路径:避免长文本逐字 SendInput 压垮目标应用; -/// `UnicodeKeystrokes` 保留给会吞掉粘贴快捷键、但接受合成字符输入的应用。 -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "camelCase")] -pub enum WindowsNonTsfFallbackMode { - #[default] - ClipboardPaste, - UnicodeKeystrokes, -} - /// Auto-update 渠道。决定 Settings → 关于 里展示哪一类版本信息。 /// `Stable` 沿用 `tauri-plugin-updater` 的默认 endpoints(即 `tauri.conf.json` /// 里的 `latest-{{target}}-{{arch}}.json`),与发版 pipeline 对齐。 @@ -555,15 +544,11 @@ pub struct UserPreferences { /// 行为一致,不破坏既有用户。 #[serde(default)] pub paste_shortcut: PasteShortcut, - /// Windows: 是否允许 TSF 失败后继续使用非 TSF 方式兜底。 - /// 具体方式由 `windows_non_tsf_fallback_mode` 决定。 + /// Windows: 是否允许 TSF 失败后继续使用快捷键粘贴 / 剪贴板兜底。 + /// 仅在剪贴板写入失败时才再试 Unicode SendInput,避免长文本注入卡顿。 /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 #[serde(default = "default_true")] pub allow_non_tsf_insertion_fallback: bool, - /// Windows: TSF 失败后的非 TSF 插入方式。默认剪贴板粘贴,避免 #491 的长文本 - /// SendInput 压力;少数会吞粘贴快捷键的应用可手动切到 Unicode SendInput。 - #[serde(default)] - pub windows_non_tsf_fallback_mode: WindowsNonTsfFallbackMode, /// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部, /// 让模型知道该用户在哪些语言间工作。详见 issue #4。 #[serde(default = "default_working_languages")] @@ -770,8 +755,6 @@ struct UserPreferencesWire { #[serde(default)] paste_shortcut: PasteShortcut, allow_non_tsf_insertion_fallback: bool, - #[serde(default)] - windows_non_tsf_fallback_mode: WindowsNonTsfFallbackMode, working_languages: Vec, translation_target_language: String, chinese_script_preference: ChineseScriptPreference, @@ -846,7 +829,6 @@ impl Default for UserPreferencesWire { restore_clipboard_after_paste: prefs.restore_clipboard_after_paste, paste_shortcut: prefs.paste_shortcut, allow_non_tsf_insertion_fallback: prefs.allow_non_tsf_insertion_fallback, - windows_non_tsf_fallback_mode: prefs.windows_non_tsf_fallback_mode, working_languages: prefs.working_languages, translation_target_language: prefs.translation_target_language, chinese_script_preference: prefs.chinese_script_preference, @@ -922,7 +904,6 @@ impl<'de> Deserialize<'de> for UserPreferences { restore_clipboard_after_paste: wire.restore_clipboard_after_paste, paste_shortcut: wire.paste_shortcut, allow_non_tsf_insertion_fallback: wire.allow_non_tsf_insertion_fallback, - windows_non_tsf_fallback_mode: wire.windows_non_tsf_fallback_mode, working_languages: wire.working_languages, translation_target_language: wire.translation_target_language, chinese_script_preference: wire.chinese_script_preference, @@ -1610,7 +1591,6 @@ impl Default for UserPreferences { restore_clipboard_after_paste: true, paste_shortcut: PasteShortcut::default(), allow_non_tsf_insertion_fallback: true, - windows_non_tsf_fallback_mode: WindowsNonTsfFallbackMode::default(), working_languages: default_working_languages(), translation_target_language: String::new(), chinese_script_preference: ChineseScriptPreference::Auto, @@ -2380,36 +2360,6 @@ mod tests { } } - #[test] - fn windows_non_tsf_fallback_mode_defaults_to_clipboard_paste() { - let prefs = UserPreferences::default(); - assert_eq!( - prefs.windows_non_tsf_fallback_mode, - WindowsNonTsfFallbackMode::ClipboardPaste - ); - - let from_empty: UserPreferences = serde_json::from_str("{}").unwrap(); - assert_eq!( - from_empty.windows_non_tsf_fallback_mode, - WindowsNonTsfFallbackMode::ClipboardPaste - ); - } - - #[test] - fn windows_non_tsf_fallback_mode_round_trips_explicit_values() { - for (raw, expected) in [ - ("clipboardPaste", WindowsNonTsfFallbackMode::ClipboardPaste), - ( - "unicodeKeystrokes", - WindowsNonTsfFallbackMode::UnicodeKeystrokes, - ), - ] { - let json = format!(r#"{{ "windowsNonTsfFallbackMode": "{raw}" }}"#); - let prefs: UserPreferences = serde_json::from_str(&json).unwrap(); - assert_eq!(prefs.windows_non_tsf_fallback_mode, expected, "raw={raw}"); - } - } - #[test] fn legacy_custom_hotkey_without_custom_binding_is_rejected() { let result = serde_json::from_str::( diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index d96e61d4..943f9880 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -546,11 +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 clipboard paste or Unicode SendInput as a fallback.', - windowsNonTsfFallbackModeLabel: 'Non-TSF fallback method', - windowsNonTsfFallbackModeDesc: 'Default to clipboard paste to avoid long-text stalls; switch to Unicode SendInput for apps that swallow paste shortcuts.', - windowsNonTsfFallbackModeClipboardPaste: 'Clipboard + paste shortcut (default)', - windowsNonTsfFallbackModeUnicodeKeystrokes: 'Unicode SendInput (for paste-blocking apps)', + allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, try shortcut paste first; only fall back to Unicode SendInput if clipboard write fails.', 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 5841b41e..fa688a09 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -548,11 +548,7 @@ export const ja: typeof zhCN = { comboClear: 'クリア', comboConflict: 'このショートカットの組み合わせは使用できません', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', - allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時に、クリップボード貼り付けまたは Unicode SendInput でフォールバックします。', - windowsNonTsfFallbackModeLabel: '非 TSF フォールバック方式', - windowsNonTsfFallbackModeDesc: '既定では長文の停止を避けるためクリップボード貼り付けを使います。貼り付けショートカットを無視するアプリでは Unicode SendInput に切り替えてください。', - windowsNonTsfFallbackModeClipboardPaste: 'クリップボード + 貼り付けショートカット(既定)', - windowsNonTsfFallbackModeUnicodeKeystrokes: '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 3db4bd6e..50304f3d 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -548,11 +548,7 @@ export const ko: typeof zhCN = { comboClear: '지우기', comboConflict: '이 단축키 조합은 사용할 수 없습니다', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', - allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 클립보드 붙여넣기 또는 Unicode SendInput으로 폴백합니다.', - windowsNonTsfFallbackModeLabel: '비 TSF 폴백 방식', - windowsNonTsfFallbackModeDesc: '기본값은 긴 텍스트 지연을 피하기 위해 클립보드 붙여넣기를 사용합니다. 붙여넣기 단축키를 무시하는 앱은 Unicode SendInput으로 전환하세요.', - windowsNonTsfFallbackModeClipboardPaste: '클립보드 + 붙여넣기 단축키 (기본값)', - windowsNonTsfFallbackModeUnicodeKeystrokes: '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 edb18bb5..11b42ead 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -544,11 +544,7 @@ export const zhCN = { comboClear: '清除', comboConflict: '该快捷键组合不可用', allowNonTsfFallbackLabel: '允许非 TSF 兜底', - allowNonTsfFallbackDesc: 'Windows:TSF 失败后允许继续用剪贴板粘贴或 Unicode SendInput 兜底。', - windowsNonTsfFallbackModeLabel: '非 TSF 兜底方式', - windowsNonTsfFallbackModeDesc: '默认用剪贴板粘贴,避免长文本卡顿;如果目标应用会吞粘贴快捷键,可改用 Unicode SendInput。', - windowsNonTsfFallbackModeClipboardPaste: '剪贴板 + 粘贴快捷键(默认)', - windowsNonTsfFallbackModeUnicodeKeystrokes: '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 1cdfe2f7..40e3334a 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -546,11 +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 兜底。', - windowsNonTsfFallbackModeLabel: '非 TSF 兜底方式', - windowsNonTsfFallbackModeDesc: '默認用剪貼簿貼上,避免長文本卡頓;如果目標應用會吞貼上快捷鍵,可改用 Unicode SendInput。', - windowsNonTsfFallbackModeClipboardPaste: '剪貼簿 + 貼上快捷鍵(默認)', - windowsNonTsfFallbackModeUnicodeKeystrokes: 'Unicode SendInput(兼容吞貼上的應用)', + allowNonTsfFallbackDesc: 'Windows:TSF 失敗時先改用快捷鍵貼上;剪貼簿寫入失敗時才退到 Unicode SendInput。', historyGroupTitle: '歷史與上下文', historyRetentionLabel: '歷史保留天數', historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。', diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 32a7c7a4..fb551d5d 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -77,7 +77,6 @@ let mockSettings: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, - windowsNonTsfFallbackMode: 'clipboardPaste', workingLanguages: ['简体中文'], translationTargetLanguage: '', qaHotkey: defaultQaShortcut(), diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index d0683428..3f93ca38 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -38,7 +38,6 @@ const previousPrefs: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, - windowsNonTsfFallbackMode: 'clipboardPaste', workingLanguages: ['简体中文'], translationTargetLanguage: '', chineseScriptPreference: 'auto', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index b293f3cb..21a6a722 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -119,7 +119,6 @@ export type ComboBinding = ShortcutBinding; * - shiftInsert : xterm / urxvt 等老派 X11 终端 * 详见 issue #360。 */ export type PasteShortcut = 'ctrlV' | 'ctrlShiftV' | 'shiftInsert'; -export type WindowsNonTsfFallbackMode = 'clipboardPaste' | 'unicodeKeystrokes'; export type WindowsImeInstallState = | 'installed' @@ -231,10 +230,8 @@ export interface UserPreferences { * 等终端只接受 Ctrl+Shift+V,硬编码 Ctrl+V 会被吞掉,听写文本只剩在剪贴板里。 * macOS 走 AX 直写不受影响。默认 'ctrlV' 与历史行为一致。 */ pasteShortcut: PasteShortcut; - /** Windows:TSF 失败后是否允许非 TSF 兜底。关闭后可验证是否真实 TSF 上屏。 */ + /** Windows:TSF 失败后是否允许快捷键粘贴 / 剪贴板兜底。仅在剪贴板写失败时才再试 SendInput。关闭后可验证是否真实 TSF 上屏。 */ allowNonTsfInsertionFallback: boolean; - /** Windows:非 TSF 兜底方式。默认剪贴板粘贴;少数吞粘贴快捷键的应用可切到 SendInput。 */ - windowsNonTsfFallbackMode: WindowsNonTsfFallbackMode; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ workingLanguages: string[]; /** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */ diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 25b70dfb..111a40e6 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -34,7 +34,6 @@ import type { HotkeyTrigger, MicrophoneDevice, PasteShortcut, - WindowsNonTsfFallbackMode, } from '../lib/types'; import { emitSaved } from '../lib/savedEvent'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -282,8 +281,6 @@ function RecordingSection() { savePrefs({ ...prefs, pasteShortcut }); const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => savePrefs({ ...prefs, allowNonTsfInsertionFallback }); - const onWindowsNonTsfFallbackModeChange = (windowsNonTsfFallbackMode: WindowsNonTsfFallbackMode) => - savePrefs({ ...prefs, windowsNonTsfFallbackMode }); // 历史保留 / 对话感知 polish 上下文窗口都用裸 number input;空字符串时回滚到默认值。 // 范围限制:retention 0-365 天,context window 0-60 分钟(再大的值对实际对话场景没意义且白烧 token)。 const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n)); @@ -510,29 +507,6 @@ function RecordingSection() { /> )} - {capability.adapter === 'windowsLowLevel' && prefs.allowNonTsfInsertionFallback && ( - - onWindowsNonTsfFallbackModeChange(next as WindowsNonTsfFallbackMode)} - options={[ - { - value: 'clipboardPaste', - label: t('settings.recording.windowsNonTsfFallbackModeClipboardPaste'), - }, - { - value: 'unicodeKeystrokes', - label: t('settings.recording.windowsNonTsfFallbackModeUnicodeKeystrokes'), - }, - ]} - ariaLabel={t('settings.recording.windowsNonTsfFallbackModeLabel')} - style={{ ...inputStyle, maxWidth: 260 }} - /> - - )} {/* ─── 历史与上下文(折叠) ────────────────────────────────── */} From 358c8d41e26f5fbe019e11172043dcbd3dc7c5ce Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 20 May 2026 16:00:17 +0800 Subject: [PATCH 4/5] fix(windows): pace Unicode insertion fallback --- openless-all/app/src-tauri/src/coordinator.rs | 102 ++++++++---------- openless-all/app/src-tauri/src/insertion.rs | 21 +++- openless-all/app/src-tauri/src/types.rs | 4 +- openless-all/app/src/i18n/en.ts | 2 +- openless-all/app/src/i18n/ja.ts | 2 +- openless-all/app/src/i18n/ko.ts | 2 +- openless-all/app/src/i18n/zh-CN.ts | 2 +- openless-all/app/src/i18n/zh-TW.ts | 2 +- 8 files changed, 68 insertions(+), 69 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c3a8ca22..9b00270f 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2026,45 +2026,29 @@ 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 { - let mut retried_unicode = false; let status = finish_non_tsf_insertion_fallback( - || { - inner.inserter.insert_via_clipboard_fallback( - polished, - restore_clipboard, - paste_shortcut, - ) - }, - || { - retried_unicode = true; - inner.inserter.insert_via_unicode_keystrokes(polished) - }, + || inner.inserter.insert_via_unicode_keystrokes(polished), + || inner.inserter.copy_fallback(polished), ); - if retried_unicode { - if status == InsertStatus::Inserted { + match status { + InsertStatus::Inserted => { log::warn!( - "[windows-ime] TSF unavailable; clipboard write failed, inserted via Unicode SendInput" + "[windows-ime] TSF unavailable; inserted via paced Unicode SendInput fallback" ); - } else { + } + InsertStatus::CopiedFallback => { log::warn!( - "[windows-ime] TSF unavailable; clipboard write failed and Unicode SendInput fallback also failed" + "[windows-ime] TSF unavailable; Unicode SendInput failed, left text on clipboard" ); } - } else { - match status { - InsertStatus::PasteSent => { - log::info!("[windows-ime] TSF unavailable; attempted shortcut paste fallback"); - } - InsertStatus::CopiedFallback => { - log::warn!( - "[windows-ime] TSF unavailable; left text on clipboard, skipped Unicode SendInput retry" - ); - } - InsertStatus::Inserted | InsertStatus::Failed => {} + InsertStatus::PasteSent | InsertStatus::Failed => { + log::warn!( + "[windows-ime] TSF unavailable; Unicode SendInput fallback failed and copy fallback failed" + ); } } @@ -2072,24 +2056,24 @@ fn insert_via_non_tsf_fallback( } #[cfg(any(target_os = "windows", test))] -fn finish_non_tsf_insertion_fallback( - mut clipboard_fallback: C, +fn finish_non_tsf_insertion_fallback( mut unicode_fallback: U, + mut copy_fallback: C, ) -> InsertStatus where - C: FnMut() -> InsertStatus, U: FnMut() -> InsertStatus, + C: FnMut() -> InsertStatus, { - match clipboard_fallback() { - InsertStatus::Failed => match unicode_fallback() { - InsertStatus::Inserted => InsertStatus::Inserted, - // 这里的 Unicode fallback 没有经过剪贴板复制;若它失败,不能再伪装成 - // "已复制,请 Ctrl+V"。 - InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => { - InsertStatus::Failed + match unicode_fallback() { + InsertStatus::Inserted => InsertStatus::Inserted, + InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => { + match copy_fallback() { + InsertStatus::CopiedFallback => InsertStatus::CopiedFallback, + InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => { + InsertStatus::Failed + } } - }, - status => status, + } } } @@ -2099,48 +2083,48 @@ mod non_tsf_fallback_tests { use crate::types::InsertStatus; #[test] - fn clipboard_copy_stops_before_unicode_retry() { - let mut unicode_called = false; + fn unicode_fallback_runs_before_copy_fallback() { + let mut copy_called = false; let status = finish_non_tsf_insertion_fallback( - || InsertStatus::CopiedFallback, + || InsertStatus::Inserted, || { - unicode_called = true; - InsertStatus::Inserted + copy_called = true; + InsertStatus::CopiedFallback }, ); - assert_eq!(status, InsertStatus::CopiedFallback); - assert!(!unicode_called); + assert_eq!(status, InsertStatus::Inserted); + assert!(!copy_called); } #[test] - fn unicode_retry_runs_only_after_clipboard_failure() { - let mut unicode_called = false; + fn copy_fallback_runs_after_unicode_failure() { + let mut copy_called = false; let status = finish_non_tsf_insertion_fallback( || InsertStatus::Failed, || { - unicode_called = true; - InsertStatus::Inserted + copy_called = true; + InsertStatus::CopiedFallback }, ); - assert_eq!(status, InsertStatus::Inserted); - assert!(unicode_called); + assert_eq!(status, InsertStatus::CopiedFallback); + assert!(copy_called); } #[test] fn double_failure_does_not_pretend_text_was_copied() { - let mut unicode_called = false; + let mut copy_called = false; let status = finish_non_tsf_insertion_fallback( || InsertStatus::Failed, || { - unicode_called = true; - InsertStatus::CopiedFallback + copy_called = true; + InsertStatus::Failed }, ); assert_eq!(status, InsertStatus::Failed); - assert!(unicode_called); + assert!(copy_called); } } 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 737c479a..0feb78de 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -544,8 +544,8 @@ pub struct UserPreferences { /// 行为一致,不破坏既有用户。 #[serde(default)] pub paste_shortcut: PasteShortcut, - /// Windows: 是否允许 TSF 失败后继续使用快捷键粘贴 / 剪贴板兜底。 - /// 仅在剪贴板写入失败时才再试 Unicode SendInput,避免长文本注入卡顿。 + /// 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 943f9880..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, try shortcut paste first; only fall back to Unicode SendInput if clipboard write fails.', + 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 fa688a09..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 50304f3d..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 11b42ead..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 40e3334a..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 = 不按時間清理。', From 96a0ee4d3a10eb5493262858cc89e84fb884df49 Mon Sep 17 00:00:00 2001 From: H-Chris233 Date: Wed, 20 May 2026 16:27:11 +0800 Subject: [PATCH 5/5] chore(windows): clarify copy-only fallback status --- openless-all/app/src-tauri/src/coordinator.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9b00270f..e137b2cb 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2058,7 +2058,7 @@ fn insert_via_non_tsf_fallback( #[cfg(any(target_os = "windows", test))] fn finish_non_tsf_insertion_fallback( mut unicode_fallback: U, - mut copy_fallback: C, + mut copy_only_fallback: C, ) -> InsertStatus where U: FnMut() -> InsertStatus, @@ -2067,8 +2067,10 @@ where match unicode_fallback() { InsertStatus::Inserted => InsertStatus::Inserted, InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => { - match copy_fallback() { + 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 }