feat: agent Rust Phase 1 — POC headless fonctionnel

1527 lignes Rust, compile sans warnings, testé sur Linux.
- Capture d'écran (xcap) + JPEG base64 + hash dedup
- Heartbeat toutes les 5s vers streaming server
- Poll replay + exécution actions (clic, frappe, combos)
- Serveur HTTP port 5006 (capture, health, file-action)
- Compatible avec le streaming server Python existant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-18 20:22:04 +01:00
parent 792cc2aa9a
commit 757432ee19
10 changed files with 1702 additions and 0 deletions

2
agent_rust/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
Cargo.lock

43
agent_rust/Cargo.toml Normal file
View File

@@ -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

130
agent_rust/README.md Normal file
View File

@@ -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": "<base64 JPEG>",
"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)

111
agent_rust/src/capture.rs Normal file
View File

@@ -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<DynamicImage> {
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<u8> {
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))
}

110
agent_rust/src/config.rs Normal file
View File

@@ -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
)
}
}

331
agent_rust/src/executor.rs Normal file
View File

@@ -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<Key> = 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<Key> {
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
}
}
}

207
agent_rust/src/main.rs Normal file
View File

@@ -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!("======================================================");
}

245
agent_rust/src/network.rs Normal file
View File

@@ -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<String>,
/// 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot: Option<String>,
}
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<Action>,
}
/// 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<Action> {
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<String>,
screenshot: &'a Option<String>,
}
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::<serde_json::Value>() {
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
}
}
}

121
agent_rust/src/replay.rs Normal file
View File

@@ -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);
}
}

402
agent_rust/src/server.rs Normal file
View File

@@ -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, &params);
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<String, u32> =
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<String, u32> =
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<dyn std::error::Error>> {
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(())
}