翻译模块

This commit is contained in:
zhongluofeng
2026-09-17 17:32:20 +08:00
parent 79c43d5340
commit f6c1cc250e
47 changed files with 10822 additions and 104 deletions
@@ -0,0 +1,500 @@
//! 取词:智能路径(UIA 直读)优先,兼容路径(模拟 Ctrl+C)兜底。
//!
//! 整个流程在**调用方的阻塞线程**上执行(最长约 1s)。**严禁在主线程调用**:
//! 全局快捷键回调与 UI 线程都不该被这段等待卡住。命令层用 `spawn_blocking` 包装,
//! 快捷键回调自行 `std::thread::spawn`。
use std::time::{Duration, Instant};
use serde::Serialize;
use specta::Type;
use tauri::{AppHandle, Manager};
use crate::clipboard::reader::{read_clipboard, write_dib, write_files, write_text, ClipData};
use crate::clipboard::ClipboardManager;
use crate::translate::engines::{ErrorKind, TranslateError};
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::DataExchange::GetClipboardSequenceNumber;
use windows_sys::Win32::System::Threading::{
OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
};
use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
GetAsyncKeyState, SendInput, INPUT, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, VK_CONTROL,
};
use windows_sys::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetWindowThreadProcessId,
};
/// 模拟按键后等待剪贴板更新的上限。
///
/// 300ms 偏短:浏览器、Electron 应用、带插件的编辑器在复制前还要走一遍自己的
/// 命令分发,慢一点的直接超时。放宽到 600ms 后失败率明显下降,而用户感知的
/// 「按下到弹出」延迟仍在可接受范围(取词是主动行为,不是输入反馈)。
const PASTE_WAIT: Duration = Duration::from_millis(600);
/// 轮询间隔
const POLL_INTERVAL: Duration = Duration::from_millis(10);
/// 等用户自然松开修饰键的时间(见 [`neutralise_modifiers`]
const MOD_RELEASE_WAIT: Duration = Duration::from_millis(400);
const VK_C: u16 = 0x43;
const VK_V: u16 = 0x56;
const VK_INSERT: u16 = 0x2D;
const VK_MENU: u16 = 0x12; // Alt
const VK_SHIFT: u16 = 0x10;
const VK_LWIN: u16 = 0x5B;
const VK_RWIN: u16 = 0x5C;
/// 会「污染」Ctrl+C 的修饰键:按下时目标应用看到的是 Alt+Ctrl+C 之类,
/// 没有任何应用把它当复制。Alt 与 Shift 会被强制松开(见 [`neutralise_modifiers`])。
const BLOCKING_MODIFIERS: [u16; 2] = [VK_MENU, VK_SHIFT];
/// 参与「等自然松开」但不强制合成的键:合成 Win 抬起会触发开始菜单,代价太大。
const WAIT_ONLY_MODIFIERS: [u16; 2] = [VK_LWIN, VK_RWIN];
/// 取词参数(来自设置)
#[derive(Debug, Clone)]
pub struct CaptureRequest {
/// 文本字符数上限,超出直接拒绝而不是发一个巨大的请求
pub max_chars: usize,
/// 取词后是否还原剪贴板
pub restore_clipboard: bool,
/// 跳过取词的进程名黑名单(终端类)
pub blacklist: Vec<String>,
/// 取词方式:"smart"(UIA 直读优先,失败退回模拟按键)| "compat"(只用模拟按键)
pub mode: String,
}
/// 取词结果
#[derive(Debug, Clone, Serialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct CaptureOutcome {
pub text: String,
/// 取词来源:"clipboard"(模拟 Ctrl+C
pub source: String,
/// 取词时的前台窗口句柄(P3 回填替换选区时要用,事后无法补齐)
pub source_hwnd: i64,
/// 前台窗口所属进程名(用于提示与黑名单判定)
pub source_process: String,
/// 剪贴板是否被成功还原。false 有两种情况:备份时剪贴板是**不支持的格式**
/// (如仅含 HTML/RTF,无法原样写回),或还原本身失败——此时选区文本会留在剪贴板上。
pub restored_clipboard: bool,
}
/// 执行一次取词。**阻塞**,见模块头注释。
///
/// 并发互斥:连按快捷键(以为没反应再按一次是常见操作)会让两套
/// 「备份 → Ctrl+C → 轮询 → 还原」并发执行,互相覆盖剪贴板状态。
/// 在途时后来者直接放弃,静默返回。
pub fn capture_selection(
app: &AppHandle,
req: &CaptureRequest,
) -> Result<CaptureOutcome, TranslateError> {
use std::sync::atomic::{AtomicBool, Ordering};
static IN_FLIGHT: AtomicBool = AtomicBool::new(false);
if IN_FLIGHT.swap(true, Ordering::SeqCst) {
return Err(TranslateError::new(
ErrorKind::Empty,
"上一次取词仍在进行中",
));
}
let result = capture_selection_inner(app, req);
IN_FLIGHT.store(false, Ordering::SeqCst);
result
}
fn capture_selection_inner(
app: &AppHandle,
req: &CaptureRequest,
) -> Result<CaptureOutcome, TranslateError> {
let (hwnd, process) = foreground_window_process();
let process_name = process.clone().unwrap_or_default();
if !process_name.is_empty()
&& req
.blacklist
.iter()
.any(|b| b.trim().eq_ignore_ascii_case(&process_name))
{
return Err(TranslateError::unsupported(format!(
"{process_name}」中 Ctrl+C 是中断信号,无法用于取词。\
请选中文字后复制,再用「翻译剪贴板」快捷键。"
)));
}
// ===== 智能路径:UIA 直读(不动键盘、不碰剪贴板)=====
// 命中即返回:既避免了修饰键残留 / 提权窗口 / 剪贴板占用这三类兼容路径问题,
// 也快得多(一次跨进程 COM 调用 vs 一次 600ms 的剪贴板等待)。
if req.mode.trim() != "compat" {
if let Some(text) = uia_selection() {
crate::logger::log_info(
"translate",
&format!(
"取词:UIA 直读命中({} 字符),前台进程 {:?}",
text.chars().count(),
process_name
),
);
return finish_text(req, text, hwnd, &process_name, true);
}
}
// 屏蔽窗口覆盖「Ctrl+C 覆盖剪贴板 → 读走 → 还原」的全过程。
// 守卫析构时自动解除,提前 return 也不会漏。
let suppress = app.try_state::<ClipboardManager>().map(|m| m.suppress());
let _guard = suppress.as_ref().map(|s| s.burst());
let backup = read_clipboard();
let before = clipboard_seq();
// 关键:先让修饰键回到「都没按」的状态,再发 Ctrl+C。
// 全局快捷键是在**按下**的瞬间触发的,此刻 Alt 必然还被物理按住;
// 直接补发 Ctrl+C,目标应用收到的是 Alt+Ctrl+C —— 复制不会发生,
// 于是必然走到下面的「取词超时」。这是兼容路径取不到词最主要的原因。
let forced = neutralise_modifiers();
send_ctrl_c();
let mut updated = wait_clipboard_change(before, PASTE_WAIT);
// 重试一次:部分应用首次按键被自身的输入法/菜单状态吃掉,第二次才真正复制。
// 复制是幂等的,多按一次没有副作用,比直接判定失败划算。
if !updated {
send_ctrl_c();
updated = wait_clipboard_change(before, PASTE_WAIT / 2);
}
// Ctrl+Insert 兜底:个别应用对合成的 Ctrl+C 不响应,但认经典的复制和弦
// (控制台/部分老程序对 Ctrl+Insert 的处理路径也与 Ctrl+C 不同)。
// 无选区时该组合键无副作用,与「复制是幂等的」同理。
if !updated {
crate::logger::log_info(
"translate",
&format!("取词:Ctrl+C 未更新剪贴板(前台进程 {process_name:?}),改试 Ctrl+Insert"),
);
send_ctrl_insert();
updated = wait_clipboard_change(before, PASTE_WAIT / 2);
}
let clip = if updated { read_clipboard() } else { None };
// 键盘状态尽早复原:越早把 Alt 按回去,越不容易让目标应用进入菜单栏模式
restore_modifiers(&forced);
if !updated {
return Err(TranslateError::new(
ErrorKind::Empty,
"取词失败:模拟 Ctrl+C 后剪贴板没有更新。\
常见原因是目标窗口以管理员权限运行(系统会丢弃来自普通权限程序的模拟按键),\
或该应用不支持复制选区。可改用「翻译剪贴板」:先复制,再按对应快捷键。"
.to_string(),
));
}
// 剪贴板已被目标应用覆盖为选区内容。**先还原再做一切判定**:
// 还原必须覆盖所有后续路径(非文本 / 空文本 / 超限 / 成功),
// 否则「取词失败」的代价是用户剪贴板被悄悄换掉,与 restore_clipboard 设置矛盾。
let restored = if req.restore_clipboard {
restore_clipboard(backup)
} else {
false
};
// 还原本身也改了剪贴板。虽然还在屏蔽窗口内,但窗口解除后监听可能才轮到这一次变化,
// 于是额外精确记账一次,把边界情况的漏网也堵上。
if restored {
if let Some(s) = suppress.as_ref() {
s.mark_seq(clipboard_seq());
}
}
let text = match clip {
Some(ClipData::Text(t)) => t,
Some(_) => {
return Err(TranslateError::new(
ErrorKind::Empty,
"取到的内容不是文本(选区可能是图片或文件)",
))
}
None => {
return Err(TranslateError::new(
ErrorKind::Empty,
"未获取到选中文本:目标窗口可能不允许复制,或当前没有选中任何文字",
))
}
};
finish_text(
req,
text,
hwnd,
&process_name,
// UIA 路径:剪贴板从头到尾没被碰过
restored,
)
}
/// 取到文本后的公共收尾:裁剪 → 空判定 → 字数上限 → 组装结果。
///
/// 两条取词路径(UIA 直读 / 模拟 Ctrl+C)都必须过这套校验,否则「字数上限」
/// 只对其中一条生效——那正是配置里写「上限」却仍被绕过的原因。
fn finish_text(
req: &CaptureRequest,
text: String,
hwnd: i64,
process_name: &str,
clip_intact: bool,
) -> Result<CaptureOutcome, TranslateError> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Err(TranslateError::new(
ErrorKind::Empty,
"未获取到选中文本(取到的内容为空)",
));
}
let char_count = trimmed.chars().count();
if req.max_chars > 0 && char_count > req.max_chars {
return Err(TranslateError::unsupported(format!(
"选中内容 {char_count} 字符,超过取词上限({})。\
请在「翻译 → 设置 → 划词翻译」中调高上限,或改用主面板翻译。",
req.max_chars
)));
}
Ok(CaptureOutcome {
text: trimmed.to_string(),
source: "selection".to_string(),
source_hwnd: hwnd,
source_process: process_name.to_string(),
restored_clipboard: clip_intact,
})
}
fn clipboard_seq() -> u32 {
unsafe { GetClipboardSequenceNumber() }
}
/// UIA 直读选区(智能路径)。`windows` crate 只在 Windows 目标上参与构建,
/// 因此非 Windows 目标这里直接返回 None(等价于「读不到」→ 走兼容路径)。
#[cfg(windows)]
fn uia_selection() -> Option<String> {
super::uia_capture::read_selection()
}
#[cfg(not(windows))]
fn uia_selection() -> Option<String> {
None
}
/// 等剪贴板序号变化(即目标应用完成了复制)。
fn wait_clipboard_change(before: u32, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
if clipboard_seq() != before {
return true;
}
std::thread::sleep(POLL_INTERVAL);
}
false
}
fn key_down(vk: u16) -> bool {
(unsafe { GetAsyncKeyState(vk as i32) } as u16 & 0x8000) != 0
}
/// 发送单个按键事件(`up` 为真表示抬起)。
fn send_key(vk: u16, up: bool) {
let mut input: INPUT = unsafe { std::mem::zeroed() };
input.r#type = INPUT_KEYBOARD;
input.Anonymous.ki = KEYBDINPUT {
wVk: vk,
wScan: 0,
dwFlags: if up { KEYEVENTF_KEYUP } else { 0 },
time: 0,
dwExtraInfo: 0,
};
unsafe {
SendInput(1, &input, std::mem::size_of::<INPUT>() as i32);
}
}
/// 让修饰键回到「都没按」的状态,返回被**强制**松开的键(调用方负责按回去)。
///
/// 两步走,顺序很重要:
/// 1. **先等用户自然松开**。全局快捷键在按键**按下**的瞬间触发,此刻 Alt 一定还按着;
/// 绝大多数情况用户几十毫秒内就松手了,等一下既解决了问题,又完全不用合成按键
/// (合成 Alt 抬起有让目标应用进入菜单栏模式的风险)。
/// 2. 超时仍未松开(长按、卡键)才合成抬起事件。只处理 Alt/Shift:合成 Win 抬起
/// 会触发开始菜单,代价远大于收益——Win 参与的组合键本就罕见。
fn neutralise_modifiers() -> Vec<u16> {
let deadline = Instant::now() + MOD_RELEASE_WAIT;
let all: Vec<u16> = BLOCKING_MODIFIERS
.iter()
.chain(WAIT_ONLY_MODIFIERS.iter())
.copied()
.collect();
while Instant::now() < deadline {
if !all.iter().copied().any(key_down) {
break;
}
std::thread::sleep(Duration::from_millis(8));
}
let mut forced = Vec::new();
for vk in BLOCKING_MODIFIERS {
if key_down(vk) {
send_key(vk, true);
forced.push(vk);
}
}
if !forced.is_empty() {
// 给目标应用一点时间处理抬起事件,避免紧接着的 Ctrl+C 被合并成 Alt+Ctrl+C
std::thread::sleep(Duration::from_millis(10));
}
forced
}
/// 把 [`neutralise_modifiers`] 强制松开的键按回去,让用户自己松手时状态一致。
///
/// **只还原仍然物理按住的键**:用户可能在等待期间就松手了,此时再合成一个 keydown
/// 会把 Alt 留在「按下」状态——他下一次敲任意键都会变成 Alt+某键,比不还原糟糕得多。
fn restore_modifiers(keys: &[u16]) {
for vk in keys {
if key_down(*vk) {
send_key(*vk, false);
}
}
}
/// 把备份内容原样写回。仅支持文本 / 图片 / 文件三种格式——其余格式(HTML/RTF 等)
/// 在备份阶段就读不出来,因此无法还原,返回 false 而不是假装成功。
fn restore_clipboard(backup: Option<ClipData>) -> bool {
match backup {
Some(ClipData::Text(t)) => write_text(&t),
Some(ClipData::Image { dib, .. }) => write_dib(&dib),
Some(ClipData::Files(files)) => write_files(&files),
None => false,
}
}
/// 模拟 Ctrl+V(译文回填替换选区用)。
///
/// 与 [`send_ctrl_c`] 相同的修饰键处理:若用户仍按着 Ctrl,只补发 V 键,
/// 避免把用户的修饰键一并释放。
pub fn send_ctrl_v() {
let ctrl_held = (unsafe { GetAsyncKeyState(VK_CONTROL as i32) } as u16 & 0x8000) != 0;
let mut inputs: Vec<INPUT> = Vec::with_capacity(4);
let mut push = |vk: u16, up: bool| {
let mut input: INPUT = unsafe { std::mem::zeroed() };
input.r#type = INPUT_KEYBOARD;
input.Anonymous.ki = KEYBDINPUT {
wVk: vk,
wScan: 0,
dwFlags: if up { KEYEVENTF_KEYUP } else { 0 },
time: 0,
dwExtraInfo: 0,
};
inputs.push(input);
};
// 取词悬浮窗是非激活的,前台窗口仍是原应用:Ctrl+V 会落在原选区上
let own_ctrl = !ctrl_held;
if own_ctrl {
push(VK_CONTROL, false);
}
push(VK_V, false);
push(VK_V, true);
if own_ctrl {
push(VK_CONTROL, true);
}
unsafe {
SendInput(
inputs.len() as u32,
inputs.as_ptr(),
std::mem::size_of::<INPUT>() as i32,
);
}
}
/// 取前台窗口句柄与所属进程名。
fn foreground_window_process() -> (i64, Option<String>) {
unsafe {
let hwnd = GetForegroundWindow();
if hwnd == 0 {
return (0, None);
}
let mut pid: u32 = 0;
GetWindowThreadProcessId(hwnd, &mut pid);
let hwnd_i64 = hwnd as i64;
if pid == 0 {
return (hwnd_i64, None);
}
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if handle == 0 {
// 目标进程权限更高时连句柄都拿不到——这本身就是一个有效信号,
// 但不必在这里下结论,让后续「取词超时」去提示
return (hwnd_i64, None);
}
let mut buf = [0u16; 512];
let mut size = buf.len() as u32;
let ok = QueryFullProcessImageNameW(handle, 0, buf.as_mut_ptr(), &mut size);
CloseHandle(handle);
if ok == 0 {
return (hwnd_i64, None);
}
let path = String::from_utf16_lossy(&buf[..size as usize]);
let name = path
.rsplit(['\\', '/'])
.next()
.unwrap_or(path.as_str())
.to_string();
(hwnd_i64, if name.is_empty() { None } else { Some(name) })
}
}
/// 模拟「Ctrl + 某键」。
///
/// 关键细节:若用户此刻**正按着 Ctrl**(例如取词快捷键本身带 Ctrl),
/// 我们发出的 Ctrl 抬起会把用户的修饰键一并释放,造成「按一次快捷键后
/// Ctrl 行为异常」。因此先探测 Ctrl 的物理状态,只补发缺失的那一段。
fn send_ctrl_chord(vk: u16) {
// 高位为 1 表示当前处于按下状态
let ctrl_held = (unsafe { GetAsyncKeyState(VK_CONTROL as i32) } as u16 & 0x8000) != 0;
let mut inputs: Vec<INPUT> = Vec::with_capacity(4);
let mut push = |vk: u16, up: bool| {
let mut input: INPUT = unsafe { std::mem::zeroed() };
input.r#type = INPUT_KEYBOARD;
input.Anonymous.ki = KEYBDINPUT {
wVk: vk,
wScan: 0,
dwFlags: if up { KEYEVENTF_KEYUP } else { 0 },
time: 0,
dwExtraInfo: 0,
};
inputs.push(input);
};
let own_ctrl = !ctrl_held;
if own_ctrl {
push(VK_CONTROL, false);
}
push(vk, false);
push(vk, true);
if own_ctrl {
push(VK_CONTROL, true);
}
unsafe {
SendInput(
inputs.len() as u32,
inputs.as_ptr(),
std::mem::size_of::<INPUT>() as i32,
);
}
}
/// 模拟 Ctrl+C。
fn send_ctrl_c() {
send_ctrl_chord(VK_C);
}
/// 模拟 Ctrl+Insert:部分应用对合成的 Ctrl+C 不响应,但认这条经典复制和弦。
fn send_ctrl_insert() {
send_ctrl_chord(VK_INSERT);
}