Files
Thing/src-tauri/src/screenshot/commands.rs
T
2026-09-12 11:05:26 +08:00

635 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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_copyraw 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<CaptureStart, String> {
#[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<tauri::ipc::Response, String> {
#[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<CaptureData, String> {
#[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<CaptureData, String> {
#[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<CaptureData, String> {
#[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<Vec<WindowInfo>, 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<Vec<WindowInfo>, 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<CaptureData, String> {
#[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<super::ScrollRegion>,
) -> Result<CaptureData, String> {
#[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<super::ScrollRegion>,
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<super::ScrollRegion>,
) -> Result<(), String> {
#[cfg(windows)]
{
use raw_window_handle::HasWindowHandle;
let mut hwnds: Vec<isize> = 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,取出即清除)
///
/// 长图(滚动截图)可达数十 MBraw IPC 相比 base64 JSON 事件传输省 ~33% 体积,
/// 且避免 JSON 序列化/多次广播。注:返回 ipc::Responsespecta 无法生成,豁免标注。
#[tauri::command]
pub async fn screenshot_take_editor_image_raw() -> Result<tauri::ipc::Response, String> {
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<super::CaptureData, String> {
#[cfg(windows)]
{
let body = match request.body() {
tauri::ipc::InvokeBody::Raw(data) => data.clone(),
_ => return Err("需要 raw bodyArrayBuffer".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<super::CaptureData, String> {
#[cfg(windows)]
{
let body = match request.body() {
tauri::ipc::InvokeBody::Raw(data) => data.clone(),
_ => return Err("需要 raw bodyArrayBuffer".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<std::path::PathBuf, String> {
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<std::path::PathBuf, String> {
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<String, String> {
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<String, String> {
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::ipc::Response, String> {
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))?
}