lib.rs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. mod capture;
  2. mod cdp;
  3. mod landing;
  4. mod paths;
  5. mod preview;
  6. mod recording;
  7. mod shortcuts;
  8. use serde::Serialize;
  9. use std::sync::Mutex;
  10. use std::{collections::HashMap, process};
  11. use tauri::{
  12. webview::WebviewBuilder, AppHandle, LogicalPosition, LogicalSize, Manager, WebviewUrl,
  13. };
  14. use windows::core::HSTRING;
  15. use recording::RecordingSession;
  16. /// 子 webview 的 label
  17. pub(crate) const CONTENT_WEBVIEW_LABEL: &str = "content";
  18. /// 子 webview 初始尺寸:1280×720 横屏
  19. const DEFAULT_CONTENT_W: f64 = 1280.0f64;
  20. const DEFAULT_CONTENT_H: f64 = 720.0f64;
  21. /// 子 webview 初始 url(空白页占位,等用户从任务列表选)
  22. const INITIAL_URL: &str = "about:blank";
  23. /// 当前任务页面所处阶段(landing 流转状态机)。
  24. ///
  25. /// 状态转换:
  26. /// navigate_webview(task.url) ──► Initial(等待中间页加载 + 轮询)
  27. /// landing 找到 Site ──► Final (等待最终页加载)
  28. /// 最终页 on_page_load ──► Ready (按钮可用,自动截图触发)
  29. /// 再次 navigate_webview ──► Initial(重置)
  30. #[derive(Debug, Clone, Copy, PartialEq, Eq)]
  31. pub enum PageStage {
  32. Initial,
  33. Final,
  34. Ready,
  35. }
  36. impl Default for PageStage {
  37. fn default() -> Self {
  38. // 首次启动 webview 加载 about:blank,没有任务上下文;Initial 不会被使用
  39. PageStage::Initial
  40. }
  41. }
  42. /// 全局应用状态:跨命令共享的"当前任务 id"、录制会话等。
  43. ///
  44. /// 用 std::sync::Mutex 包裹,命令是同步执行的,锁竞争极低;如果后续要在
  45. /// 异步任务中持有锁过临界点,再迁到 tokio::sync::Mutex。
  46. #[derive(Default)]
  47. pub struct AppState {
  48. /// 当前加载到子 webview 的任务 id;None 表示子 webview 还在 about:blank。
  49. /// 自动截图触发时读取本字段决定文件名 (task-<id>.png)。
  50. pub current_task_id: Mutex<Option<String>>,
  51. /// 进行中的录制会话:session_id → 元数据
  52. pub recording_sessions: Mutex<HashMap<String, RecordingSession>>,
  53. /// landing 流转阶段;on_page_load 据此分流到「轮询」/「就绪」分支
  54. pub page_stage: Mutex<PageStage>,
  55. }
  56. impl AppState {
  57. /// 读当前 task id(克隆出来,避免持锁跨 await)
  58. pub fn current_task(&self) -> Option<String> {
  59. self.current_task_id.lock().ok().and_then(|g| g.clone())
  60. }
  61. }
  62. /// 任务的产物文件查询结果
  63. #[derive(Serialize)]
  64. struct TaskAssets {
  65. task_id: String,
  66. /// 截图文件预期路径(无论是否存在)
  67. screenshot_path: String,
  68. /// 截图文件是否存在
  69. screenshot_exists: bool,
  70. /// 录制成片预期路径
  71. recording_path: String,
  72. /// 录制成片是否存在
  73. recording_exists: bool,
  74. /// 截图根目录(兜底 reveal 用)
  75. screenshots_dir: String,
  76. /// 录制根目录(兜底 reveal 用)
  77. recordings_dir: String,
  78. }
  79. #[tauri::command]
  80. fn app_quit(_app: AppHandle) {
  81. process::exit(0);
  82. }
  83. /// 查询指定任务有哪些可用的产物文件,供前端:
  84. /// - 决定"图片预览"/"视频预览"按钮的 disabled 状态
  85. /// - 决定"打开文件夹"按钮的 reveal 目标(mp4 > png > screenshots 目录)
  86. #[tauri::command]
  87. fn query_task_assets(app: AppHandle, task_id: String) -> Result<TaskAssets, String> {
  88. let screenshot = paths::screenshot_path_for_task(&app, &task_id)?;
  89. let recording = paths::recording_final_path_for_task(&app, &task_id)?;
  90. let s_dir = paths::screenshots_dir(&app)?;
  91. let r_dir = paths::recordings_dir(&app)?;
  92. Ok(TaskAssets {
  93. task_id,
  94. screenshot_exists: screenshot.exists(),
  95. screenshot_path: screenshot.to_string_lossy().to_string(),
  96. recording_exists: recording.exists(),
  97. recording_path: recording.to_string_lossy().to_string(),
  98. screenshots_dir: s_dir.to_string_lossy().to_string(),
  99. recordings_dir: r_dir.to_string_lossy().to_string(),
  100. })
  101. }
  102. /// 在所有 webview 中查找 label = `content` 的子 webview
  103. pub(crate) fn find_content_webview(app: &tauri::AppHandle) -> Result<tauri::Webview, String> {
  104. app.webviews()
  105. .into_iter()
  106. .find_map(|(label, w)| {
  107. if label.as_str() == CONTENT_WEBVIEW_LABEL {
  108. Some(w)
  109. } else {
  110. None
  111. }
  112. })
  113. .ok_or_else(|| format!("找不到 webview: {CONTENT_WEBVIEW_LABEL}"))
  114. }
  115. #[tauri::command]
  116. fn clean_webview(app: tauri::AppHandle) -> Result<(), String> {
  117. let parsed = url::Url::parse(INITIAL_URL)
  118. .map_err(|e| format!("url ({})解析失败: {}", INITIAL_URL, e))?;
  119. let webview = find_content_webview(&app)?;
  120. webview.navigate(parsed).map_err(|e| e.to_string())
  121. }
  122. /// 加载新的 url 到子 webview,并把 task_id 记入 AppState,用于后续自动截图命名。
  123. #[tauri::command]
  124. fn navigate_webview(
  125. app: tauri::AppHandle,
  126. state: tauri::State<'_, AppState>,
  127. task_id: String,
  128. url: String,
  129. ) -> Result<(), String> {
  130. let parsed = url::Url::parse(&url).map_err(|e| format!("url ({})解析失败: {}", &url, e))?;
  131. // 先更新 state(即便后续 navigate 失败,state 也会被下一次正常调用覆盖)。
  132. // 同时把 page_stage 重置为 Initial:这次 navigate 永远指向「中间页」,
  133. // 后续 landing 模块决定是否再 navigate 到 Site URL。
  134. if let Ok(mut guard) = state.current_task_id.lock() {
  135. *guard = Some(task_id);
  136. }
  137. if let Ok(mut guard) = state.page_stage.lock() {
  138. *guard = PageStage::Initial;
  139. }
  140. let webview = find_content_webview(&app)?;
  141. webview.navigate(parsed).map_err(|e| e.to_string())
  142. }
  143. /// 在子 webview 上执行历史导航 / 刷新动作。
  144. ///
  145. /// 通过 webview.eval 跑很短的一行 JS,避开各平台 API 差异:
  146. /// - "back" → history.back()
  147. /// - "forward" → history.forward()
  148. /// - "reload" → location.reload()
  149. ///
  150. /// 不主动重置 page_stage:用户点 Back 回到中间页 / 点 Refresh 重载最终页时,
  151. /// 状态机已经是 Final/Ready,handle_page_started/loaded 会按当前 stage 静默处理
  152. /// (即不会重新启动 landing 轮询,也不会重复触发自动截图)。如果用户想完整重跑
  153. /// landing 流程,应通过左侧任务列表再点一下任务(走 navigate_webview,那个命令
  154. /// 会把 stage 重置为 Initial)。
  155. #[tauri::command]
  156. fn webview_history_action(app: tauri::AppHandle, action: String) -> Result<(), String> {
  157. let script = match action.as_str() {
  158. "back" => "history.back()",
  159. "forward" => "history.forward()",
  160. "reload" => "location.reload()",
  161. other => return Err(format!("未知的 webview 历史动作: {other}")),
  162. };
  163. let webview = find_content_webview(&app)?;
  164. webview.eval(script).map_err(|e| e.to_string())
  165. }
  166. /// 修改主窗口的「客户区」尺寸(逻辑像素,不含系统 titlebar / 边框)。
  167. ///
  168. /// 前端传进来的 (width, height) 含义是「左栏 + 工作区宽」「工具栏高 + 工作区高」,
  169. /// 也就是客户区尺寸;外边的 titlebar / 边框由 helper 自动补偿。
  170. #[tauri::command]
  171. fn set_window_size(
  172. app: tauri::AppHandle,
  173. width: f64,
  174. height: f64,
  175. content_x: f64,
  176. content_y: f64,
  177. content_width: f64,
  178. content_height: f64,
  179. ) -> Result<(), String> {
  180. println!(
  181. "===========:w{},h{},cx{},cy{},cw{},ch{}",
  182. width, height, content_x, content_y, content_width, content_height
  183. );
  184. // 这里取底层 `Window`(而不是 `WebviewWindow`),与 setup 阶段保持一致
  185. let window = app
  186. .get_window("main")
  187. .ok_or_else(|| "找不到主窗口".to_string())?;
  188. window
  189. .set_size(LogicalSize::new(width, height))
  190. .map_err(|e| e.to_string())?;
  191. let content = find_content_webview(&app)?;
  192. content
  193. .set_position(LogicalPosition::new(content_x, content_y))
  194. .map_err(|e| e.to_string())?;
  195. content
  196. .set_size(LogicalSize::new(content_width, content_height))
  197. .map_err(|e| e.to_string())?;
  198. Ok(())
  199. }
  200. /// PageLoadEvent::Started 处理:尽早启动 landing 轮询。
  201. ///
  202. /// 之所以不等到 Finished(= window.onload)才启动,是因为 onload 必须等所有图片 /
  203. /// 广告 / 第三方脚本加载完成,而 `div.c-tags`、`.menu-float__content` 这些节点在
  204. /// HTML parse 阶段就已经进 DOM 了。Started 触发时新文档刚提交,立即跑 CDP 轮询
  205. /// 就能在几百毫秒内捕获到目标节点,无需等几秒的尾资源。
  206. /// 根据 page_stage 分流:
  207. /// - Initial:当前是中间页 → spawn landing 轮询;
  208. /// - Final:当前是 Site 最终页 → emit task-page-ready,1.5s 后自动截图;
  209. /// - Ready:已就绪还在加载(重新刷新等),不做额外动作。
  210. ///
  211. /// 平台说明:landing 轮询仅 Windows 实现(CDP 路径)。macOS 这里 no-op,靠
  212. /// handle_page_loaded 的 Finished 分支把 Initial 当 Final 处理。
  213. async fn handle_page_started(app: AppHandle, loaded_url: String) {
  214. let (task_id, stage) = match read_task_and_stage(&app) {
  215. Some(v) => v,
  216. None => return,
  217. };
  218. if stage != PageStage::Initial {
  219. // Final / Ready 阶段下的 Started(比如 final URL 已开始重定向),交给 Finished 处理
  220. return;
  221. }
  222. #[cfg(target_os = "windows")]
  223. {
  224. landing::spawn_polling(app, task_id, loaded_url);
  225. }
  226. #[cfg(not(target_os = "windows"))]
  227. {
  228. // macOS:什么都不做,等 Finished 一起处理
  229. let _ = (app, task_id, loaded_url);
  230. }
  231. }
  232. /// PageLoadEvent::Finished 处理:把 stage 推进到 Ready + 触发自动截图。
  233. ///
  234. /// 分流:
  235. /// - Initial:Windows 下不动(轮询负责);macOS 直接 promote_to_ready(无 CDP fallback)。
  236. /// - Final :跨平台都 promote_to_ready。
  237. /// - Ready :忽略(用户刷新等同 URL 重新加载)。
  238. async fn handle_page_loaded(app: AppHandle, loaded_url: String) {
  239. let (task_id, stage) = match read_task_and_stage(&app) {
  240. Some(v) => v,
  241. None => return,
  242. };
  243. let should_promote = match stage {
  244. PageStage::Final => true,
  245. // 非 Windows 没有 CDP,Initial 阶段也没有 Visit Site 自动跳转,
  246. // 把 Finished 当作就绪信号;window.onload 已触发,页面已可见,
  247. // 不会出现「按钮就绪但页面白屏」的尴尬。
  248. #[cfg(not(target_os = "windows"))]
  249. PageStage::Initial => true,
  250. _ => false,
  251. };
  252. if should_promote {
  253. promote_to_ready(&app, &task_id, &loaded_url).await;
  254. }
  255. }
  256. /// 从 AppState 读 (task_id, stage) 的小帮手;无任务上下文(about:blank 等)返回 None。
  257. fn read_task_and_stage(app: &AppHandle) -> Option<(String, PageStage)> {
  258. let state = app.try_state::<AppState>()?;
  259. let task_id = state.current_task()?;
  260. let stage = state
  261. .page_stage
  262. .lock()
  263. .ok()
  264. .map(|g| *g)
  265. .unwrap_or(PageStage::Initial);
  266. Some((task_id, stage))
  267. }
  268. /// 把 stage 切到 Ready + emit `task-page-ready` + 1.5s 后触发自动截图。
  269. async fn promote_to_ready(app: &AppHandle, task_id: &str, loaded_url: &str) {
  270. use tauri::Emitter;
  271. if let Some(state) = app.try_state::<AppState>() {
  272. if let Ok(mut g) = state.page_stage.lock() {
  273. *g = PageStage::Ready;
  274. }
  275. }
  276. let _ = app.emit(
  277. "task-page-ready",
  278. serde_json::json!({ "taskId": task_id, "url": loaded_url }),
  279. );
  280. tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
  281. capture::trigger_auto_capture(app.clone()).await;
  282. }
  283. #[cfg_attr(mobile, tauri::mobile_entry_point)]
  284. pub fn run() {
  285. // tauri-plugin-sql 的 schema 迁移定义
  286. // db 名固定为 sqlite:autorecord.db,落在 APPDATA 目录
  287. //
  288. // 注意:plugin-sql 用 SQL 文本 hash 做幂等校验,已经下发到用户机的 migration 字段
  289. // 一律不能再动(哪怕只是空白),否则启动会报 "migration X was previously applied
  290. // but has been modified" 并整个 DB 加载失败。新增改动一律走新的 version。
  291. let sql_migrations = vec![
  292. tauri_plugin_sql::Migration {
  293. version: 1,
  294. description: "create_tasks_table",
  295. 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 );",
  296. kind: tauri_plugin_sql::MigrationKind::Up,
  297. },
  298. // v2:landing 页提取出的 tags(逗号分隔),SQLite 无 VARCHAR 容量限制,统一用 TEXT
  299. tauri_plugin_sql::Migration {
  300. version: 2,
  301. description: "add_tasks_tags",
  302. sql: "ALTER TABLE tasks ADD COLUMN tags TEXT NOT NULL DEFAULT '';",
  303. kind: tauri_plugin_sql::MigrationKind::Up,
  304. },
  305. // v3~v6:补三个产物字段 + status 普通索引。拆成单语句条目避免不同 sqlx 版本
  306. // 对多语句 migration 的处理差异(id 已是 PRIMARY KEY,自带唯一索引不重复建)。
  307. // - site_url:landing 跳转后落地的最终 URL
  308. // - pic / video:最近一次截图 / 录制 mp4 的文件名(仅 basename)
  309. // - idx_tasks_status:主列表 WHERE status=0 谓词的非唯一索引
  310. tauri_plugin_sql::Migration {
  311. version: 3,
  312. description: "add_tasks_site_url",
  313. sql: "ALTER TABLE tasks ADD COLUMN site_url TEXT NOT NULL DEFAULT '';",
  314. kind: tauri_plugin_sql::MigrationKind::Up,
  315. },
  316. tauri_plugin_sql::Migration {
  317. version: 4,
  318. description: "add_tasks_pic",
  319. sql: "ALTER TABLE tasks ADD COLUMN pic TEXT NOT NULL DEFAULT '';",
  320. kind: tauri_plugin_sql::MigrationKind::Up,
  321. },
  322. tauri_plugin_sql::Migration {
  323. version: 5,
  324. description: "add_tasks_video",
  325. sql: "ALTER TABLE tasks ADD COLUMN video TEXT NOT NULL DEFAULT '';",
  326. kind: tauri_plugin_sql::MigrationKind::Up,
  327. },
  328. tauri_plugin_sql::Migration {
  329. version: 6,
  330. description: "create_idx_tasks_status",
  331. sql: "CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);",
  332. kind: tauri_plugin_sql::MigrationKind::Up,
  333. },
  334. ];
  335. tauri::Builder::default()
  336. .plugin(tauri_plugin_opener::init())
  337. .plugin(tauri_plugin_dialog::init())
  338. .plugin(shortcuts::record_shortcut_plugin())
  339. .plugin(
  340. tauri_plugin_sql::Builder::default()
  341. .add_migrations("sqlite:autorecord.db", sql_migrations)
  342. .build(),
  343. )
  344. .manage(AppState::default())
  345. .setup(|app| {
  346. // 注册 F9/F10/F11 全局快捷键。注册失败仅打日志,不阻塞应用启动
  347. // (比如 macOS 上 F9-F11 被 Mission Control 占用时)
  348. if let Err(e) = shortcuts::register_record_shortcuts(app.handle()) {
  349. eprintln!("[shortcuts] {e}");
  350. }
  351. // 在主窗口(也是 React UI 所在的 webview)所属的 Window 上挂一个 child webview
  352. // 注意:`add_child` 定义在 `Window` 上,不在 `WebviewWindow` 上,所以这里取 Window
  353. // 1) 先把窗口「客户区」调到目标值 = 左栏 + 工作区宽 × 工具栏高 + 工作区高
  354. // 必须放在 add_child 之前:否则 child webview 是在还很小的客户区里被创建,
  355. // 会被夹紧到当时的客户区大小并把右上方工具栏盖住。
  356. // 2) 在已经放大的窗口上挂 child webview,定位到工具栏下方
  357. //
  358. // on_page_load 同时处理 Started / Finished 两个事件:
  359. // - Started :导航刚开始,新文档刚提交(DOM 开始 parse)。Initial 阶段
  360. // 立即 spawn landing 轮询——比等到 Finished (window.onload)
  361. // 能早好几秒,因为 c-tags / Visit Site 链接 parse 阶段就在 DOM 里。
  362. // - Finished:window.onload,所有资源到位。这里只用来分流 Final → Ready
  363. // + 触发自动截图。
  364. // macOS 没 CDP,把 Initial 也当作 Final 处理,直接 Ready。
  365. let initial_url: url::Url = INITIAL_URL.parse()?;
  366. let app_handle_for_load = app.handle().clone();
  367. let window = app.get_window("main").ok_or("主窗口未找到")?;
  368. let content = window.add_child(
  369. WebviewBuilder::new(CONTENT_WEBVIEW_LABEL, WebviewUrl::External(initial_url))
  370. .on_new_window(|url, nwf| {
  371. let url_str: HSTRING = url.to_string().into();
  372. let _ = unsafe { nwf.opener().webview.Navigate(&url_str) };
  373. tauri::webview::NewWindowResponse::Deny
  374. })
  375. .on_page_load(move |_webview, payload| {
  376. use tauri::webview::PageLoadEvent;
  377. let app = app_handle_for_load.clone();
  378. let loaded_url = payload.url().to_string();
  379. match payload.event() {
  380. PageLoadEvent::Started => {
  381. use tauri::Emitter;
  382. let _ = app.emit("page_started", payload.url().to_string());
  383. tauri::async_runtime::spawn(async move {
  384. handle_page_started(app, loaded_url).await;
  385. });
  386. }
  387. PageLoadEvent::Finished => {
  388. use tauri::Emitter;
  389. let _ = app.emit("page_loaded(", payload.url().to_string());
  390. tauri::async_runtime::spawn(async move {
  391. handle_page_loaded(app, loaded_url).await;
  392. });
  393. }
  394. }
  395. }),
  396. LogicalPosition::new(0, 0),
  397. LogicalSize::new(
  398. DEFAULT_CONTENT_W + 180f64 + 4f64,
  399. DEFAULT_CONTENT_H + 32f64 + 24f64 + 4f64,
  400. ),
  401. )?;
  402. content.set_position(LogicalPosition::new(182f64, 34.0f64))?;
  403. content.set_size(LogicalSize::new(DEFAULT_CONTENT_W, DEFAULT_CONTENT_W))?;
  404. Ok(())
  405. })
  406. .invoke_handler(tauri::generate_handler![
  407. app_quit,
  408. navigate_webview,
  409. clean_webview,
  410. webview_history_action,
  411. set_window_size,
  412. query_task_assets,
  413. capture::capture_page,
  414. recording::start_recording,
  415. recording::stop_recording,
  416. recording::cancel_recording,
  417. preview::open_preview_window,
  418. ])
  419. .run(tauri::generate_context!())
  420. .expect("error while running tauri application");
  421. }