Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Inner>,
polished: &str,
_restore_clipboard: bool,
Expand Down Expand Up @@ -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")
);
}
Expand All @@ -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();
Expand Down
47 changes: 33 additions & 14 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2419,6 +2421,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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 {
Expand All @@ -2441,17 +2444,30 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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"))]
{
Expand Down Expand Up @@ -2507,6 +2523,7 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> 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");
Expand Down Expand Up @@ -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")
Expand Down
63 changes: 63 additions & 0 deletions openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,14 @@ pub struct UserPreferences {
/// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。
#[serde(default = "default_true")]
pub allow_non_tsf_insertion_fallback: bool,
/// Windows: 始终用 SendInput Unicode 插入,不切换 OpenLess TSF 输入法。
/// 适用于输入法无法正确还原的用户。默认 false 保持 TSF 优先。
#[serde(
default,
rename = "windowsSendInputInsertionOnly",
alias = "windowsSendinputInsertionOnly"
)]
pub windows_sendinput_insertion_only: bool,
/// 用户的工作语言(多选,原生名)。会作为前提注入 LLM polish/translate 的 system prompt 头部,
/// 让模型知道该用户在哪些语言间工作。详见 issue #4。
#[serde(default = "default_working_languages")]
Expand Down Expand Up @@ -883,6 +891,12 @@ struct UserPreferencesWire {
#[serde(default)]
paste_shortcut: PasteShortcut,
allow_non_tsf_insertion_fallback: bool,
#[serde(
default,
rename = "windowsSendInputInsertionOnly",
alias = "windowsSendinputInsertionOnly"
)]
windows_sendinput_insertion_only: bool,
working_languages: Vec<String>,
translation_target_language: String,
chinese_script_preference: ChineseScriptPreference,
Expand Down Expand Up @@ -1004,6 +1018,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,
Expand Down Expand Up @@ -1104,6 +1119,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,
Expand Down Expand Up @@ -1847,6 +1863,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,
Expand Down Expand Up @@ -2590,6 +2607,52 @@ 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_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 {
windows_sendinput_insertion_only: true,
..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);
}

#[test]
fn missing_audio_cue_on_record_pref_defaults_to_enabled() {
// 老用户的 preferences.json 没有这个字段 → 应默认开启(按下录音即提示)。
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 時間で削除しない。',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 시간 기반 정리 비활성화.',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 不按时间清理。',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = 不按時間清理。',
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/lib/ipc/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export let mockSettings: UserPreferences = {
restoreClipboardAfterPaste: true,
pasteShortcut: "ctrlV",
allowNonTsfInsertionFallback: true,
windowsSendInputInsertionOnly: false,
workingLanguages: ["简体中文"],
translationTargetLanguage: "",
qaHotkey: defaultQaShortcut(),
Expand Down
1 change: 1 addition & 0 deletions openless-all/app/src/lib/stylePrefs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const previousPrefs: UserPreferences = {
restoreClipboardAfterPaste: true,
pasteShortcut: 'ctrlV',
allowNonTsfInsertionFallback: true,
windowsSendInputInsertionOnly: false,
workingLanguages: ['简体中文'],
translationTargetLanguage: '',
chineseScriptPreference: 'auto',
Expand Down
2 changes: 2 additions & 0 deletions openless-all/app/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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。 */
Expand Down
13 changes: 13 additions & 0 deletions openless-all/app/src/pages/settings/RecordingInputSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -290,6 +292,17 @@ export function RecordingInputSection() {
/>
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel' && (
<SettingRow
label={t('settings.recording.windowsSendInputOnlyLabel')}
desc={t('settings.recording.windowsSendInputOnlyDesc')}
>
<Toggle
on={prefs.windowsSendInputInsertionOnly}
onToggle={onWindowsSendInputOnlyChange}
/>
</SettingRow>
)}
{capability.adapter === 'windowsLowLevel' && (
<SettingRow label={t('settings.recording.allowNonTsfFallbackLabel')}>
<Toggle
Expand Down
Loading