feat: replay visuel VLM-first, worker séparé, package Léa, AZERTY, sécurité HTTPS

Pipeline replay visuel :
- VLM-first : l'agent appelle Ollama directement pour trouver les éléments
- Template matching en fallback (seuil strict 0.90)
- Stop immédiat si élément non trouvé (pas de clic blind)
- Replay depuis session brute (/replay-session) sans attendre le VLM
- Vérification post-action (screenshot hash avant/après)
- Gestion des popups (Enter/Escape/Tab+Enter)

Worker VLM séparé :
- run_worker.py : process distinct du serveur HTTP
- Communication par fichiers (_worker_queue.txt + _replay_active.lock)
- Le serveur HTTP ne fait plus jamais de VLM → toujours réactif
- Service systemd rpa-worker.service

Capture clavier :
- raw_keys (vk + press/release) pour replay exact indépendant du layout
- Fix AZERTY : ToUnicodeEx + AltGr detection
- Enter capturé comme \n, Tab comme \t
- Filtrage modificateurs seuls (Ctrl/Alt/Shift parasites)
- Fusion text_input consécutifs, dédup key_combo

Sécurité & Internet :
- HTTPS Let's Encrypt (lea.labs + vwb.labs.laurinebazin.design)
- Token API fixe dans .env.local
- HTTP Basic Auth sur VWB
- Security headers (HSTS, CSP, nosniff)
- CORS domaines publics, plus de wildcard

Infrastructure :
- DPI awareness (SetProcessDpiAwareness) Python + Rust
- Métadonnées système (dpi_scale, window_bounds, monitors, os_theme)
- Template matching multi-scale [0.5, 2.0]
- Résolution dynamique (plus de hardcode 1920x1080)
- VLM prefill fix (47x speedup, 3.5s au lieu de 180s)

Modules :
- core/auth/ : credential vault (Fernet AES), TOTP (RFC 6238), auth handler
- core/federation/ : LearningPack export/import anonymisé, FAISS global
- deploy/ : package Léa (config.txt, Lea.bat, install.bat, LISEZMOI.txt)

