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:
2
agent_rust/.gitignore
vendored
2
agent_rust/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
────────────────────────────────────────────
|
||||
@@ -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)
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -1,340 +0,0 @@
|
||||
//! Floutage des zones sensibles dans les captures d'ecran.
|
||||
//!
|
||||
//! Detecte les champs de saisie (zones claires rectangulaires) et applique
|
||||
//! un flou gaussien pour proteger les donnees sensibles (mots de passe, etc.).
|
||||
//! Equivalent de agent_v1/vision/blur_sensitive.py.
|
||||
//!
|
||||
//! Algorithme :
|
||||
//! 1. Conversion en niveaux de gris
|
||||
//! 2. Seuillage binaire (detecter les zones claires = champs de saisie)
|
||||
//! 3. Detection de contours rectangulaires > 50px de large
|
||||
//! 4. Application d'un flou gaussien sur les zones detectees
|
||||
//!
|
||||
//! Utilise le crate image pour le traitement et imageproc pour le flou.
|
||||
|
||||
use image::{DynamicImage, GrayImage, Rgba, RgbaImage};
|
||||
|
||||
/// Seuil de luminosite pour detecter les champs de saisie (0-255).
|
||||
/// Les zones plus claires que ce seuil sont considerees comme des champs.
|
||||
const BRIGHTNESS_THRESHOLD: u8 = 220;
|
||||
|
||||
/// Largeur minimale d'un champ de saisie detecte (en pixels).
|
||||
const MIN_FIELD_WIDTH: u32 = 50;
|
||||
|
||||
/// Hauteur minimale d'un champ de saisie detecte (en pixels).
|
||||
const MIN_FIELD_HEIGHT: u32 = 15;
|
||||
|
||||
/// Hauteur maximale d'un champ de saisie (evite de flouter l'ecran entier).
|
||||
const MAX_FIELD_HEIGHT: u32 = 80;
|
||||
|
||||
/// Largeur maximale d'un champ (evite les faux positifs sur grandes zones blanches).
|
||||
const MAX_FIELD_WIDTH: u32 = 800;
|
||||
|
||||
/// Intensite du flou gaussien (sigma).
|
||||
const BLUR_SIGMA: f32 = 10.0;
|
||||
|
||||
/// Rectangle representant une zone a flouter.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BlurRegion {
|
||||
pub x: u32,
|
||||
pub y: u32,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// Detecte les champs de saisie dans une image et les floute.
|
||||
///
|
||||
/// Retourne l'image modifiee avec les zones sensibles floutees.
|
||||
/// Si aucun champ n'est detecte, retourne l'image inchangee.
|
||||
pub fn blur_sensitive_fields(img: &DynamicImage) -> DynamicImage {
|
||||
let regions = detect_input_fields(img);
|
||||
|
||||
if regions.is_empty() {
|
||||
return img.clone();
|
||||
}
|
||||
|
||||
println!(
|
||||
"[BLUR] {} zone(s) sensible(s) detectee(s) — floutage...",
|
||||
regions.len()
|
||||
);
|
||||
|
||||
let mut result = img.to_rgba8();
|
||||
|
||||
for region in ®ions {
|
||||
blur_region(&mut result, region);
|
||||
}
|
||||
|
||||
DynamicImage::ImageRgba8(result)
|
||||
}
|
||||
|
||||
/// Detecte les champs de saisie (zones claires rectangulaires).
|
||||
///
|
||||
/// Algorithme simplifie :
|
||||
/// 1. Convertir en niveaux de gris
|
||||
/// 2. Seuillage binaire
|
||||
/// 3. Scanner les lignes horizontales pour trouver les series de pixels clairs
|
||||
/// 4. Regrouper les series adjacentes en rectangles
|
||||
pub fn detect_input_fields(img: &DynamicImage) -> Vec<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(®ions, &new_region) {
|
||||
regions.push(new_region);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avancer de la hauteur du dernier champ detecte, ou de 1 ligne
|
||||
y += 1;
|
||||
}
|
||||
|
||||
// Deduplication : fusionner les regions tres proches
|
||||
merge_close_regions(&mut regions);
|
||||
|
||||
regions
|
||||
}
|
||||
|
||||
/// Applique un seuillage binaire simple.
|
||||
fn threshold_image(gray: &GrayImage, threshold: u8) -> GrayImage {
|
||||
let (width, height) = gray.dimensions();
|
||||
let mut binary = GrayImage::new(width, height);
|
||||
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let pixel = gray.get_pixel(x, y).0[0];
|
||||
if pixel >= threshold {
|
||||
binary.put_pixel(x, y, image::Luma([255]));
|
||||
} else {
|
||||
binary.put_pixel(x, y, image::Luma([0]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
binary
|
||||
}
|
||||
|
||||
/// Trouve les segments horizontaux de pixels blancs sur une ligne.
|
||||
fn find_white_segments(binary: &GrayImage, y: u32, width: u32) -> Vec<(u32, u32)> {
|
||||
let mut segments = Vec::new();
|
||||
let mut in_segment = false;
|
||||
let mut seg_start = 0u32;
|
||||
|
||||
for x in 0..width {
|
||||
let is_white = binary.get_pixel(x, y).0[0] > 128;
|
||||
|
||||
if is_white && !in_segment {
|
||||
seg_start = x;
|
||||
in_segment = true;
|
||||
} else if !is_white && in_segment {
|
||||
segments.push((seg_start, x));
|
||||
in_segment = false;
|
||||
}
|
||||
}
|
||||
|
||||
if in_segment {
|
||||
segments.push((seg_start, width));
|
||||
}
|
||||
|
||||
segments
|
||||
}
|
||||
|
||||
/// Compte le nombre de lignes consecutives ou le segment est blanc.
|
||||
fn count_vertical_extent(
|
||||
binary: &GrayImage,
|
||||
seg_start: u32,
|
||||
seg_end: u32,
|
||||
start_y: u32,
|
||||
max_y: u32,
|
||||
) -> u32 {
|
||||
let mut count = 0u32;
|
||||
let check_width = seg_end - seg_start;
|
||||
let threshold = (check_width as f64 * 0.7) as u32; // 70% doivent etre blancs
|
||||
|
||||
for y in start_y..max_y.min(start_y + MAX_FIELD_HEIGHT + 5) {
|
||||
let mut white_count = 0u32;
|
||||
for x in seg_start..seg_end {
|
||||
if binary.get_pixel(x, y).0[0] > 128 {
|
||||
white_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if white_count >= threshold {
|
||||
count += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
count
|
||||
}
|
||||
|
||||
/// Verifie si une region chevauche une region existante.
|
||||
fn overlaps_existing(regions: &[BlurRegion], new_region: &BlurRegion) -> bool {
|
||||
for region in regions {
|
||||
let x_overlap = new_region.x < region.x + region.width
|
||||
&& new_region.x + new_region.width > region.x;
|
||||
let y_overlap = new_region.y < region.y + region.height
|
||||
&& new_region.y + new_region.height > region.y;
|
||||
|
||||
if x_overlap && y_overlap {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Fusionne les regions tres proches (< 10px de distance).
|
||||
fn merge_close_regions(regions: &mut Vec<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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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" },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!();
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -1,402 +0,0 @@
|
||||
//! Mini serveur HTTP pour les captures d'écran à la demande.
|
||||
//!
|
||||
//! Écoute sur le port 5006 (configurable via RPA_CAPTURE_PORT).
|
||||
//! Endpoints :
|
||||
//! GET /capture -> screenshot frais en JSON {image, width, height, format}
|
||||
//! GET /health -> {"status": "ok"}
|
||||
//! POST /file-action -> opérations fichiers (list, create, move, copy, sort)
|
||||
//!
|
||||
//! Reproduit le comportement de agent_v1/ui/capture_server.py.
|
||||
|
||||
use crate::capture;
|
||||
use serde_json::json;
|
||||
use tiny_http::{Header, Method, Response, Server};
|
||||
|
||||
/// Démarre le serveur de capture sur le port donné (bloquant).
|
||||
///
|
||||
/// Cette fonction tourne dans un thread dédié et ne retourne jamais.
|
||||
pub fn start_capture_server(port: u16) {
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
let server = match Server::http(&addr) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("[CAPTURE] Impossible de demarrer le serveur sur {} : {}", addr, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!("[CAPTURE] Serveur de capture demarre sur le port {}", port);
|
||||
|
||||
for request in server.incoming_requests() {
|
||||
let url = request.url().to_string();
|
||||
let method = request.method().clone();
|
||||
|
||||
match (method, url.as_str()) {
|
||||
(Method::Get, "/capture") => handle_capture(request),
|
||||
(Method::Get, "/health") => handle_health(request),
|
||||
(Method::Post, "/file-action") => handle_file_action(request),
|
||||
(Method::Options, _) => handle_options(request),
|
||||
_ => {
|
||||
let body = json!({"error": "not found"}).to_string();
|
||||
let _ = send_json_response(request, 404, &body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /capture — Capture un screenshot frais et le renvoie en JSON base64.
|
||||
fn handle_capture(request: tiny_http::Request) {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
match capture::capture_screenshot() {
|
||||
Some(img) => {
|
||||
let width = img.width();
|
||||
let height = img.height();
|
||||
let b64 = capture::screenshot_to_jpeg_base64(&img, 80);
|
||||
let elapsed_ms = start.elapsed().as_millis();
|
||||
|
||||
let body = json!({
|
||||
"image": b64,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"format": "jpeg",
|
||||
"source": "rust_agent",
|
||||
"capture_ms": elapsed_ms,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let _ = send_json_response(request, 200, &body);
|
||||
}
|
||||
None => {
|
||||
let body = json!({"error": "Capture echouee"}).to_string();
|
||||
let _ = send_json_response(request, 500, &body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /health — Vérification de santé.
|
||||
fn handle_health(request: tiny_http::Request) {
|
||||
let body = json!({
|
||||
"status": "ok",
|
||||
"agent": "rust",
|
||||
"version": crate::config::AGENT_VERSION,
|
||||
})
|
||||
.to_string();
|
||||
let _ = send_json_response(request, 200, &body);
|
||||
}
|
||||
|
||||
/// POST /file-action — Opérations fichiers sur la machine locale.
|
||||
///
|
||||
/// Body JSON attendu : {"action": "file_list_dir", "params": {"path": "C:\\..."}}
|
||||
/// Actions supportées : file_list_dir, file_create_dir, file_move, file_copy, file_sort_by_ext
|
||||
fn handle_file_action(mut request: tiny_http::Request) {
|
||||
// Lire le body
|
||||
let mut body_str = String::new();
|
||||
if let Err(e) = request.as_reader().read_to_string(&mut body_str) {
|
||||
let resp = json!({"error": format!("Erreur lecture body : {}", e)}).to_string();
|
||||
let _ = send_json_response(request, 400, &resp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parser le JSON
|
||||
let data: serde_json::Value = match serde_json::from_str(&body_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
let resp = json!({"error": "JSON invalide"}).to_string();
|
||||
let _ = send_json_response(request, 400, &resp);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let action = data.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let params = data.get("params").cloned().unwrap_or(json!({}));
|
||||
|
||||
if action.is_empty() {
|
||||
let resp = json!({"error": "Parametre 'action' requis"}).to_string();
|
||||
let _ = send_json_response(request, 400, &resp);
|
||||
return;
|
||||
}
|
||||
|
||||
let result = execute_file_action(action, ¶ms);
|
||||
let code = if result.get("error").is_some() { 500 } else { 200 };
|
||||
let _ = send_json_response(request, code, &result.to_string());
|
||||
}
|
||||
|
||||
/// OPTIONS — Réponse CORS preflight.
|
||||
fn handle_options(request: tiny_http::Request) {
|
||||
let response = Response::empty(200)
|
||||
.with_header(cors_origin())
|
||||
.with_header(cors_methods())
|
||||
.with_header(cors_headers());
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
/// Exécute une action fichier.
|
||||
fn execute_file_action(action: &str, params: &serde_json::Value) -> serde_json::Value {
|
||||
match action {
|
||||
"file_list_dir" => {
|
||||
let path = params.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let pattern = params
|
||||
.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("*");
|
||||
|
||||
if path.is_empty() {
|
||||
return json!({"error": "Parametre 'path' requis"});
|
||||
}
|
||||
if !is_safe_path(path) {
|
||||
return json!({"error": format!("Chemin non autorise : {}", path)});
|
||||
}
|
||||
|
||||
match std::fs::read_dir(path) {
|
||||
Ok(entries) => {
|
||||
let mut files = Vec::new();
|
||||
let mut extensions: std::collections::HashMap<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(())
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user