|
| 1 | +//! 图文会话 Tauri commands(v0.11.7,ADR-020)。 |
| 2 | +//! |
| 3 | +//! @ai-context: 第三采集动线编排:start(创建 kind=photo 会话 + 互斥 + 图片库 |
| 4 | +//! store 常驻)→ capture_screen_snapshot(全屏快照 base64,前端 |
| 5 | +//! 框选)→ save_photo_capture(截图落库 + OCR)→ finish/discard。 |
| 6 | +//! @ai-context: 本层只做参数校验、互斥、调用业务模块、错误映射(AGENTS.md §6)。 |
| 7 | +//! @ai-context: 互斥槽双字段——photo_session(会话 id)与 photo_store(长驻 |
| 8 | +//! SessionImageStore,跨截图保持双指纹去重 FIFO 与预算计数)。 |
| 9 | +
|
| 10 | +use base64::Engine; |
| 11 | +use serde::Serialize; |
| 12 | +use tauri::State; |
| 13 | + |
| 14 | +use crate::commands::AppState; |
| 15 | +use crate::image_store::SessionImageStore; |
| 16 | +use crate::types::NewSession; |
| 17 | + |
| 18 | +/// 全屏快照(前端遮罩显示用;JPEG 压缩省传输量——框选裁剪后走 PNG 无损回传)。 |
| 19 | +#[derive(Debug, Clone, Serialize)] |
| 20 | +#[serde(rename_all = "camelCase")] |
| 21 | +pub struct ScreenSnapshot { |
| 22 | + /// JPEG base64(前端拼 data URL 前缀) |
| 23 | + pub base64: String, |
| 24 | + /// 图像物理像素宽(DPI 换算基准) |
| 25 | + pub width: u32, |
| 26 | + /// 图像物理像素高 |
| 27 | + pub height: u32, |
| 28 | +} |
| 29 | + |
| 30 | +/// 互斥检查(纯函数,可单测):实时捕获活跃或图文采集已占用 → Err。 |
| 31 | +/// @ai-context: 抽纯函数便于单测(命令层仅转发——AGENTS.md §6); |
| 32 | +/// 互斥理由:图文与实时共享屏幕内容动线,避免状态混乱。 |
| 33 | +pub fn check_photo_mutex(live_active: bool, occupied: Option<i64>) -> Result<(), String> { |
| 34 | + if live_active { |
| 35 | + return Err("实时捕获进行中,请先停止再开始图文采集".to_string()); |
| 36 | + } |
| 37 | + if occupied.is_some() { |
| 38 | + return Err("已有进行中的图文采集,请先完成或放弃".to_string()); |
| 39 | + } |
| 40 | + Ok(()) |
| 41 | +} |
| 42 | + |
| 43 | +/// 当前 Unix 毫秒(截图文件名时间戳;唯一且随真实时间推进)。 |
| 44 | +fn now_unix_ms() -> u64 { |
| 45 | + std::time::SystemTime::now() |
| 46 | + .duration_since(std::time::UNIX_EPOCH) |
| 47 | + .map(|d| d.as_millis() as u64) |
| 48 | + .unwrap_or(0) |
| 49 | +} |
| 50 | + |
| 51 | +/// 开始图文采集:创建 kind=photo 会话(recording)并占用互斥槽。 |
| 52 | +/// |
| 53 | +/// @ai-context: 默认标题「图文会话」——前端生成带时间的标题传入 |
| 54 | +/// (JS Date 格式化,Rust 侧免引本地时间依赖)。 |
| 55 | +#[tauri::command] |
| 56 | +pub fn start_photo_session(state: State<'_, AppState>, title: Option<String>) -> Result<i64, String> { |
| 57 | + #[cfg(target_os = "windows")] |
| 58 | + let live_active = state.live_session.active_session_id().is_some(); |
| 59 | + #[cfg(not(target_os = "windows"))] |
| 60 | + let live_active = false; |
| 61 | + let mut guard = state.photo_session.lock().map_err(|_| "图文采集状态锁中毒".to_string())?; |
| 62 | + check_photo_mutex(live_active, *guard)?; |
| 63 | + let title = title |
| 64 | + .map(|t| t.trim().to_string()) |
| 65 | + .filter(|t| !t.is_empty()) |
| 66 | + .unwrap_or_else(|| "图文会话".to_string()); |
| 67 | + let session = state |
| 68 | + .db |
| 69 | + .create_session(&NewSession { |
| 70 | + title: title.chars().take(100).collect(), |
| 71 | + source_window: None, |
| 72 | + profile: None, |
| 73 | + kind: Some("photo".to_string()), |
| 74 | + }) |
| 75 | + .map_err(|e| e.to_string())?; |
| 76 | + *guard = Some(session.id); |
| 77 | + // 长驻图片库 store(跨截图保持去重 FIFO 与预算计数——实时链路同模式) |
| 78 | + let dir = state.data_dir.join("session-images").join(session.id.to_string()); |
| 79 | + let store = SessionImageStore::new(dir).map_err(|e| e.to_string())?; |
| 80 | + *state.photo_store.lock().map_err(|_| "图文图片库锁中毒".to_string())? = Some(store); |
| 81 | + Ok(session.id) |
| 82 | +} |
| 83 | + |
| 84 | +/// 捕获全屏快照(虚拟屏合并区域,多显示器支持)→ JPEG base64。 |
| 85 | +/// @ai-context: 无需 AppState(gdi_capture 一次性调用,无持久状态)。 |
| 86 | +#[tauri::command] |
| 87 | +pub fn capture_screen_snapshot() -> Result<ScreenSnapshot, String> { |
| 88 | + let frame = crate::capture::gdi_capture::gdi_capture(None, now_unix_ms()) |
| 89 | + .map_err(|e| format!("截屏失败: {}", e))?; |
| 90 | + let rgb = crate::image_store::bgra_to_rgb_public(&frame.bgraw, frame.width, frame.height) |
| 91 | + .ok_or_else(|| "截屏数据无效".to_string())?; |
| 92 | + // JPEG q0.85(仅前端显示用;OCR 输入走裁剪后 PNG 无损回传) |
| 93 | + let mut out = std::io::Cursor::new(Vec::new()); |
| 94 | + let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, 85); |
| 95 | + rgb.write_with_encoder(encoder).map_err(|e| format!("快照编码失败: {}", e))?; |
| 96 | + let b64 = base64::engine::general_purpose::STANDARD.encode(out.into_inner()); |
| 97 | + Ok(ScreenSnapshot { base64: b64, width: frame.width, height: frame.height }) |
| 98 | +} |
| 99 | + |
| 100 | +/// 保存框选截图 + OCR(PNG base64 无损回传)。 |
| 101 | +/// |
| 102 | +/// @ai-context: 三校验——互斥槽归属(防越权提交其他会话)+ 会话状态 |
| 103 | +/// (recording 才可继续截图)+ kind=photo 限定。 |
| 104 | +/// @ai-context: store 锁内调用业务函数(OCR ≤20s 有界等待;图文采集为 |
| 105 | +/// 用户串行动线,同刻仅一个截图保存,锁时长可接受)。 |
| 106 | +#[tauri::command] |
| 107 | +pub fn save_photo_capture( |
| 108 | + state: State<'_, AppState>, |
| 109 | + session_id: i64, |
| 110 | + image_b64: String, |
| 111 | +) -> Result<crate::photo_capture::PhotoCaptureResult, String> { |
| 112 | + if session_id <= 0 { |
| 113 | + return Err("无效的会话 id".to_string()); |
| 114 | + } |
| 115 | + if image_b64.trim().is_empty() { |
| 116 | + return Err("图片数据为空".to_string()); |
| 117 | + } |
| 118 | + { |
| 119 | + let guard = state.photo_session.lock().map_err(|_| "图文采集状态锁中毒".to_string())?; |
| 120 | + if *guard != Some(session_id) { |
| 121 | + return Err("该会话不是进行中的图文采集".to_string()); |
| 122 | + } |
| 123 | + } |
| 124 | + let session = state |
| 125 | + .db |
| 126 | + .get_session(session_id) |
| 127 | + .map_err(|e| e.to_string())? |
| 128 | + .ok_or_else(|| format!("会话不存在: {}", session_id))?; |
| 129 | + if session.kind.as_deref() != Some("photo") { |
| 130 | + return Err("该会话不是图文会话".to_string()); |
| 131 | + } |
| 132 | + if session.status != "recording" { |
| 133 | + return Err("会话已结束,无法继续截图".to_string()); |
| 134 | + } |
| 135 | + let mut guard = state.photo_store.lock().map_err(|_| "图文图片库锁中毒".to_string())?; |
| 136 | + let store = guard.as_mut().ok_or_else(|| "无进行中的图文图片库(请先开始图文采集)".to_string())?; |
| 137 | + crate::photo_capture::save_photo_capture(store, &state.db, &state.engines, session_id, &image_b64, now_unix_ms()) |
| 138 | +} |
| 139 | + |
| 140 | +/// 完成图文采集:零截图 → 删除会话(不留空壳);否则 finish。 |
| 141 | +#[tauri::command] |
| 142 | +pub fn finish_photo_session(state: State<'_, AppState>, session_id: i64) -> Result<String, String> { |
| 143 | + if session_id <= 0 { |
| 144 | + return Err("无效的会话 id".to_string()); |
| 145 | + } |
| 146 | + let mut guard = state.photo_session.lock().map_err(|_| "图文采集状态锁中毒".to_string())?; |
| 147 | + if *guard != Some(session_id) { |
| 148 | + return Err("该会话不是进行中的图文采集".to_string()); |
| 149 | + } |
| 150 | + let dir = state.data_dir.join("session-images").join(session_id.to_string()); |
| 151 | + let has_images = SessionImageStore::new(dir.clone()) |
| 152 | + .map(|s| !s.list_images().is_empty()) |
| 153 | + .unwrap_or(false); |
| 154 | + if has_images { |
| 155 | + state.db.finish_session(session_id).map_err(|e| e.to_string())?; |
| 156 | + } else { |
| 157 | + state.db.delete_session(session_id).map_err(|e| e.to_string())?; |
| 158 | + let _ = std::fs::remove_dir_all(&dir); |
| 159 | + } |
| 160 | + *guard = None; |
| 161 | + *state.photo_store.lock().map_err(|_| "图文图片库锁中毒".to_string())? = None; |
| 162 | + Ok(if has_images { |
| 163 | + format!("图文会话 #{} 已保存", session_id) |
| 164 | + } else { |
| 165 | + "无截图,会话已删除(不留空壳)".to_string() |
| 166 | + }) |
| 167 | +} |
| 168 | + |
| 169 | +/// 放弃图文采集:删除会话(外键级联子表 + 清理图片库目录)。 |
| 170 | +#[tauri::command] |
| 171 | +pub fn discard_photo_session(state: State<'_, AppState>, session_id: i64) -> Result<(), String> { |
| 172 | + if session_id <= 0 { |
| 173 | + return Err("无效的会话 id".to_string()); |
| 174 | + } |
| 175 | + let mut guard = state.photo_session.lock().map_err(|_| "图文采集状态锁中毒".to_string())?; |
| 176 | + if *guard != Some(session_id) { |
| 177 | + return Err("该会话不是进行中的图文采集".to_string()); |
| 178 | + } |
| 179 | + state.db.delete_session(session_id).map_err(|e| e.to_string())?; |
| 180 | + let dir = state.data_dir.join("session-images").join(session_id.to_string()); |
| 181 | + let _ = std::fs::remove_dir_all(&dir); |
| 182 | + *guard = None; |
| 183 | + *state.photo_store.lock().map_err(|_| "图文图片库锁中毒".to_string())? = None; |
| 184 | + Ok(()) |
| 185 | +} |
| 186 | + |
| 187 | +#[cfg(test)] |
| 188 | +mod tests { |
| 189 | + use super::*; |
| 190 | + |
| 191 | + #[test] |
| 192 | + fn mutex_rejects_live_active() { |
| 193 | + let e = check_photo_mutex(true, None).unwrap_err(); |
| 194 | + assert!(e.contains("实时捕获")); |
| 195 | + } |
| 196 | + |
| 197 | + #[test] |
| 198 | + fn mutex_rejects_occupied_photo() { |
| 199 | + let e = check_photo_mutex(false, Some(7)).unwrap_err(); |
| 200 | + assert!(e.contains("已有进行中")); |
| 201 | + } |
| 202 | + |
| 203 | + #[test] |
| 204 | + fn mutex_allows_when_free() { |
| 205 | + assert!(check_photo_mutex(false, None).is_ok()); |
| 206 | + } |
| 207 | +} |
0 commit comments