1use crate::error::{AppError, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::ffi::OsString;
10use std::path::{Path, PathBuf};
11use std::process::Command;
12use std::sync::{Arc, Mutex, OnceLock};
13
14fn ffmpeg_binary() -> OsString {
28 if let Ok(path) = std::env::var("GESTURA_FFMPEG_PATH")
30 && !path.is_empty()
31 {
32 return path.into();
33 }
34
35 if let Ok(exe) = std::env::current_exe()
37 && let Some(dir) = exe.parent()
38 {
39 for name in bundled_ffmpeg_names() {
40 let candidate = dir.join(name);
41 if candidate.is_file() {
42 tracing::debug!("Using bundled ffmpeg sidecar: {:?}", candidate);
43 return candidate.into_os_string();
44 }
45 }
46 }
47
48 tracing::debug!(
50 "No bundled ffmpeg found; falling back to system ffmpeg. \
51 Install ffmpeg or set GESTURA_FFMPEG_PATH to enable screen recording."
52 );
53 OsString::from("ffmpeg")
54}
55
56fn bundled_ffmpeg_names() -> &'static [&'static str] {
63 #[cfg(target_os = "macos")]
64 {
65 &[
66 "ffmpeg-universal-apple-darwin",
67 "ffmpeg-aarch64-apple-darwin",
68 "ffmpeg-x86_64-apple-darwin",
69 ]
70 }
71 #[cfg(target_os = "linux")]
72 {
73 &[
74 "ffmpeg-x86_64-unknown-linux-gnu",
75 "ffmpeg-aarch64-unknown-linux-gnu",
76 "ffmpeg-x86_64-unknown-linux-musl",
77 ]
78 }
79 #[cfg(target_os = "windows")]
80 {
81 &[
82 "ffmpeg-x86_64-pc-windows-msvc.exe",
83 "ffmpeg-i686-pc-windows-msvc.exe",
84 "ffmpeg-x86_64-pc-windows-gnu.exe",
85 "ffmpeg.exe",
86 ]
87 }
88 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
89 {
90 &[]
91 }
92}
93
94#[cfg(unix)]
95use nix::sys::signal::{Signal, kill};
96#[cfg(unix)]
97use nix::unistd::Pid;
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ScreenshotResult {
102 pub path: PathBuf,
103 pub width: Option<u32>,
104 pub height: Option<u32>,
105 pub format: String,
106 pub timestamp: chrono::DateTime<chrono::Utc>,
107 pub file_size_bytes: u64,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct RecordingStartResult {
113 pub recording_id: String,
114 pub output_path: PathBuf,
115 pub started_at: chrono::DateTime<chrono::Utc>,
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct RecordingStopResult {
121 pub recording_id: String,
122 pub path: PathBuf,
123 pub duration_secs: f64,
124 pub file_size_bytes: u64,
125 pub format: String,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
130pub struct CaptureRegion {
131 pub x: u32,
132 pub y: u32,
133 pub width: u32,
134 pub height: u32,
135}
136
137#[derive(Debug)]
139struct ActiveRecording {
140 child: std::process::Child,
141 output_path: PathBuf,
142 started_at: chrono::DateTime<chrono::Utc>,
143}
144
145pub struct ScreenTools {
146 active_recordings: Arc<Mutex<HashMap<String, ActiveRecording>>>,
147}
148
149impl Default for ScreenTools {
150 fn default() -> Self {
151 Self::new()
152 }
153}
154
155impl ScreenTools {
156 fn shared_recordings() -> Arc<Mutex<HashMap<String, ActiveRecording>>> {
157 static RECORDINGS: OnceLock<Arc<Mutex<HashMap<String, ActiveRecording>>>> = OnceLock::new();
158 RECORDINGS
159 .get_or_init(|| Arc::new(Mutex::new(HashMap::new())))
160 .clone()
161 }
162
163 fn ensure_parent_dir(path: &Path) -> Result<()> {
164 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
165 std::fs::create_dir_all(parent)?;
166 }
167 Ok(())
168 }
169
170 pub fn new() -> Self {
171 Self {
172 active_recordings: Self::shared_recordings(),
173 }
174 }
175
176 pub fn screenshot(
183 &self,
184 output_path: &Path,
185 region: Option<CaptureRegion>,
186 display: Option<u32>,
187 ) -> Result<ScreenshotResult> {
188 Self::ensure_parent_dir(output_path)?;
189
190 #[cfg(target_os = "macos")]
191 {
192 self.screenshot_macos(output_path, region, display)
193 }
194
195 #[cfg(target_os = "linux")]
196 {
197 self.screenshot_linux(output_path, region, display)
198 }
199
200 #[cfg(target_os = "windows")]
201 {
202 self.screenshot_windows(output_path, region, display)
203 }
204
205 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
206 {
207 Err(AppError::Io(std::io::Error::new(
208 std::io::ErrorKind::Unsupported,
209 "Screenshot not supported on this platform",
210 )))
211 }
212 }
213
214 pub fn start_recording(
221 &self,
222 output_path: &Path,
223 region: Option<CaptureRegion>,
224 display: Option<u32>,
225 ) -> Result<RecordingStartResult> {
226 Self::ensure_parent_dir(output_path)?;
227
228 #[cfg(target_os = "macos")]
229 {
230 self.start_recording_macos(output_path, region, display)
231 }
232
233 #[cfg(target_os = "linux")]
234 {
235 self.start_recording_linux(output_path, region, display)
236 }
237
238 #[cfg(target_os = "windows")]
239 {
240 self.start_recording_windows(output_path, region, display)
241 }
242
243 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
244 {
245 Err(AppError::Io(std::io::Error::new(
246 std::io::ErrorKind::Unsupported,
247 "Screen recording not supported on this platform",
248 )))
249 }
250 }
251
252 pub fn stop_recording(&self, recording_id: &str) -> Result<RecordingStopResult> {
254 #[cfg(target_os = "macos")]
255 {
256 self.stop_recording_macos(recording_id)
257 }
258
259 #[cfg(target_os = "linux")]
260 {
261 self.stop_recording_linux(recording_id)
262 }
263
264 #[cfg(target_os = "windows")]
265 {
266 self.stop_recording_windows(recording_id)
267 }
268
269 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
270 {
271 Err(AppError::Io(std::io::Error::new(
272 std::io::ErrorKind::Unsupported,
273 "Screen recording not supported on this platform",
274 )))
275 }
276 }
277
278 #[cfg(target_os = "macos")]
283 fn screenshot_macos(
284 &self,
285 output_path: &Path,
286 region: Option<CaptureRegion>,
287 display: Option<u32>,
288 ) -> Result<ScreenshotResult> {
289 let mut cmd = Command::new("screencapture");
290
291 if let Some(r) = region {
293 cmd.arg("-R");
294 cmd.arg(format!("{},{},{},{}", r.x, r.y, r.width, r.height));
295 }
296
297 if let Some(d) = display {
299 cmd.arg("-D");
300 cmd.arg(d.to_string());
301 }
302
303 cmd.arg(output_path);
305
306 let output = cmd.output().map_err(|e| {
307 AppError::Io(std::io::Error::other(format!(
308 "Failed to execute screencapture: {}",
309 e
310 )))
311 })?;
312
313 if !output.status.success() {
314 return Err(AppError::Io(std::io::Error::other(format!(
315 "screencapture failed: {}",
316 String::from_utf8_lossy(&output.stderr)
317 ))));
318 }
319
320 let metadata = std::fs::metadata(output_path)?;
321 let file_size_bytes = metadata.len();
322
323 let (width, height) = self
325 .get_image_dimensions_macos(output_path)
326 .unwrap_or((None, None));
327
328 Ok(ScreenshotResult {
329 path: output_path.to_path_buf(),
330 width,
331 height,
332 format: output_path
333 .extension()
334 .and_then(|e| e.to_str())
335 .unwrap_or("png")
336 .to_string(),
337 timestamp: chrono::Utc::now(),
338 file_size_bytes,
339 })
340 }
341
342 #[cfg(target_os = "macos")]
343 fn get_image_dimensions_macos(&self, path: &Path) -> Option<(Option<u32>, Option<u32>)> {
344 let output = Command::new("sips")
345 .arg("-g")
346 .arg("pixelWidth")
347 .arg("-g")
348 .arg("pixelHeight")
349 .arg(path)
350 .output()
351 .ok()?;
352
353 if !output.status.success() {
354 return None;
355 }
356
357 let stdout = String::from_utf8_lossy(&output.stdout);
358 let mut width = None;
359 let mut height = None;
360
361 for line in stdout.lines() {
362 if line.contains("pixelWidth:") {
363 width = line.split_whitespace().last()?.parse().ok();
364 } else if line.contains("pixelHeight:") {
365 height = line.split_whitespace().last()?.parse().ok();
366 }
367 }
368
369 Some((width, height))
370 }
371
372 #[cfg(target_os = "macos")]
373 fn start_recording_macos(
374 &self,
375 output_path: &Path,
376 region: Option<CaptureRegion>,
377 _display: Option<u32>,
378 ) -> Result<RecordingStartResult> {
379 let mut cmd = Command::new(ffmpeg_binary());
381 cmd.arg("-f").arg("avfoundation");
382
383 cmd.arg("-i").arg("1:none"); if let Some(r) = region {
388 cmd.arg("-filter:v");
389 cmd.arg(format!("crop={}:{}:{}:{}", r.width, r.height, r.x, r.y));
390 }
391
392 cmd.arg("-c:v").arg("libx264");
394 cmd.arg("-preset").arg("ultrafast");
395 cmd.arg("-y"); cmd.arg(output_path);
397
398 let child = cmd.spawn().map_err(|e| {
399 AppError::Io(std::io::Error::other(format!(
400 "Failed to start ffmpeg: {}",
401 e
402 )))
403 })?;
404
405 let recording_id = uuid::Uuid::new_v4().to_string();
406
407 let started_at = chrono::Utc::now();
408 if let Ok(mut recordings) = self.active_recordings.lock() {
409 recordings.insert(
410 recording_id.clone(),
411 ActiveRecording {
412 child,
413 output_path: output_path.to_path_buf(),
414 started_at,
415 },
416 );
417 }
418
419 Ok(RecordingStartResult {
420 recording_id,
421 output_path: output_path.to_path_buf(),
422 started_at,
423 })
424 }
425
426 #[cfg(target_os = "macos")]
427 fn stop_recording_macos(&self, recording_id: &str) -> Result<RecordingStopResult> {
428 let mut handle = if let Ok(mut recordings) = self.active_recordings.lock() {
429 recordings.remove(recording_id).ok_or_else(|| {
430 AppError::Io(std::io::Error::new(
431 std::io::ErrorKind::NotFound,
432 format!("Recording not found: {}", recording_id),
433 ))
434 })?
435 } else {
436 return Err(AppError::Io(std::io::Error::other(
437 "Failed to access recordings",
438 )));
439 };
440
441 #[cfg(unix)]
443 {
444 let pid = Pid::from_raw(handle.child.id() as i32);
445 let _ = kill(pid, Signal::SIGINT);
446 }
447
448 let _ = handle.child.wait()?;
450
451 let duration_ms = (chrono::Utc::now() - handle.started_at).num_milliseconds();
452 let duration_secs = (duration_ms.max(0) as f64) / 1000.0;
453
454 let metadata = std::fs::metadata(&handle.output_path)?;
455 let file_size_bytes = metadata.len();
456
457 Ok(RecordingStopResult {
458 recording_id: recording_id.to_string(),
459 path: handle.output_path.clone(),
460 duration_secs,
461 file_size_bytes,
462 format: handle
463 .output_path
464 .extension()
465 .and_then(|e| e.to_str())
466 .unwrap_or("mp4")
467 .to_string(),
468 })
469 }
470
471 #[cfg(target_os = "linux")]
476 fn screenshot_linux(
477 &self,
478 output_path: &Path,
479 region: Option<CaptureRegion>,
480 _display: Option<u32>,
481 ) -> Result<ScreenshotResult> {
482 if self
484 .try_screenshot_portal(output_path, region.clone())
485 .is_ok()
486 {
487 return self.get_screenshot_result(output_path);
488 }
489
490 let mut cmd = Command::new("scrot");
492
493 if let Some(r) = region {
494 cmd.arg("-a");
495 cmd.arg(format!("{},{},{},{}", r.x, r.y, r.width, r.height));
496 }
497
498 cmd.arg(output_path);
499
500 let output = cmd.output().map_err(|e| {
501 AppError::Io(std::io::Error::other(format!(
502 "Failed to execute scrot (install scrot or use Wayland with xdg-desktop-portal): {}",
503 e
504 )))
505 })?;
506
507 if !output.status.success() {
508 return Err(AppError::Io(std::io::Error::other(format!(
509 "scrot failed: {}",
510 String::from_utf8_lossy(&output.stderr)
511 ))));
512 }
513
514 self.get_screenshot_result(output_path)
515 }
516
517 #[cfg(target_os = "linux")]
518 fn try_screenshot_portal(
519 &self,
520 output_path: &Path,
521 _region: Option<CaptureRegion>,
522 ) -> Result<()> {
523 let output = Command::new("grim")
525 .arg(output_path)
526 .output()
527 .map_err(|e| {
528 AppError::Io(std::io::Error::other(format!("grim not available: {}", e)))
529 })?;
530
531 if !output.status.success() {
532 return Err(AppError::Io(std::io::Error::other("grim failed")));
533 }
534
535 Ok(())
536 }
537
538 #[cfg(target_os = "linux")]
539 fn get_screenshot_result(&self, output_path: &Path) -> Result<ScreenshotResult> {
540 let metadata = std::fs::metadata(output_path)?;
541 let file_size_bytes = metadata.len();
542
543 let (width, height) = self
545 .get_image_dimensions_linux(output_path)
546 .unwrap_or((None, None));
547
548 Ok(ScreenshotResult {
549 path: output_path.to_path_buf(),
550 width,
551 height,
552 format: output_path
553 .extension()
554 .and_then(|e| e.to_str())
555 .unwrap_or("png")
556 .to_string(),
557 timestamp: chrono::Utc::now(),
558 file_size_bytes,
559 })
560 }
561
562 #[cfg(target_os = "linux")]
563 fn get_image_dimensions_linux(&self, path: &Path) -> Option<(Option<u32>, Option<u32>)> {
564 let output = Command::new("identify")
565 .arg("-format")
566 .arg("%w %h")
567 .arg(path)
568 .output()
569 .ok()?;
570
571 if !output.status.success() {
572 return None;
573 }
574
575 let stdout = String::from_utf8_lossy(&output.stdout);
576 let parts: Vec<&str> = stdout.split_whitespace().collect();
577
578 if parts.len() >= 2 {
579 let width = parts[0].parse().ok();
580 let height = parts[1].parse().ok();
581 Some((width, height))
582 } else {
583 None
584 }
585 }
586
587 #[cfg(target_os = "linux")]
588 fn start_recording_linux(
589 &self,
590 output_path: &Path,
591 region: Option<CaptureRegion>,
592 _display: Option<u32>,
593 ) -> Result<RecordingStartResult> {
594 let mut cmd = if self.is_wayland() {
596 let mut c = Command::new("wf-recorder");
597
598 if let Some(r) = region {
599 c.arg("-g");
600 c.arg(format!("{},{} {}x{}", r.x, r.y, r.width, r.height));
601 }
602
603 c.arg("-f");
604 c.arg(output_path);
605 c
606 } else {
607 let mut c = Command::new(ffmpeg_binary());
609 c.arg("-f").arg("x11grab");
610 c.arg("-i").arg(":0.0");
611
612 if let Some(r) = region {
613 c.arg("-video_size");
614 c.arg(format!("{}x{}", r.width, r.height));
615 c.arg("-grab_x").arg(r.x.to_string());
616 c.arg("-grab_y").arg(r.y.to_string());
617 }
618
619 c.arg("-c:v").arg("libx264");
620 c.arg("-preset").arg("ultrafast");
621 c.arg("-y");
622 c.arg(output_path);
623 c
624 };
625
626 let child = cmd.spawn().map_err(|e| {
627 AppError::Io(std::io::Error::other(format!(
628 "Failed to start screen recording: {}",
629 e
630 )))
631 })?;
632
633 let recording_id = uuid::Uuid::new_v4().to_string();
634
635 let started_at = chrono::Utc::now();
636 if let Ok(mut recordings) = self.active_recordings.lock() {
637 recordings.insert(
638 recording_id.clone(),
639 ActiveRecording {
640 child,
641 output_path: output_path.to_path_buf(),
642 started_at,
643 },
644 );
645 }
646
647 Ok(RecordingStartResult {
648 recording_id,
649 output_path: output_path.to_path_buf(),
650 started_at,
651 })
652 }
653
654 #[cfg(target_os = "linux")]
655 fn is_wayland(&self) -> bool {
656 std::env::var("WAYLAND_DISPLAY").is_ok()
657 }
658
659 #[cfg(target_os = "linux")]
660 fn stop_recording_linux(&self, recording_id: &str) -> Result<RecordingStopResult> {
661 let mut handle = if let Ok(mut recordings) = self.active_recordings.lock() {
662 recordings.remove(recording_id).ok_or_else(|| {
663 AppError::Io(std::io::Error::new(
664 std::io::ErrorKind::NotFound,
665 format!("Recording not found: {}", recording_id),
666 ))
667 })?
668 } else {
669 return Err(AppError::Io(std::io::Error::other(
670 "Failed to access recordings",
671 )));
672 };
673
674 let pid = Pid::from_raw(handle.child.id() as i32);
676 let _ = kill(pid, Signal::SIGINT);
677
678 let _ = handle.child.wait()?;
679
680 let duration_ms = (chrono::Utc::now() - handle.started_at).num_milliseconds();
681 let duration_secs = (duration_ms.max(0) as f64) / 1000.0;
682
683 let metadata = std::fs::metadata(&handle.output_path)?;
684 let file_size_bytes = metadata.len();
685
686 Ok(RecordingStopResult {
687 recording_id: recording_id.to_string(),
688 path: handle.output_path.clone(),
689 duration_secs,
690 file_size_bytes,
691 format: handle
692 .output_path
693 .extension()
694 .and_then(|e| e.to_str())
695 .unwrap_or("mp4")
696 .to_string(),
697 })
698 }
699
700 #[cfg(target_os = "windows")]
705 fn screenshot_windows(
706 &self,
707 output_path: &Path,
708 region: Option<CaptureRegion>,
709 _display: Option<u32>,
710 ) -> Result<ScreenshotResult> {
711 let ps_script = if let Some(r) = region {
713 format!(
714 r#"Add-Type -AssemblyName System.Windows.Forms,System.Drawing;
715 $bounds = New-Object Drawing.Rectangle {},{},{},{};
716 $bmp = New-Object Drawing.Bitmap $bounds.Width,$bounds.Height;
717 $graphics = [Drawing.Graphics]::FromImage($bmp);
718 $graphics.CopyFromScreen($bounds.Location, [Drawing.Point]::Empty, $bounds.Size);
719 $bmp.Save('{}');
720 $graphics.Dispose();
721 $bmp.Dispose();"#,
722 r.x,
723 r.y,
724 r.width,
725 r.height,
726 output_path.display()
727 )
728 } else {
729 format!(
730 r#"Add-Type -AssemblyName System.Windows.Forms,System.Drawing;
731 $screen = [System.Windows.Forms.Screen]::PrimaryScreen;
732 $bounds = $screen.Bounds;
733 $bmp = New-Object Drawing.Bitmap $bounds.Width,$bounds.Height;
734 $graphics = [Drawing.Graphics]::FromImage($bmp);
735 $graphics.CopyFromScreen($bounds.Location, [Drawing.Point]::Empty, $bounds.Size);
736 $bmp.Save('{}');
737 $graphics.Dispose();
738 $bmp.Dispose();"#,
739 output_path.display()
740 )
741 };
742
743 let output = Command::new("powershell")
744 .arg("-NoProfile")
745 .arg("-Command")
746 .arg(&ps_script)
747 .output()
748 .map_err(|e| {
749 AppError::Io(std::io::Error::other(format!(
750 "Failed to execute PowerShell: {}",
751 e
752 )))
753 })?;
754
755 if !output.status.success() {
756 return Err(AppError::Io(std::io::Error::other(format!(
757 "PowerShell screenshot failed: {}",
758 String::from_utf8_lossy(&output.stderr)
759 ))));
760 }
761
762 let metadata = std::fs::metadata(output_path)?;
763 let file_size_bytes = metadata.len();
764
765 Ok(ScreenshotResult {
766 path: output_path.to_path_buf(),
767 width: None, height: None,
769 format: output_path
770 .extension()
771 .and_then(|e| e.to_str())
772 .unwrap_or("png")
773 .to_string(),
774 timestamp: chrono::Utc::now(),
775 file_size_bytes,
776 })
777 }
778
779 #[cfg(target_os = "windows")]
780 fn start_recording_windows(
781 &self,
782 output_path: &Path,
783 _region: Option<CaptureRegion>,
784 _display: Option<u32>,
785 ) -> Result<RecordingStartResult> {
786 let mut cmd = Command::new(ffmpeg_binary());
788 cmd.arg("-f").arg("gdigrab");
789 cmd.arg("-i").arg("desktop");
790 cmd.arg("-c:v").arg("libx264");
791 cmd.arg("-preset").arg("ultrafast");
792 cmd.arg("-y");
793 cmd.arg(output_path);
794
795 let child = cmd.spawn().map_err(|e| {
796 AppError::Io(std::io::Error::other(format!(
797 "Failed to start ffmpeg for screen recording (bundled binary not found and \
798 ffmpeg is not on PATH — set GESTURA_FFMPEG_PATH if needed): {}",
799 e
800 )))
801 })?;
802
803 let recording_id = uuid::Uuid::new_v4().to_string();
804
805 let started_at = chrono::Utc::now();
806 if let Ok(mut recordings) = self.active_recordings.lock() {
807 recordings.insert(
808 recording_id.clone(),
809 ActiveRecording {
810 child,
811 output_path: output_path.to_path_buf(),
812 started_at,
813 },
814 );
815 }
816
817 Ok(RecordingStartResult {
818 recording_id,
819 output_path: output_path.to_path_buf(),
820 started_at,
821 })
822 }
823
824 #[cfg(target_os = "windows")]
825 fn stop_recording_windows(&self, recording_id: &str) -> Result<RecordingStopResult> {
826 let mut handle = if let Ok(mut recordings) = self.active_recordings.lock() {
827 recordings.remove(recording_id).ok_or_else(|| {
828 AppError::Io(std::io::Error::new(
829 std::io::ErrorKind::NotFound,
830 format!("Recording not found: {}", recording_id),
831 ))
832 })?
833 } else {
834 return Err(AppError::Io(std::io::Error::other(
835 "Failed to access recordings",
836 )));
837 };
838
839 handle.child.kill()?;
841 let _ = handle.child.wait()?;
842
843 let duration_ms = (chrono::Utc::now() - handle.started_at).num_milliseconds();
844 let duration_secs = (duration_ms.max(0) as f64) / 1000.0;
845
846 let metadata = std::fs::metadata(&handle.output_path)?;
847 let file_size_bytes = metadata.len();
848
849 Ok(RecordingStopResult {
850 recording_id: recording_id.to_string(),
851 path: handle.output_path.clone(),
852 duration_secs,
853 file_size_bytes,
854 format: handle
855 .output_path
856 .extension()
857 .and_then(|e| e.to_str())
858 .unwrap_or("mp4")
859 .to_string(),
860 })
861 }
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867 use std::sync::Arc;
868
869 #[test]
870 fn test_screen_tools_creation() {
871 let tools = ScreenTools::new();
872 assert!(std::mem::size_of_val(&tools) > 0);
874 }
875
876 #[test]
877 fn screen_tools_share_global_recording_registry() {
878 let a = ScreenTools::new();
879 let b = ScreenTools::new();
880 assert!(Arc::ptr_eq(&a.active_recordings, &b.active_recordings));
881 }
882
883 #[test]
884 fn test_capture_region_serialization() {
885 let region = CaptureRegion {
886 x: 0,
887 y: 0,
888 width: 800,
889 height: 600,
890 };
891 let json = serde_json::to_string(®ion).unwrap();
892 assert!(json.contains("800"));
893 assert!(json.contains("600"));
894 }
895
896 #[test]
897 fn test_screenshot_result_serialization() {
898 let result = ScreenshotResult {
899 path: PathBuf::from("/tmp/test.png"),
900 width: Some(1920),
901 height: Some(1080),
902 format: "png".to_string(),
903 timestamp: chrono::Utc::now(),
904 file_size_bytes: 12345,
905 };
906 let json = serde_json::to_string(&result).unwrap();
907 assert!(json.contains("1920"));
908 assert!(json.contains("1080"));
909 assert!(json.contains("png"));
910 }
911}