1use crate::error::{AppError, Result};
7use crate::screen::{CaptureRegion, ScreenTools, ScreenshotResult};
8use base64::Engine;
9use serde::Serialize;
10use std::path::Path;
11
12#[derive(Debug, Serialize)]
13struct ScreenshotOutput {
14 path: String,
15 width: Option<u32>,
16 height: Option<u32>,
17 format: String,
18 mime_type: String,
19 timestamp: chrono::DateTime<chrono::Utc>,
20 file_size_bytes: u64,
21
22 #[serde(default, skip_serializing_if = "Option::is_none")]
23 inline_base64: Option<String>,
24 #[serde(default, skip_serializing_if = "Option::is_none")]
25 inline_mime_type: Option<String>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 inline_kind: Option<String>,
28}
29
30#[derive(Debug, Serialize)]
31struct RecordingStartOutput {
32 recording_id: String,
33 output_path: String,
34 started_at: chrono::DateTime<chrono::Utc>,
35}
36
37#[derive(Debug, Serialize)]
38struct RecordingStopOutput {
39 recording_id: String,
40 path: String,
41 duration_secs: f64,
42 file_size_bytes: u64,
43 format: String,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ScreenshotReturnMode {
49 Path,
51 InlineBase64,
53}
54
55#[derive(Debug, Clone)]
57pub struct ScreenshotInlineOptions {
58 pub max_width: Option<u32>,
60 pub max_height: Option<u32>,
62 pub max_base64_chars: usize,
64 pub max_result_chars: usize,
66}
67
68impl Default for ScreenshotInlineOptions {
69 fn default() -> Self {
70 Self {
71 max_width: Some(128),
72 max_height: Some(128),
73 max_base64_chars: 1400,
74 max_result_chars: 1800,
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
80pub struct ScreenshotReturnOptions {
81 pub mode: ScreenshotReturnMode,
82 pub inline: ScreenshotInlineOptions,
83}
84
85impl Default for ScreenshotReturnOptions {
86 fn default() -> Self {
87 Self {
88 mode: ScreenshotReturnMode::Path,
89 inline: ScreenshotInlineOptions::default(),
90 }
91 }
92}
93
94fn mime_type_for_ext(ext: &str) -> String {
95 match ext.to_ascii_lowercase().as_str() {
96 "png" => "image/png".to_string(),
97 "jpg" | "jpeg" => "image/jpeg".to_string(),
98 "gif" => "image/gif".to_string(),
99 _ => "application/octet-stream".to_string(),
100 }
101}
102
103fn screenshot_output_json(
104 result: ScreenshotResult,
105 options: ScreenshotReturnOptions,
106) -> Result<String> {
107 let path = result.path.clone();
108 let ext = path
109 .extension()
110 .and_then(|e| e.to_str())
111 .unwrap_or("png")
112 .to_string();
113 let mime_type = mime_type_for_ext(&ext);
114
115 let mut out = ScreenshotOutput {
116 path: result.path.display().to_string(),
117 width: result.width,
118 height: result.height,
119 format: result.format,
120 mime_type,
121 timestamp: result.timestamp,
122 file_size_bytes: result.file_size_bytes,
123 inline_base64: None,
124 inline_mime_type: None,
125 inline_kind: None,
126 };
127
128 if options.mode == ScreenshotReturnMode::InlineBase64 {
129 let (b64, inline_mime, kind) = encode_inline_screenshot(&path, &options.inline)?;
130 out.inline_base64 = Some(b64);
131 out.inline_mime_type = Some(inline_mime);
132 out.inline_kind = Some(kind);
133
134 let max_result_chars = options.inline.max_result_chars.min(2000);
135 let json = serde_json::to_string(&out)?;
136 if json.len() > max_result_chars {
137 return Err(AppError::Io(std::io::Error::other(format!(
138 "Inline screenshot tool output is too large ({} chars; max {}). Reduce inline max_width/max_height/max_base64_chars.",
139 json.len(),
140 max_result_chars
141 ))));
142 }
143 return Ok(json);
144 }
145
146 Ok(serde_json::to_string_pretty(&out)?)
147}
148
149fn encode_inline_screenshot(
150 path: &Path,
151 inline: &ScreenshotInlineOptions,
152) -> Result<(String, String, String)> {
153 const HARD_MAX_BASE64_CHARS: usize = 1700;
154 const HARD_MAX_RESULT_CHARS: usize = 2000;
155 const MIN_THUMB_DIM: u32 = 16;
157
158 let max_base64_chars = inline.max_base64_chars.min(HARD_MAX_BASE64_CHARS);
159 let max_result_chars = inline.max_result_chars.min(HARD_MAX_RESULT_CHARS);
160 if max_base64_chars < 64 {
161 return Err(AppError::Io(std::io::Error::other(
162 "inline.max_base64_chars must be >= 64",
163 )));
164 }
165 if max_result_chars < 256 {
166 return Err(AppError::Io(std::io::Error::other(
167 "inline.max_result_chars must be >= 256",
168 )));
169 }
170
171 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
172 let ext_lc = ext.to_ascii_lowercase();
173
174 if matches!(ext_lc.as_str(), "png" | "jpg" | "jpeg")
178 && let Ok(img) = image::open(path)
179 {
180 let mut w = inline.max_width.unwrap_or(img.width());
181 let mut h = inline.max_height.unwrap_or(img.height());
182
183 loop {
184 let thumb = img.thumbnail(w, h);
185
186 let mut buf = Vec::new();
189 let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 60);
190 thumb.write_with_encoder(encoder).map_err(|e| {
191 AppError::Io(std::io::Error::other(format!(
192 "Failed to encode inline JPEG thumbnail: {e}"
193 )))
194 })?;
195
196 let b64 = base64::engine::general_purpose::STANDARD.encode(&buf);
197 if b64.len() <= max_base64_chars {
198 return Ok((b64, "image/jpeg".to_string(), "thumbnail_jpeg".to_string()));
199 }
200
201 w = (w / 2).max(MIN_THUMB_DIM);
203 h = (h / 2).max(MIN_THUMB_DIM);
204
205 if w <= MIN_THUMB_DIM && h <= MIN_THUMB_DIM {
206 return Err(AppError::Io(std::io::Error::other(format!(
208 "Inline base64 thumbnail too large even at {}×{} ({} chars; max {}). \
209 Increase max_base64_chars or use return.mode='path'.",
210 w,
211 h,
212 b64.len(),
213 max_base64_chars
214 ))));
215 }
216 }
217 }
218
219 let bytes = std::fs::read(path)?;
221 let b64 = base64::engine::general_purpose::STANDARD.encode(&bytes);
222 if b64.len() > max_base64_chars {
223 return Err(AppError::Io(std::io::Error::other(format!(
224 "Inline base64 file payload too large ({} chars; max {}). \
225 Use a smaller capture region, save as PNG/JPG, or use return.mode='path'.",
226 b64.len(),
227 max_base64_chars
228 ))));
229 }
230
231 let mime = mime_type_for_ext(ext);
232 Ok((b64, mime, "raw_file".to_string()))
233}
234
235pub async fn screenshot(
237 output_path: &str,
238 region: Option<(u32, u32, u32, u32)>,
239 display: Option<u32>,
240) -> Result<String> {
241 screenshot_with_options(
242 output_path,
243 region,
244 display,
245 ScreenshotReturnOptions::default(),
246 )
247 .await
248}
249
250pub async fn screenshot_with_options(
252 output_path: &str,
253 region: Option<(u32, u32, u32, u32)>,
254 display: Option<u32>,
255 options: ScreenshotReturnOptions,
256) -> Result<String> {
257 let path = output_path.to_string();
258 let region_opt = region.map(|(x, y, w, h)| CaptureRegion {
259 x,
260 y,
261 width: w,
262 height: h,
263 });
264
265 tokio::task::spawn_blocking(move || {
266 let tools = ScreenTools::new();
267 let result = tools.screenshot(std::path::Path::new(&path), region_opt, display)?;
268
269 screenshot_output_json(result, options)
270 })
271 .await
272 .map_err(|e| AppError::Io(std::io::Error::other(format!("Task join error: {}", e))))?
273}
274
275pub async fn start_recording(
277 output_path: &str,
278 region: Option<(u32, u32, u32, u32)>,
279 display: Option<u32>,
280) -> Result<String> {
281 let path = output_path.to_string();
282 let region_opt = region.map(|(x, y, w, h)| CaptureRegion {
283 x,
284 y,
285 width: w,
286 height: h,
287 });
288
289 tokio::task::spawn_blocking(move || {
290 let tools = ScreenTools::new();
291 let result = tools.start_recording(std::path::Path::new(&path), region_opt, display)?;
292
293 let output = RecordingStartOutput {
294 recording_id: result.recording_id,
295 output_path: result.output_path.display().to_string(),
296 started_at: result.started_at,
297 };
298
299 Ok(serde_json::to_string_pretty(&output)?)
300 })
301 .await
302 .map_err(|e| AppError::Io(std::io::Error::other(format!("Task join error: {}", e))))?
303}
304
305pub async fn stop_recording(recording_id: &str) -> Result<String> {
307 let id = recording_id.to_string();
308
309 tokio::task::spawn_blocking(move || {
310 let tools = ScreenTools::new();
311 let result = tools.stop_recording(&id)?;
312
313 let output = RecordingStopOutput {
314 recording_id: result.recording_id,
315 path: result.path.display().to_string(),
316 duration_secs: result.duration_secs,
317 file_size_bytes: result.file_size_bytes,
318 format: result.format,
319 };
320
321 Ok(serde_json::to_string_pretty(&output)?)
322 })
323 .await
324 .map_err(|e| AppError::Io(std::io::Error::other(format!("Task join error: {}", e))))?
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 #[test]
332 fn inline_jpeg_thumbnail_is_bounded_and_decodable() {
333 let dir = tempfile::tempdir().unwrap();
334 let path = dir.path().join("tiny.png");
335
336 let img = image::RgbaImage::from_fn(32, 32, |x, y| {
338 image::Rgba([(x % 255) as u8, (y % 255) as u8, 0, 255])
339 });
340 img.save_with_format(&path, image::ImageFormat::Png)
341 .unwrap();
342
343 let opts = ScreenshotInlineOptions {
344 max_width: Some(16),
345 max_height: Some(16),
346 max_base64_chars: 1700,
347 max_result_chars: 2000,
348 };
349 let (b64, mime, kind) = encode_inline_screenshot(&path, &opts).unwrap();
350 assert_eq!(mime, "image/jpeg");
352 assert_eq!(kind, "thumbnail_jpeg");
353 assert!(b64.len() <= opts.max_base64_chars);
354
355 let bytes = base64::engine::general_purpose::STANDARD
356 .decode(b64)
357 .unwrap();
358 assert!(bytes.starts_with(&[0xFF, 0xD8]));
360 }
361
362 #[test]
363 fn inline_payload_too_small_errors() {
364 let dir = tempfile::tempdir().unwrap();
365 let path = dir.path().join("tiny.png");
366 let img = image::RgbaImage::from_fn(8, 8, |_x, _y| image::Rgba([0, 0, 0, 255]));
367 img.save_with_format(&path, image::ImageFormat::Png)
368 .unwrap();
369
370 let opts = ScreenshotInlineOptions {
371 max_width: Some(8),
372 max_height: Some(8),
373 max_base64_chars: 64,
374 max_result_chars: 2000,
375 };
376 let opts = ScreenshotInlineOptions {
378 max_base64_chars: 10,
379 ..opts
380 };
381 assert!(encode_inline_screenshot(&path, &opts).is_err());
382 }
383}