//! Tauri 命令:截图模块 //! //! 命令清单: //! - screenshot_capture_fullscreen:捕获虚拟屏并存入静态(不做 PNG 编码),返回捕获时刻光标坐标 //! - screenshot_get_fullscreen_bmp:取出全屏捕获的 BMP 原始字节(raw IPC,覆盖层显示用,不移除) //! - screenshot_fullscreen_png:全屏捕获编码 PNG base64 并清除(全屏截图进编辑器用) //! - screenshot_clear_fullscreen:清除静态全屏捕获(覆盖层关闭时) //! - screenshot_crop_stored:按物理像素裁剪已存储的全屏捕获 //! - screenshot_pick_list:枚举可拾取顶层窗口(Z 序,前端缓存后本地命中测试) //! - screenshot_show_overlay:一次 IPC 完成覆盖层 show + focus(关键路径减少往返) //! - screenshot_enum_windows:枚举可见顶层窗口 //! - screenshot_capture_window:按 hwnd 捕获指定窗口 //! - screenshot_scroll_capture / screenshot_scroll_start / screenshot_scroll_finish / //! screenshot_scroll_cancel:滚动截图(同步一次调用 / 会话式:启动、完成、取消) //! - screenshot_set_scroll_hole:滚动模式遮罩挖孔(防 Chromium 遮挡检测冻结目标窗口) //! - screenshot_take_editor_image_raw:取出编辑器图片(raw IPC,滚动截图会话直接写入) //! - screenshot_compose_png / screenshot_compose_copy:raw RGBA → PNG(仅编码 / 剪贴板+编码) //! - screenshot_copy_image:写入剪贴板(CF_DIB) //! - screenshot_save_png:写入文件 //! - screenshot_disable_transitions:禁用窗口显示/隐藏过渡动画(消除覆盖层缩放动画) use super::{CaptureData, CaptureStart, WindowInfo}; use tauri::{AppHandle, Emitter, Manager}; /// 禁用指定窗口(按 label 查找)的显示/隐藏过渡动画,消除覆盖层出现/消失时的缩放动画 #[tauri::command] #[specta::specta] pub async fn screenshot_disable_transitions( app: tauri::AppHandle, label: String, ) -> Result<(), String> { #[cfg(windows)] { use raw_window_handle::HasWindowHandle; let win = app .get_webview_window(&label) .ok_or_else(|| format!("窗口不存在: {}", label))?; let handle = win .window_handle() .map_err(|e| format!("获取窗口句柄失败: {}", e))?; match handle.as_raw() { raw_window_handle::RawWindowHandle::Win32(h) => { super::capture::disable_window_transitions(h.hwnd.get() as isize) } _ => Ok(()), } } #[cfg(not(windows))] { let _ = (app, label); Ok(()) } } /// 注册(或切换)截图全局快捷键。传入空字符串则禁用快捷键。 #[tauri::command] #[specta::specta] pub async fn screenshot_register_shortcut( app: tauri::AppHandle, shortcut: String, ) -> Result<(), String> { crate::shortcut::register_shortcut(&app, "截图", &shortcut, |a| { let _ = a.emit(crate::constants::events::SCREENSHOT_SHORTCUT, ()); }) } /// 注销截图全局快捷键 #[tauri::command] #[specta::specta] pub async fn screenshot_unregister_shortcut(app: tauri::AppHandle) -> Result<(), String> { crate::shortcut::unregister_shortcut(&app, "截图"); Ok(()) } /// 注册(或切换)贴图全局快捷键。传入空字符串则禁用快捷键。 /// 按下时 emit 'screenshot-pin-shortcut',由前端切换贴图窗口显示/隐藏。 #[tauri::command] #[specta::specta] pub async fn screenshot_register_pin_shortcut( app: tauri::AppHandle, shortcut: String, ) -> Result<(), String> { crate::shortcut::register_shortcut(&app, "贴图", &shortcut, |a| { let _ = a.emit(crate::constants::events::SCREENSHOT_PIN_SHORTCUT, ()); }) } /// 注销贴图全局快捷键 #[tauri::command] #[specta::specta] pub async fn screenshot_unregister_pin_shortcut(app: tauri::AppHandle) -> Result<(), String> { crate::shortcut::unregister_shortcut(&app, "贴图"); Ok(()) } /// 捕获整个虚拟屏(多显示器拼接)存入静态,不做 PNG 编码。 /// 同时返回捕获时刻的光标物理坐标(覆盖层据此做初始窗口拾取,省一次 IPC 往返)。 #[tauri::command] #[specta::specta] pub async fn screenshot_capture_fullscreen() -> Result { #[cfg(windows)] { // 屏幕捕获涉及 GDI 调用,放线程池避免阻塞 async 调度 tauri::async_runtime::spawn_blocking(|| { let img = super::capture::capture_virtual_screen()?; super::capture::store_fullscreen(img)?; let (cursor_x, cursor_y) = super::capture::cursor_pos().unwrap_or((0, 0)); Ok(CaptureStart { cursor_x, cursor_y }) }) .await .map_err(|e| format!("捕获任务失败: {}", e))? } #[cfg(not(windows))] { Err("截图仅支持 Windows".into()) } } /// 取出全屏捕获的 BMP 原始字节(raw IPC → 前端 ArrayBuffer),不移除 #[tauri::command] pub async fn screenshot_get_fullscreen_bmp() -> Result { #[cfg(windows)] { tauri::async_runtime::spawn_blocking(|| { let bmp = super::capture::fullscreen_bmp()?; Ok(tauri::ipc::Response::new(bmp)) }) .await .map_err(|e| format!("读取捕获失败: {}", e))? } #[cfg(not(windows))] { Err("截图仅支持 Windows".into()) } } /// 全屏捕获编码为 PNG base64 并清除(全屏截图直接进编辑器) #[tauri::command] #[specta::specta] pub async fn screenshot_fullscreen_png() -> Result { #[cfg(windows)] { tauri::async_runtime::spawn_blocking(|| super::capture::fullscreen_png()) .await .map_err(|e| format!("编码任务失败: {}", e))? } #[cfg(not(windows))] { Err("截图仅支持 Windows".into()) } } /// 清除静态全屏捕获(覆盖层关闭/取消时释放内存) #[tauri::command] #[specta::specta] pub async fn screenshot_clear_fullscreen() -> Result<(), String> { #[cfg(windows)] { super::capture::clear_fullscreen(); Ok(()) } #[cfg(not(windows))] { Ok(()) } } /// 按物理像素坐标裁剪已存储的全屏捕获 #[tauri::command] #[specta::specta] pub async fn screenshot_crop_stored( x: i32, y: i32, w: i32, h: i32, ) -> Result { #[cfg(windows)] { tauri::async_runtime::spawn_blocking(move || { super::capture::crop_stored(x, y, w, h) }) .await .map_err(|e| format!("裁剪任务失败: {}", e))? } #[cfg(not(windows))] { let _ = (x, y, w, h); Err("截图仅支持 Windows".into()) } } /// 裁剪已存储的全屏捕获并直接写入剪贴板(一次 IPC 完成"裁剪+复制") #[tauri::command] #[specta::specta] pub async fn screenshot_crop_copy_stored( x: i32, y: i32, w: i32, h: i32, ) -> Result { #[cfg(windows)] { tauri::async_runtime::spawn_blocking(move || { super::capture::crop_copy_stored(x, y, w, h) }) .await .map_err(|e| format!("裁剪复制任务失败: {}", e))? } #[cfg(not(windows))] { let _ = (x, y, w, h); Err("截图仅支持 Windows".into()) } } /// 枚举可拾取的顶层窗口(Z 序顶→底,排除本进程/不可见/工具窗口)。 /// 前端在截图开始时缓存列表,鼠标移动时在 JS 侧本地命中测试,消除逐帧 IPC 往返。 #[tauri::command] #[specta::specta] pub async fn screenshot_pick_list() -> Result, String> { #[cfg(windows)] { tauri::async_runtime::spawn_blocking(super::capture::pick_windows) .await .map_err(|e| format!("枚举失败: {}", e)) } #[cfg(not(windows))] { Ok(vec![]) } } /// 一次 IPC 完成指定窗口的显示与聚焦(截图覆盖层显示关键路径,减少串行往返) #[tauri::command] #[specta::specta] pub async fn screenshot_show_overlay( app: tauri::AppHandle, label: String, ) -> Result<(), String> { let win = app .get_webview_window(&label) .ok_or_else(|| format!("窗口不存在: {}", label))?; win.show().map_err(|e| format!("显示窗口失败: {}", e))?; win.set_focus().map_err(|e| format!("聚焦窗口失败: {}", e))?; Ok(()) } /// 获取当前鼠标物理屏幕坐标(贴图窗口拖动跟随等场景使用) #[tauri::command] #[specta::specta] pub async fn screenshot_cursor_pos() -> Result<(i32, i32), String> { #[cfg(windows)] { tauri::async_runtime::spawn_blocking(super::capture::cursor_pos) .await .map_err(|e| format!("查询任务失败: {}", e))? } #[cfg(not(windows))] { Err("截图仅支持 Windows".into()) } } /// 枚举所有可见顶层窗口 #[tauri::command] #[specta::specta] pub async fn screenshot_enum_windows() -> Result, String> { #[cfg(windows)] { tauri::async_runtime::spawn_blocking(|| super::capture::enum_visible_windows()) .await .map_err(|e| format!("枚举失败: {}", e)) } #[cfg(not(windows))] { Ok(vec![]) } } /// 按 hwnd 捕获指定窗口 #[tauri::command] #[specta::specta] pub async fn screenshot_capture_window(hwnd: isize) -> Result { #[cfg(windows)] { tauri::async_runtime::spawn_blocking(move || { let img = super::capture::capture_window(hwnd)?; Ok(super::CaptureData { png_base64: super::capture::base64_encode(&img.png), width: img.width, height: img.height, }) }) .await .map_err(|e| format!("捕获任务失败: {}", e))? } #[cfg(not(windows))] { let _ = hwnd; Err("截图仅支持 Windows".into()) } } /// 滚动截图:从窗口当前滚动位置向下拼接到底部,返回超长 PNG。 /// `region` 为 Some 时仅在框选区域(屏幕物理坐标)内捕捉,宽 = 选区宽; /// 为 None 时捕捉整个客户区。结束后会把窗口滚回起始位置,不打扰用户。 #[tauri::command] #[specta::specta] pub async fn screenshot_scroll_capture( hwnd: isize, region: Option, ) -> Result { #[cfg(windows)] { tauri::async_runtime::spawn_blocking(move || { super::scroll_capture::scroll_capture(hwnd, region) }) .await .map_err(|e| format!("滚动截图任务失败: {}", e))? } #[cfg(not(windows))] { let _ = hwnd; let _ = region; Err("截图仅支持 Windows".into()) } } /// 启动滚动截图会话(后台线程持续捕捉拼接,实时推进度事件)。 /// `auto = true` 为自动滚动(线程主动下滚拼到底部);`false` 为手动(等用户滚动窗口)。 #[tauri::command] #[specta::specta] pub fn screenshot_scroll_start( app: AppHandle, hwnd: isize, region: Option, auto: bool, ) -> Result<(), String> { #[cfg(windows)] { super::scroll_session::start(app, hwnd, region, auto) } #[cfg(not(windows))] { let _ = (app, hwnd, region, auto); Err("截图仅支持 Windows".into()) } } /// 结束滚动截图会话并导出结果。 #[tauri::command] #[specta::specta] pub fn screenshot_scroll_finish() -> Result<(), String> { #[cfg(windows)] { super::scroll_session::finish() } #[cfg(not(windows))] { Err("截图仅支持 Windows".into()) } } /// 取消滚动截图会话(不导出)。 #[tauri::command] #[specta::specta] pub fn screenshot_scroll_cancel() -> Result<(), String> { #[cfg(windows)] { super::scroll_session::cancel_now() } #[cfg(not(windows))] { Err("截图仅支持 Windows".into()) } } /// 滚动模式遮罩挖孔:在截图覆盖层窗口上挖出选区带的真孔(region = None 时复位整窗)。 /// /// Chromium 系浏览器(Edge/Chrome)的窗口遮挡检测会把被完全覆盖的窗口标记为 /// occluded 并暂停渲染——滚动截图时覆盖层铺满全屏,网页"看起来完全不滚动"。 /// 挖孔后目标窗口仅部分被覆盖,恢复渲染与滚轮响应(详见 capture::set_scroll_hole)。 /// 进入滚动模式时带选区调用,会话结束/新一轮截图开始时必须传 None 复位。 #[tauri::command] #[specta::specta] pub fn screenshot_set_scroll_hole( app: AppHandle, region: Option, ) -> Result<(), String> { #[cfg(windows)] { use raw_window_handle::HasWindowHandle; let mut hwnds: Vec = Vec::new(); for (label, win) in app.webview_windows() { if label.starts_with(crate::constants::windows::SCREENSHOT_OVERLAY) { let hwnd = win .window_handle() .ok() .and_then(|h| match h.as_raw() { raw_window_handle::RawWindowHandle::Win32(w) => { Some(w.hwnd.get() as isize) } _ => None, }); if let Some(h) = hwnd { hwnds.push(h); } } } if hwnds.is_empty() { return Err("截图覆盖层窗口不存在".into()); } for h in hwnds { super::capture::set_scroll_hole(h, region)?; } Ok(()) } #[cfg(not(windows))] { let _ = (app, region); Ok(()) } } /// 取出编辑器图片(原始 PNG 字节,raw IPC → 前端 ArrayBuffer → Blob URL,取出即清除) /// /// 长图(滚动截图)可达数十 MB:raw IPC 相比 base64 JSON 事件传输省 ~33% 体积, /// 且避免 JSON 序列化/多次广播。注:返回 ipc::Response,specta 无法生成,豁免标注。 #[tauri::command] pub async fn screenshot_take_editor_image_raw() -> Result { match super::take_editor_image_raw() { Some(bytes) => Ok(tauri::ipc::Response::new(bytes)), None => Err("无待编辑的截图".into()), } } /// 将 PNG base64 写入系统剪贴板(转 CF_DIB) #[tauri::command] #[specta::specta] pub async fn screenshot_copy_image(png_base64: String) -> Result<(), String> { #[cfg(windows)] { tauri::async_runtime::spawn_blocking(move || { super::capture::copy_png_to_clipboard(&png_base64) }) .await .map_err(|e| format!("剪贴板任务失败: {}", e))? } #[cfg(not(windows))] { let _ = png_base64; Err("剪贴板仅支持 Windows".into()) } } /// 有标注导出:接收 raw RGBA(前端 canvas.getImageData 直传),一次完成 剪贴板+PNG base64。 /// /// 省去前端 toDataURL(PNG 编码+base64) → Rust base64 解码 → PNG 解码 三次往返。 /// body 格式:前 8 字节 = width(i32 LE) + height(i32 LE),之后为 raw RGBA 像素。 /// 注:参数为 tauri::ipc::Request(原始 body),specta 无法生成,豁免标注。 #[tauri::command] pub async fn screenshot_compose_copy( request: tauri::ipc::Request<'_>, ) -> Result { #[cfg(windows)] { let body = match request.body() { tauri::ipc::InvokeBody::Raw(data) => data.clone(), _ => return Err("需要 raw body(ArrayBuffer)".into()), }; if body.len() < 8 { return Err("数据不足:缺少尺寸头".into()); } let width = i32::from_le_bytes([body[0], body[1], body[2], body[3]]); let height = i32::from_le_bytes([body[4], body[5], body[6], body[7]]); let rgba = body[8..].to_vec(); tauri::async_runtime::spawn_blocking(move || { super::capture::compose_copy_rgba(&rgba, width, height) }) .await .map_err(|e| format!("合成复制任务失败: {}", e))? } #[cfg(not(windows))] { let _ = request; Err("截图仅支持 Windows".into()) } } /// 有标注导出(仅编码):接收 raw RGBA(前端 canvas.getImageData 直传),一次完成 PNG 编码。 /// 与 screenshot_compose_copy 的区别:不写剪贴板(编辑器「保存到文件」用)。 /// body 格式:前 8 字节 = width(i32 LE) + height(i32 LE),之后为 raw RGBA 像素。 /// 注:参数为 tauri::ipc::Request(原始 body),specta 无法生成,豁免标注。 #[tauri::command] pub async fn screenshot_compose_png( request: tauri::ipc::Request<'_>, ) -> Result { #[cfg(windows)] { let body = match request.body() { tauri::ipc::InvokeBody::Raw(data) => data.clone(), _ => return Err("需要 raw body(ArrayBuffer)".into()), }; if body.len() < 8 { return Err("数据不足:缺少尺寸头".into()); } let width = i32::from_le_bytes([body[0], body[1], body[2], body[3]]); let height = i32::from_le_bytes([body[4], body[5], body[6], body[7]]); let rgba = body[8..].to_vec(); tauri::async_runtime::spawn_blocking(move || { super::capture::compose_png_rgba(&rgba, width, height) }) .await .map_err(|e| format!("合成编码任务失败: {}", e))? } #[cfg(not(windows))] { let _ = request; Err("截图仅支持 Windows".into()) } } /// 将 PNG base64 写入文件 #[tauri::command] #[specta::specta] pub async fn screenshot_save_png(png_base64: String, path: String) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { #[cfg(windows)] { super::capture::save_png_to_file(&png_base64, &path) } #[cfg(not(windows))] { let _ = (png_base64, path); Err("文件写入仅支持 Windows".to_string()) } }) .await .map_err(|e| format!("保存任务失败: {}", e))? } // ===== 截图历史缓存:完整 PNG 落盘到应用数据目录(持久化,随历史保留数量清理),内存只保留缩略图 ===== /// 历史根目录(app_data_dir/screenshot/history) fn history_cache_dir(app: &tauri::AppHandle) -> Result { let dir = app .path() .app_data_dir() .map_err(|e| format!("获取应用数据目录失败: {}", e))? .join("screenshot") .join("history"); std::fs::create_dir_all(&dir).map_err(|e| format!("创建历史目录失败: {}", e))?; Ok(dir) } /// 校验 path 属于历史缓存目录(防止路径穿越/任意文件读写) fn ensure_in_history_dir(app: &tauri::AppHandle, path: &str) -> Result { let dir = history_cache_dir(app)?; let p = std::path::PathBuf::from(path); if !p.starts_with(&dir) { return Err("非法路径:不在截图历史缓存目录内".into()); } Ok(p) } /// 将完整 PNG 写入历史缓存目录,返回文件路径 #[tauri::command] #[specta::specta] pub async fn screenshot_save_cache( app: tauri::AppHandle, png_base64: String, ) -> Result { tauri::async_runtime::spawn_blocking(move || { let dir = history_cache_dir(&app)?; // 时间戳微秒命名(避免引入额外依赖;并发截图的同微秒碰撞可忽略) let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_micros()) .unwrap_or(0); let path = dir.join(format!("{}.png", ts)); super::capture::save_png_to_file(&png_base64, &path.to_string_lossy())?; Ok(path.to_string_lossy().into_owned()) }) .await .map_err(|e| format!("缓存任务失败: {}", e))? } /// 从历史缓存目录读取 PNG 并返回 base64(点击历史项复制/保存时一次性加载,不常驻内存) #[tauri::command] #[specta::specta] pub async fn screenshot_load_cache( app: tauri::AppHandle, path: String, ) -> Result { tauri::async_runtime::spawn_blocking(move || { let p = ensure_in_history_dir(&app, &path)?; let bytes = std::fs::read(&p).map_err(|e| format!("读取缓存失败: {}", e))?; use base64::Engine as _; Ok(base64::engine::general_purpose::STANDARD.encode(bytes)) }) .await .map_err(|e| format!("读取缓存任务失败: {}", e))? } /// 读取历史缓存 PNG 原始字节(raw IPC → 前端 ArrayBuffer,贴图窗口显示用: /// 跳过 base64 编码,IPC 传输与前端内存占用均省 ~33%;同 get_fullscreen_bmp 豁免 specta) #[tauri::command] pub async fn screenshot_load_cache_raw( app: tauri::AppHandle, path: String, ) -> Result { tauri::async_runtime::spawn_blocking(move || { let p = ensure_in_history_dir(&app, &path)?; let bytes = std::fs::read(&p).map_err(|e| format!("读取缓存失败: {}", e))?; Ok(tauri::ipc::Response::new(bytes)) }) .await .map_err(|e| format!("读取缓存任务失败: {}", e))? } /// 删除历史缓存文件(历史项移除/清空时调用,静默忽略不存在文件) #[tauri::command] #[specta::specta] pub async fn screenshot_delete_cache(app: tauri::AppHandle, path: String) -> Result<(), String> { tauri::async_runtime::spawn_blocking(move || { if let Ok(p) = ensure_in_history_dir(&app, &path) { let _ = std::fs::remove_file(p); } Ok(()) }) .await .map_err(|e| format!("删除缓存任务失败: {}", e))? }