feat: agent Rust complet — systray, chat, enregistrement, floutage (2.4 MB)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-18 23:18:09 +01:00
parent ad7ff3bce4
commit 90ee91caf9
11 changed files with 2329 additions and 191 deletions

View File

@@ -1,8 +1,8 @@
[package] [package]
name = "rpa-agent" name = "rpa-agent"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
description = "Agent RPA Vision - Lea (Phase 1 headless)" description = "Agent RPA Vision - Lea (Phases 1-5)"
[dependencies] [dependencies]
# Capture d'ecran # Capture d'ecran
@@ -11,12 +11,18 @@ xcap = "0.7"
# Simulation souris/clavier (replay) # Simulation souris/clavier (replay)
enigo = { version = "0.3", features = ["serde"] } enigo = { version = "0.3", features = ["serde"] }
# Capture evenements souris/clavier (recording) — Phase 5
rdev = "0.5"
# Client HTTP (mode bloquant, pas de tokio) # Client HTTP (mode bloquant, pas de tokio)
reqwest = { version = "0.12", features = ["blocking", "multipart", "json"] } reqwest = { version = "0.12", features = ["blocking", "multipart", "json"] }
# Traitement d'images (JPEG encode, resize) # Traitement d'images (JPEG encode, resize, crop)
image = "0.25" image = "0.25"
# Floutage zones sensibles — Phase 5
imageproc = "0.25"
# Encodage base64 # Encodage base64
base64 = "0.22" base64 = "0.22"
@@ -33,11 +39,47 @@ hostname = "0.4"
# Date/heure # Date/heure
chrono = "0.4" chrono = "0.4"
# Canaux inter-threads performants
crossbeam-channel = "0.5"
# Logging
log = "0.4"
env_logger = "0.11"
# Signal handling Unix (Ctrl+C) # Signal handling Unix (Ctrl+C)
[target.'cfg(unix)'.dependencies] [target.'cfg(unix)'.dependencies]
libc = "0.2" 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] [profile.release]
opt-level = "z" opt-level = "z"
lto = true lto = true
strip = true strip = true
codegen-units = 1
panic = "abort"

View File

