//! La pantalla como fuente de imágenes. //! //! Se diferencia de la cámara en lo único que de verdad importa aquí: **una //! pantalla es texto**. Y con texto este modelo tiene un modo de fallo feo. //! Medido sobre una captura con tipografía de interfaz de 13 px, preguntando //! por datos concretos: //! //! | Ancho | Tiempo | Aciertos | Qué pasa cuando falla | //! |------|--------|----------|-----------------------| //! | 640 | 2,4 s | 1 de 3 | se inventa el contenido | //! | 960 | 4,5 s | 2 de 3 | mezcla lo leído con lo supuesto | //! | 1280 | 7,6 s | 3 de 3 | — | //! //! A 640 px no dijo «no lo leo»: dijo que el error era «no se pudo abrir el //! archivo involution» y que la reunión era «a las 10:00». Ninguna de las dos //! cosas estaba en la imagen. De ahí que el valor por defecto sean 1280 px //! aunque cueste el triple que la cámara: para un asistente de voz, decir una //! hora equivocada con aplomo es peor que tardar cuatro segundos más. use std::path::PathBuf; use std::time::Duration; use asist_core::error::{Error, Result}; use crate::vision::{capture_jpeg, FrameSource}; const TOOL: &str = "mirar_la_pantalla"; #[derive(Debug, Clone)] pub struct ScreenConfig { /// Programa de captura y sus argumentos. `{ancho}` y `{salida}` se /// sustituyen antes de ejecutar. Tiene que escribir un JPEG por la salida /// estándar. pub command: Vec, /// Ancho al que se reduce la captura antes de mandarla al modelo. pub width: u32, /// Monitor concreto; vacío = todo lo que haya. pub output: String, pub timeout: Duration, /// Carpeta donde dejar las capturas. `None` = no se guarda ninguna. /// /// Aquí pesa más que en la cámara: en una captura de pantalla caben /// contraseñas, mensajes privados y correo abierto. pub save_dir: Option, } impl Default for ScreenConfig { fn default() -> Self { Self { command: vec![ "scripts/capturar-pantalla.sh".into(), "{ancho}".into(), "{salida}".into(), ], width: 1280, output: String::new(), timeout: Duration::from_secs(20), save_dir: None, } } } pub struct Screen { config: ScreenConfig, } impl Screen { pub fn new(config: ScreenConfig) -> Self { Self { config } } fn program(&self) -> Result<&String> { self.config.command.first().ok_or_else(|| Error::Tool { tool: TOOL.into(), message: "screen.command está vacío".into(), }) } } impl FrameSource for Screen { fn label(&self) -> &str { "pantalla" } fn available(&self) -> Result<()> { let program = self.program()?; // Un nombre suelto se resuelve por el PATH; una ruta tiene que existir. if program.contains('/') && !std::path::Path::new(program).exists() { return Err(Error::Tool { tool: TOOL.into(), message: format!("no existe {program}"), }); } // Sin entorno gráfico no hay nada que capturar, y más vale decirlo al // arrancar que a mitad de una pregunta. if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { return Err(Error::Tool { tool: TOOL.into(), message: "no hay sesión gráfica (ni WAYLAND_DISPLAY ni DISPLAY)".into(), }); } Ok(()) } fn capture(&self) -> Result> { self.available()?; let program = self.program()?.clone(); let args: Vec = self.config.command[1..] .iter() .map(|arg| { arg.replace("{ancho}", &self.config.width.to_string()) .replace("{salida}", &self.config.output) }) .collect(); let frame = capture_jpeg(TOOL, program.as_ref(), &args, self.config.timeout)?; if let Some(dir) = &self.config.save_dir { crate::camera::save(dir, &frame, "pantalla"); } Ok(frame) } } #[cfg(test)] mod tests { use super::*; fn config() -> ScreenConfig { ScreenConfig { command: vec!["/bin/echo".into(), "{ancho}".into(), "{salida}".into()], ..Default::default() } } #[test] fn el_ancho_por_defecto_es_el_minimo_medido_para_leer_texto() { assert_eq!( ScreenConfig::default().width, 1280, "por debajo de 1280 el modelo se inventa lo que pone en pantalla" ); } #[test] fn por_defecto_no_se_guarda_ninguna_captura() { assert!( ScreenConfig::default().save_dir.is_none(), "en una captura de pantalla caben contraseñas y mensajes privados" ); } #[test] fn un_guion_inexistente_se_detecta_antes_de_registrar_la_herramienta() { let screen = Screen::new(ScreenConfig { command: vec!["/no/existe/captura.sh".into()], ..Default::default() }); assert!(screen .available() .unwrap_err() .to_string() .contains("no existe")); } #[test] fn una_orden_vacia_se_rechaza() { let screen = Screen::new(ScreenConfig { command: vec![], ..Default::default() }); assert!(screen.available().is_err()); } #[test] fn los_marcadores_se_sustituyen_antes_de_ejecutar() { // /bin/echo devuelve los argumentos, así que la captura falla por no // ser un JPEG; lo que se comprueba es que el mensaje trae el ancho ya // sustituido, no el marcador. let mut config = config(); config.width = 1280; config.output = "eDP-1".into(); let screen = Screen::new(config); if std::env::var_os("WAYLAND_DISPLAY").is_none() && std::env::var_os("DISPLAY").is_none() { return; // sin sesión gráfica no hay nada que probar aquí } let err = screen.capture().unwrap_err().to_string(); assert!(err.contains("no devolvió un JPEG"), "{err}"); } }