chore: ménage — suppression agent Rust (5.6 GB) + vieux packages déploiement
- agent_rust/ supprimé entièrement (on reste sur Python pour Léa) - deploy/build/Lea/ supprimé (package stale avec fichiers obsolètes) - deploy/build_lea_exe.sh supprimé (script PyInstaller Rust, obsolète) - window_info*.py dupliqués retirés du package Windows - __pycache__ nettoyé du deploy Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
agent_rust/.gitignore
vendored
2
agent_rust/.gitignore
vendored
@@ -1,2 +0,0 @@
|
|||||||
/target
|
|
||||||
Cargo.lock
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
[package]
|
|
||||||
name = "rpa-agent"
|
|
||||||
version = "0.2.0"
|
|
||||||
edition = "2021"
|
|
||||||
description = "Agent RPA Vision - Lea (Phases 1-5)"
|
|
||||||
|
|
||||||
[dependencies]
|
|
||||||
# Capture d'ecran
|
|
||||||
xcap = "0.7"
|
|
||||||
|
|
||||||
# Simulation souris/clavier (replay)
|
|
||||||
enigo = { version = "0.3", features = ["serde"] }
|
|
||||||
|
|
||||||
# Capture evenements souris/clavier (recording) — Phase 5
|
|
||||||
rdev = "0.5"
|
|
||||||
|
|
||||||
# Client HTTP (mode bloquant, pas de tokio)
|
|
||||||
reqwest = { version = "0.12", features = ["blocking", "multipart", "json"] }
|
|
||||||
|
|
||||||
# Traitement d'images (JPEG encode, resize, crop)
|
|
||||||
image = "0.25"
|
|
||||||
|
|
||||||
# Floutage zones sensibles — Phase 5
|
|
||||||
imageproc = "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"
|
|
||||||
|
|
||||||
# Canaux inter-threads performants
|
|
||||||
crossbeam-channel = "0.5"
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
log = "0.4"
|
|
||||||
env_logger = "0.11"
|
|
||||||
|
|
||||||
# Signal handling Unix (Ctrl+C)
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
|
||||||
libc = "0.2"
|
|
||||||
|
|
||||||
# Dependances Windows uniquement — Phases 3-5
|
|
||||||
[target.'cfg(windows)'.dependencies]
|
|
||||||
# Systray — Phase 3
|
|
||||||
tray-icon = "0.19"
|
|
||||||
muda = "0.15"
|
|
||||||
|
|
||||||
# Boucle d'evenements — Phase 3
|
|
||||||
winit = { version = "0.30", features = ["rwh_06"] }
|
|
||||||
|
|
||||||
# Notifications toast — Phase 3
|
|
||||||
winrt-notification = "0.5"
|
|
||||||
|
|
||||||
# Chat WebView2 — Phase 4
|
|
||||||
wry = "0.48"
|
|
||||||
|
|
||||||
# Raw window handle pour wry + fenetre native
|
|
||||||
raw-window-handle = "0.6"
|
|
||||||
|
|
||||||
# Win32 API (info fenetre, dialogues, etc.)
|
|
||||||
windows-sys = { version = "0.59", features = [
|
|
||||||
"Win32_UI_WindowsAndMessaging",
|
|
||||||
"Win32_System_Threading",
|
|
||||||
"Win32_System_LibraryLoader",
|
|
||||||
"Win32_Foundation",
|
|
||||||
"Win32_Graphics_Gdi",
|
|
||||||
] }
|
|
||||||
|
|
||||||
[profile.release]
|
|
||||||
opt-level = "z"
|
|
||||||
lto = true
|
|
||||||
strip = true
|
|
||||||
codegen-units = 1
|
|
||||||
panic = "abort"
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
╔══════════════════════════════════════════╗
|
|
||||||
║ 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.
|
|
||||||
────────────────────────────────────────────
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
# RPA Vision Agent (Rust) — Phases 1-5
|
|
||||||
|
|
||||||
Agent complet pour RPA Vision V3, ecrit en Rust.
|
|
||||||
Parite fonctionnelle avec l'agent Python (`agent_v0/agent_v1/`) en un seul executable de 2.4 Mo.
|
|
||||||
|
|
||||||
## Fonctionnalites
|
|
||||||
|
|
||||||
### Phase 1 — Agent minimal (headless)
|
|
||||||
- **Heartbeat** : capture ecran toutes les 5s, JPEG, dedup par hash perceptuel
|
|
||||||
- **Replay** : poll serveur, execute actions (click, type, key_combo, scroll, wait)
|
|
||||||
- **Resolution visuelle** : resolution de cibles via le serveur (template matching)
|
|
||||||
- **Serveur de capture** : port 5006 (GET /capture, GET /health, POST /file-action)
|
|
||||||
|
|
||||||
### Phase 3 — Systray + Notifications
|
|
||||||
- **Systray** : icone avec cercle colore (gris=idle, rouge=enregistrement, vert=connecte, bleu=replay)
|
|
||||||
- **Menu contextuel** : Machine ID, statut, Apprenez-moi, C'est termine, Mes taches, ARRET D'URGENCE, Chat, Fichiers, Quitter
|
|
||||||
- **Notifications toast** : via winrt-notification (bienvenue, session, replay, connexion)
|
|
||||||
- **Etat partage** : thread-safe via AtomicBool + Mutex
|
|
||||||
|
|
||||||
### Phase 4 — Chat WebView2
|
|
||||||
- **WebView2** : fenetre 520x720, charge http://{server}:5004/chat
|
|
||||||
- **Positionnement** : bas-droite pres du systray
|
|
||||||
- **Fallback** : HTML embarque si le serveur est indisponible
|
|
||||||
- **Toggle** : show/hide via menu systray
|
|
||||||
|
|
||||||
### Phase 5 — Parite complete
|
|
||||||
- **Enregistrement** : capture evenements souris/clavier via rdev, envoi au serveur
|
|
||||||
- **Floutage** : detection de champs de saisie + blur gaussien (protection donnees sensibles)
|
|
||||||
- **Configuration** : BLUR_SENSITIVE, LOG_RETENTION_DAYS, CHAT_PORT
|
|
||||||
- **Health check** : verification connexion serveur toutes les 30s
|
|
||||||
|
|
||||||
## Build
|
|
||||||
|
|
||||||
### Linux (pour tests)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo apt install libpipewire-0.3-dev libclang-dev libgbm-dev libxdo-dev
|
|
||||||
cargo build --release
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cross-compilation vers Windows
|
|
||||||
|
|
||||||
```bash
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
### Deploiement sur le PC cible
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sshpass -p 'loli' scp -o StrictHostKeyChecking=no \
|
|
||||||
target/x86_64-pc-windows-gnu/release/rpa-agent.exe \
|
|
||||||
dom@192.168.1.11:"C:\\rpa_vision\\rpa-agent.exe"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
| 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) |
|
|
||||||
| `RPA_BLUR_SENSITIVE` | `true` | Flouter les zones sensibles |
|
|
||||||
| `RPA_LOG_RETENTION_DAYS` | `180` | Retention des logs (jours) |
|
|
||||||
| `RPA_CHAT_PORT` | `5004` | Port du serveur de chat |
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── main.rs — Orchestrateur, 7 threads (heartbeat, replay, serveur, health, recorder, chat, tray)
|
|
||||||
├── config.rs — Configuration (env vars + defauts)
|
|
||||||
├── state.rs — Etat partage thread-safe (AtomicBool, Mutex)
|
|
||||||
├── capture.rs — Capture ecran (xcap), JPEG, hash perceptuel
|
|
||||||
├── network.rs — Client HTTP (heartbeat, poll replay, rapport resultat)
|
|
||||||
├── replay.rs — Boucle de polling replay avec notifications
|
|
||||||
├── executor.rs — Execution actions (click, type, key_combo, scroll, wait)
|
|
||||||
├── visual.rs — Resolution visuelle des cibles via le serveur
|
|
||||||
├── server.rs — Mini serveur HTTP port 5006 (/capture, /health, /file-action)
|
|
||||||
├── tray.rs — Icone systray + menu contextuel (tray-icon, winit)
|
|
||||||
├── notifications.rs — Notifications toast Windows (winrt-notification)
|
|
||||||
├── chat.rs — Fenetre de chat WebView2 (wry)
|
|
||||||
├── recorder.rs — Capture evenements souris/clavier (rdev)
|
|
||||||
└── blur.rs — Floutage zones sensibles (detection + box blur)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Taille du binaire
|
|
||||||
|
|
||||||
| Configuration | Taille |
|
|
||||||
|---|---|
|
|
||||||
| Release (LTO + strip + opt-level z) | **2.4 Mo** |
|
|
||||||
| Python equivalent (venv + packages) | ~200 Mo |
|
|
||||||
|
|
||||||
## Compatibilite
|
|
||||||
|
|
||||||
- **OS** : Windows 10/11 (systray, notifications, chat WebView2)
|
|
||||||
- **Fallback Linux** : mode console (heartbeat, replay, serveur)
|
|
||||||
- **Serveur** : compatible api_stream.py (port 5005)
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
#!/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."
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
# === 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
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
//! Floutage des zones sensibles dans les captures d'ecran.
|
|
||||||
//!
|
|
||||||
//! Detecte les champs de saisie (zones claires rectangulaires) et applique
|
|
||||||
//! un flou gaussien pour proteger les donnees sensibles (mots de passe, etc.).
|
|
||||||
//! Equivalent de agent_v1/vision/blur_sensitive.py.
|
|
||||||
//!
|
|
||||||
//! Algorithme :
|
|
||||||
//! 1. Conversion en niveaux de gris
|
|
||||||
//! 2. Seuillage binaire (detecter les zones claires = champs de saisie)
|
|
||||||
//! 3. Detection de contours rectangulaires > 50px de large
|
|
||||||
//! 4. Application d'un flou gaussien sur les zones detectees
|
|
||||||
//!
|
|
||||||
//! Utilise le crate image pour le traitement et imageproc pour le flou.
|
|
||||||
|
|
||||||
use image::{DynamicImage, GrayImage, Rgba, RgbaImage};
|
|
||||||
|
|
||||||
/// Seuil de luminosite pour detecter les champs de saisie (0-255).
|
|
||||||
/// Les zones plus claires que ce seuil sont considerees comme des champs.
|
|
||||||
const BRIGHTNESS_THRESHOLD: u8 = 220;
|
|
||||||
|
|
||||||
/// Largeur minimale d'un champ de saisie detecte (en pixels).
|
|
||||||
const MIN_FIELD_WIDTH: u32 = 50;
|
|
||||||
|
|
||||||
/// Hauteur minimale d'un champ de saisie detecte (en pixels).
|
|
||||||
const MIN_FIELD_HEIGHT: u32 = 15;
|
|
||||||
|
|
||||||
/// Hauteur maximale d'un champ de saisie (evite de flouter l'ecran entier).
|
|
||||||
const MAX_FIELD_HEIGHT: u32 = 80;
|
|
||||||
|
|
||||||
/// Largeur maximale d'un champ (evite les faux positifs sur grandes zones blanches).
|
|
||||||
const MAX_FIELD_WIDTH: u32 = 800;
|
|
||||||
|
|
||||||
/// Intensite du flou gaussien (sigma).
|
|
||||||
const BLUR_SIGMA: f32 = 10.0;
|
|
||||||
|
|
||||||
/// Rectangle representant une zone a flouter.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct BlurRegion {
|
|
||||||
pub x: u32,
|
|
||||||
pub y: u32,
|
|
||||||
pub width: u32,
|
|
||||||
pub height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detecte les champs de saisie dans une image et les floute.
|
|
||||||
///
|
|
||||||
/// Retourne l'image modifiee avec les zones sensibles floutees.
|
|
||||||
/// Si aucun champ n'est detecte, retourne l'image inchangee.
|
|
||||||
pub fn blur_sensitive_fields(img: &DynamicImage) -> DynamicImage {
|
|
||||||
let regions = detect_input_fields(img);
|
|
||||||
|
|
||||||
if regions.is_empty() {
|
|
||||||
return img.clone();
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"[BLUR] {} zone(s) sensible(s) detectee(s) — floutage...",
|
|
||||||
regions.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
let mut result = img.to_rgba8();
|
|
||||||
|
|
||||||
for region in ®ions {
|
|
||||||
blur_region(&mut result, region);
|
|
||||||
}
|
|
||||||
|
|
||||||
DynamicImage::ImageRgba8(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Detecte les champs de saisie (zones claires rectangulaires).
|
|
||||||
///
|
|
||||||
/// Algorithme simplifie :
|
|
||||||
/// 1. Convertir en niveaux de gris
|
|
||||||
/// 2. Seuillage binaire
|
|
||||||
/// 3. Scanner les lignes horizontales pour trouver les series de pixels clairs
|
|
||||||
/// 4. Regrouper les series adjacentes en rectangles
|
|
||||||
pub fn detect_input_fields(img: &DynamicImage) -> Vec<BlurRegion> {
|
|
||||||
let gray = img.to_luma8();
|
|
||||||
let (width, height) = gray.dimensions();
|
|
||||||
let mut regions = Vec::new();
|
|
||||||
|
|
||||||
// Creer une image binaire (seuillage)
|
|
||||||
let binary = threshold_image(&gray, BRIGHTNESS_THRESHOLD);
|
|
||||||
|
|
||||||
// Scanner par bandes horizontales pour detecter les champs
|
|
||||||
// On cherche des sequences continues de pixels blancs sur plusieurs lignes
|
|
||||||
let mut y = 0;
|
|
||||||
while y < height {
|
|
||||||
// Pour chaque ligne, trouver les segments horizontaux blancs
|
|
||||||
let segments = find_white_segments(&binary, y, width);
|
|
||||||
|
|
||||||
for (seg_start, seg_end) in &segments {
|
|
||||||
let seg_width = seg_end - seg_start;
|
|
||||||
if seg_width < MIN_FIELD_WIDTH || seg_width > MAX_FIELD_WIDTH {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verifier combien de lignes consecutives partagent ce segment
|
|
||||||
let field_height = count_vertical_extent(
|
|
||||||
&binary,
|
|
||||||
*seg_start,
|
|
||||||
*seg_end,
|
|
||||||
y,
|
|
||||||
height,
|
|
||||||
);
|
|
||||||
|
|
||||||
if field_height >= MIN_FIELD_HEIGHT && field_height <= MAX_FIELD_HEIGHT {
|
|
||||||
// Verifier que cette region ne chevauche pas une region existante
|
|
||||||
let new_region = BlurRegion {
|
|
||||||
x: *seg_start,
|
|
||||||
y,
|
|
||||||
width: seg_width,
|
|
||||||
height: field_height,
|
|
||||||
};
|
|
||||||
|
|
||||||
if !overlaps_existing(®ions, &new_region) {
|
|
||||||
regions.push(new_region);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Avancer de la hauteur du dernier champ detecte, ou de 1 ligne
|
|
||||||
y += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deduplication : fusionner les regions tres proches
|
|
||||||
merge_close_regions(&mut regions);
|
|
||||||
|
|
||||||
regions
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applique un seuillage binaire simple.
|
|
||||||
fn threshold_image(gray: &GrayImage, threshold: u8) -> GrayImage {
|
|
||||||
let (width, height) = gray.dimensions();
|
|
||||||
let mut binary = GrayImage::new(width, height);
|
|
||||||
|
|
||||||
for y in 0..height {
|
|
||||||
for x in 0..width {
|
|
||||||
let pixel = gray.get_pixel(x, y).0[0];
|
|
||||||
if pixel >= threshold {
|
|
||||||
binary.put_pixel(x, y, image::Luma([255]));
|
|
||||||
} else {
|
|
||||||
binary.put_pixel(x, y, image::Luma([0]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
binary
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Trouve les segments horizontaux de pixels blancs sur une ligne.
|
|
||||||
fn find_white_segments(binary: &GrayImage, y: u32, width: u32) -> Vec<(u32, u32)> {
|
|
||||||
let mut segments = Vec::new();
|
|
||||||
let mut in_segment = false;
|
|
||||||
let mut seg_start = 0u32;
|
|
||||||
|
|
||||||
for x in 0..width {
|
|
||||||
let is_white = binary.get_pixel(x, y).0[0] > 128;
|
|
||||||
|
|
||||||
if is_white && !in_segment {
|
|
||||||
seg_start = x;
|
|
||||||
in_segment = true;
|
|
||||||
} else if !is_white && in_segment {
|
|
||||||
segments.push((seg_start, x));
|
|
||||||
in_segment = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if in_segment {
|
|
||||||
segments.push((seg_start, width));
|
|
||||||
}
|
|
||||||
|
|
||||||
segments
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compte le nombre de lignes consecutives ou le segment est blanc.
|
|
||||||
fn count_vertical_extent(
|
|
||||||
binary: &GrayImage,
|
|
||||||
seg_start: u32,
|
|
||||||
seg_end: u32,
|
|
||||||
start_y: u32,
|
|
||||||
max_y: u32,
|
|
||||||
) -> u32 {
|
|
||||||
let mut count = 0u32;
|
|
||||||
let check_width = seg_end - seg_start;
|
|
||||||
let threshold = (check_width as f64 * 0.7) as u32; // 70% doivent etre blancs
|
|
||||||
|
|
||||||
for y in start_y..max_y.min(start_y + MAX_FIELD_HEIGHT + 5) {
|
|
||||||
let mut white_count = 0u32;
|
|
||||||
for x in seg_start..seg_end {
|
|
||||||
if binary.get_pixel(x, y).0[0] > 128 {
|
|
||||||
white_count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if white_count >= threshold {
|
|
||||||
count += 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verifie si une region chevauche une region existante.
|
|
||||||
fn overlaps_existing(regions: &[BlurRegion], new_region: &BlurRegion) -> bool {
|
|
||||||
for region in regions {
|
|
||||||
let x_overlap = new_region.x < region.x + region.width
|
|
||||||
&& new_region.x + new_region.width > region.x;
|
|
||||||
let y_overlap = new_region.y < region.y + region.height
|
|
||||||
&& new_region.y + new_region.height > region.y;
|
|
||||||
|
|
||||||
if x_overlap && y_overlap {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fusionne les regions tres proches (< 10px de distance).
|
|
||||||
fn merge_close_regions(regions: &mut Vec<BlurRegion>) {
|
|
||||||
if regions.len() < 2 {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tri par position (y, puis x)
|
|
||||||
regions.sort_by(|a, b| a.y.cmp(&b.y).then(a.x.cmp(&b.x)));
|
|
||||||
|
|
||||||
let mut merged = Vec::new();
|
|
||||||
let mut current = regions[0].clone();
|
|
||||||
|
|
||||||
for region in regions.iter().skip(1) {
|
|
||||||
let x_close = (current.x + current.width + 10 >= region.x)
|
|
||||||
&& (region.x + region.width + 10 >= current.x);
|
|
||||||
let y_close = (current.y + current.height + 5 >= region.y)
|
|
||||||
&& (region.y + region.height + 5 >= current.y);
|
|
||||||
|
|
||||||
if x_close && y_close {
|
|
||||||
// Fusionner
|
|
||||||
let min_x = current.x.min(region.x);
|
|
||||||
let min_y = current.y.min(region.y);
|
|
||||||
let max_x = (current.x + current.width).max(region.x + region.width);
|
|
||||||
let max_y = (current.y + current.height).max(region.y + region.height);
|
|
||||||
|
|
||||||
current = BlurRegion {
|
|
||||||
x: min_x,
|
|
||||||
y: min_y,
|
|
||||||
width: max_x - min_x,
|
|
||||||
height: max_y - min_y,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
merged.push(current);
|
|
||||||
current = region.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
merged.push(current);
|
|
||||||
|
|
||||||
*regions = merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Applique un flou gaussien sur une region de l'image.
|
|
||||||
///
|
|
||||||
/// Implementation simplifiee : box blur avec plusieurs passes
|
|
||||||
/// (approximation du gaussien, plus rapide que le vrai gaussien).
|
|
||||||
fn blur_region(img: &mut RgbaImage, region: &BlurRegion) {
|
|
||||||
let (img_w, img_h) = img.dimensions();
|
|
||||||
|
|
||||||
// Borner la region aux dimensions de l'image
|
|
||||||
let x_start = region.x.min(img_w);
|
|
||||||
let y_start = region.y.min(img_h);
|
|
||||||
let x_end = (region.x + region.width).min(img_w);
|
|
||||||
let y_end = (region.y + region.height).min(img_h);
|
|
||||||
|
|
||||||
if x_start >= x_end || y_start >= y_end {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let radius = BLUR_SIGMA as u32;
|
|
||||||
let kernel_size = (radius * 2 + 1) as i32;
|
|
||||||
let kernel_area = (kernel_size * kernel_size) as u32;
|
|
||||||
|
|
||||||
// Box blur : moyenne des pixels dans un carre de rayon `radius`
|
|
||||||
// On fait 3 passes pour approximer un flou gaussien
|
|
||||||
for _pass in 0..3 {
|
|
||||||
// Copier les pixels de la region dans un buffer temporaire
|
|
||||||
let reg_w = (x_end - x_start) as usize;
|
|
||||||
let reg_h = (y_end - y_start) as usize;
|
|
||||||
let mut buffer: Vec<[u8; 4]> = Vec::with_capacity(reg_w * reg_h);
|
|
||||||
|
|
||||||
for y in y_start..y_end {
|
|
||||||
for x in x_start..x_end {
|
|
||||||
buffer.push(img.get_pixel(x, y).0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Appliquer le box blur
|
|
||||||
for y in y_start..y_end {
|
|
||||||
for x in x_start..x_end {
|
|
||||||
let mut sum_r = 0u32;
|
|
||||||
let mut sum_g = 0u32;
|
|
||||||
let mut sum_b = 0u32;
|
|
||||||
let mut count = 0u32;
|
|
||||||
|
|
||||||
for ky in -(radius as i32)..=(radius as i32) {
|
|
||||||
for kx in -(radius as i32)..=(radius as i32) {
|
|
||||||
let sx = x as i32 + kx;
|
|
||||||
let sy = y as i32 + ky;
|
|
||||||
|
|
||||||
if sx >= x_start as i32
|
|
||||||
&& sx < x_end as i32
|
|
||||||
&& sy >= y_start as i32
|
|
||||||
&& sy < y_end as i32
|
|
||||||
{
|
|
||||||
let bx = (sx - x_start as i32) as usize;
|
|
||||||
let by = (sy - y_start as i32) as usize;
|
|
||||||
let pixel = buffer[by * reg_w + bx];
|
|
||||||
sum_r += pixel[0] as u32;
|
|
||||||
sum_g += pixel[1] as u32;
|
|
||||||
sum_b += pixel[2] as u32;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if count > 0 {
|
|
||||||
let pixel = Rgba([
|
|
||||||
(sum_r / count) as u8,
|
|
||||||
(sum_g / count) as u8,
|
|
||||||
(sum_b / count) as u8,
|
|
||||||
255,
|
|
||||||
]);
|
|
||||||
img.put_pixel(x, y, pixel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = kernel_area; // suppress unused warning
|
|
||||||
}
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
//! 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).
|
|
||||||
///
|
|
||||||
/// 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
|
|
||||||
.into_iter()
|
|
||||||
.find(|m| m.is_primary().unwrap_or(false))?;
|
|
||||||
let w = primary.width().ok()?;
|
|
||||||
let h = primary.height().ok()?;
|
|
||||||
Some((w, h))
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
//! Chat Léa via Edge en mode app (--app=URL).
|
|
||||||
//!
|
|
||||||
//! Ouvre Edge sans barre d'adresse — rendu propre et professionnel.
|
|
||||||
//! Equivalent de agent_v1/ui/chat_window.py (approche Edge mode app).
|
|
||||||
|
|
||||||
use crate::config::Config;
|
|
||||||
use crate::state::AgentState;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::process::Command;
|
|
||||||
|
|
||||||
/// URL du serveur de chat
|
|
||||||
fn chat_url(config: &Config) -> String {
|
|
||||||
config.chat_url()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Chemin de Edge sur Windows (via le registre ou chemins courants)
|
|
||||||
fn find_edge() -> Option<String> {
|
|
||||||
let paths = [
|
|
||||||
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
|
|
||||||
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
|
|
||||||
];
|
|
||||||
for p in &paths {
|
|
||||||
if std::path::Path::new(p).exists() {
|
|
||||||
return Some(p.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Essayer via le registre
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
use std::process::Command;
|
|
||||||
if let Ok(output) = Command::new("reg")
|
|
||||||
.args(&["query", r"HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\msedge.exe", "/ve"])
|
|
||||||
.output()
|
|
||||||
{
|
|
||||||
let text = String::from_utf8_lossy(&output.stdout);
|
|
||||||
for line in text.lines() {
|
|
||||||
if line.contains("REG_SZ") {
|
|
||||||
if let Some(path) = line.split("REG_SZ").last() {
|
|
||||||
let path = path.trim();
|
|
||||||
if std::path::Path::new(path).exists() {
|
|
||||||
return Some(path.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Lance le chat dans un thread.
|
|
||||||
///
|
|
||||||
/// Attend que `state.chat_visible` passe à true, puis ouvre Edge en mode app.
|
|
||||||
/// Quand la fenêtre est fermée, remet `chat_visible` à false.
|
|
||||||
pub fn run_chat_thread(config: &Config, state: Arc<AgentState>) {
|
|
||||||
let url = chat_url(config);
|
|
||||||
let edge_path = find_edge();
|
|
||||||
|
|
||||||
if let Some(ref path) = edge_path {
|
|
||||||
println!("[CHAT] Edge trouvé : {}", path);
|
|
||||||
} else {
|
|
||||||
println!("[CHAT] Edge non trouvé — fallback navigateur par défaut");
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// Attendre l'activation
|
|
||||||
while !state.chat_visible.load(std::sync::atomic::Ordering::Relaxed) {
|
|
||||||
if !state.is_running() {
|
|
||||||
println!("[CHAT] Arrêt du thread chat");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("[CHAT] Ouverture du chat...");
|
|
||||||
println!("[CHAT] URL : {}", url);
|
|
||||||
|
|
||||||
let result = if let Some(ref path) = edge_path {
|
|
||||||
// Edge en mode app — fenêtre propre sans barre d'adresse
|
|
||||||
Command::new(path)
|
|
||||||
.args(&[
|
|
||||||
&format!("--app={}", url),
|
|
||||||
"--window-size=600,800",
|
|
||||||
"--window-position=1300,200",
|
|
||||||
"--disable-extensions",
|
|
||||||
"--no-first-run",
|
|
||||||
])
|
|
||||||
.spawn()
|
|
||||||
} else {
|
|
||||||
// Fallback : ouvrir dans le navigateur par défaut
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
Command::new("cmd")
|
|
||||||
.args(&["/C", "start", &url])
|
|
||||||
.spawn()
|
|
||||||
}
|
|
||||||
#[cfg(not(target_os = "windows"))]
|
|
||||||
{
|
|
||||||
Command::new("xdg-open")
|
|
||||||
.arg(&url)
|
|
||||||
.spawn()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(mut child) => {
|
|
||||||
println!("[CHAT] Fenêtre ouverte (PID: {:?})", child.id());
|
|
||||||
// Attendre que la fenêtre se ferme
|
|
||||||
let _ = child.wait();
|
|
||||||
println!("[CHAT] Fenêtre fermée");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("[CHAT] Erreur ouverture : {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Marquer comme invisible
|
|
||||||
state.chat_visible.store(false, std::sync::atomic::Ordering::Relaxed);
|
|
||||||
|
|
||||||
// Petit délai avant de pouvoir réouvrir
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
//! 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";
|
|
||||||
|
|
||||||
/// Configuration complete de l'agent
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Config {
|
|
||||||
/// URL de base du serveur streaming (ex: http://192.168.1.10:5005/api/v1)
|
|
||||||
pub server_url: String,
|
|
||||||
|
|
||||||
/// Identifiant unique de la machine (hostname_os par defaut)
|
|
||||||
pub machine_id: String,
|
|
||||||
|
|
||||||
/// Port du mini-serveur HTTP de capture (defaut: 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,
|
|
||||||
|
|
||||||
/// Qualite JPEG pour les screenshots envoyes (1-100)
|
|
||||||
pub jpeg_quality: u8,
|
|
||||||
|
|
||||||
/// Flouter les zones sensibles dans les captures (defaut: true)
|
|
||||||
pub blur_sensitive: bool,
|
|
||||||
|
|
||||||
/// Retention des logs en jours (Article 12, Reglement IA, defaut: 180)
|
|
||||||
pub log_retention_days: u32,
|
|
||||||
|
|
||||||
/// Port du serveur de chat (defaut: 5004)
|
|
||||||
pub chat_port: u16,
|
|
||||||
|
|
||||||
/// 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)
|
|
||||||
/// - `RPA_CAPTURE_PORT` : Port du serveur de capture (defaut: 5006)
|
|
||||||
/// - `RPA_HEARTBEAT_INTERVAL` : Intervalle heartbeat en secondes (defaut: 5)
|
|
||||||
/// - `RPA_JPEG_QUALITY` : Qualite JPEG (defaut: 85)
|
|
||||||
/// - `RPA_BLUR_SENSITIVE` : Flouter les zones sensibles (defaut: true)
|
|
||||||
/// - `RPA_LOG_RETENTION_DAYS` : Retention des logs en jours (defaut: 180)
|
|
||||||
/// - `RPA_CHAT_PORT` : Port du serveur de chat (defaut: 5004)
|
|
||||||
/// - `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())
|
|
||||||
.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);
|
|
||||||
|
|
||||||
let blur_sensitive = env::var("RPA_BLUR_SENSITIVE")
|
|
||||||
.map(|v| v != "0" && v.to_lowercase() != "false")
|
|
||||||
.unwrap_or(true);
|
|
||||||
|
|
||||||
let log_retention_days = env::var("RPA_LOG_RETENTION_DAYS")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(180);
|
|
||||||
|
|
||||||
let chat_port = env::var("RPA_CHAT_PORT")
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| v.parse().ok())
|
|
||||||
.unwrap_or(5004);
|
|
||||||
|
|
||||||
let api_token = env::var("RPA_API_TOKEN").unwrap_or_default();
|
|
||||||
|
|
||||||
Config {
|
|
||||||
server_url,
|
|
||||||
machine_id,
|
|
||||||
capture_port,
|
|
||||||
heartbeat_interval_s,
|
|
||||||
replay_poll_interval_s: 1.0,
|
|
||||||
jpeg_quality,
|
|
||||||
blur_sensitive,
|
|
||||||
log_retention_days,
|
|
||||||
chat_port,
|
|
||||||
api_token,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// URL du serveur de chat.
|
|
||||||
pub fn chat_url(&self) -> String {
|
|
||||||
// Extraire le host du server_url
|
|
||||||
let base = &self.server_url;
|
|
||||||
if let Some(host_start) = base.find("://") {
|
|
||||||
let after_scheme = &base[host_start + 3..];
|
|
||||||
if let Some(colon_pos) = after_scheme.find(':') {
|
|
||||||
let host = &after_scheme[..colon_pos];
|
|
||||||
return format!(
|
|
||||||
"http://{}:{}/?machine_id={}",
|
|
||||||
host, self.chat_port, self.machine_id
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
format!(
|
|
||||||
"http://localhost:{}/?machine_id={}",
|
|
||||||
self.chat_port, self.machine_id
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for Config {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
write!(
|
|
||||||
f,
|
|
||||||
"Config {{ server: {}, machine: {}, capture_port: {}, heartbeat: {}s, jpeg_q: {}, 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" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,384 +0,0 @@
|
|||||||
//! 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::config::Config;
|
|
||||||
use crate::network::{Action, ActionResult};
|
|
||||||
use crate::visual;
|
|
||||||
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.
|
|
||||||
/// Si visual_mode est activé, résout d'abord la cible via le serveur.
|
|
||||||
pub fn execute_action(
|
|
||||||
action: &Action,
|
|
||||||
screen_width: u32,
|
|
||||||
screen_height: u32,
|
|
||||||
config: &Config,
|
|
||||||
) -> ActionResult {
|
|
||||||
match action.action_type.as_str() {
|
|
||||||
"click" => execute_click(action, screen_width, screen_height, config),
|
|
||||||
"type" => execute_type(action, screen_width, screen_height, config),
|
|
||||||
"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),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Résout les coordonnées visuellement si visual_mode est activé.
|
|
||||||
///
|
|
||||||
/// Si la résolution échoue, retourne les coordonnées de fallback (blind).
|
|
||||||
/// Si visual_mode est désactivé ou target_spec absent, retourne les coordonnées originales.
|
|
||||||
fn resolve_coordinates(
|
|
||||||
action: &Action,
|
|
||||||
screen_width: u32,
|
|
||||||
screen_height: u32,
|
|
||||||
config: &Config,
|
|
||||||
) -> (f64, f64) {
|
|
||||||
let mut x_pct = action.x_pct;
|
|
||||||
let mut y_pct = action.y_pct;
|
|
||||||
|
|
||||||
if action.visual_mode && !action.target_spec.is_null() {
|
|
||||||
println!(
|
|
||||||
" [VISUAL] Mode visuel active — resolution de la cible..."
|
|
||||||
);
|
|
||||||
match visual::resolve_target_visual(
|
|
||||||
config,
|
|
||||||
&action.target_spec,
|
|
||||||
x_pct,
|
|
||||||
y_pct,
|
|
||||||
screen_width,
|
|
||||||
screen_height,
|
|
||||||
) {
|
|
||||||
Some((rx, ry)) => {
|
|
||||||
println!(" [VISUAL] Resolu : ({:.4}, {:.4})", rx, ry);
|
|
||||||
x_pct = rx;
|
|
||||||
y_pct = ry;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
println!(
|
|
||||||
" [VISUAL] Echec — fallback coordonnees aveugles ({:.4}, {:.4})",
|
|
||||||
x_pct, y_pct
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(x_pct, y_pct)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Exécute un clic souris aux coordonnées normalisées.
|
|
||||||
/// Résout visuellement la cible si visual_mode est activé.
|
|
||||||
fn execute_click(action: &Action, screen_width: u32, screen_height: u32, config: &Config) -> ActionResult {
|
|
||||||
let (x_pct, y_pct) = resolve_coordinates(action, screen_width, screen_height, config);
|
|
||||||
let real_x = (x_pct * screen_width as f64) as i32;
|
|
||||||
let real_y = (y_pct * screen_height as f64) as i32;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
" [CLICK] ({:.4}, {:.4}) -> ({}, {}) sur ({}x{}), bouton={}{}",
|
|
||||||
x_pct, y_pct, real_x, real_y, screen_width, screen_height, action.button,
|
|
||||||
if action.visual_mode { " [VISUAL]" } else { "" }
|
|
||||||
);
|
|
||||||
|
|
||||||
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, config: &Config) -> ActionResult {
|
|
||||||
let text = &action.text;
|
|
||||||
println!(
|
|
||||||
" [TYPE] Texte: '{}' ({} chars)",
|
|
||||||
if text.len() > 50 { &text[..50] } else { text },
|
|
||||||
text.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Résoudre visuellement les coordonnées si visual_mode est activé
|
|
||||||
let (x_pct, y_pct) = resolve_coordinates(action, screen_width, screen_height, config);
|
|
||||||
|
|
||||||
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 x_pct > 0.0 && y_pct > 0.0 {
|
|
||||||
let real_x = (x_pct * screen_width as f64) as i32;
|
|
||||||
let real_y = (y_pct * screen_height as f64) as i32;
|
|
||||||
println!(" [TYPE] Clic prealable sur ({}, {}){}", real_x, real_y,
|
|
||||||
if action.visual_mode { " [VISUAL]" } else { "" });
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
//! Agent RPA Vision — Phases 1-5 (parite complete)
|
|
||||||
//!
|
|
||||||
//! Point d'entree principal. Architecture multi-threads :
|
|
||||||
//!
|
|
||||||
//! - Thread principal : boucle d'evenements systray (Windows) ou attente console (Linux)
|
|
||||||
//! - Thread heartbeat : capture + envoi toutes les 5s (avec dedup par hash)
|
|
||||||
//! - Thread replay : poll toutes les 1s, execute les actions
|
|
||||||
//! - Thread serveur : HTTP port 5006 pour les captures a la demande
|
|
||||||
//! - Thread recorder : capture evenements souris/clavier (quand enregistrement actif)
|
|
||||||
//! - Thread chat : fenetre WebView2 (Windows, a la demande)
|
|
||||||
//! - Thread health : verification connexion serveur (toutes les 30s)
|
|
||||||
//!
|
|
||||||
//! Le thread principal gere le systray sur Windows via winit.
|
|
||||||
//! Sur Linux, le thread principal attend Ctrl+C (mode console).
|
|
||||||
//!
|
|
||||||
//! Configuration via variables d'environnement ou valeurs par defaut.
|
|
||||||
//! Compatible avec le serveur streaming existant (api_stream.py, port 5005).
|
|
||||||
|
|
||||||
#[allow(dead_code)]
|
|
||||||
mod blur;
|
|
||||||
mod capture;
|
|
||||||
mod chat;
|
|
||||||
mod config;
|
|
||||||
mod executor;
|
|
||||||
mod network;
|
|
||||||
#[allow(dead_code)]
|
|
||||||
mod notifications;
|
|
||||||
mod recorder;
|
|
||||||
mod replay;
|
|
||||||
mod server;
|
|
||||||
#[allow(dead_code)]
|
|
||||||
mod state;
|
|
||||||
mod sysinfo;
|
|
||||||
mod tray;
|
|
||||||
mod visual;
|
|
||||||
|
|
||||||
use config::Config;
|
|
||||||
use reqwest::blocking::Client;
|
|
||||||
use state::AgentState;
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// Trouve un navigateur compatible sur Windows (Edge, Chrome, Brave, Firefox)
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
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() {
|
|
||||||
return Some(p.to_string());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
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"),
|
|
||||||
)
|
|
||||||
.format_timestamp_secs()
|
|
||||||
.init();
|
|
||||||
|
|
||||||
let config = Config::from_env();
|
|
||||||
let config = Arc::new(config);
|
|
||||||
|
|
||||||
// Etat partage thread-safe
|
|
||||||
let state = AgentState::new();
|
|
||||||
|
|
||||||
// Banniere de demarrage
|
|
||||||
print_banner(&config);
|
|
||||||
|
|
||||||
// Handler Ctrl+C pour arret propre
|
|
||||||
install_ctrlc_handler(state.clone());
|
|
||||||
|
|
||||||
// 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 hb_state = state.clone();
|
|
||||||
let _heartbeat_thread = thread::Builder::new()
|
|
||||||
.name("heartbeat".to_string())
|
|
||||||
.spawn(move || {
|
|
||||||
heartbeat_loop(&hb_config, &hb_state);
|
|
||||||
})
|
|
||||||
.expect("Impossible de demarrer le thread heartbeat");
|
|
||||||
|
|
||||||
// Thread 2 : Replay poll loop
|
|
||||||
let rp_config = config.clone();
|
|
||||||
let rp_state = state.clone();
|
|
||||||
let _replay_thread = thread::Builder::new()
|
|
||||||
.name("replay".to_string())
|
|
||||||
.spawn(move || {
|
|
||||||
replay::replay_poll_loop(&rp_config, &rp_state);
|
|
||||||
})
|
|
||||||
.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");
|
|
||||||
|
|
||||||
// Thread 4 : Health check (verification connexion serveur)
|
|
||||||
let hc_config = config.clone();
|
|
||||||
let hc_state = state.clone();
|
|
||||||
let _health_thread = thread::Builder::new()
|
|
||||||
.name("health-check".to_string())
|
|
||||||
.spawn(move || {
|
|
||||||
health_check_loop(&hc_config, &hc_state);
|
|
||||||
})
|
|
||||||
.expect("Impossible de demarrer le thread health check");
|
|
||||||
|
|
||||||
// Thread 5 : Recorder (capture evenements — inactif jusqu'a enregistrement)
|
|
||||||
let rec_config = config.clone();
|
|
||||||
let rec_state = state.clone();
|
|
||||||
let _recorder_rx = recorder::start_recorder(rec_config, rec_state);
|
|
||||||
|
|
||||||
// Thread 6 : Chat window (WebView2, a la demande)
|
|
||||||
let chat_config = config.clone();
|
|
||||||
let chat_state = state.clone();
|
|
||||||
chat::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 dans le navigateur disponible (mode app) au démarrage
|
|
||||||
#[cfg(target_os = "windows")]
|
|
||||||
{
|
|
||||||
let chat_url = config.chat_url();
|
|
||||||
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",
|
|
||||||
"--disable-extensions",
|
|
||||||
"--no-first-run",
|
|
||||||
])
|
|
||||||
.spawn();
|
|
||||||
} else {
|
|
||||||
println!("[MAIN] Aucun navigateur trouvé — ouvrez manuellement : {}", chat_url);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attente principale : Ctrl+C pour arrêter
|
|
||||||
println!("[MAIN] Appuyez sur Ctrl+C pour quitter.\n");
|
|
||||||
loop {
|
|
||||||
if !state.is_running() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_millis(500));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si on arrive ici, l'agent doit s'arreter
|
|
||||||
println!("\n[MAIN] Arret en cours...");
|
|
||||||
state.request_shutdown();
|
|
||||||
|
|
||||||
// Laisser le temps aux threads de se terminer
|
|
||||||
thread::sleep(Duration::from_millis(500));
|
|
||||||
|
|
||||||
println!("[MAIN] Agent arrete.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Installe un handler Ctrl+C qui met l'etat a "arret demande".
|
|
||||||
fn install_ctrlc_handler(state: Arc<AgentState>) {
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
static mut WRITE_FD: i32 = -1;
|
|
||||||
WRITE_FD = fds[1];
|
|
||||||
|
|
||||||
// Sauvegarder un pointeur vers l'etat dans une static
|
|
||||||
// pour pouvoir y acceder depuis le handler
|
|
||||||
static mut STATE_PTR: *const AgentState = std::ptr::null();
|
|
||||||
STATE_PTR = Arc::as_ptr(&state);
|
|
||||||
|
|
||||||
extern "C" fn sigint_handler(_sig: i32) {
|
|
||||||
unsafe {
|
|
||||||
if !STATE_PTR.is_null() {
|
|
||||||
(*STATE_PTR)
|
|
||||||
.running
|
|
||||||
.store(false, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
let buf = [1u8];
|
|
||||||
let _ = libc::write(WRITE_FD, buf.as_ptr() as *const _, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libc::signal(libc::SIGINT, sigint_handler as *const () as libc::sighandler_t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
|
||||||
{
|
|
||||||
// Sur Windows, le systray gere l'arret via le menu "Quitter"
|
|
||||||
// Le handler console est un bonus pour le mode headless
|
|
||||||
let _ = state;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Boucle de heartbeat : capture un screenshot toutes les N secondes
|
|
||||||
/// et l'envoie au serveur si l'ecran a change.
|
|
||||||
/// Applique le floutage des zones sensibles si active dans la config.
|
|
||||||
fn heartbeat_loop(config: &Config, state: &AgentState) {
|
|
||||||
let client = Client::new();
|
|
||||||
let session_id = config.bg_session_id();
|
|
||||||
let mut last_hash: u64 = 0;
|
|
||||||
let mut consecutive_errors: u32 = 0;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"[HEARTBEAT] Boucle permanente demarree (session={}, intervalle={}s)",
|
|
||||||
session_id, config.heartbeat_interval_s
|
|
||||||
);
|
|
||||||
|
|
||||||
while state.is_running() {
|
|
||||||
// Verifier l'arret d'urgence
|
|
||||||
if state
|
|
||||||
.emergency_stop
|
|
||||||
.load(std::sync::atomic::Ordering::SeqCst)
|
|
||||||
{
|
|
||||||
thread::sleep(Duration::from_secs(1));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capturer l'ecran
|
|
||||||
match capture::capture_screenshot() {
|
|
||||||
Some(img) => {
|
|
||||||
// Deduplication par hash perceptuel
|
|
||||||
let current_hash = capture::image_hash(&img);
|
|
||||||
if current_hash == last_hash {
|
|
||||||
thread::sleep(Duration::from_secs(config.heartbeat_interval_s));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
last_hash = current_hash;
|
|
||||||
|
|
||||||
// Appliquer le floutage des zones sensibles si active
|
|
||||||
let final_img = if config.blur_sensitive {
|
|
||||||
blur::blur_sensitive_fields(&img)
|
|
||||||
} else {
|
|
||||||
img
|
|
||||||
};
|
|
||||||
|
|
||||||
// Encoder en JPEG
|
|
||||||
let jpeg_bytes =
|
|
||||||
capture::screenshot_to_jpeg_bytes(&final_img, config.jpeg_quality);
|
|
||||||
if jpeg_bytes.is_empty() {
|
|
||||||
thread::sleep(Duration::from_secs(config.heartbeat_interval_s));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Envoyer au serveur
|
|
||||||
let success =
|
|
||||||
network::send_heartbeat(&client, config, &jpeg_bytes, &session_id);
|
|
||||||
if success {
|
|
||||||
consecutive_errors = 0;
|
|
||||||
} else {
|
|
||||||
consecutive_errors += 1;
|
|
||||||
if consecutive_errors == 1 || consecutive_errors % 12 == 0 {
|
|
||||||
eprintln!(
|
|
||||||
"[HEARTBEAT] {} erreur(s) consecutives",
|
|
||||||
consecutive_errors
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
thread::sleep(Duration::from_secs(config.heartbeat_interval_s * 2));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
thread::sleep(Duration::from_secs(config.heartbeat_interval_s));
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("[HEARTBEAT] Boucle arretee.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Boucle de health check : verifie la connexion au serveur toutes les 30s.
|
|
||||||
/// Met a jour l'etat de connexion dans AgentState.
|
|
||||||
fn health_check_loop(config: &Config, state: &AgentState) {
|
|
||||||
let client = Client::new();
|
|
||||||
let check_interval = Duration::from_secs(30);
|
|
||||||
let timeout = Duration::from_secs(5);
|
|
||||||
|
|
||||||
println!("[HEALTH] Boucle health check demarree (intervalle=30s)");
|
|
||||||
|
|
||||||
while state.is_running() {
|
|
||||||
let url = format!("{}/stats", config.server_url);
|
|
||||||
let request = client.get(&url).timeout(timeout);
|
|
||||||
let connected = network::with_auth(request, config)
|
|
||||||
.send()
|
|
||||||
.map(|r| r.status().is_success())
|
|
||||||
.unwrap_or(false);
|
|
||||||
|
|
||||||
let was_connected = state.connected.load(std::sync::atomic::Ordering::SeqCst);
|
|
||||||
state.set_connected(connected);
|
|
||||||
|
|
||||||
// Notifier si le statut a change
|
|
||||||
if connected != was_connected {
|
|
||||||
notifications::connection_changed(connected);
|
|
||||||
}
|
|
||||||
|
|
||||||
thread::sleep(check_interval);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("[HEALTH] Boucle arretee.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Affiche la banniere de demarrage.
|
|
||||||
fn print_banner(config: &Config) {
|
|
||||||
let meta = sysinfo::get_screen_metadata();
|
|
||||||
|
|
||||||
println!("======================================================");
|
|
||||||
println!(
|
|
||||||
" RPA Vision Agent v{} (Rust)",
|
|
||||||
config::AGENT_VERSION
|
|
||||||
);
|
|
||||||
println!(" Phases 1-5 — Parite complete");
|
|
||||||
println!("------------------------------------------------------");
|
|
||||||
println!(" Machine : {}", config.machine_id);
|
|
||||||
println!(" Serveur : {}", config.server_url);
|
|
||||||
println!(" Capture : port {}", config.capture_port);
|
|
||||||
println!(" Chat : port {}", config.chat_port);
|
|
||||||
println!(" Heartbeat : toutes les {}s", config.heartbeat_interval_s);
|
|
||||||
println!(" JPEG : qualite {}", config.jpeg_quality);
|
|
||||||
println!(" Floutage : {}", if config.blur_sensitive { "actif" } else { "inactif" });
|
|
||||||
println!(" Logs : retention {} jours", config.log_retention_days);
|
|
||||||
println!(" 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.");
|
|
||||||
println!(" Article 50 du Reglement europeen sur l'IA.");
|
|
||||||
println!();
|
|
||||||
}
|
|
||||||
@@ -1,391 +0,0 @@
|
|||||||
//! 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 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).
|
|
||||||
#[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.
|
|
||||||
/// 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,
|
|
||||||
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());
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
.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);
|
|
||||||
|
|
||||||
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));
|
|
||||||
|
|
||||||
match with_auth(request, config).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 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));
|
|
||||||
|
|
||||||
let resp = with_auth(request, config).send().ok()?;
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let data: ReplayNextResponse = resp.json().ok()?;
|
|
||||||
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.
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
let request = client
|
|
||||||
.post(&url)
|
|
||||||
.json(&report)
|
|
||||||
.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>() {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
//! Notifications toast Windows.
|
|
||||||
//!
|
|
||||||
//! Affiche des notifications natives Windows via l'API WinRT (winrt-notification).
|
|
||||||
//! Equivalent de agent_v1/ui/notifications.py.
|
|
||||||
//!
|
|
||||||
//! Sur Linux/macOS : les notifications sont simplement affichees en console (log).
|
|
||||||
//! Le crate winrt-notification n'est disponible que sur Windows.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
|
|
||||||
/// Intervalle minimum entre deux notifications identiques (en secondes).
|
|
||||||
/// Evite le spam de notifications si le meme evenement se repete.
|
|
||||||
const MIN_INTERVAL_SECS: u64 = 5;
|
|
||||||
|
|
||||||
/// Timestamp de la derniere notification envoyee (rate limiting).
|
|
||||||
static LAST_NOTIFY_TIME: AtomicU64 = AtomicU64::new(0);
|
|
||||||
|
|
||||||
/// Affiche une notification toast native.
|
|
||||||
///
|
|
||||||
/// Sur Windows : utilise winrt-notification pour les toasts natifs.
|
|
||||||
/// Sur les autres OS : affiche en console.
|
|
||||||
/// Rate-limited : pas plus d'une notification toutes les 5 secondes.
|
|
||||||
pub fn notify(title: &str, message: &str) {
|
|
||||||
// Rate limiting
|
|
||||||
let now = SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.unwrap_or_default()
|
|
||||||
.as_secs();
|
|
||||||
let last = LAST_NOTIFY_TIME.load(Ordering::Relaxed);
|
|
||||||
if now - last < MIN_INTERVAL_SECS {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
LAST_NOTIFY_TIME.store(now, Ordering::Relaxed);
|
|
||||||
|
|
||||||
// Log console dans tous les cas
|
|
||||||
println!("[NOTIFICATION] {} : {}", title, message);
|
|
||||||
|
|
||||||
// Toast natif Windows
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
notify_windows(title, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Implementation Windows via winrt-notification.
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn notify_windows(title: &str, message: &str) {
|
|
||||||
use winrt_notification::{Toast, Sound};
|
|
||||||
|
|
||||||
let result = Toast::new(Toast::POWERSHELL_APP_ID)
|
|
||||||
.title(title)
|
|
||||||
.text1(message)
|
|
||||||
.sound(Some(Sound::Default))
|
|
||||||
.show();
|
|
||||||
|
|
||||||
if let Err(e) = result {
|
|
||||||
eprintln!("[NOTIFICATION] Erreur toast Windows : {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Notifications predefinies (equivalent Python) ---
|
|
||||||
|
|
||||||
/// Notification de bienvenue au demarrage.
|
|
||||||
pub fn greet() {
|
|
||||||
notify(
|
|
||||||
"Lea - Assistant IA",
|
|
||||||
"Bonjour ! Lea est prete. (IA)\nJe peux observer et automatiser vos taches.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notification de debut de session d'enregistrement.
|
|
||||||
pub fn session_started(name: &str) {
|
|
||||||
notify(
|
|
||||||
"Enregistrement demarre",
|
|
||||||
&format!(
|
|
||||||
"C'est parti ! Je regarde et je memorise.\nSession : {}",
|
|
||||||
name
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notification de fin de session d'enregistrement.
|
|
||||||
pub fn session_ended(actions_count: u32) {
|
|
||||||
notify(
|
|
||||||
"Enregistrement termine",
|
|
||||||
&format!(
|
|
||||||
"C'est note ! J'ai compris les {} etapes.",
|
|
||||||
actions_count
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notification de debut de replay.
|
|
||||||
pub fn replay_started(name: &str) {
|
|
||||||
notify(
|
|
||||||
"Replay en cours",
|
|
||||||
&format!(
|
|
||||||
"Le systeme d'IA execute la tache...\nWorkflow : {}",
|
|
||||||
name
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notification de fin de replay.
|
|
||||||
pub fn replay_finished(success: bool) {
|
|
||||||
if success {
|
|
||||||
notify("Replay termine", "C'est fait ! La tache a ete executee avec succes.");
|
|
||||||
} else {
|
|
||||||
notify(
|
|
||||||
"Replay echoue",
|
|
||||||
"Hmm, j'ai eu un souci. Verifiez le resultat.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notification de changement de connexion.
|
|
||||||
pub fn connection_changed(connected: bool) {
|
|
||||||
if connected {
|
|
||||||
notify("Connexion etablie", "Connectee au serveur RPA Vision.");
|
|
||||||
} else {
|
|
||||||
notify(
|
|
||||||
"Connexion perdue",
|
|
||||||
"Connexion au serveur perdue. Tentative de reconnexion...",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Notification d'arret d'urgence.
|
|
||||||
pub fn emergency_stop_activated() {
|
|
||||||
notify(
|
|
||||||
"ARRET D'URGENCE",
|
|
||||||
"Toutes les operations ont ete arretees immediatement.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,713 +0,0 @@
|
|||||||
//! Capture d'evenements souris/clavier pour l'enregistrement de sessions.
|
|
||||||
//!
|
|
||||||
//! Utilise rdev pour intercepter les evenements globaux (sans focus).
|
|
||||||
//! Les evenements sont envoyes au serveur streaming via network.rs.
|
|
||||||
//! Equivalent de agent_v1/core/captor.py.
|
|
||||||
//!
|
|
||||||
//! Le recorder est actif uniquement quand state.recording == true.
|
|
||||||
//! Il capture :
|
|
||||||
//! - Clics souris (gauche, droit, double-clic)
|
|
||||||
//! - Saisie clavier (buffer de texte avec flush apres 500ms d'inactivite)
|
|
||||||
//! - Combos clavier (Ctrl+C, Alt+Tab, etc.)
|
|
||||||
//!
|
|
||||||
//! Sur les OS non-Windows, rdev fonctionne aussi (Linux via X11/evdev)
|
|
||||||
//! mais les tests doivent etre faits manuellement.
|
|
||||||
|
|
||||||
use crate::capture;
|
|
||||||
use crate::config::Config;
|
|
||||||
use crate::state::AgentState;
|
|
||||||
use crossbeam_channel::{bounded, Receiver, Sender};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::thread;
|
|
||||||
use std::time::{Duration, Instant};
|
|
||||||
|
|
||||||
/// Evenement capture et pret a etre envoye au serveur.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum CapturedEvent {
|
|
||||||
/// Clic souris (x_pct, y_pct, bouton, window_title)
|
|
||||||
Click {
|
|
||||||
x_pct: f64,
|
|
||||||
y_pct: f64,
|
|
||||||
button: String,
|
|
||||||
window_title: String,
|
|
||||||
},
|
|
||||||
/// Double-clic (x_pct, y_pct, window_title)
|
|
||||||
DoubleClick {
|
|
||||||
x_pct: f64,
|
|
||||||
y_pct: f64,
|
|
||||||
window_title: String,
|
|
||||||
},
|
|
||||||
/// Texte saisi (accumule via le buffer de frappe)
|
|
||||||
Text {
|
|
||||||
text: String,
|
|
||||||
x_pct: f64,
|
|
||||||
y_pct: f64,
|
|
||||||
},
|
|
||||||
/// Combo clavier (ex: ["ctrl", "c"])
|
|
||||||
KeyCombo { keys: Vec<String> },
|
|
||||||
/// Scroll (delta, x_pct, y_pct)
|
|
||||||
Scroll {
|
|
||||||
delta: i32,
|
|
||||||
x_pct: f64,
|
|
||||||
y_pct: f64,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Etat interne du recorder pour le buffer de frappe.
|
|
||||||
struct RecorderState {
|
|
||||||
/// Buffer de texte en cours (flush apres 500ms d'inactivite)
|
|
||||||
text_buffer: String,
|
|
||||||
/// Dernier timestamp de frappe (pour le flush timeout)
|
|
||||||
last_keystroke: Instant,
|
|
||||||
/// Position du curseur au debut de la saisie
|
|
||||||
text_start_x: f64,
|
|
||||||
text_start_y: f64,
|
|
||||||
/// Derniere position du clic (pour le double-clic)
|
|
||||||
last_click_time: Instant,
|
|
||||||
last_click_x: f64,
|
|
||||||
last_click_y: f64,
|
|
||||||
/// Modifieurs actuellement enfonces
|
|
||||||
ctrl_held: bool,
|
|
||||||
alt_held: bool,
|
|
||||||
shift_held: bool,
|
|
||||||
meta_held: bool,
|
|
||||||
/// Dimensions de l'ecran (pour normaliser les coordonnees)
|
|
||||||
screen_width: u32,
|
|
||||||
screen_height: u32,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl RecorderState {
|
|
||||||
fn new(screen_width: u32, screen_height: u32) -> Self {
|
|
||||||
Self {
|
|
||||||
text_buffer: String::new(),
|
|
||||||
last_keystroke: Instant::now(),
|
|
||||||
text_start_x: 0.0,
|
|
||||||
text_start_y: 0.0,
|
|
||||||
last_click_time: Instant::now() - Duration::from_secs(10),
|
|
||||||
last_click_x: 0.0,
|
|
||||||
last_click_y: 0.0,
|
|
||||||
ctrl_held: false,
|
|
||||||
alt_held: false,
|
|
||||||
shift_held: false,
|
|
||||||
meta_held: false,
|
|
||||||
screen_width,
|
|
||||||
screen_height,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Normalise les coordonnees absolues en pourcentages (0.0-1.0).
|
|
||||||
fn normalize(&self, x: f64, y: f64) -> (f64, f64) {
|
|
||||||
if self.screen_width == 0 || self.screen_height == 0 {
|
|
||||||
return (0.0, 0.0);
|
|
||||||
}
|
|
||||||
(
|
|
||||||
x / self.screen_width as f64,
|
|
||||||
y / self.screen_height as f64,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Un modifieur est-il enfonce ?
|
|
||||||
fn any_modifier_held(&self) -> bool {
|
|
||||||
self.ctrl_held || self.alt_held || self.meta_held
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delai de flush du buffer de texte (ms).
|
|
||||||
const TEXT_FLUSH_DELAY_MS: u64 = 500;
|
|
||||||
|
|
||||||
/// Seuil de distance pour considerer un double-clic (pixels).
|
|
||||||
const DOUBLE_CLICK_DIST_THRESHOLD: f64 = 10.0;
|
|
||||||
|
|
||||||
/// Seuil de temps pour un double-clic (ms).
|
|
||||||
const DOUBLE_CLICK_TIME_MS: u64 = 400;
|
|
||||||
|
|
||||||
/// Demarre le thread de capture d'evenements.
|
|
||||||
///
|
|
||||||
/// Cree un canal crossbeam pour envoyer les evenements captures
|
|
||||||
/// vers le thread d'envoi reseau. Le listener rdev tourne dans
|
|
||||||
/// un thread dedie car il bloque (callback-based).
|
|
||||||
pub fn start_recorder(
|
|
||||||
config: Arc<Config>,
|
|
||||||
state: Arc<AgentState>,
|
|
||||||
) -> Receiver<CapturedEvent> {
|
|
||||||
let (tx, rx) = bounded::<CapturedEvent>(100);
|
|
||||||
|
|
||||||
// Thread du listener rdev
|
|
||||||
let listener_state = state.clone();
|
|
||||||
let listener_tx = tx.clone();
|
|
||||||
thread::Builder::new()
|
|
||||||
.name("event-listener".to_string())
|
|
||||||
.spawn(move || {
|
|
||||||
event_listener_loop(listener_tx, listener_state);
|
|
||||||
})
|
|
||||||
.expect("Impossible de demarrer le thread listener");
|
|
||||||
|
|
||||||
// Thread de flush du buffer de texte
|
|
||||||
let flush_tx = tx;
|
|
||||||
let flush_state = state.clone();
|
|
||||||
thread::Builder::new()
|
|
||||||
.name("text-flush".to_string())
|
|
||||||
.spawn(move || {
|
|
||||||
text_flush_loop(flush_tx, flush_state);
|
|
||||||
})
|
|
||||||
.expect("Impossible de demarrer le thread flush");
|
|
||||||
|
|
||||||
// Thread d'envoi des evenements captures vers le serveur
|
|
||||||
let send_state = state;
|
|
||||||
let send_rx = rx.clone();
|
|
||||||
let send_config = config;
|
|
||||||
thread::Builder::new()
|
|
||||||
.name("event-sender".to_string())
|
|
||||||
.spawn(move || {
|
|
||||||
event_sender_loop(send_rx, send_config, send_state);
|
|
||||||
})
|
|
||||||
.expect("Impossible de demarrer le thread sender");
|
|
||||||
|
|
||||||
rx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Boucle du listener rdev — capture les evenements souris/clavier globaux.
|
|
||||||
///
|
|
||||||
/// rdev::listen est bloquant et appelle le callback pour chaque evenement.
|
|
||||||
/// On filtre et transforme les evenements pertinents, puis on les envoie
|
|
||||||
/// via le canal crossbeam.
|
|
||||||
fn event_listener_loop(tx: Sender<CapturedEvent>, state: Arc<AgentState>) {
|
|
||||||
let (screen_w, screen_h) = capture::screen_dimensions().unwrap_or((1920, 1080));
|
|
||||||
let rec_state = std::sync::Mutex::new(RecorderState::new(screen_w, screen_h));
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"[RECORDER] Listener demarre (ecran {}x{})",
|
|
||||||
screen_w, screen_h
|
|
||||||
);
|
|
||||||
|
|
||||||
// rdev::listen prend un callback FnMut
|
|
||||||
let callback = move |event: rdev::Event| {
|
|
||||||
// Ne capturer que si l'enregistrement est actif
|
|
||||||
if !state.recording.load(std::sync::atomic::Ordering::SeqCst) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut rs = match rec_state.lock() {
|
|
||||||
Ok(s) => s,
|
|
||||||
Err(_) => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
match event.event_type {
|
|
||||||
rdev::EventType::ButtonPress(button) => {
|
|
||||||
let btn_name = match button {
|
|
||||||
rdev::Button::Left => "left",
|
|
||||||
rdev::Button::Right => "right",
|
|
||||||
rdev::Button::Middle => "middle",
|
|
||||||
_ => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Obtenir la position de la souris depuis l'evenement
|
|
||||||
// rdev ne fournit pas toujours les coordonnees dans ButtonPress,
|
|
||||||
// on utilise la derniere position connue via MouseMove.
|
|
||||||
// Pour simplifier, on capture la position courante du curseur.
|
|
||||||
let (mx, my) = get_cursor_position();
|
|
||||||
let (x_pct, y_pct) = rs.normalize(mx, my);
|
|
||||||
|
|
||||||
// Flush le buffer de texte avant le clic
|
|
||||||
if !rs.text_buffer.is_empty() {
|
|
||||||
let text_event = CapturedEvent::Text {
|
|
||||||
text: rs.text_buffer.clone(),
|
|
||||||
x_pct: rs.text_start_x,
|
|
||||||
y_pct: rs.text_start_y,
|
|
||||||
};
|
|
||||||
let _ = tx.try_send(text_event);
|
|
||||||
rs.text_buffer.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detection double-clic
|
|
||||||
let now = Instant::now();
|
|
||||||
let dt = now.duration_since(rs.last_click_time);
|
|
||||||
let dx = (mx - rs.last_click_x).abs();
|
|
||||||
let dy = (my - rs.last_click_y).abs();
|
|
||||||
let dist = (dx * dx + dy * dy).sqrt();
|
|
||||||
|
|
||||||
if btn_name == "left"
|
|
||||||
&& dt < Duration::from_millis(DOUBLE_CLICK_TIME_MS)
|
|
||||||
&& dist < DOUBLE_CLICK_DIST_THRESHOLD
|
|
||||||
{
|
|
||||||
// Double-clic detecte
|
|
||||||
let event = CapturedEvent::DoubleClick {
|
|
||||||
x_pct,
|
|
||||||
y_pct,
|
|
||||||
window_title: get_active_window_title(),
|
|
||||||
};
|
|
||||||
let _ = tx.try_send(event);
|
|
||||||
} else {
|
|
||||||
// Clic simple
|
|
||||||
let event = CapturedEvent::Click {
|
|
||||||
x_pct,
|
|
||||||
y_pct,
|
|
||||||
button: btn_name.to_string(),
|
|
||||||
window_title: get_active_window_title(),
|
|
||||||
};
|
|
||||||
let _ = tx.try_send(event);
|
|
||||||
|
|
||||||
// Incrementer le compteur d'actions
|
|
||||||
state.increment_actions();
|
|
||||||
}
|
|
||||||
|
|
||||||
rs.last_click_time = now;
|
|
||||||
rs.last_click_x = mx;
|
|
||||||
rs.last_click_y = my;
|
|
||||||
}
|
|
||||||
|
|
||||||
rdev::EventType::KeyPress(key) => {
|
|
||||||
// Mettre a jour les modifieurs
|
|
||||||
match key {
|
|
||||||
rdev::Key::ControlLeft | rdev::Key::ControlRight => {
|
|
||||||
rs.ctrl_held = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rdev::Key::Alt | rdev::Key::AltGr => {
|
|
||||||
rs.alt_held = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rdev::Key::ShiftLeft | rdev::Key::ShiftRight => {
|
|
||||||
rs.shift_held = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
rdev::Key::MetaLeft | rdev::Key::MetaRight => {
|
|
||||||
rs.meta_held = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si un modifieur non-shift est enfonce, c'est un combo
|
|
||||||
if rs.any_modifier_held() {
|
|
||||||
let mut keys = Vec::new();
|
|
||||||
if rs.ctrl_held {
|
|
||||||
keys.push("ctrl".to_string());
|
|
||||||
}
|
|
||||||
if rs.alt_held {
|
|
||||||
keys.push("alt".to_string());
|
|
||||||
}
|
|
||||||
if rs.meta_held {
|
|
||||||
keys.push("win".to_string());
|
|
||||||
}
|
|
||||||
if rs.shift_held {
|
|
||||||
keys.push("shift".to_string());
|
|
||||||
}
|
|
||||||
keys.push(rdev_key_to_string(key));
|
|
||||||
|
|
||||||
// Flush le buffer avant le combo
|
|
||||||
if !rs.text_buffer.is_empty() {
|
|
||||||
let text_event = CapturedEvent::Text {
|
|
||||||
text: rs.text_buffer.clone(),
|
|
||||||
x_pct: rs.text_start_x,
|
|
||||||
y_pct: rs.text_start_y,
|
|
||||||
};
|
|
||||||
let _ = tx.try_send(text_event);
|
|
||||||
rs.text_buffer.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = CapturedEvent::KeyCombo { keys };
|
|
||||||
let _ = tx.try_send(event);
|
|
||||||
state.increment_actions();
|
|
||||||
} else {
|
|
||||||
// Touche de saisie normale — ajouter au buffer
|
|
||||||
if let Some(c) = rdev_key_to_char(key) {
|
|
||||||
if rs.text_buffer.is_empty() {
|
|
||||||
let (mx, my) = get_cursor_position();
|
|
||||||
let (x, y) = rs.normalize(mx, my);
|
|
||||||
rs.text_start_x = x;
|
|
||||||
rs.text_start_y = y;
|
|
||||||
}
|
|
||||||
rs.text_buffer.push(c);
|
|
||||||
rs.last_keystroke = Instant::now();
|
|
||||||
} else {
|
|
||||||
// Touche speciale non-texte (Enter, Tab, etc.)
|
|
||||||
// Flush le buffer et envoyer comme combo simple
|
|
||||||
if !rs.text_buffer.is_empty() {
|
|
||||||
let text_event = CapturedEvent::Text {
|
|
||||||
text: rs.text_buffer.clone(),
|
|
||||||
x_pct: rs.text_start_x,
|
|
||||||
y_pct: rs.text_start_y,
|
|
||||||
};
|
|
||||||
let _ = tx.try_send(text_event);
|
|
||||||
rs.text_buffer.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
let key_name = rdev_key_to_string(key);
|
|
||||||
let event = CapturedEvent::KeyCombo {
|
|
||||||
keys: vec![key_name],
|
|
||||||
};
|
|
||||||
let _ = tx.try_send(event);
|
|
||||||
state.increment_actions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rdev::EventType::KeyRelease(key) => {
|
|
||||||
// Mettre a jour les modifieurs
|
|
||||||
match key {
|
|
||||||
rdev::Key::ControlLeft | rdev::Key::ControlRight => rs.ctrl_held = false,
|
|
||||||
rdev::Key::Alt | rdev::Key::AltGr => rs.alt_held = false,
|
|
||||||
rdev::Key::ShiftLeft | rdev::Key::ShiftRight => rs.shift_held = false,
|
|
||||||
rdev::Key::MetaLeft | rdev::Key::MetaRight => rs.meta_held = false,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rdev::EventType::Wheel { delta_x: _, delta_y } => {
|
|
||||||
let (mx, my) = get_cursor_position();
|
|
||||||
let (x_pct, y_pct) = rs.normalize(mx, my);
|
|
||||||
let delta = if delta_y > 0 { 3 } else { -3 };
|
|
||||||
|
|
||||||
let event = CapturedEvent::Scroll {
|
|
||||||
delta,
|
|
||||||
x_pct,
|
|
||||||
y_pct,
|
|
||||||
};
|
|
||||||
let _ = tx.try_send(event);
|
|
||||||
state.increment_actions();
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {
|
|
||||||
// MouseMove et autres evenements ignores
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// rdev::listen est bloquant — il ne retourne qu'en cas d'erreur
|
|
||||||
if let Err(e) = rdev::listen(callback) {
|
|
||||||
eprintln!("[RECORDER] Erreur fatale du listener rdev : {:?}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Boucle de flush periodique du buffer de texte.
|
|
||||||
///
|
|
||||||
/// Toutes les 100ms, verifie si le buffer de texte est non-vide
|
|
||||||
/// et si le delai de flush (500ms) est depasse. Si oui, flush le buffer
|
|
||||||
/// en envoyant un evenement Text.
|
|
||||||
fn text_flush_loop(_tx: Sender<CapturedEvent>, state: Arc<AgentState>) {
|
|
||||||
// Note: le flush est gere dans le callback rdev via le Mutex.
|
|
||||||
// Cette boucle est un filet de securite pour les cas ou le buffer
|
|
||||||
// resterait non-flush (timeout sans nouveau evenement).
|
|
||||||
// L'implementation complete necessiterait un acces partage au RecorderState.
|
|
||||||
// Pour l'instant, le flush est declenche par le prochain evenement (clic, combo).
|
|
||||||
|
|
||||||
while state.is_running() {
|
|
||||||
thread::sleep(Duration::from_millis(TEXT_FLUSH_DELAY_MS));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Boucle d'envoi des evenements captures vers le serveur streaming.
|
|
||||||
///
|
|
||||||
/// Lit les evenements du canal crossbeam et les envoie au serveur
|
|
||||||
/// via HTTP POST (format compatible avec le Python streamer).
|
|
||||||
fn event_sender_loop(
|
|
||||||
rx: Receiver<CapturedEvent>,
|
|
||||||
config: Arc<Config>,
|
|
||||||
state: Arc<AgentState>,
|
|
||||||
) {
|
|
||||||
let client = reqwest::blocking::Client::new();
|
|
||||||
|
|
||||||
println!("[RECORDER] Thread d'envoi d'evenements demarre");
|
|
||||||
|
|
||||||
loop {
|
|
||||||
// Bloquer jusqu'au prochain evenement (ou timeout)
|
|
||||||
match rx.recv_timeout(Duration::from_secs(1)) {
|
|
||||||
Ok(event) => {
|
|
||||||
if !state.recording.load(std::sync::atomic::Ordering::SeqCst) {
|
|
||||||
continue; // Enregistrement arrete entre-temps
|
|
||||||
}
|
|
||||||
|
|
||||||
let session_name = state.current_recording_name();
|
|
||||||
send_event_to_server(&client, &config, &event, &session_name);
|
|
||||||
}
|
|
||||||
Err(crossbeam_channel::RecvTimeoutError::Timeout) => {
|
|
||||||
if !state.is_running() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
|
|
||||||
println!("[RECORDER] Canal deconnecte — arret du sender");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Envoie un evenement capture au serveur streaming.
|
|
||||||
///
|
|
||||||
/// 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,
|
|
||||||
event: &CapturedEvent,
|
|
||||||
session_name: &str,
|
|
||||||
) {
|
|
||||||
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 {
|
|
||||||
x_pct,
|
|
||||||
y_pct,
|
|
||||||
button,
|
|
||||||
window_title,
|
|
||||||
} => {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "click",
|
|
||||||
"x_pct": x_pct,
|
|
||||||
"y_pct": y_pct,
|
|
||||||
"button": button,
|
|
||||||
"window_title": window_title,
|
|
||||||
"session_name": session_name,
|
|
||||||
"machine_id": config.machine_id,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"screen_resolution": [screen_w, screen_h],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
CapturedEvent::DoubleClick {
|
|
||||||
x_pct,
|
|
||||||
y_pct,
|
|
||||||
window_title,
|
|
||||||
} => {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "click",
|
|
||||||
"x_pct": x_pct,
|
|
||||||
"y_pct": y_pct,
|
|
||||||
"button": "double",
|
|
||||||
"window_title": window_title,
|
|
||||||
"session_name": session_name,
|
|
||||||
"machine_id": config.machine_id,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"screen_resolution": [screen_w, screen_h],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
CapturedEvent::Text {
|
|
||||||
text,
|
|
||||||
x_pct,
|
|
||||||
y_pct,
|
|
||||||
} => {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "type",
|
|
||||||
"text": text,
|
|
||||||
"x_pct": x_pct,
|
|
||||||
"y_pct": y_pct,
|
|
||||||
"session_name": session_name,
|
|
||||||
"machine_id": config.machine_id,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"screen_resolution": [screen_w, screen_h],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
CapturedEvent::KeyCombo { keys } => {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "key_combo",
|
|
||||||
"keys": keys,
|
|
||||||
"session_name": session_name,
|
|
||||||
"machine_id": config.machine_id,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"screen_resolution": [screen_w, screen_h],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
CapturedEvent::Scroll {
|
|
||||||
delta,
|
|
||||||
x_pct,
|
|
||||||
y_pct,
|
|
||||||
} => {
|
|
||||||
serde_json::json!({
|
|
||||||
"type": "scroll",
|
|
||||||
"delta": delta,
|
|
||||||
"x_pct": x_pct,
|
|
||||||
"y_pct": y_pct,
|
|
||||||
"session_name": session_name,
|
|
||||||
"machine_id": config.machine_id,
|
|
||||||
"timestamp": timestamp,
|
|
||||||
"screen_resolution": [screen_w, screen_h],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Envoi non-bloquant (on ne veut pas ralentir la capture)
|
|
||||||
match client
|
|
||||||
.post(&url)
|
|
||||||
.json(&payload)
|
|
||||||
.timeout(Duration::from_secs(5))
|
|
||||||
.send()
|
|
||||||
{
|
|
||||||
Ok(resp) => {
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
eprintln!(
|
|
||||||
"[RECORDER] Envoi evenement echoue : HTTP {}",
|
|
||||||
resp.status()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[RECORDER] Erreur reseau : {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capturer un screenshot pour les clics (dual: full + crop)
|
|
||||||
if matches!(
|
|
||||||
event,
|
|
||||||
CapturedEvent::Click { .. } | CapturedEvent::DoubleClick { .. }
|
|
||||||
) {
|
|
||||||
if let Some(img) = capture::capture_screenshot() {
|
|
||||||
let jpeg = capture::screenshot_to_jpeg_bytes(&img, 80);
|
|
||||||
if !jpeg.is_empty() {
|
|
||||||
let shot_id = format!("rec_{}", chrono::Utc::now().timestamp_millis());
|
|
||||||
let _ = crate::network::send_heartbeat(
|
|
||||||
&reqwest::blocking::Client::new(),
|
|
||||||
&crate::config::Config::from_env(),
|
|
||||||
&jpeg,
|
|
||||||
session_name,
|
|
||||||
);
|
|
||||||
let _ = shot_id; // utilise implicitement via send_heartbeat
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Fonctions utilitaires ---
|
|
||||||
|
|
||||||
/// Obtient la position actuelle du curseur souris.
|
|
||||||
fn get_cursor_position() -> (f64, f64) {
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
use windows_sys::Win32::UI::WindowsAndMessaging::GetCursorPos;
|
|
||||||
use windows_sys::Win32::Foundation::POINT;
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
let mut point: POINT = std::mem::zeroed();
|
|
||||||
if GetCursorPos(&mut point) != 0 {
|
|
||||||
return (point.x as f64, point.y as f64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback : position inconnue
|
|
||||||
(0.0, 0.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Obtient le titre de la fenetre active.
|
|
||||||
fn get_active_window_title() -> String {
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
use windows_sys::Win32::UI::WindowsAndMessaging::{
|
|
||||||
GetForegroundWindow, GetWindowTextW,
|
|
||||||
};
|
|
||||||
|
|
||||||
unsafe {
|
|
||||||
let hwnd = GetForegroundWindow();
|
|
||||||
if !hwnd.is_null() {
|
|
||||||
let mut buf = [0u16; 256];
|
|
||||||
let len = GetWindowTextW(hwnd, buf.as_mut_ptr(), buf.len() as i32);
|
|
||||||
if len > 0 {
|
|
||||||
return String::from_utf16_lossy(&buf[..len as usize]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
"Inconnu".to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convertit une touche rdev en caractere texte (pour le buffer de saisie).
|
|
||||||
/// Retourne None pour les touches speciales (Enter, Tab, etc.).
|
|
||||||
fn rdev_key_to_char(key: rdev::Key) -> Option<char> {
|
|
||||||
match key {
|
|
||||||
rdev::Key::KeyA => Some('a'),
|
|
||||||
rdev::Key::KeyB => Some('b'),
|
|
||||||
rdev::Key::KeyC => Some('c'),
|
|
||||||
rdev::Key::KeyD => Some('d'),
|
|
||||||
rdev::Key::KeyE => Some('e'),
|
|
||||||
rdev::Key::KeyF => Some('f'),
|
|
||||||
rdev::Key::KeyG => Some('g'),
|
|
||||||
rdev::Key::KeyH => Some('h'),
|
|
||||||
rdev::Key::KeyI => Some('i'),
|
|
||||||
rdev::Key::KeyJ => Some('j'),
|
|
||||||
rdev::Key::KeyK => Some('k'),
|
|
||||||
rdev::Key::KeyL => Some('l'),
|
|
||||||
rdev::Key::KeyM => Some('m'),
|
|
||||||
rdev::Key::KeyN => Some('n'),
|
|
||||||
rdev::Key::KeyO => Some('o'),
|
|
||||||
rdev::Key::KeyP => Some('p'),
|
|
||||||
rdev::Key::KeyQ => Some('q'),
|
|
||||||
rdev::Key::KeyR => Some('r'),
|
|
||||||
rdev::Key::KeyS => Some('s'),
|
|
||||||
rdev::Key::KeyT => Some('t'),
|
|
||||||
rdev::Key::KeyU => Some('u'),
|
|
||||||
rdev::Key::KeyV => Some('v'),
|
|
||||||
rdev::Key::KeyW => Some('w'),
|
|
||||||
rdev::Key::KeyX => Some('x'),
|
|
||||||
rdev::Key::KeyY => Some('y'),
|
|
||||||
rdev::Key::KeyZ => Some('z'),
|
|
||||||
rdev::Key::Num0 => Some('0'),
|
|
||||||
rdev::Key::Num1 => Some('1'),
|
|
||||||
rdev::Key::Num2 => Some('2'),
|
|
||||||
rdev::Key::Num3 => Some('3'),
|
|
||||||
rdev::Key::Num4 => Some('4'),
|
|
||||||
rdev::Key::Num5 => Some('5'),
|
|
||||||
rdev::Key::Num6 => Some('6'),
|
|
||||||
rdev::Key::Num7 => Some('7'),
|
|
||||||
rdev::Key::Num8 => Some('8'),
|
|
||||||
rdev::Key::Num9 => Some('9'),
|
|
||||||
rdev::Key::Space => Some(' '),
|
|
||||||
rdev::Key::Minus => Some('-'),
|
|
||||||
rdev::Key::Equal => Some('='),
|
|
||||||
rdev::Key::LeftBracket => Some('['),
|
|
||||||
rdev::Key::RightBracket => Some(']'),
|
|
||||||
rdev::Key::SemiColon => Some(';'),
|
|
||||||
rdev::Key::Quote => Some('\''),
|
|
||||||
rdev::Key::Comma => Some(','),
|
|
||||||
rdev::Key::Dot => Some('.'),
|
|
||||||
rdev::Key::Slash => Some('/'),
|
|
||||||
rdev::Key::BackSlash => Some('\\'),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Convertit une touche rdev en nom de touche (pour les combos).
|
|
||||||
fn rdev_key_to_string(key: rdev::Key) -> String {
|
|
||||||
match key {
|
|
||||||
rdev::Key::Return => "enter".to_string(),
|
|
||||||
rdev::Key::Tab => "tab".to_string(),
|
|
||||||
rdev::Key::Escape => "escape".to_string(),
|
|
||||||
rdev::Key::Backspace => "backspace".to_string(),
|
|
||||||
rdev::Key::Delete => "delete".to_string(),
|
|
||||||
rdev::Key::Space => "space".to_string(),
|
|
||||||
rdev::Key::UpArrow => "up".to_string(),
|
|
||||||
rdev::Key::DownArrow => "down".to_string(),
|
|
||||||
rdev::Key::LeftArrow => "left".to_string(),
|
|
||||||
rdev::Key::RightArrow => "right".to_string(),
|
|
||||||
rdev::Key::Home => "home".to_string(),
|
|
||||||
rdev::Key::End => "end".to_string(),
|
|
||||||
rdev::Key::PageUp => "page_up".to_string(),
|
|
||||||
rdev::Key::PageDown => "page_down".to_string(),
|
|
||||||
rdev::Key::F1 => "f1".to_string(),
|
|
||||||
rdev::Key::F2 => "f2".to_string(),
|
|
||||||
rdev::Key::F3 => "f3".to_string(),
|
|
||||||
rdev::Key::F4 => "f4".to_string(),
|
|
||||||
rdev::Key::F5 => "f5".to_string(),
|
|
||||||
rdev::Key::F6 => "f6".to_string(),
|
|
||||||
rdev::Key::F7 => "f7".to_string(),
|
|
||||||
rdev::Key::F8 => "f8".to_string(),
|
|
||||||
rdev::Key::F9 => "f9".to_string(),
|
|
||||||
rdev::Key::F10 => "f10".to_string(),
|
|
||||||
rdev::Key::F11 => "f11".to_string(),
|
|
||||||
rdev::Key::F12 => "f12".to_string(),
|
|
||||||
rdev::Key::CapsLock => "caps_lock".to_string(),
|
|
||||||
rdev::Key::Insert => "insert".to_string(),
|
|
||||||
rdev::Key::PrintScreen => "print_screen".to_string(),
|
|
||||||
// Pour les lettres et chiffres, reutiliser rdev_key_to_char
|
|
||||||
other => {
|
|
||||||
if let Some(c) = rdev_key_to_char(other) {
|
|
||||||
c.to_string()
|
|
||||||
} else {
|
|
||||||
format!("{:?}", other).to_lowercase()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
//! Boucle de polling replay.
|
|
||||||
//!
|
|
||||||
//! Poll le serveur toutes les secondes pour recuperer les actions a executer.
|
|
||||||
//! Quand une action est recue, l'execute via executor et rapporte le resultat.
|
|
||||||
//! Gere le backoff exponentiel en cas d'indisponibilite du serveur.
|
|
||||||
//!
|
|
||||||
//! Reproduit le comportement de _replay_poll_loop dans agent_v1/main.py.
|
|
||||||
|
|
||||||
use crate::capture;
|
|
||||||
use crate::config::Config;
|
|
||||||
use crate::executor;
|
|
||||||
use crate::network;
|
|
||||||
use crate::notifications;
|
|
||||||
use crate::state::AgentState;
|
|
||||||
use reqwest::blocking::Client;
|
|
||||||
use std::thread;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
/// Boucle de polling replay (tourne dans un thread dedie).
|
|
||||||
///
|
|
||||||
/// - Poll GET /replay/next toutes les secondes
|
|
||||||
/// - Execute l'action via executor
|
|
||||||
/// - Capture un screenshot post-action
|
|
||||||
/// - Rapporte le resultat via POST /replay/result
|
|
||||||
/// - Backoff exponentiel si le serveur est indisponible
|
|
||||||
pub fn replay_poll_loop(config: &Config, state: &AgentState) {
|
|
||||||
let client = Client::new();
|
|
||||||
let mut poll_count: u64 = 0;
|
|
||||||
let backoff = config.replay_poll_interval_s;
|
|
||||||
let _backoff_max = 30.0_f64;
|
|
||||||
let _backoff_factor = 1.5_f64;
|
|
||||||
let mut replay_active = false;
|
|
||||||
|
|
||||||
println!(
|
|
||||||
"[REPLAY] Boucle replay demarree — poll toutes les {:.0}s sur {}",
|
|
||||||
config.replay_poll_interval_s, config.server_url
|
|
||||||
);
|
|
||||||
|
|
||||||
while state.is_running() {
|
|
||||||
// Verifier l'arret d'urgence
|
|
||||||
if state
|
|
||||||
.emergency_stop
|
|
||||||
.load(std::sync::atomic::Ordering::SeqCst)
|
|
||||||
{
|
|
||||||
if replay_active {
|
|
||||||
println!("[REPLAY] ARRET D'URGENCE — replay interrompu");
|
|
||||||
replay_active = false;
|
|
||||||
state.set_replay_active(false);
|
|
||||||
}
|
|
||||||
thread::sleep(Duration::from_secs(1));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
poll_count += 1;
|
|
||||||
|
|
||||||
// Log periodique toutes les 60s pour confirmer que la boucle tourne
|
|
||||||
let polls_per_minute = (60.0 / backoff).ceil() as u64;
|
|
||||||
if polls_per_minute > 0 && poll_count % polls_per_minute == 0 {
|
|
||||||
println!(
|
|
||||||
"[REPLAY] Poll #{} — session={} — serveur={}",
|
|
||||||
poll_count,
|
|
||||||
config.agent_session_id(),
|
|
||||||
config.server_url,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
match network::poll_next_action(&client, config) {
|
|
||||||
Some(action) => {
|
|
||||||
if !replay_active {
|
|
||||||
replay_active = true;
|
|
||||||
state.set_replay_active(true);
|
|
||||||
notifications::replay_started("workflow");
|
|
||||||
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'ecran
|
|
||||||
let (sw, sh) = capture::screen_dimensions().unwrap_or((1920, 1080));
|
|
||||||
|
|
||||||
// Executer l'action (avec config pour la resolution visuelle)
|
|
||||||
println!(">>> Execution de l'action {}...", action_type);
|
|
||||||
let mut result = executor::execute_action(&action, sw, sh, config);
|
|
||||||
println!(
|
|
||||||
">>> Resultat execution : success={}, error={:?}",
|
|
||||||
result.success, result.error
|
|
||||||
);
|
|
||||||
|
|
||||||
// Capture screenshot post-action (apres 500ms)
|
|
||||||
thread::sleep(Duration::from_millis(500));
|
|
||||||
if let Some(img) = capture::capture_screenshot() {
|
|
||||||
let b64 = capture::screenshot_to_jpeg_base64(&img, 60);
|
|
||||||
if !b64.is_empty() {
|
|
||||||
result.screenshot = Some(b64);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rapporter le resultat au serveur (TOUJOURS, meme en erreur)
|
|
||||||
network::report_result(&client, config, &result);
|
|
||||||
|
|
||||||
// Poll plus rapidement pour enchainer les actions
|
|
||||||
thread::sleep(Duration::from_millis(200));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
if replay_active {
|
|
||||||
println!("[REPLAY] Replay termine — retour en mode capture");
|
|
||||||
replay_active = false;
|
|
||||||
state.set_replay_active(false);
|
|
||||||
notifications::replay_finished(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let sleep_duration = Duration::from_secs_f64(backoff);
|
|
||||||
thread::sleep(sleep_duration);
|
|
||||||
}
|
|
||||||
|
|
||||||
println!("[REPLAY] Boucle arretee.");
|
|
||||||
}
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
//! 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(())
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
//! Etat partage thread-safe de l'agent.
|
|
||||||
//!
|
|
||||||
//! Centralise l'etat courant (enregistrement, replay, connexion, etc.)
|
|
||||||
//! accessible depuis tous les threads (systray, heartbeat, replay, recorder).
|
|
||||||
//! Equivalent de agent_v1/ui/shared_state.py.
|
|
||||||
|
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
|
|
||||||
/// Etats possibles de l'icone systray
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum TrayState {
|
|
||||||
/// Gris — en attente, pas de session active
|
|
||||||
Idle,
|
|
||||||
/// Rouge — enregistrement en cours
|
|
||||||
Recording,
|
|
||||||
/// Vert — connecte au serveur, pret
|
|
||||||
Connected,
|
|
||||||
/// Bleu — replay en cours
|
|
||||||
Replay,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Etat partage de l'agent, thread-safe via Arc + atomics.
|
|
||||||
///
|
|
||||||
/// Les booleens utilisent AtomicBool pour un acces lock-free.
|
|
||||||
/// Le nom de session utilise un Mutex car c'est une String.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct AgentState {
|
|
||||||
/// Enregistrement en cours (session de capture)
|
|
||||||
pub recording: AtomicBool,
|
|
||||||
|
|
||||||
/// Nom de la session d'enregistrement courante
|
|
||||||
pub recording_name: Mutex<String>,
|
|
||||||
|
|
||||||
/// Replay en cours (execution d'actions)
|
|
||||||
pub replay_active: AtomicBool,
|
|
||||||
|
|
||||||
/// Connecte au serveur streaming
|
|
||||||
pub connected: AtomicBool,
|
|
||||||
|
|
||||||
/// Nombre d'actions capturees dans la session courante
|
|
||||||
pub actions_count: AtomicU32,
|
|
||||||
|
|
||||||
/// L'agent est en cours d'execution (false = arret demande)
|
|
||||||
pub running: AtomicBool,
|
|
||||||
|
|
||||||
/// Fenetre de chat visible
|
|
||||||
pub chat_visible: AtomicBool,
|
|
||||||
|
|
||||||
/// Arret d'urgence active
|
|
||||||
pub emergency_stop: AtomicBool,
|
|
||||||
|
|
||||||
/// Dernier message de notification (pour eviter les doublons)
|
|
||||||
#[allow(dead_code)]
|
|
||||||
pub last_notification: Mutex<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AgentState {
|
|
||||||
/// Cree un nouvel etat avec les valeurs par defaut.
|
|
||||||
pub fn new() -> Arc<Self> {
|
|
||||||
Arc::new(Self {
|
|
||||||
recording: AtomicBool::new(false),
|
|
||||||
recording_name: Mutex::new(String::new()),
|
|
||||||
replay_active: AtomicBool::new(false),
|
|
||||||
connected: AtomicBool::new(false),
|
|
||||||
actions_count: AtomicU32::new(0),
|
|
||||||
running: AtomicBool::new(true),
|
|
||||||
chat_visible: AtomicBool::new(false),
|
|
||||||
emergency_stop: AtomicBool::new(false),
|
|
||||||
last_notification: Mutex::new(String::new()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Demarre un enregistrement avec le nom donne.
|
|
||||||
pub fn start_recording(&self, name: &str) {
|
|
||||||
self.recording.store(true, Ordering::SeqCst);
|
|
||||||
self.actions_count.store(0, Ordering::SeqCst);
|
|
||||||
if let Ok(mut n) = self.recording_name.lock() {
|
|
||||||
*n = name.to_string();
|
|
||||||
}
|
|
||||||
println!("[STATE] Enregistrement demarre : '{}'", name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Arrete l'enregistrement en cours.
|
|
||||||
pub fn stop_recording(&self) -> (String, u32) {
|
|
||||||
self.recording.store(false, Ordering::SeqCst);
|
|
||||||
let count = self.actions_count.load(Ordering::SeqCst);
|
|
||||||
let name = self
|
|
||||||
.recording_name
|
|
||||||
.lock()
|
|
||||||
.map(|n| n.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
println!("[STATE] Enregistrement arrete : '{}' ({} actions)", name, count);
|
|
||||||
(name, count)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Incremente le compteur d'actions capturees.
|
|
||||||
pub fn increment_actions(&self) -> u32 {
|
|
||||||
self.actions_count.fetch_add(1, Ordering::SeqCst) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Verifie si l'agent est en cours d'execution.
|
|
||||||
pub fn is_running(&self) -> bool {
|
|
||||||
self.running.load(Ordering::SeqCst)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Demande l'arret de l'agent.
|
|
||||||
pub fn request_shutdown(&self) {
|
|
||||||
self.running.store(false, Ordering::SeqCst);
|
|
||||||
println!("[STATE] Arret demande");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Active/desactive le replay.
|
|
||||||
pub fn set_replay_active(&self, active: bool) {
|
|
||||||
self.replay_active.store(active, Ordering::SeqCst);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Met a jour le statut de connexion au serveur.
|
|
||||||
pub fn set_connected(&self, connected: bool) {
|
|
||||||
let was_connected = self.connected.swap(connected, Ordering::SeqCst);
|
|
||||||
if was_connected != connected {
|
|
||||||
println!(
|
|
||||||
"[STATE] Connexion serveur : {}",
|
|
||||||
if connected { "CONNECTE" } else { "DECONNECTE" }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Active l'arret d'urgence — stoppe tout immediatement.
|
|
||||||
pub fn emergency_stop(&self) {
|
|
||||||
self.emergency_stop.store(true, Ordering::SeqCst);
|
|
||||||
self.recording.store(false, Ordering::SeqCst);
|
|
||||||
self.replay_active.store(false, Ordering::SeqCst);
|
|
||||||
println!("[STATE] === ARRET D'URGENCE ACTIVE ===");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retourne l'etat courant du systray.
|
|
||||||
pub fn tray_state(&self) -> TrayState {
|
|
||||||
if self.recording.load(Ordering::SeqCst) {
|
|
||||||
TrayState::Recording
|
|
||||||
} else if self.replay_active.load(Ordering::SeqCst) {
|
|
||||||
TrayState::Replay
|
|
||||||
} else if self.connected.load(Ordering::SeqCst) {
|
|
||||||
TrayState::Connected
|
|
||||||
} else {
|
|
||||||
TrayState::Idle
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retourne le nom de la session d'enregistrement courante.
|
|
||||||
pub fn current_recording_name(&self) -> String {
|
|
||||||
self.recording_name
|
|
||||||
.lock()
|
|
||||||
.map(|n| n.clone())
|
|
||||||
.unwrap_or_default()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for AgentState {
|
|
||||||
fn default() -> Self {
|
|
||||||
// Note: on ne peut pas retourner Arc<Self> depuis Default,
|
|
||||||
// donc on fournit les valeurs brutes. Utiliser new() de preference.
|
|
||||||
Self {
|
|
||||||
recording: AtomicBool::new(false),
|
|
||||||
recording_name: Mutex::new(String::new()),
|
|
||||||
replay_active: AtomicBool::new(false),
|
|
||||||
connected: AtomicBool::new(false),
|
|
||||||
actions_count: AtomicU32::new(0),
|
|
||||||
running: AtomicBool::new(true),
|
|
||||||
chat_visible: AtomicBool::new(false),
|
|
||||||
emergency_stop: AtomicBool::new(false),
|
|
||||||
last_notification: Mutex::new(String::new()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
//! 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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,336 +0,0 @@
|
|||||||
//! Icone systray avec menu contextuel.
|
|
||||||
//!
|
|
||||||
//! Affiche une icone dans la barre des taches Windows avec un menu contextuel
|
|
||||||
//! permettant de controler l'agent (enregistrement, replay, chat, etc.).
|
|
||||||
//! Equivalent de agent_v1/ui/smart_tray.py.
|
|
||||||
//!
|
|
||||||
//! Utilise tray-icon (crate Tauri) pour l'icone et le menu.
|
|
||||||
//! Necessite une boucle d'evenements Windows (winit ou Win32 message pump).
|
|
||||||
//!
|
|
||||||
//! Sur Linux : le systray n'est pas disponible, l'agent tourne en mode console.
|
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
use crate::config::Config;
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
use crate::notifications;
|
|
||||||
#[allow(unused_imports)]
|
|
||||||
use crate::state::{AgentState, TrayState};
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
/// Identifiants des elements du menu (pour le dispatch des evenements).
|
|
||||||
#[cfg(windows)]
|
|
||||||
pub struct TrayMenuIds {
|
|
||||||
pub machine_info: tray_icon::menu::MenuItem,
|
|
||||||
pub status_item: tray_icon::menu::MenuItem,
|
|
||||||
pub start_recording: tray_icon::menu::MenuItem,
|
|
||||||
pub stop_recording: tray_icon::menu::MenuItem,
|
|
||||||
pub workflows_submenu: tray_icon::menu::Submenu,
|
|
||||||
pub emergency_stop: tray_icon::menu::MenuItem,
|
|
||||||
pub open_chat: tray_icon::menu::MenuItem,
|
|
||||||
pub open_files: tray_icon::menu::MenuItem,
|
|
||||||
pub quit: tray_icon::menu::MenuItem,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cree l'icone du systray et la boucle d'evenements associee.
|
|
||||||
///
|
|
||||||
/// Cette fonction bloque le thread appelant (doit etre le thread principal sur Windows).
|
|
||||||
/// Sur les OS non-Windows, attend Ctrl+C en mode console.
|
|
||||||
#[cfg(windows)]
|
|
||||||
pub fn run_tray_loop(config: Arc<Config>, state: Arc<AgentState>) {
|
|
||||||
use tray_icon::{
|
|
||||||
menu::MenuEvent,
|
|
||||||
TrayIconBuilder,
|
|
||||||
};
|
|
||||||
use winit::application::ApplicationHandler;
|
|
||||||
use winit::event::WindowEvent;
|
|
||||||
use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
|
|
||||||
use winit::window::WindowId;
|
|
||||||
|
|
||||||
// Creer le menu
|
|
||||||
let menu_ids = create_menu(&config);
|
|
||||||
let menu = build_tray_menu(&menu_ids);
|
|
||||||
|
|
||||||
// Generer l'icone initiale (gris = idle)
|
|
||||||
let icon = generate_tray_icon(TrayState::Idle);
|
|
||||||
|
|
||||||
// Creer l'icone systray
|
|
||||||
let tray = match TrayIconBuilder::new()
|
|
||||||
.with_menu(Box::new(menu))
|
|
||||||
.with_tooltip("Lea - Agent RPA Vision (IA)")
|
|
||||||
.with_icon(icon)
|
|
||||||
.build()
|
|
||||||
{
|
|
||||||
Ok(t) => t,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[TRAY] Impossible de creer l'icone systray : {}", e);
|
|
||||||
// Fallback mode console
|
|
||||||
fallback_console_loop(&state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
println!("[TRAY] Icone systray creee — menu contextuel disponible");
|
|
||||||
notifications::greet();
|
|
||||||
|
|
||||||
// Structure pour l'ApplicationHandler de winit
|
|
||||||
struct TrayApp {
|
|
||||||
config: Arc<Config>,
|
|
||||||
state: Arc<AgentState>,
|
|
||||||
tray: tray_icon::TrayIcon,
|
|
||||||
menu_ids: TrayMenuIds,
|
|
||||||
current_tray_state: TrayState,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ApplicationHandler for TrayApp {
|
|
||||||
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {
|
|
||||||
// Rien a faire — pas de fenetre winit
|
|
||||||
}
|
|
||||||
|
|
||||||
fn window_event(
|
|
||||||
&mut self,
|
|
||||||
_event_loop: &ActiveEventLoop,
|
|
||||||
_window_id: WindowId,
|
|
||||||
_event: WindowEvent,
|
|
||||||
) {
|
|
||||||
// Pas de fenetre winit — ignorer
|
|
||||||
}
|
|
||||||
|
|
||||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
|
||||||
// Verifier si l'agent doit s'arreter
|
|
||||||
if !self.state.is_running() {
|
|
||||||
event_loop.exit();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Traiter les evenements menu
|
|
||||||
let menu_receiver = MenuEvent::receiver();
|
|
||||||
if let Ok(event) = menu_receiver.try_recv() {
|
|
||||||
handle_menu_event(&event, &self.menu_ids, &self.config, &self.state);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mettre a jour l'icone si l'etat a change
|
|
||||||
let new_state = self.state.tray_state();
|
|
||||||
if new_state != self.current_tray_state {
|
|
||||||
self.current_tray_state = new_state;
|
|
||||||
let tooltip = match new_state {
|
|
||||||
TrayState::Idle => "Lea - En attente",
|
|
||||||
TrayState::Recording => "Lea - ENREGISTREMENT EN COURS",
|
|
||||||
TrayState::Connected => "Lea - Connectee au serveur",
|
|
||||||
TrayState::Replay => "Lea - REPLAY EN COURS",
|
|
||||||
};
|
|
||||||
let _ = self.tray.set_tooltip(Some(tooltip));
|
|
||||||
let icon = generate_tray_icon(new_state);
|
|
||||||
let _ = self.tray.set_icon(Some(icon));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attendre un peu avant le prochain cycle
|
|
||||||
event_loop.set_control_flow(ControlFlow::WaitUntil(
|
|
||||||
std::time::Instant::now() + std::time::Duration::from_millis(100),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Creer et demarrer la boucle d'evenements winit
|
|
||||||
let event_loop = match EventLoop::new() {
|
|
||||||
Ok(el) => el,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("[TRAY] Impossible de creer la boucle d'evenements : {}", e);
|
|
||||||
fallback_console_loop(&state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut app = TrayApp {
|
|
||||||
config,
|
|
||||||
state,
|
|
||||||
tray,
|
|
||||||
menu_ids,
|
|
||||||
current_tray_state: TrayState::Idle,
|
|
||||||
};
|
|
||||||
|
|
||||||
let _ = event_loop.run_app(&mut app);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Cree les elements de menu avec leurs labels.
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn create_menu(config: &Config) -> TrayMenuIds {
|
|
||||||
use tray_icon::menu::{MenuItem, Submenu};
|
|
||||||
|
|
||||||
let machine_info = MenuItem::new(
|
|
||||||
format!("Machine : {}", config.machine_id),
|
|
||||||
false, // disabled — info seulement
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let status_item = MenuItem::new("Deconnectee", false, None);
|
|
||||||
|
|
||||||
let start_recording = MenuItem::new("Apprenez-moi une tache", true, None);
|
|
||||||
|
|
||||||
let stop_recording = MenuItem::new("C'est termine", true, None);
|
|
||||||
|
|
||||||
let workflows_submenu = Submenu::new("Mes taches", true);
|
|
||||||
let _ = workflows_submenu.append(&MenuItem::new("(chargement...)", false, None));
|
|
||||||
|
|
||||||
let emergency_stop = MenuItem::new("ARRET D'URGENCE", true, None);
|
|
||||||
|
|
||||||
let open_chat = MenuItem::new("Discuter avec Lea", true, None);
|
|
||||||
|
|
||||||
let open_files = MenuItem::new("Mes fichiers", true, None);
|
|
||||||
|
|
||||||
let quit = MenuItem::new("Quitter Lea", true, None);
|
|
||||||
|
|
||||||
TrayMenuIds {
|
|
||||||
machine_info,
|
|
||||||
status_item,
|
|
||||||
start_recording,
|
|
||||||
stop_recording,
|
|
||||||
workflows_submenu,
|
|
||||||
emergency_stop,
|
|
||||||
open_chat,
|
|
||||||
open_files,
|
|
||||||
quit,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Construit le menu systray a partir des elements.
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn build_tray_menu(ids: &TrayMenuIds) -> tray_icon::menu::Menu {
|
|
||||||
use tray_icon::menu::{Menu, PredefinedMenuItem};
|
|
||||||
|
|
||||||
let menu = Menu::new();
|
|
||||||
|
|
||||||
let _ = menu.append(&ids.machine_info);
|
|
||||||
let _ = menu.append(&ids.status_item);
|
|
||||||
let _ = menu.append(&PredefinedMenuItem::separator());
|
|
||||||
let _ = menu.append(&ids.start_recording);
|
|
||||||
let _ = menu.append(&ids.stop_recording);
|
|
||||||
let _ = menu.append(&PredefinedMenuItem::separator());
|
|
||||||
let _ = menu.append(&ids.workflows_submenu);
|
|
||||||
let _ = menu.append(&PredefinedMenuItem::separator());
|
|
||||||
let _ = menu.append(&ids.emergency_stop);
|
|
||||||
let _ = menu.append(&ids.open_chat);
|
|
||||||
let _ = menu.append(&ids.open_files);
|
|
||||||
let _ = menu.append(&PredefinedMenuItem::separator());
|
|
||||||
let _ = menu.append(&ids.quit);
|
|
||||||
|
|
||||||
menu
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gere un evenement de clic sur un element du menu.
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn handle_menu_event(
|
|
||||||
event: &tray_icon::menu::MenuEvent,
|
|
||||||
ids: &TrayMenuIds,
|
|
||||||
_config: &Config,
|
|
||||||
state: &AgentState,
|
|
||||||
) {
|
|
||||||
let event_id = event.id();
|
|
||||||
|
|
||||||
if event_id == ids.start_recording.id() {
|
|
||||||
if !state.recording.load(std::sync::atomic::Ordering::SeqCst) {
|
|
||||||
let name = format!(
|
|
||||||
"session_{}",
|
|
||||||
chrono::Utc::now().format("%Y%m%d_%H%M%S")
|
|
||||||
);
|
|
||||||
state.start_recording(&name);
|
|
||||||
notifications::session_started(&name);
|
|
||||||
println!("[TRAY] Enregistrement demarre : {}", name);
|
|
||||||
}
|
|
||||||
} else if event_id == ids.stop_recording.id() {
|
|
||||||
if state.recording.load(std::sync::atomic::Ordering::SeqCst) {
|
|
||||||
let (name, count) = state.stop_recording();
|
|
||||||
notifications::session_ended(count);
|
|
||||||
println!(
|
|
||||||
"[TRAY] Enregistrement arrete : {} ({} actions)",
|
|
||||||
name, count
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if event_id == ids.emergency_stop.id() {
|
|
||||||
state.emergency_stop();
|
|
||||||
notifications::emergency_stop_activated();
|
|
||||||
println!("[TRAY] ARRET D'URGENCE ACTIVE");
|
|
||||||
} else if event_id == ids.open_chat.id() {
|
|
||||||
state
|
|
||||||
.chat_visible
|
|
||||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
||||||
println!("[TRAY] Ouverture du chat demandee");
|
|
||||||
} else if event_id == ids.open_files.id() {
|
|
||||||
let sessions_dir = if cfg!(target_os = "windows") {
|
|
||||||
"C:\\rpa_vision\\sessions".to_string()
|
|
||||||
} else {
|
|
||||||
"/tmp/rpa_vision/sessions".to_string()
|
|
||||||
};
|
|
||||||
println!("[TRAY] Ouverture du dossier : {}", sessions_dir);
|
|
||||||
#[cfg(windows)]
|
|
||||||
{
|
|
||||||
let _ = std::process::Command::new("explorer")
|
|
||||||
.arg(&sessions_dir)
|
|
||||||
.spawn();
|
|
||||||
}
|
|
||||||
} else if event_id == ids.quit.id() {
|
|
||||||
println!("[TRAY] Fermeture demandee par l'utilisateur");
|
|
||||||
state.request_shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Genere une icone systray coloree selon l'etat.
|
|
||||||
///
|
|
||||||
/// Cree une image 32x32 RGBA avec un cercle colore :
|
|
||||||
/// - Gris (#808080) : idle
|
|
||||||
/// - Rouge (#FF0000) : enregistrement
|
|
||||||
/// - Vert (#00CC00) : connecte
|
|
||||||
/// - Bleu (#0066FF) : replay
|
|
||||||
#[cfg(windows)]
|
|
||||||
fn generate_tray_icon(tray_state: TrayState) -> tray_icon::Icon {
|
|
||||||
let size = 32u32;
|
|
||||||
let mut rgba = vec![0u8; (size * size * 4) as usize];
|
|
||||||
|
|
||||||
let (r, g, b) = match tray_state {
|
|
||||||
TrayState::Idle => (128u8, 128u8, 128u8),
|
|
||||||
TrayState::Recording => (255u8, 0u8, 0u8),
|
|
||||||
TrayState::Connected => (0u8, 204u8, 0u8),
|
|
||||||
TrayState::Replay => (0u8, 102u8, 255u8),
|
|
||||||
};
|
|
||||||
|
|
||||||
let center = (size / 2) as f64;
|
|
||||||
let radius = (size / 2 - 2) as f64;
|
|
||||||
|
|
||||||
for y in 0..size {
|
|
||||||
for x in 0..size {
|
|
||||||
let dx = x as f64 - center;
|
|
||||||
let dy = y as f64 - center;
|
|
||||||
let dist = (dx * dx + dy * dy).sqrt();
|
|
||||||
|
|
||||||
let offset = ((y * size + x) * 4) as usize;
|
|
||||||
if dist <= radius {
|
|
||||||
rgba[offset] = r;
|
|
||||||
rgba[offset + 1] = g;
|
|
||||||
rgba[offset + 2] = b;
|
|
||||||
rgba[offset + 3] = 255;
|
|
||||||
} else if dist <= radius + 1.0 {
|
|
||||||
let alpha = ((radius + 1.0 - dist) * 255.0) as u8;
|
|
||||||
rgba[offset] = r;
|
|
||||||
rgba[offset + 1] = g;
|
|
||||||
rgba[offset + 2] = b;
|
|
||||||
rgba[offset + 3] = alpha;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tray_icon::Icon::from_rgba(rgba, size, size).expect("Erreur creation icone systray")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mode console (Linux ou fallback si le systray echoue).
|
|
||||||
fn fallback_console_loop(state: &AgentState) {
|
|
||||||
println!("[TRAY] Mode console — Appuyez sur Ctrl+C pour quitter");
|
|
||||||
while state.is_running() {
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Version non-Windows : pas de systray, l'agent tourne en mode console.
|
|
||||||
#[cfg(not(windows))]
|
|
||||||
pub fn run_tray_loop(_config: Arc<Config>, state: Arc<AgentState>) {
|
|
||||||
println!("[TRAY] Systray non disponible sur cet OS — mode console");
|
|
||||||
fallback_console_loop(&state);
|
|
||||||
}
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
//! Résolution visuelle des cibles via le serveur.
|
|
||||||
//!
|
|
||||||
//! Envoie un screenshot + target_spec au serveur qui effectue le template
|
|
||||||
//! matching OpenCV et retourne les coordonnées résolues (x_pct, y_pct).
|
|
||||||
//! Approche server-side : pas de dépendance OpenCV dans le binaire Rust.
|
|
||||||
|
|
||||||
use crate::capture;
|
|
||||||
use crate::config::Config;
|
|
||||||
use reqwest::blocking::Client;
|
|
||||||
|
|
||||||
/// Résout visuellement une cible en envoyant le screenshot courant au serveur.
|
|
||||||
///
|
|
||||||
/// Capture l'écran, l'encode en JPEG base64, envoie au endpoint
|
|
||||||
/// `/traces/stream/replay/resolve_target` qui fait le template matching.
|
|
||||||
///
|
|
||||||
/// Retourne Some((x_pct, y_pct)) si la cible est trouvée, None sinon.
|
|
||||||
pub fn resolve_target_visual(
|
|
||||||
config: &Config,
|
|
||||||
target_spec: &serde_json::Value,
|
|
||||||
fallback_x: f64,
|
|
||||||
fallback_y: f64,
|
|
||||||
screen_width: u32,
|
|
||||||
screen_height: u32,
|
|
||||||
) -> Option<(f64, f64)> {
|
|
||||||
// 1. Capturer le screenshot actuel
|
|
||||||
let screenshot = match capture::capture_screenshot() {
|
|
||||||
Some(img) => img,
|
|
||||||
None => {
|
|
||||||
eprintln!(" [VISUAL] Echec capture screenshot pour résolution visuelle");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Encoder en JPEG base64 (qualité 75 — bon compromis taille/précision)
|
|
||||||
let screenshot_b64 = capture::screenshot_to_jpeg_base64(&screenshot, 75);
|
|
||||||
if screenshot_b64.is_empty() {
|
|
||||||
eprintln!(" [VISUAL] Echec encodage JPEG");
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
println!(
|
|
||||||
" [VISUAL] Screenshot capture ({}x{}), envoi au serveur...",
|
|
||||||
screen_width, screen_height
|
|
||||||
);
|
|
||||||
|
|
||||||
// 2. Envoyer au serveur /replay/resolve_target
|
|
||||||
let client = Client::new();
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"session_id": config.agent_session_id(),
|
|
||||||
"screenshot_b64": screenshot_b64,
|
|
||||||
"target_spec": target_spec,
|
|
||||||
"fallback_x_pct": fallback_x,
|
|
||||||
"fallback_y_pct": fallback_y,
|
|
||||||
"screen_width": screen_width,
|
|
||||||
"screen_height": screen_height,
|
|
||||||
});
|
|
||||||
|
|
||||||
let url = format!("{}/traces/stream/replay/resolve_target", config.server_url);
|
|
||||||
|
|
||||||
let resp = match client
|
|
||||||
.post(&url)
|
|
||||||
.json(&payload)
|
|
||||||
.timeout(std::time::Duration::from_secs(30))
|
|
||||||
.send()
|
|
||||||
{
|
|
||||||
Ok(r) => r,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(" [VISUAL] Erreur reseau vers {} : {}", url, e);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if !resp.status().is_success() {
|
|
||||||
eprintln!(
|
|
||||||
" [VISUAL] Serveur a repondu HTTP {}",
|
|
||||||
resp.status()
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Parser la réponse
|
|
||||||
let data: serde_json::Value = match resp.json() {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!(" [VISUAL] Erreur parsing reponse JSON : {}", e);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let resolved = data["resolved"].as_bool().unwrap_or(false);
|
|
||||||
if resolved {
|
|
||||||
let x = data["x_pct"].as_f64()?;
|
|
||||||
let y = data["y_pct"].as_f64()?;
|
|
||||||
let method = data["method"].as_str().unwrap_or("?");
|
|
||||||
let score = data["score"].as_f64().unwrap_or(0.0);
|
|
||||||
println!(
|
|
||||||
" [VISUAL] Resolu par {} (score={:.3}) : ({:.4}, {:.4})",
|
|
||||||
method, score, x, y
|
|
||||||
);
|
|
||||||
Some((x, y))
|
|
||||||
} else {
|
|
||||||
let reason = data["reason"].as_str().unwrap_or("inconnu");
|
|
||||||
let method = data["method"].as_str().unwrap_or("?");
|
|
||||||
println!(
|
|
||||||
" [VISUAL] Non resolu (methode={}, raison={})",
|
|
||||||
method, reason
|
|
||||||
);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# window_info.py
|
|
||||||
"""
|
|
||||||
Récupération des informations sur la fenêtre active (X11).
|
|
||||||
|
|
||||||
v0 :
|
|
||||||
- utilise xdotool pour obtenir :
|
|
||||||
- le titre de la fenêtre active
|
|
||||||
- le PID de la fenêtre active, puis le nom du process via ps
|
|
||||||
|
|
||||||
Si quelque chose ne fonctionne pas, on renvoie des valeurs "unknown".
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _run_cmd(cmd: list[str]) -> Optional[str]:
|
|
||||||
"""Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur."""
|
|
||||||
try:
|
|
||||||
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
|
|
||||||
return out.decode("utf-8", errors="ignore").strip()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_window_info() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Renvoie un dict :
|
|
||||||
{
|
|
||||||
"title": "...",
|
|
||||||
"app_name": "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
Nécessite xdotool installé sur le système.
|
|
||||||
"""
|
|
||||||
title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"])
|
|
||||||
pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"])
|
|
||||||
|
|
||||||
app_name: Optional[str] = None
|
|
||||||
if pid_str:
|
|
||||||
pid_str = pid_str.strip()
|
|
||||||
# On récupère le nom du binaire via ps
|
|
||||||
app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="])
|
|
||||||
|
|
||||||
if not title:
|
|
||||||
title = "unknown_window"
|
|
||||||
if not app_name:
|
|
||||||
app_name = "unknown_app"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"app_name": app_name,
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# window_info_crossplatform.py
|
|
||||||
"""
|
|
||||||
Récupération des informations sur la fenêtre active - CROSS-PLATFORM
|
|
||||||
|
|
||||||
Supporte:
|
|
||||||
- Linux (X11 via xdotool)
|
|
||||||
- Windows (via pywin32)
|
|
||||||
- macOS (via pyobjc)
|
|
||||||
|
|
||||||
Installation des dépendances:
|
|
||||||
pip install pywin32 # Windows
|
|
||||||
pip install pyobjc-framework-Cocoa # macOS
|
|
||||||
pip install psutil # Tous OS
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import platform
|
|
||||||
import subprocess
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _run_cmd(cmd: list[str]) -> Optional[str]:
|
|
||||||
"""Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur."""
|
|
||||||
try:
|
|
||||||
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
|
|
||||||
return out.decode("utf-8", errors="ignore").strip()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_window_info() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Renvoie un dict :
|
|
||||||
{
|
|
||||||
"title": "...",
|
|
||||||
"app_name": "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
Détecte automatiquement l'OS et utilise la méthode appropriée.
|
|
||||||
"""
|
|
||||||
system = platform.system()
|
|
||||||
|
|
||||||
if system == "Linux":
|
|
||||||
return _get_window_info_linux()
|
|
||||||
elif system == "Windows":
|
|
||||||
return _get_window_info_windows()
|
|
||||||
elif system == "Darwin": # macOS
|
|
||||||
return _get_window_info_macos()
|
|
||||||
else:
|
|
||||||
return {"title": "unknown_window", "app_name": "unknown_app"}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_window_info_linux() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Linux: utilise xdotool (X11)
|
|
||||||
|
|
||||||
Nécessite: sudo apt-get install xdotool
|
|
||||||
"""
|
|
||||||
title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"])
|
|
||||||
pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"])
|
|
||||||
|
|
||||||
app_name: Optional[str] = None
|
|
||||||
if pid_str:
|
|
||||||
pid_str = pid_str.strip()
|
|
||||||
# On récupère le nom du binaire via ps
|
|
||||||
app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="])
|
|
||||||
|
|
||||||
if not title:
|
|
||||||
title = "unknown_window"
|
|
||||||
if not app_name:
|
|
||||||
app_name = "unknown_app"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"app_name": app_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_window_info_windows() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Windows: utilise pywin32 + psutil
|
|
||||||
|
|
||||||
Nécessite: pip install pywin32 psutil
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import win32gui
|
|
||||||
import win32process
|
|
||||||
import psutil
|
|
||||||
|
|
||||||
# Fenêtre au premier plan
|
|
||||||
hwnd = win32gui.GetForegroundWindow()
|
|
||||||
|
|
||||||
# Titre de la fenêtre
|
|
||||||
title = win32gui.GetWindowText(hwnd)
|
|
||||||
if not title:
|
|
||||||
title = "unknown_window"
|
|
||||||
|
|
||||||
# PID du processus
|
|
||||||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
|
||||||
|
|
||||||
# Nom du processus
|
|
||||||
try:
|
|
||||||
process = psutil.Process(pid)
|
|
||||||
app_name = process.name()
|
|
||||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
||||||
app_name = "unknown_app"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"app_name": app_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
# pywin32 ou psutil non installé
|
|
||||||
return {
|
|
||||||
"title": "unknown_window (pywin32 missing)",
|
|
||||||
"app_name": "unknown_app (pywin32 missing)",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"title": f"error: {e}",
|
|
||||||
"app_name": "unknown_app",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_window_info_macos() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
macOS: utilise pyobjc (AppKit)
|
|
||||||
|
|
||||||
Nécessite: pip install pyobjc-framework-Cocoa
|
|
||||||
|
|
||||||
Note: Nécessite les permissions "Accessibility" dans System Preferences
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from AppKit import NSWorkspace
|
|
||||||
from Quartz import (
|
|
||||||
CGWindowListCopyWindowInfo,
|
|
||||||
kCGWindowListOptionOnScreenOnly,
|
|
||||||
kCGNullWindowID
|
|
||||||
)
|
|
||||||
|
|
||||||
# Application active
|
|
||||||
active_app = NSWorkspace.sharedWorkspace().activeApplication()
|
|
||||||
app_name = active_app.get('NSApplicationName', 'unknown_app')
|
|
||||||
|
|
||||||
# Titre de la fenêtre (via Quartz)
|
|
||||||
# On cherche la fenêtre de l'app active qui est au premier plan
|
|
||||||
window_list = CGWindowListCopyWindowInfo(
|
|
||||||
kCGWindowListOptionOnScreenOnly,
|
|
||||||
kCGNullWindowID
|
|
||||||
)
|
|
||||||
|
|
||||||
title = "unknown_window"
|
|
||||||
for window in window_list:
|
|
||||||
owner_name = window.get('kCGWindowOwnerName', '')
|
|
||||||
if owner_name == app_name:
|
|
||||||
window_title = window.get('kCGWindowName', '')
|
|
||||||
if window_title:
|
|
||||||
title = window_title
|
|
||||||
break
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"app_name": app_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
# pyobjc non installé
|
|
||||||
return {
|
|
||||||
"title": "unknown_window (pyobjc missing)",
|
|
||||||
"app_name": "unknown_app (pyobjc missing)",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"title": f"error: {e}",
|
|
||||||
"app_name": "unknown_app",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Test rapide
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import time
|
|
||||||
|
|
||||||
print(f"OS détecté: {platform.system()}")
|
|
||||||
print("\nTest de capture fenêtre active (5 secondes)...")
|
|
||||||
print("Changez de fenêtre pour tester!\n")
|
|
||||||
|
|
||||||
for i in range(5):
|
|
||||||
info = get_active_window_info()
|
|
||||||
print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}")
|
|
||||||
time.sleep(1)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
# window_info.py
|
|
||||||
"""
|
|
||||||
Récupération des informations sur la fenêtre active (X11).
|
|
||||||
|
|
||||||
v0 :
|
|
||||||
- utilise xdotool pour obtenir :
|
|
||||||
- le titre de la fenêtre active
|
|
||||||
- le PID de la fenêtre active, puis le nom du process via ps
|
|
||||||
|
|
||||||
Si quelque chose ne fonctionne pas, on renvoie des valeurs "unknown".
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _run_cmd(cmd: list[str]) -> Optional[str]:
|
|
||||||
"""Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur."""
|
|
||||||
try:
|
|
||||||
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
|
|
||||||
return out.decode("utf-8", errors="ignore").strip()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_window_info() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Renvoie un dict :
|
|
||||||
{
|
|
||||||
"title": "...",
|
|
||||||
"app_name": "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
Nécessite xdotool installé sur le système.
|
|
||||||
"""
|
|
||||||
title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"])
|
|
||||||
pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"])
|
|
||||||
|
|
||||||
app_name: Optional[str] = None
|
|
||||||
if pid_str:
|
|
||||||
pid_str = pid_str.strip()
|
|
||||||
# On récupère le nom du binaire via ps
|
|
||||||
app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="])
|
|
||||||
|
|
||||||
if not title:
|
|
||||||
title = "unknown_window"
|
|
||||||
if not app_name:
|
|
||||||
app_name = "unknown_app"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"app_name": app_name,
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
# window_info_crossplatform.py
|
|
||||||
"""
|
|
||||||
Récupération des informations sur la fenêtre active - CROSS-PLATFORM
|
|
||||||
|
|
||||||
Supporte:
|
|
||||||
- Linux (X11 via xdotool)
|
|
||||||
- Windows (via pywin32)
|
|
||||||
- macOS (via pyobjc)
|
|
||||||
|
|
||||||
Installation des dépendances:
|
|
||||||
pip install pywin32 # Windows
|
|
||||||
pip install pyobjc-framework-Cocoa # macOS
|
|
||||||
pip install psutil # Tous OS
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import platform
|
|
||||||
import subprocess
|
|
||||||
from typing import Dict, Optional
|
|
||||||
|
|
||||||
|
|
||||||
def _run_cmd(cmd: list[str]) -> Optional[str]:
|
|
||||||
"""Exécute une commande et renvoie la sortie texte (strippée), ou None en cas d'erreur."""
|
|
||||||
try:
|
|
||||||
out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL)
|
|
||||||
return out.decode("utf-8", errors="ignore").strip()
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def get_active_window_info() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Renvoie un dict :
|
|
||||||
{
|
|
||||||
"title": "...",
|
|
||||||
"app_name": "..."
|
|
||||||
}
|
|
||||||
|
|
||||||
Détecte automatiquement l'OS et utilise la méthode appropriée.
|
|
||||||
"""
|
|
||||||
system = platform.system()
|
|
||||||
|
|
||||||
if system == "Linux":
|
|
||||||
return _get_window_info_linux()
|
|
||||||
elif system == "Windows":
|
|
||||||
return _get_window_info_windows()
|
|
||||||
elif system == "Darwin": # macOS
|
|
||||||
return _get_window_info_macos()
|
|
||||||
else:
|
|
||||||
return {"title": "unknown_window", "app_name": "unknown_app"}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_window_info_linux() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Linux: utilise xdotool (X11)
|
|
||||||
|
|
||||||
Nécessite: sudo apt-get install xdotool
|
|
||||||
"""
|
|
||||||
title = _run_cmd(["xdotool", "getactivewindow", "getwindowname"])
|
|
||||||
pid_str = _run_cmd(["xdotool", "getactivewindow", "getwindowpid"])
|
|
||||||
|
|
||||||
app_name: Optional[str] = None
|
|
||||||
if pid_str:
|
|
||||||
pid_str = pid_str.strip()
|
|
||||||
# On récupère le nom du binaire via ps
|
|
||||||
app_name = _run_cmd(["ps", "-p", pid_str, "-o", "comm="])
|
|
||||||
|
|
||||||
if not title:
|
|
||||||
title = "unknown_window"
|
|
||||||
if not app_name:
|
|
||||||
app_name = "unknown_app"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"app_name": app_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_window_info_windows() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
Windows: utilise pywin32 + psutil
|
|
||||||
|
|
||||||
Nécessite: pip install pywin32 psutil
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
import win32gui
|
|
||||||
import win32process
|
|
||||||
import psutil
|
|
||||||
|
|
||||||
# Fenêtre au premier plan
|
|
||||||
hwnd = win32gui.GetForegroundWindow()
|
|
||||||
|
|
||||||
# Titre de la fenêtre
|
|
||||||
title = win32gui.GetWindowText(hwnd)
|
|
||||||
if not title:
|
|
||||||
title = "unknown_window"
|
|
||||||
|
|
||||||
# PID du processus
|
|
||||||
_, pid = win32process.GetWindowThreadProcessId(hwnd)
|
|
||||||
|
|
||||||
# Nom du processus
|
|
||||||
try:
|
|
||||||
process = psutil.Process(pid)
|
|
||||||
app_name = process.name()
|
|
||||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
||||||
app_name = "unknown_app"
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"app_name": app_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
# pywin32 ou psutil non installé
|
|
||||||
return {
|
|
||||||
"title": "unknown_window (pywin32 missing)",
|
|
||||||
"app_name": "unknown_app (pywin32 missing)",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"title": f"error: {e}",
|
|
||||||
"app_name": "unknown_app",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_window_info_macos() -> Dict[str, str]:
|
|
||||||
"""
|
|
||||||
macOS: utilise pyobjc (AppKit)
|
|
||||||
|
|
||||||
Nécessite: pip install pyobjc-framework-Cocoa
|
|
||||||
|
|
||||||
Note: Nécessite les permissions "Accessibility" dans System Preferences
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from AppKit import NSWorkspace
|
|
||||||
from Quartz import (
|
|
||||||
CGWindowListCopyWindowInfo,
|
|
||||||
kCGWindowListOptionOnScreenOnly,
|
|
||||||
kCGNullWindowID
|
|
||||||
)
|
|
||||||
|
|
||||||
# Application active
|
|
||||||
active_app = NSWorkspace.sharedWorkspace().activeApplication()
|
|
||||||
app_name = active_app.get('NSApplicationName', 'unknown_app')
|
|
||||||
|
|
||||||
# Titre de la fenêtre (via Quartz)
|
|
||||||
# On cherche la fenêtre de l'app active qui est au premier plan
|
|
||||||
window_list = CGWindowListCopyWindowInfo(
|
|
||||||
kCGWindowListOptionOnScreenOnly,
|
|
||||||
kCGNullWindowID
|
|
||||||
)
|
|
||||||
|
|
||||||
title = "unknown_window"
|
|
||||||
for window in window_list:
|
|
||||||
owner_name = window.get('kCGWindowOwnerName', '')
|
|
||||||
if owner_name == app_name:
|
|
||||||
window_title = window.get('kCGWindowName', '')
|
|
||||||
if window_title:
|
|
||||||
title = window_title
|
|
||||||
break
|
|
||||||
|
|
||||||
return {
|
|
||||||
"title": title,
|
|
||||||
"app_name": app_name,
|
|
||||||
}
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
# pyobjc non installé
|
|
||||||
return {
|
|
||||||
"title": "unknown_window (pyobjc missing)",
|
|
||||||
"app_name": "unknown_app (pyobjc missing)",
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
return {
|
|
||||||
"title": f"error: {e}",
|
|
||||||
"app_name": "unknown_app",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# Test rapide
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import time
|
|
||||||
|
|
||||||
print(f"OS détecté: {platform.system()}")
|
|
||||||
print("\nTest de capture fenêtre active (5 secondes)...")
|
|
||||||
print("Changez de fenêtre pour tester!\n")
|
|
||||||
|
|
||||||
for i in range(5):
|
|
||||||
info = get_active_window_info()
|
|
||||||
print(f"[{i+1}] App: {info['app_name']:20s} | Title: {info['title']}")
|
|
||||||
time.sleep(1)
|
|
||||||
@@ -1,275 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# ============================================================
|
|
||||||
# build_lea_exe.sh — Cree un executable Windows autonome via PyInstaller
|
|
||||||
#
|
|
||||||
# IMPORTANT : Ce script doit tourner SUR WINDOWS (ou dans Wine/WSL
|
|
||||||
# avec acces a un Python Windows). PyInstaller ne peut pas produire
|
|
||||||
# un .exe Windows depuis Linux natif.
|
|
||||||
#
|
|
||||||
# Procedure recommandee :
|
|
||||||
# 1. Sur le PC Windows (192.168.1.11 ou autre) :
|
|
||||||
# - Installer Python 3.12 (https://python.org)
|
|
||||||
# - pip install pyinstaller
|
|
||||||
# 2. Copier ce script et le dossier agent_v0/ sur le PC Windows
|
|
||||||
# 3. Executer depuis PowerShell/cmd :
|
|
||||||
# python -m PyInstaller --onefile --windowed ^
|
|
||||||
# --name "Lea" ^
|
|
||||||
# --add-data "agent_v1;agent_v1" ^
|
|
||||||
# --add-data "lea_ui;lea_ui" ^
|
|
||||||
# --add-data "config.txt;." ^
|
|
||||||
# --hidden-import "pynput.keyboard._win32" ^
|
|
||||||
# --hidden-import "pynput.mouse._win32" ^
|
|
||||||
# --hidden-import "pystray._win32" ^
|
|
||||||
# --hidden-import "plyer.platforms.win.notification" ^
|
|
||||||
# --hidden-import "win32api" ^
|
|
||||||
# --hidden-import "win32con" ^
|
|
||||||
# --hidden-import "win32gui" ^
|
|
||||||
# run_agent_v1.py
|
|
||||||
#
|
|
||||||
# Le .exe resultant sera dans dist/Lea.exe (~50-100 MB)
|
|
||||||
#
|
|
||||||
# ============================================================
|
|
||||||
#
|
|
||||||
# OPTION ALTERNATIVE : Python Embedded (recommandee)
|
|
||||||
#
|
|
||||||
# Python Embedded est un Python portable officiel (pas d'installation).
|
|
||||||
# Combine avec le code source, c'est la methode la plus fiable
|
|
||||||
# pour les non-informaticiens.
|
|
||||||
#
|
|
||||||
# Sur une machine Windows :
|
|
||||||
# 1. Telecharger Python Embedded 3.12 :
|
|
||||||
# https://www.python.org/ftp/python/3.12.9/python-3.12.9-embed-amd64.zip
|
|
||||||
#
|
|
||||||
# 2. Dezipper dans un dossier temporaire
|
|
||||||
#
|
|
||||||
# 3. Activer pip dans Python Embedded :
|
|
||||||
# - Editer python312._pth, decommenter "import site"
|
|
||||||
# - Telecharger get-pip.py : https://bootstrap.pypa.io/get-pip.py
|
|
||||||
# - Executer : python.exe get-pip.py
|
|
||||||
#
|
|
||||||
# 4. Installer les dependances :
|
|
||||||
# python.exe -m pip install -r requirements_agent.txt
|
|
||||||
#
|
|
||||||
# 5. Copier le code source (agent_v1/, lea_ui/, run_agent_v1.py)
|
|
||||||
#
|
|
||||||
# 6. Zipper le tout → Lea_Portable.zip (~40-60 MB)
|
|
||||||
#
|
|
||||||
# Le Lea.bat dans ce cas utiliserait :
|
|
||||||
# python\python.exe run_agent_v1.py
|
|
||||||
# au lieu de .venv\Scripts\python.exe
|
|
||||||
#
|
|
||||||
# ============================================================
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
||||||
|
|
||||||
echo "============================================================"
|
|
||||||
echo " Build Lea.exe (PyInstaller)"
|
|
||||||
echo "============================================================"
|
|
||||||
echo ""
|
|
||||||
echo " Ce script ne peut pas produire un .exe Windows depuis Linux."
|
|
||||||
echo ""
|
|
||||||
echo " OPTIONS DISPONIBLES :"
|
|
||||||
echo ""
|
|
||||||
echo " 1. OPTION VIA PC WINDOWS (recommandee pour .exe) :"
|
|
||||||
echo " Copiez le dossier deploy/ sur le PC Windows"
|
|
||||||
echo " puis lancez la commande PyInstaller ci-dessous."
|
|
||||||
echo ""
|
|
||||||
echo " 2. OPTION ZIP + VENV (recommandee pour deploiement rapide) :"
|
|
||||||
echo " Lancez ./deploy/build_package.sh"
|
|
||||||
echo " Le zip resultant contient install.bat + Lea.bat"
|
|
||||||
echo ""
|
|
||||||
echo " 3. OPTION PYTHON EMBEDDED (recommandee pour zero install) :"
|
|
||||||
echo " Suivez les instructions dans ce script (section ALTERNATIVE)"
|
|
||||||
echo ""
|
|
||||||
echo "============================================================"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Generer le .spec PyInstaller pour reference
|
|
||||||
SPEC_FILE="$SCRIPT_DIR/Lea.spec"
|
|
||||||
cat > "$SPEC_FILE" << 'PYINSTALLER_SPEC'
|
|
||||||
# -*- mode: python ; coding: utf-8 -*-
|
|
||||||
# Lea.spec — Configuration PyInstaller pour l'agent Lea
|
|
||||||
#
|
|
||||||
# Usage sur Windows :
|
|
||||||
# pip install pyinstaller
|
|
||||||
# pyinstaller Lea.spec
|
|
||||||
#
|
|
||||||
# Le .exe resultant sera dans dist/Lea.exe
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
block_cipher = None
|
|
||||||
|
|
||||||
# Repertoire de travail (ou se trouve ce .spec)
|
|
||||||
SPEC_DIR = os.path.dirname(os.path.abspath(SPEC())) if 'SPEC' in dir() else '.'
|
|
||||||
|
|
||||||
a = Analysis(
|
|
||||||
['run_agent_v1.py'],
|
|
||||||
pathex=['.'],
|
|
||||||
binaries=[],
|
|
||||||
datas=[
|
|
||||||
('agent_v1', 'agent_v1'),
|
|
||||||
('lea_ui', 'lea_ui'),
|
|
||||||
('config.txt', '.'),
|
|
||||||
('LISEZMOI.txt', '.'),
|
|
||||||
],
|
|
||||||
hiddenimports=[
|
|
||||||
# pynput backends Windows
|
|
||||||
'pynput.keyboard._win32',
|
|
||||||
'pynput.mouse._win32',
|
|
||||||
# pystray backend Windows
|
|
||||||
'pystray._win32',
|
|
||||||
# plyer notification Windows
|
|
||||||
'plyer.platforms.win',
|
|
||||||
'plyer.platforms.win.notification',
|
|
||||||
# pywin32
|
|
||||||
'win32api',
|
|
||||||
'win32con',
|
|
||||||
'win32gui',
|
|
||||||
'win32com',
|
|
||||||
'pythoncom',
|
|
||||||
# tkinter (stdlib, parfois manquant dans PyInstaller)
|
|
||||||
'tkinter',
|
|
||||||
'tkinter.simpledialog',
|
|
||||||
'tkinter.messagebox',
|
|
||||||
'tkinter.filedialog',
|
|
||||||
],
|
|
||||||
hookspath=[],
|
|
||||||
hooksconfig={},
|
|
||||||
runtime_hooks=[],
|
|
||||||
excludes=[
|
|
||||||
# Exclure les modules lourds non necessaires cote client
|
|
||||||
'torch',
|
|
||||||
'torchvision',
|
|
||||||
'transformers',
|
|
||||||
'clip',
|
|
||||||
'open_clip',
|
|
||||||
'faiss',
|
|
||||||
'cv2', # opencv pas obligatoire (blur_sensitive a un fallback)
|
|
||||||
'numpy', # requis par PIL mais pas directement
|
|
||||||
'scipy',
|
|
||||||
'sklearn',
|
|
||||||
'matplotlib',
|
|
||||||
'pandas',
|
|
||||||
'tensorflow',
|
|
||||||
],
|
|
||||||
win_no_prefer_redirects=False,
|
|
||||||
win_private_assemblies=False,
|
|
||||||
cipher=block_cipher,
|
|
||||||
noarchive=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
|
|
||||||
|
|
||||||
exe = EXE(
|
|
||||||
pyz,
|
|
||||||
a.scripts,
|
|
||||||
a.binaries,
|
|
||||||
a.zipfiles,
|
|
||||||
a.datas,
|
|
||||||
[],
|
|
||||||
name='Lea',
|
|
||||||
debug=False,
|
|
||||||
bootloader_ignore_signals=False,
|
|
||||||
strip=False,
|
|
||||||
upx=True,
|
|
||||||
upx_exclude=[],
|
|
||||||
runtime_tmpdir=None,
|
|
||||||
console=False, # --windowed : pas de console visible
|
|
||||||
disable_windowed_traceback=False,
|
|
||||||
argv_emulation=False,
|
|
||||||
target_arch=None,
|
|
||||||
codesign_identity=None,
|
|
||||||
entitlements_file=None,
|
|
||||||
# icon='assets/lea_icon.ico', # Decommenter quand l'icone sera creee
|
|
||||||
)
|
|
||||||
PYINSTALLER_SPEC
|
|
||||||
|
|
||||||
echo " Fichier Lea.spec genere dans : $SPEC_FILE"
|
|
||||||
echo ""
|
|
||||||
echo " Pour builder sur Windows :"
|
|
||||||
echo " 1. Copier le dossier Lea/ (apres build_package.sh) sur le PC Windows"
|
|
||||||
echo " 2. pip install pyinstaller"
|
|
||||||
echo " 3. cd Lea"
|
|
||||||
echo " 4. pyinstaller ../Lea.spec"
|
|
||||||
echo " 5. Le .exe sera dans dist/Lea.exe"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Generer aussi un script batch pour builder sur Windows
|
|
||||||
WIN_BUILD="$SCRIPT_DIR/build_exe_windows.bat"
|
|
||||||
cat > "$WIN_BUILD" << 'WIN_BATCH'
|
|
||||||
@echo off
|
|
||||||
chcp 65001 >nul 2>&1
|
|
||||||
title Build Lea.exe
|
|
||||||
|
|
||||||
echo ============================================================
|
|
||||||
echo Build Lea.exe (PyInstaller)
|
|
||||||
echo ============================================================
|
|
||||||
echo.
|
|
||||||
|
|
||||||
:: Verifier PyInstaller
|
|
||||||
pip show pyinstaller >nul 2>&1
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo Installation de PyInstaller...
|
|
||||||
pip install pyinstaller
|
|
||||||
)
|
|
||||||
|
|
||||||
:: Builder
|
|
||||||
echo Build en cours (cela prend 2-5 minutes)...
|
|
||||||
echo.
|
|
||||||
|
|
||||||
pyinstaller --onefile --windowed ^
|
|
||||||
--name "Lea" ^
|
|
||||||
--add-data "agent_v1;agent_v1" ^
|
|
||||||
--add-data "lea_ui;lea_ui" ^
|
|
||||||
--add-data "config.txt;." ^
|
|
||||||
--add-data "LISEZMOI.txt;." ^
|
|
||||||
--hidden-import "pynput.keyboard._win32" ^
|
|
||||||
--hidden-import "pynput.mouse._win32" ^
|
|
||||||
--hidden-import "pystray._win32" ^
|
|
||||||
--hidden-import "plyer.platforms.win.notification" ^
|
|
||||||
--hidden-import "win32api" ^
|
|
||||||
--hidden-import "win32con" ^
|
|
||||||
--hidden-import "win32gui" ^
|
|
||||||
--hidden-import "tkinter" ^
|
|
||||||
--hidden-import "tkinter.simpledialog" ^
|
|
||||||
--hidden-import "tkinter.messagebox" ^
|
|
||||||
--exclude-module "torch" ^
|
|
||||||
--exclude-module "torchvision" ^
|
|
||||||
--exclude-module "transformers" ^
|
|
||||||
--exclude-module "clip" ^
|
|
||||||
--exclude-module "faiss" ^
|
|
||||||
--exclude-module "scipy" ^
|
|
||||||
--exclude-module "sklearn" ^
|
|
||||||
--exclude-module "matplotlib" ^
|
|
||||||
--exclude-module "pandas" ^
|
|
||||||
--exclude-module "tensorflow" ^
|
|
||||||
run_agent_v1.py
|
|
||||||
|
|
||||||
if errorlevel 1 (
|
|
||||||
echo.
|
|
||||||
echo ERREUR : Le build a echoue.
|
|
||||||
pause
|
|
||||||
exit /b 1
|
|
||||||
)
|
|
||||||
|
|
||||||
echo.
|
|
||||||
echo ============================================================
|
|
||||||
echo Build termine !
|
|
||||||
echo.
|
|
||||||
echo Lea.exe est dans le dossier dist\
|
|
||||||
echo Taille :
|
|
||||||
dir dist\Lea.exe | findstr "Lea.exe"
|
|
||||||
echo.
|
|
||||||
echo Pour deployer : copiez dist\Lea.exe + config.txt + LISEZMOI.txt
|
|
||||||
echo ============================================================
|
|
||||||
pause
|
|
||||||
WIN_BATCH
|
|
||||||
|
|
||||||
echo " Script Windows genere : $WIN_BUILD"
|
|
||||||
echo ""
|
|
||||||
echo "============================================================"
|
|
||||||
Reference in New Issue
Block a user