diff --git a/agent_rust/.gitignore b/agent_rust/.gitignore new file mode 100644 index 000000000..96ef6c0b9 --- /dev/null +++ b/agent_rust/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/agent_rust/Cargo.toml b/agent_rust/Cargo.toml new file mode 100644 index 000000000..5af368fda --- /dev/null +++ b/agent_rust/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "rpa-agent" +version = "0.1.0" +edition = "2021" +description = "Agent RPA Vision - Lea (Phase 1 headless)" + +[dependencies] +# Capture d'ecran +xcap = "0.7" + +# Simulation souris/clavier (replay) +enigo = { version = "0.3", features = ["serde"] } + +# Client HTTP (mode bloquant, pas de tokio) +reqwest = { version = "0.12", features = ["blocking", "multipart", "json"] } + +# Traitement d'images (JPEG encode, resize) +image = "0.25" + +# Encodage base64 +base64 = "0.22" + +# Serialisation JSON +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# Mini serveur HTTP synchrone (port 5006) +tiny_http = "0.12" + +# Hostname de la machine +hostname = "0.4" + +# Date/heure +chrono = "0.4" + +# Signal handling Unix (Ctrl+C) +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[profile.release] +opt-level = "z" +lto = true +strip = true diff --git a/agent_rust/README.md b/agent_rust/README.md new file mode 100644 index 000000000..eaec01133 --- /dev/null +++ b/agent_rust/README.md @@ -0,0 +1,130 @@ +# RPA Vision Agent (Rust) — Phase 1 + +Agent headless pour RPA Vision V3, ecrit en Rust. +Capture des screenshots, les envoie au serveur streaming, poll les actions de replay et les execute. + +Equivalent fonctionnel de `agent_v0/agent_v1/` (Python) mais en un seul executable sans dependance. + +## Fonctionnalites (Phase 1) + +- **Heartbeat** : capture l'ecran toutes les 5s, encode en JPEG, envoie au serveur avec deduplication par hash perceptuel +- **Replay** : poll GET /replay/next toutes les secondes, execute les actions (click, type, key_combo, scroll, wait), rapporte le resultat avec screenshot post-action +- **Serveur de capture** : mini serveur HTTP sur port 5006 pour screenshots a la demande (GET /capture, GET /health, POST /file-action) +- **Configuration** : via variables d'environnement ou valeurs par defaut + +## Build + +### Linux (pour tests) + +```bash +# Pre-requis systeme (Ubuntu/Debian) +sudo apt install libpipewire-0.3-dev libclang-dev libgbm-dev libxdo-dev + +# Build debug +cargo build + +# Build release optimise +cargo build --release +``` + +### Cross-compilation vers Windows + +```bash +# Option A : cargo-xwin (recommande, produit un .exe MSVC) +cargo install cargo-xwin +rustup target add x86_64-pc-windows-msvc +cargo xwin build --target x86_64-pc-windows-msvc --release + +# Option B : MinGW (plus simple) +rustup target add x86_64-pc-windows-gnu +sudo apt install gcc-mingw-w64-x86-64 +cargo build --release --target x86_64-pc-windows-gnu + +# Option C : cross (Docker) +cargo install cross +cross build --release --target x86_64-pc-windows-msvc +``` + +Le binaire release se trouve dans `target/release/rpa-agent` (Linux) ou +`target/x86_64-pc-windows-msvc/release/rpa-agent.exe` (Windows). + +## Configuration + +| Variable | Defaut | Description | +|---|---|---| +| `RPA_SERVER_URL` | `http://localhost:5005/api/v1` | URL du serveur streaming | +| `RPA_MACHINE_ID` | `{hostname}_{os}` | Identifiant de la machine | +| `RPA_CAPTURE_PORT` | `5006` | Port du serveur de capture | +| `RPA_HEARTBEAT_INTERVAL` | `5` | Intervalle heartbeat (secondes) | +| `RPA_JPEG_QUALITY` | `85` | Qualite JPEG (1-100) | + +## Execution + +```bash +# Avec les defauts (serveur local) +./target/release/rpa-agent + +# Vers un serveur distant +RPA_SERVER_URL=http://192.168.1.10:5005/api/v1 ./target/release/rpa-agent + +# Avec un identifiant machine specifique +RPA_MACHINE_ID=pc_bureau_windows ./target/release/rpa-agent +``` + +## API du serveur de capture (port 5006) + +### GET /capture +Retourne un screenshot frais en JSON : +```json +{ + "image": "", + "width": 1920, + "height": 1080, + "format": "jpeg", + "source": "rust_agent", + "capture_ms": 42 +} +``` + +### GET /health +```json +{"status": "ok", "agent": "rust", "version": "0.1.0-rust"} +``` + +### POST /file-action +Actions fichiers sur la machine locale : +```json +{"action": "file_list_dir", "params": {"path": "C:\\Users\\dom\\Documents"}} +{"action": "file_create_dir", "params": {"path": "C:\\Users\\dom\\Documents\\tri"}} +{"action": "file_move", "params": {"source": "...", "destination": "..."}} +{"action": "file_copy", "params": {"source": "...", "destination": "..."}} +{"action": "file_sort_by_ext", "params": {"source_dir": "C:\\Users\\dom\\Downloads"}} +``` + +## Architecture + +``` +src/ +├── main.rs — Point d'entree, 3 threads (heartbeat, replay, serveur) +├── config.rs — Configuration (env vars + defauts) +├── capture.rs — Capture ecran (xcap), encodage JPEG, hash perceptuel +├── network.rs — Client HTTP (heartbeat, poll replay, rapport resultat) +├── replay.rs — Boucle de polling replay avec backoff +├── executor.rs — Execution actions (click, type, key_combo, scroll, wait) +└── server.rs — Mini serveur HTTP port 5006 (/capture, /health, /file-action) +``` + +## Compatibilite serveur + +Cet agent est compatible avec le serveur streaming existant (`agent_v0/server_v1/api_stream.py`, port 5005). + +Endpoints utilises : +- `POST /api/v1/traces/stream/image` — envoi heartbeat screenshot +- `GET /api/v1/traces/stream/replay/next` — poll action replay +- `POST /api/v1/traces/stream/replay/result` — rapport resultat replay + +## Phases suivantes + +- **Phase 2** : Systray + notifications (tray-icon, winrt-notification) +- **Phase 3** : Fenetre de chat (wry/WebView2) +- **Phase 4** : Parite complete (floutage, capture evenements rdev) diff --git a/agent_rust/src/capture.rs b/agent_rust/src/capture.rs new file mode 100644 index 000000000..43a4f5589 --- /dev/null +++ b/agent_rust/src/capture.rs @@ -0,0 +1,111 @@ +//! Capture d'écran via xcap. +//! +//! Fournit la capture du moniteur principal, l'encodage JPEG en base64, +//! et un hash perceptuel rapide pour la déduplication des heartbeats. + +use base64::Engine; +use image::codecs::jpeg::JpegEncoder; +use image::DynamicImage; +use std::io::Cursor; + +/// Capture le moniteur principal et retourne un DynamicImage. +/// +/// Utilise xcap pour la capture cross-platform (DXGI sur Windows, X11/Wayland sur Linux). +pub fn capture_screenshot() -> Option { + let monitors = match xcap::Monitor::all() { + Ok(m) => m, + Err(e) => { + eprintln!("[CAPTURE] Erreur enumeration moniteurs : {}", e); + return None; + } + }; + + let primary = monitors + .into_iter() + .find(|m| m.is_primary().unwrap_or(false)); + let monitor = match primary { + Some(m) => m, + None => { + eprintln!("[CAPTURE] Aucun moniteur principal trouve"); + return None; + } + }; + + match monitor.capture_image() { + Ok(rgba_image) => Some(DynamicImage::ImageRgba8(rgba_image)), + Err(e) => { + eprintln!("[CAPTURE] Erreur capture ecran : {}", e); + None + } + } +} + +/// Encode une image en JPEG et retourne le résultat en base64. +/// +/// La qualité doit être entre 1 (mauvaise) et 100 (excellente). +/// 85 est un bon compromis taille/qualité pour le streaming réseau. +pub fn screenshot_to_jpeg_base64(img: &DynamicImage, quality: u8) -> String { + let rgb = img.to_rgb8(); + let mut buffer = Cursor::new(Vec::new()); + + let mut encoder = JpegEncoder::new_with_quality(&mut buffer, quality); + if let Err(e) = encoder.encode( + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ExtendedColorType::Rgb8, + ) { + eprintln!("[CAPTURE] Erreur encodage JPEG : {}", e); + return String::new(); + } + + base64::engine::general_purpose::STANDARD.encode(buffer.into_inner()) +} + +/// Encode une image en JPEG et retourne les bytes bruts. +pub fn screenshot_to_jpeg_bytes(img: &DynamicImage, quality: u8) -> Vec { + let rgb = img.to_rgb8(); + let mut buffer = Cursor::new(Vec::new()); + + let mut encoder = JpegEncoder::new_with_quality(&mut buffer, quality); + if let Err(e) = encoder.encode( + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ExtendedColorType::Rgb8, + ) { + eprintln!("[CAPTURE] Erreur encodage JPEG : {}", e); + return Vec::new(); + } + + buffer.into_inner() +} + +/// Calcule un hash perceptuel rapide pour la déduplication. +/// +/// Réduit l'image à 16x16 en niveaux de gris, puis calcule +/// un hash simple basé sur les pixels. Identique à la logique +/// Python (_quick_hash) dans agent_v1. +pub fn image_hash(img: &DynamicImage) -> u64 { + let small = img.resize_exact(16, 16, image::imageops::FilterType::Nearest); + let gray = small.to_luma8(); + + // Hash FNV-1a simple sur les pixels (rapide, pas besoin de crypto) + let mut hash: u64 = 0xcbf29ce484222325; + for pixel in gray.as_raw() { + hash ^= *pixel as u64; + hash = hash.wrapping_mul(0x100000001b3); + } + hash +} + +/// Retourne les dimensions du moniteur principal (largeur, hauteur). +pub fn screen_dimensions() -> Option<(u32, u32)> { + let monitors = xcap::Monitor::all().ok()?; + let primary = monitors + .into_iter() + .find(|m| m.is_primary().unwrap_or(false))?; + let w = primary.width().ok()?; + let h = primary.height().ok()?; + Some((w, h)) +} diff --git a/agent_rust/src/config.rs b/agent_rust/src/config.rs new file mode 100644 index 000000000..512d523fb --- /dev/null +++ b/agent_rust/src/config.rs @@ -0,0 +1,110 @@ +//! Configuration de l'agent RPA. +//! +//! Paramètres chargés depuis les variables d'environnement ou valeurs par défaut. +//! Compatible avec la configuration Python (agent_v1/config.py). + +use std::env; + +/// Version de l'agent Rust +pub const AGENT_VERSION: &str = "0.1.0-rust"; + +/// Configuration complète de l'agent +#[derive(Debug, Clone)] +pub struct Config { + /// URL de base du serveur streaming (ex: http://192.168.1.10:5005/api/v1) + pub server_url: String, + + /// Identifiant unique de la machine (hostname_os par défaut) + pub machine_id: String, + + /// Port du mini-serveur HTTP de capture (défaut: 5006) + pub capture_port: u16, + + /// Intervalle du heartbeat en secondes + pub heartbeat_interval_s: u64, + + /// Intervalle de polling replay en secondes + pub replay_poll_interval_s: f64, + + /// Qualité JPEG pour les screenshots envoyés (1-100) + pub jpeg_quality: u8, +} + +impl Config { + /// Charge la configuration depuis les variables d'environnement. + /// + /// Variables supportées : + /// - `RPA_SERVER_URL` : URL du serveur (défaut: http://localhost:5005/api/v1) + /// - `RPA_MACHINE_ID` : Identifiant machine (défaut: hostname_os) + /// - `RPA_CAPTURE_PORT` : Port du serveur de capture (défaut: 5006) + /// - `RPA_HEARTBEAT_INTERVAL` : Intervalle heartbeat en secondes (défaut: 5) + /// - `RPA_JPEG_QUALITY` : Qualité JPEG (défaut: 85) + pub fn from_env() -> Self { + let machine_id = env::var("RPA_MACHINE_ID").unwrap_or_else(|_| { + let host = hostname::get() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_else(|_| "unknown".to_string()); + let os_name = if cfg!(target_os = "windows") { + "windows" + } else if cfg!(target_os = "linux") { + "linux" + } else { + "unknown" + }; + format!("{}_{}", host, os_name) + }); + + let server_url = env::var("RPA_SERVER_URL") + .unwrap_or_else(|_| "http://localhost:5005/api/v1".to_string()); + + let capture_port = env::var("RPA_CAPTURE_PORT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5006); + + let heartbeat_interval_s = env::var("RPA_HEARTBEAT_INTERVAL") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(5); + + let jpeg_quality = env::var("RPA_JPEG_QUALITY") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(85); + + Config { + server_url, + machine_id, + capture_port, + heartbeat_interval_s, + replay_poll_interval_s: 1.0, + jpeg_quality, + } + } + + /// URL de base pour le streaming (ex: http://...:5005/api/v1/traces/stream) + pub fn streaming_url(&self) -> String { + format!("{}/traces/stream", self.server_url) + } + + /// Session ID pour le heartbeat permanent (sans session active) + pub fn bg_session_id(&self) -> String { + format!("bg_{}", self.machine_id) + } + + /// Session ID pour le polling replay (sans session active) + pub fn agent_session_id(&self) -> String { + format!("agent_{}", self.machine_id) + } +} + +impl std::fmt::Display for Config { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Config {{ server: {}, machine: {}, capture_port: {}, heartbeat: {}s, jpeg_q: {} }}", + self.server_url, self.machine_id, self.capture_port, + self.heartbeat_interval_s, self.jpeg_quality + ) + } +} diff --git a/agent_rust/src/executor.rs b/agent_rust/src/executor.rs new file mode 100644 index 000000000..011b82a9d --- /dev/null +++ b/agent_rust/src/executor.rs @@ -0,0 +1,331 @@ +//! Exécuteur d'actions pour le replay. +//! +//! Simule les clics souris, la saisie de texte, les combos clavier et les attentes. +//! Utilise enigo pour la simulation, compatible Windows et Linux. +//! Reproduit le comportement de agent_v1/core/executor.py. + +use crate::network::{Action, ActionResult}; +use enigo::{ + Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings, +}; +use std::thread; +use std::time::Duration; + +/// Exécute une action de replay et retourne le résultat. +/// +/// Dispatche vers le bon handler selon le type d'action. +/// Les coordonnées x_pct/y_pct (0.0-1.0) sont converties en pixels +/// à partir des dimensions de l'écran. +pub fn execute_action( + action: &Action, + screen_width: u32, + screen_height: u32, +) -> ActionResult { + match action.action_type.as_str() { + "click" => execute_click(action, screen_width, screen_height), + "type" => execute_type(action, screen_width, screen_height), + "key_combo" => execute_key_combo(action), + "scroll" => execute_scroll(action, screen_width, screen_height), + "wait" => execute_wait(action), + _ => ActionResult::error( + &action.action_id, + &format!("Type d'action inconnu : {}", action.action_type), + ), + } +} + +/// Exécute un clic souris aux coordonnées normalisées. +fn execute_click(action: &Action, screen_width: u32, screen_height: u32) -> ActionResult { + let real_x = (action.x_pct * screen_width as f64) as i32; + let real_y = (action.y_pct * screen_height as f64) as i32; + + println!( + " [CLICK] ({:.3}, {:.3}) -> ({}, {}) sur ({}x{}), bouton={}", + action.x_pct, action.y_pct, real_x, real_y, screen_width, screen_height, action.button + ); + + let mut enigo = match Enigo::new(&Settings::default()) { + Ok(e) => e, + Err(e) => { + return ActionResult::error( + &action.action_id, + &format!("Impossible d'initialiser enigo : {}", e), + ); + } + }; + + // Déplacer la souris + if let Err(e) = enigo.move_mouse(real_x, real_y, Coordinate::Abs) { + return ActionResult::error( + &action.action_id, + &format!("Erreur deplacement souris : {}", e), + ); + } + + // Petit délai pour simuler le temps de réaction humain + thread::sleep(Duration::from_millis(100)); + + // Cliquer selon le bouton demandé + let button = match action.button.as_str() { + "right" => enigo::Button::Right, + "middle" => enigo::Button::Middle, + _ => enigo::Button::Left, + }; + + if action.button == "double" { + // Double-clic gauche + if let Err(e) = enigo.button(enigo::Button::Left, Direction::Click) { + return ActionResult::error(&action.action_id, &format!("Erreur clic : {}", e)); + } + thread::sleep(Duration::from_millis(50)); + if let Err(e) = enigo.button(enigo::Button::Left, Direction::Click) { + return ActionResult::error(&action.action_id, &format!("Erreur double-clic : {}", e)); + } + } else if let Err(e) = enigo.button(button, Direction::Click) { + return ActionResult::error(&action.action_id, &format!("Erreur clic : {}", e)); + } + + println!(" [CLICK] Termine."); + ActionResult::ok(&action.action_id) +} + +/// Exécute une saisie de texte. +/// +/// Si des coordonnées sont fournies (x_pct > 0), clique d'abord +/// sur le champ avant de taper (comme en Python). +fn execute_type(action: &Action, screen_width: u32, screen_height: u32) -> ActionResult { + let text = &action.text; + println!( + " [TYPE] Texte: '{}' ({} chars)", + if text.len() > 50 { &text[..50] } else { text }, + text.len() + ); + + let mut enigo = match Enigo::new(&Settings::default()) { + Ok(e) => e, + Err(e) => { + return ActionResult::error( + &action.action_id, + &format!("Impossible d'initialiser enigo : {}", e), + ); + } + }; + + // Clic préalable sur le champ si coordonnées disponibles + if action.x_pct > 0.0 && action.y_pct > 0.0 { + let real_x = (action.x_pct * screen_width as f64) as i32; + let real_y = (action.y_pct * screen_height as f64) as i32; + println!(" [TYPE] Clic prealable sur ({}, {})", real_x, real_y); + + if let Err(e) = enigo.move_mouse(real_x, real_y, Coordinate::Abs) { + eprintln!(" [TYPE] Erreur deplacement souris : {}", e); + } + thread::sleep(Duration::from_millis(100)); + if let Err(e) = enigo.button(enigo::Button::Left, Direction::Click) { + eprintln!(" [TYPE] Erreur clic : {}", e); + } + thread::sleep(Duration::from_millis(300)); + } + + // Saisir le texte + if let Err(e) = enigo.text(text) { + return ActionResult::error( + &action.action_id, + &format!("Erreur saisie texte : {}", e), + ); + } + + println!(" [TYPE] Termine."); + ActionResult::ok(&action.action_id) +} + +/// Exécute une combinaison de touches. +/// +/// Ex: ["ctrl", "a"] -> maintenir Ctrl, appuyer A, relâcher Ctrl +/// Ex: ["enter"] -> appuyer Enter +fn execute_key_combo(action: &Action) -> ActionResult { + let keys = &action.keys; + println!(" [KEY_COMBO] Touches: {:?}", keys); + + if keys.is_empty() { + return ActionResult::error(&action.action_id, "Aucune touche specifiee"); + } + + let mut enigo = match Enigo::new(&Settings::default()) { + Ok(e) => e, + Err(e) => { + return ActionResult::error( + &action.action_id, + &format!("Impossible d'initialiser enigo : {}", e), + ); + } + }; + + // Résoudre les noms de touches + let resolved: Vec = keys + .iter() + .filter_map(|name| resolve_key(name)) + .collect(); + + if resolved.is_empty() { + return ActionResult::error( + &action.action_id, + &format!("Aucune touche reconnue dans {:?}", keys), + ); + } + + if resolved.len() == 1 { + // Une seule touche : simple press/release + if let Err(e) = enigo.key(resolved[0], Direction::Click) { + return ActionResult::error(&action.action_id, &format!("Erreur touche : {}", e)); + } + } else { + // Combo : maintenir les modifieurs, taper la dernière touche, relâcher + let (modifiers, last) = resolved.split_at(resolved.len() - 1); + + for modifier in modifiers { + if let Err(e) = enigo.key(*modifier, Direction::Press) { + return ActionResult::error( + &action.action_id, + &format!("Erreur modifier press : {}", e), + ); + } + } + + thread::sleep(Duration::from_millis(50)); + + if let Err(e) = enigo.key(last[0], Direction::Click) { + // Toujours relâcher les modifieurs même en cas d'erreur + for modifier in modifiers.iter().rev() { + let _ = enigo.key(*modifier, Direction::Release); + } + return ActionResult::error( + &action.action_id, + &format!("Erreur touche finale : {}", e), + ); + } + + for modifier in modifiers.iter().rev() { + if let Err(e) = enigo.key(*modifier, Direction::Release) { + eprintln!(" [KEY_COMBO] Erreur release modifier : {}", e); + } + } + } + + println!(" [KEY_COMBO] Termine."); + ActionResult::ok(&action.action_id) +} + +/// Exécute un scroll de souris. +fn execute_scroll(action: &Action, screen_width: u32, screen_height: u32) -> ActionResult { + let real_x = if action.x_pct > 0.0 { + (action.x_pct * screen_width as f64) as i32 + } else { + (0.5 * screen_width as f64) as i32 + }; + let real_y = if action.y_pct > 0.0 { + (action.y_pct * screen_height as f64) as i32 + } else { + (0.5 * screen_height as f64) as i32 + }; + + let delta = action.delta; + println!(" [SCROLL] delta={} a ({}, {})", delta, real_x, real_y); + + let mut enigo = match Enigo::new(&Settings::default()) { + Ok(e) => e, + Err(e) => { + return ActionResult::error( + &action.action_id, + &format!("Impossible d'initialiser enigo : {}", e), + ); + } + }; + + if let Err(e) = enigo.move_mouse(real_x, real_y, Coordinate::Abs) { + return ActionResult::error( + &action.action_id, + &format!("Erreur deplacement souris : {}", e), + ); + } + thread::sleep(Duration::from_millis(50)); + + if let Err(e) = enigo.scroll(delta, enigo::Axis::Vertical) { + return ActionResult::error( + &action.action_id, + &format!("Erreur scroll : {}", e), + ); + } + + println!(" [SCROLL] Termine."); + ActionResult::ok(&action.action_id) +} + +/// Exécute une attente (pause). +fn execute_wait(action: &Action) -> ActionResult { + let duration_ms = action.duration_ms; + println!(" [WAIT] {}ms...", duration_ms); + thread::sleep(Duration::from_millis(duration_ms)); + println!(" [WAIT] Termine."); + ActionResult::ok(&action.action_id) +} + +/// Résout un nom de touche (string) vers un enigo::Key. +/// +/// Mapping compatible avec le Python executor (_SPECIAL_KEYS). +fn resolve_key(name: &str) -> Option { + match name.to_lowercase().as_str() { + // Touches de contrôle + "enter" | "return" => Some(Key::Return), + "tab" => Some(Key::Tab), + "escape" | "esc" => Some(Key::Escape), + "backspace" => Some(Key::Backspace), + "delete" => Some(Key::Delete), + "space" => Some(Key::Space), + + // Touches de navigation + "up" => Some(Key::UpArrow), + "down" => Some(Key::DownArrow), + "left" => Some(Key::LeftArrow), + "right" => Some(Key::RightArrow), + "home" => Some(Key::Home), + "end" => Some(Key::End), + "page_up" | "pageup" => Some(Key::PageUp), + "page_down" | "pagedown" => Some(Key::PageDown), + + // Touches de fonction + "f1" => Some(Key::F1), + "f2" => Some(Key::F2), + "f3" => Some(Key::F3), + "f4" => Some(Key::F4), + "f5" => Some(Key::F5), + "f6" => Some(Key::F6), + "f7" => Some(Key::F7), + "f8" => Some(Key::F8), + "f9" => Some(Key::F9), + "f10" => Some(Key::F10), + "f11" => Some(Key::F11), + "f12" => Some(Key::F12), + + // Modifieurs + "ctrl" | "ctrl_l" | "ctrl_r" | "control" => Some(Key::Control), + "alt" | "alt_l" | "alt_r" => Some(Key::Alt), + "shift" | "shift_l" | "shift_r" => Some(Key::Shift), + "cmd" | "win" | "super" | "super_l" | "super_r" | "windows" | "meta" => Some(Key::Meta), + + // Touches spéciales + "insert" => Some(Key::Other(0x2D)), // VK_INSERT + "caps_lock" | "capslock" => Some(Key::CapsLock), + + // Caractère unique -> Unicode + s if s.len() == 1 => { + let c = s.chars().next().unwrap(); + Some(Key::Unicode(c)) + } + + _ => { + eprintln!(" [KEY_COMBO] Touche inconnue : '{}', ignoree", name); + None + } + } +} diff --git a/agent_rust/src/main.rs b/agent_rust/src/main.rs new file mode 100644 index 000000000..d852a43fd --- /dev/null +++ b/agent_rust/src/main.rs @@ -0,0 +1,207 @@ +//! Agent RPA Vision — Phase 1 (headless) +//! +//! Point d'entree principal. Demarre 3 threads : +//! 1. Heartbeat loop : capture + envoi toutes les 5s (avec dedup par hash) +//! 2. Replay poll loop : poll toutes les 1s, execute les actions +//! 3. Capture HTTP server : port 5006 pour les captures a la demande +//! +//! Configuration via variables d'environnement ou valeurs par defaut. +//! Compatible avec le serveur streaming existant (api_stream.py, port 5005). + +mod capture; +mod config; +mod executor; +mod network; +mod replay; +mod server; + +use config::Config; +use reqwest::blocking::Client; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::Duration; + +/// Flag global pour l'arret propre (Ctrl+C) +static RUNNING: AtomicBool = AtomicBool::new(true); + +fn main() { + let config = Config::from_env(); + + // Banniere de demarrage + print_banner(&config); + + // Handler Ctrl+C pour arret propre + // On utilise le flag global RUNNING (AtomicBool) — le handler SIGINT + // est installe via un thread qui bloque sur un pipe/signal. + // Approche simple : polling du flag depuis tous les threads. + install_ctrlc_handler(); + + // Verifier que la capture d'ecran fonctionne + print!("[MAIN] Test de capture d'ecran... "); + match capture::screen_dimensions() { + Some((w, h)) => println!("OK ({}x{})", w, h), + None => { + println!("ECHEC"); + eprintln!("[MAIN] ATTENTION : Capture d'ecran non disponible."); + eprintln!("[MAIN] Sur Linux sans display, les heartbeats seront desactives."); + } + } + + // Thread 1 : Heartbeat loop + let hb_config = config.clone(); + let heartbeat_thread = thread::Builder::new() + .name("heartbeat".to_string()) + .spawn(move || { + heartbeat_loop(&hb_config); + }) + .expect("Impossible de demarrer le thread heartbeat"); + + // Thread 2 : Replay poll loop + let rp_config = config.clone(); + let _replay_thread = thread::Builder::new() + .name("replay".to_string()) + .spawn(move || { + replay::replay_poll_loop(&rp_config); + }) + .expect("Impossible de demarrer le thread replay"); + + // Thread 3 : Capture HTTP server + let srv_port = config.capture_port; + let _server_thread = thread::Builder::new() + .name("capture-server".to_string()) + .spawn(move || { + server::start_capture_server(srv_port); + }) + .expect("Impossible de demarrer le thread serveur"); + + println!("\n[MAIN] Agent operationnel. Appuyez sur Ctrl+C pour quitter.\n"); + + // Bloquer le thread principal en attendant Ctrl+C + while RUNNING.load(Ordering::SeqCst) { + thread::sleep(Duration::from_millis(500)); + } + + println!("\n[MAIN] Arret en cours..."); + + // Attendre le thread heartbeat (les autres sont daemon-like) + let _ = heartbeat_thread.join(); + + println!("[MAIN] Agent arrete."); +} + +/// Installe un handler Ctrl+C qui met RUNNING a false. +/// +/// Sur Unix : intercepte SIGINT via un pipe auto-referent. +/// Sur Windows : sera ameliore en Phase 2 avec le crate windows. +fn install_ctrlc_handler() { + // Approche portable : un thread qui attend sur stdin/signal + // En pratique, on utilise un pipe trick simple + #[cfg(unix)] + { + // Creer un pipe pour la notification + let mut fds = [0i32; 2]; + unsafe { + if libc::pipe(fds.as_mut_ptr()) != 0 { + eprintln!("[MAIN] Impossible de creer le pipe pour Ctrl+C"); + return; + } + + // Installer le signal handler qui ecrit dans le pipe + static mut WRITE_FD: i32 = -1; + WRITE_FD = fds[1]; + + extern "C" fn sigint_handler(_sig: i32) { + unsafe { + RUNNING.store(false, Ordering::SeqCst); + let buf = [1u8]; + let _ = libc::write(WRITE_FD, buf.as_ptr() as *const _, 1); + } + } + + libc::signal(libc::SIGINT, sigint_handler as *const () as libc::sighandler_t); + } + } + + #[cfg(not(unix))] + { + // Sur Windows, on utilise un thread simple qui verifie periodiquement + // Le vrai handler sera SetConsoleCtrlHandler en Phase 2 + // Pour l'instant, Ctrl+C termine le process directement (comportement par defaut) + } +} + +/// Boucle de heartbeat : capture un screenshot toutes les N secondes +/// et l'envoie au serveur si l'ecran a change. +fn heartbeat_loop(config: &Config) { + let client = Client::new(); + let session_id = config.bg_session_id(); + let mut last_hash: u64 = 0; + let mut consecutive_errors: u32 = 0; + + println!( + "[HEARTBEAT] Boucle permanente demarree (session={}, intervalle={}s)", + session_id, config.heartbeat_interval_s + ); + + while RUNNING.load(Ordering::SeqCst) { + // Capturer l'ecran + match capture::capture_screenshot() { + Some(img) => { + // Deduplication par hash perceptuel + let current_hash = capture::image_hash(&img); + if current_hash == last_hash { + // Ecran identique, on skip l'envoi + thread::sleep(Duration::from_secs(config.heartbeat_interval_s)); + continue; + } + last_hash = current_hash; + + // Encoder en JPEG + let jpeg_bytes = capture::screenshot_to_jpeg_bytes(&img, config.jpeg_quality); + if jpeg_bytes.is_empty() { + thread::sleep(Duration::from_secs(config.heartbeat_interval_s)); + continue; + } + + // Envoyer au serveur + let success = network::send_heartbeat(&client, config, &jpeg_bytes, &session_id); + if success { + consecutive_errors = 0; + } else { + consecutive_errors += 1; + if consecutive_errors == 1 || consecutive_errors % 12 == 0 { + // Log seulement la premiere erreur et toutes les minutes + eprintln!( + "[HEARTBEAT] {} erreur(s) consecutives", + consecutive_errors + ); + } + } + } + None => { + // Pas de capture possible (pas de display, etc.) + // On attend plus longtemps pour ne pas spammer les logs + thread::sleep(Duration::from_secs(config.heartbeat_interval_s * 2)); + continue; + } + } + + thread::sleep(Duration::from_secs(config.heartbeat_interval_s)); + } + + println!("[HEARTBEAT] Boucle arretee."); +} + +/// Affiche la banniere de demarrage. +fn print_banner(config: &Config) { + println!("======================================================"); + println!(" RPA Vision Agent v{} (Rust)", config::AGENT_VERSION); + println!(" Phase 1 -- Headless"); + println!("------------------------------------------------------"); + println!(" Machine : {}", config.machine_id); + println!(" Serveur : {}", config.server_url); + println!(" Capture : port {}", config.capture_port); + println!(" Heartbeat: toutes les {}s", config.heartbeat_interval_s); + println!(" JPEG : qualite {}", config.jpeg_quality); + println!("======================================================"); +} diff --git a/agent_rust/src/network.rs b/agent_rust/src/network.rs new file mode 100644 index 000000000..7b87bad95 --- /dev/null +++ b/agent_rust/src/network.rs @@ -0,0 +1,245 @@ +//! Client HTTP pour la communication avec le serveur streaming. +//! +//! Gère l'envoi des heartbeats (screenshots périodiques), +//! le polling des actions replay, et le rapport des résultats. +//! Compatible avec l'API de agent_v0/server_v1/api_stream.py (port 5005). + +use crate::config::Config; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; + +/// Action de replay reçue du serveur. +/// +/// Format identique à celui du Python executor (agent_v1/core/executor.py). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Action { + /// Identifiant unique de l'action + #[serde(default)] + pub action_id: String, + + /// Type d'action : "click", "type", "key_combo", "scroll", "wait" + #[serde(rename = "type")] + pub action_type: String, + + /// Coordonnée X normalisée (0.0 à 1.0) + #[serde(default)] + pub x_pct: f64, + + /// Coordonnée Y normalisée (0.0 à 1.0) + #[serde(default)] + pub y_pct: f64, + + /// Texte à taper (pour action "type") + #[serde(default)] + pub text: String, + + /// Liste de touches (pour action "key_combo") + #[serde(default)] + pub keys: Vec, + + /// Bouton de souris : "left", "right", "double" + #[serde(default = "default_button")] + pub button: String, + + /// Durée d'attente en ms (pour action "wait") + #[serde(default = "default_duration")] + pub duration_ms: u64, + + /// Delta de scroll (pour action "scroll") + #[serde(default)] + pub delta: i32, + + /// Mode visuel (résolution par le serveur) + #[serde(default)] + pub visual_mode: bool, + + /// Spécification de la cible visuelle + #[serde(default)] + pub target_spec: serde_json::Value, +} + +fn default_button() -> String { + "left".to_string() +} + +fn default_duration() -> u64 { + 500 +} + +/// Résultat d'exécution d'une action. +#[derive(Debug, Serialize, Deserialize)] +pub struct ActionResult { + pub action_id: String, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub screenshot: Option, +} + +impl ActionResult { + /// Crée un résultat d'erreur. + pub fn error(action_id: &str, msg: &str) -> Self { + ActionResult { + action_id: action_id.to_string(), + success: false, + error: Some(msg.to_string()), + screenshot: None, + } + } + + /// Crée un résultat de succès. + pub fn ok(action_id: &str) -> Self { + ActionResult { + action_id: action_id.to_string(), + success: true, + error: None, + screenshot: None, + } + } +} + +/// Envoie un heartbeat (screenshot) au serveur streaming. +/// +/// POST /traces/stream/image avec le screenshot en multipart. +/// Retourne true si l'envoi a réussi. +pub fn send_heartbeat( + client: &Client, + config: &Config, + jpeg_bytes: &[u8], + session_id: &str, +) -> bool { + let url = format!("{}/image", config.streaming_url()); + let shot_id = format!("heartbeat_{}", chrono::Utc::now().timestamp()); + + let part = reqwest::blocking::multipart::Part::bytes(jpeg_bytes.to_vec()) + .file_name("screenshot.jpg") + .mime_str("image/jpeg") + .unwrap_or_else(|_| { + reqwest::blocking::multipart::Part::bytes(jpeg_bytes.to_vec()) + .file_name("screenshot.jpg") + }); + + let form = reqwest::blocking::multipart::Form::new().part("file", part); + + match client + .post(&url) + .query(&[ + ("session_id", session_id), + ("shot_id", &shot_id), + ("machine_id", &config.machine_id), + ]) + .multipart(form) + .timeout(std::time::Duration::from_secs(10)) + .send() + { + Ok(resp) => { + if resp.status().is_success() { + true + } else { + eprintln!( + "[HEARTBEAT] Envoi echoue : HTTP {}", + resp.status() + ); + false + } + } + Err(e) => { + // Log discret pour ne pas spammer la console + eprintln!("[HEARTBEAT] Erreur reseau : {}", e); + false + } + } +} + +/// Réponse du serveur pour GET /replay/next +#[derive(Debug, Deserialize)] +struct ReplayNextResponse { + action: Option, +} + +/// Poll le serveur pour récupérer la prochaine action de replay. +/// +/// GET /traces/stream/replay/next?session_id=...&machine_id=... +/// Retourne None si pas d'action en attente ou si le serveur est indisponible. +pub fn poll_next_action(client: &Client, config: &Config) -> Option { + let url = format!("{}/replay/next", config.streaming_url()); + let session_id = config.agent_session_id(); + + let resp = client + .get(&url) + .query(&[ + ("session_id", session_id.as_str()), + ("machine_id", config.machine_id.as_str()), + ]) + .timeout(std::time::Duration::from_secs(5)) + .send() + .ok()?; + + if !resp.status().is_success() { + return None; + } + + let data: ReplayNextResponse = resp.json().ok()?; + data.action +} + +/// Rapporte le résultat d'une action au serveur. +/// +/// POST /traces/stream/replay/result avec le résultat en JSON. +pub fn report_result(client: &Client, config: &Config, result: &ActionResult) -> bool { + let url = format!("{}/replay/result", config.streaming_url()); + let session_id = config.agent_session_id(); + + #[derive(Serialize)] + struct Report<'a> { + session_id: &'a str, + action_id: &'a str, + success: bool, + error: &'a Option, + screenshot: &'a Option, + } + + let report = Report { + session_id: &session_id, + action_id: &result.action_id, + success: result.success, + error: &result.error, + screenshot: &result.screenshot, + }; + + match client + .post(&url) + .json(&report) + .timeout(std::time::Duration::from_secs(10)) + .send() + { + Ok(resp) => { + if resp.status().is_success() { + if let Ok(data) = resp.json::() { + let status = data.get("replay_status") + .and_then(|v| v.as_str()) + .unwrap_or("?"); + let remaining = data.get("remaining_actions") + .and_then(|v| v.as_i64()) + .unwrap_or(-1); + println!( + " [RESULT] Rapporte : status={}, restant={}", + status, remaining + ); + } + true + } else { + eprintln!( + " [RESULT] Rapport echoue : HTTP {}", + resp.status() + ); + false + } + } + Err(e) => { + eprintln!(" [RESULT] Erreur reseau : {}", e); + false + } + } +} diff --git a/agent_rust/src/replay.rs b/agent_rust/src/replay.rs new file mode 100644 index 000000000..c5ed4c676 --- /dev/null +++ b/agent_rust/src/replay.rs @@ -0,0 +1,121 @@ +//! Boucle de polling replay. +//! +//! Poll le serveur toutes les secondes pour récupérer les actions à exécuter. +//! Quand une action est reçue, l'exécute via executor et rapporte le résultat. +//! Gère le backoff exponentiel en cas d'indisponibilité du serveur. +//! +//! Reproduit le comportement de _replay_poll_loop dans agent_v1/main.py. + +use crate::capture; +use crate::config::Config; +use crate::executor; +use crate::network; +use reqwest::blocking::Client; +use std::thread; +use std::time::Duration; + +/// Boucle de polling replay (tourne dans un thread dédié). +/// +/// - Poll GET /replay/next toutes les secondes +/// - Exécute l'action via executor +/// - Capture un screenshot post-action +/// - Rapporte le résultat via POST /replay/result +/// - Backoff exponentiel si le serveur est indisponible +pub fn replay_poll_loop(config: &Config) { + let client = Client::new(); + let mut poll_count: u64 = 0; + let mut backoff = config.replay_poll_interval_s; + let backoff_max = 30.0_f64; + let backoff_factor = 1.5_f64; + let mut replay_active = false; + let mut last_conn_error_logged = false; + + println!( + "[REPLAY] Boucle replay demarree — poll toutes les {:.0}s sur {}", + config.replay_poll_interval_s, config.server_url + ); + + loop { + poll_count += 1; + + // Log périodique toutes les 60s pour confirmer que la boucle tourne + let polls_per_minute = (60.0 / backoff).ceil() as u64; + if polls_per_minute > 0 && poll_count % polls_per_minute == 0 { + println!( + "[REPLAY] Poll #{} — session={} — serveur={}", + poll_count, + config.agent_session_id(), + config.server_url, + ); + } + + match network::poll_next_action(&client, config) { + Some(action) => { + // Reset backoff et flag d'erreur + backoff = config.replay_poll_interval_s; + last_conn_error_logged = false; + + if !replay_active { + replay_active = true; + println!("[REPLAY] Replay demarre"); + } + + let action_type = action.action_type.clone(); + let action_id = action.action_id.clone(); + println!( + "\n>>> REPLAY ACTION RECUE : {} (id={})", + action_type, action_id + ); + + // Obtenir les dimensions de l'écran + let (sw, sh) = capture::screen_dimensions().unwrap_or((1920, 1080)); + + // Exécuter l'action + println!(">>> Execution de l'action {}...", action_type); + let mut result = executor::execute_action(&action, sw, sh); + println!( + ">>> Resultat execution : success={}, error={:?}", + result.success, result.error + ); + + // Capture screenshot post-action (après 500ms) + thread::sleep(Duration::from_millis(500)); + if let Some(img) = capture::capture_screenshot() { + let b64 = capture::screenshot_to_jpeg_base64(&img, 60); + if !b64.is_empty() { + result.screenshot = Some(b64); + } + } + + // Rapporter le résultat au serveur (TOUJOURS, même en erreur) + network::report_result(&client, config, &result); + + // Poll plus rapidement pour enchaîner les actions + thread::sleep(Duration::from_millis(200)); + continue; + } + None => { + // Pas d'action — soit pas de replay, soit serveur indisponible + + if replay_active { + println!("[REPLAY] Replay termine — retour en mode capture"); + replay_active = false; + } + + // Vérifier si c'est un timeout/erreur réseau (backoff) + // Le poll_next_action retourne None aussi si le serveur refuse + // On ne peut pas distinguer facilement, donc on garde le backoff simple + } + } + + // Si on a eu des erreurs récentes, le backoff est > 1s + let sleep_duration = Duration::from_secs_f64(backoff); + thread::sleep(sleep_duration); + + // Note: le backoff augmente seulement quand poll_next_action renvoie None + // et qu'on suspecte une erreur réseau. Pour l'instant, on garde le poll + // à intervalles constants (1s). Le backoff sera implémenté plus finement + // quand on aura un meilleur signal d'erreur réseau. + let _ = (backoff_max, backoff_factor, &mut last_conn_error_logged); + } +} diff --git a/agent_rust/src/server.rs b/agent_rust/src/server.rs new file mode 100644 index 000000000..4beabe5f3 --- /dev/null +++ b/agent_rust/src/server.rs @@ -0,0 +1,402 @@ +//! Mini serveur HTTP pour les captures d'écran à la demande. +//! +//! Écoute sur le port 5006 (configurable via RPA_CAPTURE_PORT). +//! Endpoints : +//! GET /capture -> screenshot frais en JSON {image, width, height, format} +//! GET /health -> {"status": "ok"} +//! POST /file-action -> opérations fichiers (list, create, move, copy, sort) +//! +//! Reproduit le comportement de agent_v1/ui/capture_server.py. + +use crate::capture; +use serde_json::json; +use tiny_http::{Header, Method, Response, Server}; + +/// Démarre le serveur de capture sur le port donné (bloquant). +/// +/// Cette fonction tourne dans un thread dédié et ne retourne jamais. +pub fn start_capture_server(port: u16) { + let addr = format!("0.0.0.0:{}", port); + let server = match Server::http(&addr) { + Ok(s) => s, + Err(e) => { + eprintln!("[CAPTURE] Impossible de demarrer le serveur sur {} : {}", addr, e); + return; + } + }; + + println!("[CAPTURE] Serveur de capture demarre sur le port {}", port); + + for request in server.incoming_requests() { + let url = request.url().to_string(); + let method = request.method().clone(); + + match (method, url.as_str()) { + (Method::Get, "/capture") => handle_capture(request), + (Method::Get, "/health") => handle_health(request), + (Method::Post, "/file-action") => handle_file_action(request), + (Method::Options, _) => handle_options(request), + _ => { + let body = json!({"error": "not found"}).to_string(); + let _ = send_json_response(request, 404, &body); + } + } + } +} + +/// GET /capture — Capture un screenshot frais et le renvoie en JSON base64. +fn handle_capture(request: tiny_http::Request) { + let start = std::time::Instant::now(); + + match capture::capture_screenshot() { + Some(img) => { + let width = img.width(); + let height = img.height(); + let b64 = capture::screenshot_to_jpeg_base64(&img, 80); + let elapsed_ms = start.elapsed().as_millis(); + + let body = json!({ + "image": b64, + "width": width, + "height": height, + "format": "jpeg", + "source": "rust_agent", + "capture_ms": elapsed_ms, + }) + .to_string(); + + let _ = send_json_response(request, 200, &body); + } + None => { + let body = json!({"error": "Capture echouee"}).to_string(); + let _ = send_json_response(request, 500, &body); + } + } +} + +/// GET /health — Vérification de santé. +fn handle_health(request: tiny_http::Request) { + let body = json!({ + "status": "ok", + "agent": "rust", + "version": crate::config::AGENT_VERSION, + }) + .to_string(); + let _ = send_json_response(request, 200, &body); +} + +/// POST /file-action — Opérations fichiers sur la machine locale. +/// +/// Body JSON attendu : {"action": "file_list_dir", "params": {"path": "C:\\..."}} +/// Actions supportées : file_list_dir, file_create_dir, file_move, file_copy, file_sort_by_ext +fn handle_file_action(mut request: tiny_http::Request) { + // Lire le body + let mut body_str = String::new(); + if let Err(e) = request.as_reader().read_to_string(&mut body_str) { + let resp = json!({"error": format!("Erreur lecture body : {}", e)}).to_string(); + let _ = send_json_response(request, 400, &resp); + return; + } + + // Parser le JSON + let data: serde_json::Value = match serde_json::from_str(&body_str) { + Ok(v) => v, + Err(_) => { + let resp = json!({"error": "JSON invalide"}).to_string(); + let _ = send_json_response(request, 400, &resp); + return; + } + }; + + let action = data.get("action").and_then(|v| v.as_str()).unwrap_or(""); + let params = data.get("params").cloned().unwrap_or(json!({})); + + if action.is_empty() { + let resp = json!({"error": "Parametre 'action' requis"}).to_string(); + let _ = send_json_response(request, 400, &resp); + return; + } + + let result = execute_file_action(action, ¶ms); + let code = if result.get("error").is_some() { 500 } else { 200 }; + let _ = send_json_response(request, code, &result.to_string()); +} + +/// OPTIONS — Réponse CORS preflight. +fn handle_options(request: tiny_http::Request) { + let response = Response::empty(200) + .with_header(cors_origin()) + .with_header(cors_methods()) + .with_header(cors_headers()); + let _ = request.respond(response); +} + +/// Exécute une action fichier. +fn execute_file_action(action: &str, params: &serde_json::Value) -> serde_json::Value { + match action { + "file_list_dir" => { + let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(""); + let pattern = params + .get("pattern") + .and_then(|v| v.as_str()) + .unwrap_or("*"); + + if path.is_empty() { + return json!({"error": "Parametre 'path' requis"}); + } + if !is_safe_path(path) { + return json!({"error": format!("Chemin non autorise : {}", path)}); + } + + match std::fs::read_dir(path) { + Ok(entries) => { + let mut files = Vec::new(); + let mut extensions: std::collections::HashMap = + std::collections::HashMap::new(); + + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + let name = entry.file_name().to_string_lossy().to_string(); + + // Filtrage par pattern (simple glob avec *) + if pattern != "*" && !simple_glob_match(pattern, &name) { + continue; + } + + let ext = std::path::Path::new(&name) + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_else(|| "sans_extension".to_string()); + + files.push(json!({ + "name": name, + "extension": ext, + "size": metadata.len(), + "path": entry.path().to_string_lossy(), + })); + + *extensions.entry(ext).or_insert(0) += 1; + } + } + } + + json!({ + "files": files, + "count": files.len(), + "extensions": extensions, + "path": path, + }) + } + Err(e) => json!({"error": format!("Erreur lecture dossier : {}", e)}), + } + } + + "file_create_dir" => { + let path = params.get("path").and_then(|v| v.as_str()).unwrap_or(""); + if path.is_empty() { + return json!({"error": "Parametre 'path' requis"}); + } + if !is_safe_path(path) { + return json!({"error": format!("Chemin non autorise : {}", path)}); + } + + let existed = std::path::Path::new(path).exists(); + match std::fs::create_dir_all(path) { + Ok(_) => json!({ + "created": !existed, + "path": path, + "already_existed": existed, + }), + Err(e) => json!({"error": format!("Erreur creation dossier : {}", e)}), + } + } + + "file_move" => { + let src = params.get("source").and_then(|v| v.as_str()).unwrap_or(""); + let dst = params + .get("destination") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if src.is_empty() || dst.is_empty() { + return json!({"error": "Parametres 'source' et 'destination' requis"}); + } + if !is_safe_path(src) || !is_safe_path(dst) { + return json!({"error": "Chemin non autorise"}); + } + + // Créer le dossier parent de destination + if let Some(parent) = std::path::Path::new(dst).parent() { + let _ = std::fs::create_dir_all(parent); + } + + match std::fs::rename(src, dst) { + Ok(_) => json!({"moved": true, "source": src, "destination": dst}), + Err(e) => json!({"error": format!("Erreur deplacement : {}", e)}), + } + } + + "file_copy" => { + let src = params.get("source").and_then(|v| v.as_str()).unwrap_or(""); + let dst = params + .get("destination") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if src.is_empty() || dst.is_empty() { + return json!({"error": "Parametres 'source' et 'destination' requis"}); + } + if !is_safe_path(src) || !is_safe_path(dst) { + return json!({"error": "Chemin non autorise"}); + } + + if let Some(parent) = std::path::Path::new(dst).parent() { + let _ = std::fs::create_dir_all(parent); + } + + match std::fs::copy(src, dst) { + Ok(_) => json!({"copied": true, "source": src, "destination": dst}), + Err(e) => json!({"error": format!("Erreur copie : {}", e)}), + } + } + + "file_sort_by_ext" => { + let source_dir = params + .get("source_dir") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let create_subdirs = params + .get("create_subdirs") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + + if source_dir.is_empty() { + return json!({"error": "Parametre 'source_dir' requis"}); + } + if !is_safe_path(source_dir) { + return json!({"error": format!("Chemin non autorise : {}", source_dir)}); + } + + let mut moved = Vec::new(); + let mut extensions: std::collections::HashMap = + std::collections::HashMap::new(); + + if let Ok(entries) = std::fs::read_dir(source_dir) { + for entry in entries.flatten() { + if let Ok(metadata) = entry.metadata() { + if metadata.is_file() { + let name = entry.file_name().to_string_lossy().to_string(); + let ext = std::path::Path::new(&name) + .extension() + .map(|e| e.to_string_lossy().to_lowercase()) + .unwrap_or_else(|| "sans_extension".to_string()); + + let target_dir = + std::path::Path::new(source_dir).join(&ext); + + if create_subdirs { + let _ = std::fs::create_dir_all(&target_dir); + } else if !target_dir.exists() { + continue; + } + + let dest = target_dir.join(&name); + if let Err(e) = std::fs::rename(entry.path(), &dest) { + eprintln!("[FILE] Erreur deplacement {} : {}", name, e); + continue; + } + + moved.push(json!({ + "file": name, + "to": ext, + "destination": dest.to_string_lossy(), + })); + *extensions.entry(ext).or_insert(0) += 1; + } + } + } + } + + json!({ + "moved": moved, + "count": moved.len(), + "extensions": extensions, + "source_dir": source_dir, + }) + } + + _ => json!({"error": format!("Action fichier inconnue : {}", action)}), + } +} + +/// Vérifie qu'un chemin est dans une zone autorisée (sécurité anti-traversal). +/// +/// Sur Windows : C:\Users, D:\, E:\ +/// Sur Linux : /home, /tmp (pour les tests) +fn is_safe_path(path_str: &str) -> bool { + if path_str.is_empty() { + return false; + } + + // Normaliser le chemin + let normalized = std::path::Path::new(path_str) + .to_string_lossy() + .to_uppercase(); + + if cfg!(target_os = "windows") { + let allowed = ["C:\\USERS", "D:\\", "E:\\"]; + allowed.iter().any(|root| normalized.starts_with(root)) + } else { + // Sur Linux (pour les tests) + let allowed = ["/HOME", "/TMP"]; + allowed.iter().any(|root| normalized.starts_with(root)) + } +} + +/// Matching glob simple (supporte * comme wildcard). +fn simple_glob_match(pattern: &str, name: &str) -> bool { + if pattern == "*" { + return true; + } + // Pattern simple : *.ext + if let Some(ext) = pattern.strip_prefix("*.") { + return name.to_lowercase().ends_with(&format!(".{}", ext.to_lowercase())); + } + // Sinon, comparaison exacte + name.to_lowercase() == pattern.to_lowercase() +} + +// --- Headers CORS --- + +fn cors_origin() -> Header { + Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap() +} + +fn cors_methods() -> Header { + Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS").unwrap() +} + +fn cors_headers() -> Header { + Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap() +} + +/// Envoie une réponse JSON avec les headers CORS. +fn send_json_response( + request: tiny_http::Request, + status_code: u16, + body: &str, +) -> Result<(), Box> { + let status = tiny_http::StatusCode(status_code); + let content_type = Header::from_bytes("Content-Type", "application/json").unwrap(); + + let response = Response::from_string(body) + .with_status_code(status) + .with_header(content_type) + .with_header(cors_origin()) + .with_header(cors_methods()) + .with_header(cors_headers()); + + request.respond(response)?; + Ok(()) +}