|
|
@@ -0,0 +1,331 @@
|
|
|
+//! 子 webview 截图模块
|
|
|
+//!
|
|
|
+//! 暴露:
|
|
|
+//! - 命令 `capture_page`:前端手动触发
|
|
|
+//! - 函数 `trigger_auto_capture`:`on_page_load` 钩子触发
|
|
|
+//!
|
|
|
+//! 平台实现:
|
|
|
+//! - Windows:通过 `ICoreWebView2::CallDevToolsProtocolMethod` 走 CDP
|
|
|
+//! 1) Runtime.evaluate 跑预热脚本(强制 lazy img 急加载、整页滚动一遍触发
|
|
|
+//! IntersectionObserver、等图片 / 字体 / 布局稳定)
|
|
|
+//! 2) Page.captureScreenshot 带 captureBeyondViewport=true 一次性出整页 PNG
|
|
|
+//! - 非 Windows:暂返回友好错误,下一轮接入 WKWebView.takeSnapshot 做单页截图
|
|
|
+
|
|
|
+use crate::{find_content_webview, paths, AppState};
|
|
|
+use serde::Serialize;
|
|
|
+use tauri::{AppHandle, Emitter, Manager};
|
|
|
+
|
|
|
+/// 事件名常量,与前端 src/types/ipc.ts 保持一致
|
|
|
+const EVT_SCREENSHOT_FINISHED: &str = "screenshot-finished";
|
|
|
+const EVT_SCREENSHOT_FAILED: &str = "screenshot-failed";
|
|
|
+
|
|
|
+#[derive(Serialize, Clone)]
|
|
|
+struct ScreenshotFinished {
|
|
|
+ #[serde(rename = "taskId")]
|
|
|
+ task_id: String,
|
|
|
+ path: String,
|
|
|
+ auto: bool,
|
|
|
+}
|
|
|
+
|
|
|
+#[derive(Serialize, Clone)]
|
|
|
+struct ScreenshotFailed {
|
|
|
+ #[serde(rename = "taskId")]
|
|
|
+ task_id: String,
|
|
|
+ auto: bool,
|
|
|
+ error: String,
|
|
|
+}
|
|
|
+
|
|
|
+/// 前端手动触发的截图命令。
|
|
|
+#[tauri::command]
|
|
|
+pub async fn capture_page(app: AppHandle, task_id: String) -> Result<String, String> {
|
|
|
+ perform_capture(app, task_id, false).await
|
|
|
+}
|
|
|
+
|
|
|
+/// 自动截图入口(由 `on_page_load` 完成回调触发,不直接面向前端)。
|
|
|
+///
|
|
|
+/// - 读 AppState 拿当前 task_id;为空(about:blank 或未选任务)则跳过
|
|
|
+/// - 失败只通过 emit 事件告知前端,不让 panic 影响主循环
|
|
|
+pub async fn trigger_auto_capture(app: AppHandle) {
|
|
|
+ let task_id = {
|
|
|
+ let state = match app.try_state::<AppState>() {
|
|
|
+ Some(s) => s,
|
|
|
+ None => return,
|
|
|
+ };
|
|
|
+ match state.current_task() {
|
|
|
+ Some(id) => id,
|
|
|
+ None => return, // 初始 about:blank,没有任务上下文
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ if let Err(e) = perform_capture(app, task_id, true).await {
|
|
|
+ // perform_capture 内部失败时已经 emit 过 failed 事件,这里只是兜底日志
|
|
|
+ eprintln!("自动截图失败: {e}");
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/// 真正执行截图:决定文件路径 → 平台调用 → 写盘 → emit 事件。
|
|
|
+///
|
|
|
+/// 成功返回保存路径(用于命令的 invoke 返回值);失败时同时 emit screenshot-failed
|
|
|
+/// 并返回 Err,便于前端通过两种路径感知失败。
|
|
|
+async fn perform_capture(
|
|
|
+ app: AppHandle,
|
|
|
+ task_id: String,
|
|
|
+ auto: bool,
|
|
|
+) -> Result<String, String> {
|
|
|
+ let target_path = paths::screenshot_path_for_task(&app, &task_id)?;
|
|
|
+
|
|
|
+ let result = capture_to_png_bytes(&app).await;
|
|
|
+
|
|
|
+ let bytes = match result {
|
|
|
+ Ok(b) => b,
|
|
|
+ Err(e) => {
|
|
|
+ let msg = e.to_string();
|
|
|
+ let _ = app.emit(
|
|
|
+ EVT_SCREENSHOT_FAILED,
|
|
|
+ ScreenshotFailed {
|
|
|
+ task_id: task_id.clone(),
|
|
|
+ auto,
|
|
|
+ error: msg.clone(),
|
|
|
+ },
|
|
|
+ );
|
|
|
+ return Err(msg);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ if let Err(e) = std::fs::write(&target_path, &bytes) {
|
|
|
+ let msg = format!("写截图文件失败 {}: {}", target_path.display(), e);
|
|
|
+ let _ = app.emit(
|
|
|
+ EVT_SCREENSHOT_FAILED,
|
|
|
+ ScreenshotFailed {
|
|
|
+ task_id: task_id.clone(),
|
|
|
+ auto,
|
|
|
+ error: msg.clone(),
|
|
|
+ },
|
|
|
+ );
|
|
|
+ return Err(msg);
|
|
|
+ }
|
|
|
+
|
|
|
+ let path_str = target_path.to_string_lossy().to_string();
|
|
|
+ let _ = app.emit(
|
|
|
+ EVT_SCREENSHOT_FINISHED,
|
|
|
+ ScreenshotFinished {
|
|
|
+ task_id,
|
|
|
+ path: path_str.clone(),
|
|
|
+ auto,
|
|
|
+ },
|
|
|
+ );
|
|
|
+ Ok(path_str)
|
|
|
+}
|
|
|
+
|
|
|
+// =====================================================================
|
|
|
+// 平台分发:拿到子 webview,调用平台特定实现得到 PNG bytes
|
|
|
+// =====================================================================
|
|
|
+
|
|
|
+async fn capture_to_png_bytes(app: &AppHandle) -> anyhow::Result<Vec<u8>> {
|
|
|
+ let webview = find_content_webview(app).map_err(anyhow::Error::msg)?;
|
|
|
+
|
|
|
+ #[cfg(target_os = "windows")]
|
|
|
+ {
|
|
|
+ windows_impl::capture_full_page(webview).await
|
|
|
+ }
|
|
|
+
|
|
|
+ #[cfg(not(target_os = "windows"))]
|
|
|
+ {
|
|
|
+ let _ = webview; // 静默未使用警告
|
|
|
+ anyhow::bail!("当前平台暂未实现截图,仅 Windows 已支持(macOS 接单页截图将在下一阶段加入)")
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// =====================================================================
|
|
|
+// Windows: CDP 走 ICoreWebView2.CallDevToolsProtocolMethod
|
|
|
+// =====================================================================
|
|
|
+
|
|
|
+#[cfg(target_os = "windows")]
|
|
|
+mod windows_impl {
|
|
|
+ use anyhow::{anyhow, bail, Context, Result};
|
|
|
+ use base64::{engine::general_purpose::STANDARD, Engine};
|
|
|
+ use serde_json::{json, Value};
|
|
|
+ use std::sync::{Arc, Mutex};
|
|
|
+ use tauri::Webview;
|
|
|
+ use tokio::sync::oneshot;
|
|
|
+ use webview2_com::{
|
|
|
+ CallDevToolsProtocolMethodCompletedHandler,
|
|
|
+ Microsoft::Web::WebView2::Win32::ICoreWebView2,
|
|
|
+ };
|
|
|
+ use windows::core::HSTRING;
|
|
|
+
|
|
|
+ /// 预热脚本:等懒加载内容、字体、图片加载完成;最大 8s 兜底超时。
|
|
|
+ ///
|
|
|
+ /// 注:通过 CDP Runtime.evaluate 跑,awaitPromise:true,Rust 端拿到 resolved
|
|
|
+ /// 结果后再发 captureScreenshot。
|
|
|
+ const WARMUP_SCRIPT: &str = r#"
|
|
|
+(async () => {
|
|
|
+ const TIMEOUT_MS = 8000;
|
|
|
+ const QUIET_MS = 800;
|
|
|
+
|
|
|
+ const timeout = new Promise(resolve => setTimeout(() => resolve('timeout'), TIMEOUT_MS));
|
|
|
+
|
|
|
+ const work = (async () => {
|
|
|
+ // 1) 强制 lazy img 急加载
|
|
|
+ document.querySelectorAll('img[loading="lazy"]').forEach(img => {
|
|
|
+ try { img.loading = 'eager'; } catch (e) {}
|
|
|
+ });
|
|
|
+
|
|
|
+ // 2) 整页滚动一遍触发 IntersectionObserver
|
|
|
+ const se = document.scrollingElement || document.documentElement;
|
|
|
+ const maxScroll = Math.max(0, se.scrollHeight - window.innerHeight);
|
|
|
+ const step = Math.max(window.innerHeight * 0.9, 200);
|
|
|
+ for (let y = 0; y <= maxScroll; y += step) {
|
|
|
+ se.scrollTop = y;
|
|
|
+ await new Promise(r => setTimeout(r, 60));
|
|
|
+ }
|
|
|
+ se.scrollTop = 0;
|
|
|
+
|
|
|
+ // 3) 等所有未完成的 <img> 加载完(或失败)
|
|
|
+ await Promise.all(Array.from(document.images)
|
|
|
+ .filter(img => !img.complete)
|
|
|
+ .map(img => new Promise(r => {
|
|
|
+ const done = () => r();
|
|
|
+ img.addEventListener('load', done, { once: true });
|
|
|
+ img.addEventListener('error', done, { once: true });
|
|
|
+ })));
|
|
|
+
|
|
|
+ // 4) 等字体
|
|
|
+ if (document.fonts && document.fonts.ready) {
|
|
|
+ try { await document.fonts.ready; } catch (e) {}
|
|
|
+ }
|
|
|
+
|
|
|
+ // 5) DOM 静默 QUIET_MS 视为稳定
|
|
|
+ await new Promise(resolve => {
|
|
|
+ let timer = setTimeout(resolve, QUIET_MS);
|
|
|
+ const obs = new MutationObserver(() => {
|
|
|
+ clearTimeout(timer);
|
|
|
+ timer = setTimeout(() => { obs.disconnect(); resolve(); }, QUIET_MS);
|
|
|
+ });
|
|
|
+ obs.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
|
|
|
+ });
|
|
|
+
|
|
|
+ // 6) 等两帧让最终布局生效
|
|
|
+ await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
|
|
|
+
|
|
|
+ return 'ok';
|
|
|
+ })();
|
|
|
+
|
|
|
+ return await Promise.race([work, timeout]);
|
|
|
+})()
|
|
|
+"#;
|
|
|
+
|
|
|
+ /// 在 Windows 上对子 webview 做整页截图,返回 PNG bytes。
|
|
|
+ pub async fn capture_full_page(webview: Webview) -> Result<Vec<u8>> {
|
|
|
+ // 1) 预热
|
|
|
+ let warm_resp = call_cdp(
|
|
|
+ &webview,
|
|
|
+ "Runtime.evaluate",
|
|
|
+ json!({
|
|
|
+ "expression": WARMUP_SCRIPT,
|
|
|
+ "awaitPromise": true,
|
|
|
+ "returnByValue": true,
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ .await
|
|
|
+ .context("CDP Runtime.evaluate 预热失败")?;
|
|
|
+
|
|
|
+ if let Some(detail) = warm_resp.get("exceptionDetails") {
|
|
|
+ // 预热脚本抛异常通常意味着页面 CSP 限制或语法兼容问题,记录但不中断
|
|
|
+ // —— 截图本身仍可继续,只是没预热。
|
|
|
+ eprintln!("CDP 预热脚本异常(已忽略,继续截图): {detail}");
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2) 整页截图
|
|
|
+ let shot_resp = call_cdp(
|
|
|
+ &webview,
|
|
|
+ "Page.captureScreenshot",
|
|
|
+ json!({
|
|
|
+ "format": "png",
|
|
|
+ "captureBeyondViewport": true,
|
|
|
+ "fromSurface": true,
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ .await
|
|
|
+ .context("CDP Page.captureScreenshot 失败")?;
|
|
|
+
|
|
|
+ let b64 = shot_resp
|
|
|
+ .get("data")
|
|
|
+ .and_then(|v| v.as_str())
|
|
|
+ .ok_or_else(|| anyhow!("CDP 响应缺少 data 字段: {shot_resp}"))?;
|
|
|
+
|
|
|
+ let bytes = STANDARD
|
|
|
+ .decode(b64)
|
|
|
+ .context("CDP 返回的 base64 解码失败")?;
|
|
|
+ if bytes.is_empty() {
|
|
|
+ bail!("CDP 截图返回了空数据");
|
|
|
+ }
|
|
|
+ Ok(bytes)
|
|
|
+ }
|
|
|
+
|
|
|
+ /// 调一个 CDP 方法,返回响应 JSON(解析后的 serde_json::Value)。
|
|
|
+ ///
|
|
|
+ /// CallDevToolsProtocolMethod 是异步 API,结果通过 completed handler 回调,
|
|
|
+ /// 我们用 tokio::sync::oneshot 把它桥到 async/await。
|
|
|
+ async fn call_cdp(webview: &Webview, method: &str, params: Value) -> Result<Value> {
|
|
|
+ let (tx, rx) = oneshot::channel::<Result<String, String>>();
|
|
|
+ let tx = Arc::new(Mutex::new(Some(tx)));
|
|
|
+
|
|
|
+ let method_owned = method.to_string();
|
|
|
+ let params_str = serde_json::to_string(¶ms)?;
|
|
|
+
|
|
|
+ let tx_for_native = tx.clone();
|
|
|
+ let method_for_native = method_owned.clone();
|
|
|
+ webview
|
|
|
+ .with_webview(move |platform| {
|
|
|
+ // platform 在 Windows 上是 wry 的 webview2 包装,controller() 给 ICoreWebView2Controller
|
|
|
+ let result: Result<()> = (|| {
|
|
|
+ let controller = platform.controller();
|
|
|
+ let core: ICoreWebView2 = unsafe { controller.CoreWebView2()? };
|
|
|
+
|
|
|
+ let method_h: HSTRING = method_for_native.clone().into();
|
|
|
+ let params_h: HSTRING = params_str.clone().into();
|
|
|
+
|
|
|
+ let tx_handler = tx_for_native.clone();
|
|
|
+ let handler = CallDevToolsProtocolMethodCompletedHandler::create(Box::new(
|
|
|
+ move |hr, json_pcwstr| {
|
|
|
+ let json_str = unsafe { json_pcwstr.to_string().unwrap_or_default() };
|
|
|
+ let res = if hr.is_ok() {
|
|
|
+ Ok(json_str)
|
|
|
+ } else {
|
|
|
+ Err(format!("CDP HRESULT 错误: 0x{:08x}", hr.0))
|
|
|
+ };
|
|
|
+ if let Ok(mut guard) = tx_handler.lock() {
|
|
|
+ if let Some(sender) = guard.take() {
|
|
|
+ let _ = sender.send(res);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ Ok(())
|
|
|
+ },
|
|
|
+ ));
|
|
|
+
|
|
|
+ unsafe {
|
|
|
+ core.CallDevToolsProtocolMethod(&method_h, ¶ms_h, &handler)?;
|
|
|
+ }
|
|
|
+ Ok(())
|
|
|
+ })();
|
|
|
+
|
|
|
+ // 调用本身失败时(不是 CDP 回调失败),把错误送回 rx
|
|
|
+ if let Err(e) = result {
|
|
|
+ if let Ok(mut guard) = tx_for_native.lock() {
|
|
|
+ if let Some(sender) = guard.take() {
|
|
|
+ let _ = sender.send(Err(format!("CDP 同步阶段失败: {e}")));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .map_err(|e| anyhow!("with_webview 调用失败: {e}"))?;
|
|
|
+
|
|
|
+ let raw = rx
|
|
|
+ .await
|
|
|
+ .map_err(|_| anyhow!("CDP oneshot 通道关闭"))??;
|
|
|
+
|
|
|
+ serde_json::from_str(&raw)
|
|
|
+ .map_err(|e| anyhow!("CDP 响应不是合法 JSON: {e}; raw={raw}"))
|
|
|
+ }
|
|
|
+}
|