chore: ménage — suppression agent Rust (5.6 GB) + vieux packages déploiement

- agent_rust/ supprimé entièrement (on reste sur Python pour Léa)
- deploy/build/Lea/ supprimé (package stale avec fichiers obsolètes)
- deploy/build_lea_exe.sh supprimé (script PyInstaller Rust, obsolète)
- window_info*.py dupliqués retirés du package Windows
- __pycache__ nettoyé du deploy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-31 10:12:48 +02:00
parent a92d04621a
commit 1253a40051
26 changed files with 0 additions and 5324 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &regions {
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<BlurRegion> {
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(&regions, &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<BlurRegion>) {
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
}

View File

@@ -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<DynamicImage> {
let monitors = match xcap::Monitor::all() {
Ok(m) => m,
Err(e) => {
eprintln!("[CAPTURE] Erreur enumeration moniteurs : {}", e);
return None;
}
};
let primary = monitors
.into_iter()
.find(|m| m.is_primary().unwrap_or(false));
let monitor = match primary {
Some(m) => m,
None => {
eprintln!("[CAPTURE] Aucun moniteur principal trouve");
return None;
}
};
match monitor.capture_image() {
Ok(rgba_image) => Some(DynamicImage::ImageRgba8(rgba_image)),
Err(e) => {
eprintln!("[CAPTURE] Erreur capture ecran : {}", e);
None
}
}
}
/// Encode une image en JPEG et retourne le résultat en base64.
///
/// La qualité doit être entre 1 (mauvaise) et 100 (excellente).
/// 85 est un bon compromis taille/qualité pour le streaming réseau.
pub fn screenshot_to_jpeg_base64(img: &DynamicImage, quality: u8) -> String {
let rgb = img.to_rgb8();
let mut buffer = Cursor::new(Vec::new());
let mut encoder = JpegEncoder::new_with_quality(&mut buffer, quality);
if let Err(e) = encoder.encode(
rgb.as_raw(),
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
) {
eprintln!("[CAPTURE] Erreur encodage JPEG : {}", e);
return String::new();
}
base64::engine::general_purpose::STANDARD.encode(buffer.into_inner())
}
/// Encode une image en JPEG et retourne les bytes bruts.
pub fn screenshot_to_jpeg_bytes(img: &DynamicImage, quality: u8) -> Vec<u8> {
let rgb = img.to_rgb8();
let mut buffer = Cursor::new(Vec::new());
let mut encoder = JpegEncoder::new_with_quality(&mut buffer, quality);
if let Err(e) = encoder.encode(
rgb.as_raw(),
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
) {
eprintln!("[CAPTURE] Erreur encodage JPEG : {}", e);
return Vec::new();
}
buffer.into_inner()
}
/// Calcule un hash perceptuel rapide pour la déduplication.
///
/// Réduit l'image à 16x16 en niveaux de gris, puis calcule
/// un hash simple basé sur les pixels. Identique à la logique
/// Python (_quick_hash) dans agent_v1.
pub fn image_hash(img: &DynamicImage) -> u64 {
let small = img.resize_exact(16, 16, image::imageops::FilterType::Nearest);
let gray = small.to_luma8();
// Hash FNV-1a simple sur les pixels (rapide, pas besoin de crypto)
let mut hash: u64 = 0xcbf29ce484222325;
for pixel in gray.as_raw() {
hash ^= *pixel as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
/// Retourne les dimensions du moniteur principal (largeur, hauteur).
///
/// 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))
}

View File

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

View File

@@ -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<PathBuf> = 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" },
)
}
}

View File

