501 lines
18 KiB
Rust
501 lines
18 KiB
Rust
//! 取词:智能路径(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);
|
||
}
|