From f37745a31e37a79e984dd8210f11902808edf12f Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Mon, 22 Jun 2026 09:27:21 +0800 Subject: [PATCH 1/6] 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/6] 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); } From c6fb436f7d51f241b7646d718b56f2170980eb3a Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Tue, 23 Jun 2026 16:05:09 +0800 Subject: [PATCH 3/6] feat(windows): allow hiding OpenLess from keyboard list in SendInput mode (Fixes #738) Add windowsShowOpenlessInKeyboardList pref with TSF EnableLanguageProfile apply on startup/save, transactional rollback with ASR sync, and UI error toast/refresh for both SendInput and keyboard-list toggles. Co-authored-by: Cursor --- .../app/src-tauri/src/commands/settings.rs | 285 +++++++++++++++++- openless-all/app/src-tauri/src/lib.rs | 11 + openless-all/app/src-tauri/src/types.rs | 38 +++ .../app/src-tauri/src/windows_ime_profile.rs | 116 ++++++- openless-all/app/src/i18n/en.ts | 3 + openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 3 + openless-all/app/src/i18n/zh-CN.ts | 3 + openless-all/app/src/i18n/zh-TW.ts | 3 + 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 | 31 +- 13 files changed, 485 insertions(+), 15 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands/settings.rs b/openless-all/app/src-tauri/src/commands/settings.rs index 03d96fb0..06e47433 100644 --- a/openless-all/app/src-tauri/src/commands/settings.rs +++ b/openless-all/app/src-tauri/src/commands/settings.rs @@ -108,8 +108,20 @@ impl SettingsWriter for Arc { } pub(crate) fn persist_settings( + coord: &T, + prefs: UserPreferences, +) -> Result<(), String> { + persist_settings_with_keyboard_apply( + coord, + prefs, + crate::windows_ime_profile::apply_windows_openless_keyboard_list_pref, + ) +} + +pub(crate) fn persist_settings_with_keyboard_apply( coord: &T, mut prefs: UserPreferences, + apply_keyboard_list: impl Fn(&UserPreferences) -> Result<(), String>, ) -> Result<(), String> { let mut previous = coord.read_settings(); sync_dictation_hotkey_legacy_fields(&mut previous); @@ -123,24 +135,68 @@ pub(crate) fn persist_settings( let open_app_changed = previous.open_app_hotkey != prefs.open_app_hotkey; let coding_agent_changed = previous.coding_agent_enabled != prefs.coding_agent_enabled || previous.coding_agent_voice_hotkey != prefs.coding_agent_voice_hotkey; + let windows_keyboard_list_changed = previous.windows_sendinput_insertion_only + != prefs.windows_sendinput_insertion_only + || previous.windows_show_openless_in_keyboard_list + != prefs.windows_show_openless_in_keyboard_list; let active_asr_provider_changed = previous.active_asr_provider != prefs.active_asr_provider; let active_asr_provider = prefs.active_asr_provider.clone(); + + if windows_keyboard_list_changed { + apply_keyboard_list(&prefs)?; + } + if active_asr_provider_changed { - coord.sync_active_asr_provider(&active_asr_provider)?; + if let Err(asr_err) = coord.sync_active_asr_provider(&active_asr_provider) { + if windows_keyboard_list_changed { + if let Err(kb_rollback_err) = apply_keyboard_list(&previous) { + return Err(format!( + "{asr_err}; additionally failed to rollback keyboard list visibility: {kb_rollback_err}" + )); + } + log::warn!( + "[windows-ime] rolled back keyboard list visibility after ASR provider sync failure" + ); + } + return Err(asr_err); + } } + if let Err(error) = coord.write_settings(prefs.clone()) { if active_asr_provider_changed { - if let Err(rollback_error) = - coord.sync_active_asr_provider(&previous.active_asr_provider) - { - coord.write_settings(prefs).map_err(|roll_forward_error| { - format!( - "{error}; additionally failed to restore active ASR provider: {rollback_error}; additionally failed to preserve active ASR provider consistency: {roll_forward_error}" - ) - })?; - } else { - return Err(error); + match coord.sync_active_asr_provider(&previous.active_asr_provider) { + Ok(()) => { + if windows_keyboard_list_changed { + if let Err(rollback_err) = apply_keyboard_list(&previous) { + return Err(format!( + "{error}; additionally failed to rollback keyboard list visibility: {rollback_err}" + )); + } + log::warn!( + "[windows-ime] rolled back keyboard list visibility after settings write failure" + ); + } + return Err(error); + } + Err(rollback_error) => { + // ASR vault 无法回滚时 roll-forward prefs;键盘列表保持新状态,避免三者分叉。 + coord.write_settings(prefs).map_err(|roll_forward_error| { + format!( + "{error}; additionally failed to restore active ASR provider: {rollback_error}; additionally failed to preserve active ASR provider consistency: {roll_forward_error}" + ) + })?; + } + } + } else if windows_keyboard_list_changed { + if let Err(rollback_err) = apply_keyboard_list(&previous) { + return Err(format!( + "{error}; additionally failed to rollback keyboard list visibility: {rollback_err}" + )); } + log::warn!( + "[windows-ime] rolled back keyboard list visibility after settings write failure" + ); + return Err(error); } else { return Err(error); } @@ -465,6 +521,213 @@ pub async fn app_check_update_with_channel( } } +#[cfg(test)] +mod persist_settings_tests { + use super::*; + use std::cell::RefCell; + + struct MockWriter { + prefs: RefCell, + write_calls: RefCell, + asr_sync_calls: RefCell>, + /// 前 N 次 write_settings 调用返回失败;0 = 从不失败。 + write_fail_count: u32, + fail_forward_asr_sync: bool, + fail_rollback_asr_sync: bool, + } + + impl MockWriter { + fn new(prefs: UserPreferences) -> Self { + Self { + prefs: RefCell::new(prefs), + write_calls: RefCell::new(0), + asr_sync_calls: RefCell::new(Vec::new()), + write_fail_count: 0, + fail_forward_asr_sync: false, + fail_rollback_asr_sync: false, + } + } + } + + impl SettingsWriter for MockWriter { + fn read_settings(&self) -> UserPreferences { + self.prefs.borrow().clone() + } + + fn write_settings(&self, prefs: UserPreferences) -> Result<(), String> { + let mut calls = self.write_calls.borrow_mut(); + *calls += 1; + if *calls <= self.write_fail_count { + return Err("write failed".into()); + } + *self.prefs.borrow_mut() = prefs; + Ok(()) + } + + fn sync_active_asr_provider(&self, provider: &str) -> Result<(), String> { + self.asr_sync_calls.borrow_mut().push(provider.to_string()); + let stored = self.prefs.borrow().active_asr_provider.clone(); + if self.fail_forward_asr_sync && provider != stored { + return Err("asr forward sync failed".into()); + } + if self.fail_rollback_asr_sync && provider == stored { + return Err("asr rollback sync failed".into()); + } + Ok(()) + } + + fn refresh_dictation_hotkey(&self) {} + fn refresh_qa_hotkey(&self) {} + fn refresh_combo_hotkey(&self) {} + fn refresh_translation_hotkey(&self) {} + fn refresh_switch_style_hotkey(&self) {} + fn refresh_open_app_hotkey(&self) {} + fn refresh_coding_agent_hotkey(&self) {} + } + + #[test] + fn keyboard_apply_failure_does_not_sync_asr_or_write_prefs() { + let writer = MockWriter::new(UserPreferences::default()); + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + next.active_asr_provider = "other-asr".into(); + + let result = persist_settings_with_keyboard_apply(&writer, next, |_| { + Err("apply failed".into()) + }); + + assert!(result.is_err()); + assert_eq!(*writer.write_calls.borrow(), 0); + assert!(writer.asr_sync_calls.borrow().is_empty()); + assert!(writer.read_settings().windows_show_openless_in_keyboard_list); + } + + #[test] + fn keyboard_apply_success_writes_prefs() { + let writer = MockWriter::new(UserPreferences::default()); + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + + let result = persist_settings_with_keyboard_apply(&writer, next.clone(), |_| Ok(())); + + assert!(result.is_ok()); + assert_eq!(*writer.write_calls.borrow(), 1); + assert!(!writer.read_settings().windows_show_openless_in_keyboard_list); + } + + #[test] + fn asr_sync_failure_rolls_back_keyboard_list() { + let writer = MockWriter { + prefs: RefCell::new(UserPreferences::default()), + write_calls: RefCell::new(0), + asr_sync_calls: RefCell::new(Vec::new()), + write_fail_count: 0, + fail_forward_asr_sync: true, + fail_rollback_asr_sync: false, + }; + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + next.active_asr_provider = "other-asr".into(); + + let apply_calls = RefCell::new(0); + let result = persist_settings_with_keyboard_apply(&writer, next, |_| { + *apply_calls.borrow_mut() += 1; + Ok(()) + }); + + assert!(result.is_err()); + assert_eq!(*writer.write_calls.borrow(), 0); + assert_eq!(*apply_calls.borrow(), 2); + assert!(writer.read_settings().windows_show_openless_in_keyboard_list); + } + + #[test] + fn keyboard_write_failure_rolls_back_profile_without_asr_change() { + let writer = MockWriter { + prefs: RefCell::new(UserPreferences::default()), + write_calls: RefCell::new(0), + asr_sync_calls: RefCell::new(Vec::new()), + write_fail_count: 1, + fail_forward_asr_sync: false, + fail_rollback_asr_sync: false, + }; + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + + let rollback_calls = RefCell::new(0); + let result = persist_settings_with_keyboard_apply(&writer, next, |prefs| { + if prefs.windows_show_openless_in_keyboard_list { + *rollback_calls.borrow_mut() += 1; + } + Ok(()) + }); + + assert!(result.is_err()); + assert_eq!(*writer.write_calls.borrow(), 1); + assert_eq!(*rollback_calls.borrow(), 1); + assert!(writer.asr_sync_calls.borrow().is_empty()); + } + + #[test] + fn keyboard_write_failure_rolls_back_profile_when_asr_rollback_succeeds() { + let writer = MockWriter { + prefs: RefCell::new(UserPreferences::default()), + write_calls: RefCell::new(0), + asr_sync_calls: RefCell::new(Vec::new()), + write_fail_count: 1, + fail_forward_asr_sync: false, + fail_rollback_asr_sync: false, + }; + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + next.active_asr_provider = "other-asr".into(); + + let rollback_calls = RefCell::new(0); + let result = persist_settings_with_keyboard_apply(&writer, next, |prefs| { + if prefs.windows_show_openless_in_keyboard_list { + *rollback_calls.borrow_mut() += 1; + } + Ok(()) + }); + + assert!(result.is_err()); + assert_eq!(*writer.write_calls.borrow(), 1); + assert_eq!(*rollback_calls.borrow(), 1); + } + + #[test] + fn keyboard_write_failure_keeps_new_keyboard_when_asr_roll_forward_succeeds() { + let writer = MockWriter { + prefs: RefCell::new(UserPreferences::default()), + write_calls: RefCell::new(0), + asr_sync_calls: RefCell::new(Vec::new()), + write_fail_count: 1, + fail_forward_asr_sync: false, + fail_rollback_asr_sync: true, + }; + let mut next = writer.read_settings(); + next.windows_sendinput_insertion_only = true; + next.windows_show_openless_in_keyboard_list = false; + next.active_asr_provider = "other-asr".into(); + + let apply_calls = RefCell::new(0); + let result = persist_settings_with_keyboard_apply(&writer, next.clone(), |_| { + *apply_calls.borrow_mut() += 1; + Ok(()) + }); + + assert!(result.is_ok()); + assert_eq!(*writer.write_calls.borrow(), 2); + assert_eq!(*apply_calls.borrow(), 1); + assert!(!writer.read_settings().windows_show_openless_in_keyboard_list); + } +} + #[cfg(mobile)] #[tauri::command] pub async fn app_download_and_install_android_update( diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8b75b5df..57c011b1 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -465,6 +465,17 @@ fn run_desktop() { init_file_logger(); log::info!("=== OpenLess 启动 ==="); + #[cfg(target_os = "windows")] + if let Err(err) = + crate::windows_ime_profile::apply_windows_openless_keyboard_list_pref( + &coordinator.prefs().get(), + ) + { + log::warn!( + "[windows-ime] apply keyboard list visibility pref on startup failed: {err}" + ); + } + // Capsule 启动时定位到屏幕底部居中并隐藏;coordinator 按需显示。 // 与 Swift `CapsuleWindowController.repositionToBottomCenter` 同语义。 if let Some(capsule) = app.get_webview_window("capsule") { diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 1cff61b5..35761534 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -594,6 +594,13 @@ pub struct UserPreferences { alias = "windowsSendinputInsertionOnly" )] pub windows_sendinput_insertion_only: bool, + /// Windows:SendInput 模式下是否在系统键盘列表(Win+Space)中显示 OpenLess TSF 输入法。 + /// 默认 true 保持现有行为;关闭后用户级禁用语言配置文件,无需管理员权限。 + #[serde( + default = "default_true", + rename = "windowsShowOpenlessInKeyboardList" + )] + pub windows_show_openless_in_keyboard_list: bool, /// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部, /// 让模型知道该用户在哪些语言间工作。详见 issue #4。 #[serde(default = "default_working_languages")] @@ -897,6 +904,8 @@ struct UserPreferencesWire { alias = "windowsSendinputInsertionOnly" )] windows_sendinput_insertion_only: bool, + #[serde(default = "default_true", rename = "windowsShowOpenlessInKeyboardList")] + windows_show_openless_in_keyboard_list: bool, working_languages: Vec, translation_target_language: String, chinese_script_preference: ChineseScriptPreference, @@ -1019,6 +1028,7 @@ impl Default for UserPreferencesWire { 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, + windows_show_openless_in_keyboard_list: prefs.windows_show_openless_in_keyboard_list, working_languages: prefs.working_languages, translation_target_language: prefs.translation_target_language, chinese_script_preference: prefs.chinese_script_preference, @@ -1120,6 +1130,7 @@ impl<'de> Deserialize<'de> for UserPreferences { 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, + windows_show_openless_in_keyboard_list: wire.windows_show_openless_in_keyboard_list, working_languages: wire.working_languages, translation_target_language: wire.translation_target_language, chinese_script_preference: wire.chinese_script_preference, @@ -1864,6 +1875,7 @@ impl Default for UserPreferences { paste_shortcut: PasteShortcut::default(), allow_non_tsf_insertion_fallback: true, windows_sendinput_insertion_only: false, + windows_show_openless_in_keyboard_list: true, working_languages: default_working_languages(), translation_target_language: String::new(), chinese_script_preference: ChineseScriptPreference::Auto, @@ -2653,6 +2665,32 @@ mod tests { assert!(restored.windows_sendinput_insertion_only); } + #[test] + fn windows_show_openless_in_keyboard_list_defaults_to_enabled() { + let prefs = UserPreferences::default(); + assert!(prefs.windows_show_openless_in_keyboard_list); + + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + assert!(prefs.windows_show_openless_in_keyboard_list); + } + + #[test] + fn windows_show_openless_in_keyboard_list_deserializes_frontend_wire_key() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsShowOpenlessInKeyboardList": false}"#).unwrap(); + assert!(!prefs.windows_show_openless_in_keyboard_list); + } + + #[test] + fn windows_show_openless_in_keyboard_list_serializes_frontend_wire_key() { + let hidden = UserPreferences { + windows_show_openless_in_keyboard_list: false, + ..UserPreferences::default() + }; + let json = serde_json::to_string(&hidden).unwrap(); + assert!(json.contains(r#""windowsShowOpenlessInKeyboardList":false"#)); + } + #[test] fn missing_audio_cue_on_record_pref_defaults_to_enabled() { // 老用户的 preferences.json 没有这个字段 → 应默认开启(按下录音即提示)。 diff --git a/openless-all/app/src-tauri/src/windows_ime_profile.rs b/openless-all/app/src-tauri/src/windows_ime_profile.rs index 8ec380f2..e941e6b5 100644 --- a/openless-all/app/src-tauri/src/windows_ime_profile.rs +++ b/openless-all/app/src-tauri/src/windows_ime_profile.rs @@ -3,7 +3,7 @@ pub const OPENLESS_TSF_LANG_ID: u16 = 0x0804; pub const OPENLESS_TEXT_SERVICE_CLSID_BRACED: &str = "{6B9F3F4F-5EE7-42D6-9C61-9F80B03A5D7D}"; pub const OPENLESS_PROFILE_GUID_BRACED: &str = "{9B5F5E04-23F6-47DA-9A26-D221F6C3F02E}"; -use crate::types::{WindowsImeInstallState, WindowsImeStatus}; +use crate::types::{UserPreferences, WindowsImeInstallState, WindowsImeStatus}; #[cfg(target_os = "windows")] fn parse_guid(value: &str) -> WindowsImeProfileResult { @@ -122,6 +122,66 @@ pub fn get_windows_ime_status() -> WindowsImeStatus { } } +/// 根据偏好决定 OpenLess 语言配置文件是否应在用户键盘列表中启用。 +pub fn desired_openless_language_profile_enabled(prefs: &UserPreferences) -> bool { + if !prefs.windows_sendinput_insertion_only { + return true; + } + prefs.windows_show_openless_in_keyboard_list +} + +#[cfg(target_os = "windows")] +pub fn set_openless_language_profile_enabled(enabled: bool) -> WindowsImeProfileResult<()> { + windows_impl::set_openless_language_profile_enabled(enabled) +} + +#[cfg(not(target_os = "windows"))] +pub fn set_openless_language_profile_enabled(_enabled: bool) -> WindowsImeProfileResult<()> { + Err(WindowsImeProfileError::Unavailable( + "Windows TSF profiles are only available on Windows".to_string(), + )) +} + +#[cfg(target_os = "windows")] +pub fn is_openless_language_profile_enabled() -> WindowsImeProfileResult { + windows_impl::is_openless_language_profile_enabled() +} + +#[cfg(not(target_os = "windows"))] +pub fn is_openless_language_profile_enabled() -> WindowsImeProfileResult { + Err(WindowsImeProfileError::Unavailable( + "Windows TSF profiles are only available on Windows".to_string(), + )) +} + +/// 将「SendInput + 键盘列表可见性」偏好同步到当前用户的 TSF 语言配置文件。 +pub fn apply_windows_openless_keyboard_list_pref(prefs: &UserPreferences) -> Result<(), String> { + let desired = desired_openless_language_profile_enabled(prefs); + #[cfg(target_os = "windows")] + { + let status = get_windows_ime_status(); + if status.state != WindowsImeInstallState::Installed { + if desired { + return Ok(()); + } + return Err( + "OpenLess TSF IME is not installed; cannot hide it from the keyboard list" + .to_string(), + ); + } + set_openless_language_profile_enabled(desired).map_err(|err| { + let message = err.to_string(); + log::warn!("[windows-ime] apply keyboard list visibility pref failed: {message}"); + message + }) + } + #[cfg(not(target_os = "windows"))] + { + let _ = desired; + Ok(()) + } +} + #[cfg(target_os = "windows")] pub struct WindowsImeProfileManager; @@ -193,6 +253,7 @@ mod windows_impl { COINIT_APARTMENTTHREADED, }; use windows::Win32::UI::Input::KeyboardAndMouse::HKL; + use windows::Win32::Foundation::BOOL; use windows::Win32::UI::TextServices::{ CLSID_TF_InputProcessorProfiles, ITfInputProcessorProfileMgr, ITfInputProcessorProfiles, GUID_TFCAT_TIP_KEYBOARD, TF_INPUTPROCESSORPROFILE, TF_IPPMF_DONTCARECURRENTINPUTLANGUAGE, @@ -403,6 +464,35 @@ mod windows_impl { == Some(OPENLESS_PROFILE_GUID_BRACED)) } + pub fn set_openless_language_profile_enabled(enabled: bool) -> WindowsImeProfileResult<()> { + let clsid = parse_guid(OPENLESS_TEXT_SERVICE_CLSID_BRACED)?; + let profile_guid = parse_guid(OPENLESS_PROFILE_GUID_BRACED)?; + let enable_flag = BOOL::from(enabled); + + with_input_processor_profiles(|profiles| unsafe { + profiles.EnableLanguageProfile( + &clsid, + OPENLESS_TSF_LANG_ID, + &profile_guid, + enable_flag, + ) + }) + } + + pub fn is_openless_language_profile_enabled() -> WindowsImeProfileResult { + let clsid = parse_guid(OPENLESS_TEXT_SERVICE_CLSID_BRACED)?; + let profile_guid = parse_guid(OPENLESS_PROFILE_GUID_BRACED)?; + + with_input_processor_profiles(|profiles| unsafe { + let enabled = profiles.IsEnabledLanguageProfile( + &clsid, + OPENLESS_TSF_LANG_ID, + &profile_guid, + )?; + Ok(enabled.as_bool()) + }) + } + pub fn get_windows_ime_status() -> WindowsImeStatus { match inspect_windows_ime_registration() { RegistrationInspection::Installed { dll_path } => WindowsImeStatus { @@ -692,6 +782,30 @@ mod tests { ProfileRestoreDecision::KeepCurrentProfile ); } + + #[test] + fn desired_openless_language_profile_enabled_follows_sendinput_and_visibility_pref() { + let tsf_only = UserPreferences { + windows_sendinput_insertion_only: false, + windows_show_openless_in_keyboard_list: false, + ..UserPreferences::default() + }; + assert!(desired_openless_language_profile_enabled(&tsf_only)); + + let sendinput_show = UserPreferences { + windows_sendinput_insertion_only: true, + windows_show_openless_in_keyboard_list: true, + ..UserPreferences::default() + }; + assert!(desired_openless_language_profile_enabled(&sendinput_show)); + + let sendinput_hide = UserPreferences { + windows_sendinput_insertion_only: true, + windows_show_openless_in_keyboard_list: false, + ..UserPreferences::default() + }; + assert!(!desired_openless_language_profile_enabled(&sendinput_hide)); + } } #[cfg(all(test, target_os = "windows"))] diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 67ac5d8e..9362446a 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -686,6 +686,9 @@ export const en: typeof zhCN = { 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.', + windowsShowOpenlessInKeyboardListLabel: 'Show OpenLess in keyboard list', + windowsShowOpenlessInKeyboardListDesc: 'When off, Win+Space will not cycle to OpenLess. SendInput insertion is unaffected. Turn this back on to restore the entry.', + windowsShowOpenlessInKeyboardListError: 'Could not update the keyboard list: OpenLess TSF IME is not installed or the system rejected the change.', 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 7997c293..d549bef3 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -688,6 +688,9 @@ export const ja: typeof zhCN = { allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', windowsSendInputOnlyLabel: '常に SendInput を使用(IME 切替なし)', windowsSendInputOnlyDesc: '聴写中に OpenLess TSF IME へ切り替えず、Unicode キー入力シミュレーションで直接挿入します。挿入に失敗した場合は、下の「非 TSF フォールバックを許可」でクリップボードへのコピー可否を制御します。一部のアプリ(Word など)は TSF より不安定な場合があります。', + windowsShowOpenlessInKeyboardListLabel: 'キーボード一覧に OpenLess を表示', + windowsShowOpenlessInKeyboardListDesc: 'オフにすると Win+Space で OpenLess に切り替わりません。SendInput 挿入には影響しません。オンに戻すと一覧に再表示されます。', + windowsShowOpenlessInKeyboardListError: 'キーボード一覧を更新できません:OpenLess TSF IME が未インストールか、システムが変更を拒否しました。', historyGroupTitle: '履歴とコンテキスト', historyRetentionLabel: '履歴保持期間(日)', historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 012919f8..97cadf44 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -688,6 +688,9 @@ export const ko: typeof zhCN = { allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', windowsSendInputOnlyLabel: '항상 SendInput 사용(입력기 전환 없음)', windowsSendInputOnlyDesc: '받아쓰기 중 OpenLess TSF 입력기로 전환하지 않고 Unicode 키 입력 시뮬레이션으로 직접 삽입합니다. 삽입에 실패하면 아래 「비 TSF 폴백 허용」으로 클립보드 복사 여부를 제어합니다. 일부 앱(Word 등)은 TSF보다 불안정할 수 있습니다.', + windowsShowOpenlessInKeyboardListLabel: '키보드 목록에 OpenLess 표시', + windowsShowOpenlessInKeyboardListDesc: '끄면 Win+Space로 OpenLess에 전환되지 않습니다. SendInput 삽입에는 영향 없습니다. 다시 켜면 목록에 복원됩니다.', + windowsShowOpenlessInKeyboardListError: '키보드 목록을 업데이트할 수 없습니다: OpenLess TSF IME가 설치되지 않았거나 시스템이 변경을 거부했습니다.', 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 7968593b..d10ebba0 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -684,6 +684,9 @@ export const zhCN = { allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', windowsSendInputOnlyLabel: '始终使用 SendInput(不切换输入法)', windowsSendInputOnlyDesc: '听写期间不切换到 OpenLess TSF 输入法,直接用 Unicode 按键模拟插入。若插入失败,仍受下方「允许非 TSF 兜底」控制是否复制到剪贴板。部分应用(如 Word)可能不如 TSF 稳定。', + windowsShowOpenlessInKeyboardListLabel: '在键盘列表中显示 OpenLess', + windowsShowOpenlessInKeyboardListDesc: '关闭后 Win+Space 切换输入法时不会出现 OpenLess;SendInput 插入不受影响。重新开启本项可恢复显示。', + windowsShowOpenlessInKeyboardListError: '无法更新键盘列表:OpenLess 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 872b155d..f67b1c05 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -686,6 +686,9 @@ export const zhTW: typeof zhCN = { allowNonTsfFallbackDesc: 'Windows:TSF 失敗時使用分批 Unicode SendInput;如果仍失敗,再複製到剪貼簿。', windowsSendInputOnlyLabel: '始終使用 SendInput(不切換輸入法)', windowsSendInputOnlyDesc: '聽寫期間不切換到 OpenLess TSF 輸入法,直接用 Unicode 按鍵模擬插入。若插入失敗,仍受下方「允許非 TSF 兜底」控制是否複製到剪貼簿。部分應用(如 Word)可能不如 TSF 穩定。', + windowsShowOpenlessInKeyboardListLabel: '在鍵盤列表中顯示 OpenLess', + windowsShowOpenlessInKeyboardListDesc: '關閉後 Win+Space 切換輸入法時不會出現 OpenLess;SendInput 插入不受影響。重新開啟本項可恢復顯示。', + windowsShowOpenlessInKeyboardListError: '無法更新鍵盤列表:OpenLess 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 e5140168..420ab3ac 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -50,6 +50,7 @@ export let mockSettings: UserPreferences = { pasteShortcut: "ctrlV", allowNonTsfInsertionFallback: true, windowsSendInputInsertionOnly: false, + windowsShowOpenlessInKeyboardList: true, 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 7ccb1a60..53bc1894 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -40,6 +40,7 @@ const previousPrefs: UserPreferences = { pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, windowsSendInputInsertionOnly: false, + windowsShowOpenlessInKeyboardList: true, workingLanguages: ['简体中文'], translationTargetLanguage: '', chineseScriptPreference: 'auto', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index cc304f4f..c03b5929 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -270,6 +270,8 @@ export interface UserPreferences { allowNonTsfInsertionFallback: boolean; /** Windows:始终用 SendInput Unicode 插入,听写期间不切换 OpenLess TSF 输入法。 */ windowsSendInputInsertionOnly: boolean; + /** Windows:SendInput 模式下是否在系统键盘列表(Win+Space)中显示 OpenLess。 */ + windowsShowOpenlessInKeyboardList: 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 fefc7cec..19fce541 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { ShortcutRecorder } from '../../components/ShortcutRecorder'; import { playRecordStartCue } from '../../lib/audioCue'; +import { emitSaved } from '../../lib/savedEvent'; import { isHotkeyModeMigrationNoticeActive } from '../../lib/hotkeyMigration'; import { isTauri, @@ -13,7 +14,7 @@ import { setDictationHotkey, } from '../../lib/ipc'; import { getPlatformCapabilities } from '../../lib/platform'; -import type { HotkeyMode, MicrophoneDevice, PasteShortcut, PlatformCapabilities } from '../../lib/types'; +import type { HotkeyMode, MicrophoneDevice, PasteShortcut, PlatformCapabilities, UserPreferences } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { SelectLite } from '../../components/ui/SelectLite'; import { Card, Collapsible } from '../_atoms'; @@ -39,7 +40,7 @@ async function autostartDisable(): Promise { export function RecordingInputSection() { const { t } = useTranslation(); const os = detectOS(); - const { prefs, capability, updatePrefs: savePrefs } = useHotkeySettings(); + const { prefs, capability, updatePrefs: savePrefs, refresh } = useHotkeySettings(); const [platformCaps, setPlatformCaps] = useState(null); const [microphoneDevices, setMicrophoneDevices] = useState([]); const [microphoneDevicesLoaded, setMicrophoneDevicesLoaded] = useState(false); @@ -102,6 +103,16 @@ export function RecordingInputSection() { }; }, [loadMicrophoneDevices]); + const saveKeyboardListAffectingPrefs = useCallback(async (nextPrefs: UserPreferences) => { + try { + await savePrefs(nextPrefs); + } catch (error) { + console.error('[settings] keyboard list visibility pref save failed', error); + emitSaved('failed', t('settings.recording.windowsShowOpenlessInKeyboardListError')); + await refresh(); + } + }, [savePrefs, refresh, t]); + if (!prefs || !capability) { return ( @@ -132,7 +143,10 @@ export function RecordingInputSection() { const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => savePrefs({ ...prefs, allowNonTsfInsertionFallback }); const onWindowsSendInputOnlyChange = (windowsSendInputInsertionOnly: boolean) => - savePrefs({ ...prefs, windowsSendInputInsertionOnly }); + void saveKeyboardListAffectingPrefs({ ...prefs, windowsSendInputInsertionOnly }); + const onWindowsShowOpenlessInKeyboardListChange = ( + windowsShowOpenlessInKeyboardList: boolean, + ) => void saveKeyboardListAffectingPrefs({ ...prefs, windowsShowOpenlessInKeyboardList }); const onStartMinimizedChange = (startMinimized: boolean) => savePrefs({ ...prefs, startMinimized }); const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) => @@ -303,6 +317,17 @@ export function RecordingInputSection() { /> )} + {capability.adapter === 'windowsLowLevel' && prefs.windowsSendInputInsertionOnly && ( + + void onWindowsShowOpenlessInKeyboardListChange(next)} + /> + + )} {capability.adapter === 'windowsLowLevel' && ( Date: Wed, 24 Jun 2026 09:29:11 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=20Windows=20?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E6=8F=92=E5=85=A5=E6=96=B9=E6=B3=95=E4=B8=8E?= =?UTF-8?q?=E5=81=8F=E5=A5=BD=E8=AE=BE=E7=BD=AE=20=E2=80=A2=20=E5=BC=95?= =?UTF-8?q?=E5=85=A5=20WindowsInsertionMode=20=E6=9E=9A=E4=B8=BE=EF=BC=8C?= =?UTF-8?q?=E7=94=A8=E4=BA=8E=E7=AE=A1=E7=90=86=E6=96=87=E6=9C=AC=E6=8F=92?= =?UTF-8?q?=E5=85=A5=E7=AD=96=E7=95=A5=EF=BC=88TSF=E3=80=81SendInput?= =?UTF-8?q?=E3=80=81=E7=B2=98=E8=B4=B4=EF=BC=89=E3=80=82=20=E2=80=A2=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20WindowsSendInputNewlineMode=20=E6=9E=9A?= =?UTF-8?q?=E4=B8=BE=EF=BC=8C=E7=94=A8=E4=BA=8E=E6=8C=87=E5=AE=9A=20SendIn?= =?UTF-8?q?put=20=E4=B8=AD=E7=9A=84=E6=8D=A2=E8=A1=8C=E7=AC=A6=E5=A4=84?= =?UTF-8?q?=E7=90=86=E6=96=B9=E5=BC=8F=E3=80=82=20=E2=80=A2=20=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=20TextInserter=EF=BC=8C=E4=BB=A5=E6=94=AF=E6=8C=81=20?= =?UTF-8?q?Windows=20=E7=9A=84=E6=96=B0=E6=8F=92=E5=85=A5=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E5=92=8C=E9=80=89=E9=A1=B9=E3=80=82=20=E2=80=A2=20?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20Unicode=20=E6=8C=89=E9=94=AE=E5=A4=84?= =?UTF-8?q?=E7=90=86=EF=BC=8C=E4=BB=A5=E9=80=82=E5=BA=94=E6=8D=A2=E8=A1=8C?= =?UTF-8?q?=E7=AC=A6=E6=A8=A1=E6=8B=9F=E6=A8=A1=E5=BC=8F=E3=80=82=20?= =?UTF-8?q?=E2=80=A2=20=E5=A2=9E=E5=BC=BA=E7=94=A8=E6=88=B7=E5=81=8F?= =?UTF-8?q?=E5=A5=BD=E8=AE=BE=E7=BD=AE=EF=BC=8C=E5=8C=85=E5=90=AB=E6=96=B0?= =?UTF-8?q?=E7=9A=84=E6=8F=92=E5=85=A5=E6=A8=A1=E5=BC=8F=E5=8F=8A=E5=85=B6?= =?UTF-8?q?=E5=90=84=E8=87=AA=E7=9A=84=E9=BB=98=E8=AE=A4=E5=80=BC=E3=80=82?= =?UTF-8?q?=20=E2=80=A2=20=E6=9B=B4=E6=96=B0=20UI=20=E7=BB=84=E4=BB=B6?= =?UTF-8?q?=EF=BC=8C=E5=85=81=E8=AE=B8=E7=94=A8=E6=88=B7=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E9=A6=96=E9=80=89=E7=9A=84=E6=8F=92=E5=85=A5=E6=96=B9=E6=B3=95?= =?UTF-8?q?=E5=92=8C=E6=8D=A2=E8=A1=8C=E5=A4=84=E7=90=86=E6=96=B9=E5=BC=8F?= =?UTF-8?q?=E3=80=82=20=E2=80=A2=20=E6=94=B9=E8=BF=9B=E4=BA=86=E8=8B=B1?= =?UTF-8?q?=E6=96=87=E3=80=81=E6=97=A5=E6=96=87=E3=80=81=E9=9F=A9=E6=96=87?= =?UTF-8?q?=E3=80=81=E7=AE=80=E4=BD=93=E4=B8=AD=E6=96=87=E5=92=8C=E7=B9=81?= =?UTF-8?q?=E4=BD=93=E4=B8=AD=E6=96=87=E6=96=B0=E8=AE=BE=E7=BD=AE=E7=9A=84?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8C=96=E3=80=82=20=E2=80=A2=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E4=BA=86=E6=B5=8B=E8=AF=95=E4=BB=A5=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E6=96=B0=E5=8A=9F=E8=83=BD=EF=BC=8C=E5=B9=B6=E7=A1=AE=E4=BF=9D?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E6=8F=92=E5=85=A5=E7=AC=A6=E5=90=88=E9=A2=84?= =?UTF-8?q?=E6=9C=9F=E8=A1=8C=E4=B8=BA=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- openless-all/app/src-tauri/src/coordinator.rs | 32 +++- .../src-tauri/src/coordinator/dictation.rs | 146 ++++++++++++--- openless-all/app/src-tauri/src/insertion.rs | 112 +++++------- openless-all/app/src-tauri/src/types.rs | 102 ++++++++++- .../app/src-tauri/src/unicode_keystroke.rs | 169 +++++++++++++++++- openless-all/app/src/i18n/en.ts | 12 +- openless-all/app/src/i18n/ja.ts | 12 +- openless-all/app/src/i18n/ko.ts | 12 +- openless-all/app/src/i18n/zh-CN.ts | 12 +- openless-all/app/src/i18n/zh-TW.ts | 12 +- openless-all/app/src/lib/ipc/mock-data.ts | 2 + openless-all/app/src/lib/stylePrefs.test.ts | 2 + openless-all/app/src/lib/types.ts | 12 +- .../pages/settings/RecordingInputSection.tsx | 48 ++++- 14 files changed, 561 insertions(+), 124 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 52b38614..3044616c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1758,8 +1758,14 @@ pub(super) fn insert_via_non_tsf_fallback( _restore_clipboard: bool, _paste_shortcut: PasteShortcut, ) -> InsertStatus { + let prefs = inner.prefs.get(); + let sendinput_options = crate::unicode_keystroke::WindowsSendInputOptions { + newline_mode: prefs.windows_sendinput_newline_mode, + }; let status = finish_non_tsf_insertion_fallback( - || inner.inserter.insert_via_unicode_keystrokes(polished), + || inner + .inserter + .insert_via_unicode_keystrokes(polished, sendinput_options), || inner.inserter.copy_fallback(polished), ); @@ -2767,7 +2773,13 @@ mod tests { #[test] fn focus_restore_failure_uses_specific_error_code_when_insert_fails() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, false, false, false), + dictation_error_code( + InsertStatus::Failed, + false, + false, + false, + crate::types::WindowsInsertionMode::Tsf, + ), Some("focusRestoreFailed") ); } @@ -2784,7 +2796,13 @@ 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, false), + dictation_error_code( + InsertStatus::Failed, + false, + true, + false, + crate::types::WindowsInsertionMode::Tsf, + ), Some("windowsImeTsfRequired") ); } @@ -2792,7 +2810,13 @@ mod tests { #[test] fn sendinput_only_mode_skips_tsf_required_error() { assert_eq!( - dictation_error_code(InsertStatus::Failed, false, true, false, true), + dictation_error_code( + InsertStatus::Failed, + false, + true, + false, + crate::types::WindowsInsertionMode::SendInput, + ), None ); } diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index f3b31d7b..c3527123 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -154,8 +154,22 @@ async fn run_streaming_polish( // 与用户实际看到的内容一致;(b)pr-agent #412 反馈 \"saved output diverges // from what the user actually sees\"。 let (tx, rx) = std::sync::mpsc::channel::(); + #[cfg(target_os = "windows")] + let sendinput_options = + windows_sendinput_options_from_prefs(&inner.prefs.get()); let typer_handle = tokio::task::spawn_blocking(move || { - drain_streaming_insert_deltas(rx, STREAMING_INSERT_FLUSH_INTERVAL) + #[cfg(target_os = "windows")] + { + drain_streaming_insert_deltas_with_sendinput_options( + rx, + STREAMING_INSERT_FLUSH_INTERVAL, + sendinput_options, + ) + } + #[cfg(not(target_os = "windows"))] + { + drain_streaming_insert_deltas(rx, STREAMING_INSERT_FLUSH_INTERVAL) + } }); // 3. 调流式润色,on_delta 塞 mpsc;should_cancel 检查 dictation 取消旗。 @@ -294,6 +308,25 @@ async fn run_streaming_polish( } } +#[cfg(target_os = "windows")] +fn windows_sendinput_options_from_prefs( + prefs: &crate::types::UserPreferences, +) -> crate::unicode_keystroke::WindowsSendInputOptions { + crate::unicode_keystroke::WindowsSendInputOptions { + newline_mode: prefs.windows_sendinput_newline_mode, + } +} + +#[cfg(target_os = "windows")] +fn windows_insertion_allows_streaming(mode: crate::types::WindowsInsertionMode) -> bool { + mode == crate::types::WindowsInsertionMode::SendInput +} + +#[cfg(not(target_os = "windows"))] +fn windows_insertion_allows_streaming(_mode: crate::types::WindowsInsertionMode) -> bool { + true +} + fn drain_streaming_insert_deltas( rx: std::sync::mpsc::Receiver, flush_interval: std::time::Duration, @@ -301,6 +334,17 @@ fn drain_streaming_insert_deltas( drain_streaming_insert_deltas_with(rx, flush_interval, flush_streaming_insert_buffer) } +#[cfg(target_os = "windows")] +fn drain_streaming_insert_deltas_with_sendinput_options( + rx: std::sync::mpsc::Receiver, + flush_interval: std::time::Duration, + options: crate::unicode_keystroke::WindowsSendInputOptions, +) -> (String, Option) { + drain_streaming_insert_deltas_with(rx, flush_interval, move |pending, typed| { + flush_streaming_insert_buffer_with_options(pending, typed, options) + }) +} + fn drain_streaming_insert_deltas_with( rx: std::sync::mpsc::Receiver, flush_interval: std::time::Duration, @@ -351,6 +395,17 @@ fn flush_streaming_insert_buffer(pending: &mut String, typed_text: &mut String) ) } +#[cfg(target_os = "windows")] +fn flush_streaming_insert_buffer_with_options( + pending: &mut String, + typed_text: &mut String, + options: crate::unicode_keystroke::WindowsSendInputOptions, +) -> Option { + flush_streaming_insert_buffer_with(pending, typed_text, move |text| { + crate::unicode_keystroke::type_unicode_chunk_with_options(text, options) + }) +} + fn flush_streaming_insert_buffer_with( pending: &mut String, typed_text: &mut String, @@ -443,6 +498,7 @@ fn streaming_insert_eligible( mode: PolishMode, raw_uses_llm: bool, chinese_script_preference: crate::types::ChineseScriptPreference, + windows_insertion_mode: crate::types::WindowsInsertionMode, ) -> bool { streaming_insert_enabled && !translation_active @@ -451,6 +507,7 @@ fn streaming_insert_eligible( // 没有成品可后处理(finalize_polished_text 在 already_streamed 时直接 return)。 // → 非 Auto 时关掉流式,走一次性路径,确保简/繁转换真正生效(issue #643)。 && chinese_script_preference == crate::types::ChineseScriptPreference::Auto + && windows_insertion_allows_streaming(windows_insertion_mode) } fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option { @@ -1068,7 +1125,7 @@ pub(super) async fn begin_session_as( }; #[cfg(target_os = "windows")] { - if !inner.prefs.get().windows_sendinput_insertion_only { + if inner.prefs.get().windows_insertion_mode == crate::types::WindowsInsertionMode::Tsf { 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); @@ -2314,6 +2371,7 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { mode, raw_uses_llm, chinese_script_preference, + prefs.windows_insertion_mode, ); log::info!( "[coord] polish dispatch: translation={translation_active} mode={mode:?} streaming_eligible={streaming_eligible}" @@ -2421,7 +2479,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 windows_insertion_mode = prefs.windows_insertion_mode; let paste_shortcut = prefs.paste_shortcut; // 流式路径下,字符已经通过 Unicode keystroke 落到光标处,跳过 inserter.insert。 let status = if already_streamed { @@ -2444,29 +2502,40 @@ pub(super) async fn end_session(inner: &Arc) -> Result<(), String> { if focus_ready_for_paste { #[cfg(target_os = "windows")] { - if windows_sendinput_insertion_only { - if allow_non_tsf_insertion_fallback { - insert_via_non_tsf_fallback( + match windows_insertion_mode { + crate::types::WindowsInsertionMode::SendInput => { + let sendinput_options = windows_sendinput_options_from_prefs(&prefs); + 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, sendinput_options) + } + } + crate::types::WindowsInsertionMode::Paste => inner.inserter.insert( + &polished, + restore_clipboard, + paste_shortcut, + ), + crate::types::WindowsInsertionMode::Tsf => { + 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, ) - } else { - inner.inserter.insert_via_unicode_keystrokes(&polished) + .await } - } 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"))] @@ -2523,7 +2592,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, + windows_insertion_mode, ) .map(str::to_string); let tsf_required_insert_failed = error_code.as_deref() == Some("windowsImeTsfRequired"); @@ -2608,14 +2677,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, + windows_insertion_mode: crate::types::WindowsInsertionMode, ) -> 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 + && windows_insertion_mode == crate::types::WindowsInsertionMode::Tsf && status == InsertStatus::Failed { Some("windowsImeTsfRequired") @@ -3035,6 +3104,31 @@ mod tests { PolishMode::Light, false, ChineseScriptPreference::Auto, + crate::types::WindowsInsertionMode::SendInput, + )); + } + + #[test] + fn streaming_disabled_for_windows_tsf_insertion_mode() { + assert!(!streaming_insert_eligible( + true, + false, + PolishMode::Light, + false, + ChineseScriptPreference::Auto, + crate::types::WindowsInsertionMode::Tsf, + )); + } + + #[test] + fn streaming_disabled_for_windows_paste_insertion_mode() { + assert!(!streaming_insert_eligible( + true, + false, + PolishMode::Light, + false, + ChineseScriptPreference::Auto, + crate::types::WindowsInsertionMode::Paste, )); } @@ -3050,16 +3144,18 @@ mod tests { false, PolishMode::Light, false, - pref + pref, + crate::types::WindowsInsertionMode::Tsf, )); } - // Auto 不受影响,仍可流式。 + // Auto + SendInput 仍可流式。 assert!(streaming_insert_eligible( true, false, PolishMode::Light, false, ChineseScriptPreference::Auto, + crate::types::WindowsInsertionMode::SendInput, )); } diff --git a/openless-all/app/src-tauri/src/insertion.rs b/openless-all/app/src-tauri/src/insertion.rs index 114488cd..db5468a6 100644 --- a/openless-all/app/src-tauri/src/insertion.rs +++ b/openless-all/app/src-tauri/src/insertion.rs @@ -74,17 +74,18 @@ impl TextInserter { } #[cfg(target_os = "windows")] - pub fn insert_via_unicode_keystrokes(&self, text: &str) -> InsertStatus { + pub fn insert_via_unicode_keystrokes( + &self, + text: &str, + options: crate::unicode_keystroke::WindowsSendInputOptions, + ) -> InsertStatus { if text.is_empty() { return InsertStatus::CopiedFallback; } - match windows_unicode::send_text(text) { - Ok(()) => InsertStatus::Inserted, - Err(err) => { - log::warn!("[insertion] Unicode SendInput failed: {err}"); - InsertStatus::CopiedFallback - } - } + map_sendinput_type_result( + text, + crate::unicode_keystroke::type_unicode_chunk_with_options(text, options), + ) } /// macOS 路径:保存原剪贴板 → 写转写文字 → post Cmd+V → 按需恢复原剪贴板。 @@ -179,6 +180,27 @@ impl Default for TextInserter { } } +#[cfg(target_os = "windows")] +fn map_sendinput_type_result( + text: &str, + result: Result, +) -> InsertStatus { + let expected = crate::unicode_keystroke::expected_sendinput_typed_chars(text); + match result { + Ok(typed_chars) if typed_chars == expected => InsertStatus::Inserted, + Ok(typed_chars) => { + log::warn!( + "[insertion] Unicode SendInput typed only {typed_chars}/{expected} chars" + ); + InsertStatus::CopiedFallback + } + Err(err) => { + log::warn!("[insertion] Unicode SendInput failed: {err}"); + InsertStatus::CopiedFallback + } + } +} + #[cfg(not(any(target_os = "android", target_os = "ios")))] #[derive(Debug)] struct ClipboardRestorePlan { @@ -454,63 +476,6 @@ fn insertion_success_status() -> InsertStatus { InsertStatus::PasteSent } -#[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> { - 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(()) - } - - fn send_utf16_unit(unit: u16, key_up: bool) -> Result<(), String> { - let flags = if key_up { - KEYEVENTF_UNICODE | KEYEVENTF_KEYUP - } else { - KEYEVENTF_UNICODE - }; - let input = INPUT { - r#type: INPUT_KEYBOARD, - Anonymous: INPUT_0 { - ki: KEYBDINPUT { - wVk: VIRTUAL_KEY(0), - wScan: unit, - dwFlags: KEYBD_EVENT_FLAGS(flags.0), - time: 0, - dwExtraInfo: 0, - }, - }, - }; - let sent = unsafe { SendInput(&[input], std::mem::size_of::() as i32) }; - if sent == 1 { - Ok(()) - } else { - Err(std::io::Error::last_os_error().to_string()) - } - } -} - // ── macOS CGEvent paste ── // 直接调 CoreGraphics FFI 发送 Cmd+V,避开 enigo 在主线程外触发的 TSM 断言。 @@ -635,6 +600,23 @@ mod tests { assert!(matches!(primary, Key::Insert)); } + #[test] + #[cfg(target_os = "windows")] + fn crlf_sendinput_success_uses_expected_typed_count() { + let text = "a\r\nb"; + let status = map_sendinput_type_result(text, Ok(3)); + assert_eq!(status, InsertStatus::Inserted); + assert_ne!(text.chars().count(), 3); + } + + #[test] + #[cfg(target_os = "windows")] + fn crlf_sendinput_partial_mismatch_falls_back_to_clipboard() { + let text = "a\r\nb"; + let status = map_sendinput_type_result(text, Ok(text.chars().count())); + assert_eq!(status, InsertStatus::CopiedFallback); + } + #[test] fn empty_insertions_never_touch_clipboard_or_paste_path() { let inserter = TextInserter::new(); diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 1cff61b5..536f9a8e 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -77,6 +77,26 @@ pub enum PasteShortcut { ShiftInsert, } +/// Windows 听写文本插入策略。默认 TSF 输入法;SendInput 逐字模拟;Paste 走剪贴板 + 模拟粘贴键。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum WindowsInsertionMode { + #[default] + Tsf, + SendInput, + Paste, +} + +/// Windows SendInput 路径的换行模拟方式。仅 `WindowsInsertionMode::SendInput` 生效。 +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub enum WindowsSendInputNewlineMode { + #[default] + Enter, + ShiftEnter, + CrLf, +} + /// Auto-update 渠道。决定后台 AutoUpdateGate 拉哪条 manifest。 /// `Stable` = `latest-android-{arch}.json`(或桌面 plugin-updater 正式版 endpoints)。 /// `Beta` = `latest-android-{arch}-beta.json`(或桌面 beta endpoints)。 @@ -539,6 +559,26 @@ fn default_true() -> bool { true } +fn resolve_windows_insertion_mode( + mode: WindowsInsertionMode, + legacy_sendinput_only: bool, +) -> WindowsInsertionMode { + if mode != WindowsInsertionMode::Tsf { + mode + } else if legacy_sendinput_only { + WindowsInsertionMode::SendInput + } else { + WindowsInsertionMode::Tsf + } +} + +fn resolve_windows_sendinput_insertion_only_legacy( + mode: WindowsInsertionMode, + legacy_sendinput_only: bool, +) -> bool { + resolve_windows_insertion_mode(mode, legacy_sendinput_only) == WindowsInsertionMode::SendInput +} + #[derive(Debug, Clone, Serialize)] #[serde(default, rename_all = "camelCase")] pub struct UserPreferences { @@ -586,8 +626,13 @@ pub struct UserPreferences { /// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。 #[serde(default = "default_true")] pub allow_non_tsf_insertion_fallback: bool, - /// Windows: 始终用 SendInput Unicode 插入,不切换 OpenLess TSF 输入法。 - /// 适用于输入法无法正确还原的用户。默认 false 保持 TSF 优先。 + /// Windows 听写插入策略:TSF / SendInput / 剪贴板粘贴。 + #[serde(default)] + pub windows_insertion_mode: WindowsInsertionMode, + /// Windows SendInput 路径的换行模拟方式。 + #[serde(default)] + pub windows_sendinput_newline_mode: WindowsSendInputNewlineMode, + /// 旧版 wire 兼容:`true` 等价于 `windows_insertion_mode = SendInput`。 #[serde( default, rename = "windowsSendInputInsertionOnly", @@ -891,6 +936,10 @@ struct UserPreferencesWire { #[serde(default)] paste_shortcut: PasteShortcut, allow_non_tsf_insertion_fallback: bool, + #[serde(default)] + windows_insertion_mode: WindowsInsertionMode, + #[serde(default)] + windows_sendinput_newline_mode: WindowsSendInputNewlineMode, #[serde( default, rename = "windowsSendInputInsertionOnly", @@ -1018,6 +1067,8 @@ 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_insertion_mode: prefs.windows_insertion_mode, + windows_sendinput_newline_mode: prefs.windows_sendinput_newline_mode, windows_sendinput_insertion_only: prefs.windows_sendinput_insertion_only, working_languages: prefs.working_languages, translation_target_language: prefs.translation_target_language, @@ -1119,7 +1170,15 @@ 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, + windows_insertion_mode: resolve_windows_insertion_mode( + wire.windows_insertion_mode, + wire.windows_sendinput_insertion_only, + ), + windows_sendinput_newline_mode: wire.windows_sendinput_newline_mode, + windows_sendinput_insertion_only: resolve_windows_sendinput_insertion_only_legacy( + wire.windows_insertion_mode, + wire.windows_sendinput_insertion_only, + ), working_languages: wire.working_languages, translation_target_language: wire.translation_target_language, chinese_script_preference: wire.chinese_script_preference, @@ -1863,6 +1922,8 @@ impl Default for UserPreferences { restore_clipboard_after_paste: true, paste_shortcut: PasteShortcut::default(), allow_non_tsf_insertion_fallback: true, + windows_insertion_mode: WindowsInsertionMode::default(), + windows_sendinput_newline_mode: WindowsSendInputNewlineMode::default(), windows_sendinput_insertion_only: false, working_languages: default_working_languages(), translation_target_language: String::new(), @@ -2611,9 +2672,11 @@ mod tests { fn windows_sendinput_insertion_only_defaults_to_disabled() { let prefs = UserPreferences::default(); assert!(!prefs.windows_sendinput_insertion_only); + assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::Tsf); let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); assert!(!prefs.windows_sendinput_insertion_only); + assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::Tsf); } #[test] @@ -2621,6 +2684,7 @@ mod tests { let prefs: UserPreferences = serde_json::from_str(r#"{"windowsSendInputInsertionOnly": true}"#).unwrap(); assert!(prefs.windows_sendinput_insertion_only); + assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::SendInput); } #[test] @@ -2628,11 +2692,40 @@ mod tests { let prefs: UserPreferences = serde_json::from_str(r#"{"windowsSendinputInsertionOnly": true}"#).unwrap(); assert!(prefs.windows_sendinput_insertion_only); + assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::SendInput); + } + + #[test] + fn windows_insertion_mode_deserializes_explicit_paste() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsInsertionMode":"paste"}"#).unwrap(); + assert_eq!(prefs.windows_insertion_mode, WindowsInsertionMode::Paste); + assert!(!prefs.windows_sendinput_insertion_only); + } + + #[test] + fn windows_sendinput_newline_mode_defaults_to_enter() { + let prefs: UserPreferences = serde_json::from_str("{}").unwrap(); + assert_eq!( + prefs.windows_sendinput_newline_mode, + WindowsSendInputNewlineMode::Enter + ); + } + + #[test] + fn windows_sendinput_newline_mode_deserializes_shift_enter() { + let prefs: UserPreferences = + serde_json::from_str(r#"{"windowsSendInputNewlineMode":"shiftEnter"}"#).unwrap(); + assert_eq!( + prefs.windows_sendinput_newline_mode, + WindowsSendInputNewlineMode::ShiftEnter + ); } #[test] fn windows_sendinput_insertion_only_serializes_frontend_wire_key() { let enabled = UserPreferences { + windows_insertion_mode: WindowsInsertionMode::SendInput, windows_sendinput_insertion_only: true, ..UserPreferences::default() }; @@ -2644,13 +2737,16 @@ mod tests { #[test] fn windows_sendinput_insertion_only_pref_round_trips_explicit_true() { let enabled = UserPreferences { + windows_insertion_mode: WindowsInsertionMode::SendInput, windows_sendinput_insertion_only: true, ..UserPreferences::default() }; let json = serde_json::to_string(&enabled).unwrap(); assert!(json.contains(r#""windowsSendInputInsertionOnly":true"#)); + assert!(json.contains(r#""windowsInsertionMode":"sendInput""#)); let restored: UserPreferences = serde_json::from_str(&json).unwrap(); assert!(restored.windows_sendinput_insertion_only); + assert_eq!(restored.windows_insertion_mode, WindowsInsertionMode::SendInput); } #[test] diff --git a/openless-all/app/src-tauri/src/unicode_keystroke.rs b/openless-all/app/src-tauri/src/unicode_keystroke.rs index d22fd00f..ff453400 100644 --- a/openless-all/app/src-tauri/src/unicode_keystroke.rs +++ b/openless-all/app/src-tauri/src/unicode_keystroke.rs @@ -313,11 +313,12 @@ mod macos_impl { #[cfg(target_os = "windows")] mod windows_impl { use super::{TisError, TypeError}; + use crate::types::WindowsSendInputNewlineMode; use std::time::Duration; use tauri::{AppHandle, Runtime}; use windows::Win32::UI::Input::KeyboardAndMouse::{ SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP, - KEYEVENTF_UNICODE, VIRTUAL_KEY, + KEYEVENTF_UNICODE, VIRTUAL_KEY, VK_RETURN, VK_SHIFT, VK_TAB, }; const SENDINPUT_CHUNK_CHARS: usize = 16; @@ -326,7 +327,27 @@ mod windows_impl { /// Windows / Linux 上没有 input source 概念,token 留空。Send/Sync 自动派生。 pub struct PreviousInputSource; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub struct WindowsSendInputOptions { + pub newline_mode: WindowsSendInputNewlineMode, + } + + impl Default for WindowsSendInputOptions { + fn default() -> Self { + Self { + newline_mode: WindowsSendInputNewlineMode::Enter, + } + } + } + pub fn type_unicode_chunk(text: &str) -> Result { + type_unicode_chunk_with_options(text, WindowsSendInputOptions::default()) + } + + pub fn type_unicode_chunk_with_options( + text: &str, + options: WindowsSendInputOptions, + ) -> Result { if text.is_empty() { return Ok(0); } @@ -334,17 +355,36 @@ mod windows_impl { 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) { - if let Err(e) = send_utf16_unit(*unit, false) { + if ch == '\r' { + continue; + } + + if ch == '\n' { + if let Err(e) = send_newline(options.newline_mode) { return Err(partial_or_original(typed_chars, e)); } - if let Err(e) = send_utf16_unit(*unit, true) { + typed_chars += 1; + sent_in_chunk += 1; + } else if ch == '\t' { + if let Err(e) = press_vk(VK_TAB) { return Err(partial_or_original(typed_chars, e)); } + typed_chars += 1; + sent_in_chunk += 1; + } else { + let mut buf = [0u16; 2]; + for unit in ch.encode_utf16(&mut buf) { + if let Err(e) = send_utf16_unit(*unit, false) { + return Err(partial_or_original(typed_chars, e)); + } + if let Err(e) = send_utf16_unit(*unit, true) { + return Err(partial_or_original(typed_chars, e)); + } + } + typed_chars += 1; + sent_in_chunk += 1; } - typed_chars += 1; - 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; @@ -364,6 +404,57 @@ mod windows_impl { } } + fn send_newline(mode: WindowsSendInputNewlineMode) -> Result<(), TypeError> { + match mode { + WindowsSendInputNewlineMode::Enter => press_vk(VK_RETURN), + WindowsSendInputNewlineMode::ShiftEnter => press_shift_enter(), + WindowsSendInputNewlineMode::CrLf => { + send_utf16_unit(0x000D, false)?; + send_utf16_unit(0x000D, true)?; + send_utf16_unit(0x000A, false)?; + send_utf16_unit(0x000A, true) + } + } + } + + fn press_shift_enter() -> Result<(), TypeError> { + send_vk(VK_SHIFT, false)?; + press_vk(VK_RETURN)?; + send_vk(VK_SHIFT, true) + } + + fn press_vk(vk: VIRTUAL_KEY) -> Result<(), TypeError> { + send_vk(vk, false)?; + send_vk(vk, true) + } + + fn send_vk(vk: VIRTUAL_KEY, key_up: bool) -> Result<(), TypeError> { + let mut flags = KEYBD_EVENT_FLAGS(0); + if key_up { + flags |= KEYEVENTF_KEYUP; + } + let input = INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + }; + let sent = unsafe { SendInput(&[input], std::mem::size_of::() as i32) }; + if sent == 1 { + Ok(()) + } else { + Err(TypeError::SendInputFailed( + std::io::Error::last_os_error().to_string(), + )) + } + } + fn send_utf16_unit(unit: u16, key_up: bool) -> Result<(), TypeError> { let flags = if key_up { KEYEVENTF_UNICODE | KEYEVENTF_KEYUP @@ -406,6 +497,24 @@ mod windows_impl { ) -> Result<(), TisError> { Ok(()) } + + #[cfg(test)] + pub(super) fn classify_sendinput_char(ch: char) -> SendInputCharKind { + match ch { + '\r' => SendInputCharKind::Skip, + '\n' => SendInputCharKind::Newline, + '\t' => SendInputCharKind::Tab, + _ => SendInputCharKind::Unicode, + } + } + + #[cfg(test)] + pub(super) enum SendInputCharKind { + Skip, + Newline, + Tab, + Unicode, + } } // ═══════════════════════════════════════════════════════════════════════════ @@ -481,6 +590,49 @@ mod tests { fn platform_error() -> TypeError { TypeError::EnigoText("fail".into()) } + + #[test] + fn expected_sendinput_typed_chars_skips_carriage_return() { + assert_eq!(super::expected_sendinput_typed_chars("a\r\nb"), 3); + assert_eq!(super::expected_sendinput_typed_chars("hello"), 5); + assert_eq!(super::expected_sendinput_typed_chars("\r\r\n"), 1); + } + + #[cfg(target_os = "windows")] + mod windows_sendinput_char_tests { + use super::super::windows_impl::{classify_sendinput_char, SendInputCharKind}; + + #[test] + fn classify_skips_carriage_return() { + assert!(matches!( + classify_sendinput_char('\r'), + SendInputCharKind::Skip + )); + } + + #[test] + fn classify_newline_and_tab() { + assert!(matches!( + classify_sendinput_char('\n'), + SendInputCharKind::Newline + )); + assert!(matches!(classify_sendinput_char('\t'), SendInputCharKind::Tab)); + } + + #[test] + fn classify_regular_text_as_unicode() { + assert!(matches!( + classify_sendinput_char('你'), + SendInputCharKind::Unicode + )); + } + } +} + +/// Windows SendInput 路径上 `type_unicode_chunk` 计入的 typed char 数。 +/// `\r` 会被跳过(CRLF 只产生一次换行),因此不能与 `text.chars().count()` 直接比较。 +pub fn expected_sendinput_typed_chars(text: &str) -> usize { + text.chars().filter(|ch| *ch != '\r').count() } // ═══════════════════════════════════════════════════════════════════════════ @@ -495,7 +647,8 @@ pub use macos_impl::{ #[cfg(target_os = "windows")] #[allow(unused_imports)] pub use windows_impl::{ - restore_input_source, switch_to_ascii, type_unicode_chunk, PreviousInputSource, + restore_input_source, switch_to_ascii, type_unicode_chunk, type_unicode_chunk_with_options, + PreviousInputSource, WindowsSendInputOptions, }; #[cfg(target_os = "linux")] diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 67ac5d8e..dcf24c4c 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -684,8 +684,16 @@ 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.', + windowsInsertionModeLabel: 'Windows insertion method', + windowsInsertionModeDesc: 'How dictation output is inserted at the cursor. Clipboard paste uses the simulated paste shortcut above and preserves line breaks.', + windowsInsertionModeTsf: 'TSF IME (default)', + windowsInsertionModeSendInput: 'SendInput keystroke simulation', + windowsInsertionModePaste: 'Clipboard paste (Ctrl+V, etc.)', + windowsSendInputNewlineModeLabel: 'SendInput newline simulation', + windowsSendInputNewlineModeDesc: 'How SendInput turns line breaks into keys. Use Shift+Enter for chat boxes; Enter for Notepad / VS Code and most editors.', + windowsSendInputNewlineModeEnter: 'Enter (most editors)', + windowsSendInputNewlineModeShiftEnter: 'Shift+Enter (chat input boxes)', + windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', 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 7997c293..5038ee90 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -686,8 +686,16 @@ export const ja: typeof zhCN = { comboConflict: 'このショートカットの組み合わせは使用できません', allowNonTsfFallbackLabel: '非 TSF フォールバックを許可', allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。', - windowsSendInputOnlyLabel: '常に SendInput を使用(IME 切替なし)', - windowsSendInputOnlyDesc: '聴写中に OpenLess TSF IME へ切り替えず、Unicode キー入力シミュレーションで直接挿入します。挿入に失敗した場合は、下の「非 TSF フォールバックを許可」でクリップボードへのコピー可否を制御します。一部のアプリ(Word など)は TSF より不安定な場合があります。', + windowsInsertionModeLabel: 'Windows 挿入方式', + windowsInsertionModeDesc: '聴写結果をカーソル位置へ挿入する方法。クリップボード貼り付けは上の「貼り付けショートカット」を使い、改行を保持します。', + windowsInsertionModeTsf: 'TSF IME(既定)', + windowsInsertionModeSendInput: 'SendInput キー入力シミュレーション', + windowsInsertionModePaste: 'クリップボード貼り付け(Ctrl+V など)', + windowsSendInputNewlineModeLabel: 'SendInput 改行シミュレーション', + windowsSendInputNewlineModeDesc: 'SendInput で改行をどのキーとして送るか。チャット入力は Shift+Enter、メモ帳 / VS Code などは Enter。', + windowsSendInputNewlineModeEnter: 'Enter(多くのエディタ)', + windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(チャット入力)', + windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', historyGroupTitle: '履歴とコンテキスト', historyRetentionLabel: '履歴保持期間(日)', historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 012919f8..b5914c92 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -686,8 +686,16 @@ export const ko: typeof zhCN = { comboConflict: '이 단축키 조합은 사용할 수 없습니다', allowNonTsfFallbackLabel: '비 TSF 폴백 허용', allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.', - windowsSendInputOnlyLabel: '항상 SendInput 사용(입력기 전환 없음)', - windowsSendInputOnlyDesc: '받아쓰기 중 OpenLess TSF 입력기로 전환하지 않고 Unicode 키 입력 시뮬레이션으로 직접 삽입합니다. 삽입에 실패하면 아래 「비 TSF 폴백 허용」으로 클립보드 복사 여부를 제어합니다. 일부 앱(Word 등)은 TSF보다 불안정할 수 있습니다.', + windowsInsertionModeLabel: 'Windows 삽입 방식', + windowsInsertionModeDesc: '받아쓰기 결과를 커서 위치에 삽입하는 방법. 클립보드 붙여넣기는 위의 「붙여넣기 단축키」를 사용하며 줄바꿈을 유지합니다.', + windowsInsertionModeTsf: 'TSF 입력기(기본)', + windowsInsertionModeSendInput: 'SendInput 키 입력 시뮬레이션', + windowsInsertionModePaste: '클립보드 붙여넣기(Ctrl+V 등)', + windowsSendInputNewlineModeLabel: 'SendInput 줄바꿈 시뮬레이션', + windowsSendInputNewlineModeDesc: 'SendInput 모드에서 줄바꿈을 어떤 키로 보낼지. 채팅 입력창은 Shift+Enter, 메모장 / VS Code 등은 Enter.', + windowsSendInputNewlineModeEnter: 'Enter(대부분 편집기)', + windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(채팅 입력창)', + windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', 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 7968593b..76b2b35f 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -682,8 +682,16 @@ export const zhCN = { comboConflict: '该快捷键组合不可用', allowNonTsfFallbackLabel: '允许非 TSF 兜底', allowNonTsfFallbackDesc: 'Windows:TSF 失败时使用分批 Unicode SendInput;如果仍失败,再复制到剪贴板。', - windowsSendInputOnlyLabel: '始终使用 SendInput(不切换输入法)', - windowsSendInputOnlyDesc: '听写期间不切换到 OpenLess TSF 输入法,直接用 Unicode 按键模拟插入。若插入失败,仍受下方「允许非 TSF 兜底」控制是否复制到剪贴板。部分应用(如 Word)可能不如 TSF 稳定。', + windowsInsertionModeLabel: 'Windows 插入方式', + windowsInsertionModeDesc: '听写结果如何插入到当前光标位置。剪贴板粘贴模式使用上方「模拟粘贴快捷键」,可完整保留换行。', + windowsInsertionModeTsf: 'TSF 输入法(默认)', + windowsInsertionModeSendInput: 'SendInput 逐字模拟', + windowsInsertionModePaste: '剪贴板粘贴(Ctrl+V 等)', + windowsSendInputNewlineModeLabel: 'SendInput 换行模拟', + windowsSendInputNewlineModeDesc: 'SendInput 模式下如何把换行符模拟成按键。聊天框通常选 Shift+Enter;记事本 / VS Code 等选 Enter。', + windowsSendInputNewlineModeEnter: 'Enter(多数编辑器)', + windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(聊天输入框)', + windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', 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 872b155d..311a2b65 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -684,8 +684,16 @@ 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 穩定。', + windowsInsertionModeLabel: 'Windows 插入方式', + windowsInsertionModeDesc: '聽寫結果如何插入到目前游標位置。剪貼簿貼上模式使用上方「模擬粘貼快捷鍵」,可完整保留換行。', + windowsInsertionModeTsf: 'TSF 輸入法(預設)', + windowsInsertionModeSendInput: 'SendInput 逐字模擬', + windowsInsertionModePaste: '剪貼簿貼上(Ctrl+V 等)', + windowsSendInputNewlineModeLabel: 'SendInput 換行模擬', + windowsSendInputNewlineModeDesc: 'SendInput 模式下如何把換行符模擬成按鍵。聊天框通常選 Shift+Enter;記事本 / VS Code 等選 Enter。', + windowsSendInputNewlineModeEnter: 'Enter(多數編輯器)', + windowsSendInputNewlineModeShiftEnter: 'Shift+Enter(聊天輸入框)', + windowsSendInputNewlineModeCrLf: 'CR+LF Unicode', 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 e5140168..10eb961c 100644 --- a/openless-all/app/src/lib/ipc/mock-data.ts +++ b/openless-all/app/src/lib/ipc/mock-data.ts @@ -49,6 +49,8 @@ export let mockSettings: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: "ctrlV", allowNonTsfInsertionFallback: true, + windowsInsertionMode: "tsf", + windowsSendInputNewlineMode: "enter", windowsSendInputInsertionOnly: false, workingLanguages: ["简体中文"], translationTargetLanguage: "", diff --git a/openless-all/app/src/lib/stylePrefs.test.ts b/openless-all/app/src/lib/stylePrefs.test.ts index 7ccb1a60..7ccc354f 100644 --- a/openless-all/app/src/lib/stylePrefs.test.ts +++ b/openless-all/app/src/lib/stylePrefs.test.ts @@ -39,6 +39,8 @@ const previousPrefs: UserPreferences = { restoreClipboardAfterPaste: true, pasteShortcut: 'ctrlV', allowNonTsfInsertionFallback: true, + windowsInsertionMode: 'tsf', + windowsSendInputNewlineMode: 'enter', windowsSendInputInsertionOnly: false, workingLanguages: ['简体中文'], translationTargetLanguage: '', diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index cc304f4f..a98b0ee1 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -151,6 +151,12 @@ export type CodingAgentPermissionMode = * 详见 issue #360。 */ export type PasteShortcut = 'ctrlV' | 'ctrlShiftV' | 'shiftInsert'; +/** Windows 听写文本插入策略。 */ +export type WindowsInsertionMode = 'tsf' | 'sendInput' | 'paste'; + +/** Windows SendInput 路径的换行模拟方式。 */ +export type WindowsSendInputNewlineMode = 'enter' | 'shiftEnter' | 'crlf'; + export type WindowsImeInstallState = | 'installed' | 'notInstalled' @@ -268,7 +274,11 @@ export interface UserPreferences { pasteShortcut: PasteShortcut; /** Windows:TSF 失败后是否允许快捷键粘贴 / 剪贴板兜底。仅在剪贴板写失败时才再试 SendInput。关闭后可验证是否真实 TSF 上屏。 */ allowNonTsfInsertionFallback: boolean; - /** Windows:始终用 SendInput Unicode 插入,听写期间不切换 OpenLess TSF 输入法。 */ + /** Windows:听写插入策略(TSF / SendInput / 剪贴板粘贴)。 */ + windowsInsertionMode: WindowsInsertionMode; + /** Windows SendInput 路径的换行模拟方式。 */ + windowsSendInputNewlineMode: WindowsSendInputNewlineMode; + /** 旧版兼容:`true` 等价于 `windowsInsertionMode === 'sendInput'`。 */ windowsSendInputInsertionOnly: boolean; /** 用户的工作语言(多选,原生名);作为前提注入 LLM polish/translate prompt 头部。 */ workingLanguages: string[]; diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index fefc7cec..5d0e0750 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -13,7 +13,7 @@ import { setDictationHotkey, } from '../../lib/ipc'; import { getPlatformCapabilities } from '../../lib/platform'; -import type { HotkeyMode, MicrophoneDevice, PasteShortcut, PlatformCapabilities } from '../../lib/types'; +import type { HotkeyMode, MicrophoneDevice, PasteShortcut, PlatformCapabilities, WindowsInsertionMode, WindowsSendInputNewlineMode } from '../../lib/types'; import { useHotkeySettings } from '../../state/HotkeySettingsContext'; import { SelectLite } from '../../components/ui/SelectLite'; import { Card, Collapsible } from '../_atoms'; @@ -131,8 +131,14 @@ export function RecordingInputSection() { savePrefs({ ...prefs, pasteShortcut }); const onAllowNonTsfFallbackChange = (allowNonTsfInsertionFallback: boolean) => savePrefs({ ...prefs, allowNonTsfInsertionFallback }); - const onWindowsSendInputOnlyChange = (windowsSendInputInsertionOnly: boolean) => - savePrefs({ ...prefs, windowsSendInputInsertionOnly }); + const onWindowsInsertionModeChange = (windowsInsertionMode: WindowsInsertionMode) => + savePrefs({ + ...prefs, + windowsInsertionMode, + windowsSendInputInsertionOnly: windowsInsertionMode === 'sendInput', + }); + const onWindowsSendInputNewlineModeChange = (windowsSendInputNewlineMode: WindowsSendInputNewlineMode) => + savePrefs({ ...prefs, windowsSendInputNewlineMode }); const onStartMinimizedChange = (startMinimized: boolean) => savePrefs({ ...prefs, startMinimized }); const onAutoUpdateCheckChange = (autoUpdateCheck: boolean) => @@ -294,12 +300,38 @@ export function RecordingInputSection() { )} {capability.adapter === 'windowsLowLevel' && ( - onWindowsInsertionModeChange(next as WindowsInsertionMode)} + options={[ + { value: 'tsf', label: t('settings.recording.windowsInsertionModeTsf') }, + { value: 'sendInput', label: t('settings.recording.windowsInsertionModeSendInput') }, + { value: 'paste', label: t('settings.recording.windowsInsertionModePaste') }, + ]} + ariaLabel={t('settings.recording.windowsInsertionModeLabel')} + style={{ ...inputStyle, maxWidth: 260 }} + /> + + )} + {capability.adapter === 'windowsLowLevel' + && (prefs.windowsInsertionMode === 'sendInput' || prefs.windowsSendInputInsertionOnly) && ( + + onWindowsSendInputNewlineModeChange(next as WindowsSendInputNewlineMode)} + options={[ + { value: 'enter', label: t('settings.recording.windowsSendInputNewlineModeEnter') }, + { value: 'shiftEnter', label: t('settings.recording.windowsSendInputNewlineModeShiftEnter') }, + { value: 'crlf', label: t('settings.recording.windowsSendInputNewlineModeCrLf') }, + ]} + ariaLabel={t('settings.recording.windowsSendInputNewlineModeLabel')} + style={{ ...inputStyle, maxWidth: 260 }} /> )} From 2318266e6c0cefcf6502df597d13824374c38a10 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 24 Jun 2026 09:59:10 +0800 Subject: [PATCH 5/6] Fix SendInput CI regressions --- .../backend-tests/tests/backend_rust.rs | 10 ++++++++++ .../src-tauri/src/coordinator/dictation.rs | 20 +++++++++++++++++++ openless-all/app/src-tauri/src/types.rs | 20 +++++++++++++++++-- 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs index a3760ccb..d62e4dce 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs @@ -7,6 +7,13 @@ #![allow(dead_code, unused_variables)] +#[cfg(target_os = "windows")] +mod tauri { + pub struct AppHandle(std::marker::PhantomData); + + pub trait Runtime {} +} + mod asr { pub mod local { pub mod foundry { @@ -47,3 +54,6 @@ mod recorder; mod shortcut_binding; #[path = "../../src/types.rs"] mod types; +#[cfg(target_os = "windows")] +#[path = "../../src/unicode_keystroke.rs"] +mod unicode_keystroke; diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index c3527123..f5cf5aaf 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -3108,6 +3108,7 @@ mod tests { )); } + #[cfg(target_os = "windows")] #[test] fn streaming_disabled_for_windows_tsf_insertion_mode() { assert!(!streaming_insert_eligible( @@ -3120,6 +3121,7 @@ mod tests { )); } + #[cfg(target_os = "windows")] #[test] fn streaming_disabled_for_windows_paste_insertion_mode() { assert!(!streaming_insert_eligible( @@ -3132,6 +3134,24 @@ mod tests { )); } + #[cfg(not(target_os = "windows"))] + #[test] + fn streaming_ignores_windows_insertion_mode_on_non_windows() { + for mode in [ + crate::types::WindowsInsertionMode::Tsf, + crate::types::WindowsInsertionMode::Paste, + ] { + assert!(streaming_insert_eligible( + true, + false, + PolishMode::Light, + false, + ChineseScriptPreference::Auto, + mode, + )); + } + } + #[test] fn streaming_disabled_for_non_auto_script_so_opencc_runs() { // issue #643:非 Auto 字形(简/繁)必须走一次性路径,让 finalize 的 OpenCC 转换生效。 diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 536f9a8e..cafc8b84 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -630,7 +630,7 @@ pub struct UserPreferences { #[serde(default)] pub windows_insertion_mode: WindowsInsertionMode, /// Windows SendInput 路径的换行模拟方式。 - #[serde(default)] + #[serde(default, rename = "windowsSendInputNewlineMode")] pub windows_sendinput_newline_mode: WindowsSendInputNewlineMode, /// 旧版 wire 兼容:`true` 等价于 `windows_insertion_mode = SendInput`。 #[serde( @@ -938,7 +938,11 @@ struct UserPreferencesWire { allow_non_tsf_insertion_fallback: bool, #[serde(default)] windows_insertion_mode: WindowsInsertionMode, - #[serde(default)] + #[serde( + default, + rename = "windowsSendInputNewlineMode", + alias = "windowsSendinputNewlineMode" + )] windows_sendinput_newline_mode: WindowsSendInputNewlineMode, #[serde( default, @@ -2722,6 +2726,18 @@ mod tests { ); } + #[test] + fn windows_sendinput_newline_mode_serializes_frontend_wire_key() { + let prefs = UserPreferences { + windows_insertion_mode: WindowsInsertionMode::SendInput, + windows_sendinput_newline_mode: WindowsSendInputNewlineMode::ShiftEnter, + ..UserPreferences::default() + }; + let json = serde_json::to_string(&prefs).unwrap(); + assert!(json.contains(r#""windowsSendInputNewlineMode":"shiftEnter""#)); + assert!(!json.contains("windowsSendinputNewlineMode")); + } + #[test] fn windows_sendinput_insertion_only_serializes_frontend_wire_key() { let enabled = UserPreferences { From 1498c51c1b7ef4cd1608ccaad482412c484d99a0 Mon Sep 17 00:00:00 2001 From: HKLHaoBin Date: Wed, 24 Jun 2026 10:13:31 +0800 Subject: [PATCH 6/6] Fix backend test tauri stub resolution --- .../app/src-tauri/backend-tests/tests/backend_rust.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs index d62e4dce..3f3fa3f9 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs @@ -8,11 +8,13 @@ #![allow(dead_code, unused_variables)] #[cfg(target_os = "windows")] -mod tauri { - pub struct AppHandle(std::marker::PhantomData); +extern crate self as tauri; - pub trait Runtime {} -} +#[cfg(target_os = "windows")] +pub struct AppHandle(std::marker::PhantomData); + +#[cfg(target_os = "windows")] +pub trait Runtime {} mod asr { pub mod local {