Skip to content
Merged
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
109 changes: 100 additions & 9 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2026,16 +2026,107 @@ fn should_try_non_tsf_insertion_fallback(
fn insert_via_non_tsf_fallback(
inner: &Arc<Inner>,
polished: &str,
restore_clipboard: bool,
paste_shortcut: PasteShortcut,
_restore_clipboard: bool,
_paste_shortcut: PasteShortcut,
) -> InsertStatus {
if inner.inserter.insert_via_unicode_keystrokes(polished) == InsertStatus::Inserted {
log::info!("[windows-ime] TSF unavailable; inserted via Unicode SendInput");
InsertStatus::Inserted
} else {
inner
.inserter
.insert_via_clipboard_fallback(polished, restore_clipboard, paste_shortcut)
let status = finish_non_tsf_insertion_fallback(
|| inner.inserter.insert_via_unicode_keystrokes(polished),
|| inner.inserter.copy_fallback(polished),
);

match status {
InsertStatus::Inserted => {
log::warn!(
"[windows-ime] TSF unavailable; inserted via paced Unicode SendInput fallback"
);
}
InsertStatus::CopiedFallback => {
log::warn!(
"[windows-ime] TSF unavailable; Unicode SendInput failed, left text on clipboard"
);
}
InsertStatus::PasteSent | InsertStatus::Failed => {
log::warn!(
"[windows-ime] TSF unavailable; Unicode SendInput fallback failed and copy fallback failed"
);
}
}

status
}

#[cfg(any(target_os = "windows", test))]
fn finish_non_tsf_insertion_fallback<U, C>(
mut unicode_fallback: U,
mut copy_only_fallback: C,
) -> InsertStatus
where
U: FnMut() -> InsertStatus,
C: FnMut() -> InsertStatus,
{
match unicode_fallback() {
InsertStatus::Inserted => InsertStatus::Inserted,
InsertStatus::PasteSent | InsertStatus::CopiedFallback | InsertStatus::Failed => {
match copy_only_fallback() {
InsertStatus::CopiedFallback => InsertStatus::CopiedFallback,
// TextInserter::copy_fallback is copy-only: success is CopiedFallback.
// Treat any other status as failure so this helper never invents an insert.
InsertStatus::Inserted | InsertStatus::PasteSent | InsertStatus::Failed => {
InsertStatus::Failed
}
}
}
}
}

#[cfg(test)]
mod non_tsf_fallback_tests {
use super::finish_non_tsf_insertion_fallback;
use crate::types::InsertStatus;

#[test]
fn unicode_fallback_runs_before_copy_fallback() {
let mut copy_called = false;
let status = finish_non_tsf_insertion_fallback(
|| InsertStatus::Inserted,
|| {
copy_called = true;
InsertStatus::CopiedFallback
},
);

assert_eq!(status, InsertStatus::Inserted);
assert!(!copy_called);
}

#[test]
fn copy_fallback_runs_after_unicode_failure() {
let mut copy_called = false;
let status = finish_non_tsf_insertion_fallback(
|| InsertStatus::Failed,
|| {
copy_called = true;
InsertStatus::CopiedFallback
},
);

assert_eq!(status, InsertStatus::CopiedFallback);
assert!(copy_called);
}

#[test]
fn double_failure_does_not_pretend_text_was_copied() {
let mut copy_called = false;
let status = finish_non_tsf_insertion_fallback(
|| InsertStatus::Failed,
|| {
copy_called = true;
InsertStatus::Failed
},
);

assert_eq!(status, InsertStatus::Failed);
assert!(copy_called);
}
}

Expand Down
165 changes: 126 additions & 39 deletions openless-all/app/src-tauri/src/coordinator/dictation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,39 +114,7 @@ async fn run_streaming_polish(
// from what the user actually sees\"。
let (tx, rx) = std::sync::mpsc::channel::<String>();
let typer_handle = tokio::task::spawn_blocking(move || {
let mut typed_text = String::new();
let mut first_failure: Option<String> = None;
let mut pending = String::new();
while let Ok(delta) = rx.recv() {
pending.push_str(&delta);
let flush_at = std::time::Instant::now() + STREAMING_INSERT_FLUSH_INTERVAL;
loop {
let now = std::time::Instant::now();
if now >= flush_at {
break;
}
match rx.recv_timeout(flush_at.duration_since(now)) {
Ok(delta) => pending.push_str(&delta),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
first_failure =
flush_streaming_insert_buffer(&mut pending, &mut typed_text);
return (typed_text, first_failure);
}
}
}
first_failure = flush_streaming_insert_buffer(&mut pending, &mut typed_text);
if first_failure.is_some() {
// 一旦类型链路出错(如 Secure Input 启用),后续 delta 全部丢弃,但仍
// 把 mpsc drain 完,避免发送端阻塞。
while rx.recv().is_ok() {}
break;
}
}
if first_failure.is_none() {
first_failure = flush_streaming_insert_buffer(&mut pending, &mut typed_text);
}
(typed_text, first_failure)
drain_streaming_insert_deltas(rx, STREAMING_INSERT_FLUSH_INTERVAL)
});

// 3. 调流式润色,on_delta 塞 mpsc;should_cancel 检查 dictation 取消旗。
Expand Down Expand Up @@ -283,13 +251,77 @@ async fn run_streaming_polish(
}
}

fn drain_streaming_insert_deltas(
rx: std::sync::mpsc::Receiver<String>,
flush_interval: std::time::Duration,
) -> (String, Option<String>) {
drain_streaming_insert_deltas_with(rx, flush_interval, flush_streaming_insert_buffer)
}

fn drain_streaming_insert_deltas_with<F>(
rx: std::sync::mpsc::Receiver<String>,
flush_interval: std::time::Duration,
mut flush_pending: F,
) -> (String, Option<String>)
where
F: FnMut(&mut String, &mut String) -> Option<String>,
{
let mut typed_text = String::new();
let mut first_failure: Option<String> = None;
let mut pending = String::new();
while let Ok(delta) = rx.recv() {
pending.push_str(&delta);
let flush_at = std::time::Instant::now() + flush_interval;
loop {
let now = std::time::Instant::now();
if now >= flush_at {
break;
}
match rx.recv_timeout(flush_at.duration_since(now)) {
Ok(delta) => pending.push_str(&delta),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => break,
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
first_failure = flush_pending(&mut pending, &mut typed_text);
return (typed_text, first_failure);
}
}
}
first_failure = flush_pending(&mut pending, &mut typed_text);
if first_failure.is_some() {
// 一旦类型链路出错(如 Secure Input 启用),后续 delta 全部丢弃,但仍
// 把 mpsc drain 完,避免发送端阻塞。
while rx.recv().is_ok() {}
break;
}
}
if first_failure.is_none() {
first_failure = flush_pending(&mut pending, &mut typed_text);
}
(typed_text, first_failure)
}

