//! 取词:智能路径(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, /// 取词方式:"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 { 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 { 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::().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 { 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 { super::uia_capture::read_selection() } #[cfg(not(windows))] fn uia_selection() -> Option { 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::() as i32); } } /// 让修饰键回到「都没按」的状态,返回被**强制**松开的键(调用方负责按回去)。 /// /// 两步走,顺序很重要: /// 1. **先等用户自然松开**。全局快捷键在按键**按下**的瞬间触发,此刻 Alt 一定还按着; /// 绝大多数情况用户几十毫秒内就松手了,等一下既解决了问题,又完全不用合成按键 /// (合成 Alt 抬起有让目标应用进入菜单栏模式的风险)。 /// 2. 超时仍未松开(长按、卡键)才合成抬起事件。只处理 Alt/Shift:合成 Win 抬起 /// 会触发开始菜单,代价远大于收益——Win 参与的组合键本就罕见。 fn neutralise_modifiers() -> Vec { let deadline = Instant::now() + MOD_RELEASE_WAIT; let all: Vec = 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) -> 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 = 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::() as i32, ); } } /// 取前台窗口句柄与所属进程名。 fn foreground_window_process() -> (i64, Option) { 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 = 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::() 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); }