@@ -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<Key> = keys
.iter()
.filter_map(|name| resolve_key(name))
.collect();
if resolved.is_empty() {
return ActionResult::error(
&action.action_id,
&format!("Aucune touche reconnue dans {:?}", keys),
);
}
if resolved.len() == 1 {
// Une seule touche : simple press/release
if let Err(e) = enigo.key(resolved[0], Direction::Click) {
return ActionResult::error(&action.action_id, &format!("Erreur touche : {}", e));
}
} else {
// Combo : maintenir les modifieurs, taper la dernière touche, relâcher
let (modifiers, last) = resolved.split_at(resolved.len() - 1);
for modifier in modifiers {
if let Err(e) = enigo.key(*modifier, Direction::Press) {
return ActionResult::error(
&action.action_id,
&format!("Erreur modifier press : {}", e),
);
}
}
thread::sleep(Duration::from_millis(50));
if let Err(e) = enigo.key(last[0], Direction::Click) {
// Toujours relâcher les modifieurs même en cas d'erreur
for modifier in modifiers.iter().rev() {
let _ = enigo.key(*modifier, Direction::Release);
}
return ActionResult::error(
&action.action_id,
&format!("Erreur touche finale : {}", e),
);
}
for modifier in modifiers.iter().rev() {
if let Err(e) = enigo.key(*modifier, Direction::Release) {
eprintln!(" [KEY_COMBO] Erreur release modifier : {}", e);
}
}
}
println!(" [KEY_COMBO] Termine.");
ActionResult::ok(&action.action_id)
}
/// Exécute un scroll de souris.
fn execute_scroll(action: &Action, screen_width: u32, screen_height: u32) -> ActionResult {
let real_x = if action.x_pct > 0.0 {
(action.x_pct * screen_width as f64) as i32
} else {
(0.5 * screen_width as f64) as i32
};
let real_y = if action.y_pct > 0.0 {
(action.y_pct * screen_height as f64) as i32
} else {
(0.5 * screen_height as f64) as i32
};
let delta = action.delta;
println!(" [SCROLL] delta={} a ({}, {})", delta, real_x, real_y);
let mut enigo = match Enigo::new(&Settings::default()) {
Ok(e) => e,
Err(e) => {
return ActionResult::error(
&action.action_id,
&format!("Impossible d'initialiser enigo : {}", e),
);
}
};
if let Err(e) = enigo.move_mouse(real_x, real_y, Coordinate::Abs) {
return ActionResult::error(
&action.action_id,
&format!("Erreur deplacement souris : {}", e),
);
}
thread::sleep(Duration::from_millis(50));
if let Err(e) = enigo.scroll(delta, enigo::Axis::Vertical) {
return ActionResult::error(
&action.action_id,
&format!("Erreur scroll : {}", e),
);
}
println!(" [SCROLL] Termine.");
ActionResult::ok(&action.action_id)
}
/// Exécute une attente (pause).
fn execute_wait(action: &Action) -> ActionResult {
let duration_ms = action.duration_ms;
println!(" [WAIT] {}ms...", duration_ms);
thread::sleep(Duration::from_millis(duration_ms));
println!(" [WAIT] Termine.");
ActionResult::ok(&action.action_id)
}
/// Résout un nom de touche (string) vers un enigo::Key.
///
/// Mapping compatible avec le Python executor (_SPECIAL_KEYS).
fn resolve_key(name: &str) -> Option<Key> {
match name.to_lowercase().as_str() {
// Touches de contrôle
"enter" | "return" => Some(Key::Return),
"tab" => Some(Key::Tab),
"escape" | "esc" => Some(Key::Escape),
"backspace" => Some(Key::Backspace),
"delete" => Some(Key::Delete),
"space" => Some(Key::Space),
// Touches de navigation
"up" => Some(Key::UpArrow),
"down" => Some(Key::DownArrow),
"left" => Some(Key::LeftArrow),
"right" => Some(Key::RightArrow),
"home" => Some(Key::Home),
"end" => Some(Key::End),
"page_up" | "pageup" => Some(Key::PageUp),
"page_down" | "pagedown" => Some(Key::PageDown),
// Touches de fonction
"f1" => Some(Key::F1),
"f2" => Some(Key::F2),
"f3" => Some(Key::F3),
"f4" => Some(Key::F4),
"f5" => Some(Key::F5),
"f6" => Some(Key::F6),
"f7" => Some(Key::F7),
"f8" => Some(Key::F8),
"f9" => Some(Key::F9),
"f10" => Some(Key::F10),
"f11" => Some(Key::F11),
"f12" => Some(Key::F12),
// Modifieurs
"ctrl" | "ctrl_l" | "ctrl_r" | "control" => Some(Key::Control),
"alt" | "alt_l" | "alt_r" => Some(Key::Alt),
"shift" | "shift_l" | "shift_r" => Some(Key::Shift),
"cmd" | "win" | "super" | "super_l" | "super_r" | "windows" | "meta" => Some(Key::Meta),
// Touches spéciales
"insert" => Some(Key::Other(0x2D)), // VK_INSERT
"caps_lock" | "capslock" => Some(Key::CapsLock),
// Caractère unique -> Unicode
s if s.len() == 1 => {
let c = s.chars().next().unwrap();
Some(Key::Unicode(c))
}
_ => {
eprintln!(" [KEY_COMBO] Touche inconnue : '{}', ignoree", name);
None
}
}
}