@@ -1,52 +1,58 @@
# RPA Vision Agent (Rust) — Phase 1 # RPA Vision Agent (Rust) — Phases 1-5
Agent headless pour RPA Vision V3, ecrit en Rust. Agent complet pour RPA Vision V3, ecrit en Rust.
Capture des screenshots, les envoie au serveur streaming, poll les actions de replay et les execute. Parite fonctionnelle avec l'agent Python (`agent_v0/agent_v1/`) en un seul executable de 2.4 Mo.
Equivalent fonctionnel de `agent_v0/agent_v1/` (Python) mais en un seul executable sans dependance. ## Fonctionnalites
## Fonctionnalites (Phase 1) ### 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)
- **Heartbeat** : capture l'ecran toutes les 5s, encode en JPEG, envoie au serveur avec deduplication par hash perceptuel ### Phase 3 — Systray + Notifications
- **Replay** : poll GET /replay/next toutes les secondes, execute les actions (click, type, key_combo, scroll, wait), rapporte le resultat avec screenshot post-action - **Systray** : icone avec cercle colore (gris=idle, rouge=enregistrement, vert=connecte, bleu=replay)
- **Serveur de capture** : mini serveur HTTP sur port 5006 pour screenshots a la demande (GET /capture, GET /health, POST /file-action) - **Menu contextuel** : Machine ID, statut, Apprenez-moi, C'est termine, Mes taches, ARRET D'URGENCE, Chat, Fichiers, Quitter
- **Configuration** : via variables d'environnement ou valeurs par defaut - **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 ## Build
### Linux (pour tests) ### Linux (pour tests)
```bash ```bash
# Pre-requis systeme (Ubuntu/Debian)
sudo apt install libpipewire-0.3-dev libclang-dev libgbm-dev libxdo-dev sudo apt install libpipewire-0.3-dev libclang-dev libgbm-dev libxdo-dev
# Build debug
cargo build
# Build release optimise
cargo build --release cargo build --release
``` ```
### Cross-compilation vers Windows ### Cross-compilation vers Windows
```bash ```bash
# Option A : cargo-xwin (recommande, produit un .exe MSVC)
cargo install cargo-xwin
rustup target add x86_64-pc-windows-msvc
cargo xwin build --target x86_64-pc-windows-msvc --release
# Option B : MinGW (plus simple)
rustup target add x86_64-pc-windows-gnu rustup target add x86_64-pc-windows-gnu
sudo apt install gcc-mingw-w64-x86-64 sudo apt install gcc-mingw-w64-x86-64
cargo build --release --target x86_64-pc-windows-gnu cargo build --release --target x86_64-pc-windows-gnu
# Option C : cross (Docker)
cargo install cross
cross build --release --target x86_64-pc-windows-msvc
``` ```
Le binaire release se trouve dans `target/release/rpa-agent` (Linux) ou ### Deploiement sur le PC cible
`target/x86_64-pc-windows-msvc/release/rpa-agent.exe` (Windows).
```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 ## Configuration
@@ -57,74 +63,39 @@ Le binaire release se trouve dans `target/release/rpa-agent` (Linux) ou
| `RPA_CAPTURE_PORT` | `5006` | Port du serveur de capture | | `RPA_CAPTURE_PORT` | `5006` | Port du serveur de capture |
| `RPA_HEARTBEAT_INTERVAL` | `5` | Intervalle heartbeat (secondes) | | `RPA_HEARTBEAT_INTERVAL` | `5` | Intervalle heartbeat (secondes) |
| `RPA_JPEG_QUALITY` | `85` | Qualite JPEG (1-100) | | `RPA_JPEG_QUALITY` | `85` | Qualite JPEG (1-100) |
| `RPA_BLUR_SENSITIVE` | `true` | Flouter les zones sensibles |
## Execution | `RPA_LOG_RETENTION_DAYS` | `180` | Retention des logs (jours) |
| `RPA_CHAT_PORT` | `5004` | Port du serveur de chat |
```bash
# Avec les defauts (serveur local)
./target/release/rpa-agent
# Vers un serveur distant
RPA_SERVER_URL=http://192.168.1.10:5005/api/v1 ./target/release/rpa-agent
# Avec un identifiant machine specifique
RPA_MACHINE_ID=pc_bureau_windows ./target/release/rpa-agent
```
## API du serveur de capture (port 5006)
### GET /capture
Retourne un screenshot frais en JSON :
```json
{
"image": "<base64 JPEG>",
"width": 1920,
"height": 1080,
"format": "jpeg",
"source": "rust_agent",
"capture_ms": 42
}
```
### GET /health
```json
{"status": "ok", "agent": "rust", "version": "0.1.0-rust"}
```
### POST /file-action
Actions fichiers sur la machine locale :
```json
{"action": "file_list_dir", "params": {"path": "C:\\Users\\dom\\Documents"}}
{"action": "file_create_dir", "params": {"path": "C:\\Users\\dom\\Documents\\tri"}}
{"action": "file_move", "params": {"source": "...", "destination": "..."}}
{"action": "file_copy", "params": {"source": "...", "destination": "..."}}
{"action": "file_sort_by_ext", "params": {"source_dir": "C:\\Users\\dom\\Downloads"}}
```
## Architecture ## Architecture
``` ```
src/ src/
├── main.rs — Point d'entree, 3 threads (heartbeat, replay, serveur) ├── main.rs — Orchestrateur, 7 threads (heartbeat, replay, serveur, health, recorder, chat, tray)
├── config.rs — Configuration (env vars + defauts) ├── config.rs — Configuration (env vars + defauts)
├── capture.rsCapture ecran (xcap), encodage JPEG, hash perceptuel ├── 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) ├── network.rs — Client HTTP (heartbeat, poll replay, rapport resultat)
├── replay.rs — Boucle de polling replay avec backoff ├── replay.rs — Boucle de polling replay avec notifications
├── executor.rs — Execution actions (click, type, key_combo, scroll, wait) ├── executor.rs — Execution actions (click, type, key_combo, scroll, wait)
── server.rs — Mini serveur HTTP port 5006 (/capture, /health, /file-action) ── 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)
``` ```
## Compatibilite serveur ## Taille du binaire
Cet agent est compatible avec le serveur streaming existant (`agent_v0/server_v1/api_stream.py`, port 5005). | Configuration | Taille |
|---|---|
| Release (LTO + strip + opt-level z) | **2.4 Mo** |
| Python equivalent (venv + packages) | ~200 Mo |
Endpoints utilises : ## Compatibilite
- `POST /api/v1/traces/stream/image` — envoi heartbeat screenshot
- `GET /api/v1/traces/stream/replay/next` — poll action replay
- `POST /api/v1/traces/stream/replay/result` — rapport resultat replay
## Phases suivantes - **OS** : Windows 10/11 (systray, notifications, chat WebView2)
- **Fallback Linux** : mode console (heartbeat, replay, serveur)
- **Phase 2** : Systray + notifications (tray-icon, winrt-notification) - **Serveur** : compatible api_stream.py (port 5005)
- **Phase 3** : Fenetre de chat (wry/WebView2)
- **Phase 4** : Parite complete (floutage, capture evenements rdev)

340
agent_rust/src/blur.rs Normal file
View File

@@ -0,0 +1,340 @@
//! Floutage des zones sensibles dans les captures d'ecran.
//!
//! Detecte les champs de saisie (zones claires rectangulaires) et applique
//! un flou gaussien pour proteger les donnees sensibles (mots de passe, etc.).
//! Equivalent de agent_v1/vision/blur_sensitive.py.
//!
//! Algorithme :
//! 1. Conversion en niveaux de gris
//! 2. Seuillage binaire (detecter les zones claires = champs de saisie)
//! 3. Detection de contours rectangulaires > 50px de large
//! 4. Application d'un flou gaussien sur les zones detectees
//!
//! Utilise le crate image pour le traitement et imageproc pour le flou.
use image::{DynamicImage, GrayImage, Rgba, RgbaImage};
/// Seuil de luminosite pour detecter les champs de saisie (0-255).
/// Les zones plus claires que ce seuil sont considerees comme des champs.
const BRIGHTNESS_THRESHOLD: u8 = 220;
/// Largeur minimale d'un champ de saisie detecte (en pixels).
const MIN_FIELD_WIDTH: u32 = 50;
/// Hauteur minimale d'un champ de saisie detecte (en pixels).
const MIN_FIELD_HEIGHT: u32 = 15;
/// Hauteur maximale d'un champ de saisie (evite de flouter l'ecran entier).
const MAX_FIELD_HEIGHT: u32 = 80;
/// Largeur maximale d'un champ (evite les faux positifs sur grandes zones blanches).
const MAX_FIELD_WIDTH: u32 = 800;
/// Intensite du flou gaussien (sigma).
const BLUR_SIGMA: f32 = 10.0;
/// Rectangle representant une zone a flouter.
#[derive(Debug, Clone)]
pub struct BlurRegion {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
/// Detecte les champs de saisie dans une image et les floute.
///
/// Retourne l'image modifiee avec les zones sensibles floutees.
/// Si aucun champ n'est detecte, retourne l'image inchangee.
pub fn blur_sensitive_fields(img: &DynamicImage) -> DynamicImage {
let regions = detect_input_fields(img);
if regions.is_empty() {
return img.clone();
}
println!(
"[BLUR] {} zone(s) sensible(s) detectee(s) — floutage...",
regions.len()
);
let mut result = img.to_rgba8();
for region in &regions {
blur_region(&mut result, region);
}
DynamicImage::ImageRgba8(result)
}
/// Detecte les champs de saisie (zones claires rectangulaires).
///
/// Algorithme simplifie :
/// 1. Convertir en niveaux de gris
/// 2. Seuillage binaire
/// 3. Scanner les lignes horizontales pour trouver les series de pixels clairs
/// 4. Regrouper les series adjacentes en rectangles
pub fn detect_input_fields(img: &DynamicImage) -> Vec<BlurRegion> {
let gray = img.to_luma8();
let (width, height) = gray.dimensions();
let mut regions = Vec::new();
// Creer une image binaire (seuillage)
let binary = threshold_image(&gray, BRIGHTNESS_THRESHOLD);
// Scanner par bandes horizontales pour detecter les champs
// On cherche des sequences continues de pixels blancs sur plusieurs lignes
let mut y = 0;
while y < height {
// Pour chaque ligne, trouver les segments horizontaux blancs
let segments = find_white_segments(&binary, y, width);
for (seg_start, seg_end) in &segments {
let seg_width = seg_end - seg_start;
if seg_width < MIN_FIELD_WIDTH || seg_width > MAX_FIELD_WIDTH {
continue;
}
// Verifier combien de lignes consecutives partagent ce segment
let field_height = count_vertical_extent(
&binary,
*seg_start,
*seg_end,
y,
height,
);
if field_height >= MIN_FIELD_HEIGHT && field_height <= MAX_FIELD_HEIGHT {
// Verifier que cette region ne chevauche pas une region existante
let new_region = BlurRegion {
x: *seg_start,
y,
width: seg_width,
height: field_height,
};
if !overlaps_existing(&regions, &new_region) {
regions.push(new_region);
}
}
}
// Avancer de la hauteur du dernier champ detecte, ou de 1 ligne
y += 1;
}
// Deduplication : fusionner les regions tres proches
merge_close_regions(&mut regions);
regions
}
/// Applique un seuillage binaire simple.
fn threshold_image(gray: &GrayImage, threshold: u8) -> GrayImage {
let (width, height) = gray.dimensions();
let mut binary = GrayImage::new(width, height);
for y in 0..height {
for x in 0..width {
let pixel = gray.get_pixel(x, y).0[0];
if pixel >= threshold {
binary.put_pixel(x, y, image::Luma([255]));
} else {
binary.put_pixel(x, y, image::Luma([0]));
}
}
}
binary
}
/// Trouve les segments horizontaux de pixels blancs sur une ligne.
fn find_white_segments(binary: &GrayImage, y: u32, width: u32) -> Vec<(u32, u32)> {
let mut segments = Vec::new();
let mut in_segment = false;
let mut seg_start = 0u32;
for x in 0..width {
let is_white = binary.get_pixel(x, y).0[0] > 128;
if is_white && !in_segment {
seg_start = x;
in_segment = true;
} else if !is_white && in_segment {
segments.push((seg_start, x));
in_segment = false;
}
}
if in_segment {
segments.push((seg_start, width));
}
segments
}
/// Compte le nombre de lignes consecutives ou le segment est blanc.
fn count_vertical_extent(
binary: &GrayImage,
seg_start: u32,
seg_end: u32,
start_y: u32,
max_y: u32,
) -> u32 {
let mut count = 0u32;
let check_width = seg_end - seg_start;
let threshold = (check_width as f64 * 0.7) as u32; // 70% doivent etre blancs
for y in start_y..max_y.min(start_y + MAX_FIELD_HEIGHT + 5) {
let mut white_count = 0u32;
for x in seg_start..seg_end {
if binary.get_pixel(x, y).0[0] > 128 {
white_count += 1;
}
}
if white_count >= threshold {
count += 1;
} else {
break;
}
}
count
}
/// Verifie si une region chevauche une region existante.
fn overlaps_existing(regions: &[BlurRegion], new_region: &BlurRegion) -> bool {
for region in regions {
let x_overlap = new_region.x < region.x + region.width
&& new_region.x + new_region.width > region.x;
let y_overlap = new_region.y < region.y + region.height
&& new_region.y + new_region.height > region.y;
if x_overlap && y_overlap {
return true;
}
}
false
}
/// Fusionne les regions tres proches (< 10px de distance).
fn merge_close_regions(regions: &mut Vec<BlurRegion>) {
if regions.len() < 2 {
return;
}
// Tri par position (y, puis x)
regions.sort_by(|a, b| a.y.cmp(&b.y).then(a.x.cmp(&b.x)));
let mut merged = Vec::new();
let mut current = regions[0].clone();
for region in regions.iter().skip(1) {
let x_close = (current.x + current.width + 10 >= region.x)
&& (region.x + region.width + 10 >= current.x);
let y_close = (current.y + current.height + 5 >= region.y)
&& (region.y + region.height + 5 >= current.y);
if x_close && y_close {
// Fusionner
let min_x = current.x.min(region.x);
let min_y = current.y.min(region.y);
let max_x = (current.x + current.width).max(region.x + region.width);
let max_y = (current.y + current.height).max(region.y + region.height);
current = BlurRegion {
x: min_x,
y: min_y,
width: max_x - min_x,
height: max_y - min_y,
};
} else {
merged.push(current);
current = region.clone();
}
}
merged.push(current);
*regions = merged;
}
/// Applique un flou gaussien sur une region de l'image.
///
/// Implementation simplifiee : box blur avec plusieurs passes
/// (approximation du gaussien, plus rapide que le vrai gaussien).
fn blur_region(img: &mut RgbaImage, region: &BlurRegion) {
let (img_w, img_h) = img.dimensions();
// Borner la region aux dimensions de l'image
let x_start = region.x.min(img_w);
let y_start = region.y.min(img_h);
let x_end = (region.x + region.width).min(img_w);
let y_end = (region.y + region.height).min(img_h);
if x_start >= x_end || y_start >= y_end {
return;
}
let radius = BLUR_SIGMA as u32;
let kernel_size = (radius * 2 + 1) as i32;
let kernel_area = (kernel_size * kernel_size) as u32;
// Box blur : moyenne des pixels dans un carre de rayon `radius`
// On fait 3 passes pour approximer un flou gaussien
for _pass in 0..3 {
// Copier les pixels de la region dans un buffer temporaire
let reg_w = (x_end - x_start) as usize;
let reg_h = (y_end - y_start) as usize;
let mut buffer: Vec<[u8; 4]> = Vec::with_capacity(reg_w * reg_h);
for y in y_start..y_end {
for x in x_start..x_end {
buffer.push(img.get_pixel(x, y).0);
}
}
// Appliquer le box blur
for y in y_start..y_end {
for x in x_start..x_end {
let mut sum_r = 0u32;
let mut sum_g = 0u32;
let mut sum_b = 0u32;
let mut count = 0u32;
for ky in -(radius as i32)..=(radius as i32) {
for kx in -(radius as i32)..=(radius as i32) {
let sx = x as i32 + kx;
let sy = y as i32 + ky;
if sx >= x_start as i32
&& sx < x_end as i32
&& sy >= y_start as i32
&& sy < y_end as i32
{
let bx = (sx - x_start as i32) as usize;
let by = (sy - y_start as i32) as usize;
let pixel = buffer[by * reg_w + bx];
sum_r += pixel[0] as u32;
sum_g += pixel[1] as u32;
sum_b += pixel[2] as u32;
count += 1;
}
}
}
if count > 0 {
let pixel = Rgba([
(sum_r / count) as u8,
(sum_g / count) as u8,
(sum_b / count) as u8,
255,
]);
img.put_pixel(x, y, pixel);
}
}
}
}
let _ = kernel_area; // suppress unused warning
}

277
agent_rust/src/chat.rs Normal file
View File

@@ -0,0 +1,277 @@
//! Fenetre de chat WebView2 (wry).
//!
//! Ouvre une fenetre WebView2 qui charge l'interface de chat du serveur
//! (http://{server}:5004/chat). Plus simple et plus riche que l'approche
//! tkinter Python — on reutilise directement le frontend web existant.
//!
//! Equivalent de agent_v1/ui/chat_window.py (mais beaucoup plus simple).
//!
//! Sur Windows : utilise wry (crate Tauri) qui instancie Edge WebView2.
//! Sur les autres OS : pas de fenetre de chat (log en console).
use crate::config::Config;
use crate::state::AgentState;
use std::sync::Arc;
/// URL du serveur de chat (port 5004 par defaut).
fn chat_url(config: &Config) -> String {
config.chat_url()
}
/// HTML de fallback affiche quand le serveur est indisponible.
#[allow(dead_code)]
const FALLBACK_HTML: &str = r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: 'Segoe UI', Tahoma, sans-serif;
background: #1e1e2e;
color: #cdd6f4;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
.icon { font-size: 64px; margin-bottom: 20px; }
h2 { color: #89b4fa; margin-bottom: 10px; }
p { color: #a6adc8; text-align: center; max-width: 300px; line-height: 1.5; }
.retry-btn {
margin-top: 20px;
padding: 10px 24px;
background: #89b4fa;
color: #1e1e2e;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
}
.retry-btn:hover { background: #74c7ec; }
</style>
</head>
<body>
<div class="icon">&#x1F50C;</div>
<h2>Connexion au serveur requise</h2>
<p>
Le serveur de chat n'est pas accessible.
Verifiez que le serveur RPA Vision est demarre.
</p>
<button class="retry-btn" onclick="location.reload()">Reessayer</button>
<p style="margin-top: 30px; font-size: 12px; color: #585b70;">
Lea Agent v0.2.0 (Rust) - IA
</p>
</body>
</html>"#;
/// Lance la fenetre de chat dans un thread dedie.
///
/// Sur Windows : ouvre un WebView2 qui charge l'URL du chat.
/// La fenetre peut etre masquee/affichee via l'etat partage.
/// Sur les autres OS : ne fait rien.
pub fn start_chat_thread(config: Arc<Config>, state: Arc<AgentState>) {
std::thread::Builder::new()
.name("chat-window".to_string())
.spawn(move || {
chat_window_loop(&config, &state);
})
.expect("Impossible de demarrer le thread chat");
}
/// Boucle de la fenetre de chat (Windows).
///
/// Attend que l'etat chat_visible passe a true, puis ouvre la fenetre.
/// Quand la fenetre est fermee, remet chat_visible a false.
#[cfg(windows)]
fn chat_window_loop(config: &Config, state: &AgentState) {
println!("[CHAT] Thread chat demarre — en attente d'activation");
loop {
// Attendre que le chat soit demande
while !state.chat_visible.load(std::sync::atomic::Ordering::SeqCst) {
if !state.is_running() {
println!("[CHAT] Arret du thread chat");
return;
}
std::thread::sleep(std::time::Duration::from_millis(200));
}
println!("[CHAT] Ouverture de la fenetre de chat...");
let url = chat_url(config);
println!("[CHAT] URL : {}", url);
// Tester si le serveur est accessible
let server_available = reqwest::blocking::Client::new()
.get(&url)
.timeout(std::time::Duration::from_secs(3))
.send()
.map(|r| r.status().is_success() || r.status().is_redirection())
.unwrap_or(false);
// Ouvrir le WebView2 dans une fenetre dediee
// On utilise un EventLoop winit separe pour la fenetre de chat
match open_chat_window(&url, server_available) {
Ok(_) => {
println!("[CHAT] Fenetre de chat fermee");
}
Err(e) => {
eprintln!("[CHAT] Erreur ouverture fenetre : {}", e);
}
}
// La fenetre a ete fermee, desactiver le flag
state
.chat_visible
.store(false, std::sync::atomic::Ordering::SeqCst);
// Petit delai avant de pouvoir reouvrir
std::thread::sleep(std::time::Duration::from_millis(500));
}
}
/// Ouvre la fenetre de chat avec wry WebView2.
///
/// Cree une fenetre native via la Win32 API et y attache un WebView2.
/// La fenetre fait 520x720 et est positionnee en bas a droite de l'ecran.
///
/// Note: wry 0.48 attend un objet implementant HasWindowHandle.
/// On utilise un wrapper HWND pour satisfaire ce trait.
#[cfg(windows)]
fn open_chat_window(url: &str, server_available: bool) -> Result<(), String> {
use wry::WebViewBuilder;
use raw_window_handle::{RawWindowHandle, WindowHandle, Win32WindowHandle};
use windows_sys::Win32::UI::WindowsAndMessaging::*;
use windows_sys::Win32::System::LibraryLoader::GetModuleHandleW;
// Obtenir les dimensions de l'ecran
let (screen_w, screen_h) = unsafe {
(GetSystemMetrics(SM_CXSCREEN), GetSystemMetrics(SM_CYSCREEN))
};
let win_w = 520;
let win_h = 720;
let win_x = screen_w - win_w - 20;
let win_y = screen_h - win_h - 60;
// Creer la classe de fenetre
let class_name: Vec<u16> = "LeaChatWindow\0".encode_utf16().collect();
let window_title: Vec<u16> = "Lea - Chat IA\0".encode_utf16().collect();
unsafe {
let h_instance = GetModuleHandleW(std::ptr::null());
let wc = WNDCLASSW {
style: 0,
lpfnWndProc: Some(chat_wnd_proc),
cbClsExtra: 0,
cbWndExtra: 0,
hInstance: h_instance,
hIcon: std::ptr::null_mut(),
hCursor: LoadCursorW(std::ptr::null_mut(), IDC_ARROW),
hbrBackground: 6 as _, // COLOR_WINDOW + 1
lpszMenuName: std::ptr::null(),
lpszClassName: class_name.as_ptr(),
};
RegisterClassW(&wc);
let hwnd = CreateWindowExW(
WS_EX_TOOLWINDOW,
class_name.as_ptr(),
window_title.as_ptr(),
WS_OVERLAPPEDWINDOW | WS_VISIBLE,
win_x,
win_y,
win_w,
win_h,
std::ptr::null_mut(),
std::ptr::null_mut(),
h_instance,
std::ptr::null(),
);
if hwnd.is_null() {
return Err("Impossible de creer la fenetre de chat".to_string());
}
// Creer un wrapper HasWindowHandle pour le HWND
let mut win32_handle = Win32WindowHandle::new(
std::num::NonZero::new(hwnd as isize)
.ok_or("HWND invalide")?,
);
win32_handle.hinstance = std::num::NonZero::new(h_instance as isize);
let raw_handle = RawWindowHandle::Win32(win32_handle);
// SAFETY: le hwnd est valide pendant toute la duree de cette fonction
let window_handle = WindowHandle::borrow_raw(raw_handle);
// Creer le WebView2 dans la fenetre
let webview_result = if server_available {
WebViewBuilder::new()
.with_url(url)
.build_as_child(&window_handle)
} else {
WebViewBuilder::new()
.with_html(FALLBACK_HTML)
.build_as_child(&window_handle)
};
match webview_result {
Ok(_webview) => {
ShowWindow(hwnd, SW_SHOW);
// Boucle de messages Windows
let mut msg: MSG = std::mem::zeroed();
while GetMessageW(&mut msg, std::ptr::null_mut(), 0, 0) > 0 {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
Ok(())
}
Err(e) => {
DestroyWindow(hwnd);
Err(format!("Erreur creation WebView2 : {}", e))
}
}
}
}
/// Procedure de fenetre Win32 pour la fenetre de chat.
#[cfg(windows)]
unsafe extern "system" fn chat_wnd_proc(
hwnd: windows_sys::Win32::Foundation::HWND,
msg: u32,
wparam: windows_sys::Win32::Foundation::WPARAM,
lparam: windows_sys::Win32::Foundation::LPARAM,
) -> windows_sys::Win32::Foundation::LRESULT {
use windows_sys::Win32::UI::WindowsAndMessaging::*;
match msg {
WM_CLOSE => {
ShowWindow(hwnd, SW_HIDE);
PostQuitMessage(0);
0
}
WM_DESTROY => {
PostQuitMessage(0);
0
}
_ => DefWindowProcW(hwnd, msg, wparam, lparam),
}
}
/// Version non-Windows : pas de fenetre de chat.
#[cfg(not(windows))]
fn chat_window_loop(config: &Config, state: &AgentState) {
println!("[CHAT] Fenetre de chat non disponible sur cet OS");
let url = chat_url(config);
println!("[CHAT] Pour acceder au chat, ouvrez : {}", url);
while state.is_running() {
std::thread::sleep(std::time::Duration::from_millis(1000));
}
}

View File

@@ -1,23 +1,23 @@
//! Configuration de l'agent RPA. //! Configuration de l'agent RPA.
//! //!
//! Paramètres chargés depuis les variables d'environnement ou valeurs par défaut. //! Parametres charges depuis les variables d'environnement ou valeurs par defaut.
//! Compatible avec la configuration Python (agent_v1/config.py). //! Compatible avec la configuration Python (agent_v1/config.py).
use std::env; use std::env;
/// Version de l'agent Rust /// Version de l'agent Rust
pub const AGENT_VERSION: &str = "0.1.0-rust"; pub const AGENT_VERSION: &str = "0.2.0-rust";
/// Configuration complète de l'agent /// Configuration complete de l'agent
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
/// URL de base du serveur streaming (ex: http://192.168.1.10:5005/api/v1) /// URL de base du serveur streaming (ex: http://192.168.1.10:5005/api/v1)
pub server_url: String, pub server_url: String,
/// Identifiant unique de la machine (hostname_os par défaut) /// Identifiant unique de la machine (hostname_os par defaut)
pub machine_id: String, pub machine_id: String,
/// Port du mini-serveur HTTP de capture (défaut: 5006) /// Port du mini-serveur HTTP de capture (defaut: 5006)
pub capture_port: u16, pub capture_port: u16,
/// Intervalle du heartbeat en secondes /// Intervalle du heartbeat en secondes
@@ -26,19 +26,31 @@ pub struct Config {
/// Intervalle de polling replay en secondes /// Intervalle de polling replay en secondes
pub replay_poll_interval_s: f64, pub replay_poll_interval_s: f64,
/// Qualité JPEG pour les screenshots envoyés (1-100) /// Qualite JPEG pour les screenshots envoyes (1-100)
pub jpeg_quality: u8, 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,
} }
impl Config { impl Config {
/// Charge la configuration depuis les variables d'environnement. /// Charge la configuration depuis les variables d'environnement.
/// ///
/// Variables supportées : /// Variables supportees :
/// - `RPA_SERVER_URL` : URL du serveur (défaut: http://localhost:5005/api/v1) /// - `RPA_SERVER_URL` : URL du serveur (defaut: http://localhost:5005/api/v1)
/// - `RPA_MACHINE_ID` : Identifiant machine (défaut: hostname_os) /// - `RPA_MACHINE_ID` : Identifiant machine (defaut: hostname_os)
/// - `RPA_CAPTURE_PORT` : Port du serveur de capture (défaut: 5006) /// - `RPA_CAPTURE_PORT` : Port du serveur de capture (defaut: 5006)
/// - `RPA_HEARTBEAT_INTERVAL` : Intervalle heartbeat en secondes (défaut: 5) /// - `RPA_HEARTBEAT_INTERVAL` : Intervalle heartbeat en secondes (defaut: 5)
/// - `RPA_JPEG_QUALITY` : Qualité JPEG (défaut: 85) /// - `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)
pub fn from_env() -> Self { pub fn from_env() -> Self {
let machine_id = env::var("RPA_MACHINE_ID").unwrap_or_else(|_| { let machine_id = env::var("RPA_MACHINE_ID").unwrap_or_else(|_| {
let host = hostname::get() let host = hostname::get()
@@ -72,6 +84,20 @@ impl Config {
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(85); .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);
Config { Config {
server_url, server_url,
machine_id, machine_id,
@@ -79,6 +105,9 @@ impl Config {
heartbeat_interval_s, heartbeat_interval_s,
replay_poll_interval_s: 1.0, replay_poll_interval_s: 1.0,
jpeg_quality, jpeg_quality,
blur_sensitive,
log_retention_days,
chat_port,
} }
} }
@@ -96,15 +125,36 @@ impl Config {
pub fn agent_session_id(&self) -> String { pub fn agent_session_id(&self) -> String {
format!("agent_{}", self.machine_id) 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://{}:{}/chat?machine_id={}",
host, self.chat_port, self.machine_id
);
}
}
format!(
"http://localhost:{}/chat?machine_id={}",
self.chat_port, self.machine_id
)
}
} }
impl std::fmt::Display for Config { impl std::fmt::Display for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!( write!(
f, f,
"Config {{ server: {}, machine: {}, capture_port: {}, heartbeat: {}s, jpeg_q: {} }}", "Config {{ server: {}, machine: {}, capture_port: {}, heartbeat: {}s, jpeg_q: {}, blur: {}, log_retention: {}j, chat_port: {} }}",
self.server_url, self.machine_id, self.capture_port, self.server_url, self.machine_id, self.capture_port,
self.heartbeat_interval_s, self.jpeg_quality self.heartbeat_interval_s, self.jpeg_quality,
self.blur_sensitive, self.log_retention_days, self.chat_port,
) )
} }
} }

