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

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.
//!
//! 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).
use std::env;
/// 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)]
pub struct Config {
/// URL de base du serveur streaming (ex: http://192.168.1.10:5005/api/v1)
pub server_url: String,
/// Identifiant unique de la machine (hostname_os par défaut)
/// Identifiant unique de la machine (hostname_os par defaut)
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,
/// Intervalle du heartbeat en secondes
@@ -26,19 +26,31 @@ pub struct Config {
/// Intervalle de polling replay en secondes
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,
/// 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 {
/// Charge la configuration depuis les variables d'environnement.
///
/// Variables supportées :
/// - `RPA_SERVER_URL` : URL du serveur (défaut: http://localhost:5005/api/v1)
/// - `RPA_MACHINE_ID` : Identifiant machine (défaut: hostname_os)
/// - `RPA_CAPTURE_PORT` : Port du serveur de capture (défaut: 5006)
/// - `RPA_HEARTBEAT_INTERVAL` : Intervalle heartbeat en secondes (défaut: 5)
/// - `RPA_JPEG_QUALITY` : Qualité JPEG (défaut: 85)
/// 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)
pub fn from_env() -> Self {
let machine_id = env::var("RPA_MACHINE_ID").unwrap_or_else(|_| {
let host = hostname::get()
@@ -72,6 +84,20 @@ impl Config {
.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);
Config {
server_url,
machine_id,
@@ -79,6 +105,9 @@ impl Config {
heartbeat_interval_s,
replay_poll_interval_s: 1.0,
jpeg_quality,
blur_sensitive,
log_retention_days,
chat_port,
}
}
@@ -96,15 +125,36 @@ impl Config {
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://{}:{}/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 {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
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.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 :
//! 1. Heartbeat loop : capture + envoi toutes les 5s (avec dedup par hash)
//! 2. Replay poll loop : poll toutes les 1s, execute les actions
//! 3. Capture HTTP server : port 5006 pour les captures a la demande
//! 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 tray;
mod visual;
use config::Config;
use reqwest::blocking::Client;
use std::sync::atomic::{AtomicBool, Ordering};
use state::AgentState;
use std::sync::Arc;
use std::thread;
use std::time::Duration;
/// Flag global pour l'arret propre (Ctrl+C)
static RUNNING: AtomicBool = AtomicBool::new(true);
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 = Arc::new(config);
// Etat partage thread-safe
let state = AgentState::new();
// Banniere de demarrage
print_banner(&config);
// Handler Ctrl+C pour arret propre
// On utilise le flag global RUNNING (AtomicBool) — le handler SIGINT
// est installe via un thread qui bloque sur un pipe/signal.
// Approche simple : polling du flag depuis tous les threads.
install_ctrlc_handler();
install_ctrlc_handler(state.clone());
// Verifier que la capture d'ecran fonctionne
print!("[MAIN] Test de capture d'ecran... ");
@@ -50,19 +73,21 @@ fn main() {
// Thread 1 : Heartbeat loop
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())
.spawn(move || {
heartbeat_loop(&hb_config);
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);
replay::replay_poll_loop(&rp_config, &rp_state);
})
.expect("Impossible de demarrer le thread replay");
@@ -75,31 +100,46 @@ fn main() {
})
.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
while RUNNING.load(Ordering::SeqCst) {
thread::sleep(Duration::from_millis(500));
}
// 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::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...");
state.request_shutdown();
// Attendre le thread heartbeat (les autres sont daemon-like)
let _ = heartbeat_thread.join();
// 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 RUNNING a false.
///
/// Sur Unix : intercepte SIGINT via un pipe auto-referent.
/// Sur Windows : sera ameliore en Phase 2 avec le crate windows.
fn install_ctrlc_handler() {
// Approche portable : un thread qui attend sur stdin/signal
// En pratique, on utilise un pipe trick simple
/// Installe un handler Ctrl+C qui met l'etat a "arret demande".
fn install_ctrlc_handler(state: Arc<AgentState>) {
#[cfg(unix)]
{
// Creer un pipe pour la notification
let mut fds = [0i32; 2];
unsafe {
if libc::pipe(fds.as_mut_ptr()) != 0 {
@@ -107,13 +147,21 @@ fn install_ctrlc_handler() {
return;
}
// Installer le signal handler qui ecrit dans le pipe
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 {
RUNNING.store(false, Ordering::SeqCst);
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);
}
@@ -125,15 +173,16 @@ fn install_ctrlc_handler() {
#[cfg(not(unix))]
{
// Sur Windows, on utilise un thread simple qui verifie periodiquement
// Le vrai handler sera SetConsoleCtrlHandler en Phase 2
// Pour l'instant, Ctrl+C termine le process directement (comportement par defaut)
// 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.
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 session_id = config.bg_session_id();
let mut last_hash: u64 = 0;
@@ -144,34 +193,50 @@ fn heartbeat_loop(config: &Config) {
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
match capture::capture_screenshot() {
Some(img) => {
// Deduplication par hash perceptuel
let current_hash = capture::image_hash(&img);
if current_hash == last_hash {
// Ecran identique, on skip l'envoi
thread::sleep(Duration::from_secs(config.heartbeat_interval_s));
continue;
}
last_hash = current_hash;
// 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(&img, config.jpeg_quality);
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);
let success =
network::send_heartbeat(&client, config, &jpeg_bytes, &session_id);
if success {
consecutive_errors = 0;
} else {
consecutive_errors += 1;
if consecutive_errors == 1 || consecutive_errors % 12 == 0 {
// Log seulement la premiere erreur et toutes les minutes
eprintln!(
"[HEARTBEAT] {} erreur(s) consecutives",
consecutive_errors
@@ -180,8 +245,6 @@ fn heartbeat_loop(config: &Config) {
}
}
None => {
// Pas de capture possible (pas de display, etc.)
// On attend plus longtemps pour ne pas spammer les logs
thread::sleep(Duration::from_secs(config.heartbeat_interval_s * 2));
continue;
}
@@ -193,16 +256,58 @@ fn heartbeat_loop(config: &Config) {
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.
fn print_banner(config: &Config) {
println!("======================================================");
println!(" RPA Vision Agent v{} (Rust)", config::AGENT_VERSION);
println!(" Phase 1 -- Headless");
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!(" Heartbeat: toutes les {}s", config.heartbeat_interval_s);
println!(" JPEG : qualite {}", config.jpeg_quality);
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!("======================================================");
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.
//!
//! Poll le serveur toutes les secondes pour récupérer les actions à exécuter.
//! Quand une action est reçue, l'exécute via executor et rapporte le résultat.
//! Gère le backoff exponentiel en cas d'indisponibilité du serveur.
//! 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.
@@ -10,35 +10,50 @@ 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 dédié).
/// Boucle de polling replay (tourne dans un thread dedie).
///
/// - Poll GET /replay/next toutes les secondes
/// - Exécute l'action via executor
/// - Execute l'action via executor
/// - 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
pub fn replay_poll_loop(config: &Config) {
pub fn replay_poll_loop(config: &Config, state: &AgentState) {
let client = Client::new();
let mut poll_count: u64 = 0;
let mut backoff = config.replay_poll_interval_s;
let backoff_max = 30.0_f64;
let backoff_factor = 1.5_f64;
let backoff = config.replay_poll_interval_s;
let _backoff_max = 30.0_f64;
let _backoff_factor = 1.5_f64;
let mut replay_active = false;
let mut last_conn_error_logged = false;
println!(
"[REPLAY] Boucle replay demarree — poll toutes les {:.0}s sur {}",
config.replay_poll_interval_s, config.server_url
);
loop {
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 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;
if polls_per_minute > 0 && poll_count % polls_per_minute == 0 {
println!(
@@ -51,12 +66,10 @@ pub fn replay_poll_loop(config: &Config) {
match network::poll_next_action(&client, config) {
Some(action) => {
// Reset backoff et flag d'erreur
backoff = config.replay_poll_interval_s;
last_conn_error_logged = false;
if !replay_active {
replay_active = true;
state.set_replay_active(true);
notifications::replay_started("workflow");
println!("[REPLAY] Replay demarre");
}
@@ -67,10 +80,10 @@ pub fn replay_poll_loop(config: &Config) {
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));
// 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);
let mut result = executor::execute_action(&action, sw, sh, config);
println!(
@@ -78,7 +91,7 @@ pub fn replay_poll_loop(config: &Config) {
result.success, result.error
);
// Capture screenshot post-action (après 500ms)
// 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);
@@ -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);
// Poll plus rapidement pour enchaîner les actions
// Poll plus rapidement pour enchainer les actions
thread::sleep(Duration::from_millis(200));
continue;
}
None => {
// Pas d'action — soit pas de replay, soit serveur indisponible
if replay_active {
println!("[REPLAY] Replay termine — retour en mode capture");
replay_active = false;
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);
thread::sleep(sleep_duration);
// Note: le backoff augmente seulement quand poll_next_action renvoie None
// et qu'on suspecte une erreur réseau. Pour l'instant, on garde le poll
// à intervalles constants (1s). Le backoff sera implémenté plus finement
// quand on aura un meilleur signal d'erreur réseau.
let _ = (backoff_max, backoff_factor, &mut last_conn_error_logged);
}
println!("[REPLAY] Boucle arretee.");
}

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