View File

@@ -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<String> {
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<AgentState>) {
#[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!();
}

View File

@@ -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<String>,
/// Bouton de souris : "left", "right", "double"
#[serde(default = "default_button")]
pub button: String,
/// Durée d'attente en ms (pour action "wait")
#[serde(default = "default_duration")]
pub duration_ms: u64,
/// Delta de scroll (pour action "scroll")
#[serde(default)]
pub delta: i32,
/// Mode visuel (résolution par le serveur)
#[serde(default)]
pub visual_mode: bool,
/// Spécification de la cible visuelle
#[serde(default)]
pub target_spec: serde_json::Value,
}
fn default_button() -> String {
"left".to_string()
}
fn default_duration() -> u64 {
500
}
/// Résultat d'exécution d'une action.
#[derive(Debug, Serialize, Deserialize)]
pub struct ActionResult {
pub action_id: String,
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot: Option<String>,
}
impl ActionResult {
/// Crée un résultat d'erreur.
pub fn error(action_id: &str, msg: &str) -> Self {
ActionResult {
action_id: action_id.to_string(),
success: false,
error: Some(msg.to_string()),
screenshot: None,
}
}
/// Crée un résultat de succès.
pub fn ok(action_id: &str) -> Self {
ActionResult {
action_id: action_id.to_string(),
success: true,
error: None,
screenshot: None,
}
}
}
/// Envoie un heartbeat (screenshot) au serveur streaming.
///
/// POST /traces/stream/image avec le screenshot en multipart.
/// 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<Action>,
}
/// Poll le serveur pour récupérer la prochaine action de replay.
///
/// GET /traces/stream/replay/next?session_id=...&machine_id=...
/// Retourne None si pas d'action en attente ou si le serveur est indisponible.
pub fn poll_next_action(client: &Client, config: &Config) -> Option<Action> {
let url = format!("{}/replay/next", config.streaming_url());
let session_id = config.agent_session_id();
let 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<WorkflowInfo>,
}
/// Récupère la liste des workflows disponibles pour cette machine.
///
/// GET /traces/stream/workflows?machine_id=<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<WorkflowInfo> {
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::<WorkflowsResponse>() {
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<WorkflowInfo> {
let path = workflows_cache_path();
match std::fs::read_to_string(&path) {
Ok(content) => {
match serde_json::from_str::<Vec<WorkflowInfo>>(&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<String>,
screenshot: &'a Option<String>,
}
let report = Report {
session_id: &session_id,
action_id: &result.action_id,
success: result.success,
error: &result.error,
screenshot: &result.screenshot,
};
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::<serde_json::Value>() {
let status = data.get("replay_status")
.and_then(|v| v.as_str())
.unwrap_or("?");
let remaining = data.get("remaining_actions")
.and_then(|v| v.as_i64())
.unwrap_or(-1);
println!(
" [RESULT] Rapporte : status={}, restant={}",
status, remaining
);
}
true
} else {
eprintln!(
" [RESULT] Rapport echoue : HTTP {}",
resp.status()
);
false
}
}
Err(e) => {
eprintln!(" [RESULT] Erreur reseau : {}", e);
false
}
}
}

View File

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

View File

@@ -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<String> },
/// 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<Config>,
state: Arc<AgentState>,
) -> Receiver<CapturedEvent> {
let (tx, rx) = bounded::<CapturedEvent>(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<CapturedEvent>, state: Arc<AgentState>) {
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<CapturedEvent>, state: Arc<AgentState>) {
// 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<CapturedEvent>,
config: Arc<Config>,
state: Arc<AgentState>,
) {
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<char> {
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()
}
}
}
}