View File

@@ -1,41 +1,64 @@
//! Agent RPA Vision — Phase 1 (headless) //! Agent RPA Vision — Phases 1-5 (parite complete)
//! //!
//! Point d'entree principal. Demarre 3 threads : //! Point d'entree principal. Architecture multi-threads :
//! 1. Heartbeat loop : capture + envoi toutes les 5s (avec dedup par hash) //!
//! 2. Replay poll loop : poll toutes les 1s, execute les actions //! - Thread principal : boucle d'evenements systray (Windows) ou attente console (Linux)
//! 3. Capture HTTP server : port 5006 pour les captures a la demande //! - 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. //! Configuration via variables d'environnement ou valeurs par defaut.
//! Compatible avec le serveur streaming existant (api_stream.py, port 5005). //! Compatible avec le serveur streaming existant (api_stream.py, port 5005).
#[allow(dead_code)]
mod blur;
mod capture; mod capture;
mod chat;
mod config; mod config;
mod executor; mod executor;
mod network; mod network;
#[allow(dead_code)]
mod notifications;
mod recorder;
mod replay; mod replay;
mod server; mod server;
#[allow(dead_code)]
mod state;
mod tray;
mod visual; mod visual;
use config::Config; use config::Config;
use reqwest::blocking::Client; use reqwest::blocking::Client;
use std::sync::atomic::{AtomicBool, Ordering}; use state::AgentState;
use std::sync::Arc;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
/// Flag global pour l'arret propre (Ctrl+C)
static RUNNING: AtomicBool = AtomicBool::new(true);
fn main() { fn main() {
// 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 = Config::from_env();
let config = Arc::new(config);
// Etat partage thread-safe
let state = AgentState::new();
// Banniere de demarrage // Banniere de demarrage
print_banner(&config); print_banner(&config);
// Handler Ctrl+C pour arret propre // Handler Ctrl+C pour arret propre
// On utilise le flag global RUNNING (AtomicBool) — le handler SIGINT install_ctrlc_handler(state.clone());
// est installe via un thread qui bloque sur un pipe/signal.
// Approche simple : polling du flag depuis tous les threads.
install_ctrlc_handler();
// Verifier que la capture d'ecran fonctionne // Verifier que la capture d'ecran fonctionne
print!("[MAIN] Test de capture d'ecran... "); print!("[MAIN] Test de capture d'ecran... ");
@@ -50,19 +73,21 @@ fn main() {
// Thread 1 : Heartbeat loop // Thread 1 : Heartbeat loop
let hb_config = config.clone(); let hb_config = config.clone();
let heartbeat_thread = thread::Builder::new() let hb_state = state.clone();
let _heartbeat_thread = thread::Builder::new()
.name("heartbeat".to_string()) .name("heartbeat".to_string())
.spawn(move || { .spawn(move || {
heartbeat_loop(&hb_config); heartbeat_loop(&hb_config, &hb_state);
}) })
.expect("Impossible de demarrer le thread heartbeat"); .expect("Impossible de demarrer le thread heartbeat");
// Thread 2 : Replay poll loop // Thread 2 : Replay poll loop
let rp_config = config.clone(); let rp_config = config.clone();
let rp_state = state.clone();
let _replay_thread = thread::Builder::new() let _replay_thread = thread::Builder::new()
.name("replay".to_string()) .name("replay".to_string())
.spawn(move || { .spawn(move || {
replay::replay_poll_loop(&rp_config); replay::replay_poll_loop(&rp_config, &rp_state);
}) })
.expect("Impossible de demarrer le thread replay"); .expect("Impossible de demarrer le thread replay");
@@ -75,31 +100,46 @@ fn main() {
}) })
.expect("Impossible de demarrer le thread serveur"); .expect("Impossible de demarrer le thread serveur");
println!("\n[MAIN] Agent operationnel. Appuyez sur Ctrl+C pour quitter.\n"); // 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");
// Bloquer le thread principal en attendant Ctrl+C // Thread 5 : Recorder (capture evenements — inactif jusqu'a enregistrement)
while RUNNING.load(Ordering::SeqCst) { let rec_config = config.clone();
thread::sleep(Duration::from_millis(500)); 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::start_chat_thread(chat_config, chat_state);
println!("\n[MAIN] Agent operationnel — tous les threads demarres.\n");
// Thread principal : boucle systray (Windows) ou attente console (Linux)
// Le systray bloque le thread principal (necessaire pour la message pump Windows)
tray::run_tray_loop(config.clone(), state.clone());
// Si on arrive ici, l'agent doit s'arreter
println!("\n[MAIN] Arret en cours..."); println!("\n[MAIN] Arret en cours...");
state.request_shutdown();
// Attendre le thread heartbeat (les autres sont daemon-like) // Laisser le temps aux threads de se terminer
let _ = heartbeat_thread.join(); thread::sleep(Duration::from_millis(500));
println!("[MAIN] Agent arrete."); println!("[MAIN] Agent arrete.");
} }
/// Installe un handler Ctrl+C qui met RUNNING a false. /// Installe un handler Ctrl+C qui met l'etat a "arret demande".
/// fn install_ctrlc_handler(state: Arc<AgentState>) {
/// Sur Unix : intercepte SIGINT via un pipe auto-referent.
/// Sur Windows : sera ameliore en Phase 2 avec le crate windows.
fn install_ctrlc_handler() {
// Approche portable : un thread qui attend sur stdin/signal
// En pratique, on utilise un pipe trick simple
#[cfg(unix)] #[cfg(unix)]
{ {
// Creer un pipe pour la notification
let mut fds = [0i32; 2]; let mut fds = [0i32; 2];
unsafe { unsafe {
if libc::pipe(fds.as_mut_ptr()) != 0 { if libc::pipe(fds.as_mut_ptr()) != 0 {
@@ -107,13 +147,21 @@ fn install_ctrlc_handler() {
return; return;
} }
// Installer le signal handler qui ecrit dans le pipe
static mut WRITE_FD: i32 = -1; static mut WRITE_FD: i32 = -1;
WRITE_FD = fds[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) { extern "C" fn sigint_handler(_sig: i32) {
unsafe { unsafe {
RUNNING.store(false, Ordering::SeqCst); if !STATE_PTR.is_null() {
(*STATE_PTR)
.running
.store(false, std::sync::atomic::Ordering::SeqCst);
}
let buf = [1u8]; let buf = [1u8];
let _ = libc::write(WRITE_FD, buf.as_ptr() as *const _, 1); let _ = libc::write(WRITE_FD, buf.as_ptr() as *const _, 1);
} }
@@ -125,15 +173,16 @@ fn install_ctrlc_handler() {
#[cfg(not(unix))] #[cfg(not(unix))]
{ {
// Sur Windows, on utilise un thread simple qui verifie periodiquement // Sur Windows, le systray gere l'arret via le menu "Quitter"
// Le vrai handler sera SetConsoleCtrlHandler en Phase 2 // Le handler console est un bonus pour le mode headless
// Pour l'instant, Ctrl+C termine le process directement (comportement par defaut) let _ = state;
} }
} }
/// Boucle de heartbeat : capture un screenshot toutes les N secondes /// Boucle de heartbeat : capture un screenshot toutes les N secondes
/// et l'envoie au serveur si l'ecran a change. /// et l'envoie au serveur si l'ecran a change.
fn heartbeat_loop(config: &Config) { /// Applique le floutage des zones sensibles si active dans la config.
fn heartbeat_loop(config: &Config, state: &AgentState) {
let client = Client::new(); let client = Client::new();
let session_id = config.bg_session_id(); let session_id = config.bg_session_id();
let mut last_hash: u64 = 0; let mut last_hash: u64 = 0;
@@ -144,34 +193,50 @@ fn heartbeat_loop(config: &Config) {
session_id, config.heartbeat_interval_s session_id, config.heartbeat_interval_s
); );
while RUNNING.load(Ordering::SeqCst) { 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 // Capturer l'ecran
match capture::capture_screenshot() { match capture::capture_screenshot() {
Some(img) => { Some(img) => {
// Deduplication par hash perceptuel // Deduplication par hash perceptuel
let current_hash = capture::image_hash(&img); let current_hash = capture::image_hash(&img);
if current_hash == last_hash { if current_hash == last_hash {
// Ecran identique, on skip l'envoi
thread::sleep(Duration::from_secs(config.heartbeat_interval_s)); thread::sleep(Duration::from_secs(config.heartbeat_interval_s));
continue; continue;
} }
last_hash = current_hash; 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 // Encoder en JPEG
let jpeg_bytes = capture::screenshot_to_jpeg_bytes(&img, config.jpeg_quality); let jpeg_bytes =
capture::screenshot_to_jpeg_bytes(&final_img, config.jpeg_quality);
if jpeg_bytes.is_empty() { if jpeg_bytes.is_empty() {
thread::sleep(Duration::from_secs(config.heartbeat_interval_s)); thread::sleep(Duration::from_secs(config.heartbeat_interval_s));
continue; continue;
} }
// Envoyer au serveur // Envoyer au serveur
let success = network::send_heartbeat(&client, config, &jpeg_bytes, &session_id); let success =
network::send_heartbeat(&client, config, &jpeg_bytes, &session_id);
if success { if success {
consecutive_errors = 0; consecutive_errors = 0;
} else { } else {
consecutive_errors += 1; consecutive_errors += 1;
if consecutive_errors == 1 || consecutive_errors % 12 == 0 { if consecutive_errors == 1 || consecutive_errors % 12 == 0 {
// Log seulement la premiere erreur et toutes les minutes
eprintln!( eprintln!(
"[HEARTBEAT] {} erreur(s) consecutives", "[HEARTBEAT] {} erreur(s) consecutives",
consecutive_errors consecutive_errors
@@ -180,8 +245,6 @@ fn heartbeat_loop(config: &Config) {
} }
} }
None => { None => {
// Pas de capture possible (pas de display, etc.)
// On attend plus longtemps pour ne pas spammer les logs
thread::sleep(Duration::from_secs(config.heartbeat_interval_s * 2)); thread::sleep(Duration::from_secs(config.heartbeat_interval_s * 2));
continue; continue;
} }
@@ -193,16 +256,58 @@ fn heartbeat_loop(config: &Config) {
println!("[HEARTBEAT] Boucle arretee."); 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 connected = client
.get(&url)
.timeout(timeout)
.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. /// Affiche la banniere de demarrage.
fn print_banner(config: &Config) { fn print_banner(config: &Config) {
println!("======================================================"); println!("======================================================");
println!(" RPA Vision Agent v{} (Rust)", config::AGENT_VERSION); println!(
println!(" Phase 1 -- Headless"); " RPA Vision Agent v{} (Rust)",
config::AGENT_VERSION
);
println!(" Phases 1-5 — Parite complete");
println!("------------------------------------------------------"); println!("------------------------------------------------------");
println!(" Machine : {}", config.machine_id); println!(" Machine : {}", config.machine_id);
println!(" Serveur : {}", config.server_url); println!(" Serveur : {}", config.server_url);
println!(" Capture : port {}", config.capture_port); println!(" Capture : port {}", config.capture_port);
println!(" Chat : port {}", config.chat_port);
println!(" Heartbeat : toutes les {}s", config.heartbeat_interval_s); println!(" Heartbeat : toutes les {}s", config.heartbeat_interval_s);
println!(" JPEG : qualite {}", config.jpeg_quality); println!(" JPEG : qualite {}", config.jpeg_quality);
println!(" Floutage : {}", if config.blur_sensitive { "actif" } else { "inactif" });
println!(" Logs : retention {} jours", config.log_retention_days);
println!("======================================================"); println!("======================================================");
println!();
println!(" [IA] Cet agent utilise l'intelligence artificielle.");
println!(" Article 50 du Reglement europeen sur l'IA.");
println!();
} }

View File

@@ -0,0 +1,135 @@
//! 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.",
);
}

703
agent_rust/src/recorder.rs Normal file
View File

@@ -0,0 +1,703 @@
//! 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.
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 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,
})
}
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,
})
}
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,
})
}
CapturedEvent::KeyCombo { keys } => {
serde_json::json!({
"type": "key_combo",
"keys": keys,
"session_name": session_name,
"machine_id": config.machine_id,
"timestamp": timestamp,
})
}
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,
})
}
};
// Envoi non-bloquant (on ne veut pas ralentir la capture)
match client
.post(&url)
.json(&payload)
.timeout(Duration::from_secs(5))
.send()
{
Ok(resp) => {
if !resp.status().is_success() {
eprintln!(
"[RECORDER] Envoi evenement echoue : HTTP {}",
resp.status()
);
}
}
Err(e) => {
eprintln!("[RECORDER] Erreur reseau : {}", e);
}
}
// Capturer un screenshot pour les clics (dual: full + crop)
if matches!(
event,
CapturedEvent::Click { .. } | CapturedEvent::DoubleClick { .. }
) {
if let Some(img) = capture::capture_screenshot() {
let jpeg = capture::screenshot_to_jpeg_bytes(&img, 80);
if !jpeg.is_empty() {
let shot_id = format!("rec_{}", chrono::Utc::now().timestamp_millis());
let _ = crate::network::send_heartbeat(
&reqwest::blocking::Client::new(),
&crate::config::Config::from_env(),
&jpeg,
session_name,
);
let _ = shot_id; // utilise implicitement via send_heartbeat
}
}
}
}
// --- Fonctions utilitaires ---
/// Obtient la position actuelle du curseur souris.
fn get_cursor_position() -> (f64, f64) {
#[cfg(windows)]
{
use windows_sys::Win32::UI::WindowsAndMessaging::GetCursorPos;
use windows_sys::Win32::Foundation::POINT;
unsafe {
let mut point: POINT = std::mem::zeroed();
if GetCursorPos(&mut point) != 0 {
return (point.x as f64, point.y as f64);
}
}
}
// Fallback : position inconnue
(0.0, 0.0)
}
/// Obtient le titre de la fenetre active.
fn get_active_window_title() -> String {
#[cfg(windows)]
{
use windows_sys::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetWindowTextW,
};
unsafe {
let hwnd = GetForegroundWindow();
if !hwnd.is_null() {
let mut buf = [0u16; 256];
let len = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
if len > 0 {
return String::from_utf16_lossy(&buf[..len as usize]);
}
}
}
}
"Inconnu".to_string()
}
/// Convertit une touche rdev en caractere texte (pour le buffer de saisie).
/// Retourne None pour les touches speciales (Enter, Tab, etc.).
fn rdev_key_to_char(key: rdev::Key) -> Option<char> {
match key {
rdev::Key::KeyA => Some('a'),
rdev::Key::KeyB => Some('b'),
rdev::Key::KeyC => Some('c'),
rdev::Key::KeyD => Some('d'),
rdev::Key::KeyE => Some('e'),
rdev::Key::KeyF => Some('f'),
rdev::Key::KeyG => Some('g'),
rdev::Key::KeyH => Some('h'),
rdev::Key::KeyI => Some('i'),
rdev::Key::KeyJ => Some('j'),
rdev::Key::KeyK => Some('k'),
rdev::Key::KeyL => Some('l'),
rdev::Key::KeyM => Some('m'),
rdev::Key::KeyN => Some('n'),
rdev::Key::KeyO => Some('o'),
rdev::Key::KeyP => Some('p'),
rdev::Key::KeyQ => Some('q'),
rdev::Key::KeyR => Some('r'),
rdev::Key::KeyS => Some('s'),
rdev::Key::KeyT => Some('t'),
rdev::Key::KeyU => Some('u'),
rdev::Key::KeyV => Some('v'),
rdev::Key::KeyW => Some('w'),
rdev::Key::KeyX => Some('x'),
rdev::Key::KeyY => Some('y'),
rdev::Key::KeyZ => Some('z'),
rdev::Key::Num0 => Some('0'),
rdev::Key::Num1 => Some('1'),
rdev::Key::Num2 => Some('2'),
rdev::Key::Num3 => Some('3'),
rdev::Key::Num4 => Some('4'),
rdev::Key::Num5 => Some('5'),
rdev::Key::Num6 => Some('6'),
rdev::Key::Num7 => Some('7'),
rdev::Key::Num8 => Some('8'),
rdev::Key::Num9 => Some('9'),
rdev::Key::Space => Some(' '),
rdev::Key::Minus => Some('-'),
rdev::Key::Equal => Some('='),
rdev::Key::LeftBracket => Some('['),
rdev::Key::RightBracket => Some(']'),
rdev::Key::SemiColon => Some(';'),
rdev::Key::Quote => Some('\''),
rdev::Key::Comma => Some(','),
rdev::Key::Dot => Some('.'),
rdev::Key::Slash => Some('/'),
rdev::Key::BackSlash => Some('\\'),
_ => None,
}
}
/// Convertit une touche rdev en nom de touche (pour les combos).
fn rdev_key_to_string(key: rdev::Key) -> String {
match key {
rdev::Key::Return => "enter".to_string(),
rdev::Key::Tab => "tab".to_string(),
rdev::Key::Escape => "escape".to_string(),
rdev::Key::Backspace => "backspace".to_string(),
rdev::Key::Delete => "delete".to_string(),
rdev::Key::Space => "space".to_string(),
rdev::Key::UpArrow => "up".to_string(),
rdev::Key::DownArrow => "down".to_string(),
rdev::Key::LeftArrow => "left".to_string(),
rdev::Key::RightArrow => "right".to_string(),
rdev::Key::Home => "home".to_string(),
rdev::Key::End => "end".to_string(),
rdev::Key::PageUp => "page_up".to_string(),
rdev::Key::PageDown => "page_down".to_string(),
rdev::Key::F1 => "f1".to_string(),
rdev::Key::F2 => "f2".to_string(),
rdev::Key::F3 => "f3".to_string(),
rdev::Key::F4 => "f4".to_string(),
rdev::Key::F5 => "f5".to_string(),
rdev::Key::F6 => "f6".to_string(),
rdev::Key::F7 => "f7".to_string(),
rdev::Key::F8 => "f8".to_string(),
rdev::Key::F9 => "f9".to_string(),
rdev::Key::F10 => "f10".to_string(),
rdev::Key::F11 => "f11".to_string(),
rdev::Key::F12 => "f12".to_string(),
rdev::Key::CapsLock => "caps_lock".to_string(),
rdev::Key::Insert => "insert".to_string(),
rdev::Key::PrintScreen => "print_screen".to_string(),
// Pour les lettres et chiffres, reutiliser rdev_key_to_char
other => {
if let Some(c) = rdev_key_to_char(other) {
c.to_string()
} else {
format!("{:?}", other).to_lowercase()
}
}
}
}

