lib.rs 18 KB

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