mod capture; mod cdp; mod landing; mod paths; mod preview; mod recording; mod shortcuts; use serde::Serialize; use std::sync::Mutex; use std::{collections::HashMap, process}; use tauri::{ webview::WebviewBuilder, AppHandle, LogicalPosition, LogicalSize, Manager, WebviewUrl, }; use windows::core::HSTRING; use recording::RecordingSession; /// 子 webview 的 label pub(crate) const CONTENT_WEBVIEW_LABEL: &str = "content"; /// 子 webview 初始尺寸:1280×720 横屏 const DEFAULT_CONTENT_W: f64 = 1280.0f64; const DEFAULT_CONTENT_H: f64 = 720.0f64; /// 子 webview 初始 url(空白页占位,等用户从任务列表选) const INITIAL_URL: &str = "about:blank"; /// 当前任务页面所处阶段(landing 流转状态机)。 /// /// 状态转换: /// navigate_webview(task.url) ──► Initial(等待中间页加载 + 轮询) /// landing 找到 Site ──► Final (等待最终页加载) /// 最终页 on_page_load ──► Ready (按钮可用,自动截图触发) /// 再次 navigate_webview ──► Initial(重置) #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PageStage { Initial, Final, Ready, } impl Default for PageStage { fn default() -> Self { // 首次启动 webview 加载 about:blank,没有任务上下文;Initial 不会被使用 PageStage::Initial } } /// 全局应用状态:跨命令共享的"当前任务 id"、录制会话等。 /// /// 用 std::sync::Mutex 包裹,命令是同步执行的,锁竞争极低;如果后续要在 /// 异步任务中持有锁过临界点,再迁到 tokio::sync::Mutex。 #[derive(Default)] pub struct AppState { /// 当前加载到子 webview 的任务 id;None 表示子 webview 还在 about:blank。 /// 自动截图触发时读取本字段决定文件名 (task-.png)。 pub current_task_id: Mutex>, /// 进行中的录制会话:session_id → 元数据 pub recording_sessions: Mutex>, /// landing 流转阶段;on_page_load 据此分流到「轮询」/「就绪」分支 pub page_stage: Mutex, } impl AppState { /// 读当前 task id(克隆出来,避免持锁跨 await) pub fn current_task(&self) -> Option { self.current_task_id.lock().ok().and_then(|g| g.clone()) } } /// 任务的产物文件查询结果 #[derive(Serialize)] struct TaskAssets { task_id: String, /// 截图文件预期路径(无论是否存在) screenshot_path: String, /// 截图文件是否存在 screenshot_exists: bool, /// 录制成片预期路径 recording_path: String, /// 录制成片是否存在 recording_exists: bool, /// 截图根目录(兜底 reveal 用) screenshots_dir: String, /// 录制根目录(兜底 reveal 用) recordings_dir: String, } #[tauri::command] fn app_quit(_app: AppHandle) { process::exit(0); } /// 查询指定任务有哪些可用的产物文件,供前端: /// - 决定"图片预览"/"视频预览"按钮的 disabled 状态 /// - 决定"打开文件夹"按钮的 reveal 目标(mp4 > png > screenshots 目录) #[tauri::command] fn query_task_assets(app: AppHandle, task_id: String) -> Result { let screenshot = paths::screenshot_path_for_task(&app, &task_id)?; let recording = paths::recording_final_path_for_task(&app, &task_id)?; let s_dir = paths::screenshots_dir(&app)?; let r_dir = paths::recordings_dir(&app)?; Ok(TaskAssets { task_id, screenshot_exists: screenshot.exists(), screenshot_path: screenshot.to_string_lossy().to_string(), recording_exists: recording.exists(), recording_path: recording.to_string_lossy().to_string(), screenshots_dir: s_dir.to_string_lossy().to_string(), recordings_dir: r_dir.to_string_lossy().to_string(), }) } /// 在所有 webview 中查找 label = `content` 的子 webview pub(crate) fn find_content_webview(app: &tauri::AppHandle) -> Result { app.webviews() .into_iter() .find_map(|(label, w)| { if label.as_str() == CONTENT_WEBVIEW_LABEL { Some(w) } else { None } }) .ok_or_else(|| format!("找不到 webview: {CONTENT_WEBVIEW_LABEL}")) } #[tauri::command] fn clean_webview(app: tauri::AppHandle) -> Result<(), String> { let parsed = url::Url::parse(INITIAL_URL) .map_err(|e| format!("url ({})解析失败: {}", INITIAL_URL, e))?; let webview = find_content_webview(&app)?; webview.navigate(parsed).map_err(|e| e.to_string()) } /// 加载新的 url 到子 webview,并把 task_id 记入 AppState,用于后续自动截图命名。 #[tauri::command] fn navigate_webview( app: tauri::AppHandle, state: tauri::State<'_, AppState>, task_id: String, url: String, ) -> Result<(), String> { let parsed = url::Url::parse(&url).map_err(|e| format!("url ({})解析失败: {}", &url, e))?; // 先更新 state(即便后续 navigate 失败,state 也会被下一次正常调用覆盖)。 // 同时把 page_stage 重置为 Initial:这次 navigate 永远指向「中间页」, // 后续 landing 模块决定是否再 navigate 到 Site URL。 if let Ok(mut guard) = state.current_task_id.lock() { *guard = Some(task_id); } if let Ok(mut guard) = state.page_stage.lock() { *guard = PageStage::Initial; } let webview = find_content_webview(&app)?; webview.navigate(parsed).map_err(|e| e.to_string()) } /// 在子 webview 上执行历史导航 / 刷新动作。 /// /// 通过 webview.eval 跑很短的一行 JS,避开各平台 API 差异: /// - "back" → history.back() /// - "forward" → history.forward() /// - "reload" → location.reload() /// /// 不主动重置 page_stage:用户点 Back 回到中间页 / 点 Refresh 重载最终页时, /// 状态机已经是 Final/Ready,handle_page_started/loaded 会按当前 stage 静默处理 /// (即不会重新启动 landing 轮询,也不会重复触发自动截图)。如果用户想完整重跑 /// landing 流程,应通过左侧任务列表再点一下任务(走 navigate_webview,那个命令 /// 会把 stage 重置为 Initial)。 #[tauri::command] fn webview_history_action(app: tauri::AppHandle, action: String) -> Result<(), String> { let script = match action.as_str() { "back" => "history.back()", "forward" => "history.forward()", "reload" => "location.reload()", other => return Err(format!("未知的 webview 历史动作: {other}")), }; let webview = find_content_webview(&app)?; webview.eval(script).map_err(|e| e.to_string()) } /// 修改主窗口的「客户区」尺寸(逻辑像素,不含系统 titlebar / 边框)。 /// /// 前端传进来的 (width, height) 含义是「左栏 + 工作区宽」「工具栏高 + 工作区高」, /// 也就是客户区尺寸;外边的 titlebar / 边框由 helper 自动补偿。 #[tauri::command] fn set_window_size( app: tauri::AppHandle, width: f64, height: f64, content_x: f64, content_y: f64, content_width: f64, content_height: f64, ) -> Result<(), String> { println!( "===========:w{},h{},cx{},cy{},cw{},ch{}", width, height, content_x, content_y, content_width, content_height ); // 这里取底层 `Window`(而不是 `WebviewWindow`),与 setup 阶段保持一致 let window = app .get_window("main") .ok_or_else(|| "找不到主窗口".to_string())?; window .set_size(LogicalSize::new(width, height)) .map_err(|e| e.to_string())?; let content = find_content_webview(&app)?; content .set_position(LogicalPosition::new(content_x, content_y)) .map_err(|e| e.to_string())?; content .set_size(LogicalSize::new(content_width, content_height)) .map_err(|e| e.to_string())?; Ok(()) } /// PageLoadEvent::Started 处理:尽早启动 landing 轮询。 /// /// 之所以不等到 Finished(= window.onload)才启动,是因为 onload 必须等所有图片 / /// 广告 / 第三方脚本加载完成,而 `div.c-tags`、`.menu-float__content` 这些节点在 /// HTML parse 阶段就已经进 DOM 了。Started 触发时新文档刚提交,立即跑 CDP 轮询 /// 就能在几百毫秒内捕获到目标节点,无需等几秒的尾资源。 /// 根据 page_stage 分流: /// - Initial:当前是中间页 → spawn landing 轮询; /// - Final:当前是 Site 最终页 → emit task-page-ready,1.5s 后自动截图; /// - Ready:已就绪还在加载(重新刷新等),不做额外动作。 /// /// 平台说明:landing 轮询仅 Windows 实现(CDP 路径)。macOS 这里 no-op,靠 /// handle_page_loaded 的 Finished 分支把 Initial 当 Final 处理。 async fn handle_page_started(app: AppHandle, loaded_url: String) { let (task_id, stage) = match read_task_and_stage(&app) { Some(v) => v, None => return, }; if stage != PageStage::Initial { // Final / Ready 阶段下的 Started(比如 final URL 已开始重定向),交给 Finished 处理 return; } #[cfg(target_os = "windows")] { landing::spawn_polling(app, task_id, loaded_url); } #[cfg(not(target_os = "windows"))] { // macOS:什么都不做,等 Finished 一起处理 let _ = (app, task_id, loaded_url); } } /// PageLoadEvent::Finished 处理:把 stage 推进到 Ready + 触发自动截图。 /// /// 分流: /// - Initial:Windows 下不动(轮询负责);macOS 直接 promote_to_ready(无 CDP fallback)。 /// - Final :跨平台都 promote_to_ready。 /// - Ready :忽略(用户刷新等同 URL 重新加载)。 async fn handle_page_loaded(app: AppHandle, loaded_url: String) { let (task_id, stage) = match read_task_and_stage(&app) { Some(v) => v, None => return, }; let should_promote = match stage { PageStage::Final => true, // 非 Windows 没有 CDP,Initial 阶段也没有 Visit Site 自动跳转, // 把 Finished 当作就绪信号;window.onload 已触发,页面已可见, // 不会出现「按钮就绪但页面白屏」的尴尬。 #[cfg(not(target_os = "windows"))] PageStage::Initial => true, _ => false, }; if should_promote { promote_to_ready(&app, &task_id, &loaded_url).await; } } /// 从 AppState 读 (task_id, stage) 的小帮手;无任务上下文(about:blank 等)返回 None。 fn read_task_and_stage(app: &AppHandle) -> Option<(String, PageStage)> { let state = app.try_state::()?; let task_id = state.current_task()?; let stage = state .page_stage .lock() .ok() .map(|g| *g) .unwrap_or(PageStage::Initial); Some((task_id, stage)) } /// 把 stage 切到 Ready + emit `task-page-ready` + 1.5s 后触发自动截图。 async fn promote_to_ready(app: &AppHandle, task_id: &str, loaded_url: &str) { use tauri::Emitter; if let Some(state) = app.try_state::() { if let Ok(mut g) = state.page_stage.lock() { *g = PageStage::Ready; } } let _ = app.emit( "task-page-ready", serde_json::json!({ "taskId": task_id, "url": loaded_url }), ); tokio::time::sleep(std::time::Duration::from_millis(1500)).await; capture::trigger_auto_capture(app.clone()).await; } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { // tauri-plugin-sql 的 schema 迁移定义 // db 名固定为 sqlite:autorecord.db,落在 APPDATA 目录 // // 注意:plugin-sql 用 SQL 文本 hash 做幂等校验,已经下发到用户机的 migration 字段 // 一律不能再动(哪怕只是空白),否则启动会报 "migration X was previously applied // but has been modified" 并整个 DB 加载失败。新增改动一律走新的 version。 let sql_migrations = vec![ tauri_plugin_sql::Migration { version: 1, description: "create_tasks_table", sql: "CREATE TABLE IF NOT EXISTS tasks (\n id TEXT PRIMARY KEY,\n url TEXT NOT NULL,\n status INTEGER NOT NULL DEFAULT 0,\n desc TEXT NOT NULL DEFAULT ''\n );", kind: tauri_plugin_sql::MigrationKind::Up, }, // v2:landing 页提取出的 tags(逗号分隔),SQLite 无 VARCHAR 容量限制,统一用 TEXT tauri_plugin_sql::Migration { version: 2, description: "add_tasks_tags", sql: "ALTER TABLE tasks ADD COLUMN tags TEXT NOT NULL DEFAULT '';", kind: tauri_plugin_sql::MigrationKind::Up, }, // v3~v6:补三个产物字段 + status 普通索引。拆成单语句条目避免不同 sqlx 版本 // 对多语句 migration 的处理差异(id 已是 PRIMARY KEY,自带唯一索引不重复建)。 // - site_url:landing 跳转后落地的最终 URL // - pic / video:最近一次截图 / 录制 mp4 的文件名(仅 basename) // - idx_tasks_status:主列表 WHERE status=0 谓词的非唯一索引 tauri_plugin_sql::Migration { version: 3, description: "add_tasks_site_url", sql: "ALTER TABLE tasks ADD COLUMN site_url TEXT NOT NULL DEFAULT '';", kind: tauri_plugin_sql::MigrationKind::Up, }, tauri_plugin_sql::Migration { version: 4, description: "add_tasks_pic", sql: "ALTER TABLE tasks ADD COLUMN pic TEXT NOT NULL DEFAULT '';", kind: tauri_plugin_sql::MigrationKind::Up, }, tauri_plugin_sql::Migration { version: 5, description: "add_tasks_video", sql: "ALTER TABLE tasks ADD COLUMN video TEXT NOT NULL DEFAULT '';", kind: tauri_plugin_sql::MigrationKind::Up, }, tauri_plugin_sql::Migration { version: 6, description: "create_idx_tasks_status", sql: "CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);", kind: tauri_plugin_sql::MigrationKind::Up, }, ]; tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) .plugin(shortcuts::record_shortcut_plugin()) .plugin( tauri_plugin_sql::Builder::default() .add_migrations("sqlite:autorecord.db", sql_migrations) .build(), ) .manage(AppState::default()) .setup(|app| { // 注册 F9/F10/F11 全局快捷键。注册失败仅打日志,不阻塞应用启动 // (比如 macOS 上 F9-F11 被 Mission Control 占用时) if let Err(e) = shortcuts::register_record_shortcuts(app.handle()) { eprintln!("[shortcuts] {e}"); } // 在主窗口(也是 React UI 所在的 webview)所属的 Window 上挂一个 child webview // 注意:`add_child` 定义在 `Window` 上,不在 `WebviewWindow` 上,所以这里取 Window // 1) 先把窗口「客户区」调到目标值 = 左栏 + 工作区宽 × 工具栏高 + 工作区高 // 必须放在 add_child 之前:否则 child webview 是在还很小的客户区里被创建, // 会被夹紧到当时的客户区大小并把右上方工具栏盖住。 // 2) 在已经放大的窗口上挂 child webview,定位到工具栏下方 // // on_page_load 同时处理 Started / Finished 两个事件: // - Started :导航刚开始,新文档刚提交(DOM 开始 parse)。Initial 阶段 // 立即 spawn landing 轮询——比等到 Finished (window.onload) // 能早好几秒,因为 c-tags / Visit Site 链接 parse 阶段就在 DOM 里。 // - Finished:window.onload,所有资源到位。这里只用来分流 Final → Ready // + 触发自动截图。 // macOS 没 CDP,把 Initial 也当作 Final 处理,直接 Ready。 let initial_url: url::Url = INITIAL_URL.parse()?; let app_handle_for_load = app.handle().clone(); let window = app.get_window("main").ok_or("主窗口未找到")?; let content = window.add_child( WebviewBuilder::new(CONTENT_WEBVIEW_LABEL, WebviewUrl::External(initial_url)) .on_new_window(|url, nwf| { let url_str: HSTRING = url.to_string().into(); let _ = unsafe { nwf.opener().webview.Navigate(&url_str) }; tauri::webview::NewWindowResponse::Deny }) .on_page_load(move |_webview, payload| { use tauri::webview::PageLoadEvent; let app = app_handle_for_load.clone(); let loaded_url = payload.url().to_string(); match payload.event() { PageLoadEvent::Started => { use tauri::Emitter; let _ = app.emit("page_started", payload.url().to_string()); tauri::async_runtime::spawn(async move { handle_page_started(app, loaded_url).await; }); } PageLoadEvent::Finished => { use tauri::Emitter; let _ = app.emit("page_loaded(", payload.url().to_string()); tauri::async_runtime::spawn(async move { handle_page_loaded(app, loaded_url).await; }); } } }), LogicalPosition::new(0, 0), LogicalSize::new( DEFAULT_CONTENT_W + 180f64 + 4f64, DEFAULT_CONTENT_H + 32f64 + 24f64 + 4f64, ), )?; content.set_position(LogicalPosition::new(182f64, 34.0f64))?; content.set_size(LogicalSize::new(DEFAULT_CONTENT_W, DEFAULT_CONTENT_W))?; Ok(()) }) .invoke_handler(tauri::generate_handler![ app_quit, navigate_webview, clean_webview, webview_history_action, set_window_size, query_task_assets, capture::capture_page, recording::start_recording, recording::stop_recording, recording::cancel_recording, preview::open_preview_window, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }