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:
34
agent_rust/LISEZMOI.txt
Normal file
34
agent_rust/LISEZMOI.txt
Normal 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
22
agent_rust/build_demo.sh
Executable 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
12
agent_rust/config.txt
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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>() {
|
||||
|
||||
@@ -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
274
agent_rust/src/sysinfo.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user