fn flush_streaming_insert_buffer(pending: &mut String, typed_text: &mut String) -> Option<String> {
flush_streaming_insert_buffer_with(
pending,
typed_text,
crate::unicode_keystroke::type_unicode_chunk,
)
}

fn flush_streaming_insert_buffer_with<F>(
pending: &mut String,
typed_text: &mut String,
mut type_chunk: F,
) -> Option<String>
where
F: FnMut(&str) -> Result<usize, crate::unicode_keystroke::TypeError>,
{
if pending.is_empty() {
return None;
}
let delta = std::mem::take(pending);
let delta_chars = delta.chars().count();
match crate::unicode_keystroke::type_unicode_chunk(&delta) {
match type_chunk(&delta) {
Ok(typed_chars) => {
let appended = append_typed_prefix(typed_text, &delta, typed_chars);
if appended < delta_chars {
Expand Down Expand Up @@ -362,9 +394,7 @@ fn streaming_insert_eligible(
mode: PolishMode,
raw_uses_llm: bool,
) -> bool {
streaming_insert_enabled
&& !translation_active
&& (mode != PolishMode::Raw || raw_uses_llm)
streaming_insert_enabled && !translation_active && (mode != PolishMode::Raw || raw_uses_llm)
}

fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option<String> {
Expand Down Expand Up @@ -1728,8 +1758,8 @@ fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) ->
#[cfg(test)]
mod tests {
use super::{
append_typed_prefix, default_done_message, dictation_error_code,
finalize_polished_text, streaming_insert_eligible,
append_typed_prefix, default_done_message, drain_streaming_insert_deltas_with,
finalize_polished_text, flush_streaming_insert_buffer_with, streaming_insert_eligible,
};
use crate::types::{ChineseScriptPreference, CorrectionRule, InsertStatus, PolishMode};

Expand Down Expand Up @@ -1836,4 +1866,61 @@ mod tests {
Some("润色失败,已插入原文".to_string())
);
}

#[test]
fn streaming_insert_batches_queued_deltas_before_flush() {
let (tx, rx) = std::sync::mpsc::channel();
tx.send("你".to_string()).unwrap();
tx.send("好".to_string()).unwrap();
tx.send("🙂".to_string()).unwrap();
drop(tx);

let mut flushed = Vec::new();
let (typed, failure) = drain_streaming_insert_deltas_with(
rx,
std::time::Duration::from_millis(50),
|pending, typed_text| {
flushed.push(pending.clone());
typed_text.push_str(pending);
pending.clear();
None
},
);

assert_eq!(flushed, vec!["你好🙂".to_string()]);
assert_eq!(typed, "你好🙂");
assert_eq!(failure, None);
}

#[test]
fn flush_streaming_insert_buffer_keeps_partial_unicode_prefix() {
let mut pending = "a你🙂b".to_string();
let mut typed = String::new();

let failure = flush_streaming_insert_buffer_with(&mut pending, &mut typed, |_| {
Err(crate::unicode_keystroke::TypeError::Partial {
typed_chars: 3,
source: Box::new(platform_type_error()),
})
});

assert_eq!(typed, "a你🙂");
assert!(pending.is_empty());
assert!(failure.is_some());
}

#[cfg(target_os = "macos")]
fn platform_type_error() -> crate::unicode_keystroke::TypeError {
crate::unicode_keystroke::TypeError::EventAllocFailed
}

#[cfg(target_os = "windows")]
fn platform_type_error() -> crate::unicode_keystroke::TypeError {
crate::unicode_keystroke::TypeError::SendInputFailed("fail".into())
}

#[cfg(target_os = "linux")]
fn platform_type_error() -> crate::unicode_keystroke::TypeError {
crate::unicode_keystroke::TypeError::EnigoText("fail".into())
}
}
21 changes: 18 additions & 3 deletions openless-all/app/src-tauri/src/insertion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -386,15 +386,30 @@ fn insertion_success_status() -> InsertStatus {

#[cfg(target_os = "windows")]
mod windows_unicode {
use std::time::Duration;

use windows::Win32::UI::Input::KeyboardAndMouse::{
SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYBD_EVENT_FLAGS, KEYEVENTF_KEYUP,
KEYEVENTF_UNICODE, VIRTUAL_KEY,
};

const SENDINPUT_CHUNK_CHARS: usize = 16;
const SENDINPUT_CHUNK_DELAY: Duration = Duration::from_millis(12);

pub fn send_text(text: &str) -> Result<(), String> {
for unit in text.encode_utf16() {
send_utf16_unit(unit, false)?;
send_utf16_unit(unit, true)?;
let mut sent_in_chunk = 0usize;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
let mut buf = [0u16; 2];
for unit in ch.encode_utf16(&mut buf) {
send_utf16_unit(*unit, false)?;
send_utf16_unit(*unit, true)?;
}
sent_in_chunk += 1;
if sent_in_chunk >= SENDINPUT_CHUNK_CHARS && chars.peek().is_some() {
std::thread::sleep(SENDINPUT_CHUNK_DELAY);
sent_in_chunk = 0;
}
}
Ok(())
}
Expand Down
3 changes: 2 additions & 1 deletion openless-all/app/src-tauri/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -544,7 +544,8 @@ pub struct UserPreferences {
/// 行为一致,不破坏既有用户。
#[serde(default)]
pub paste_shortcut: PasteShortcut,
/// Windows: 是否允许 TSF 失败后继续使用 SendInput / 粘贴类非 TSF 兜底。
/// Windows: 是否允许 TSF 失败后继续使用分批 Unicode SendInput / 剪贴板兜底。
/// Unicode SendInput 失败时才复制到剪贴板,避免文本丢失。
/// 默认开启以保持可用性;关闭后可验证文本是否真正由 TSF 上屏。
#[serde(default = "default_true")]
pub allow_non_tsf_insertion_fallback: bool,
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ export const en: typeof zhCN = {
comboClear: 'Clear',
comboConflict: 'This shortcut combination is not available',
allowNonTsfFallbackLabel: 'Allow non-TSF fallback',
allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, allow Unicode SendInput / shortcut paste.',
allowNonTsfFallbackDesc: 'Windows: when TSF insertion fails, use paced Unicode SendInput; if that still fails, copy the text to the clipboard.',
historyGroupTitle: 'History & context',
historyRetentionLabel: 'History retention (days)',
historyRetentionDesc: 'Entries older than this are pruned on new writes; 0 = no time-based pruning.',
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ export const ja: typeof zhCN = {
comboClear: 'クリア',
comboConflict: 'このショートカットの組み合わせは使用できません',
allowNonTsfFallbackLabel: '非 TSF フォールバックを許可',
allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時に Unicode SendInput / ショートカットペーストへの切替を許可。',
allowNonTsfFallbackDesc: 'Windows:TSF 入力が失敗した時は分割した Unicode SendInput を使い、それも失敗した場合はクリップボードへコピーします。',
historyGroupTitle: '履歴とコンテキスト',
historyRetentionLabel: '履歴保持期間(日)',
historyRetentionDesc: '保持日数を超えた履歴は新規書き込み時に削除されます。0 = 時間で削除しない。',
Expand Down
2 changes: 1 addition & 1 deletion openless-all/app/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ export const ko: typeof zhCN = {
comboClear: '지우기',
comboConflict: '이 단축키 조합은 사용할 수 없습니다',
allowNonTsfFallbackLabel: '비 TSF 폴백 허용',
allowNonTsfFallbackDesc: 'Windows: TSF 입력 실패 시 Unicode SendInput / 단축키 붙여넣기로 전환 허용.',
allowNonTsfFallbackDesc: 'Windows: TSF 입력이 실패하면 분할된 Unicode SendInput을 사용하고, 그래도 실패하면 텍스트를 클립보드에 복사합니다.',
historyGroupTitle: '기록 및 컨텍스트',
historyRetentionLabel: '기록 보관 기간(일)',
historyRetentionDesc: '보관 기간을 초과한 기록은 새 항목 작성 시 정리됩니다. 0 = 시간 기반 정리 비활성화.',
Expand Down
Loading
Loading