feat: agent Rust Phase 1 — POC headless fonctionnel
1527 lignes Rust, compile sans warnings, testé sur Linux. - Capture d'écran (xcap) + JPEG base64 + hash dedup - Heartbeat toutes les 5s vers streaming server - Poll replay + exécution actions (clic, frappe, combos) - Serveur HTTP port 5006 (capture, health, file-action) - Compatible avec le streaming server Python existant Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
agent_rust/.gitignore
vendored
Normal file
2
agent_rust/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
43
agent_rust/Cargo.toml
Normal file
43
agent_rust/Cargo.toml
Normal file
@@ -0,0 +1,43 @@
|
||||
[package]
|
||||
name = "rpa-agent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Agent RPA Vision - Lea (Phase 1 headless)"
|
||||
|
||||
[dependencies]
|
||||
# Capture d'ecran
|
||||
xcap = "0.7"
|
||||
|
||||
# Simulation souris/clavier (replay)
|
||||
enigo = { version = "0.3", features = ["serde"] }
|
||||
|
||||
# Client HTTP (mode bloquant, pas de tokio)
|
||||
reqwest = { version = "0.12", features = ["blocking", "multipart", "json"] }
|
||||
|
||||
# Traitement d'images (JPEG encode, resize)
|
||||
image = "0.25"
|
||||
|
||||
# Encodage base64
|
||||
base64 = "0.22"
|
||||
|
||||
# Serialisation JSON
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# Mini serveur HTTP synchrone (port 5006)
|
||||
tiny_http = "0.12"
|
||||
|
||||
# Hostname de la machine
|
||||
hostname = "0.4"
|
||||
|
||||
# Date/heure
|
||||
chrono = "0.4"
|
||||
|
||||
# Signal handling Unix (Ctrl+C)
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
strip = true
|
||||
130
agent_rust/README.md
Normal file
130
agent_rust/README.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# RPA Vision Agent (Rust) — Phase 1
|
||||
|
||||
Agent headless pour RPA Vision V3, ecrit en Rust.
|
||||
Capture des screenshots, les envoie au serveur streaming, poll les actions de replay et les execute.
|
||||
|
||||
Equivalent fonctionnel de `agent_v0/agent_v1/` (Python) mais en un seul executable sans dependance.
|
||||
|
||||
## Fonctionnalites (Phase 1)
|
||||
|
||||
- **Heartbeat** : capture l'ecran toutes les 5s, encode en JPEG, envoie au serveur avec deduplication par hash perceptuel
|
||||
- **Replay** : poll GET /replay/next toutes les secondes, execute les actions (click, type, key_combo, scroll, wait), rapporte le resultat avec screenshot post-action
|
||||
- **Serveur de capture** : mini serveur HTTP sur port 5006 pour screenshots a la demande (GET /capture, GET /health, POST /file-action)
|
||||
- **Configuration** : via variables d'environnement ou valeurs par defaut
|
||||
|
||||
## Build
|
||||
|
||||
### Linux (pour tests)
|
||||
|
||||
```bash
|
||||
# Pre-requis systeme (Ubuntu/Debian)
|
||||
sudo apt install libpipewire-0.3-dev libclang-dev libgbm-dev libxdo-dev
|
||||
|
||||
# Build debug
|
||||
cargo build
|
||||
|
||||
# Build release optimise
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Cross-compilation vers Windows
|
||||
|
||||
```bash
|
||||
# Option A : cargo-xwin (recommande, produit un .exe MSVC)
|
||||
cargo install cargo-xwin
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
cargo xwin build --target x86_64-pc-windows-msvc --release
|
||||
|
||||
# Option B : MinGW (plus simple)
|
||||
rustup target add x86_64-pc-windows-gnu
|
||||
sudo apt install gcc-mingw-w64-x86-64
|
||||
cargo build --release --target x86_64-pc-windows-gnu
|
||||
|
||||
# Option C : cross (Docker)
|
||||
cargo install cross
|
||||
cross build --release --target x86_64-pc-windows-msvc
|
||||
```
|
||||
|
||||
Le binaire release se trouve dans `target/release/rpa-agent` (Linux) ou
|
||||
`target/x86_64-pc-windows-msvc/release/rpa-agent.exe` (Windows).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Variable | Defaut | Description |
|
||||
|---|---|---|
|
||||
| `RPA_SERVER_URL` | `http://localhost:5005/api/v1` | URL du serveur streaming |
|
||||
| `RPA_MACHINE_ID` | `{hostname}_{os}` | Identifiant de la machine |
|
||||
| `RPA_CAPTURE_PORT` | `5006` | Port du serveur de capture |
|
||||
| `RPA_HEARTBEAT_INTERVAL` | `5` | Intervalle heartbeat (secondes) |
|
||||
| `RPA_JPEG_QUALITY` | `85` | Qualite JPEG (1-100) |
|
||||
|
||||
## Execution
|
||||
|
||||
```bash
|
||||
# Avec les defauts (serveur local)
|
||||
./target/release/rpa-agent
|
||||
|
||||
# Vers un serveur distant
|
||||
RPA_SERVER_URL=http://192.168.1.10:5005/api/v1 ./target/release/rpa-agent
|
||||
|
||||
# Avec un identifiant machine specifique
|
||||
RPA_MACHINE_ID=pc_bureau_windows ./target/release/rpa-agent
|
||||
```
|
||||
|
||||
## API du serveur de capture (port 5006)
|
||||
|
||||
### GET /capture
|
||||
Retourne un screenshot frais en JSON :
|
||||
```json
|
||||
{
|
||||
"image": "<base64 JPEG>",
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"format": "jpeg",
|
||||
"source": "rust_agent",
|
||||
"capture_ms": 42
|
||||
}
|
||||
```
|
||||
|
||||
### GET /health
|
||||
```json
|
||||
{"status": "ok", "agent": "rust", "version": "0.1.0-rust"}
|
||||
```
|
||||
|
||||
### POST /file-action
|
||||
Actions fichiers sur la machine locale :
|
||||
```json
|
||||
{"action": "file_list_dir", "params": {"path": "C:\\Users\\dom\\Documents"}}
|
||||
{"action": "file_create_dir", "params": {"path": "C:\\Users\\dom\\Documents\\tri"}}
|
||||
{"action": "file_move", "params": {"source": "...", "destination": "..."}}
|
||||
{"action": "file_copy", "params": {"source": "...", "destination": "..."}}
|
||||
{"action": "file_sort_by_ext", "params": {"source_dir": "C:\\Users\\dom\\Downloads"}}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs — Point d'entree, 3 threads (heartbeat, replay, serveur)
|
||||
├── config.rs — Configuration (env vars + defauts)
|
||||
├── capture.rs — Capture ecran (xcap), encodage JPEG, hash perceptuel
|
||||
├── network.rs — Client HTTP (heartbeat, poll replay, rapport resultat)
|
||||
├── replay.rs — Boucle de polling replay avec backoff
|
||||
├── executor.rs — Execution actions (click, type, key_combo, scroll, wait)
|
||||
└── server.rs — Mini serveur HTTP port 5006 (/capture, /health, /file-action)
|
||||
```
|
||||
|
||||
## Compatibilite serveur
|
||||
|
||||
Cet agent est compatible avec le serveur streaming existant (`agent_v0/server_v1/api_stream.py`, port 5005).
|
||||
|
||||
Endpoints utilises :
|
||||
- `POST /api/v1/traces/stream/image` — envoi heartbeat screenshot
|
||||
- `GET /api/v1/traces/stream/replay/next` — poll action replay
|
||||
- `POST /api/v1/traces/stream/replay/result` — rapport resultat replay
|
||||
|
||||
## Phases suivantes
|
||||
|
||||
- **Phase 2** : Systray + notifications (tray-icon, winrt-notification)
|
||||
- **Phase 3** : Fenetre de chat (wry/WebView2)
|
||||
- **Phase 4** : Parite complete (floutage, capture evenements rdev)
|
||||
111
agent_rust/src/capture.rs
Normal file
111
agent_rust/src/capture.rs
Normal file
@@ -0,0 +1,111 @@
|
||||
//! Capture d'écran via xcap.
|
||||
//!
|
||||
//! Fournit la capture du moniteur principal, l'encodage JPEG en base64,
|
||||
//! et un hash perceptuel rapide pour la déduplication des heartbeats.
|
||||
|
||||
use base64::Engine;
|
||||
use image::codecs::jpeg::JpegEncoder;
|
||||
use image::DynamicImage;
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Capture le moniteur principal et retourne un DynamicImage.
|
||||
///
|
||||
/// Utilise xcap pour la capture cross-platform (DXGI sur Windows, X11/Wayland sur Linux).
|
||||
pub fn capture_screenshot() -> Option<DynamicImage> {
|
||||
let monitors = match xcap::Monitor::all() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
eprintln!("[CAPTURE] Erreur enumeration moniteurs : {}", e);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let primary = monitors
|
||||
.into_iter()
|
||||
.find(|m| m.is_primary().unwrap_or(false));
|
||||
let monitor = match primary {
|
||||
Some(m) => m,
|
||||
None => {
|
||||
eprintln!("[CAPTURE] Aucun moniteur principal trouve");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
match monitor.capture_image() {
|
||||
Ok(rgba_image) => Some(DynamicImage::ImageRgba8(rgba_image)),
|
||||
Err(e) => {
|
||||
eprintln!("[CAPTURE] Erreur capture ecran : {}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode une image en JPEG et retourne le résultat en base64.
|
||||
///
|
||||
/// La qualité doit être entre 1 (mauvaise) et 100 (excellente).
|
||||
/// 85 est un bon compromis taille/qualité pour le streaming réseau.
|
||||
pub fn screenshot_to_jpeg_base64(img: &DynamicImage, quality: u8) -> String {
|
||||
let rgb = img.to_rgb8();
|
||||
let mut buffer = Cursor::new(Vec::new());
|
||||
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut buffer, quality);
|
||||
if let Err(e) = encoder.encode(
|
||||
rgb.as_raw(),
|
||||
rgb.width(),
|
||||
rgb.height(),
|
||||
image::ExtendedColorType::Rgb8,
|
||||
) {
|
||||
eprintln!("[CAPTURE] Erreur encodage JPEG : {}", e);
|
||||
return String::new();
|
||||
}
|
||||
|
||||
base64::engine::general_purpose::STANDARD.encode(buffer.into_inner())
|
||||
}
|
||||
|
||||
/// Encode une image en JPEG et retourne les bytes bruts.
|
||||
pub fn screenshot_to_jpeg_bytes(img: &DynamicImage, quality: u8) -> Vec<u8> {
|
||||
let rgb = img.to_rgb8();
|
||||
let mut buffer = Cursor::new(Vec::new());
|
||||
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut buffer, quality);
|
||||
if let Err(e) = encoder.encode(
|
||||
rgb.as_raw(),
|
||||
rgb.width(),
|
||||
rgb.height(),
|
||||
image::ExtendedColorType::Rgb8,
|
||||
) {
|
||||
eprintln!("[CAPTURE] Erreur encodage JPEG : {}", e);
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
buffer.into_inner()
|
||||
}
|
||||
|
||||
/// Calcule un hash perceptuel rapide pour la déduplication.
|
||||
///
|
||||
/// Réduit l'image à 16x16 en niveaux de gris, puis calcule
|
||||
/// un hash simple basé sur les pixels. Identique à la logique
|
||||
/// Python (_quick_hash) dans agent_v1.
|
||||
pub fn image_hash(img: &DynamicImage) -> u64 {
|
||||
let small = img.resize_exact(16, 16, image::imageops::FilterType::Nearest);
|
||||
let gray = small.to_luma8();
|
||||
|
||||
// Hash FNV-1a simple sur les pixels (rapide, pas besoin de crypto)
|
||||
let mut hash: u64 = 0xcbf29ce484222325;
|
||||
for pixel in gray.as_raw() {
|
||||
hash ^= *pixel as u64;
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Retourne les dimensions du moniteur principal (largeur, hauteur).
|
||||
pub fn screen_dimensions() -> Option<(u32, u32)> {
|
||||
let monitors = xcap::Monitor::all().ok()?;
|
||||
let primary = monitors
|
||||
.into_iter()
|
||||
.find(|m| m.is_primary().unwrap_or(false))?;
|
||||
let w = primary.width().ok()?;
|
||||
let h = primary.height().ok()?;
|
||||
Some((w, h))
|
||||
}
|
||||
110
agent_rust/src/config.rs
Normal file
110
agent_rust/src/config.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
//! Configuration de l'agent RPA.
|
||||
//!
|
||||
//! Paramètres chargés depuis les variables d'environnement ou valeurs par défaut.
|
||||
//! 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";
|
||||
|
||||
/// Configuration complète 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)
|
||||
pub machine_id: String,
|
||||
|
||||
/// Port du mini-serveur HTTP de capture (défaut: 5006)
|
||||
pub capture_port: u16,
|
||||
|
||||
/// Intervalle du heartbeat en secondes
|
||||
pub heartbeat_interval_s: u64,
|
||||
|
||||
/// Intervalle de polling replay en secondes
|
||||
pub replay_poll_interval_s: f64,
|
||||
|
||||
/// Qualité JPEG pour les screenshots envoyés (1-100)
|
||||
pub jpeg_quality: u8,
|
||||
}
|
||||
|
||||
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)
|
||||
pub fn from_env() -> Self {
|
||||
let machine_id = env::var("RPA_MACHINE_ID").unwrap_or_else(|_| {
|
||||
let host = hostname::get()
|
||||
.map(|h| h.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
let os_name = if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
format!("{}_{}", host, os_name)
|
||||
});
|
||||
|
||||
let server_url = env::var("RPA_SERVER_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:5005/api/v1".to_string());
|
||||
|
||||
let capture_port = env::var("RPA_CAPTURE_PORT")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5006);
|
||||
|
||||
let heartbeat_interval_s = env::var("RPA_HEARTBEAT_INTERVAL")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(5);
|
||||
|
||||
let jpeg_quality = env::var("RPA_JPEG_QUALITY")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(85);
|
||||
|
||||
Config {
|
||||
server_url,
|
||||
machine_id,
|
||||
capture_port,
|
||||
heartbeat_interval_s,
|
||||
replay_poll_interval_s: 1.0,
|
||||
jpeg_quality,
|
||||
}
|
||||
}
|
||||
|
||||
/// URL de base pour le streaming (ex: http://...:5005/api/v1/traces/stream)
|
||||
pub fn streaming_url(&self) -> String {
|
||||
format!("{}/traces/stream", self.server_url)
|
||||
}
|
||||
|
||||
/// Session ID pour le heartbeat permanent (sans session active)
|
||||
pub fn bg_session_id(&self) -> String {
|
||||
format!("bg_{}", self.machine_id)
|
||||
}
|
||||
|
||||
/// Session ID pour le polling replay (sans session active)
|
||||
pub fn agent_session_id(&self) -> String {
|
||||
format!("agent_{}", self.machine_id)
|
||||
}
|
||||
}
|
||||
|
||||
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: {} }}",
|
||||
self.server_url, self.machine_id, self.capture_port,
|
||||
self.heartbeat_interval_s, self.jpeg_quality
|
||||
)
|
||||
}
|
||||
}
|
||||
331
agent_rust/src/executor.rs
Normal file
331
agent_rust/src/executor.rs
Normal file
@@ -0,0 +1,331 @@
|
||||
//! Exécuteur d'actions pour le replay.
|
||||
//!
|
||||
//! Simule les clics souris, la saisie de texte, les combos clavier et les attentes.
|
||||
//! Utilise enigo pour la simulation, compatible Windows et Linux.
|
||||
//! Reproduit le comportement de agent_v1/core/executor.py.
|
||||
|
||||
use crate::network::{Action, ActionResult};
|
||||
use enigo::{
|
||||
Coordinate, Direction, Enigo, Key, Keyboard, Mouse, Settings,
|
||||
};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Exécute une action de replay et retourne le résultat.
|
||||
///
|
||||
/// Dispatche vers le bon handler selon le type d'action.
|
||||
/// Les coordonnées x_pct/y_pct (0.0-1.0) sont converties en pixels
|
||||
/// à partir des dimensions de l'écran.
|
||||
pub fn execute_action(
|
||||
action: &Action,
|
||||
screen_width: u32,
|
||||
screen_height: u32,
|
||||
) -> ActionResult {
|
||||
match action.action_type.as_str() {
|
||||
"click" => execute_click(action, screen_width, screen_height),
|
||||
"type" => execute_type(action, screen_width, screen_height),
|
||||
"key_combo" => execute_key_combo(action),
|
||||
"scroll" => execute_scroll(action, screen_width, screen_height),
|
||||
"wait" => execute_wait(action),
|
||||
_ => ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Type d'action inconnu : {}", action.action_type),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Exécute un clic souris aux coordonnées normalisées.
|
||||
fn execute_click(action: &Action, screen_width: u32, screen_height: u32) -> ActionResult {
|
||||
let real_x = (action.x_pct * screen_width as f64) as i32;
|
||||
let real_y = (action.y_pct * screen_height as f64) as i32;
|
||||
|
||||
println!(
|
||||
" [CLICK] ({:.3}, {:.3}) -> ({}, {}) sur ({}x{}), bouton={}",
|
||||
action.x_pct, action.y_pct, real_x, real_y, screen_width, screen_height, action.button
|
||||
);
|
||||
|
||||
let mut enigo = match Enigo::new(&Settings::default()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
return ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Impossible d'initialiser enigo : {}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Déplacer la souris
|
||||
if let Err(e) = enigo.move_mouse(real_x, real_y, Coordinate::Abs) {
|
||||
return ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Erreur deplacement souris : {}", e),
|
||||
);
|
||||
}
|
||||
|
||||
// Petit délai pour simuler le temps de réaction humain
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
|
||||
// Cliquer selon le bouton demandé
|
||||
let button = match action.button.as_str() {
|
||||
"right" => enigo::Button::Right,
|
||||
"middle" => enigo::Button::Middle,
|
||||
_ => enigo::Button::Left,
|
||||
};
|
||||
|
||||
if action.button == "double" {
|
||||
// Double-clic gauche
|
||||
if let Err(e) = enigo.button(enigo::Button::Left, Direction::Click) {
|
||||
return ActionResult::error(&action.action_id, &format!("Erreur clic : {}", e));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
if let Err(e) = enigo.button(enigo::Button::Left, Direction::Click) {
|
||||
return ActionResult::error(&action.action_id, &format!("Erreur double-clic : {}", e));
|
||||
}
|
||||
} else if let Err(e) = enigo.button(button, Direction::Click) {
|
||||
return ActionResult::error(&action.action_id, &format!("Erreur clic : {}", e));
|
||||
}
|
||||
|
||||
println!(" [CLICK] Termine.");
|
||||
ActionResult::ok(&action.action_id)
|
||||
}
|
||||
|
||||
/// Exécute une saisie de texte.
|
||||
///
|
||||
/// Si des coordonnées sont fournies (x_pct > 0), clique d'abord
|
||||
/// sur le champ avant de taper (comme en Python).
|
||||
fn execute_type(action: &Action, screen_width: u32, screen_height: u32) -> ActionResult {
|
||||
let text = &action.text;
|
||||
println!(
|
||||
" [TYPE] Texte: '{}' ({} chars)",
|
||||
if text.len() > 50 { &text[..50] } else { text },
|
||||
text.len()
|
||||
);
|
||||
|
||||
let mut enigo = match Enigo::new(&Settings::default()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
return ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Impossible d'initialiser enigo : {}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Clic préalable sur le champ si coordonnées disponibles
|
||||
if action.x_pct > 0.0 && action.y_pct > 0.0 {
|
||||
let real_x = (action.x_pct * screen_width as f64) as i32;
|
||||
let real_y = (action.y_pct * screen_height as f64) as i32;
|
||||
println!(" [TYPE] Clic prealable sur ({}, {})", real_x, real_y);
|
||||
|
||||
if let Err(e) = enigo.move_mouse(real_x, real_y, Coordinate::Abs) {
|
||||
eprintln!(" [TYPE] Erreur deplacement souris : {}", e);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
if let Err(e) = enigo.button(enigo::Button::Left, Direction::Click) {
|
||||
eprintln!(" [TYPE] Erreur clic : {}", e);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
|
||||
// Saisir le texte
|
||||
if let Err(e) = enigo.text(text) {
|
||||
return ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Erreur saisie texte : {}", e),
|
||||
);
|
||||
}
|
||||
|
||||
println!(" [TYPE] Termine.");
|
||||
ActionResult::ok(&action.action_id)
|
||||
}
|
||||
|
||||
/// Exécute une combinaison de touches.
|
||||
///
|
||||
/// Ex: ["ctrl", "a"] -> maintenir Ctrl, appuyer A, relâcher Ctrl
|
||||
/// Ex: ["enter"] -> appuyer Enter
|
||||
fn execute_key_combo(action: &Action) -> ActionResult {
|
||||
let keys = &action.keys;
|
||||
println!(" [KEY_COMBO] Touches: {:?}", keys);
|
||||
|
||||
if keys.is_empty() {
|
||||
return ActionResult::error(&action.action_id, "Aucune touche specifiee");
|
||||
}
|
||||
|
||||
let mut enigo = match Enigo::new(&Settings::default()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
return ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Impossible d'initialiser enigo : {}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Résoudre les noms de touches
|
||||
let resolved: Vec<Key> = keys
|
||||
.iter()
|
||||
.filter_map(|name| resolve_key(name))
|
||||
.collect();
|
||||
|
||||
if resolved.is_empty() {
|
||||
return ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Aucune touche reconnue dans {:?}", keys),
|
||||
);
|
||||
}
|
||||
|
||||
if resolved.len() == 1 {
|
||||
// Une seule touche : simple press/release
|
||||
if let Err(e) = enigo.key(resolved[0], Direction::Click) {
|
||||
return ActionResult::error(&action.action_id, &format!("Erreur touche : {}", e));
|
||||
}
|
||||
} else {
|
||||
// Combo : maintenir les modifieurs, taper la dernière touche, relâcher
|
||||
let (modifiers, last) = resolved.split_at(resolved.len() - 1);
|
||||
|
||||
for modifier in modifiers {
|
||||
if let Err(e) = enigo.key(*modifier, Direction::Press) {
|
||||
return ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Erreur modifier press : {}", e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
if let Err(e) = enigo.key(last[0], Direction::Click) {
|
||||
// Toujours relâcher les modifieurs même en cas d'erreur
|
||||
for modifier in modifiers.iter().rev() {
|
||||
let _ = enigo.key(*modifier, Direction::Release);
|
||||
}
|
||||
return ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Erreur touche finale : {}", e),
|
||||
);
|
||||
}
|
||||
|
||||
for modifier in modifiers.iter().rev() {
|
||||
if let Err(e) = enigo.key(*modifier, Direction::Release) {
|
||||
eprintln!(" [KEY_COMBO] Erreur release modifier : {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!(" [KEY_COMBO] Termine.");
|
||||
ActionResult::ok(&action.action_id)
|
||||
}
|
||||
|
||||
/// Exécute un scroll de souris.
|
||||
fn execute_scroll(action: &Action, screen_width: u32, screen_height: u32) -> ActionResult {
|
||||
let real_x = if action.x_pct > 0.0 {
|
||||
(action.x_pct * screen_width as f64) as i32
|
||||
} else {
|
||||
(0.5 * screen_width as f64) as i32
|
||||
};
|
||||
let real_y = if action.y_pct > 0.0 {
|
||||
(action.y_pct * screen_height as f64) as i32
|
||||
} else {
|
||||
(0.5 * screen_height as f64) as i32
|
||||
};
|
||||
|
||||
let delta = action.delta;
|
||||
println!(" [SCROLL] delta={} a ({}, {})", delta, real_x, real_y);
|
||||
|
||||
let mut enigo = match Enigo::new(&Settings::default()) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
return ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Impossible d'initialiser enigo : {}", e),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = enigo.move_mouse(real_x, real_y, Coordinate::Abs) {
|
||||
return ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Erreur deplacement souris : {}", e),
|
||||
);
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
|
||||
if let Err(e) = enigo.scroll(delta, enigo::Axis::Vertical) {
|
||||
return ActionResult::error(
|
||||
&action.action_id,
|
||||
&format!("Erreur scroll : {}", e),
|
||||
);
|
||||
}
|
||||
|
||||
println!(" [SCROLL] Termine.");
|
||||
ActionResult::ok(&action.action_id)
|
||||
}
|
||||
|
||||
/// Exécute une attente (pause).
|
||||
fn execute_wait(action: &Action) -> ActionResult {
|
||||
let duration_ms = action.duration_ms;
|
||||
println!(" [WAIT] {}ms...", duration_ms);
|
||||
thread::sleep(Duration::from_millis(duration_ms));
|
||||
println!(" [WAIT] Termine.");
|
||||
ActionResult::ok(&action.action_id)
|
||||
}
|
||||
|
||||
/// Résout un nom de touche (string) vers un enigo::Key.
|
||||
///
|
||||
/// Mapping compatible avec le Python executor (_SPECIAL_KEYS).
|
||||
fn resolve_key(name: &str) -> Option<Key> {
|
||||
match name.to_lowercase().as_str() {
|
||||
// Touches de contrôle
|
||||
"enter" | "return" => Some(Key::Return),
|
||||
"tab" => Some(Key::Tab),
|
||||
"escape" | "esc" => Some(Key::Escape),
|
||||
"backspace" => Some(Key::Backspace),
|
||||
"delete" => Some(Key::Delete),
|
||||
"space" => Some(Key::Space),
|
||||
|
||||
// Touches de navigation
|
||||
"up" => Some(Key::UpArrow),
|
||||
"down" => Some(Key::DownArrow),
|
||||
"left" => Some(Key::LeftArrow),
|
||||
"right" => Some(Key::RightArrow),
|
||||
"home" => Some(Key::Home),
|
||||
"end" => Some(Key::End),
|
||||
"page_up" | "pageup" => Some(Key::PageUp),
|
||||
"page_down" | "pagedown" => Some(Key::PageDown),
|
||||
|
||||
// Touches de fonction
|
||||
"f1" => Some(Key::F1),
|
||||
"f2" => Some(Key::F2),
|
||||
"f3" => Some(Key::F3),
|
||||
"f4" => Some(Key::F4),
|
||||
"f5" => Some(Key::F5),
|
||||
"f6" => Some(Key::F6),
|
||||
"f7" => Some(Key::F7),
|
||||
"f8" => Some(Key::F8),
|
||||
"f9" => Some(Key::F9),
|
||||
"f10" => Some(Key::F10),
|
||||
"f11" => Some(Key::F11),
|
||||
"f12" => Some(Key::F12),
|
||||
|
||||
// Modifieurs
|
||||
"ctrl" | "ctrl_l" | "ctrl_r" | "control" => Some(Key::Control),
|
||||
"alt" | "alt_l" | "alt_r" => Some(Key::Alt),
|
||||
"shift" | "shift_l" | "shift_r" => Some(Key::Shift),
|
||||
"cmd" | "win" | "super" | "super_l" | "super_r" | "windows" | "meta" => Some(Key::Meta),
|
||||
|
||||
// Touches spéciales
|
||||
"insert" => Some(Key::Other(0x2D)), // VK_INSERT
|
||||
"caps_lock" | "capslock" => Some(Key::CapsLock),
|
||||
|
||||
// Caractère unique -> Unicode
|
||||
s if s.len() == 1 => {
|
||||
let c = s.chars().next().unwrap();
|
||||
Some(Key::Unicode(c))
|
||||
}
|
||||
|
||||
_ => {
|
||||
eprintln!(" [KEY_COMBO] Touche inconnue : '{}', ignoree", name);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
207
agent_rust/src/main.rs
Normal file
207
agent_rust/src/main.rs
Normal file
@@ -0,0 +1,207 @@
|
||||
//! Agent RPA Vision — Phase 1 (headless)
|
||||
//!
|
||||
//! 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
|
||||
//!
|
||||
//! Configuration via variables d'environnement ou valeurs par defaut.
|
||||
//! Compatible avec le serveur streaming existant (api_stream.py, port 5005).
|
||||
|
||||
mod capture;
|
||||
mod config;
|
||||
mod executor;
|
||||
mod network;
|
||||
mod replay;
|
||||
mod server;
|
||||
|
||||
use config::Config;
|
||||
use reqwest::blocking::Client;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Flag global pour l'arret propre (Ctrl+C)
|
||||
static RUNNING: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
fn main() {
|
||||
let config = Config::from_env();
|
||||
|
||||
// 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();
|
||||
|
||||
// Verifier que la capture d'ecran fonctionne
|
||||
print!("[MAIN] Test de capture d'ecran... ");
|
||||
match capture::screen_dimensions() {
|
||||
Some((w, h)) => println!("OK ({}x{})", w, h),
|
||||
None => {
|
||||
println!("ECHEC");
|
||||
eprintln!("[MAIN] ATTENTION : Capture d'ecran non disponible.");
|
||||
eprintln!("[MAIN] Sur Linux sans display, les heartbeats seront desactives.");
|
||||
}
|
||||
}
|
||||
|
||||
// Thread 1 : Heartbeat loop
|
||||
let hb_config = config.clone();
|
||||
let heartbeat_thread = thread::Builder::new()
|
||||
.name("heartbeat".to_string())
|
||||
.spawn(move || {
|
||||
heartbeat_loop(&hb_config);
|
||||
})
|
||||
.expect("Impossible de demarrer le thread heartbeat");
|
||||
|
||||
// Thread 2 : Replay poll loop
|
||||
let rp_config = config.clone();
|
||||
let _replay_thread = thread::Builder::new()
|
||||
.name("replay".to_string())
|
||||
.spawn(move || {
|
||||
replay::replay_poll_loop(&rp_config);
|
||||
})
|
||||
.expect("Impossible de demarrer le thread replay");
|
||||
|
||||
// Thread 3 : Capture HTTP server
|
||||
let srv_port = config.capture_port;
|
||||
let _server_thread = thread::Builder::new()
|
||||
.name("capture-server".to_string())
|
||||
.spawn(move || {
|
||||
server::start_capture_server(srv_port);
|
||||
})
|
||||
.expect("Impossible de demarrer le thread serveur");
|
||||
|
||||
println!("\n[MAIN] Agent operationnel. Appuyez sur Ctrl+C pour quitter.\n");
|
||||
|
||||
// Bloquer le thread principal en attendant Ctrl+C
|
||||
while RUNNING.load(Ordering::SeqCst) {
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
|
||||
println!("\n[MAIN] Arret en cours...");
|
||||
|
||||
// Attendre le thread heartbeat (les autres sont daemon-like)
|
||||
let _ = heartbeat_thread.join();
|
||||
|
||||
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
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Creer un pipe pour la notification
|
||||
let mut fds = [0i32; 2];
|
||||
unsafe {
|
||||
if libc::pipe(fds.as_mut_ptr()) != 0 {
|
||||
eprintln!("[MAIN] Impossible de creer le pipe pour Ctrl+C");
|
||||
return;
|
||||
}
|
||||
|
||||
// Installer le signal handler qui ecrit dans le pipe
|
||||
static mut WRITE_FD: i32 = -1;
|
||||
WRITE_FD = fds[1];
|
||||
|
||||
extern "C" fn sigint_handler(_sig: i32) {
|
||||
unsafe {
|
||||
RUNNING.store(false, Ordering::SeqCst);
|
||||
let buf = [1u8];
|
||||
let _ = libc::write(WRITE_FD, buf.as_ptr() as *const _, 1);
|
||||
}
|
||||
}
|
||||
|
||||
libc::signal(libc::SIGINT, sigint_handler as *const () as libc::sighandler_t);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
// Sur Windows, 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)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
let client = Client::new();
|
||||
let session_id = config.bg_session_id();
|
||||
let mut last_hash: u64 = 0;
|
||||
let mut consecutive_errors: u32 = 0;
|
||||
|
||||
println!(
|
||||
"[HEARTBEAT] Boucle permanente demarree (session={}, intervalle={}s)",
|
||||
session_id, config.heartbeat_interval_s
|
||||
);
|
||||
|
||||
while RUNNING.load(Ordering::SeqCst) {
|
||||
// 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;
|
||||
|
||||
// Encoder en JPEG
|
||||
let jpeg_bytes = capture::screenshot_to_jpeg_bytes(&img, config.jpeg_quality);
|
||||
if jpeg_bytes.is_empty() {
|
||||
thread::sleep(Duration::from_secs(config.heartbeat_interval_s));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Envoyer au serveur
|
||||
let success = network::send_heartbeat(&client, config, &jpeg_bytes, &session_id);
|
||||
if success {
|
||||
consecutive_errors = 0;
|
||||
} else {
|
||||
consecutive_errors += 1;
|
||||
if consecutive_errors == 1 || consecutive_errors % 12 == 0 {
|
||||
// Log seulement la premiere erreur et toutes les minutes
|
||||
eprintln!(
|
||||
"[HEARTBEAT] {} erreur(s) consecutives",
|
||||
consecutive_errors
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_secs(config.heartbeat_interval_s));
|
||||
}
|
||||
|
||||
println!("[HEARTBEAT] 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!("------------------------------------------------------");
|
||||
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!("======================================================");
|
||||
}
|
||||
245
agent_rust/src/network.rs
Normal file
245
agent_rust/src/network.rs
Normal file
@@ -0,0 +1,245 @@
|
||||
//! Client HTTP pour la communication avec le serveur streaming.
|
||||
//!
|
||||
//! Gère l'envoi des heartbeats (screenshots périodiques),
|
||||
//! le polling des actions replay, et le rapport des résultats.
|
||||
//! Compatible avec l'API de agent_v0/server_v1/api_stream.py (port 5005).
|
||||
|
||||
use crate::config::Config;
|
||||
use reqwest::blocking::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Action de replay reçue du serveur.
|
||||
///
|
||||
/// Format identique à celui du Python executor (agent_v1/core/executor.py).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Action {
|
||||
/// Identifiant unique de l'action
|
||||
#[serde(default)]
|
||||
pub action_id: String,
|
||||
|
||||
/// Type d'action : "click", "type", "key_combo", "scroll", "wait"
|
||||
#[serde(rename = "type")]
|
||||
pub action_type: String,
|
||||
|
||||
/// Coordonnée X normalisée (0.0 à 1.0)
|
||||
#[serde(default)]
|
||||
pub x_pct: f64,
|
||||
|
||||
/// Coordonnée Y normalisée (0.0 à 1.0)
|
||||
#[serde(default)]
|
||||
pub y_pct: f64,
|
||||
|
||||
/// Texte à taper (pour action "type")
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
|
||||
/// Liste de touches (pour action "key_combo")
|
||||
#[serde(default)]
|
||||
pub keys: Vec<String>,
|
||||
|
||||
/// Bouton de souris : "left", "right", "double"
|
||||
#[serde(default = "default_button")]
|
||||
pub button: String,
|
||||
|
||||
/// Durée d'attente en ms (pour action "wait")
|
||||
#[serde(default = "default_duration")]
|
||||
pub duration_ms: u64,
|
||||
|
||||
/// Delta de scroll (pour action "scroll")
|
||||
#[serde(default)]
|
||||
pub delta: i32,
|
||||
|
||||
/// Mode visuel (résolution par le serveur)
|
||||
#[serde(default)]
|
||||
pub visual_mode: bool,
|
||||
|
||||
/// Spécification de la cible visuelle
|
||||
#[serde(default)]
|
||||
pub target_spec: serde_json::Value,
|
||||
}
|
||||
|
||||
fn default_button() -> String {
|
||||
"left".to_string()
|
||||
}
|
||||
|
||||
fn default_duration() -> u64 {
|
||||
500
|
||||
}
|
||||
|
||||
/// Résultat d'exécution d'une action.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ActionResult {
|
||||
pub action_id: String,
|
||||
pub success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub screenshot: Option<String>,
|
||||
}
|
||||
|
||||
impl ActionResult {
|
||||
/// Crée un résultat d'erreur.
|
||||
pub fn error(action_id: &str, msg: &str) -> Self {
|
||||
ActionResult {
|
||||
action_id: action_id.to_string(),
|
||||
success: false,
|
||||
error: Some(msg.to_string()),
|
||||
screenshot: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée un résultat de succès.
|
||||
pub fn ok(action_id: &str) -> Self {
|
||||
ActionResult {
|
||||
action_id: action_id.to_string(),
|
||||
success: true,
|
||||
error: None,
|
||||
screenshot: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Envoie un heartbeat (screenshot) au serveur streaming.
|
||||
///
|
||||
/// POST /traces/stream/image avec le screenshot en multipart.
|
||||
/// Retourne true si l'envoi a réussi.
|
||||
pub fn send_heartbeat(
|
||||
client: &Client,
|
||||
config: &Config,
|
||||
jpeg_bytes: &[u8],
|
||||
session_id: &str,
|
||||
) -> bool {
|
||||
let url = format!("{}/image", config.streaming_url());
|
||||
let shot_id = format!("heartbeat_{}", chrono::Utc::now().timestamp());
|
||||
|
||||
let part = reqwest::blocking::multipart::Part::bytes(jpeg_bytes.to_vec())
|
||||
.file_name("screenshot.jpg")
|
||||
.mime_str("image/jpeg")
|
||||
.unwrap_or_else(|_| {
|
||||
reqwest::blocking::multipart::Part::bytes(jpeg_bytes.to_vec())
|
||||
.file_name("screenshot.jpg")
|
||||
});
|
||||
|
||||
let form = reqwest::blocking::multipart::Form::new().part("file", part);
|
||||
|
||||
match client
|
||||
.post(&url)
|
||||
.query(&[
|
||||
("session_id", session_id),
|
||||
("shot_id", &shot_id),
|
||||
("machine_id", &config.machine_id),
|
||||
])
|
||||
.multipart(form)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
true
|
||||
} else {
|
||||
eprintln!(
|
||||
"[HEARTBEAT] Envoi echoue : HTTP {}",
|
||||
resp.status()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// Log discret pour ne pas spammer la console
|
||||
eprintln!("[HEARTBEAT] Erreur reseau : {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Réponse du serveur pour GET /replay/next
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReplayNextResponse {
|
||||
action: Option<Action>,
|
||||
}
|
||||
|
||||
/// Poll le serveur pour récupérer la prochaine action de replay.
|
||||
///
|
||||
/// GET /traces/stream/replay/next?session_id=...&machine_id=...
|
||||
/// Retourne None si pas d'action en attente ou si le serveur est indisponible.
|
||||
pub fn poll_next_action(client: &Client, config: &Config) -> Option<Action> {
|
||||
let url = format!("{}/replay/next", config.streaming_url());
|
||||
let session_id = config.agent_session_id();
|
||||
|
||||
let resp = 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()?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let data: ReplayNextResponse = resp.json().ok()?;
|
||||
data.action
|
||||
}
|
||||
|
||||
/// Rapporte le résultat d'une action au serveur.
|
||||
///
|
||||
/// POST /traces/stream/replay/result avec le résultat en JSON.
|
||||
pub fn report_result(client: &Client, config: &Config, result: &ActionResult) -> bool {
|
||||
let url = format!("{}/replay/result", config.streaming_url());
|
||||
let session_id = config.agent_session_id();
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Report<'a> {
|
||||
session_id: &'a str,
|
||||
action_id: &'a str,
|
||||
success: bool,
|
||||
error: &'a Option<String>,
|
||||
screenshot: &'a Option<String>,
|
||||
}
|
||||
|
||||
let report = Report {
|
||||
session_id: &session_id,
|
||||
action_id: &result.action_id,
|
||||
success: result.success,
|
||||
error: &result.error,
|
||||
screenshot: &result.screenshot,
|
||||
};
|
||||
|
||||
match client
|
||||
.post(&url)
|
||||
.json(&report)
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.send()
|
||||
{
|
||||
Ok(resp) => {
|
||||
if resp.status().is_success() {
|
||||
if let Ok(data) = resp.json::<serde_json::Value>() {
|
||||
let status = data.get("replay_status")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("?");
|
||||
let remaining = data.get("remaining_actions")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(-1);
|
||||
println!(
|
||||
" [RESULT] Rapporte : status={}, restant={}",
|
||||
status, remaining
|
||||
);
|
||||
}
|
||||
true
|
||||
} else {
|
||||
eprintln!(
|
||||
" [RESULT] Rapport echoue : HTTP {}",
|
||||
resp.status()
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(" [RESULT] Erreur reseau : {}", e);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
121
agent_rust/src/replay.rs
Normal file
121
agent_rust/src/replay.rs
Normal file
@@ -0,0 +1,121 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! Reproduit le comportement de _replay_poll_loop dans agent_v1/main.py.
|
||||
|
||||
use crate::capture;
|
||||
use crate::config::Config;
|
||||
use crate::executor;
|
||||
use crate::network;
|
||||
use reqwest::blocking::Client;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Boucle de polling replay (tourne dans un thread dédié).
|
||||
///
|
||||
/// - Poll GET /replay/next toutes les secondes
|
||||
/// - Exécute l'action via executor
|
||||
/// - Capture un screenshot post-action
|
||||
/// - Rapporte le résultat via POST /replay/result
|
||||
/// - Backoff exponentiel si le serveur est indisponible
|
||||
pub fn replay_poll_loop(config: &Config) {
|
||||
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 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 {
|
||||
poll_count += 1;
|
||||
|
||||
// Log périodique toutes les 60s pour confirmer que la boucle tourne
|
||||
let polls_per_minute = (60.0 / backoff).ceil() as u64;
|
||||
if polls_per_minute > 0 && poll_count % polls_per_minute == 0 {
|
||||
println!(
|
||||
"[REPLAY] Poll #{} — session={} — serveur={}",
|
||||
poll_count,
|
||||
config.agent_session_id(),
|
||||
config.server_url,
|
||||
);
|
||||
}
|
||||
|
||||
match network::poll_next_action(&client, config) {
|
||||
Some(action) => {
|
||||
// Reset backoff et flag d'erreur
|
||||
backoff = config.replay_poll_interval_s;
|
||||
last_conn_error_logged = false;
|
||||
|
||||
if !replay_active {
|
||||
replay_active = true;
|
||||
println!("[REPLAY] Replay demarre");
|
||||
}
|
||||
|
||||
let action_type = action.action_type.clone();
|
||||
let action_id = action.action_id.clone();
|
||||
println!(
|
||||
"\n>>> REPLAY ACTION RECUE : {} (id={})",
|
||||
action_type, action_id
|
||||
);
|
||||
|
||||
// Obtenir les dimensions de l'écran
|
||||
let (sw, sh) = capture::screen_dimensions().unwrap_or((1920, 1080));
|
||||
|
||||
// Exécuter l'action
|
||||
println!(">>> Execution de l'action {}...", action_type);
|
||||
let mut result = executor::execute_action(&action, sw, sh);
|
||||
println!(
|
||||
">>> Resultat execution : success={}, error={:?}",
|
||||
result.success, result.error
|
||||
);
|
||||
|
||||
// Capture screenshot post-action (après 500ms)
|
||||
thread::sleep(Duration::from_millis(500));
|
||||
if let Some(img) = capture::capture_screenshot() {
|
||||
let b64 = capture::screenshot_to_jpeg_base64(&img, 60);
|
||||
if !b64.is_empty() {
|
||||
result.screenshot = Some(b64);
|
||||
}
|
||||
}
|
||||
|
||||
// Rapporter le résultat au serveur (TOUJOURS, même en erreur)
|
||||
network::report_result(&client, config, &result);
|
||||
|
||||
// Poll plus rapidement pour enchaîner 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;
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
402
agent_rust/src/server.rs
Normal file
402
agent_rust/src/server.rs
Normal file
@@ -0,0 +1,402 @@
|
||||
//! Mini serveur HTTP pour les captures d'écran à la demande.
|
||||
//!
|
||||
//! Écoute sur le port 5006 (configurable via RPA_CAPTURE_PORT).
|
||||
//! Endpoints :
|
||||
//! GET /capture -> screenshot frais en JSON {image, width, height, format}
|
||||
//! GET /health -> {"status": "ok"}
|
||||
//! POST /file-action -> opérations fichiers (list, create, move, copy, sort)
|
||||
//!
|
||||
//! Reproduit le comportement de agent_v1/ui/capture_server.py.
|
||||
|
||||
use crate::capture;
|
||||
use serde_json::json;
|
||||
use tiny_http::{Header, Method, Response, Server};
|
||||
|
||||
/// Démarre le serveur de capture sur le port donné (bloquant).
|
||||
///
|
||||
/// Cette fonction tourne dans un thread dédié et ne retourne jamais.
|
||||
pub fn start_capture_server(port: u16) {
|
||||
let addr = format!("0.0.0.0:{}", port);
|
||||
let server = match Server::http(&addr) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("[CAPTURE] Impossible de demarrer le serveur sur {} : {}", addr, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
println!("[CAPTURE] Serveur de capture demarre sur le port {}", port);
|
||||
|
||||
for request in server.incoming_requests() {
|
||||
let url = request.url().to_string();
|
||||
let method = request.method().clone();
|
||||
|
||||
match (method, url.as_str()) {
|
||||
(Method::Get, "/capture") => handle_capture(request),
|
||||
(Method::Get, "/health") => handle_health(request),
|
||||
(Method::Post, "/file-action") => handle_file_action(request),
|
||||
(Method::Options, _) => handle_options(request),
|
||||
_ => {
|
||||
let body = json!({"error": "not found"}).to_string();
|
||||
let _ = send_json_response(request, 404, &body);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /capture — Capture un screenshot frais et le renvoie en JSON base64.
|
||||
fn handle_capture(request: tiny_http::Request) {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
match capture::capture_screenshot() {
|
||||
Some(img) => {
|
||||
let width = img.width();
|
||||
let height = img.height();
|
||||
let b64 = capture::screenshot_to_jpeg_base64(&img, 80);
|
||||
let elapsed_ms = start.elapsed().as_millis();
|
||||
|
||||
let body = json!({
|
||||
"image": b64,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"format": "jpeg",
|
||||
"source": "rust_agent",
|
||||
"capture_ms": elapsed_ms,
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let _ = send_json_response(request, 200, &body);
|
||||
}
|
||||
None => {
|
||||
let body = json!({"error": "Capture echouee"}).to_string();
|
||||
let _ = send_json_response(request, 500, &body);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// GET /health — Vérification de santé.
|
||||
fn handle_health(request: tiny_http::Request) {
|
||||
let body = json!({
|
||||
"status": "ok",
|
||||
"agent": "rust",
|
||||
"version": crate::config::AGENT_VERSION,
|
||||
})
|
||||
.to_string();
|
||||
let _ = send_json_response(request, 200, &body);
|
||||
}
|
||||
|
||||
/// POST /file-action — Opérations fichiers sur la machine locale.
|
||||
///
|
||||
/// Body JSON attendu : {"action": "file_list_dir", "params": {"path": "C:\\..."}}
|
||||
/// Actions supportées : file_list_dir, file_create_dir, file_move, file_copy, file_sort_by_ext
|
||||
fn handle_file_action(mut request: tiny_http::Request) {
|
||||
// Lire le body
|
||||
let mut body_str = String::new();
|
||||
if let Err(e) = request.as_reader().read_to_string(&mut body_str) {
|
||||
let resp = json!({"error": format!("Erreur lecture body : {}", e)}).to_string();
|
||||
let _ = send_json_response(request, 400, &resp);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parser le JSON
|
||||
let data: serde_json::Value = match serde_json::from_str(&body_str) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
let resp = json!({"error": "JSON invalide"}).to_string();
|
||||
let _ = send_json_response(request, 400, &resp);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let action = data.get("action").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let params = data.get("params").cloned().unwrap_or(json!({}));
|
||||
|
||||
if action.is_empty() {
|
||||
let resp = json!({"error": "Parametre 'action' requis"}).to_string();
|
||||
let _ = send_json_response(request, 400, &resp);
|
||||
return;
|
||||
}
|
||||
|
||||
let result = execute_file_action(action, ¶ms);
|
||||
let code = if result.get("error").is_some() { 500 } else { 200 };
|
||||
let _ = send_json_response(request, code, &result.to_string());
|
||||
}
|
||||
|
||||
/// OPTIONS — Réponse CORS preflight.
|
||||
fn handle_options(request: tiny_http::Request) {
|
||||
let response = Response::empty(200)
|
||||
.with_header(cors_origin())
|
||||
.with_header(cors_methods())
|
||||
.with_header(cors_headers());
|
||||
let _ = request.respond(response);
|
||||
}
|
||||
|
||||
/// Exécute une action fichier.
|
||||
fn execute_file_action(action: &str, params: &serde_json::Value) -> serde_json::Value {
|
||||
match action {
|
||||
"file_list_dir" => {
|
||||
let path = params.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let pattern = params
|
||||
.get("pattern")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("*");
|
||||
|
||||
if path.is_empty() {
|
||||
return json!({"error": "Parametre 'path' requis"});
|
||||
}
|
||||
if !is_safe_path(path) {
|
||||
return json!({"error": format!("Chemin non autorise : {}", path)});
|
||||
}
|
||||
|
||||
match std::fs::read_dir(path) {
|
||||
Ok(entries) => {
|
||||
let mut files = Vec::new();
|
||||
let mut extensions: std::collections::HashMap<String, u32> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if let Ok(metadata) = entry.metadata() {
|
||||
if metadata.is_file() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
// Filtrage par pattern (simple glob avec *)
|
||||
if pattern != "*" && !simple_glob_match(pattern, &name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let ext = std::path::Path::new(&name)
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_else(|| "sans_extension".to_string());
|
||||
|
||||
files.push(json!({
|
||||
"name": name,
|
||||
"extension": ext,
|
||||
"size": metadata.len(),
|
||||
"path": entry.path().to_string_lossy(),
|
||||
}));
|
||||
|
||||
*extensions.entry(ext).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
"files": files,
|
||||
"count": files.len(),
|
||||
"extensions": extensions,
|
||||
"path": path,
|
||||
})
|
||||
}
|
||||
Err(e) => json!({"error": format!("Erreur lecture dossier : {}", e)}),
|
||||
}
|
||||
}
|
||||
|
||||
"file_create_dir" => {
|
||||
let path = params.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
||||
if path.is_empty() {
|
||||
return json!({"error": "Parametre 'path' requis"});
|
||||
}
|
||||
if !is_safe_path(path) {
|
||||
return json!({"error": format!("Chemin non autorise : {}", path)});
|
||||
}
|
||||
|
||||
let existed = std::path::Path::new(path).exists();
|
||||
match std::fs::create_dir_all(path) {
|
||||
Ok(_) => json!({
|
||||
"created": !existed,
|
||||
"path": path,
|
||||
"already_existed": existed,
|
||||
}),
|
||||
Err(e) => json!({"error": format!("Erreur creation dossier : {}", e)}),
|
||||
}
|
||||
}
|
||||
|
||||
"file_move" => {
|
||||
let src = params.get("source").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let dst = params
|
||||
.get("destination")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if src.is_empty() || dst.is_empty() {
|
||||
return json!({"error": "Parametres 'source' et 'destination' requis"});
|
||||
}
|
||||
if !is_safe_path(src) || !is_safe_path(dst) {
|
||||
return json!({"error": "Chemin non autorise"});
|
||||
}
|
||||
|
||||
// Créer le dossier parent de destination
|
||||
if let Some(parent) = std::path::Path::new(dst).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
match std::fs::rename(src, dst) {
|
||||
Ok(_) => json!({"moved": true, "source": src, "destination": dst}),
|
||||
Err(e) => json!({"error": format!("Erreur deplacement : {}", e)}),
|
||||
}
|
||||
}
|
||||
|
||||
"file_copy" => {
|
||||
let src = params.get("source").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let dst = params
|
||||
.get("destination")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if src.is_empty() || dst.is_empty() {
|
||||
return json!({"error": "Parametres 'source' et 'destination' requis"});
|
||||
}
|
||||
if !is_safe_path(src) || !is_safe_path(dst) {
|
||||
return json!({"error": "Chemin non autorise"});
|
||||
}
|
||||
|
||||
if let Some(parent) = std::path::Path::new(dst).parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
|
||||
match std::fs::copy(src, dst) {
|
||||
Ok(_) => json!({"copied": true, "source": src, "destination": dst}),
|
||||
Err(e) => json!({"error": format!("Erreur copie : {}", e)}),
|
||||
}
|
||||
}
|
||||
|
||||
"file_sort_by_ext" => {
|
||||
let source_dir = params
|
||||
.get("source_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let create_subdirs = params
|
||||
.get("create_subdirs")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true);
|
||||
|
||||
if source_dir.is_empty() {
|
||||
return json!({"error": "Parametre 'source_dir' requis"});
|
||||
}
|
||||
if !is_safe_path(source_dir) {
|
||||
return json!({"error": format!("Chemin non autorise : {}", source_dir)});
|
||||
}
|
||||
|
||||
let mut moved = Vec::new();
|
||||
let mut extensions: std::collections::HashMap<String, u32> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(source_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if let Ok(metadata) = entry.metadata() {
|
||||
if metadata.is_file() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
let ext = std::path::Path::new(&name)
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_lowercase())
|
||||
.unwrap_or_else(|| "sans_extension".to_string());
|
||||
|
||||
let target_dir =
|
||||
std::path::Path::new(source_dir).join(&ext);
|
||||
|
||||
if create_subdirs {
|
||||
let _ = std::fs::create_dir_all(&target_dir);
|
||||
} else if !target_dir.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let dest = target_dir.join(&name);
|
||||
if let Err(e) = std::fs::rename(entry.path(), &dest) {
|
||||
eprintln!("[FILE] Erreur deplacement {} : {}", name, e);
|
||||
continue;
|
||||
}
|
||||
|
||||
moved.push(json!({
|
||||
"file": name,
|
||||
"to": ext,
|
||||
"destination": dest.to_string_lossy(),
|
||||
}));
|
||||
*extensions.entry(ext).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
json!({
|
||||
"moved": moved,
|
||||
"count": moved.len(),
|
||||
"extensions": extensions,
|
||||
"source_dir": source_dir,
|
||||
})
|
||||
}
|
||||
|
||||
_ => json!({"error": format!("Action fichier inconnue : {}", action)}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Vérifie qu'un chemin est dans une zone autorisée (sécurité anti-traversal).
|
||||
///
|
||||
/// Sur Windows : C:\Users, D:\, E:\
|
||||
/// Sur Linux : /home, /tmp (pour les tests)
|
||||
fn is_safe_path(path_str: &str) -> bool {
|
||||
if path_str.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normaliser le chemin
|
||||
let normalized = std::path::Path::new(path_str)
|
||||
.to_string_lossy()
|
||||
.to_uppercase();
|
||||
|
||||
if cfg!(target_os = "windows") {
|
||||
let allowed = ["C:\\USERS", "D:\\", "E:\\"];
|
||||
allowed.iter().any(|root| normalized.starts_with(root))
|
||||
} else {
|
||||
// Sur Linux (pour les tests)
|
||||
let allowed = ["/HOME", "/TMP"];
|
||||
allowed.iter().any(|root| normalized.starts_with(root))
|
||||
}
|
||||
}
|
||||
|
||||
/// Matching glob simple (supporte * comme wildcard).
|
||||
fn simple_glob_match(pattern: &str, name: &str) -> bool {
|
||||
if pattern == "*" {
|
||||
return true;
|
||||
}
|
||||
// Pattern simple : *.ext
|
||||
if let Some(ext) = pattern.strip_prefix("*.") {
|
||||
return name.to_lowercase().ends_with(&format!(".{}", ext.to_lowercase()));
|
||||
}
|
||||
// Sinon, comparaison exacte
|
||||
name.to_lowercase() == pattern.to_lowercase()
|
||||
}
|
||||
|
||||
// --- Headers CORS ---
|
||||
|
||||
fn cors_origin() -> Header {
|
||||
Header::from_bytes("Access-Control-Allow-Origin", "*").unwrap()
|
||||
}
|
||||
|
||||
fn cors_methods() -> Header {
|
||||
Header::from_bytes("Access-Control-Allow-Methods", "GET, POST, OPTIONS").unwrap()
|
||||
}
|
||||
|
||||
fn cors_headers() -> Header {
|
||||
Header::from_bytes("Access-Control-Allow-Headers", "Content-Type").unwrap()
|
||||
}
|
||||
|
||||
/// Envoie une réponse JSON avec les headers CORS.
|
||||
fn send_json_response(
|
||||
request: tiny_http::Request,
|
||||
status_code: u16,
|
||||
body: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let status = tiny_http::StatusCode(status_code);
|
||||
let content_type = Header::from_bytes("Content-Type", "application/json").unwrap();
|
||||
|
||||
let response = Response::from_string(body)
|
||||
.with_status_code(status)
|
||||
.with_header(content_type)
|
||||
.with_header(cors_origin())
|
||||
.with_header(cors_methods())
|
||||
.with_header(cors_headers());
|
||||
|
||||
request.respond(response)?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user