diff --git a/agent_rust/.gitignore b/agent_rust/.gitignore deleted file mode 100644 index 96ef6c0b9..000000000 --- a/agent_rust/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/target -Cargo.lock diff --git a/agent_rust/Cargo.toml b/agent_rust/Cargo.toml deleted file mode 100644 index ab988b3af..000000000 --- a/agent_rust/Cargo.toml +++ /dev/null @@ -1,85 +0,0 @@ -[package] -name = "rpa-agent" -version = "0.2.0" -edition = "2021" -description = "Agent RPA Vision - Lea (Phases 1-5)" - -[dependencies] -# Capture d'ecran -xcap = "0.7" - -# Simulation souris/clavier (replay) -enigo = { version = "0.3", features = ["serde"] } - -# Capture evenements souris/clavier (recording) — Phase 5 -rdev = "0.5" - -# Client HTTP (mode bloquant, pas de tokio) -reqwest = { version = "0.12", features = ["blocking", "multipart", "json"] } - -# Traitement d'images (JPEG encode, resize, crop) -image = "0.25" - -# Floutage zones sensibles — Phase 5 -imageproc = "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" - -# Canaux inter-threads performants -crossbeam-channel = "0.5" - -# Logging -log = "0.4" -env_logger = "0.11" - -# Signal handling Unix (Ctrl+C) -[target.'cfg(unix)'.dependencies] -libc = "0.2" - -# Dependances Windows uniquement — Phases 3-5 -[target.'cfg(windows)'.dependencies] -# Systray — Phase 3 -tray-icon = "0.19" -muda = "0.15" - -# Boucle d'evenements — Phase 3 -winit = { version = "0.30", features = ["rwh_06"] } - -# Notifications toast — Phase 3 -winrt-notification = "0.5" - -# Chat WebView2 — Phase 4 -wry = "0.48" - -# Raw window handle pour wry + fenetre native -raw-window-handle = "0.6" - -# Win32 API (info fenetre, dialogues, etc.) -windows-sys = { version = "0.59", features = [ - "Win32_UI_WindowsAndMessaging", - "Win32_System_Threading", - "Win32_System_LibraryLoader", - "Win32_Foundation", - "Win32_Graphics_Gdi", -] } - -[profile.release] -opt-level = "z" -lto = true -strip = true -codegen-units = 1 -panic = "abort" diff --git a/agent_rust/LISEZMOI.txt b/agent_rust/LISEZMOI.txt deleted file mode 100644 index de09ad38f..000000000 --- a/agent_rust/LISEZMOI.txt +++ /dev/null @@ -1,34 +0,0 @@ -╔══════════════════════════════════════════╗ -║ Léa — Assistante IA ║ -║ Automatisation de tâches ║ -╚══════════════════════════════════════════╝ - -INSTALLATION -──────────── -1. Copiez le dossier "Lea" sur votre Bureau -2. Double-cliquez sur "Lea.exe" pour démarrer - -PREMIÈRE UTILISATION -──────────────────── -• Léa s'ouvre automatiquement dans votre navigateur -• Cliquez "Apprenez-moi une tâche" pour commencer -• Effectuez votre tâche normalement -• Cliquez "C'est terminé" quand vous avez fini -• Léa a appris ! Demandez-lui de refaire la tâche - -ARRÊTER LÉA -──────────── -• Fermez la fenêtre Léa dans la barre des tâches -• Ou appuyez Ctrl+C dans le terminal - -BESOIN D'AIDE ? -─────────────── -Contactez le support : [à compléter] - -──────────────────────────────────────────── -⚠ Cet outil utilise l'intelligence artificielle. -Article 50 du Règlement européen sur l'IA. -Vos données restent sur votre ordinateur et notre -serveur sécurisé. Aucune donnée n'est partagée -avec des tiers. -──────────────────────────────────────────── diff --git a/agent_rust/README.md b/agent_rust/README.md deleted file mode 100644 index ee820516a..000000000 --- a/agent_rust/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# RPA Vision Agent (Rust) — Phases 1-5 - -Agent complet pour RPA Vision V3, ecrit en Rust. -Parite fonctionnelle avec l'agent Python (`agent_v0/agent_v1/`) en un seul executable de 2.4 Mo. - -## Fonctionnalites - -### Phase 1 — Agent minimal (headless) -- **Heartbeat** : capture ecran toutes les 5s, JPEG, dedup par hash perceptuel -- **Replay** : poll serveur, execute actions (click, type, key_combo, scroll, wait) -- **Resolution visuelle** : resolution de cibles via le serveur (template matching) -- **Serveur de capture** : port 5006 (GET /capture, GET /health, POST /file-action) - -### Phase 3 — Systray + Notifications -- **Systray** : icone avec cercle colore (gris=idle, rouge=enregistrement, vert=connecte, bleu=replay) -- **Menu contextuel** : Machine ID, statut, Apprenez-moi, C'est termine, Mes taches, ARRET D'URGENCE, Chat, Fichiers, Quitter -- **Notifications toast** : via winrt-notification (bienvenue, session, replay, connexion) -- **Etat partage** : thread-safe via AtomicBool + Mutex - -### Phase 4 — Chat WebView2 -- **WebView2** : fenetre 520x720, charge http://{server}:5004/chat -- **Positionnement** : bas-droite pres du systray -- **Fallback** : HTML embarque si le serveur est indisponible -- **Toggle** : show/hide via menu systray - -### Phase 5 — Parite complete -- **Enregistrement** : capture evenements souris/clavier via rdev, envoi au serveur -- **Floutage** : detection de champs de saisie + blur gaussien (protection donnees sensibles) -- **Configuration** : BLUR_SENSITIVE, LOG_RETENTION_DAYS, CHAT_PORT -- **Health check** : verification connexion serveur toutes les 30s - -## Build - -### Linux (pour tests) - -```bash -sudo apt install libpipewire-0.3-dev libclang-dev libgbm-dev libxdo-dev -cargo build --release -``` - -### Cross-compilation vers Windows - -```bash -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 -``` - -### Deploiement sur le PC cible - -```bash -sshpass -p 'loli' scp -o StrictHostKeyChecking=no \ - target/x86_64-pc-windows-gnu/release/rpa-agent.exe \ - dom@192.168.1.11:"C:\\rpa_vision\\rpa-agent.exe" -``` - -## 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) | -| `RPA_BLUR_SENSITIVE` | `true` | Flouter les zones sensibles | -| `RPA_LOG_RETENTION_DAYS` | `180` | Retention des logs (jours) | -| `RPA_CHAT_PORT` | `5004` | Port du serveur de chat | - -## Architecture - -``` -src/ -├── main.rs — Orchestrateur, 7 threads (heartbeat, replay, serveur, health, recorder, chat, tray) -├── config.rs — Configuration (env vars + defauts) -├── state.rs — Etat partage thread-safe (AtomicBool, Mutex) -├── capture.rs — Capture ecran (xcap), JPEG, hash perceptuel -├── network.rs — Client HTTP (heartbeat, poll replay, rapport resultat) -├── replay.rs — Boucle de polling replay avec notifications -├── executor.rs — Execution actions (click, type, key_combo, scroll, wait) -├── visual.rs — Resolution visuelle des cibles via le serveur -├── server.rs — Mini serveur HTTP port 5006 (/capture, /health, /file-action) -├── tray.rs — Icone systray + menu contextuel (tray-icon, winit) -├── notifications.rs — Notifications toast Windows (winrt-notification) -├── chat.rs — Fenetre de chat WebView2 (wry) -├── recorder.rs — Capture evenements souris/clavier (rdev) -└── blur.rs — Floutage zones sensibles (detection + box blur) -``` - -## Taille du binaire - -| Configuration | Taille | -|---|---| -| Release (LTO + strip + opt-level z) | **2.4 Mo** | -| Python equivalent (venv + packages) | ~200 Mo | - -## Compatibilite - -- **OS** : Windows 10/11 (systray, notifications, chat WebView2) -- **Fallback Linux** : mode console (heartbeat, replay, serveur) -- **Serveur** : compatible api_stream.py (port 5005) diff --git a/agent_rust/build_demo.sh b/agent_rust/build_demo.sh deleted file mode 100755 index 6a567e9e4..000000000 --- a/agent_rust/build_demo.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/bin/bash -# Build du kit démo pour Windows -set -e - -echo "=== Build Léa pour Windows ===" -cargo build --release --target x86_64-pc-windows-gnu - -# Préparer le dossier de démo -DEMO_DIR="demo_kit/Lea" -rm -rf demo_kit -mkdir -p "$DEMO_DIR" - -# Copier les fichiers -cp target/x86_64-pc-windows-gnu/release/rpa-agent.exe "$DEMO_DIR/Lea.exe" -cp config.txt "$DEMO_DIR/config.txt" -cp LISEZMOI.txt "$DEMO_DIR/LISEZMOI.txt" - -echo "" -echo "=== Kit démo prêt dans demo_kit/Lea/ ===" -ls -lh "$DEMO_DIR/" -echo "" -echo "Copiez le dossier Lea/ sur le PC du docteur." diff --git a/agent_rust/config.txt b/agent_rust/config.txt deleted file mode 100644 index 37cd80424..000000000 --- a/agent_rust/config.txt +++ /dev/null @@ -1,12 +0,0 @@ -# === Configuration Léa === -# Adresse du serveur (ne pas modifier sauf instruction) -RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1 - -# Clé d'accès (ne pas modifier) -RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab - -# Qualité des captures (1-100, défaut: 85) -RPA_JPEG_QUALITY=85 - -# Floutage des données sensibles (true/false) -RPA_BLUR_SENSITIVE=true diff --git a/agent_rust/src/blur.rs b/agent_rust/src/blur.rs deleted file mode 100644 index 0f9da5a05..000000000 --- a/agent_rust/src/blur.rs +++ /dev/null @@ -1,340 +0,0 @@ -//! Floutage des zones sensibles dans les captures d'ecran. -//! -//! Detecte les champs de saisie (zones claires rectangulaires) et applique -//! un flou gaussien pour proteger les donnees sensibles (mots de passe, etc.). -//! Equivalent de agent_v1/vision/blur_sensitive.py. -//! -//! Algorithme : -//! 1. Conversion en niveaux de gris -//! 2. Seuillage binaire (detecter les zones claires = champs de saisie) -//! 3. Detection de contours rectangulaires > 50px de large -//! 4. Application d'un flou gaussien sur les zones detectees -//! -//! Utilise le crate image pour le traitement et imageproc pour le flou. - -use image::{DynamicImage, GrayImage, Rgba, RgbaImage}; - -/// Seuil de luminosite pour detecter les champs de saisie (0-255). -/// Les zones plus claires que ce seuil sont considerees comme des champs. -const BRIGHTNESS_THRESHOLD: u8 = 220; - -/// Largeur minimale d'un champ de saisie detecte (en pixels). -const MIN_FIELD_WIDTH: u32 = 50; - -/// Hauteur minimale d'un champ de saisie detecte (en pixels). -const MIN_FIELD_HEIGHT: u32 = 15; - -/// Hauteur maximale d'un champ de saisie (evite de flouter l'ecran entier). -const MAX_FIELD_HEIGHT: u32 = 80; - -/// Largeur maximale d'un champ (evite les faux positifs sur grandes zones blanches). -const MAX_FIELD_WIDTH: u32 = 800; - -/// Intensite du flou gaussien (sigma). -const BLUR_SIGMA: f32 = 10.0; - -/// Rectangle representant une zone a flouter. -#[derive(Debug, Clone)] -pub struct BlurRegion { - pub x: u32, - pub y: u32, - pub width: u32, - pub height: u32, -} - -/// Detecte les champs de saisie dans une image et les floute. -/// -/// Retourne l'image modifiee avec les zones sensibles floutees. -/// Si aucun champ n'est detecte, retourne l'image inchangee. -pub fn blur_sensitive_fields(img: &DynamicImage) -> DynamicImage { - let regions = detect_input_fields(img); - - if regions.is_empty() { - return img.clone(); - } - - println!( - "[BLUR] {} zone(s) sensible(s) detectee(s) — floutage...", - regions.len() - ); - - let mut result = img.to_rgba8(); - - for region in ®ions { - blur_region(&mut result, region); - } - - DynamicImage::ImageRgba8(result) -} - -/// Detecte les champs de saisie (zones claires rectangulaires). -/// -/// Algorithme simplifie : -/// 1. Convertir en niveaux de gris -/// 2. Seuillage binaire -/// 3. Scanner les lignes horizontales pour trouver les series de pixels clairs -/// 4. Regrouper les series adjacentes en rectangles -pub fn detect_input_fields(img: &DynamicImage) -> Vec { - let gray = img.to_luma8(); - let (width, height) = gray.dimensions(); - let mut regions = Vec::new(); - - // Creer une image binaire (seuillage) - let binary = threshold_image(&gray, BRIGHTNESS_THRESHOLD); - - // Scanner par bandes horizontales pour detecter les champs - // On cherche des sequences continues de pixels blancs sur plusieurs lignes - let mut y = 0; - while y < height { - // Pour chaque ligne, trouver les segments horizontaux blancs - let segments = find_white_segments(&binary, y, width); - - for (seg_start, seg_end) in &segments { - let seg_width = seg_end - seg_start; - if seg_width < MIN_FIELD_WIDTH || seg_width > MAX_FIELD_WIDTH { - continue; - } - - // Verifier combien de lignes consecutives partagent ce segment - let field_height = count_vertical_extent( - &binary, - *seg_start, - *seg_end, - y, - height, - ); - - if field_height >= MIN_FIELD_HEIGHT && field_height <= MAX_FIELD_HEIGHT { - // Verifier que cette region ne chevauche pas une region existante - let new_region = BlurRegion { - x: *seg_start, - y, - width: seg_width, - height: field_height, - }; - - if !overlaps_existing(®ions, &new_region) { - regions.push(new_region); - } - } - } - - // Avancer de la hauteur du dernier champ detecte, ou de 1 ligne - y += 1; - } - - // Deduplication : fusionner les regions tres proches - merge_close_regions(&mut regions); - - regions -} - -/// Applique un seuillage binaire simple. -fn threshold_image(gray: &GrayImage, threshold: u8) -> GrayImage { - let (width, height) = gray.dimensions(); - let mut binary = GrayImage::new(width, height); - - for y in 0..height { - for x in 0..width { - let pixel = gray.get_pixel(x, y).0[0]; - if pixel >= threshold { - binary.put_pixel(x, y, image::Luma([255])); - } else { - binary.put_pixel(x, y, image::Luma([0])); - } - } - } - - binary -} - -/// Trouve les segments horizontaux de pixels blancs sur une ligne. -fn find_white_segments(binary: &GrayImage, y: u32, width: u32) -> Vec<(u32, u32)> { - let mut segments = Vec::new(); - let mut in_segment = false; - let mut seg_start = 0u32; - - for x in 0..width { - let is_white = binary.get_pixel(x, y).0[0] > 128; - - if is_white && !in_segment { - seg_start = x; - in_segment = true; - } else if !is_white && in_segment { - segments.push((seg_start, x)); - in_segment = false; - } - } - - if in_segment { - segments.push((seg_start, width)); - } - - segments -} - -/// Compte le nombre de lignes consecutives ou le segment est blanc. -fn count_vertical_extent( - binary: &GrayImage, - seg_start: u32, - seg_end: u32, - start_y: u32, - max_y: u32, -) -> u32 { - let mut count = 0u32; - let check_width = seg_end - seg_start; - let threshold = (check_width as f64 * 0.7) as u32; // 70% doivent etre blancs - - for y in start_y..max_y.min(start_y + MAX_FIELD_HEIGHT + 5) { - let mut white_count = 0u32; - for x in seg_start..seg_end { - if binary.get_pixel(x, y).0[0] > 128 { - white_count += 1; - } - } - - if white_count >= threshold { - count += 1; - } else { - break; - } - } - - count -} - -/// Verifie si une region chevauche une region existante. -fn overlaps_existing(regions: &[BlurRegion], new_region: &BlurRegion) -> bool { - for region in regions { - let x_overlap = new_region.x < region.x + region.width - && new_region.x + new_region.width > region.x; - let y_overlap = new_region.y < region.y + region.height - && new_region.y + new_region.height > region.y; - - if x_overlap && y_overlap { - return true; - } - } - false -} - -/// Fusionne les regions tres proches (< 10px de distance). -fn merge_close_regions(regions: &mut Vec) { - if regions.len() < 2 { - return; - } - - // Tri par position (y, puis x) - regions.sort_by(|a, b| a.y.cmp(&b.y).then(a.x.cmp(&b.x))); - - let mut merged = Vec::new(); - let mut current = regions[0].clone(); - - for region in regions.iter().skip(1) { - let x_close = (current.x + current.width + 10 >= region.x) - && (region.x + region.width + 10 >= current.x); - let y_close = (current.y + current.height + 5 >= region.y) - && (region.y + region.height + 5 >= current.y); - - if x_close && y_close { - // Fusionner - let min_x = current.x.min(region.x); - let min_y = current.y.min(region.y); - let max_x = (current.x + current.width).max(region.x + region.width); - let max_y = (current.y + current.height).max(region.y + region.height); - - current = BlurRegion { - x: min_x, - y: min_y, - width: max_x - min_x, - height: max_y - min_y, - }; - } else { - merged.push(current); - current = region.clone(); - } - } - merged.push(current); - - *regions = merged; -} - -/// Applique un flou gaussien sur une region de l'image. -/// -/// Implementation simplifiee : box blur avec plusieurs passes -/// (approximation du gaussien, plus rapide que le vrai gaussien). -fn blur_region(img: &mut RgbaImage, region: &BlurRegion) { - let (img_w, img_h) = img.dimensions(); - - // Borner la region aux dimensions de l'image - let x_start = region.x.min(img_w); - let y_start = region.y.min(img_h); - let x_end = (region.x + region.width).min(img_w); - let y_end = (region.y + region.height).min(img_h); - - if x_start >= x_end || y_start >= y_end { - return; - } - - let radius = BLUR_SIGMA as u32; - let kernel_size = (radius * 2 + 1) as i32; - let kernel_area = (kernel_size * kernel_size) as u32; - - // Box blur : moyenne des pixels dans un carre de rayon `radius` - // On fait 3 passes pour approximer un flou gaussien - for _pass in 0..3 { - // Copier les pixels de la region dans un buffer temporaire - let reg_w = (x_end - x_start) as usize; - let reg_h = (y_end - y_start) as usize; - let mut buffer: Vec<[u8; 4]> = Vec::with_capacity(reg_w * reg_h); - - for y in y_start..y_end { - for x in x_start..x_end { - buffer.push(img.get_pixel(x, y).0); - } - } - - // Appliquer le box blur - for y in y_start..y_end { - for x in x_start..x_end { - let mut sum_r = 0u32; - let mut sum_g = 0u32; - let mut sum_b = 0u32; - let mut count = 0u32; - - for ky in -(radius as i32)..=(radius as i32) { - for kx in -(radius as i32)..=(radius as i32) { - let sx = x as i32 + kx; - let sy = y as i32 + ky; - - if sx >= x_start as i32 - && sx < x_end as i32 - && sy >= y_start as i32 - && sy < y_end as i32 - { - let bx = (sx - x_start as i32) as usize; - let by = (sy - y_start as i32) as usize; - let pixel = buffer[by * reg_w + bx]; - sum_r += pixel[0] as u32; - sum_g += pixel[1] as u32; - sum_b += pixel[2] as u32; - count += 1; - } - } - } - - if count > 0 { - let pixel = Rgba([ - (sum_r / count) as u8, - (sum_g / count) as u8, - (sum_b / count) as u8, - 255, - ]); - img.put_pixel(x, y, pixel); - } - } - } - } - - let _ = kernel_area; // suppress unused warning -} diff --git a/agent_rust/src/capture.rs b/agent_rust/src/capture.rs deleted file mode 100644 index f650a2c68..000000000 --- a/agent_rust/src/capture.rs +++ /dev/null @@ -1,115 +0,0 @@ -//! 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). -/// -/// xcap utilise DXGI sur Windows qui retourne toujours les pixels physiques, -/// independamment du DPI awareness. Ceci est coherent avec les coordonnees -/// physiques d'enigo quand le process est DPI-aware. -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/chat.rs b/agent_rust/src/chat.rs deleted file mode 100644 index 39cba547b..000000000 --- a/agent_rust/src/chat.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! Chat Léa via Edge en mode app (--app=URL). -//! -//! Ouvre Edge sans barre d'adresse — rendu propre et professionnel. -//! Equivalent de agent_v1/ui/chat_window.py (approche Edge mode app). - -use crate::config::Config; -use crate::state::AgentState; -use std::sync::Arc; -use std::process::Command; - -/// URL du serveur de chat -fn chat_url(config: &Config) -> String { - config.chat_url() -} - -/// Chemin de Edge sur Windows (via le registre ou chemins courants) -fn find_edge() -> Option { - let paths = [ - r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", - r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", - ]; - for p in &paths { - if std::path::Path::new(p).exists() { - return Some(p.to_string()); - } - } - // Essayer via le registre - #[cfg(target_os = "windows")] - { - use std::process::Command; - if let Ok(output) = Command::new("reg") - .args(&["query", r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe", "/ve"]) - .output() - { - let text = String::from_utf8_lossy(&output.stdout); - for line in text.lines() { - if line.contains("REG_SZ") { - if let Some(path) = line.split("REG_SZ").last() { - let path = path.trim(); - if std::path::Path::new(path).exists() { - return Some(path.to_string()); - } - } - } - } - } - } - None -} - -/// Lance le chat dans un thread. -/// -/// Attend que `state.chat_visible` passe à true, puis ouvre Edge en mode app. -/// Quand la fenêtre est fermée, remet `chat_visible` à false. -pub fn run_chat_thread(config: &Config, state: Arc) { - let url = chat_url(config); - let edge_path = find_edge(); - - if let Some(ref path) = edge_path { - println!("[CHAT] Edge trouvé : {}", path); - } else { - println!("[CHAT] Edge non trouvé — fallback navigateur par défaut"); - } - - loop { - // Attendre l'activation - while !state.chat_visible.load(std::sync::atomic::Ordering::Relaxed) { - if !state.is_running() { - println!("[CHAT] Arrêt du thread chat"); - return; - } - std::thread::sleep(std::time::Duration::from_millis(200)); - } - - println!("[CHAT] Ouverture du chat..."); - println!("[CHAT] URL : {}", url); - - let result = if let Some(ref path) = edge_path { - // Edge en mode app — fenêtre propre sans barre d'adresse - Command::new(path) - .args(&[ - &format!("--app={}", url), - "--window-size=600,800", - "--window-position=1300,200", - "--disable-extensions", - "--no-first-run", - ]) - .spawn() - } else { - // Fallback : ouvrir dans le navigateur par défaut - #[cfg(target_os = "windows")] - { - Command::new("cmd") - .args(&["/C", "start", &url]) - .spawn() - } - #[cfg(not(target_os = "windows"))] - { - Command::new("xdg-open") - .arg(&url) - .spawn() - } - }; - - match result { - Ok(mut child) => { - println!("[CHAT] Fenêtre ouverte (PID: {:?})", child.id()); - // Attendre que la fenêtre se ferme - let _ = child.wait(); - println!("[CHAT] Fenêtre fermée"); - } - Err(e) => { - println!("[CHAT] Erreur ouverture : {}", e); - } - } - - // Marquer comme invisible - state.chat_visible.store(false, std::sync::atomic::Ordering::Relaxed); - - // Petit délai avant de pouvoir réouvrir - std::thread::sleep(std::time::Duration::from_millis(500)); - } -} diff --git a/agent_rust/src/config.rs b/agent_rust/src/config.rs deleted file mode 100644 index 5d50adc15..000000000 --- a/agent_rust/src/config.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! Configuration de l'agent RPA. -//! -//! Parametres charges depuis les variables d'environnement ou valeurs par defaut. -//! Un fichier `config.txt` (clé=valeur) peut être placé à côté de l'exécutable. -//! Les variables d'environnement ont priorité sur le fichier. -//! Compatible avec la configuration Python (agent_v1/config.py). - -use std::env; -use std::fs; -use std::path::PathBuf; - -/// Version de l'agent Rust -pub const AGENT_VERSION: &str = "0.2.0-rust"; - -/// Configuration complete 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 defaut) - pub machine_id: String, - - /// Port du mini-serveur HTTP de capture (defaut: 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, - - /// Qualite JPEG pour les screenshots envoyes (1-100) - pub jpeg_quality: u8, - - /// Flouter les zones sensibles dans les captures (defaut: true) - pub blur_sensitive: bool, - - /// Retention des logs en jours (Article 12, Reglement IA, defaut: 180) - pub log_retention_days: u32, - - /// Port du serveur de chat (defaut: 5004) - pub chat_port: u16, - - /// Token Bearer pour l'authentification API (defaut: vide = pas d'auth) - pub api_token: String, -} - -impl Config { - /// Charge le fichier `config.txt` situé à côté de l'exécutable (ou dans le dossier courant). - /// - /// Format : une ligne par clé, `CLÉ=VALEUR`. Les lignes vides et celles commençant - /// par `#` sont ignorées. Seules les clés **absentes** de l'environnement sont injectées - /// (les variables d'environnement ont toujours priorité). - fn load_config_file() { - // 1. Chercher config.txt à côté de l'exécutable - let mut config_path: Option = None; - - if let Ok(exe) = env::current_exe() { - let candidate = exe.parent().map(|p| p.join("config.txt")); - if let Some(ref p) = candidate { - if p.is_file() { - config_path = candidate; - } - } - } - - // 2. Fallback : dossier courant - if config_path.is_none() { - let cwd_candidate = PathBuf::from("config.txt"); - if cwd_candidate.is_file() { - config_path = Some(cwd_candidate); - } - } - - let path = match config_path { - Some(p) => p, - None => return, // Pas de fichier config — ce n'est pas une erreur - }; - - let content = match fs::read_to_string(&path) { - Ok(c) => c, - Err(e) => { - eprintln!("[config] Impossible de lire {} : {}", path.display(), e); - return; - } - }; - - eprintln!("[config] Chargement de {}", path.display()); - - for line in content.lines() { - let trimmed = line.trim(); - - // Ignorer les lignes vides et les commentaires - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - // Séparer au premier '=' - if let Some(eq_pos) = trimmed.find('=') { - let key = trimmed[..eq_pos].trim(); - let value = trimmed[eq_pos + 1..].trim(); - - if key.is_empty() { - continue; - } - - // Ne positionner que si la variable n'existe pas déjà - if env::var(key).is_err() { - // SAFETY: appelé une seule fois au démarrage, avant tout thread - unsafe { - env::set_var(key, value); - } - } - } - } - } - - /// Charge la configuration depuis les variables d'environnement. - /// - /// Le fichier `config.txt` est lu en premier (voir [`load_config_file`]) ; - /// les variables d'environnement déjà définies ne sont pas écrasées. - /// - /// Variables supportees : - /// - `RPA_SERVER_URL` : URL du serveur (defaut: http://localhost:5005/api/v1) - /// - `RPA_MACHINE_ID` : Identifiant machine (defaut: hostname_os) - /// - `RPA_CAPTURE_PORT` : Port du serveur de capture (defaut: 5006) - /// - `RPA_HEARTBEAT_INTERVAL` : Intervalle heartbeat en secondes (defaut: 5) - /// - `RPA_JPEG_QUALITY` : Qualite JPEG (defaut: 85) - /// - `RPA_BLUR_SENSITIVE` : Flouter les zones sensibles (defaut: true) - /// - `RPA_LOG_RETENTION_DAYS` : Retention des logs en jours (defaut: 180) - /// - `RPA_CHAT_PORT` : Port du serveur de chat (defaut: 5004) - /// - `RPA_API_TOKEN` : Token Bearer pour l'authentification (defaut: vide) - pub fn from_env() -> Self { - // Charger config.txt AVANT de lire les variables d'environnement - Self::load_config_file(); - 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); - - let blur_sensitive = env::var("RPA_BLUR_SENSITIVE") - .map(|v| v != "0" && v.to_lowercase() != "false") - .unwrap_or(true); - - let log_retention_days = env::var("RPA_LOG_RETENTION_DAYS") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(180); - - let chat_port = env::var("RPA_CHAT_PORT") - .ok() - .and_then(|v| v.parse().ok()) - .unwrap_or(5004); - - let api_token = env::var("RPA_API_TOKEN").unwrap_or_default(); - - Config { - server_url, - machine_id, - capture_port, - heartbeat_interval_s, - replay_poll_interval_s: 1.0, - jpeg_quality, - blur_sensitive, - log_retention_days, - chat_port, - api_token, - } - } - - /// 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) - } - - /// URL du serveur de chat. - pub fn chat_url(&self) -> String { - // Extraire le host du server_url - let base = &self.server_url; - if let Some(host_start) = base.find("://") { - let after_scheme = &base[host_start + 3..]; - if let Some(colon_pos) = after_scheme.find(':') { - let host = &after_scheme[..colon_pos]; - return format!( - "http://{}:{}/?machine_id={}", - host, self.chat_port, self.machine_id - ); - } - } - format!( - "http://localhost:{}/?machine_id={}", - self.chat_port, 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: {}, blur: {}, log_retention: {}j, chat_port: {}, auth: {} }}", - self.server_url, self.machine_id, self.capture_port, - self.heartbeat_interval_s, self.jpeg_quality, - self.blur_sensitive, self.log_retention_days, self.chat_port, - if self.api_token.is_empty() { "none" } else { "Bearer" }, - ) - } -} diff --git a/agent_rust/src/executor.rs b/agent_rust/src/executor.rs deleted file mode 100644 index 52fef6881..000000000 --- a/agent_rust/src/executor.rs +++ /dev/null @@ -1,384 +0,0 @@ -//! 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::config::Config; -use crate::network::{Action, ActionResult}; -use crate::visual; -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. -/// Si visual_mode est activé, résout d'abord la cible via le serveur. -pub fn execute_action( - action: &Action, - screen_width: u32, - screen_height: u32, - config: &Config, -) -> ActionResult { - match action.action_type.as_str() { - "click" => execute_click(action, screen_width, screen_height, config), - "type" => execute_type(action, screen_width, screen_height, config), - "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), - ), - } -} - -/// Résout les coordonnées visuellement si visual_mode est activé. -/// -/// Si la résolution échoue, retourne les coordonnées de fallback (blind). -/// Si visual_mode est désactivé ou target_spec absent, retourne les coordonnées originales. -fn resolve_coordinates( - action: &Action, - screen_width: u32, - screen_height: u32, - config: &Config, -) -> (f64, f64) { - let mut x_pct = action.x_pct; - let mut y_pct = action.y_pct; - - if action.visual_mode && !action.target_spec.is_null() { - println!( - " [VISUAL] Mode visuel active — resolution de la cible..." - ); - match visual::resolve_target_visual( - config, - &action.target_spec, - x_pct, - y_pct, - screen_width, - screen_height, - ) { - Some((rx, ry)) => { - println!(" [VISUAL] Resolu : ({:.4}, {:.4})", rx, ry); - x_pct = rx; - y_pct = ry; - } - None => { - println!( - " [VISUAL] Echec — fallback coordonnees aveugles ({:.4}, {:.4})", - x_pct, y_pct - ); - } - } - } - - (x_pct, y_pct) -} - -/// Exécute un clic souris aux coordonnées normalisées. -/// Résout visuellement la cible si visual_mode est activé. -fn execute_click(action: &Action, screen_width: u32, screen_height: u32, config: &Config) -> ActionResult { - let (x_pct, y_pct) = resolve_coordinates(action, screen_width, screen_height, config); - let real_x = (x_pct * screen_width as f64) as i32; - let real_y = (y_pct * screen_height as f64) as i32; - - println!( - " [CLICK] ({:.4}, {:.4}) -> ({}, {}) sur ({}x{}), bouton={}{}", - x_pct, y_pct, real_x, real_y, screen_width, screen_height, action.button, - if action.visual_mode { " [VISUAL]" } else { "" } - ); - - 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, config: &Config) -> ActionResult { - let text = &action.text; - println!( - " [TYPE] Texte: '{}' ({} chars)", - if text.len() > 50 { &text[..50] } else { text }, - text.len() - ); - - // Résoudre visuellement les coordonnées si visual_mode est activé - let (x_pct, y_pct) = resolve_coordinates(action, screen_width, screen_height, config); - - 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 x_pct > 0.0 && y_pct > 0.0 { - let real_x = (x_pct * screen_width as f64) as i32; - let real_y = (y_pct * screen_height as f64) as i32; - println!(" [TYPE] Clic prealable sur ({}, {}){}", real_x, real_y, - if action.visual_mode { " [VISUAL]" } else { "" }); - - 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 deleted file mode 100644 index a256cf867..000000000 --- a/agent_rust/src/main.rs +++ /dev/null @@ -1,430 +0,0 @@ -//! Agent RPA Vision — Phases 1-5 (parite complete) -//! -//! Point d'entree principal. Architecture multi-threads : -//! -//! - Thread principal : boucle d'evenements systray (Windows) ou attente console (Linux) -//! - Thread heartbeat : capture + envoi toutes les 5s (avec dedup par hash) -//! - Thread replay : poll toutes les 1s, execute les actions -//! - Thread serveur : HTTP port 5006 pour les captures a la demande -//! - Thread recorder : capture evenements souris/clavier (quand enregistrement actif) -//! - Thread chat : fenetre WebView2 (Windows, a la demande) -//! - Thread health : verification connexion serveur (toutes les 30s) -//! -//! Le thread principal gere le systray sur Windows via winit. -//! Sur Linux, le thread principal attend Ctrl+C (mode console). -//! -//! Configuration via variables d'environnement ou valeurs par defaut. -//! Compatible avec le serveur streaming existant (api_stream.py, port 5005). - -#[allow(dead_code)] -mod blur; -mod capture; -mod chat; -mod config; -mod executor; -mod network; -#[allow(dead_code)] -mod notifications; -mod recorder; -mod replay; -mod server; -#[allow(dead_code)] -mod state; -mod sysinfo; -mod tray; -mod visual; - -use config::Config; -use reqwest::blocking::Client; -use state::AgentState; -use std::sync::Arc; -use std::thread; -use std::time::Duration; - -/// Trouve un navigateur compatible sur Windows (Edge, Chrome, Brave, Firefox) -#[cfg(target_os = "windows")] -fn find_browser() -> Option { - let paths = [ - // Edge - r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", - r"C:\Program Files\Microsoft\Edge\Application\msedge.exe", - // Chrome - r"C:\Program Files\Google\Chrome\Application\chrome.exe", - r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe", - // Brave - r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe", - // Firefox (supporte --kiosk mais pas --app) - r"C:\Program Files\Mozilla Firefox\firefox.exe", - ]; - for p in &paths { - if std::path::Path::new(p).exists() { - return Some(p.to_string()); - } - } - None -} - -fn main() { - // --- DPI awareness (DOIT etre appele avant toute operation graphique) --- - // Rend le process DPI-aware sur Windows pour que les API (enigo, xcap, - // GetSystemMetrics, etc.) travaillent en coordonnees physiques (pixels reels) - // au lieu de coordonnees logiques (virtualisees par le DPI scaling). - // Sans cet appel, un ecran 2560x1600 a 150% DPI apparait comme 1707x1067 - // pour enigo et GetSystemMetrics, ce qui cause des erreurs de positionnement - // pendant le replay. - // PROCESS_PER_MONITOR_DPI_AWARE = 2 : le niveau le plus precis. - #[cfg(target_os = "windows")] - { - // SetProcessDpiAwareness (shcore.dll) et SetProcessDPIAware (user32.dll) - // ne sont pas toujours exposes par windows-sys selon les features. - // On utilise des appels FFI raw pour eviter d'ajouter des features. - #[link(name = "shcore")] - extern "system" { - fn SetProcessDpiAwareness(value: i32) -> i32; - } - #[link(name = "user32")] - extern "system" { - fn SetProcessDPIAware() -> i32; - } - unsafe { - // Tenter SetProcessDpiAwareness(2) = PROCESS_PER_MONITOR_DPI_AWARE - let hr = SetProcessDpiAwareness(2); - if hr != 0 { - // Fallback pour Windows < 8.1 : SetProcessDPIAware() - SetProcessDPIAware(); - } - } - } - - // Initialiser le logging - env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or("info"), - ) - .format_timestamp_secs() - .init(); - - let config = Config::from_env(); - let config = Arc::new(config); - - // Etat partage thread-safe - let state = AgentState::new(); - - // Banniere de demarrage - print_banner(&config); - - // Handler Ctrl+C pour arret propre - install_ctrlc_handler(state.clone()); - - // 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 hb_state = state.clone(); - let _heartbeat_thread = thread::Builder::new() - .name("heartbeat".to_string()) - .spawn(move || { - heartbeat_loop(&hb_config, &hb_state); - }) - .expect("Impossible de demarrer le thread heartbeat"); - - // Thread 2 : Replay poll loop - let rp_config = config.clone(); - let rp_state = state.clone(); - let _replay_thread = thread::Builder::new() - .name("replay".to_string()) - .spawn(move || { - replay::replay_poll_loop(&rp_config, &rp_state); - }) - .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"); - - // Thread 4 : Health check (verification connexion serveur) - let hc_config = config.clone(); - let hc_state = state.clone(); - let _health_thread = thread::Builder::new() - .name("health-check".to_string()) - .spawn(move || { - health_check_loop(&hc_config, &hc_state); - }) - .expect("Impossible de demarrer le thread health check"); - - // Thread 5 : Recorder (capture evenements — inactif jusqu'a enregistrement) - let rec_config = config.clone(); - let rec_state = state.clone(); - let _recorder_rx = recorder::start_recorder(rec_config, rec_state); - - // Thread 6 : Chat window (WebView2, a la demande) - let chat_config = config.clone(); - let chat_state = state.clone(); - chat::run_chat_thread(&chat_config, chat_state); - - // Synchroniser les workflows disponibles depuis le serveur - let sync_config = config.clone(); - let workflows = { - let client = Client::new(); - network::fetch_workflows(&client, &sync_config) - }; - if workflows.is_empty() { - println!("[MAIN] Aucun workflow disponible pour cette machine."); - } else { - println!( - "[MAIN] {} workflow(s) disponible(s) :", - workflows.len() - ); - for wf in &workflows { - println!( - " - {} ({} noeuds, {} transitions)", - wf.name, wf.nodes, wf.edges - ); - } - } - - println!("\n[MAIN] Agent operationnel — tous les threads demarres.\n"); - - // Ouvrir Léa dans le navigateur disponible (mode app) au démarrage - #[cfg(target_os = "windows")] - { - let chat_url = config.chat_url(); - if let Some(browser) = find_browser() { - let browser_name = if browser.contains("chrome") { "Chrome" } - else if browser.contains("edge") || browser.contains("Edge") { "Edge" } - else if browser.contains("brave") || browser.contains("Brave") { "Brave" } - else if browser.contains("firefox") || browser.contains("Firefox") { "Firefox" } - else { "navigateur" }; - println!("[MAIN] Ouverture de Léa dans {}...", browser_name); - let _ = std::process::Command::new(&browser) - .args(&[ - &format!("--app={}", chat_url), - "--window-size=600,800", - "--disable-extensions", - "--no-first-run", - ]) - .spawn(); - } else { - println!("[MAIN] Aucun navigateur trouvé — ouvrez manuellement : {}", chat_url); - } - } - - // Attente principale : Ctrl+C pour arrêter - println!("[MAIN] Appuyez sur Ctrl+C pour quitter.\n"); - loop { - if !state.is_running() { - break; - } - thread::sleep(Duration::from_millis(500)); - } - - // Si on arrive ici, l'agent doit s'arreter - println!("\n[MAIN] Arret en cours..."); - state.request_shutdown(); - - // Laisser le temps aux threads de se terminer - thread::sleep(Duration::from_millis(500)); - - println!("[MAIN] Agent arrete."); -} - -/// Installe un handler Ctrl+C qui met l'etat a "arret demande". -fn install_ctrlc_handler(state: Arc) { - #[cfg(unix)] - { - 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; - } - - static mut WRITE_FD: i32 = -1; - WRITE_FD = fds[1]; - - // Sauvegarder un pointeur vers l'etat dans une static - // pour pouvoir y acceder depuis le handler - static mut STATE_PTR: *const AgentState = std::ptr::null(); - STATE_PTR = Arc::as_ptr(&state); - - extern "C" fn sigint_handler(_sig: i32) { - unsafe { - if !STATE_PTR.is_null() { - (*STATE_PTR) - .running - .store(false, std::sync::atomic::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, le systray gere l'arret via le menu "Quitter" - // Le handler console est un bonus pour le mode headless - let _ = state; - } -} - -/// Boucle de heartbeat : capture un screenshot toutes les N secondes -/// et l'envoie au serveur si l'ecran a change. -/// Applique le floutage des zones sensibles si active dans la config. -fn heartbeat_loop(config: &Config, state: &AgentState) { - 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 state.is_running() { - // Verifier l'arret d'urgence - if state - .emergency_stop - .load(std::sync::atomic::Ordering::SeqCst) - { - thread::sleep(Duration::from_secs(1)); - continue; - } - - // 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 { - thread::sleep(Duration::from_secs(config.heartbeat_interval_s)); - continue; - } - last_hash = current_hash; - - // Appliquer le floutage des zones sensibles si active - let final_img = if config.blur_sensitive { - blur::blur_sensitive_fields(&img) - } else { - img - }; - - // Encoder en JPEG - let jpeg_bytes = - capture::screenshot_to_jpeg_bytes(&final_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 { - eprintln!( - "[HEARTBEAT] {} erreur(s) consecutives", - consecutive_errors - ); - } - } - } - None => { - thread::sleep(Duration::from_secs(config.heartbeat_interval_s * 2)); - continue; - } - } - - thread::sleep(Duration::from_secs(config.heartbeat_interval_s)); - } - - println!("[HEARTBEAT] Boucle arretee."); -} - -/// Boucle de health check : verifie la connexion au serveur toutes les 30s. -/// Met a jour l'etat de connexion dans AgentState. -fn health_check_loop(config: &Config, state: &AgentState) { - let client = Client::new(); - let check_interval = Duration::from_secs(30); - let timeout = Duration::from_secs(5); - - println!("[HEALTH] Boucle health check demarree (intervalle=30s)"); - - while state.is_running() { - let url = format!("{}/stats", config.server_url); - let request = client.get(&url).timeout(timeout); - let connected = network::with_auth(request, config) - .send() - .map(|r| r.status().is_success()) - .unwrap_or(false); - - let was_connected = state.connected.load(std::sync::atomic::Ordering::SeqCst); - state.set_connected(connected); - - // Notifier si le statut a change - if connected != was_connected { - notifications::connection_changed(connected); - } - - thread::sleep(check_interval); - } - - println!("[HEALTH] Boucle arretee."); -} - -/// Affiche la banniere de demarrage. -fn print_banner(config: &Config) { - let meta = sysinfo::get_screen_metadata(); - - println!("======================================================"); - println!( - " RPA Vision Agent v{} (Rust)", - config::AGENT_VERSION - ); - println!(" Phases 1-5 — Parite complete"); - println!("------------------------------------------------------"); - println!(" Machine : {}", config.machine_id); - println!(" Serveur : {}", config.server_url); - println!(" Capture : port {}", config.capture_port); - println!(" Chat : port {}", config.chat_port); - println!(" Heartbeat : toutes les {}s", config.heartbeat_interval_s); - println!(" JPEG : qualite {}", config.jpeg_quality); - println!(" Floutage : {}", if config.blur_sensitive { "actif" } else { "inactif" }); - println!(" Logs : retention {} jours", config.log_retention_days); - println!(" Auth : {}", if config.api_token.is_empty() { "aucune" } else { "Bearer token" }); - println!(" Workflows : synchronisation au demarrage"); - println!( - " Ecran : {}x{} @ {}% DPI", - meta.screen_resolution[0], meta.screen_resolution[1], meta.dpi_scale - ); - println!( - " Moniteur : #{} ({})", - meta.monitor_index, - if meta.monitor_index == 0 { "principal" } else { "secondaire" } - ); - println!("======================================================"); - println!(); - println!(" [IA] Cet agent utilise l'intelligence artificielle."); - println!(" Article 50 du Reglement europeen sur l'IA."); - println!(); -} diff --git a/agent_rust/src/network.rs b/agent_rust/src/network.rs deleted file mode 100644 index 15286bef9..000000000 --- a/agent_rust/src/network.rs +++ /dev/null @@ -1,391 +0,0 @@ -//! 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 crate::sysinfo; -use reqwest::blocking::{Client, RequestBuilder}; -use serde::{Deserialize, Serialize}; - -/// Ajoute le header Authorization Bearer si un token est configure. -/// -/// Si `config.api_token` est vide, la requete est retournee telle quelle. -pub fn with_auth(request: RequestBuilder, config: &Config) -> RequestBuilder { - if config.api_token.is_empty() { - request - } else { - request.header("Authorization", format!("Bearer {}", config.api_token)) - } -} - -/// 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. -/// Inclut les métadonnées système (DPI, résolution, fenêtre, moniteur) -/// dans les query params pour que le serveur puisse les exploiter. -/// 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()); - - // Collecter les métadonnées système - let meta = sysinfo::get_screen_metadata(); - let dpi_str = meta.dpi_scale.to_string(); - let screen_w_str = meta.screen_resolution[0].to_string(); - let screen_h_str = meta.screen_resolution[1].to_string(); - let monitor_str = meta.monitor_index.to_string(); - - // Sérialiser window_bounds en JSON compact (ou "null") - let wb_str = match meta.window_bounds { - Some(wb) => format!("[{},{},{},{}]", wb[0], wb[1], wb[2], wb[3]), - None => "null".to_string(), - }; - - 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); - - let request = client - .post(&url) - .query(&[ - ("session_id", session_id), - ("shot_id", &shot_id), - ("machine_id", &config.machine_id), - ("dpi_scale", &dpi_str), - ("screen_w", &screen_w_str), - ("screen_h", &screen_h_str), - ("monitor_index", &monitor_str), - ("window_bounds", &wb_str), - ]) - .multipart(form) - .timeout(std::time::Duration::from_secs(10)); - - match with_auth(request, config).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 request = client - .get(&url) - .query(&[ - ("session_id", session_id.as_str()), - ("machine_id", config.machine_id.as_str()), - ]) - .timeout(std::time::Duration::from_secs(5)); - - let resp = with_auth(request, config).send().ok()?; - - if !resp.status().is_success() { - return None; - } - - let data: ReplayNextResponse = resp.json().ok()?; - data.action -} - -/// Informations résumées d'un workflow disponible. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkflowInfo { - /// Identifiant unique du workflow - pub workflow_id: String, - - /// Nom lisible du workflow - #[serde(default)] - pub name: String, - - /// Identifiant machine associé - #[serde(default)] - pub machine_id: String, - - /// Nombre de nœuds - #[serde(default)] - pub nodes: u32, - - /// Nombre de transitions - #[serde(default)] - pub edges: u32, -} - -/// Réponse du serveur pour GET /traces/stream/workflows -#[derive(Debug, Deserialize)] -struct WorkflowsResponse { - #[serde(default)] - workflows: Vec, -} - -/// Récupère la liste des workflows disponibles pour cette machine. -/// -/// GET /traces/stream/workflows?machine_id= -/// Sauvegarde le résultat dans workflows.json à côté de l'exécutable. -/// Retourne la liste (éventuellement depuis le cache local si le serveur est indisponible). -pub fn fetch_workflows(client: &Client, config: &Config) -> Vec { - let url = format!("{}/workflows", config.streaming_url()); - - let request = client - .get(&url) - .query(&[("machine_id", config.machine_id.as_str())]) - .timeout(std::time::Duration::from_secs(5)); - - let workflows = match with_auth(request, config).send() { - Ok(resp) if resp.status().is_success() => { - match resp.json::() { - Ok(data) => data.workflows, - Err(e) => { - eprintln!("[WORKFLOWS] Erreur parsing reponse : {}", e); - Vec::new() - } - } - } - Ok(resp) => { - eprintln!("[WORKFLOWS] Serveur HTTP {} — chargement cache local", resp.status()); - return load_workflows_cache(); - } - Err(e) => { - eprintln!("[WORKFLOWS] Serveur injoignable ({}) — chargement cache local", e); - return load_workflows_cache(); - } - }; - - // Sauvegarder dans le cache local - save_workflows_cache(&workflows); - - workflows -} - -/// Chemin du fichier cache workflows.json (à côté de l'exécutable ou dans le dossier courant). -fn workflows_cache_path() -> std::path::PathBuf { - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - return dir.join("workflows.json"); - } - } - std::path::PathBuf::from("workflows.json") -} - -/// Sauvegarde les workflows dans le cache local. -fn save_workflows_cache(workflows: &[WorkflowInfo]) { - let path = workflows_cache_path(); - match serde_json::to_string_pretty(workflows) { - Ok(json) => { - if let Err(e) = std::fs::write(&path, json) { - eprintln!("[WORKFLOWS] Erreur ecriture cache {} : {}", path.display(), e); - } - } - Err(e) => { - eprintln!("[WORKFLOWS] Erreur serialisation cache : {}", e); - } - } -} - -/// Charge les workflows depuis le cache local. -fn load_workflows_cache() -> Vec { - let path = workflows_cache_path(); - match std::fs::read_to_string(&path) { - Ok(content) => { - match serde_json::from_str::>(&content) { - Ok(workflows) => { - println!("[WORKFLOWS] {} workflow(s) charges depuis le cache local", workflows.len()); - workflows - } - Err(e) => { - eprintln!("[WORKFLOWS] Erreur parsing cache : {}", e); - Vec::new() - } - } - } - Err(_) => Vec::new(), // Pas de cache, pas d'erreur - } -} - -/// 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, - }; - - let request = client - .post(&url) - .json(&report) - .timeout(std::time::Duration::from_secs(10)); - - match with_auth(request, config).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/notifications.rs b/agent_rust/src/notifications.rs deleted file mode 100644 index bd0aae3b1..000000000 --- a/agent_rust/src/notifications.rs +++ /dev/null @@ -1,135 +0,0 @@ -//! Notifications toast Windows. -//! -//! Affiche des notifications natives Windows via l'API WinRT (winrt-notification). -//! Equivalent de agent_v1/ui/notifications.py. -//! -//! Sur Linux/macOS : les notifications sont simplement affichees en console (log). -//! Le crate winrt-notification n'est disponible que sur Windows. - -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// Intervalle minimum entre deux notifications identiques (en secondes). -/// Evite le spam de notifications si le meme evenement se repete. -const MIN_INTERVAL_SECS: u64 = 5; - -/// Timestamp de la derniere notification envoyee (rate limiting). -static LAST_NOTIFY_TIME: AtomicU64 = AtomicU64::new(0); - -/// Affiche une notification toast native. -/// -/// Sur Windows : utilise winrt-notification pour les toasts natifs. -/// Sur les autres OS : affiche en console. -/// Rate-limited : pas plus d'une notification toutes les 5 secondes. -pub fn notify(title: &str, message: &str) { - // Rate limiting - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - let last = LAST_NOTIFY_TIME.load(Ordering::Relaxed); - if now - last < MIN_INTERVAL_SECS { - return; - } - LAST_NOTIFY_TIME.store(now, Ordering::Relaxed); - - // Log console dans tous les cas - println!("[NOTIFICATION] {} : {}", title, message); - - // Toast natif Windows - #[cfg(windows)] - { - notify_windows(title, message); - } -} - -/// Implementation Windows via winrt-notification. -#[cfg(windows)] -fn notify_windows(title: &str, message: &str) { - use winrt_notification::{Toast, Sound}; - - let result = Toast::new(Toast::POWERSHELL_APP_ID) - .title(title) - .text1(message) - .sound(Some(Sound::Default)) - .show(); - - if let Err(e) = result { - eprintln!("[NOTIFICATION] Erreur toast Windows : {:?}", e); - } -} - -// --- Notifications predefinies (equivalent Python) --- - -/// Notification de bienvenue au demarrage. -pub fn greet() { - notify( - "Lea - Assistant IA", - "Bonjour ! Lea est prete. (IA)\nJe peux observer et automatiser vos taches.", - ); -} - -/// Notification de debut de session d'enregistrement. -pub fn session_started(name: &str) { - notify( - "Enregistrement demarre", - &format!( - "C'est parti ! Je regarde et je memorise.\nSession : {}", - name - ), - ); -} - -/// Notification de fin de session d'enregistrement. -pub fn session_ended(actions_count: u32) { - notify( - "Enregistrement termine", - &format!( - "C'est note ! J'ai compris les {} etapes.", - actions_count - ), - ); -} - -/// Notification de debut de replay. -pub fn replay_started(name: &str) { - notify( - "Replay en cours", - &format!( - "Le systeme d'IA execute la tache...\nWorkflow : {}", - name - ), - ); -} - -/// Notification de fin de replay. -pub fn replay_finished(success: bool) { - if success { - notify("Replay termine", "C'est fait ! La tache a ete executee avec succes."); - } else { - notify( - "Replay echoue", - "Hmm, j'ai eu un souci. Verifiez le resultat.", - ); - } -} - -/// Notification de changement de connexion. -pub fn connection_changed(connected: bool) { - if connected { - notify("Connexion etablie", "Connectee au serveur RPA Vision."); - } else { - notify( - "Connexion perdue", - "Connexion au serveur perdue. Tentative de reconnexion...", - ); - } -} - -/// Notification d'arret d'urgence. -pub fn emergency_stop_activated() { - notify( - "ARRET D'URGENCE", - "Toutes les operations ont ete arretees immediatement.", - ); -} diff --git a/agent_rust/src/recorder.rs b/agent_rust/src/recorder.rs deleted file mode 100644 index 010bff819..000000000 --- a/agent_rust/src/recorder.rs +++ /dev/null @@ -1,713 +0,0 @@ -//! Capture d'evenements souris/clavier pour l'enregistrement de sessions. -//! -//! Utilise rdev pour intercepter les evenements globaux (sans focus). -//! Les evenements sont envoyes au serveur streaming via network.rs. -//! Equivalent de agent_v1/core/captor.py. -//! -//! Le recorder est actif uniquement quand state.recording == true. -//! Il capture : -//! - Clics souris (gauche, droit, double-clic) -//! - Saisie clavier (buffer de texte avec flush apres 500ms d'inactivite) -//! - Combos clavier (Ctrl+C, Alt+Tab, etc.) -//! -//! Sur les OS non-Windows, rdev fonctionne aussi (Linux via X11/evdev) -//! mais les tests doivent etre faits manuellement. - -use crate::capture; -use crate::config::Config; -use crate::state::AgentState; -use crossbeam_channel::{bounded, Receiver, Sender}; -use std::sync::Arc; -use std::thread; -use std::time::{Duration, Instant}; - -/// Evenement capture et pret a etre envoye au serveur. -#[derive(Debug, Clone)] -pub enum CapturedEvent { - /// Clic souris (x_pct, y_pct, bouton, window_title) - Click { - x_pct: f64, - y_pct: f64, - button: String, - window_title: String, - }, - /// Double-clic (x_pct, y_pct, window_title) - DoubleClick { - x_pct: f64, - y_pct: f64, - window_title: String, - }, - /// Texte saisi (accumule via le buffer de frappe) - Text { - text: String, - x_pct: f64, - y_pct: f64, - }, - /// Combo clavier (ex: ["ctrl", "c"]) - KeyCombo { keys: Vec }, - /// Scroll (delta, x_pct, y_pct) - Scroll { - delta: i32, - x_pct: f64, - y_pct: f64, - }, -} - -/// Etat interne du recorder pour le buffer de frappe. -struct RecorderState { - /// Buffer de texte en cours (flush apres 500ms d'inactivite) - text_buffer: String, - /// Dernier timestamp de frappe (pour le flush timeout) - last_keystroke: Instant, - /// Position du curseur au debut de la saisie - text_start_x: f64, - text_start_y: f64, - /// Derniere position du clic (pour le double-clic) - last_click_time: Instant, - last_click_x: f64, - last_click_y: f64, - /// Modifieurs actuellement enfonces - ctrl_held: bool, - alt_held: bool, - shift_held: bool, - meta_held: bool, - /// Dimensions de l'ecran (pour normaliser les coordonnees) - screen_width: u32, - screen_height: u32, -} - -impl RecorderState { - fn new(screen_width: u32, screen_height: u32) -> Self { - Self { - text_buffer: String::new(), - last_keystroke: Instant::now(), - text_start_x: 0.0, - text_start_y: 0.0, - last_click_time: Instant::now() - Duration::from_secs(10), - last_click_x: 0.0, - last_click_y: 0.0, - ctrl_held: false, - alt_held: false, - shift_held: false, - meta_held: false, - screen_width, - screen_height, - } - } - - /// Normalise les coordonnees absolues en pourcentages (0.0-1.0). - fn normalize(&self, x: f64, y: f64) -> (f64, f64) { - if self.screen_width == 0 || self.screen_height == 0 { - return (0.0, 0.0); - } - ( - x / self.screen_width as f64, - y / self.screen_height as f64, - ) - } - - /// Un modifieur est-il enfonce ? - fn any_modifier_held(&self) -> bool { - self.ctrl_held || self.alt_held || self.meta_held - } -} - -/// Delai de flush du buffer de texte (ms). -const TEXT_FLUSH_DELAY_MS: u64 = 500; - -/// Seuil de distance pour considerer un double-clic (pixels). -const DOUBLE_CLICK_DIST_THRESHOLD: f64 = 10.0; - -/// Seuil de temps pour un double-clic (ms). -const DOUBLE_CLICK_TIME_MS: u64 = 400; - -/// Demarre le thread de capture d'evenements. -/// -/// Cree un canal crossbeam pour envoyer les evenements captures -/// vers le thread d'envoi reseau. Le listener rdev tourne dans -/// un thread dedie car il bloque (callback-based). -pub fn start_recorder( - config: Arc, - state: Arc, -) -> Receiver { - let (tx, rx) = bounded::(100); - - // Thread du listener rdev - let listener_state = state.clone(); - let listener_tx = tx.clone(); - thread::Builder::new() - .name("event-listener".to_string()) - .spawn(move || { - event_listener_loop(listener_tx, listener_state); - }) - .expect("Impossible de demarrer le thread listener"); - - // Thread de flush du buffer de texte - let flush_tx = tx; - let flush_state = state.clone(); - thread::Builder::new() - .name("text-flush".to_string()) - .spawn(move || { - text_flush_loop(flush_tx, flush_state); - }) - .expect("Impossible de demarrer le thread flush"); - - // Thread d'envoi des evenements captures vers le serveur - let send_state = state; - let send_rx = rx.clone(); - let send_config = config; - thread::Builder::new() - .name("event-sender".to_string()) - .spawn(move || { - event_sender_loop(send_rx, send_config, send_state); - }) - .expect("Impossible de demarrer le thread sender"); - - rx -} - -/// Boucle du listener rdev — capture les evenements souris/clavier globaux. -/// -/// rdev::listen est bloquant et appelle le callback pour chaque evenement. -/// On filtre et transforme les evenements pertinents, puis on les envoie -/// via le canal crossbeam. -fn event_listener_loop(tx: Sender, state: Arc) { - let (screen_w, screen_h) = capture::screen_dimensions().unwrap_or((1920, 1080)); - let rec_state = std::sync::Mutex::new(RecorderState::new(screen_w, screen_h)); - - println!( - "[RECORDER] Listener demarre (ecran {}x{})", - screen_w, screen_h - ); - - // rdev::listen prend un callback FnMut - let callback = move |event: rdev::Event| { - // Ne capturer que si l'enregistrement est actif - if !state.recording.load(std::sync::atomic::Ordering::SeqCst) { - return; - } - - let mut rs = match rec_state.lock() { - Ok(s) => s, - Err(_) => return, - }; - - match event.event_type { - rdev::EventType::ButtonPress(button) => { - let btn_name = match button { - rdev::Button::Left => "left", - rdev::Button::Right => "right", - rdev::Button::Middle => "middle", - _ => return, - }; - - // Obtenir la position de la souris depuis l'evenement - // rdev ne fournit pas toujours les coordonnees dans ButtonPress, - // on utilise la derniere position connue via MouseMove. - // Pour simplifier, on capture la position courante du curseur. - let (mx, my) = get_cursor_position(); - let (x_pct, y_pct) = rs.normalize(mx, my); - - // Flush le buffer de texte avant le clic - if !rs.text_buffer.is_empty() { - let text_event = CapturedEvent::Text { - text: rs.text_buffer.clone(), - x_pct: rs.text_start_x, - y_pct: rs.text_start_y, - }; - let _ = tx.try_send(text_event); - rs.text_buffer.clear(); - } - - // Detection double-clic - let now = Instant::now(); - let dt = now.duration_since(rs.last_click_time); - let dx = (mx - rs.last_click_x).abs(); - let dy = (my - rs.last_click_y).abs(); - let dist = (dx * dx + dy * dy).sqrt(); - - if btn_name == "left" - && dt < Duration::from_millis(DOUBLE_CLICK_TIME_MS) - && dist < DOUBLE_CLICK_DIST_THRESHOLD - { - // Double-clic detecte - let event = CapturedEvent::DoubleClick { - x_pct, - y_pct, - window_title: get_active_window_title(), - }; - let _ = tx.try_send(event); - } else { - // Clic simple - let event = CapturedEvent::Click { - x_pct, - y_pct, - button: btn_name.to_string(), - window_title: get_active_window_title(), - }; - let _ = tx.try_send(event); - - // Incrementer le compteur d'actions - state.increment_actions(); - } - - rs.last_click_time = now; - rs.last_click_x = mx; - rs.last_click_y = my; - } - - rdev::EventType::KeyPress(key) => { - // Mettre a jour les modifieurs - match key { - rdev::Key::ControlLeft | rdev::Key::ControlRight => { - rs.ctrl_held = true; - return; - } - rdev::Key::Alt | rdev::Key::AltGr => { - rs.alt_held = true; - return; - } - rdev::Key::ShiftLeft | rdev::Key::ShiftRight => { - rs.shift_held = true; - return; - } - rdev::Key::MetaLeft | rdev::Key::MetaRight => { - rs.meta_held = true; - return; - } - _ => {} - } - - // Si un modifieur non-shift est enfonce, c'est un combo - if rs.any_modifier_held() { - let mut keys = Vec::new(); - if rs.ctrl_held { - keys.push("ctrl".to_string()); - } - if rs.alt_held { - keys.push("alt".to_string()); - } - if rs.meta_held { - keys.push("win".to_string()); - } - if rs.shift_held { - keys.push("shift".to_string()); - } - keys.push(rdev_key_to_string(key)); - - // Flush le buffer avant le combo - if !rs.text_buffer.is_empty() { - let text_event = CapturedEvent::Text { - text: rs.text_buffer.clone(), - x_pct: rs.text_start_x, - y_pct: rs.text_start_y, - }; - let _ = tx.try_send(text_event); - rs.text_buffer.clear(); - } - - let event = CapturedEvent::KeyCombo { keys }; - let _ = tx.try_send(event); - state.increment_actions(); - } else { - // Touche de saisie normale — ajouter au buffer - if let Some(c) = rdev_key_to_char(key) { - if rs.text_buffer.is_empty() { - let (mx, my) = get_cursor_position(); - let (x, y) = rs.normalize(mx, my); - rs.text_start_x = x; - rs.text_start_y = y; - } - rs.text_buffer.push(c); - rs.last_keystroke = Instant::now(); - } else { - // Touche speciale non-texte (Enter, Tab, etc.) - // Flush le buffer et envoyer comme combo simple - if !rs.text_buffer.is_empty() { - let text_event = CapturedEvent::Text { - text: rs.text_buffer.clone(), - x_pct: rs.text_start_x, - y_pct: rs.text_start_y, - }; - let _ = tx.try_send(text_event); - rs.text_buffer.clear(); - } - - let key_name = rdev_key_to_string(key); - let event = CapturedEvent::KeyCombo { - keys: vec![key_name], - }; - let _ = tx.try_send(event); - state.increment_actions(); - } - } - } - - rdev::EventType::KeyRelease(key) => { - // Mettre a jour les modifieurs - match key { - rdev::Key::ControlLeft | rdev::Key::ControlRight => rs.ctrl_held = false, - rdev::Key::Alt | rdev::Key::AltGr => rs.alt_held = false, - rdev::Key::ShiftLeft | rdev::Key::ShiftRight => rs.shift_held = false, - rdev::Key::MetaLeft | rdev::Key::MetaRight => rs.meta_held = false, - _ => {} - } - } - - rdev::EventType::Wheel { delta_x: _, delta_y } => { - let (mx, my) = get_cursor_position(); - let (x_pct, y_pct) = rs.normalize(mx, my); - let delta = if delta_y > 0 { 3 } else { -3 }; - - let event = CapturedEvent::Scroll { - delta, - x_pct, - y_pct, - }; - let _ = tx.try_send(event); - state.increment_actions(); - } - - _ => { - // MouseMove et autres evenements ignores - } - } - }; - - // rdev::listen est bloquant — il ne retourne qu'en cas d'erreur - if let Err(e) = rdev::listen(callback) { - eprintln!("[RECORDER] Erreur fatale du listener rdev : {:?}", e); - } -} - -/// Boucle de flush periodique du buffer de texte. -/// -/// Toutes les 100ms, verifie si le buffer de texte est non-vide -/// et si le delai de flush (500ms) est depasse. Si oui, flush le buffer -/// en envoyant un evenement Text. -fn text_flush_loop(_tx: Sender, state: Arc) { - // Note: le flush est gere dans le callback rdev via le Mutex. - // Cette boucle est un filet de securite pour les cas ou le buffer - // resterait non-flush (timeout sans nouveau evenement). - // L'implementation complete necessiterait un acces partage au RecorderState. - // Pour l'instant, le flush est declenche par le prochain evenement (clic, combo). - - while state.is_running() { - thread::sleep(Duration::from_millis(TEXT_FLUSH_DELAY_MS)); - } -} - -/// Boucle d'envoi des evenements captures vers le serveur streaming. -/// -/// Lit les evenements du canal crossbeam et les envoie au serveur -/// via HTTP POST (format compatible avec le Python streamer). -fn event_sender_loop( - rx: Receiver, - config: Arc, - state: Arc, -) { - let client = reqwest::blocking::Client::new(); - - println!("[RECORDER] Thread d'envoi d'evenements demarre"); - - loop { - // Bloquer jusqu'au prochain evenement (ou timeout) - match rx.recv_timeout(Duration::from_secs(1)) { - Ok(event) => { - if !state.recording.load(std::sync::atomic::Ordering::SeqCst) { - continue; // Enregistrement arrete entre-temps - } - - let session_name = state.current_recording_name(); - send_event_to_server(&client, &config, &event, &session_name); - } - Err(crossbeam_channel::RecvTimeoutError::Timeout) => { - if !state.is_running() { - break; - } - } - Err(crossbeam_channel::RecvTimeoutError::Disconnected) => { - println!("[RECORDER] Canal deconnecte — arret du sender"); - break; - } - } - } -} - -/// Envoie un evenement capture au serveur streaming. -/// -/// Inclut la resolution de l'ecran dans chaque event pour que le serveur -/// puisse construire des ScreenStates avec la bonne resolution d'apprentissage -/// (au lieu du fallback 1920x1080). -fn send_event_to_server( - client: &reqwest::blocking::Client, - config: &Config, - event: &CapturedEvent, - session_name: &str, -) { - let url = format!("{}/traces/stream/event", config.server_url); - let timestamp = chrono::Utc::now().to_rfc3339(); - let (screen_w, screen_h) = capture::screen_dimensions().unwrap_or((1920, 1080)); - - let payload = match event { - CapturedEvent::Click { - x_pct, - y_pct, - button, - window_title, - } => { - serde_json::json!({ - "type": "click", - "x_pct": x_pct, - "y_pct": y_pct, - "button": button, - "window_title": window_title, - "session_name": session_name, - "machine_id": config.machine_id, - "timestamp": timestamp, - "screen_resolution": [screen_w, screen_h], - }) - } - CapturedEvent::DoubleClick { - x_pct, - y_pct, - window_title, - } => { - serde_json::json!({ - "type": "click", - "x_pct": x_pct, - "y_pct": y_pct, - "button": "double", - "window_title": window_title, - "session_name": session_name, - "machine_id": config.machine_id, - "timestamp": timestamp, - "screen_resolution": [screen_w, screen_h], - }) - } - CapturedEvent::Text { - text, - x_pct, - y_pct, - } => { - serde_json::json!({ - "type": "type", - "text": text, - "x_pct": x_pct, - "y_pct": y_pct, - "session_name": session_name, - "machine_id": config.machine_id, - "timestamp": timestamp, - "screen_resolution": [screen_w, screen_h], - }) - } - CapturedEvent::KeyCombo { keys } => { - serde_json::json!({ - "type": "key_combo", - "keys": keys, - "session_name": session_name, - "machine_id": config.machine_id, - "timestamp": timestamp, - "screen_resolution": [screen_w, screen_h], - }) - } - CapturedEvent::Scroll { - delta, - x_pct, - y_pct, - } => { - serde_json::json!({ - "type": "scroll", - "delta": delta, - "x_pct": x_pct, - "y_pct": y_pct, - "session_name": session_name, - "machine_id": config.machine_id, - "timestamp": timestamp, - "screen_resolution": [screen_w, screen_h], - }) - } - }; - - // Envoi non-bloquant (on ne veut pas ralentir la capture) - match client - .post(&url) - .json(&payload) - .timeout(Duration::from_secs(5)) - .send() - { - Ok(resp) => { - if !resp.status().is_success() { - eprintln!( - "[RECORDER] Envoi evenement echoue : HTTP {}", - resp.status() - ); - } - } - Err(e) => { - eprintln!("[RECORDER] Erreur reseau : {}", e); - } - } - - // Capturer un screenshot pour les clics (dual: full + crop) - if matches!( - event, - CapturedEvent::Click { .. } | CapturedEvent::DoubleClick { .. } - ) { - if let Some(img) = capture::capture_screenshot() { - let jpeg = capture::screenshot_to_jpeg_bytes(&img, 80); - if !jpeg.is_empty() { - let shot_id = format!("rec_{}", chrono::Utc::now().timestamp_millis()); - let _ = crate::network::send_heartbeat( - &reqwest::blocking::Client::new(), - &crate::config::Config::from_env(), - &jpeg, - session_name, - ); - let _ = shot_id; // utilise implicitement via send_heartbeat - } - } - } -} - -// --- Fonctions utilitaires --- - -/// Obtient la position actuelle du curseur souris. -fn get_cursor_position() -> (f64, f64) { - #[cfg(windows)] - { - use windows_sys::Win32::UI::WindowsAndMessaging::GetCursorPos; - use windows_sys::Win32::Foundation::POINT; - - unsafe { - let mut point: POINT = std::mem::zeroed(); - if GetCursorPos(&mut point) != 0 { - return (point.x as f64, point.y as f64); - } - } - } - - // Fallback : position inconnue - (0.0, 0.0) -} - -/// Obtient le titre de la fenetre active. -fn get_active_window_title() -> String { - #[cfg(windows)] - { - use windows_sys::Win32::UI::WindowsAndMessaging::{ - GetForegroundWindow, GetWindowTextW, - }; - - unsafe { - let hwnd = GetForegroundWindow(); - if !hwnd.is_null() { - let mut buf = [0u16; 256]; - let len = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32); - if len > 0 { - return String::from_utf16_lossy(&buf[..len as usize]); - } - } - } - } - - "Inconnu".to_string() -} - -/// Convertit une touche rdev en caractere texte (pour le buffer de saisie). -/// Retourne None pour les touches speciales (Enter, Tab, etc.). -fn rdev_key_to_char(key: rdev::Key) -> Option { - match key { - rdev::Key::KeyA => Some('a'), - rdev::Key::KeyB => Some('b'), - rdev::Key::KeyC => Some('c'), - rdev::Key::KeyD => Some('d'), - rdev::Key::KeyE => Some('e'), - rdev::Key::KeyF => Some('f'), - rdev::Key::KeyG => Some('g'), - rdev::Key::KeyH => Some('h'), - rdev::Key::KeyI => Some('i'), - rdev::Key::KeyJ => Some('j'), - rdev::Key::KeyK => Some('k'), - rdev::Key::KeyL => Some('l'), - rdev::Key::KeyM => Some('m'), - rdev::Key::KeyN => Some('n'), - rdev::Key::KeyO => Some('o'), - rdev::Key::KeyP => Some('p'), - rdev::Key::KeyQ => Some('q'), - rdev::Key::KeyR => Some('r'), - rdev::Key::KeyS => Some('s'), - rdev::Key::KeyT => Some('t'), - rdev::Key::KeyU => Some('u'), - rdev::Key::KeyV => Some('v'), - rdev::Key::KeyW => Some('w'), - rdev::Key::KeyX => Some('x'), - rdev::Key::KeyY => Some('y'), - rdev::Key::KeyZ => Some('z'), - rdev::Key::Num0 => Some('0'), - rdev::Key::Num1 => Some('1'), - rdev::Key::Num2 => Some('2'), - rdev::Key::Num3 => Some('3'), - rdev::Key::Num4 => Some('4'), - rdev::Key::Num5 => Some('5'), - rdev::Key::Num6 => Some('6'), - rdev::Key::Num7 => Some('7'), - rdev::Key::Num8 => Some('8'), - rdev::Key::Num9 => Some('9'), - rdev::Key::Space => Some(' '), - rdev::Key::Minus => Some('-'), - rdev::Key::Equal => Some('='), - rdev::Key::LeftBracket => Some('['), - rdev::Key::RightBracket => Some(']'), - rdev::Key::SemiColon => Some(';'), - rdev::Key::Quote => Some('\''), - rdev::Key::Comma => Some(','), - rdev::Key::Dot => Some('.'), - rdev::Key::Slash => Some('/'), - rdev::Key::BackSlash => Some('\\'), - _ => None, - } -} - -/// Convertit une touche rdev en nom de touche (pour les combos). -fn rdev_key_to_string(key: rdev::Key) -> String { - match key { - rdev::Key::Return => "enter".to_string(), - rdev::Key::Tab => "tab".to_string(), - rdev::Key::Escape => "escape".to_string(), - rdev::Key::Backspace => "backspace".to_string(), - rdev::Key::Delete => "delete".to_string(), - rdev::Key::Space => "space".to_string(), - rdev::Key::UpArrow => "up".to_string(), - rdev::Key::DownArrow => "down".to_string(), - rdev::Key::LeftArrow => "left".to_string(), - rdev::Key::RightArrow => "right".to_string(), - rdev::Key::Home => "home".to_string(), - rdev::Key::End => "end".to_string(), - rdev::Key::PageUp => "page_up".to_string(), - rdev::Key::PageDown => "page_down".to_string(), - rdev::Key::F1 => "f1".to_string(), - rdev::Key::F2 => "f2".to_string(), - rdev::Key::F3 => "f3".to_string(), - rdev::Key::F4 => "f4".to_string(), - rdev::Key::F5 => "f5".to_string(), - rdev::Key::F6 => "f6".to_string(), - rdev::Key::F7 => "f7".to_string(), - rdev::Key::F8 => "f8".to_string(), - rdev::Key::F9 => "f9".to_string(), - rdev::Key::F10 => "f10".to_string(), - rdev::Key::F11 => "f11".to_string(), - rdev::Key::F12 => "f12".to_string(), - rdev::Key::CapsLock => "caps_lock".to_string(), - rdev::Key::Insert => "insert".to_string(), - rdev::Key::PrintScreen => "print_screen".to_string(), - // Pour les lettres et chiffres, reutiliser rdev_key_to_char - other => { - if let Some(c) = rdev_key_to_char(other) { - c.to_string() - } else { - format!("{:?}", other).to_lowercase() - } - } - } -} diff --git a/agent_rust/src/replay.rs b/agent_rust/src/replay.rs deleted file mode 100644 index d37fabf24..000000000 --- a/agent_rust/src/replay.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Boucle de polling replay. -//! -//! Poll le serveur toutes les secondes pour recuperer les actions a executer. -//! Quand une action est recue, l'execute via executor et rapporte le resultat. -//! Gere le backoff exponentiel en cas d'indisponibilite 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 crate::notifications; -use crate::state::AgentState; -use reqwest::blocking::Client; -use std::thread; -use std::time::Duration; - -/// Boucle de polling replay (tourne dans un thread dedie). -/// -/// - Poll GET /replay/next toutes les secondes -/// - Execute l'action via executor -/// - Capture un screenshot post-action -/// - Rapporte le resultat via POST /replay/result -/// - Backoff exponentiel si le serveur est indisponible -pub fn replay_poll_loop(config: &Config, state: &AgentState) { - let client = Client::new(); - let mut poll_count: u64 = 0; - let backoff = config.replay_poll_interval_s; - let _backoff_max = 30.0_f64; - let _backoff_factor = 1.5_f64; - let mut replay_active = false; - - println!( - "[REPLAY] Boucle replay demarree — poll toutes les {:.0}s sur {}", - config.replay_poll_interval_s, config.server_url - ); - - while state.is_running() { - // Verifier l'arret d'urgence - if state - .emergency_stop - .load(std::sync::atomic::Ordering::SeqCst) - { - if replay_active { - println!("[REPLAY] ARRET D'URGENCE — replay interrompu"); - replay_active = false; - state.set_replay_active(false); - } - thread::sleep(Duration::from_secs(1)); - continue; - } - - poll_count += 1; - - // Log periodique 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) => { - if !replay_active { - replay_active = true; - state.set_replay_active(true); - notifications::replay_started("workflow"); - 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'ecran - let (sw, sh) = capture::screen_dimensions().unwrap_or((1920, 1080)); - - // Executer l'action (avec config pour la resolution visuelle) - println!(">>> Execution de l'action {}...", action_type); - let mut result = executor::execute_action(&action, sw, sh, config); - println!( - ">>> Resultat execution : success={}, error={:?}", - result.success, result.error - ); - - // Capture screenshot post-action (apres 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 resultat au serveur (TOUJOURS, meme en erreur) - network::report_result(&client, config, &result); - - // Poll plus rapidement pour enchainer les actions - thread::sleep(Duration::from_millis(200)); - continue; - } - None => { - if replay_active { - println!("[REPLAY] Replay termine — retour en mode capture"); - replay_active = false; - state.set_replay_active(false); - notifications::replay_finished(true); - } - } - } - - let sleep_duration = Duration::from_secs_f64(backoff); - thread::sleep(sleep_duration); - } - - println!("[REPLAY] Boucle arretee."); -} diff --git a/agent_rust/src/server.rs b/agent_rust/src/server.rs deleted file mode 100644 index 4beabe5f3..000000000 --- a/agent_rust/src/server.rs +++ /dev/null @@ -1,402 +0,0 @@ -//! 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(()) -} diff --git a/agent_rust/src/state.rs b/agent_rust/src/state.rs deleted file mode 100644 index 3a997f0e9..000000000 --- a/agent_rust/src/state.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! Etat partage thread-safe de l'agent. -//! -//! Centralise l'etat courant (enregistrement, replay, connexion, etc.) -//! accessible depuis tous les threads (systray, heartbeat, replay, recorder). -//! Equivalent de agent_v1/ui/shared_state.py. - -use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::{Arc, Mutex}; - -/// Etats possibles de l'icone systray -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TrayState { - /// Gris — en attente, pas de session active - Idle, - /// Rouge — enregistrement en cours - Recording, - /// Vert — connecte au serveur, pret - Connected, - /// Bleu — replay en cours - Replay, -} - -/// Etat partage de l'agent, thread-safe via Arc + atomics. -/// -/// Les booleens utilisent AtomicBool pour un acces lock-free. -/// Le nom de session utilise un Mutex car c'est une String. -#[derive(Debug)] -pub struct AgentState { - /// Enregistrement en cours (session de capture) - pub recording: AtomicBool, - - /// Nom de la session d'enregistrement courante - pub recording_name: Mutex, - - /// Replay en cours (execution d'actions) - pub replay_active: AtomicBool, - - /// Connecte au serveur streaming - pub connected: AtomicBool, - - /// Nombre d'actions capturees dans la session courante - pub actions_count: AtomicU32, - - /// L'agent est en cours d'execution (false = arret demande) - pub running: AtomicBool, - - /// Fenetre de chat visible - pub chat_visible: AtomicBool, - - /// Arret d'urgence active - pub emergency_stop: AtomicBool, - - /// Dernier message de notification (pour eviter les doublons) - #[allow(dead_code)] - pub last_notification: Mutex, -} - -impl AgentState { - /// Cree un nouvel etat avec les valeurs par defaut. - pub fn new() -> Arc { - Arc::new(Self { - recording: AtomicBool::new(false), - recording_name: Mutex::new(String::new()), - replay_active: AtomicBool::new(false), - connected: AtomicBool::new(false), - actions_count: AtomicU32::new(0), - running: AtomicBool::new(true), - chat_visible: AtomicBool::new(false), - emergency_stop: AtomicBool::new(false), - last_notification: Mutex::new(String::new()), - }) - } - - /// Demarre un enregistrement avec le nom donne. - pub fn start_recording(&self, name: &str) { - self.recording.store(true, Ordering::SeqCst); - self.actions_count.store(0, Ordering::SeqCst); - if let Ok(mut n) = self.recording_name.lock() { - *n = name.to_string(); - } - println!("[STATE] Enregistrement demarre : '{}'", name); - } - - /// Arrete l'enregistrement en cours. - pub fn stop_recording(&self) -> (String, u32) { - self.recording.store(false, Ordering::SeqCst); - let count = self.actions_count.load(Ordering::SeqCst); - let name = self - .recording_name - .lock() - .map(|n| n.clone()) - .unwrap_or_default(); - println!("[STATE] Enregistrement arrete : '{}' ({} actions)", name, count); - (name, count) - } - - /// Incremente le compteur d'actions capturees. - pub fn increment_actions(&self) -> u32 { - self.actions_count.fetch_add(1, Ordering::SeqCst) + 1 - } - - /// Verifie si l'agent est en cours d'execution. - pub fn is_running(&self) -> bool { - self.running.load(Ordering::SeqCst) - } - - /// Demande l'arret de l'agent. - pub fn request_shutdown(&self) { - self.running.store(false, Ordering::SeqCst); - println!("[STATE] Arret demande"); - } - - /// Active/desactive le replay. - pub fn set_replay_active(&self, active: bool) { - self.replay_active.store(active, Ordering::SeqCst); - } - - /// Met a jour le statut de connexion au serveur. - pub fn set_connected(&self, connected: bool) { - let was_connected = self.connected.swap(connected, Ordering::SeqCst); - if was_connected != connected { - println!( - "[STATE] Connexion serveur : {}", - if connected { "CONNECTE" } else { "DECONNECTE" } - ); - } - } - - /// Active l'arret d'urgence — stoppe tout immediatement. - pub fn emergency_stop(&self) { - self.emergency_stop.store(true, Ordering::SeqCst); - self.recording.store(false, Ordering::SeqCst); - self.replay_active.store(false, Ordering::SeqCst); - println!("[STATE] === ARRET D'URGENCE ACTIVE ==="); - } - - /// Retourne l'etat courant du systray. - pub fn tray_state(&self) -> TrayState { - if self.recording.load(Ordering::SeqCst) { - TrayState::Recording - } else if self.replay_active.load(Ordering::SeqCst) { - TrayState::Replay - } else if self.connected.load(Ordering::SeqCst) { - TrayState::Connected - } else { - TrayState::Idle - } - } - - /// Retourne le nom de la session d'enregistrement courante. - pub fn current_recording_name(&self) -> String { - self.recording_name - .lock() - .map(|n| n.clone()) - .unwrap_or_default() - } -} - -impl Default for AgentState { - fn default() -> Self { - // Note: on ne peut pas retourner Arc depuis Default, - // donc on fournit les valeurs brutes. Utiliser new() de preference. - Self { - recording: AtomicBool::new(false), - recording_name: Mutex::new(String::new()), - replay_active: AtomicBool::new(false), - connected: AtomicBool::new(false), - actions_count: AtomicU32::new(0), - running: AtomicBool::new(true), - chat_visible: AtomicBool::new(false), - emergency_stop: AtomicBool::new(false), - last_notification: Mutex::new(String::new()), - } - } -} diff --git a/agent_rust/src/sysinfo.rs b/agent_rust/src/sysinfo.rs deleted file mode 100644 index a68815d2f..000000000 --- a/agent_rust/src/sysinfo.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! Métadonnées système : DPI, résolution, fenêtre active, moniteur. -//! -//! Expose des fonctions pour capturer les informations d'affichage -//! critiques qui seront envoyées au serveur avec chaque heartbeat. -//! Sur Windows, utilise les API Win32 (user32.dll). -//! Sur Linux, retourne des valeurs par défaut ou utilise xcap. - -use serde::Serialize; - -/// Métadonnées complètes de l'écran. -#[derive(Debug, Clone, Serialize)] -pub struct ScreenMetadata { - /// Facteur DPI en pourcentage (100 = normal, 150 = haute résolution) - pub dpi_scale: u32, - /// Résolution de l'écran principal [largeur, hauteur] - pub screen_resolution: [u32; 2], - /// Bounds de la fenêtre active [x, y, largeur, hauteur], None si pas de fenêtre - pub window_bounds: Option<[i32; 4]>, - /// Index du moniteur sur lequel se trouve la fenêtre active (0 = principal) - pub monitor_index: u32, -} - -impl std::fmt::Display for ScreenMetadata { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}x{} @ {}% DPI, monitor #{}", - self.screen_resolution[0], - self.screen_resolution[1], - self.dpi_scale, - self.monitor_index, - )?; - if let Some(wb) = &self.window_bounds { - write!(f, ", fenetre [{}x{} @ ({},{})]", wb[2], wb[3], wb[0], wb[1])?; - } - Ok(()) - } -} - -// ============================================================================= -// Windows : API Win32 via FFI -// ============================================================================= - -#[cfg(target_os = "windows")] -mod win { - use windows_sys::Win32::Foundation::{BOOL, LPARAM, RECT}; - use windows_sys::Win32::Graphics::Gdi::{ - EnumDisplayMonitors, GetMonitorInfoW, MonitorFromWindow, HMONITOR, MONITORINFO, - MONITOR_DEFAULTTOPRIMARY, - }; - use windows_sys::Win32::UI::WindowsAndMessaging::{ - GetForegroundWindow, GetSystemMetrics, GetWindowRect, SM_CXSCREEN, SM_CYSCREEN, - }; - - // GetDpiForSystem est dans Win32_UI_HiDpi (non activée). - // On utilise un appel FFI raw pour éviter d'ajouter une feature. - extern "system" { - fn GetDpiForSystem() -> u32; - } - - /// Retourne le facteur DPI en % (100 = normal, 125, 150, 200...). - pub fn get_dpi_scale() -> u32 { - unsafe { - let dpi = GetDpiForSystem(); - if dpi == 0 { - // Fallback si l'API n'est pas disponible (Windows < 10 1607) - 100 - } else { - (dpi * 100) / 96 - } - } - } - - /// Retourne (largeur, hauteur) du moniteur principal via GetSystemMetrics. - /// - /// IMPORTANT : Retourne la resolution physique uniquement si le process est - /// DPI-aware (SetProcessDpiAwareness(2) appele dans main.rs). Sans cela, - /// retourne la resolution logique (virtualisee par le DPI scaling). - pub fn get_screen_resolution() -> (u32, u32) { - unsafe { - let w = GetSystemMetrics(SM_CXSCREEN); - let h = GetSystemMetrics(SM_CYSCREEN); - if w > 0 && h > 0 { - (w as u32, h as u32) - } else { - (0, 0) - } - } - } - - /// Retourne (x, y, largeur, hauteur) de la fenêtre active, ou None. - pub fn get_window_bounds() -> Option<(i32, i32, i32, i32)> { - unsafe { - let hwnd = GetForegroundWindow(); - if hwnd.is_null() { - return None; - } - let mut rect: RECT = std::mem::zeroed(); - if GetWindowRect(hwnd, &mut rect) != 0 { - let w = rect.right - rect.left; - let h = rect.bottom - rect.top; - Some((rect.left, rect.top, w, h)) - } else { - None - } - } - } - - /// Flag indiquant le moniteur principal dans MONITORINFO.dwFlags. - const MONITORINFOF_PRIMARY: u32 = 1; - - /// Retourne l'index du moniteur sur lequel se trouve la fenêtre active. - /// 0 = moniteur principal. Enumère tous les moniteurs pour trouver l'index. - pub fn get_monitor_index() -> u32 { - unsafe { - let hwnd = GetForegroundWindow(); - if hwnd.is_null() { - return 0; - } - - let target_hmon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY); - if target_hmon.is_null() { - return 0; - } - - // Énumérer les moniteurs pour trouver l'index - struct CallbackData { - target: HMONITOR, - current_index: u32, - found_index: u32, - } - - unsafe extern "system" fn enum_callback( - hmonitor: HMONITOR, - _hdc: windows_sys::Win32::Graphics::Gdi::HDC, - _lprect: *mut RECT, - lparam: LPARAM, - ) -> BOOL { - let data = &mut *(lparam as *mut CallbackData); - - // Vérifier si c'est le moniteur principal — il est toujours #0 - let mut info: MONITORINFO = std::mem::zeroed(); - info.cbSize = std::mem::size_of::() as u32; - GetMonitorInfoW(hmonitor, &mut info); - - if info.dwFlags & MONITORINFOF_PRIMARY != 0 { - // Moniteur principal — index 0, mais on continue pour le comptage - if hmonitor == data.target { - data.found_index = 0; - } - } else if hmonitor == data.target { - data.found_index = data.current_index; - } - - data.current_index += 1; - 1 // TRUE, continuer l'énumération - } - - let mut data = CallbackData { - target: target_hmon, - current_index: 0, - found_index: 0, - }; - - EnumDisplayMonitors( - std::ptr::null_mut(), // HDC null = tous les moniteurs - std::ptr::null(), - Some(enum_callback), - &mut data as *mut CallbackData as LPARAM, - ); - - data.found_index - } - } -} - -// ============================================================================= -// Linux / fallback : valeurs par défaut ou xcap -// ============================================================================= - -#[cfg(not(target_os = "windows"))] -mod fallback { - /// Sur Linux, pas de DPI système accessible simplement. Retourne 100%. - pub fn get_dpi_scale() -> u32 { - 100 - } - - /// Résolution via xcap (mêmes moniteurs que la capture). - pub fn get_screen_resolution() -> (u32, u32) { - if let Ok(monitors) = xcap::Monitor::all() { - if let Some(primary) = monitors.into_iter().find(|m| m.is_primary().unwrap_or(false)) { - let w = primary.width().unwrap_or(0); - let h = primary.height().unwrap_or(0); - return (w, h); - } - } - (0, 0) - } - - /// Pas d'API window bounds sur Linux en mode headless. Retourne None. - pub fn get_window_bounds() -> Option<(i32, i32, i32, i32)> { - None - } - - /// Moniteur principal = index 0 (fallback). - pub fn get_monitor_index() -> u32 { - 0 - } -} - -// ============================================================================= -// API publique -// ============================================================================= - -/// Retourne le facteur DPI en % (100 = normal, 150 = haute résolution). -pub fn get_dpi_scale() -> u32 { - #[cfg(target_os = "windows")] - { - win::get_dpi_scale() - } - #[cfg(not(target_os = "windows"))] - { - fallback::get_dpi_scale() - } -} - -/// Retourne (largeur, hauteur) du moniteur principal. -pub fn get_screen_resolution() -> (u32, u32) { - #[cfg(target_os = "windows")] - { - win::get_screen_resolution() - } - #[cfg(not(target_os = "windows"))] - { - fallback::get_screen_resolution() - } -} - -/// Retourne (x, y, largeur, hauteur) de la fenêtre active, ou None. -pub fn get_window_bounds() -> Option<(i32, i32, i32, i32)> { - #[cfg(target_os = "windows")] - { - win::get_window_bounds() - } - #[cfg(not(target_os = "windows"))] - { - fallback::get_window_bounds() - } -} - -/// Retourne l'index du moniteur de la fenêtre active (0 = principal). -pub fn get_monitor_index() -> u32 { - #[cfg(target_os = "windows")] - { - win::get_monitor_index() - } - #[cfg(not(target_os = "windows"))] - { - fallback::get_monitor_index() - } -} - -/// Collecte toutes les métadonnées système en une seule structure. -pub fn get_screen_metadata() -> ScreenMetadata { - let (sw, sh) = get_screen_resolution(); - let wb = get_window_bounds().map(|(x, y, w, h)| [x, y, w, h]); - - ScreenMetadata { - dpi_scale: get_dpi_scale(), - screen_resolution: [sw, sh], - window_bounds: wb, - monitor_index: get_monitor_index(), - } -} diff --git a/agent_rust/src/tray.rs b/agent_rust/src/tray.rs deleted file mode 100644 index 66b398353..000000000 --- a/agent_rust/src/tray.rs +++ /dev/null @@ -1,336 +0,0 @@ -//! Icone systray avec menu contextuel. -//! -//! Affiche une icone dans la barre des taches Windows avec un menu contextuel -//! permettant de controler l'agent (enregistrement, replay, chat, etc.). -//! Equivalent de agent_v1/ui/smart_tray.py. -//! -//! Utilise tray-icon (crate Tauri) pour l'icone et le menu. -//! Necessite une boucle d'evenements Windows (winit ou Win32 message pump). -//! -//! Sur Linux : le systray n'est pas disponible, l'agent tourne en mode console. - -#[allow(unused_imports)] -use crate::config::Config; -#[allow(unused_imports)] -use crate::notifications; -#[allow(unused_imports)] -use crate::state::{AgentState, TrayState}; -use std::sync::Arc; - -/// Identifiants des elements du menu (pour le dispatch des evenements). -#[cfg(windows)] -pub struct TrayMenuIds { - pub machine_info: tray_icon::menu::MenuItem, - pub status_item: tray_icon::menu::MenuItem, - pub start_recording: tray_icon::menu::MenuItem, - pub stop_recording: tray_icon::menu::MenuItem, - pub workflows_submenu: tray_icon::menu::Submenu, - pub emergency_stop: tray_icon::menu::MenuItem, - pub open_chat: tray_icon::menu::MenuItem, - pub open_files: tray_icon::menu::MenuItem, - pub quit: tray_icon::menu::MenuItem, -} - -/// Cree l'icone du systray et la boucle d'evenements associee. -/// -/// Cette fonction bloque le thread appelant (doit etre le thread principal sur Windows). -/// Sur les OS non-Windows, attend Ctrl+C en mode console. -#[cfg(windows)] -pub fn run_tray_loop(config: Arc, state: Arc) { - use tray_icon::{ - menu::MenuEvent, - TrayIconBuilder, - }; - use winit::application::ApplicationHandler; - use winit::event::WindowEvent; - use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop}; - use winit::window::WindowId; - - // Creer le menu - let menu_ids = create_menu(&config); - let menu = build_tray_menu(&menu_ids); - - // Generer l'icone initiale (gris = idle) - let icon = generate_tray_icon(TrayState::Idle); - - // Creer l'icone systray - let tray = match TrayIconBuilder::new() - .with_menu(Box::new(menu)) - .with_tooltip("Lea - Agent RPA Vision (IA)") - .with_icon(icon) - .build() - { - Ok(t) => t, - Err(e) => { - eprintln!("[TRAY] Impossible de creer l'icone systray : {}", e); - // Fallback mode console - fallback_console_loop(&state); - return; - } - }; - - println!("[TRAY] Icone systray creee — menu contextuel disponible"); - notifications::greet(); - - // Structure pour l'ApplicationHandler de winit - struct TrayApp { - config: Arc, - state: Arc, - tray: tray_icon::TrayIcon, - menu_ids: TrayMenuIds, - current_tray_state: TrayState, - } - - impl ApplicationHandler for TrayApp { - fn resumed(&mut self, _event_loop: &ActiveEventLoop) { - // Rien a faire — pas de fenetre winit - } - - fn window_event( - &mut self, - _event_loop: &ActiveEventLoop, - _window_id: WindowId, - _event: WindowEvent, - ) { - // Pas de fenetre winit — ignorer - } - - fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { - // Verifier si l'agent doit s'arreter - if !self.state.is_running() { - event_loop.exit(); - return; - } - - // Traiter les evenements menu - let menu_receiver = MenuEvent::receiver(); - if let Ok(event) = menu_receiver.try_recv() { - handle_menu_event(&event, &self.menu_ids, &self.config, &self.state); - } - - // Mettre a jour l'icone si l'etat a change - let new_state = self.state.tray_state(); - if new_state != self.current_tray_state { - self.current_tray_state = new_state; - let tooltip = match new_state { - TrayState::Idle => "Lea - En attente", - TrayState::Recording => "Lea - ENREGISTREMENT EN COURS", - TrayState::Connected => "Lea - Connectee au serveur", - TrayState::Replay => "Lea - REPLAY EN COURS", - }; - let _ = self.tray.set_tooltip(Some(tooltip)); - let icon = generate_tray_icon(new_state); - let _ = self.tray.set_icon(Some(icon)); - } - - // Attendre un peu avant le prochain cycle - event_loop.set_control_flow(ControlFlow::WaitUntil( - std::time::Instant::now() + std::time::Duration::from_millis(100), - )); - } - } - - // Creer et demarrer la boucle d'evenements winit - let event_loop = match EventLoop::new() { - Ok(el) => el, - Err(e) => { - eprintln!("[TRAY] Impossible de creer la boucle d'evenements : {}", e); - fallback_console_loop(&state); - return; - } - }; - - let mut app = TrayApp { - config, - state, - tray, - menu_ids, - current_tray_state: TrayState::Idle, - }; - - let _ = event_loop.run_app(&mut app); -} - -/// Cree les elements de menu avec leurs labels. -#[cfg(windows)] -fn create_menu(config: &Config) -> TrayMenuIds { - use tray_icon::menu::{MenuItem, Submenu}; - - let machine_info = MenuItem::new( - format!("Machine : {}", config.machine_id), - false, // disabled — info seulement - None, - ); - - let status_item = MenuItem::new("Deconnectee", false, None); - - let start_recording = MenuItem::new("Apprenez-moi une tache", true, None); - - let stop_recording = MenuItem::new("C'est termine", true, None); - - let workflows_submenu = Submenu::new("Mes taches", true); - let _ = workflows_submenu.append(&MenuItem::new("(chargement...)", false, None)); - - let emergency_stop = MenuItem::new("ARRET D'URGENCE", true, None); - - let open_chat = MenuItem::new("Discuter avec Lea", true, None); - - let open_files = MenuItem::new("Mes fichiers", true, None); - - let quit = MenuItem::new("Quitter Lea", true, None); - - TrayMenuIds { - machine_info, - status_item, - start_recording, - stop_recording, - workflows_submenu, - emergency_stop, - open_chat, - open_files, - quit, - } -} - -/// Construit le menu systray a partir des elements. -#[cfg(windows)] -fn build_tray_menu(ids: &TrayMenuIds) -> tray_icon::menu::Menu { - use tray_icon::menu::{Menu, PredefinedMenuItem}; - - let menu = Menu::new(); - - let _ = menu.append(&ids.machine_info); - let _ = menu.append(&ids.status_item); - let _ = menu.append(&PredefinedMenuItem::separator()); - let _ = menu.append(&ids.start_recording); - let _ = menu.append(&ids.stop_recording); - let _ = menu.append(&PredefinedMenuItem::separator()); - let _ = menu.append(&ids.workflows_submenu); - let _ = menu.append(&PredefinedMenuItem::separator()); - let _ = menu.append(&ids.emergency_stop); - let _ = menu.append(&ids.open_chat); - let _ = menu.append(&ids.open_files); - let _ = menu.append(&PredefinedMenuItem::separator()); - let _ = menu.append(&ids.quit); - - menu -} - -/// Gere un evenement de clic sur un element du menu. -#[cfg(windows)] -fn handle_menu_event( - event: &tray_icon::menu::MenuEvent, - ids: &TrayMenuIds, - _config: &Config, - state: &AgentState, -) { - let event_id = event.id(); - - if event_id == ids.start_recording.id() { - if !state.recording.load(std::sync::atomic::Ordering::SeqCst) { - let name = format!( - "session_{}", - chrono::Utc::now().format("%Y%m%d_%H%M%S") - ); - state.start_recording(&name); - notifications::session_started(&name); - println!("[TRAY] Enregistrement demarre : {}", name); - } - } else if event_id == ids.stop_recording.id() { - if state.recording.load(std::sync::atomic::Ordering::SeqCst) { - let (name, count) = state.stop_recording(); - notifications::session_ended(count); - println!( - "[TRAY] Enregistrement arrete : {} ({} actions)", - name, count - ); - } - } else if event_id == ids.emergency_stop.id() { - state.emergency_stop(); - notifications::emergency_stop_activated(); - println!("[TRAY] ARRET D'URGENCE ACTIVE"); - } else if event_id == ids.open_chat.id() { - state - .chat_visible - .store(true, std::sync::atomic::Ordering::SeqCst); - println!("[TRAY] Ouverture du chat demandee"); - } else if event_id == ids.open_files.id() { - let sessions_dir = if cfg!(target_os = "windows") { - "C:\\rpa_vision\\sessions".to_string() - } else { - "/tmp/rpa_vision/sessions".to_string() - }; - println!("[TRAY] Ouverture du dossier : {}", sessions_dir); - #[cfg(windows)] - { - let _ = std::process::Command::new("explorer") - .arg(&sessions_dir) - .spawn(); - } - } else if event_id == ids.quit.id() { - println!("[TRAY] Fermeture demandee par l'utilisateur"); - state.request_shutdown(); - } -} - -/// Genere une icone systray coloree selon l'etat. -/// -/// Cree une image 32x32 RGBA avec un cercle colore : -/// - Gris (#808080) : idle -/// - Rouge (#FF0000) : enregistrement -/// - Vert (#00CC00) : connecte -/// - Bleu (#0066FF) : replay -#[cfg(windows)] -fn generate_tray_icon(tray_state: TrayState) -> tray_icon::Icon { - let size = 32u32; - let mut rgba = vec![0u8; (size * size * 4) as usize]; - - let (r, g, b) = match tray_state { - TrayState::Idle => (128u8, 128u8, 128u8), - TrayState::Recording => (255u8, 0u8, 0u8), - TrayState::Connected => (0u8, 204u8, 0u8), - TrayState::Replay => (0u8, 102u8, 255u8), - }; - - let center = (size / 2) as f64; - let radius = (size / 2 - 2) as f64; - - for y in 0..size { - for x in 0..size { - let dx = x as f64 - center; - let dy = y as f64 - center; - let dist = (dx * dx + dy * dy).sqrt(); - - let offset = ((y * size + x) * 4) as usize; - if dist <= radius { - rgba[offset] = r; - rgba[offset + 1] = g; - rgba[offset + 2] = b; - rgba[offset + 3] = 255; - } else if dist <= radius + 1.0 { - let alpha = ((radius + 1.0 - dist) * 255.0) as u8; - rgba[offset] = r; - rgba[offset + 1] = g; - rgba[offset + 2] = b; - rgba[offset + 3] = alpha; - } - } - } - - tray_icon::Icon::from_rgba(rgba, size, size).expect("Erreur creation icone systray") -} - -/// Mode console (Linux ou fallback si le systray echoue). -fn fallback_console_loop(state: &AgentState) { - println!("[TRAY] Mode console — Appuyez sur Ctrl+C pour quitter"); - while state.is_running() { - std::thread::sleep(std::time::Duration::from_millis(500)); - } -} - -/// Version non-Windows : pas de systray, l'agent tourne en mode console. -#[cfg(not(windows))] -pub fn run_tray_loop(_config: Arc, state: Arc) { - println!("[TRAY] Systray non disponible sur cet OS — mode console"); - fallback_console_loop(&state); -} diff --git a/agent_rust/src/visual.rs b/agent_rust/src/visual.rs deleted file mode 100644 index 5bf75c195..000000000 --- a/agent_rust/src/visual.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Résolution visuelle des cibles via le serveur. -//! -//! Envoie un screenshot + target_spec au serveur qui effectue le template -//! matching OpenCV et retourne les coordonnées résolues (x_pct, y_pct). -//! Approche server-side : pas de dépendance OpenCV dans le binaire Rust. - -use crate::capture; -use crate::config::Config; -use reqwest::blocking::Client; - -/// Résout visuellement une cible en envoyant le screenshot courant au serveur. -/// -/// Capture l'écran, l'encode en JPEG base64, envoie au endpoint -/// `/traces/stream/replay/resolve_target` qui fait le template matching. -/// -/// Retourne Some((x_pct, y_pct)) si la cible est trouvée, None sinon. -pub fn resolve_target_visual( - config: &Config, - target_spec: &serde_json::Value, - fallback_x: f64, - fallback_y: f64, - screen_width: u32, - screen_height: u32, -) -> Option<(f64, f64)> { - // 1. Capturer le screenshot actuel - let screenshot = match capture::capture_screenshot() { - Some(img) => img, - None => { - eprintln!(" [VISUAL] Echec capture screenshot pour résolution visuelle"); - return None; - } - }; - - // Encoder en JPEG base64 (qualité 75 — bon compromis taille/précision) - let screenshot_b64 = capture::screenshot_to_jpeg_base64(&screenshot, 75); - if screenshot_b64.is_empty() { - eprintln!(" [VISUAL] Echec encodage JPEG"); - return None; - } - - println!( - " [VISUAL] Screenshot capture ({}x{}), envoi au serveur...", - screen_width, screen_height - ); - - // 2. Envoyer au serveur /replay/resolve_target - let client = Client::new(); - let payload = serde_json::json!({ - "session_id": config.agent_session_id(), - "screenshot_b64": screenshot_b64, - "target_spec": target_spec, - "fallback_x_pct": fallback_x, - "fallback_y_pct": fallback_y, - "screen_width": screen_width, - "screen_height": screen_height, - }); - - let url = format!("{}/traces/stream/replay/resolve_target", config.server_url); - - let resp = match client - .post(&url) - .json(&payload) - .timeout(std::time::Duration::from_secs(30)) - .send() - { - Ok(r) => r, - Err(e) => { - eprintln!(" [VISUAL] Erreur reseau vers {} : {}", url, e); - return None; - } - }; - - if !resp.status().is_success() { - eprintln!( - " [VISUAL] Serveur a repondu HTTP {}", - resp.status() - ); - return None; - } - - // 3. Parser la réponse - let data: serde_json::Value = match resp.json() { - Ok(d) => d, - Err(e) => { - eprintln!(" [VISUAL] Erreur parsing reponse JSON : {}", e); - return None; - } - }; - - let resolved = data["resolved"].as_bool().unwrap_or(false); - if resolved { - let x = data["x_pct"].as_f64()?; - let y = data["y_pct"].as_f64()?; - let method = data["method"].as_str().unwrap_or("?"); - let score = data["score"].as_f64().unwrap_or(0.0); - println!( - " [VISUAL] Resolu par {} (score={:.3}) : ({:.4}, {:.4})", - method, score, x, y - ); - Some((x, y)) - } else { - let reason = data["reason"].as_str().unwrap_or("inconnu"); - let method = data["method"].as_str().unwrap_or("?"); - println!( - " [VISUAL] Non resolu (methode={}, raison={})", - method, reason - ); - None - } -} diff --git a/agent_v0/deploy/windows_client/agent_v1/core/window_info.py b/agent_v0/deploy/windows_client/agent_v1/core/window_info.py deleted file mode 100644 index 7e6be8744..000000000 --- a/agent_v0/deploy/windows_client/agent_v1/core/window_info.py +++ /dev/null @@ -1,55 +0,0 @@ -# window_info.py -""" -Récupération des informations sur la fenêtre active (X11). - -v0 : -- utilise xdotool pour obtenir : - - le titre de la fenêtre active - - le PID de la fenêtre active, puis le nom du process via ps - -Si quelque chose ne fonctionne pas, on renvoie des valeurs "unknown". -""" - -from __future__ import annotations - -import subprocess -from typing import Dict, Optional - - -def _run_cmd(cmd: list[str]) -> Optional[str]: - """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" - try: - out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) - return out.decode("utf-8", errors="ignore").strip() - except Exception: - return None - - -def get_active_window_info() -> Dict[str, str]: - """ - Renvoie un dict : - { - "title": "...", - "app_name": "..." - } - - Nécessite xdotool installé sur le système. - """ - title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) - pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) - - app_name: Optional[str] = None - if pid_str: - pid_str = pid_str.strip() - # On récupère le nom du binaire via ps - app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) - - if not title: - title = "unknown_window" - if not app_name: - app_name = "unknown_app" - - return { - "title": title, - "app_name": app_name, - } diff --git a/agent_v0/deploy/windows_client/agent_v1/core/window_info_crossplatform.py b/agent_v0/deploy/windows_client/agent_v1/core/window_info_crossplatform.py deleted file mode 100644 index ba059a3fc..000000000 --- a/agent_v0/deploy/windows_client/agent_v1/core/window_info_crossplatform.py +++ /dev/null @@ -1,192 +0,0 @@ -# window_info_crossplatform.py -""" -Récupération des informations sur la fenêtre active - CROSS-PLATFORM - -Supporte: -- Linux (X11 via xdotool) -- Windows (via pywin32) -- macOS (via pyobjc) - -Installation des dépendances: - pip install pywin32 # Windows - pip install pyobjc-framework-Cocoa # macOS - pip install psutil # Tous OS -""" - -from __future__ import annotations - -import platform -import subprocess -from typing import Dict, Optional - - -def _run_cmd(cmd: list[str]) -> Optional[str]: - """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" - try: - out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) - return out.decode("utf-8", errors="ignore").strip() - except Exception: - return None - - -def get_active_window_info() -> Dict[str, str]: - """ - Renvoie un dict : - { - "title": "...", - "app_name": "..." - } - - Détecte automatiquement l'OS et utilise la méthode appropriée. - """ - system = platform.system() - - if system == "Linux": - return _get_window_info_linux() - elif system == "Windows": - return _get_window_info_windows() - elif system == "Darwin": # macOS - return _get_window_info_macos() - else: - return {"title": "unknown_window", "app_name": "unknown_app"} - - -def _get_window_info_linux() -> Dict[str, str]: - """ - Linux: utilise xdotool (X11) - - Nécessite: sudo apt-get install xdotool - """ - title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) - pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) - - app_name: Optional[str] = None - if pid_str: - pid_str = pid_str.strip() - # On récupère le nom du binaire via ps - app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) - - if not title: - title = "unknown_window" - if not app_name: - app_name = "unknown_app" - - return { - "title": title, - "app_name": app_name, - } - - -def _get_window_info_windows() -> Dict[str, str]: - """ - Windows: utilise pywin32 + psutil - - Nécessite: pip install pywin32 psutil - """ - try: - import win32gui - import win32process - import psutil - - # Fenêtre au premier plan - hwnd = win32gui.GetForegroundWindow() - - # Titre de la fenêtre - title = win32gui.GetWindowText(hwnd) - if not title: - title = "unknown_window" - - # PID du processus - _, pid = win32process.GetWindowThreadProcessId(hwnd) - - # Nom du processus - try: - process = psutil.Process(pid) - app_name = process.name() - except (psutil.NoSuchProcess, psutil.AccessDenied): - app_name = "unknown_app" - - return { - "title": title, - "app_name": app_name, - } - - except ImportError: - # pywin32 ou psutil non installé - return { - "title": "unknown_window (pywin32 missing)", - "app_name": "unknown_app (pywin32 missing)", - } - except Exception as e: - return { - "title": f"error: {e}", - "app_name": "unknown_app", - } - - -def _get_window_info_macos() -> Dict[str, str]: - """ - macOS: utilise pyobjc (AppKit) - - Nécessite: pip install pyobjc-framework-Cocoa - - Note: Nécessite les permissions "Accessibility" dans System Preferences - """ - try: - from AppKit import NSWorkspace - from Quartz import ( - CGWindowListCopyWindowInfo, - kCGWindowListOptionOnScreenOnly, - kCGNullWindowID - ) - - # Application active - active_app = NSWorkspace.sharedWorkspace().activeApplication() - app_name = active_app.get('NSApplicationName', 'unknown_app') - - # Titre de la fenêtre (via Quartz) - # On cherche la fenêtre de l'app active qui est au premier plan - window_list = CGWindowListCopyWindowInfo( - kCGWindowListOptionOnScreenOnly, - kCGNullWindowID - ) - - title = "unknown_window" - for window in window_list: - owner_name = window.get('kCGWindowOwnerName', '') - if owner_name == app_name: - window_title = window.get('kCGWindowName', '') - if window_title: - title = window_title - break - - return { - "title": title, - "app_name": app_name, - } - - except ImportError: - # pyobjc non installé - return { - "title": "unknown_window (pyobjc missing)", - "app_name": "unknown_app (pyobjc missing)", - } - except Exception as e: - return { - "title": f"error: {e}", - "app_name": "unknown_app", - } - - -# Test rapide -if __name__ == "__main__": - import time - - print(f"OS détecté: {platform.system()}") - print("\nTest de capture fenêtre active (5 secondes)...") - print("Changez de fenêtre pour tester!\n") - - for i in range(5): - info = get_active_window_info() - print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}") - time.sleep(1) diff --git a/agent_v0/deploy/windows_client/agent_v1/window_info.py b/agent_v0/deploy/windows_client/agent_v1/window_info.py deleted file mode 100644 index 7e6be8744..000000000 --- a/agent_v0/deploy/windows_client/agent_v1/window_info.py +++ /dev/null @@ -1,55 +0,0 @@ -# window_info.py -""" -Récupération des informations sur la fenêtre active (X11). - -v0 : -- utilise xdotool pour obtenir : - - le titre de la fenêtre active - - le PID de la fenêtre active, puis le nom du process via ps - -Si quelque chose ne fonctionne pas, on renvoie des valeurs "unknown". -""" - -from __future__ import annotations - -import subprocess -from typing import Dict, Optional - - -def _run_cmd(cmd: list[str]) -> Optional[str]: - """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" - try: - out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) - return out.decode("utf-8", errors="ignore").strip() - except Exception: - return None - - -def get_active_window_info() -> Dict[str, str]: - """ - Renvoie un dict : - { - "title": "...", - "app_name": "..." - } - - Nécessite xdotool installé sur le système. - """ - title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) - pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) - - app_name: Optional[str] = None - if pid_str: - pid_str = pid_str.strip() - # On récupère le nom du binaire via ps - app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) - - if not title: - title = "unknown_window" - if not app_name: - app_name = "unknown_app" - - return { - "title": title, - "app_name": app_name, - } diff --git a/agent_v0/deploy/windows_client/agent_v1/window_info_crossplatform.py b/agent_v0/deploy/windows_client/agent_v1/window_info_crossplatform.py deleted file mode 100644 index ba059a3fc..000000000 --- a/agent_v0/deploy/windows_client/agent_v1/window_info_crossplatform.py +++ /dev/null @@ -1,192 +0,0 @@ -# window_info_crossplatform.py -""" -Récupération des informations sur la fenêtre active - CROSS-PLATFORM - -Supporte: -- Linux (X11 via xdotool) -- Windows (via pywin32) -- macOS (via pyobjc) - -Installation des dépendances: - pip install pywin32 # Windows - pip install pyobjc-framework-Cocoa # macOS - pip install psutil # Tous OS -""" - -from __future__ import annotations - -import platform -import subprocess -from typing import Dict, Optional - - -def _run_cmd(cmd: list[str]) -> Optional[str]: - """Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur.""" - try: - out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL) - return out.decode("utf-8", errors="ignore").strip() - except Exception: - return None - - -def get_active_window_info() -> Dict[str, str]: - """ - Renvoie un dict : - { - "title": "...", - "app_name": "..." - } - - Détecte automatiquement l'OS et utilise la méthode appropriée. - """ - system = platform.system() - - if system == "Linux": - return _get_window_info_linux() - elif system == "Windows": - return _get_window_info_windows() - elif system == "Darwin": # macOS - return _get_window_info_macos() - else: - return {"title": "unknown_window", "app_name": "unknown_app"} - - -def _get_window_info_linux() -> Dict[str, str]: - """ - Linux: utilise xdotool (X11) - - Nécessite: sudo apt-get install xdotool - """ - title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"]) - pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"]) - - app_name: Optional[str] = None - if pid_str: - pid_str = pid_str.strip() - # On récupère le nom du binaire via ps - app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="]) - - if not title: - title = "unknown_window" - if not app_name: - app_name = "unknown_app" - - return { - "title": title, - "app_name": app_name, - } - - -def _get_window_info_windows() -> Dict[str, str]: - """ - Windows: utilise pywin32 + psutil - - Nécessite: pip install pywin32 psutil - """ - try: - import win32gui - import win32process - import psutil - - # Fenêtre au premier plan - hwnd = win32gui.GetForegroundWindow() - - # Titre de la fenêtre - title = win32gui.GetWindowText(hwnd) - if not title: - title = "unknown_window" - - # PID du processus - _, pid = win32process.GetWindowThreadProcessId(hwnd) - - # Nom du processus - try: - process = psutil.Process(pid) - app_name = process.name() - except (psutil.NoSuchProcess, psutil.AccessDenied): - app_name = "unknown_app" - - return { - "title": title, - "app_name": app_name, - } - - except ImportError: - # pywin32 ou psutil non installé - return { - "title": "unknown_window (pywin32 missing)", - "app_name": "unknown_app (pywin32 missing)", - } - except Exception as e: - return { - "title": f"error: {e}", - "app_name": "unknown_app", - } - - -def _get_window_info_macos() -> Dict[str, str]: - """ - macOS: utilise pyobjc (AppKit) - - Nécessite: pip install pyobjc-framework-Cocoa - - Note: Nécessite les permissions "Accessibility" dans System Preferences - """ - try: - from AppKit import NSWorkspace - from Quartz import ( - CGWindowListCopyWindowInfo, - kCGWindowListOptionOnScreenOnly, - kCGNullWindowID - ) - - # Application active - active_app = NSWorkspace.sharedWorkspace().activeApplication() - app_name = active_app.get('NSApplicationName', 'unknown_app') - - # Titre de la fenêtre (via Quartz) - # On cherche la fenêtre de l'app active qui est au premier plan - window_list = CGWindowListCopyWindowInfo( - kCGWindowListOptionOnScreenOnly, - kCGNullWindowID - ) - - title = "unknown_window" - for window in window_list: - owner_name = window.get('kCGWindowOwnerName', '') - if owner_name == app_name: - window_title = window.get('kCGWindowName', '') - if window_title: - title = window_title - break - - return { - "title": title, - "app_name": app_name, - } - - except ImportError: - # pyobjc non installé - return { - "title": "unknown_window (pyobjc missing)", - "app_name": "unknown_app (pyobjc missing)", - } - except Exception as e: - return { - "title": f"error: {e}", - "app_name": "unknown_app", - } - - -# Test rapide -if __name__ == "__main__": - import time - - print(f"OS détecté: {platform.system()}") - print("\nTest de capture fenêtre active (5 secondes)...") - print("Changez de fenêtre pour tester!\n") - - for i in range(5): - info = get_active_window_info() - print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}") - time.sleep(1) diff --git a/deploy/build_lea_exe.sh b/deploy/build_lea_exe.sh deleted file mode 100755 index e2e48b121..000000000 --- a/deploy/build_lea_exe.sh +++ /dev/null @@ -1,275 +0,0 @@ -#!/bin/bash -# ============================================================ -# build_lea_exe.sh — Cree un executable Windows autonome via PyInstaller -# -# IMPORTANT : Ce script doit tourner SUR WINDOWS (ou dans Wine/WSL -# avec acces a un Python Windows). PyInstaller ne peut pas produire -# un .exe Windows depuis Linux natif. -# -# Procedure recommandee : -# 1. Sur le PC Windows (192.168.1.11 ou autre) : -# - Installer Python 3.12 (https://python.org) -# - pip install pyinstaller -# 2. Copier ce script et le dossier agent_v0/ sur le PC Windows -# 3. Executer depuis PowerShell/cmd : -# python -m PyInstaller --onefile --windowed ^ -# --name "Lea" ^ -# --add-data "agent_v1;agent_v1" ^ -# --add-data "lea_ui;lea_ui" ^ -# --add-data "config.txt;." ^ -# --hidden-import "pynput.keyboard._win32" ^ -# --hidden-import "pynput.mouse._win32" ^ -# --hidden-import "pystray._win32" ^ -# --hidden-import "plyer.platforms.win.notification" ^ -# --hidden-import "win32api" ^ -# --hidden-import "win32con" ^ -# --hidden-import "win32gui" ^ -# run_agent_v1.py -# -# Le .exe resultant sera dans dist/Lea.exe (~50-100 MB) -# -# ============================================================ -# -# OPTION ALTERNATIVE : Python Embedded (recommandee) -# -# Python Embedded est un Python portable officiel (pas d'installation). -# Combine avec le code source, c'est la methode la plus fiable -# pour les non-informaticiens. -# -# Sur une machine Windows : -# 1. Telecharger Python Embedded 3.12 : -# https://www.python.org/ftp/python/3.12.9/python-3.12.9-embed-amd64.zip -# -# 2. Dezipper dans un dossier temporaire -# -# 3. Activer pip dans Python Embedded : -# - Editer python312._pth, decommenter "import site" -# - Telecharger get-pip.py : https://bootstrap.pypa.io/get-pip.py -# - Executer : python.exe get-pip.py -# -# 4. Installer les dependances : -# python.exe -m pip install -r requirements_agent.txt -# -# 5. Copier le code source (agent_v1/, lea_ui/, run_agent_v1.py) -# -# 6. Zipper le tout → Lea_Portable.zip (~40-60 MB) -# -# Le Lea.bat dans ce cas utiliserait : -# python\python.exe run_agent_v1.py -# au lieu de .venv\Scripts\python.exe -# -# ============================================================ - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" - -echo "============================================================" -echo " Build Lea.exe (PyInstaller)" -echo "============================================================" -echo "" -echo " Ce script ne peut pas produire un .exe Windows depuis Linux." -echo "" -echo " OPTIONS DISPONIBLES :" -echo "" -echo " 1. OPTION VIA PC WINDOWS (recommandee pour .exe) :" -echo " Copiez le dossier deploy/ sur le PC Windows" -echo " puis lancez la commande PyInstaller ci-dessous." -echo "" -echo " 2. OPTION ZIP + VENV (recommandee pour deploiement rapide) :" -echo " Lancez ./deploy/build_package.sh" -echo " Le zip resultant contient install.bat + Lea.bat" -echo "" -echo " 3. OPTION PYTHON EMBEDDED (recommandee pour zero install) :" -echo " Suivez les instructions dans ce script (section ALTERNATIVE)" -echo "" -echo "============================================================" -echo "" - -# Generer le .spec PyInstaller pour reference -SPEC_FILE="$SCRIPT_DIR/Lea.spec" -cat > "$SPEC_FILE" << 'PYINSTALLER_SPEC' -# -*- mode: python ; coding: utf-8 -*- -# Lea.spec — Configuration PyInstaller pour l'agent Lea -# -# Usage sur Windows : -# pip install pyinstaller -# pyinstaller Lea.spec -# -# Le .exe resultant sera dans dist/Lea.exe - -import os -import sys - -block_cipher = None - -# Repertoire de travail (ou se trouve ce .spec) -SPEC_DIR = os.path.dirname(os.path.abspath(SPEC())) if 'SPEC' in dir() else '.' - -a = Analysis( - ['run_agent_v1.py'], - pathex=['.'], - binaries=[], - datas=[ - ('agent_v1', 'agent_v1'), - ('lea_ui', 'lea_ui'), - ('config.txt', '.'), - ('LISEZMOI.txt', '.'), - ], - hiddenimports=[ - # pynput backends Windows - 'pynput.keyboard._win32', - 'pynput.mouse._win32', - # pystray backend Windows - 'pystray._win32', - # plyer notification Windows - 'plyer.platforms.win', - 'plyer.platforms.win.notification', - # pywin32 - 'win32api', - 'win32con', - 'win32gui', - 'win32com', - 'pythoncom', - # tkinter (stdlib, parfois manquant dans PyInstaller) - 'tkinter', - 'tkinter.simpledialog', - 'tkinter.messagebox', - 'tkinter.filedialog', - ], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[ - # Exclure les modules lourds non necessaires cote client - 'torch', - 'torchvision', - 'transformers', - 'clip', - 'open_clip', - 'faiss', - 'cv2', # opencv pas obligatoire (blur_sensitive a un fallback) - 'numpy', # requis par PIL mais pas directement - 'scipy', - 'sklearn', - 'matplotlib', - 'pandas', - 'tensorflow', - ], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, -) - -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - a.binaries, - a.zipfiles, - a.datas, - [], - name='Lea', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - upx_exclude=[], - runtime_tmpdir=None, - console=False, # --windowed : pas de console visible - disable_windowed_traceback=False, - argv_emulation=False, - target_arch=None, - codesign_identity=None, - entitlements_file=None, - # icon='assets/lea_icon.ico', # Decommenter quand l'icone sera creee -) -PYINSTALLER_SPEC - -echo " Fichier Lea.spec genere dans : $SPEC_FILE" -echo "" -echo " Pour builder sur Windows :" -echo " 1. Copier le dossier Lea/ (apres build_package.sh) sur le PC Windows" -echo " 2. pip install pyinstaller" -echo " 3. cd Lea" -echo " 4. pyinstaller ../Lea.spec" -echo " 5. Le .exe sera dans dist/Lea.exe" -echo "" - -# Generer aussi un script batch pour builder sur Windows -WIN_BUILD="$SCRIPT_DIR/build_exe_windows.bat" -cat > "$WIN_BUILD" << 'WIN_BATCH' -@echo off -chcp 65001 >nul 2>&1 -title Build Lea.exe - -echo ============================================================ -echo Build Lea.exe (PyInstaller) -echo ============================================================ -echo. - -:: Verifier PyInstaller -pip show pyinstaller >nul 2>&1 -if errorlevel 1 ( - echo Installation de PyInstaller... - pip install pyinstaller -) - -:: Builder -echo Build en cours (cela prend 2-5 minutes)... -echo. - -pyinstaller --onefile --windowed ^ - --name "Lea" ^ - --add-data "agent_v1;agent_v1" ^ - --add-data "lea_ui;lea_ui" ^ - --add-data "config.txt;." ^ - --add-data "LISEZMOI.txt;." ^ - --hidden-import "pynput.keyboard._win32" ^ - --hidden-import "pynput.mouse._win32" ^ - --hidden-import "pystray._win32" ^ - --hidden-import "plyer.platforms.win.notification" ^ - --hidden-import "win32api" ^ - --hidden-import "win32con" ^ - --hidden-import "win32gui" ^ - --hidden-import "tkinter" ^ - --hidden-import "tkinter.simpledialog" ^ - --hidden-import "tkinter.messagebox" ^ - --exclude-module "torch" ^ - --exclude-module "torchvision" ^ - --exclude-module "transformers" ^ - --exclude-module "clip" ^ - --exclude-module "faiss" ^ - --exclude-module "scipy" ^ - --exclude-module "sklearn" ^ - --exclude-module "matplotlib" ^ - --exclude-module "pandas" ^ - --exclude-module "tensorflow" ^ - run_agent_v1.py - -if errorlevel 1 ( - echo. - echo ERREUR : Le build a echoue. - pause - exit /b 1 -) - -echo. -echo ============================================================ -echo Build termine ! -echo. -echo Lea.exe est dans le dossier dist\ -echo Taille : -dir dist\Lea.exe | findstr "Lea.exe" -echo. -echo Pour deployer : copiez dist\Lea.exe + config.txt + LISEZMOI.txt -echo ============================================================ -pause -WIN_BATCH - -echo " Script Windows genere : $WIN_BUILD" -echo "" -echo "============================================================"