View File

@@ -1,8 +1,8 @@
//! Boucle de polling replay. //! Boucle de polling replay.
//! //!
//! Poll le serveur toutes les secondes pour récupérer les actions à exécuter. //! Poll le serveur toutes les secondes pour recuperer les actions a executer.
//! Quand une action est reçue, l'exécute via executor et rapporte le résultat. //! Quand une action est recue, l'execute via executor et rapporte le resultat.
//! Gère le backoff exponentiel en cas d'indisponibilité du serveur. //! Gere le backoff exponentiel en cas d'indisponibilite du serveur.
//! //!
//! Reproduit le comportement de _replay_poll_loop dans agent_v1/main.py. //! Reproduit le comportement de _replay_poll_loop dans agent_v1/main.py.
@@ -10,35 +10,50 @@ use crate::capture;
use crate::config::Config; use crate::config::Config;
use crate::executor; use crate::executor;
use crate::network; use crate::network;
use crate::notifications;
use crate::state::AgentState;
use reqwest::blocking::Client; use reqwest::blocking::Client;
use std::thread; use std::thread;
use std::time::Duration; use std::time::Duration;
/// Boucle de polling replay (tourne dans un thread dédié). /// Boucle de polling replay (tourne dans un thread dedie).
/// ///
/// - Poll GET /replay/next toutes les secondes /// - Poll GET /replay/next toutes les secondes
/// - Exécute l'action via executor /// - Execute l'action via executor
/// - Capture un screenshot post-action /// - Capture un screenshot post-action
/// - Rapporte le résultat via POST /replay/result /// - Rapporte le resultat via POST /replay/result
/// - Backoff exponentiel si le serveur est indisponible /// - Backoff exponentiel si le serveur est indisponible
pub fn replay_poll_loop(config: &Config) { pub fn replay_poll_loop(config: &Config, state: &AgentState) {
let client = Client::new(); let client = Client::new();
let mut poll_count: u64 = 0; let mut poll_count: u64 = 0;
let mut backoff = config.replay_poll_interval_s; let backoff = config.replay_poll_interval_s;
let backoff_max = 30.0_f64; let _backoff_max = 30.0_f64;
let backoff_factor = 1.5_f64; let _backoff_factor = 1.5_f64;
let mut replay_active = false; let mut replay_active = false;
let mut last_conn_error_logged = false;
println!( println!(
"[REPLAY] Boucle replay demarree — poll toutes les {:.0}s sur {}", "[REPLAY] Boucle replay demarree — poll toutes les {:.0}s sur {}",
config.replay_poll_interval_s, config.server_url config.replay_poll_interval_s, config.server_url
); );
loop { 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; poll_count += 1;
// Log périodique toutes les 60s pour confirmer que la boucle tourne // Log periodique toutes les 60s pour confirmer que la boucle tourne
let polls_per_minute = (60.0 / backoff).ceil() as u64; let polls_per_minute = (60.0 / backoff).ceil() as u64;
if polls_per_minute > 0 && poll_count % polls_per_minute == 0 { if polls_per_minute > 0 && poll_count % polls_per_minute == 0 {
println!( println!(
@@ -51,12 +66,10 @@ pub fn replay_poll_loop(config: &Config) {
match network::poll_next_action(&client, config) { match network::poll_next_action(&client, config) {
Some(action) => { Some(action) => {
// Reset backoff et flag d'erreur
backoff = config.replay_poll_interval_s;
last_conn_error_logged = false;
if !replay_active { if !replay_active {
replay_active = true; replay_active = true;
state.set_replay_active(true);
notifications::replay_started("workflow");
println!("[REPLAY] Replay demarre"); println!("[REPLAY] Replay demarre");
} }
@@ -67,10 +80,10 @@ pub fn replay_poll_loop(config: &Config) {
action_type, action_id action_type, action_id
); );
// Obtenir les dimensions de l'écran // Obtenir les dimensions de l'ecran
let (sw, sh) = capture::screen_dimensions().unwrap_or((1920, 1080)); let (sw, sh) = capture::screen_dimensions().unwrap_or((1920, 1080));
// Exécuter l'action (avec config pour la résolution visuelle) // Executer l'action (avec config pour la resolution visuelle)
println!(">>> Execution de l'action {}...", action_type); println!(">>> Execution de l'action {}...", action_type);
let mut result = executor::execute_action(&action, sw, sh, config); let mut result = executor::execute_action(&action, sw, sh, config);
println!( println!(
@@ -78,7 +91,7 @@ pub fn replay_poll_loop(config: &Config) {
result.success, result.error result.success, result.error
); );
// Capture screenshot post-action (après 500ms) // Capture screenshot post-action (apres 500ms)
thread::sleep(Duration::from_millis(500)); thread::sleep(Duration::from_millis(500));
if let Some(img) = capture::capture_screenshot() { if let Some(img) = capture::capture_screenshot() {
let b64 = capture::screenshot_to_jpeg_base64(&img, 60); let b64 = capture::screenshot_to_jpeg_base64(&img, 60);
@@ -87,35 +100,26 @@ pub fn replay_poll_loop(config: &Config) {
} }
} }
// Rapporter le résultat au serveur (TOUJOURS, même en erreur) // Rapporter le resultat au serveur (TOUJOURS, meme en erreur)
network::report_result(&client, config, &result); network::report_result(&client, config, &result);
// Poll plus rapidement pour enchaîner les actions // Poll plus rapidement pour enchainer les actions
thread::sleep(Duration::from_millis(200)); thread::sleep(Duration::from_millis(200));
continue; continue;
} }
None => { None => {
// Pas d'action — soit pas de replay, soit serveur indisponible
if replay_active { if replay_active {
println!("[REPLAY] Replay termine — retour en mode capture"); println!("[REPLAY] Replay termine — retour en mode capture");
replay_active = false; replay_active = false;
state.set_replay_active(false);
notifications::replay_finished(true);
} }
// Vérifier si c'est un timeout/erreur réseau (backoff)
// Le poll_next_action retourne None aussi si le serveur refuse
// On ne peut pas distinguer facilement, donc on garde le backoff simple
} }
} }
// Si on a eu des erreurs récentes, le backoff est > 1s
let sleep_duration = Duration::from_secs_f64(backoff); let sleep_duration = Duration::from_secs_f64(backoff);
thread::sleep(sleep_duration); thread::sleep(sleep_duration);
}
// Note: le backoff augmente seulement quand poll_next_action renvoie None println!("[REPLAY] Boucle arretee.");
// et qu'on suspecte une erreur réseau. Pour l'instant, on garde le poll
// à intervalles constants (1s). Le backoff sera implémenté plus finement
// quand on aura un meilleur signal d'erreur réseau.
let _ = (backoff_max, backoff_factor, &mut last_conn_error_logged);
}
} }

175
agent_rust/src/state.rs Normal file
View File

@@ -0,0 +1,175 @@
//! 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()),
}
}
}

336
agent_rust/src/tray.rs Normal file
View File

@@ -0,0 +1,336 @@
//! 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);
}