From f37745a31e37a79e984dd8210f11902808edf12f Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 22 Jun 2026 09:27:21 +0800 Subject: [PATCH 1/2] feat(windows): add SendInput-only insertion mode to skip TSF IME switch Add windowsSendInputInsertionOnly so users who cannot restore their IME after dictation can opt into Unicode SendInput without switching to OpenLess TSF at session start. Open-Less/openless#733 Co-authored-by: Cursor --- openless-all/app/src-tauri/src/coordinator.rs | 14 ++++-- .../src-tauri/src/coordinator/dictation.rs | 47 +++++++++++++------ openless-all/app/src-tauri/src/types.rs | 29 ++++++++++++ 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/ipc/mock-data.ts | 1 + openless-all/app/src/lib/stylePrefs.test.ts | 1 + openless-all/app/src/lib/types.ts | 2 + .../pages/settings/RecordingInputSection.tsx | 13 +++++ 12 files changed, 100 insertions(+), 17 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9218eef7..52b38614 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1752,7 +1752,7 @@ fn should_try_non_tsf_insertion_fallback( } #[cfg(target_os = "windows")] -fn insert_via_non_tsf_fallback( +pub(super) fn insert_via_non_tsf_fallback( inner: &Arc, polished: &str, _restore_clipboard: bool, @@ -2767,7 +2767,7 @@ mod tests { #[test] fn focus_restore_failure_uses_specific_error_code_when_insert_fails() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, false, false), + dictation_error_code(InsertStatus::Failed, false, false, false, false), Some("focusRestoreFailed") ); } @@ -2784,11 +2784,19 @@ mod tests { #[cfg(target_os = "windows")] fn tsf_required_failure_keeps_tsf_error_when_focus_was_ready() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, true, false), + dictation_error_code(InsertStatus::Failed, false, true, false, false), Some("windowsImeTsfRequired") ); } + #[test] + fn sendinput_only_mode_skips_tsf_required_error() { + assert_eq!( + dictation_error_code(InsertStatus::Failed, false, true, false, true), + None + ); + } + #[test] fn startup_race_check_treats_newer_session_as_stale() { let mut state = SessionState::default(); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 00b5beec..f3b31d7b 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -1068,9 +1068,11 @@ pub(super) async fn begin_session_as( }; #[cfg(target_os = "windows")] { - let prepared = inner.windows_ime.prepare_session(); - let mut slots = inner.prepared_windows_ime_session.lock(); - store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); + if !inner.prefs.get().windows_sendinput_insertion_only { + let prepared = inner.windows_ime.prepare_session(); + let mut slots = inner.prepared_windows_ime_session.lock(); + store_prepared_windows_ime_session(&mut slots, current_session_id, prepared); + } } // 翻译模式标志重置;hotkey 监听器在 Shift down 时再 set true。 inner @@ -2419,6 +2421,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { let prefs = inner.prefs.get(); let restore_clipboard = prefs.restore_clipboard_after_paste; let allow_non_tsf_insertion_fallback = prefs.allow_non_tsf_insertion_fallback; + let windows_sendinput_insertion_only = prefs.windows_sendinput_insertion_only; let paste_shortcut = prefs.paste_shortcut; // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 let status = if already_streamed { @@ -2441,17 +2444,30 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { if focus_ready_for_paste { #[cfg(target_os = "windows")] { - let ime_target = capture_ime_submit_target(); - insert_with_windows_ime_first( - inner, - current_session_id, - &polished, - restore_clipboard, - allow_non_tsf_insertion_fallback, - paste_shortcut, - ime_target, - ) - .await + if windows_sendinput_insertion_only { + if allow_non_tsf_insertion_fallback { + insert_via_non_tsf_fallback( + inner, + &polished, + restore_clipboard, + paste_shortcut, + ) + } else { + inner.inserter.insert_via_unicode_keystrokes(&polished) + } + } else { + let ime_target = capture_ime_submit_target(); + insert_with_windows_ime_first( + inner, + current_session_id, + &polished, + restore_clipboard, + allow_non_tsf_insertion_fallback, + paste_shortcut, + ime_target, + ) + .await + } } #[cfg(not(target_os = "windows"))] { @@ -2507,6 +2523,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { polish_error.is_some(), focus_ready_for_paste, allow_non_tsf_insertion_fallback, + windows_sendinput_insertion_only, ) .map(str::to_string); let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); @@ -2591,12 +2608,14 @@ pub(super) fn dictation_error_code( polish_failed: bool, focus_ready_for_paste: bool, allow_non_tsf_insertion_fallback: bool, + windows_sendinput_insertion_only: bool, ) -> Option<&'static str> { if !focus_ready_for_paste && status == InsertStatus::Failed { Some("focusRestoreFailed") } else if cfg!(target_os = "windows") && focus_ready_for_paste && !allow_non_tsf_insertion_fallback + && !windows_sendinput_insertion_only && status == InsertStatus::Failed { Some("windowsImeTsfRequired") diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index c63c65a1..a75fb343 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -586,6 +586,10 @@ pub struct UserPreferences { /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 #[serde(default = "default_true")] pub allow_non_tsf_insertion_fallback: bool, + /// Windows: 始终用 SendInput Unicode 插入,不切换 OpenLess TSF 输入法。 + /// 适用于输入法无法正确还原的用户。默认 false 保持 TSF 优先。 + #[serde(default)] + pub windows_sendinput_insertion_only: bool, /// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部, /// 让模型知道该用户在哪些语言间工作。详见 issue #4。 #[serde(default = "default_working_languages")] @@ -883,6 +887,8 @@ struct UserPreferencesWire { #[serde(default)] paste_shortcut: PasteShortcut, allow_non_tsf_insertion_fallback: bool, + #[serde(default)] + windows_sendinput_insertion_only: bool, working_languages: Vec, translation_target_language: String, chinese_script_preference: ChineseScriptPreference, @@ -1004,6 +1010,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_sendinput_insertion_only: prefs.windows_sendinput_insertion_only, working_languages: prefs.working_languages, translation_target_language: prefs.translation_target_language, chinese_script_preference: prefs.chinese_script_preference, @@ -1104,6 +1111,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_sendinput_insertion_only: wire.windows_sendinput_insertion_only, working_languages: wire.working_languages, translation_target_language: wire.translation_target_language, chinese_script_preference: wire.chinese_script_preference, @@ -1847,6 +1855,7 @@ impl Default for UserPreferences { restore_clipboard_after_paste: true, paste_shortcut: PasteShortcut::default(), allow_non_tsf_insertion_fallback: true, + windows_sendinput_insertion_only: false, working_languages: default_working_languages(), translation_target_language: String::new(), chinese_script_preference: ChineseScriptPreference::Auto, @@ -2590,6 +2599,26 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + #[test] + fn windows_sendinput_insertion_only_defaults_to_disabled() { + let prefs = UserPreferences::default(); + assert!(!prefs.windows_sendinput_insertion_only); + + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(!prefs.windows_sendinput_insertion_only); + } + + #[test] + fn windows_sendinput_insertion_only_pref_round_trips_explicit_true() { + let enabled = UserPreferences { + windows_sendinput_insertion_only: true, + ..UserPreferences::default() + }; + let json = serde_json::to_string(&enabled).unwrap(); + let restored: UserPreferences = serde_json::from_str(&json).unwrap(); + assert!(restored.windows_sendinput_insertion_only); + } + #[test] fn missing_audio_cue_on_record_pref_defaults_to_enabled() { // 老用户的 preferences.json 没有这个字段 → 应默认开启(按下录音即提示)。 diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 6a17fecf..67ac5d8e 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -684,6 +684,8 @@ export const en: typeof zhCN = { comboConflict: 'This shortcut combination is not available', allowNonTsfFallbackLabel: 'Allow non-TSF fallback', allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.', + windowsSendInputOnlyLabel: 'Always use SendInput (no IME switch)', + windowsSendInputOnlyDesc: 'Do not switch to the OpenLess TSF IME during dictation; insert text via Unicode keystroke simulation instead. If insertion fails, the “Allow non-TSF fallback” option below still controls clipboard fallback. Some apps (e.g. Word) may be less reliable than TSF.', 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 f4c25e04..7997c293 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -686,6 +686,8 @@ export const ja: typeof zhCN = { comboConflict: 'このショートカットの組み合わせは使用できません', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', + windowsSendInputOnlyLabel: '常に SendInput を使用(IME 切替なし)', + windowsSendInputOnlyDesc: '聴写中に OpenLess TSF IME へ切り替えず、Unicode キー入力シミュレーションで直接挿入します。挿入に失敗した場合は、下の「非 TSF フォールバックを許可」でクリップボードへのコピー可否を制御します。一部のアプリ(Word など)は TSF より不安定な場合があります。', historyGroupTitle: '履歴とコンテキスト', historyRetentionLabel: '履歴保持期間(日)', historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 95b74594..012919f8 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -686,6 +686,8 @@ export const ko: typeof zhCN = { comboConflict: '이 단축키 조합은 사용할 수 없습니다', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', + windowsSendInputOnlyLabel: '항상 SendInput 사용(입력기 전환 없음)', + windowsSendInputOnlyDesc: '받아쓰기 중 OpenLess TSF 입력기로 전환하지 않고 Unicode 키 입력 시뮬레이션으로 직접 삽입합니다. 삽입에 실패하면 아래 「비 TSF 폴백 허용」으로 클립보드 복사 여부를 제어합니다. 일부 앱(Word 등)은 TSF보다 불안정할 수 있습니다.', 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 2d87e8ab..7968593b 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -682,6 +682,8 @@ export const zhCN = { comboConflict: '该快捷键组合不可用', allowNonTsfFallbackLabel: '允许非 TSF 兜底', allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', + windowsSendInputOnlyLabel: '始终使用 SendInput(不切换输入法)', + windowsSendInputOnlyDesc: '听写期间不切换到 OpenLess TSF 输入法,直接用 Unicode 按键模拟插入。若插入失败,仍受下方「允许非 TSF 兜底」控制是否复制到剪贴板。部分应用(如 Word)可能不如 TSF 稳定。', 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 4390c7d4..872b155d 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -684,6 +684,8 @@ export const zhTW: typeof zhCN = { pasteShortcutShiftInsert: 'Shift+Insert(xterm / urxvt)', allowNonTsfFallbackLabel: '允許非 TSF 兜底', allowNonTsfFallbackDesc: 'Windows:TSF 失敗時使用分批 Unicode SendInput;如果仍失敗,再複製到剪貼簿。', + windowsSendInputOnlyLabel: '始終使用 SendInput(不切換輸入法)', + windowsSendInputOnlyDesc: '聽寫期間不切換到 OpenLess TSF 輸入法,直接用 Unicode 按鍵模擬插入。若插入失敗,仍受下方「允許非 TSF 兜底」控制是否複製到剪貼簿。部分應用(如 Word)可能不如 TSF 穩定。', historyGroupTitle: '歷史與上下文', historyRetentionLabel: '歷史保留天數', historyRetentionDesc: '超過保留天數的歷史在寫入新條目時被清理;0 = 不按時間清理。', diff --git a/openless-all/app/src/lib/ipc/mock-data.ts b/openless-all/app/src/lib/ipc/mock-data.ts index 402f8cc8..e5140168 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -49,6 +49,7 @@ export let mockSettings: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: "ctrlV", allowNonTsfInsertionFallback: true, + windowsSendInputInsertionOnly: false, 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 d049a4a5..7ccb1a60 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -39,6 +39,7 @@ const previousPrefs: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, + windowsSendInputInsertionOnly: false, workingLanguages: ['简体中文'], translationTargetLanguage: '', chineseScriptPreference: 'auto', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 17b52bd7..cc304f4f 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -268,6 +268,8 @@ export interface UserPreferences { pasteShortcut: PasteShortcut; /** Windows:TSF 失败后是否允许快捷键粘贴 / 剪贴板兜底。仅在剪贴板写失败时才再试 SendInput。关闭后可验证是否真实 TSF 上屏。 */ allowNonTsfInsertionFallback: boolean; + /** Windows:始终用 SendInput Unicode 插入,听写期间不切换 OpenLess TSF 输入法。 */ + windowsSendInputInsertionOnly: boolean; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ workingLanguages: string[]; /** 翻译模式目标语言(单选,原生名);空串 = 不启用 Shift 翻译。详见 issue #4。 */ diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index c66530a9..fefc7cec 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -131,6 +131,8 @@ export function RecordingInputSection() { savePrefs({ ...prefs, pasteShortcut }); const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => savePrefs({ ...prefs, allowNonTsfInsertionFallback }); + const onWindowsSendInputOnlyChange = (windowsSendInputInsertionOnly: boolean) => + savePrefs({ ...prefs, windowsSendInputInsertionOnly }); const onStartMinimizedChange = (startMinimized: boolean) => savePrefs({ ...prefs, startMinimized }); const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) => @@ -290,6 +292,17 @@ export function RecordingInputSection() { /> )} + {capability.adapter === 'windowsLowLevel' && ( + + + + )} {capability.adapter === 'windowsLowLevel' && ( Date: Mon, 22 Jun 2026 10:24:30 +0800 Subject: [PATCH 2/2] fix(windows): align SendInput-only pref JSON key with frontend Serde camelCase produced windowsSendinputInsertionOnly while the UI sends windowsSendInputInsertionOnly, causing the toggle to revert after save. Add explicit rename/alias on UserPreferences wire types and contract tests. Refs Open-Less/openless#733 Co-authored-by: Cursor --- openless-all/app/src-tauri/src/types.rs | 38 +++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index a75fb343..1cff61b5 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -588,7 +588,11 @@ pub struct UserPreferences { pub allow_non_tsf_insertion_fallback: bool, /// Windows: 始终用 SendInput Unicode 插入,不切换 OpenLess TSF 输入法。 /// 适用于输入法无法正确还原的用户。默认 false 保持 TSF 优先。 - #[serde(default)] + #[serde( + default, + rename = "windowsSendInputInsertionOnly", + alias = "windowsSendinputInsertionOnly" + )] pub windows_sendinput_insertion_only: bool, /// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部, /// 让模型知道该用户在哪些语言间工作。详见 issue #4。 @@ -887,7 +891,11 @@ struct UserPreferencesWire { #[serde(default)] paste_shortcut: PasteShortcut, allow_non_tsf_insertion_fallback: bool, - #[serde(default)] + #[serde( + default, + rename = "windowsSendInputInsertionOnly", + alias = "windowsSendinputInsertionOnly" + )] windows_sendinput_insertion_only: bool, working_languages: Vec, translation_target_language: String, @@ -2608,6 +2616,31 @@ mod tests { assert!(!prefs.windows_sendinput_insertion_only); } + #[test] + fn windows_sendinput_insertion_only_deserializes_frontend_wire_key() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsSendInputInsertionOnly": true}"#).unwrap(); + assert!(prefs.windows_sendinput_insertion_only); + } + + #[test] + fn windows_sendinput_insertion_only_deserializes_legacy_wrong_camel_key() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsSendinputInsertionOnly": true}"#).unwrap(); + assert!(prefs.windows_sendinput_insertion_only); + } + + #[test] + fn windows_sendinput_insertion_only_serializes_frontend_wire_key() { + let enabled = UserPreferences { + windows_sendinput_insertion_only: true, + ..UserPreferences::default() + }; + let json = serde_json::to_string(&enabled).unwrap(); + assert!(json.contains(r#""windowsSendInputInsertionOnly":true"#)); + assert!(!json.contains("windowsSendinputInsertionOnly")); + } + #[test] fn windows_sendinput_insertion_only_pref_round_trips_explicit_true() { let enabled = UserPreferences { @@ -2615,6 +2648,7 @@ mod tests { ..UserPreferences::default() }; let json = serde_json::to_string(&enabled).unwrap(); + assert!(json.contains(r#""windowsSendInputInsertionOnly":true"#)); let restored: UserPreferences = serde_json::from_str(&json).unwrap(); assert!(restored.windows_sendinput_insertion_only); }