UX :
- Filtrage OS (VWB + Chat montrent que les workflows de l'OS courant)
- Bibliothèque persistante (cache local + SQLite)
- Clustering hybride (titre fenêtre + DBSCAN)
- EdgeConstraints + PostConditions peuplés
- GraphBuilder compound actions (toutes les frappes)

Agent Rust :
- Token Bearer auth (network.rs)
- sysinfo.rs (DPI, résolution, window bounds via Win32 API)
- config.txt lu automatiquement
- Support Chrome/Brave/Firefox (pas que Edge)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-26 10:19:18 +01:00
parent fe5e0ba83d
commit d5deac3029
162 changed files with 25669 additions and 557 deletions

34
agent_rust/LISEZMOI.txt Normal file
View File

@@ -0,0 +1,34 @@
╔══════════════════════════════════════════╗
║ Léa — Assistante IA ║
║ Automatisation de tâches ║
╚══════════════════════════════════════════╝
INSTALLATION
────────────
1. Copiez le dossier "Lea" sur votre Bureau
2. Double-cliquez sur "Lea.exe" pour démarrer
PREMIÈRE UTILISATION
────────────────────
• Léa s'ouvre automatiquement dans votre navigateur
• Cliquez "Apprenez-moi une tâche" pour commencer
• Effectuez votre tâche normalement
• Cliquez "C'est terminé" quand vous avez fini
• Léa a appris ! Demandez-lui de refaire la tâche
ARRÊTER LÉA
────────────
• Fermez la fenêtre Léa dans la barre des tâches
• Ou appuyez Ctrl+C dans le terminal
BESOIN D'AIDE ?
───────────────
Contactez le support : [à compléter]
────────────────────────────────────────────
⚠ Cet outil utilise l'intelligence artificielle.
Article 50 du Règlement européen sur l'IA.
Vos données restent sur votre ordinateur et notre
serveur sécurisé. Aucune donnée n'est partagée
avec des tiers.
────────────────────────────────────────────

22
agent_rust/build_demo.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/bin/bash
# Build du kit démo pour Windows
set -e
echo "=== Build Léa pour Windows ==="
cargo build --release --target x86_64-pc-windows-gnu
# Préparer le dossier de démo
DEMO_DIR="demo_kit/Lea"
rm -rf demo_kit
mkdir -p "$DEMO_DIR"
# Copier les fichiers
cp target/x86_64-pc-windows-gnu/release/rpa-agent.exe "$DEMO_DIR/Lea.exe"
cp config.txt "$DEMO_DIR/config.txt"
cp LISEZMOI.txt "$DEMO_DIR/LISEZMOI.txt"
echo ""
echo "=== Kit démo prêt dans demo_kit/Lea/ ==="
ls -lh "$DEMO_DIR/"
echo ""
echo "Copiez le dossier Lea/ sur le PC du docteur."

12
agent_rust/config.txt Normal file
View File

@@ -0,0 +1,12 @@
# === Configuration Léa ===
# Adresse du serveur (ne pas modifier sauf instruction)
RPA_SERVER_URL=https://lea.labs.laurinebazin.design/api/v1
# Clé d'accès (ne pas modifier)
RPA_API_TOKEN=86031addb338e449fccdb1a983f61807aec15d42d482b9c7748ad607dc23caab
# Qualité des captures (1-100, défaut: 85)
RPA_JPEG_QUALITY=85
# Floutage des données sensibles (true/false)
RPA_BLUR_SENSITIVE=true

View File

@@ -100,6 +100,10 @@ pub fn image_hash(img: &DynamicImage) -> u64 {
}
/// Retourne les dimensions du moniteur principal (largeur, hauteur).
///
/// xcap utilise DXGI sur Windows qui retourne toujours les pixels physiques,
/// independamment du DPI awareness. Ceci est coherent avec les coordonnees
/// physiques d'enigo quand le process est DPI-aware.
pub fn screen_dimensions() -> Option<(u32, u32)> {
let monitors = xcap::Monitor::all().ok()?;
let primary = monitors

View File

@@ -1,9 +1,13 @@
//! Configuration de l'agent RPA.
//!
//! Parametres charges depuis les variables d'environnement ou valeurs par defaut.
//! Un fichier `config.txt` (clé=valeur) peut être placé à côté de l'exécutable.
//! Les variables d'environnement ont priorité sur le fichier.
//! Compatible avec la configuration Python (agent_v1/config.py).
use std::env;
use std::fs;
use std::path::PathBuf;
/// Version de l'agent Rust
pub const AGENT_VERSION: &str = "0.2.0-rust";
@@ -37,11 +41,86 @@ pub struct Config {
/// Port du serveur de chat (defaut: 5004)
pub chat_port: u16,
/// Token Bearer pour l'authentification API (defaut: vide = pas d'auth)
pub api_token: String,
}
impl Config {
/// Charge le fichier `config.txt` situé à côté de l'exécutable (ou dans le dossier courant).
///
/// Format : une ligne par clé, `CLÉ=VALEUR`. Les lignes vides et celles commençant
/// par `#` sont ignorées. Seules les clés **absentes** de l'environnement sont injectées
/// (les variables d'environnement ont toujours priorité).
fn load_config_file() {
// 1. Chercher config.txt à côté de l'exécutable
let mut config_path: Option<PathBuf> = None;
if let Ok(exe) = env::current_exe() {
let candidate = exe.parent().map(|p| p.join("config.txt"));
if let Some(ref p) = candidate {
if p.is_file() {
config_path = candidate;
}
}
}
// 2. Fallback : dossier courant
if config_path.is_none() {
let cwd_candidate = PathBuf::from("config.txt");
if cwd_candidate.is_file() {
config_path = Some(cwd_candidate);
}
}
let path = match config_path {
Some(p) => p,
None => return, // Pas de fichier config — ce n'est pas une erreur
};
let content = match fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
eprintln!("[config] Impossible de lire {} : {}", path.display(), e);
return;
}
};
eprintln!("[config] Chargement de {}", path.display());
for line in content.lines() {
let trimmed = line.trim();
// Ignorer les lignes vides et les commentaires
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
// Séparer au premier '='
if let Some(eq_pos) = trimmed.find('=') {
let key = trimmed[..eq_pos].trim();
let value = trimmed[eq_pos + 1..].trim();
if key.is_empty() {
continue;
}
// Ne positionner que si la variable n'existe pas déjà
if env::var(key).is_err() {
// SAFETY: appelé une seule fois au démarrage, avant tout thread
unsafe {
env::set_var(key, value);
}
}
}
}
}
/// Charge la configuration depuis les variables d'environnement.
///
/// Le fichier `config.txt` est lu en premier (voir [`load_config_file`]) ;
/// les variables d'environnement déjà définies ne sont pas écrasées.
///
/// Variables supportees :
/// - `RPA_SERVER_URL` : URL du serveur (defaut: http://localhost:5005/api/v1)
/// - `RPA_MACHINE_ID` : Identifiant machine (defaut: hostname_os)
@@ -51,7 +130,10 @@ impl Config {
/// - `RPA_BLUR_SENSITIVE` : Flouter les zones sensibles (defaut: true)
/// - `RPA_LOG_RETENTION_DAYS` : Retention des logs en jours (defaut: 180)
/// - `RPA_CHAT_PORT` : Port du serveur de chat (defaut: 5004)
/// - `RPA_API_TOKEN` : Token Bearer pour l'authentification (defaut: vide)
pub fn from_env() -> Self {
// Charger config.txt AVANT de lire les variables d'environnement
Self::load_config_file();
let machine_id = env::var("RPA_MACHINE_ID").unwrap_or_else(|_| {
let host = hostname::get()
.map(|h| h.to_string_lossy().to_string())
@@ -98,6 +180,8 @@ impl Config {
.and_then(|v| v.parse().ok())
.unwrap_or(5004);
let api_token = env::var("RPA_API_TOKEN").unwrap_or_default();
Config {
server_url,
machine_id,
@@ -108,6 +192,7 @@ impl Config {
blur_sensitive,
log_retention_days,
chat_port,
api_token,
}
}
@@ -151,10 +236,11 @@ impl std::fmt::Display for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Config {{ server: {}, machine: {}, capture_port: {}, heartbeat: {}s, jpeg_q: {}, blur: {}, log_retention: {}j, chat_port: {} }}",
"Config {{ server: {}, machine: {}, capture_port: {}, heartbeat: {}s, jpeg_q: {}, blur: {}, log_retention: {}j, chat_port: {}, auth: {} }}",
self.server_url, self.machine_id, self.capture_port,
self.heartbeat_interval_s, self.jpeg_quality,
self.blur_sensitive, self.log_retention_days, self.chat_port,
if self.api_token.is_empty() { "none" } else { "Bearer" },
)
}
}

View File

@@ -30,6 +30,7 @@ mod replay;
mod server;
#[allow(dead_code)]
mod state;
mod sysinfo;
mod tray;
mod visual;
@@ -40,12 +41,20 @@ use std::sync::Arc;
use std::thread;
use std::time::Duration;
/// Trouve Edge sur Windows
/// Trouve un navigateur compatible sur Windows (Edge, Chrome, Brave, Firefox)
#[cfg(target_os = "windows")]
fn find_edge() -> Option<String> {
fn find_browser() -> Option<String> {
let paths = [
// Edge
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
// Chrome
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
// Brave
r"C:\Program Files\BraveSoftware\Brave-Browser\Application\brave.exe",
// Firefox (supporte --kiosk mais pas --app)
r"C:\Program Files\Mozilla Firefox\firefox.exe",
];
for p in &paths {
if std::path::Path::new(p).exists() {
@@ -56,6 +65,37 @@ fn find_edge() -> Option<String> {
}
fn main() {
// --- DPI awareness (DOIT etre appele avant toute operation graphique) ---
// Rend le process DPI-aware sur Windows pour que les API (enigo, xcap,
// GetSystemMetrics, etc.) travaillent en coordonnees physiques (pixels reels)
// au lieu de coordonnees logiques (virtualisees par le DPI scaling).
// Sans cet appel, un ecran 2560x1600 a 150% DPI apparait comme 1707x1067
// pour enigo et GetSystemMetrics, ce qui cause des erreurs de positionnement
// pendant le replay.
// PROCESS_PER_MONITOR_DPI_AWARE = 2 : le niveau le plus precis.
#[cfg(target_os = "windows")]
{
// SetProcessDpiAwareness (shcore.dll) et SetProcessDPIAware (user32.dll)
// ne sont pas toujours exposes par windows-sys selon les features.
// On utilise des appels FFI raw pour eviter d'ajouter des features.
#[link(name = "shcore")]
extern "system" {
fn SetProcessDpiAwareness(value: i32) -> i32;
}
#[link(name = "user32")]
extern "system" {
fn SetProcessDPIAware() -> i32;
}
unsafe {
// Tenter SetProcessDpiAwareness(2) = PROCESS_PER_MONITOR_DPI_AWARE
let hr = SetProcessDpiAwareness(2);
if hr != 0 {
// Fallback pour Windows < 8.1 : SetProcessDPIAware()
SetProcessDPIAware();
}
}
}
// Initialiser le logging
env_logger::Builder::from_env(
env_logger::Env::default().default_filter_or("info"),
@@ -135,15 +175,41 @@ fn main() {
let chat_state = state.clone();
chat::run_chat_thread(&chat_config, chat_state);
// Synchroniser les workflows disponibles depuis le serveur
let sync_config = config.clone();
let workflows = {
let client = Client::new();
network::fetch_workflows(&client, &sync_config)
};
if workflows.is_empty() {
println!("[MAIN] Aucun workflow disponible pour cette machine.");
} else {
println!(
"[MAIN] {} workflow(s) disponible(s) :",
workflows.len()
);
for wf in &workflows {
println!(
" - {} ({} noeuds, {} transitions)",
wf.name, wf.nodes, wf.edges
);
}
}
println!("\n[MAIN] Agent operationnel — tous les threads demarres.\n");
// Ouvrir Léa (Edge mode app) automatiquement au démarrage
// Ouvrir Léa dans le navigateur disponible (mode app) au démarrage
#[cfg(target_os = "windows")]
{
let chat_url = config.chat_url();
if let Some(edge) = find_edge() {
println!("[MAIN] Ouverture de Léa dans Edge...");
let _ = std::process::Command::new(&edge)
if let Some(browser) = find_browser() {
let browser_name = if browser.contains("chrome") { "Chrome" }
else if browser.contains("edge") || browser.contains("Edge") { "Edge" }
else if browser.contains("brave") || browser.contains("Brave") { "Brave" }
else if browser.contains("firefox") || browser.contains("Firefox") { "Firefox" }
else { "navigateur" };
println!("[MAIN] Ouverture de Léa dans {}...", browser_name);
let _ = std::process::Command::new(&browser)
.args(&[
&format!("--app={}", chat_url),
"--window-size=600,800",
@@ -151,6 +217,8 @@ fn main() {
"--no-first-run",
])
.spawn();
} else {
println!("[MAIN] Aucun navigateur trouvé — ouvrez manuellement : {}", chat_url);
}
}
@@ -304,9 +372,8 @@ fn health_check_loop(config: &Config, state: &AgentState) {
while state.is_running() {
let url = format!("{}/stats", config.server_url);
let connected = client
.get(&url)
.timeout(timeout)
let request = client.get(&url).timeout(timeout);
let connected = network::with_auth(request, config)
.send()
.map(|r| r.status().is_success())
.unwrap_or(false);
@@ -327,6 +394,8 @@ fn health_check_loop(config: &Config, state: &AgentState) {
/// Affiche la banniere de demarrage.
fn print_banner(config: &Config) {
let meta = sysinfo::get_screen_metadata();
println!("======================================================");
println!(
" RPA Vision Agent v{} (Rust)",
@@ -342,6 +411,17 @@ fn print_banner(config: &Config) {
println!(" JPEG : qualite {}", config.jpeg_quality);
println!(" Floutage : {}", if config.blur_sensitive { "actif" } else { "inactif" });
println!(" Logs : retention {} jours", config.log_retention_days);
println!(" Auth : {}", if config.api_token.is_empty() { "aucune" } else { "Bearer token" });
println!(" Workflows : synchronisation au demarrage");
println!(
" Ecran : {}x{} @ {}% DPI",
meta.screen_resolution[0], meta.screen_resolution[1], meta.dpi_scale
);
println!(
" Moniteur : #{} ({})",
meta.monitor_index,
if meta.monitor_index == 0 { "principal" } else { "secondaire" }
);
println!("======================================================");
println!();
println!(" [IA] Cet agent utilise l'intelligence artificielle.");

View File

@@ -5,9 +5,21 @@
//! Compatible avec l'API de agent_v0/server_v1/api_stream.py (port 5005).
use crate::config::Config;
use reqwest::blocking::Client;
use crate::sysinfo;
use reqwest::blocking::{Client, RequestBuilder};
use serde::{Deserialize, Serialize};
/// Ajoute le header Authorization Bearer si un token est configure.
///
/// Si `config.api_token` est vide, la requete est retournee telle quelle.
pub fn with_auth(request: RequestBuilder, config: &Config) -> RequestBuilder {
if config.api_token.is_empty() {
request
} else {
request.header("Authorization", format!("Bearer {}", config.api_token))
}
}
/// Action de replay reçue du serveur.
///
/// Format identique à celui du Python executor (agent_v1/core/executor.py).
@@ -102,6 +114,8 @@ impl ActionResult {
/// Envoie un heartbeat (screenshot) au serveur streaming.
///
/// POST /traces/stream/image avec le screenshot en multipart.
/// Inclut les métadonnées système (DPI, résolution, fenêtre, moniteur)
/// dans les query params pour que le serveur puisse les exploiter.
/// Retourne true si l'envoi a réussi.
pub fn send_heartbeat(
client: &Client,
@@ -112,6 +126,19 @@ pub fn send_heartbeat(
let url = format!("{}/image", config.streaming_url());
let shot_id = format!("heartbeat_{}", chrono::Utc::now().timestamp());
// Collecter les métadonnées système
let meta = sysinfo::get_screen_metadata();
let dpi_str = meta.dpi_scale.to_string();
let screen_w_str = meta.screen_resolution[0].to_string();
let screen_h_str = meta.screen_resolution[1].to_string();
let monitor_str = meta.monitor_index.to_string();
// Sérialiser window_bounds en JSON compact (ou "null")
let wb_str = match meta.window_bounds {
Some(wb) => format!("[{},{},{},{}]", wb[0], wb[1], wb[2], wb[3]),
None => "null".to_string(),
};
let part = reqwest::blocking::multipart::Part::bytes(jpeg_bytes.to_vec())
.file_name("screenshot.jpg")
.mime_str("image/jpeg")
@@ -122,17 +149,22 @@ pub fn send_heartbeat(
let form = reqwest::blocking::multipart::Form::new().part("file", part);
match client
let request = client
.post(&url)
.query(&[
("session_id", session_id),
("shot_id", &shot_id),
("machine_id", &config.machine_id),
("dpi_scale", &dpi_str),
("screen_w", &screen_w_str),
("screen_h", &screen_h_str),
("monitor_index", &monitor_str),
("window_bounds", &wb_str),
])
.multipart(form)
.timeout(std::time::Duration::from_secs(10))
.send()
{
.timeout(std::time::Duration::from_secs(10));
match with_auth(request, config).send() {
Ok(resp) => {
if resp.status().is_success() {
true
@@ -166,15 +198,15 @@ pub fn poll_next_action(client: &Client, config: &Config) -> Option<Action> {
let url = format!("{}/replay/next", config.streaming_url());
let session_id = config.agent_session_id();
let resp = client
let request = client
.get(&url)
.query(&[
("session_id", session_id.as_str()),
("machine_id", config.machine_id.as_str()),
])
.timeout(std::time::Duration::from_secs(5))
.send()
.ok()?;
.timeout(std::time::Duration::from_secs(5));
let resp = with_auth(request, config).send().ok()?;
if !resp.status().is_success() {
return None;
@@ -184,6 +216,120 @@ pub fn poll_next_action(client: &Client, config: &Config) -> Option<Action> {
data.action
}
/// Informations résumées d'un workflow disponible.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowInfo {
/// Identifiant unique du workflow
pub workflow_id: String,
/// Nom lisible du workflow
#[serde(default)]
pub name: String,
/// Identifiant machine associé
#[serde(default)]
pub machine_id: String,
/// Nombre de nœuds
#[serde(default)]
pub nodes: u32,
/// Nombre de transitions
#[serde(default)]
pub edges: u32,
}
/// Réponse du serveur pour GET /traces/stream/workflows
#[derive(Debug, Deserialize)]
struct WorkflowsResponse {
#[serde(default)]
workflows: Vec<WorkflowInfo>,
}
/// Récupère la liste des workflows disponibles pour cette machine.
///
/// GET /traces/stream/workflows?machine_id=<machine_id>
/// Sauvegarde le résultat dans workflows.json à côté de l'exécutable.
/// Retourne la liste (éventuellement depuis le cache local si le serveur est indisponible).
pub fn fetch_workflows(client: &Client, config: &Config) -> Vec<WorkflowInfo> {
let url = format!("{}/workflows", config.streaming_url());
let request = client
.get(&url)
.query(&[("machine_id", config.machine_id.as_str())])
.timeout(std::time::Duration::from_secs(5));
let workflows = match with_auth(request, config).send() {
Ok(resp) if resp.status().is_success() => {
match resp.json::<WorkflowsResponse>() {
Ok(data) => data.workflows,
Err(e) => {
eprintln!("[WORKFLOWS] Erreur parsing reponse : {}", e);
Vec::new()
}
}
}
Ok(resp) => {
eprintln!("[WORKFLOWS] Serveur HTTP {} — chargement cache local", resp.status());
return load_workflows_cache();
}
Err(e) => {
eprintln!("[WORKFLOWS] Serveur injoignable ({}) — chargement cache local", e);
return load_workflows_cache();
}
};
// Sauvegarder dans le cache local
save_workflows_cache(&workflows);
workflows
}
/// Chemin du fichier cache workflows.json (à côté de l'exécutable ou dans le dossier courant).
fn workflows_cache_path() -> std::path::PathBuf {
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
return dir.join("workflows.json");
}
}
std::path::PathBuf::from("workflows.json")
}
/// Sauvegarde les workflows dans le cache local.
fn save_workflows_cache(workflows: &[WorkflowInfo]) {
let path = workflows_cache_path();
match serde_json::to_string_pretty(workflows) {
Ok(json) => {
if let Err(e) = std::fs::write(&path, json) {
eprintln!("[WORKFLOWS] Erreur ecriture cache {} : {}", path.display(), e);
}
}
Err(e) => {
eprintln!("[WORKFLOWS] Erreur serialisation cache : {}", e);
}
}
}
/// Charge les workflows depuis le cache local.
fn load_workflows_cache() -> Vec<WorkflowInfo> {
let path = workflows_cache_path();
match std::fs::read_to_string(&path) {
Ok(content) => {
match serde_json::from_str::<Vec<WorkflowInfo>>(&content) {
Ok(workflows) => {
println!("[WORKFLOWS] {} workflow(s) charges depuis le cache local", workflows.len());
workflows
}
Err(e) => {
eprintln!("[WORKFLOWS] Erreur parsing cache : {}", e);
Vec::new()
}
}
}
Err(_) => Vec::new(), // Pas de cache, pas d'erreur
}
}
/// Rapporte le résultat d'une action au serveur.
///
/// POST /traces/stream/replay/result avec le résultat en JSON.
@@ -208,12 +354,12 @@ pub fn report_result(client: &Client, config: &Config, result: &ActionResult) ->
screenshot: &result.screenshot,
};
match client
let request = client
.post(&url)
.json(&report)
.timeout(std::time::Duration::from_secs(10))
.send()
{
.timeout(std::time::Duration::from_secs(10));
match with_auth(request, config).send() {
Ok(resp) => {
if resp.status().is_success() {
if let Ok(data) = resp.json::<serde_json::Value>() {

View File

@@ -435,6 +435,10 @@ fn event_sender_loop(
}
/// Envoie un evenement capture au serveur streaming.
///
/// Inclut la resolution de l'ecran dans chaque event pour que le serveur
/// puisse construire des ScreenStates avec la bonne resolution d'apprentissage
/// (au lieu du fallback 1920x1080).
fn send_event_to_server(
client: &reqwest::blocking::Client,
config: &Config,
@@ -443,6 +447,7 @@ fn send_event_to_server(
) {
let url = format!("{}/traces/stream/event", config.server_url);
let timestamp = chrono::Utc::now().to_rfc3339();
let (screen_w, screen_h) = capture::screen_dimensions().unwrap_or((1920, 1080));
let payload = match event {
CapturedEvent::Click {
@@ -460,6 +465,7 @@ fn send_event_to_server(
"session_name": session_name,
"machine_id": config.machine_id,
"timestamp": timestamp,
"screen_resolution": [screen_w, screen_h],
})
}
CapturedEvent::DoubleClick {
@@ -476,6 +482,7 @@ fn send_event_to_server(
"session_name": session_name,
"machine_id": config.machine_id,
"timestamp": timestamp,
"screen_resolution": [screen_w, screen_h],
})
}
CapturedEvent::Text {
@@ -491,6 +498,7 @@ fn send_event_to_server(
"session_name": session_name,
"machine_id": config.machine_id,
"timestamp": timestamp,
"screen_resolution": [screen_w, screen_h],
})
}
CapturedEvent::KeyCombo { keys } => {
@@ -500,6 +508,7 @@ fn send_event_to_server(
"session_name": session_name,
"machine_id": config.machine_id,
"timestamp": timestamp,
"screen_resolution": [screen_w, screen_h],
})
}
CapturedEvent::Scroll {
@@ -515,6 +524,7 @@ fn send_event_to_server(
"session_name": session_name,
"machine_id": config.machine_id,
"timestamp": timestamp,
"screen_resolution": [screen_w, screen_h],
})
}
};

274
agent_rust/src/sysinfo.rs Normal file
View File

@@ -0,0 +1,274 @@
//! Métadonnées système : DPI, résolution, fenêtre active, moniteur.
//!
//! Expose des fonctions pour capturer les informations d'affichage
//! critiques qui seront envoyées au serveur avec chaque heartbeat.
//! Sur Windows, utilise les API Win32 (user32.dll).
//! Sur Linux, retourne des valeurs par défaut ou utilise xcap.
use serde::Serialize;
/// Métadonnées complètes de l'écran.
#[derive(Debug, Clone, Serialize)]
pub struct ScreenMetadata {
/// Facteur DPI en pourcentage (100 = normal, 150 = haute résolution)
pub dpi_scale: u32,
/// Résolution de l'écran principal [largeur, hauteur]
pub screen_resolution: [u32; 2],
/// Bounds de la fenêtre active [x, y, largeur, hauteur], None si pas de fenêtre
pub window_bounds: Option<[i32; 4]>,
/// Index du moniteur sur lequel se trouve la fenêtre active (0 = principal)
pub monitor_index: u32,
}
impl std::fmt::Display for ScreenMetadata {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}x{} @ {}% DPI, monitor #{}",
self.screen_resolution[0],
self.screen_resolution[1],
self.dpi_scale,
self.monitor_index,
)?;
if let Some(wb) = &self.window_bounds {
write!(f, ", fenetre [{}x{} @ ({},{})]", wb[2], wb[3], wb[0], wb[1])?;
}
Ok(())
}
}
// =============================================================================
// Windows : API Win32 via FFI
// =============================================================================
#[cfg(target_os = "windows")]
mod win {
use windows_sys::Win32::Foundation::{BOOL, LPARAM, RECT};
use windows_sys::Win32::Graphics::Gdi::{
EnumDisplayMonitors, GetMonitorInfoW, MonitorFromWindow, HMONITOR, MONITORINFO,
MONITOR_DEFAULTTOPRIMARY,
};
use windows_sys::Win32::UI::WindowsAndMessaging::{
GetForegroundWindow, GetSystemMetrics, GetWindowRect, SM_CXSCREEN, SM_CYSCREEN,
};
// GetDpiForSystem est dans Win32_UI_HiDpi (non activée).
// On utilise un appel FFI raw pour éviter d'ajouter une feature.
extern "system" {
fn GetDpiForSystem() -> u32;
}
/// Retourne le facteur DPI en % (100 = normal, 125, 150, 200...).
pub fn get_dpi_scale() -> u32 {
unsafe {
let dpi = GetDpiForSystem();
if dpi == 0 {
// Fallback si l'API n'est pas disponible (Windows < 10 1607)
100
} else {
(dpi * 100) / 96
}
}
}
/// Retourne (largeur, hauteur) du moniteur principal via GetSystemMetrics.
///
/// IMPORTANT : Retourne la resolution physique uniquement si le process est
/// DPI-aware (SetProcessDpiAwareness(2) appele dans main.rs). Sans cela,
/// retourne la resolution logique (virtualisee par le DPI scaling).
pub fn get_screen_resolution() -> (u32, u32) {
unsafe {
let w = GetSystemMetrics(SM_CXSCREEN);
let h = GetSystemMetrics(SM_CYSCREEN);
if w > 0 && h > 0 {
(w as u32, h as u32)
} else {
(0, 0)
}
}
}
/// Retourne (x, y, largeur, hauteur) de la fenêtre active, ou None.
pub fn get_window_bounds() -> Option<(i32, i32, i32, i32)> {
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.is_null() {
return None;
}
let mut rect: RECT = std::mem::zeroed();
if GetWindowRect(hwnd, &mut rect) != 0 {
let w = rect.right - rect.left;
let h = rect.bottom - rect.top;
Some((rect.left, rect.top, w, h))
} else {
None
}
}
}
/// Flag indiquant le moniteur principal dans MONITORINFO.dwFlags.
const MONITORINFOF_PRIMARY: u32 = 1;
/// Retourne l'index du moniteur sur lequel se trouve la fenêtre active.
/// 0 = moniteur principal. Enumère tous les moniteurs pour trouver l'index.
pub fn get_monitor_index() -> u32 {
unsafe {
let hwnd = GetForegroundWindow();
if hwnd.is_null() {
return 0;
}
let target_hmon = MonitorFromWindow(hwnd, MONITOR_DEFAULTTOPRIMARY);
if target_hmon.is_null() {
return 0;
}
// Énumérer les moniteurs pour trouver l'index
struct CallbackData {
target: HMONITOR,
current_index: u32,
found_index: u32,
}
unsafe extern "system" fn enum_callback(
hmonitor: HMONITOR,
_hdc: windows_sys::Win32::Graphics::Gdi::HDC,
_lprect: *mut RECT,
lparam: LPARAM,
) -> BOOL {
let data = &mut *(lparam as *mut CallbackData);
// Vérifier si c'est le moniteur principal — il est toujours #0
let mut info: MONITORINFO = std::mem::zeroed();
info.cbSize = std::mem::size_of::<MONITORINFO>() as u32;
GetMonitorInfoW(hmonitor, &mut info);
if info.dwFlags & MONITORINFOF_PRIMARY != 0 {
// Moniteur principal — index 0, mais on continue pour le comptage
if hmonitor == data.target {
data.found_index = 0;
}
} else if hmonitor == data.target {
data.found_index = data.current_index;
}
data.current_index += 1;
1 // TRUE, continuer l'énumération
}
let mut data = CallbackData {
target: target_hmon,
current_index: 0,
found_index: 0,
};
EnumDisplayMonitors(
std::ptr::null_mut(), // HDC null = tous les moniteurs
std::ptr::null(),
Some(enum_callback),
&mut data as *mut CallbackData as LPARAM,
);
data.found_index
}
}
}
// =============================================================================
// Linux / fallback : valeurs par défaut ou xcap
// =============================================================================
#[cfg(not(target_os = "windows"))]
mod fallback {
/// Sur Linux, pas de DPI système accessible simplement. Retourne 100%.
pub fn get_dpi_scale() -> u32 {
100
}
/// Résolution via xcap (mêmes moniteurs que la capture).
pub fn get_screen_resolution() -> (u32, u32) {
if let Ok(monitors) = xcap::Monitor::all() {
if let Some(primary) = monitors.into_iter().find(|m| m.is_primary().unwrap_or(false)) {
let w = primary.width().unwrap_or(0);
let h = primary.height().unwrap_or(0);
return (w, h);
}
}
(0, 0)
}
/// Pas d'API window bounds sur Linux en mode headless. Retourne None.
pub fn get_window_bounds() -> Option<(i32, i32, i32, i32)> {
None
}
/// Moniteur principal = index 0 (fallback).
pub fn get_monitor_index() -> u32 {
0
}
}
// =============================================================================
// API publique
// =============================================================================
/// Retourne le facteur DPI en % (100 = normal, 150 = haute résolution).
pub fn get_dpi_scale() -> u32 {
#[cfg(target_os = "windows")]
{
win::get_dpi_scale()
}
#[cfg(not(target_os = "windows"))]
{
fallback::get_dpi_scale()
}
}
/// Retourne (largeur, hauteur) du moniteur principal.
pub fn get_screen_resolution() -> (u32, u32) {
#[cfg(target_os = "windows")]
{
win::get_screen_resolution()
}
#[cfg(not(target_os = "windows"))]
{
fallback::get_screen_resolution()
}
}
/// Retourne (x, y, largeur, hauteur) de la fenêtre active, ou None.
pub fn get_window_bounds() -> Option<(i32, i32, i32, i32)> {
#[cfg(target_os = "windows")]
{
win::get_window_bounds()
}
#[cfg(not(target_os = "windows"))]
{
fallback::get_window_bounds()
}
}
/// Retourne l'index du moniteur de la fenêtre active (0 = principal).
pub fn get_monitor_index() -> u32 {
#[cfg(target_os = "windows")]
{
win::get_monitor_index()
}
#[cfg(not(target_os = "windows"))]
{
fallback::get_monitor_index()
}
}
/// Collecte toutes les métadonnées système en une seule structure.
pub fn get_screen_metadata() -> ScreenMetadata {
let (sw, sh) = get_screen_resolution();
let wb = get_window_bounds().map(|(x, y, w, h)| [x, y, w, h]);
ScreenMetadata {
dpi_scale: get_dpi_scale(),
screen_resolution: [sw, sh],
window_bounds: wb,
monitor_index: get_monitor_index(),
}
}