| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462 |
- 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-<id>.png)。
- pub current_task_id: Mutex<Option<String>>,
- /// 进行中的录制会话:session_id → 元数据
- pub recording_sessions: Mutex<HashMap<String, RecordingSession>>,
- /// landing 流转阶段;on_page_load 据此分流到「轮询」/「就绪」分支
- pub page_stage: Mutex<PageStage>,
- }
- impl AppState {
- /// 读当前 task id(克隆出来,避免持锁跨 await)
- pub fn current_task(&self) -> Option<String> {
- 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<TaskAssets, String> {
- 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<tauri::Webview, String> {
- 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::<AppState>()?;
- 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::<AppState>() {
- 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");
- }
|