View File

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

View File

@@ -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, &params);
let code = if result.get("error").is_some() { 500 } else { 200 };
let _ = send_json_response(request, code, &result.to_string());
}
/// OPTIONS — Réponse CORS preflight.
fn handle_options(request: tiny_http::Request) {
let response = Response::empty(200)
.with_header(cors_origin())
.with_header(cors_methods())
.with_header(cors_headers());
let _ = request.respond(response);
}
/// Exécute une action fichier.
fn execute_file_action(action: &str, params: &serde_json::Value) -> serde_json::Value {
match action {
"file_list_dir" => {
let path = params.get("path").and_then(|v| v.as_str()).unwrap_or("");
let pattern = params
.get("pattern")
.and_then(|v| v.as_str())
.unwrap_or("*");
if path.is_empty() {
return json!({"error": "Parametre 'path' requis"});
}
if !is_safe_path(path) {
return json!({"error": format!("Chemin non autorise : {}", path)});
}
match std::fs::read_dir(path) {
Ok(entries) => {
let mut files = Vec::new();
let mut extensions: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
if metadata.is_file() {
let name = entry.file_name().to_string_lossy().to_string();
// Filtrage par pattern (simple glob avec *)
if pattern != "*" && !simple_glob_match(pattern, &name) {
continue;
}
let ext = std::path::Path::new(&name)
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_else(|| "sans_extension".to_string());
files.push(json!({
"name": name,
"extension": ext,
"size": metadata.len(),
"path": entry.path().to_string_lossy(),
}));
*extensions.entry(ext).or_insert(0) += 1;
}
}
}
json!({
"files": files,
"count": files.len(),
"extensions": extensions,
"path": path,
})
}
Err(e) => json!({"error": format!("Erreur lecture dossier : {}", e)}),
}
}
"file_create_dir" => {
let path = params.get("path").and_then(|v| v.as_str()).unwrap_or("");
if path.is_empty() {
return json!({"error": "Parametre 'path' requis"});
}
if !is_safe_path(path) {
return json!({"error": format!("Chemin non autorise : {}", path)});
}
let existed = std::path::Path::new(path).exists();
match std::fs::create_dir_all(path) {
Ok(_) => json!({
"created": !existed,
"path": path,
"already_existed": existed,
}),
Err(e) => json!({"error": format!("Erreur creation dossier : {}", e)}),
}
}
"file_move" => {
let src = params.get("source").and_then(|v| v.as_str()).unwrap_or("");
let dst = params
.get("destination")
.and_then(|v| v.as_str())
.unwrap_or("");
if src.is_empty() || dst.is_empty() {
return json!({"error": "Parametres 'source' et 'destination' requis"});
}
if !is_safe_path(src) || !is_safe_path(dst) {
return json!({"error": "Chemin non autorise"});
}
// Créer le dossier parent de destination
if let Some(parent) = std::path::Path::new(dst).parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::rename(src, dst) {
Ok(_) => json!({"moved": true, "source": src, "destination": dst}),
Err(e) => json!({"error": format!("Erreur deplacement : {}", e)}),
}
}
"file_copy" => {
let src = params.get("source").and_then(|v| v.as_str()).unwrap_or("");
let dst = params
.get("destination")
.and_then(|v| v.as_str())
.unwrap_or("");
if src.is_empty() || dst.is_empty() {
return json!({"error": "Parametres 'source' et 'destination' requis"});
}
if !is_safe_path(src) || !is_safe_path(dst) {
return json!({"error": "Chemin non autorise"});
}
if let Some(parent) = std::path::Path::new(dst).parent() {
let _ = std::fs::create_dir_all(parent);
}
match std::fs::copy(src, dst) {
Ok(_) => json!({"copied": true, "source": src, "destination": dst}),
Err(e) => json!({"error": format!("Erreur copie : {}", e)}),
}
}
"file_sort_by_ext" => {
let source_dir = params
.get("source_dir")
.and_then(|v| v.as_str())
.unwrap_or("");
let create_subdirs = params
.get("create_subdirs")
.and_then(|v| v.as_bool())
.unwrap_or(true);
if source_dir.is_empty() {
return json!({"error": "Parametre 'source_dir' requis"});
}
if !is_safe_path(source_dir) {
return json!({"error": format!("Chemin non autorise : {}", source_dir)});
}
let mut moved = Vec::new();
let mut extensions: std::collections::HashMap<String, u32> =
std::collections::HashMap::new();
if let Ok(entries) = std::fs::read_dir(source_dir) {
for entry in entries.flatten() {
if let Ok(metadata) = entry.metadata() {
if metadata.is_file() {
let name = entry.file_name().to_string_lossy().to_string();
let ext = std::path::Path::new(&name)
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_else(|| "sans_extension".to_string());
let target_dir =
std::path::Path::new(source_dir).join(&ext);
if create_subdirs {
let _ = std::fs::create_dir_all(&target_dir);
} else if !target_dir.exists() {
continue;
}
let dest = target_dir.join(&name);
if let Err(e) = std::fs::rename(entry.path(), &dest) {
eprintln!("[FILE] Erreur deplacement {} : {}", name, e);
continue;
}
moved.push(json!({
"file": name,
"to": ext,
"destination": dest.to_string_lossy(),
}));
*extensions.entry(ext).or_insert(0) += 1;
}
}
}
}
json!({
"moved": moved,
"count": moved.len(),
"extensions": extensions,
"source_dir": source_dir,
})
}
_ => json!({"error": format!("Action fichier inconnue : {}", action)}),
}
}
/// Vérifie qu'un chemin est dans une zone autorisée (sécurité anti-traversal).
///
/// Sur Windows : C:\Users, D:\, E:\
/// Sur Linux : /home, /tmp (pour les tests)
fn is_safe_path(path_str: &str) -> bool {
if path_str.is_empty() {
return false;
}
// Normaliser le chemin
let normalized = std::path::Path::new(path_str)
.to_string_lossy()
.to_uppercase();
if cfg!(target_os = "windows") {
let allowed = ["C:\\USERS", "D:\\", "E:\\"];
allowed.iter().any(|root| normalized.starts_with(root))
} else {
// Sur Linux (pour les tests)
let allowed = ["/HOME", "/TMP"];
allowed.iter().any(|root| normalized.starts_with(root))
}
}
/// Matching glob simple (supporte * comme wildcard).
fn simple_glob_match(pattern: &str, name: &str) -> bool {
if pattern == "*" {
return true;
}
// Pattern simple : *.ext
if let Some(ext) = pattern.strip_prefix("*.") {
return name.to_lowercase().ends_with(&format!(".{}", ext.to_lowercase()));
}
// Sinon, comparaison exacte
name.to_lowercase() == pattern.to_lowercase()
}
// --- Headers CORS ---
fn cors_origin() -> Header {
Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap()
}
fn cors_methods() -> Header {
Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS").unwrap()
}
fn cors_headers() -> Header {
Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap()
}
/// Envoie une réponse JSON avec les headers CORS.
fn send_json_response(
request: tiny_http::Request,
status_code: u16,
body: &str,
) -> Result<(), Box<dyn std::error::Error>> {
let status = tiny_http::StatusCode(status_code);
let content_type = Header::from_bytes("Content-Type", "application/json").unwrap();
let response = Response::from_string(body)
.with_status_code(status)
.with_header(content_type)
.with_header(cors_origin())
.with_header(cors_methods())
.with_header(cors_headers());
request.respond(response)?;
Ok(())
}

View File

@@ -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<String>,
/// 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<String>,
}
impl AgentState {
/// Cree un nouvel etat avec les valeurs par defaut.
pub fn new() -> Arc<Self> {
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<Self> 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()),
}
}
}

View File

@@ -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::<MONITORINFO>() 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(),
}
}

View File

@@ -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<Config>, state: Arc<AgentState>) {
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<Config>,
state: Arc<AgentState>,
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<Config>, state: Arc<AgentState>) {
println!("[TRAY] Systray non disponible sur cet OS — mode console");
fallback_console_loop(&state);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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