gestura_core_tools/
screen.rs

1//! Screen capture and recording tool
2//!
3//! Provides cross-platform screenshot and screen recording capabilities with structured output.
4//! All functions return data structures rather than formatted strings.
5
6use 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
14// ============================================================================
15// Bundled ffmpeg resolver
16// ============================================================================
17
18/// Resolve the path to the ffmpeg binary to use for screen recording.
19///
20/// Resolution order (first match wins):
21/// 1. `GESTURA_FFMPEG_PATH` environment variable — allows the host app or tests
22///    to point at a specific binary.
23/// 2. Bundled sidecar placed next to the running executable by the Tauri
24///    installer (named `ffmpeg-<target-triple>[.exe]`).
25/// 3. `ffmpeg` on the system `PATH` (original behaviour — requires the user to
26///    have ffmpeg installed).
27fn ffmpeg_binary() -> OsString {
28    // 1. Explicit override via env var.
29    if let Ok(path) = std::env::var("GESTURA_FFMPEG_PATH")
30        && !path.is_empty()
31    {
32        return path.into();
33    }
34
35    // 2. Bundled sidecar next to the running executable.
36    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    // 3. System ffmpeg fallback.
49    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
56/// Platform-specific candidate sidecar filenames (Tauri externalBin naming).
57///
58/// Tauri appends the target triple to the base name supplied in
59/// `bundle.externalBin`, so the staged binary must be named
60/// `ffmpeg-<triple>[.exe]`.  We probe the most-common triples for each
61/// platform so that both architecture-specific and universal builds work.
62fn 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/// Result of capturing a screenshot
100#[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/// Result of starting a screen recording
111#[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/// Result of stopping a screen recording
119#[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/// Region to capture (optional)
129#[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/// Screen capture and recording service
138#[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    /// Capture a screenshot
177    ///
178    /// # Arguments
179    /// * `output_path` - Where to save the screenshot
180    /// * `region` - Optional region to capture (None = full screen)
181    /// * `display` - Optional display number (None = primary display)
182    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    /// Start screen recording
215    ///
216    /// # Arguments
217    /// * `output_path` - Where to save the recording
218    /// * `region` - Optional region to record (None = full screen)
219    /// * `display` - Optional display number (None = primary display)
220    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    /// Stop an active screen recording
253    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    // ============================================================================
279    // macOS Implementation
280    // ============================================================================
281
282    #[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        // Add region if specified
292        if let Some(r) = region {
293            cmd.arg("-R");
294            cmd.arg(format!("{},{},{},{}", r.x, r.y, r.width, r.height));
295        }
296
297        // Add display if specified
298        if let Some(d) = display {
299            cmd.arg("-D");
300            cmd.arg(d.to_string());
301        }
302
303        // Output path
304        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        // Try to get image dimensions using sips
324        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        // Use ffmpeg for screen recording on macOS (bundled sidecar preferred)
380        let mut cmd = Command::new(ffmpeg_binary());
381        cmd.arg("-f").arg("avfoundation");
382
383        // Input device (screen capture)
384        cmd.arg("-i").arg("1:none"); // Capture screen 1, no audio
385
386        // Add region filter if specified
387        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        // Output settings
393        cmd.arg("-c:v").arg("libx264");
394        cmd.arg("-preset").arg("ultrafast");
395        cmd.arg("-y"); // Overwrite output file
396        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        // Send SIGINT to ffmpeg to stop recording gracefully
442        #[cfg(unix)]
443        {
444            let pid = Pid::from_raw(handle.child.id() as i32);
445            let _ = kill(pid, Signal::SIGINT);
446        }
447
448        // Wait for process to finish
449        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    // ============================================================================
472    // Linux Implementation
473    // ============================================================================
474
475    #[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        // Try xdg-desktop-portal first (Wayland-compatible)
483        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        // Fallback to scrot for X11
491        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        // Use grim for Wayland screenshot (works with xdg-desktop-portal)
524        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        // Try to get dimensions using imagemagick identify
544        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        // Use wf-recorder for Wayland or ffmpeg for X11
595        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            // X11 fallback using bundled/system ffmpeg
608            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        // Send SIGINT to stop recording gracefully
675        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    // ============================================================================
701    // Windows Implementation
702    // ============================================================================
703
704    #[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        // Use PowerShell to capture screenshot
712        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, // Could parse from PowerShell output
768            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        // Use bundled/system ffmpeg for Windows screen recording
787        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        // Kill the process (Windows doesn't have SIGINT)
840        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        // Just verify we can create the tools struct
873        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(&region).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}