feat: replay visuel VLM-first, worker séparé, package Léa, AZERTY, sécurité HTTPS
Pipeline replay visuel : - VLM-first : l'agent appelle Ollama directement pour trouver les éléments - Template matching en fallback (seuil strict 0.90) - Stop immédiat si élément non trouvé (pas de clic blind) - Replay depuis session brute (/replay-session) sans attendre le VLM - Vérification post-action (screenshot hash avant/après) - Gestion des popups (Enter/Escape/Tab+Enter) Worker VLM séparé : - run_worker.py : process distinct du serveur HTTP - Communication par fichiers (_worker_queue.txt + _replay_active.lock) - Le serveur HTTP ne fait plus jamais de VLM → toujours réactif - Service systemd rpa-worker.service Capture clavier : - raw_keys (vk + press/release) pour replay exact indépendant du layout - Fix AZERTY : ToUnicodeEx + AltGr detection - Enter capturé comme \n, Tab comme \t - Filtrage modificateurs seuls (Ctrl/Alt/Shift parasites) - Fusion text_input consécutifs, dédup key_combo Sécurité & Internet : - HTTPS Let's Encrypt (lea.labs + vwb.labs.laurinebazin.design) - Token API fixe dans .env.local - HTTP Basic Auth sur VWB - Security headers (HSTS, CSP, nosniff) - CORS domaines publics, plus de wildcard Infrastructure : - DPI awareness (SetProcessDpiAwareness) Python + Rust - Métadonnées système (dpi_scale, window_bounds, monitors, os_theme) - Template matching multi-scale [0.5, 2.0] - Résolution dynamique (plus de hardcode 1920x1080) - VLM prefill fix (47x speedup, 3.5s au lieu de 180s) Modules : - core/auth/ : credential vault (Fernet AES), TOTP (RFC 6238), auth handler - core/federation/ : LearningPack export/import anonymisé, FAISS global - deploy/ : package Léa (config.txt, Lea.bat, install.bat, LISEZMOI.txt) UX : - Filtrage OS (VWB + Chat montrent que les workflows de l'OS courant) - Bibliothèque persistante (cache local + SQLite) - Clustering hybride (titre fenêtre + DBSCAN) - EdgeConstraints + PostConditions peuplés - GraphBuilder compound actions (toutes les frappes) Agent Rust : - Token Bearer auth (network.rs) - sysinfo.rs (DPI, résolution, window bounds via Win32 API) - config.txt lu automatiquement - Support Chrome/Brave/Firefox (pas que Edge) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,25 @@ import platform
|
||||
import socket
|
||||
from pathlib import Path
|
||||
|
||||
# --- DPI awareness (DOIT etre appele avant tout import de pynput/mss/tkinter) ---
|
||||
# Rend le process DPI-aware sur Windows pour que toutes les API (pynput, mss, pyautogui)
|
||||
# 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 les API,
|
||||
# ce qui cause des erreurs de positionnement pendant le replay.
|
||||
# Sur Linux/Mac : no-op silencieux.
|
||||
# PROCESS_PER_MONITOR_DPI_AWARE = 2 : le niveau le plus precis.
|
||||
if platform.system() == "Windows":
|
||||
try:
|
||||
import ctypes
|
||||
ctypes.windll.shcore.SetProcessDpiAwareness(2) # PROCESS_PER_MONITOR_DPI_AWARE
|
||||
except Exception:
|
||||
try:
|
||||
# Fallback pour Windows < 8.1 (API plus ancienne)
|
||||
ctypes.windll.user32.SetProcessDPIAware()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
AGENT_VERSION = "1.0.0"
|
||||
|
||||
# Identifiant unique de la machine (utilisé pour le multi-machine)
|
||||
@@ -34,7 +53,7 @@ MAX_SESSION_DURATION_S = 60 * 60 # 1 heure
|
||||
SESSIONS_ROOT = BASE_DIR / "sessions"
|
||||
|
||||
# Paramètres Vision (Crops pour qwen3-vl)
|
||||
TARGETED_CROP_SIZE = (400, 400)
|
||||
TARGETED_CROP_SIZE = (150, 150)
|
||||
SCREENSHOT_QUALITY = 85
|
||||
|
||||
# Floutage des données sensibles (conformité AI Act)
|
||||
@@ -52,6 +71,22 @@ PERF_MONITOR_INTERVAL_S = 30
|
||||
LOGS_DIR = BASE_DIR / "logs"
|
||||
LOG_FILE = LOGS_DIR / "agent_v1.log"
|
||||
|
||||
# --- Métadonnées système (capturées au chargement du module) ---
|
||||
# Utilisées pour la bannière de démarrage et le diagnostic.
|
||||
# Import tardif pour éviter les dépendances circulaires.
|
||||
try:
|
||||
from .vision.system_info import get_dpi_scale, get_os_theme, get_monitor_info
|
||||
_monitor_index, _monitors = get_monitor_info()
|
||||
_primary = _monitors[0] if _monitors else {"width": 1920, "height": 1080}
|
||||
SCREEN_RESOLUTION = (_primary["width"], _primary["height"])
|
||||
DPI_SCALE = get_dpi_scale()
|
||||
OS_THEME = get_os_theme()
|
||||
except Exception:
|
||||
# Fallback silencieux si les métadonnées ne sont pas disponibles
|
||||
SCREEN_RESOLUTION = (1920, 1080)
|
||||
DPI_SCALE = 100
|
||||
OS_THEME = "unknown"
|
||||
|
||||
# Création des dossiers
|
||||
os.makedirs(SESSIONS_ROOT, exist_ok=True)
|
||||
os.makedirs(LOGS_DIR, exist_ok=True)
|
||||
|
||||
@@ -10,11 +10,20 @@ Fonctionnalités :
|
||||
- Buffer de saisie texte : accumule les frappes et émet un événement
|
||||
text_input après 500ms d'inactivité clavier
|
||||
- Surveillance du focus fenêtre
|
||||
|
||||
NOTE DPI : Les coordonnees retournees par pynput dependent du DPI awareness
|
||||
du process. Quand SetProcessDpiAwareness(2) est appele (dans config.py),
|
||||
pynput retourne des coordonnees en pixels PHYSIQUES. Les metadonnees
|
||||
screen_metadata (resolution via mss) sont aussi en pixels physiques.
|
||||
Ceci garantit que la normalisation pos/resolution est coherente.
|
||||
Sans DPI awareness, pynput retourne des coordonnees LOGIQUES mais mss
|
||||
retourne des pixels physiques, ce qui cause une erreur de normalisation.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
import platform
|
||||
from typing import Callable, Optional, List, Dict, Any, Tuple
|
||||
from pynput import mouse, keyboard
|
||||
from pynput.mouse import Button
|
||||
@@ -22,10 +31,14 @@ from pynput.keyboard import Key, KeyCode
|
||||
|
||||
# Importation relative pour rester dans le module v1
|
||||
from ..vision.capturer import VisionCapturer
|
||||
from ..vision.system_info import get_screen_metadata
|
||||
# from ..monitoring.system import SystemMonitor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Détection Windows une seule fois au chargement du module
|
||||
IS_WINDOWS = platform.system() == "Windows"
|
||||
|
||||
# Délai d'inactivité avant flush du buffer texte (en secondes)
|
||||
TEXT_FLUSH_DELAY = 0.5
|
||||
# Délai max entre deux clics pour un double-clic (en secondes)
|
||||
@@ -57,6 +70,11 @@ class EventCaptorV1:
|
||||
self._text_start_pos: Optional[Tuple[int, int]] = None
|
||||
# Timer pour le flush après inactivité
|
||||
self._text_flush_timer: Optional[threading.Timer] = None
|
||||
# Compteur de génération pour éviter qu'un timer obsolète ne flush
|
||||
# un buffer en cours de remplissage (race condition). Incrémenté
|
||||
# à chaque reset du timer. Le timer ne flush que si la génération
|
||||
# n'a pas changé.
|
||||
self._text_flush_generation: int = 0
|
||||
# Dernière position connue de la souris (pour associer le texte
|
||||
# au champ dans lequel l'utilisateur tape)
|
||||
self._last_mouse_pos: Tuple[int, int] = (0, 0)
|
||||
@@ -65,6 +83,17 @@ class EventCaptorV1:
|
||||
# Dernier clic : (x, y, timestamp, button)
|
||||
self._last_click: Optional[Tuple[int, int, float, str]] = None
|
||||
|
||||
# --- Buffer de raw_keys (press/release bruts avec vk codes) ---
|
||||
# Accumule chaque press/release pour le replay exact (solution AZERTY).
|
||||
# Vidé en même temps que le text_buffer ou à l'émission d'un key_combo.
|
||||
self._raw_key_buffer: List[Dict[str, Any]] = []
|
||||
|
||||
# --- Métadonnées système (DPI, résolution, moniteur, thème, langue) ---
|
||||
# Capturées au démarrage puis rafraîchies à chaque changement de focus.
|
||||
# Injectées dans chaque événement via le champ "screen_metadata".
|
||||
self._screen_metadata: Dict[str, Any] = {}
|
||||
self._screen_metadata_lock = threading.Lock()
|
||||
|
||||
def start(self):
|
||||
self.running = True
|
||||
self.mouse_listener = mouse.Listener(
|
||||
@@ -80,6 +109,9 @@ class EventCaptorV1:
|
||||
self.mouse_listener.start()
|
||||
self.keyboard_listener.start()
|
||||
|
||||
# Capture initiale des métadonnées système
|
||||
self._refresh_screen_metadata()
|
||||
|
||||
# Thread de surveillance du focus fenêtre (Proactif)
|
||||
self._focus_thread = threading.Thread(target=self._watch_window_focus, daemon=True)
|
||||
self._focus_thread.start()
|
||||
@@ -131,6 +163,7 @@ class EventCaptorV1:
|
||||
"pos": (x, y),
|
||||
"timestamp": now,
|
||||
}
|
||||
self._inject_screen_metadata(event)
|
||||
self.on_event(event)
|
||||
# Réinitialiser pour éviter un triple-clic = 2 double-clics
|
||||
self._last_click = None
|
||||
@@ -144,6 +177,7 @@ class EventCaptorV1:
|
||||
"pos": (x, y),
|
||||
"timestamp": now,
|
||||
}
|
||||
self._inject_screen_metadata(event)
|
||||
self.on_event(event)
|
||||
|
||||
def _on_scroll(self, x, y, dx, dy):
|
||||
@@ -168,7 +202,106 @@ class EventCaptorV1:
|
||||
return key.name
|
||||
return str(key)
|
||||
|
||||
# Ensemble des touches considérées comme modificateurs purs.
|
||||
# Utilisé pour ne PAS émettre de key_combo quand seuls des
|
||||
# modificateurs sont enfoncés (évite le bruit).
|
||||
_MODIFIER_KEYS = {
|
||||
Key.ctrl, Key.ctrl_l, Key.ctrl_r,
|
||||
Key.alt, Key.alt_l, Key.alt_r,
|
||||
Key.shift, Key.shift_l, Key.shift_r,
|
||||
Key.cmd, Key.cmd_l, Key.cmd_r,
|
||||
}
|
||||
_MODIFIER_KEY_NAMES = {
|
||||
"ctrl", "ctrl_l", "ctrl_r",
|
||||
"alt", "alt_l", "alt_r",
|
||||
"shift", "shift_l", "shift_r",
|
||||
"cmd", "cmd_l", "cmd_r",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _vk_to_char(vk_code: int) -> Optional[str]:
|
||||
"""Convertir un virtual key code en caractère réel (AZERTY-aware).
|
||||
|
||||
Utilise ToUnicodeEx avec le layout clavier actif pour obtenir
|
||||
le bon caractère même pour les touches AltGr, Shift+chiffres,
|
||||
et autres combinaisons spécifiques au layout (AZERTY, QWERTZ, etc.).
|
||||
|
||||
Ne fonctionne que sur Windows. Retourne None sur Linux/Mac.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return None
|
||||
try:
|
||||
import ctypes
|
||||
import ctypes.wintypes as wt
|
||||
user32 = ctypes.windll.user32
|
||||
|
||||
kbd_state = (ctypes.c_ubyte * 256)()
|
||||
user32.GetKeyboardState(kbd_state)
|
||||
|
||||
buf = (ctypes.c_wchar * 8)()
|
||||
scan = user32.MapVirtualKeyW(vk_code, 0)
|
||||
|
||||
# Layout du thread de la fenêtre active (gère AZERTY, QWERTZ, etc.)
|
||||
hwnd = user32.GetForegroundWindow()
|
||||
tid = user32.GetWindowThreadProcessId(hwnd, None)
|
||||
hkl = user32.GetKeyboardLayout(tid)
|
||||
|
||||
n = user32.ToUnicodeEx(vk_code, scan, kbd_state, buf, 8, 0, hkl)
|
||||
if n > 0:
|
||||
return buf[0]
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _is_altgr_producing_char(self, key) -> Optional[str]:
|
||||
"""Détecte si la combinaison actuelle est AltGr+touche produisant un caractère.
|
||||
|
||||
Sur Windows AZERTY, AltGr est envoyé comme Ctrl+Alt par pynput.
|
||||
Cette méthode vérifie si Ctrl+Alt est enfoncé et que la touche
|
||||
produit un caractère imprimable via le layout clavier.
|
||||
Ex: AltGr+é → ~, AltGr+( → {, AltGr+à → @
|
||||
|
||||
Retourne le caractère produit ou None si ce n'est pas un AltGr valide.
|
||||
"""
|
||||
if not IS_WINDOWS:
|
||||
return None
|
||||
# AltGr = Ctrl+Alt (sans Win) sur Windows
|
||||
if self.modifiers != {"ctrl", "alt"} and self.modifiers != {"ctrl", "alt", "shift"}:
|
||||
return None
|
||||
# Ne s'applique qu'aux touches non-modificatrices
|
||||
if key in self._MODIFIER_KEYS:
|
||||
return None
|
||||
# Essayer de résoudre le caractère via ToUnicodeEx
|
||||
# Le keyboard state inclut déjà Ctrl+Alt (= AltGr) grâce à GetKeyboardState
|
||||
vk = getattr(key, 'vk', None)
|
||||
if vk is not None:
|
||||
char = self._vk_to_char(vk)
|
||||
if char is not None and len(char) == 1 and (char.isprintable() and char != ' '):
|
||||
return char
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _encode_key(key) -> Dict[str, Any]:
|
||||
"""Encode un objet pynput Key/KeyCode en dictionnaire sérialisable.
|
||||
|
||||
Utilisé pour constituer le buffer raw_keys (séquence press/release
|
||||
exacte avec virtual key codes) qui permet un replay fidèle
|
||||
indépendant du layout clavier (AZERTY, QWERTZ, etc.).
|
||||
"""
|
||||
if isinstance(key, KeyCode):
|
||||
return {"kind": "vk", "vk": key.vk, "char": key.char}
|
||||
if isinstance(key, Key):
|
||||
return {"kind": "key", "name": key.name}
|
||||
return {"kind": "unknown", "str": str(key)}
|
||||
|
||||
def _on_press(self, key):
|
||||
# TOUJOURS enregistrer le press brut dans le buffer raw_keys
|
||||
with self._text_lock:
|
||||
self._raw_key_buffer.append({
|
||||
"action": "press",
|
||||
**self._encode_key(key),
|
||||
})
|
||||
|
||||
# Gestion des touches modificatrices
|
||||
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
|
||||
self.modifiers.add("ctrl")
|
||||
@@ -176,26 +309,54 @@ class EventCaptorV1:
|
||||
self.modifiers.add("alt")
|
||||
elif key in (Key.shift, Key.shift_l, Key.shift_r):
|
||||
self.modifiers.add("shift")
|
||||
elif key in (Key.cmd, Key.cmd_l, Key.cmd_r):
|
||||
self.modifiers.add("win")
|
||||
|
||||
# --- Combos avec modificateur (sauf Shift seul) ---
|
||||
# Shift seul n'est pas un « vrai » modificateur pour les combos :
|
||||
# Shift+a = 'A' = saisie texte, pas un raccourci.
|
||||
# On considère un combo seulement si Ctrl ou Alt est enfoncé.
|
||||
has_real_modifier = self.modifiers & {"ctrl", "alt"}
|
||||
# On considère un combo seulement si Ctrl, Alt ou Win est enfoncé.
|
||||
has_real_modifier = self.modifiers & {"ctrl", "alt", "win"}
|
||||
if has_real_modifier:
|
||||
# --- Détection AltGr (Windows AZERTY) ---
|
||||
# Sur Windows, AltGr est envoyé comme Ctrl+Alt par le système.
|
||||
# Avant de traiter comme un key_combo, vérifier si c'est
|
||||
# AltGr qui produit un caractère imprimable (@, #, {, }, etc.)
|
||||
altgr_char = self._is_altgr_producing_char(key)
|
||||
if altgr_char is not None:
|
||||
# C'est un caractère AltGr → router vers le buffer texte
|
||||
with self._text_lock:
|
||||
if not self._text_buffer:
|
||||
self._text_start_pos = self._last_mouse_pos
|
||||
self._text_buffer.append(altgr_char)
|
||||
self._reset_flush_timer()
|
||||
return
|
||||
|
||||
key_name = self._get_key_name(key)
|
||||
if key_name and key_name not in ("ctrl", "alt", "shift"):
|
||||
# Ne PAS émettre de combo si c'est un modificateur seul
|
||||
# (ex: appui sur Ctrl sans autre touche = pas de combo)
|
||||
if key_name and key_name not in self._MODIFIER_KEY_NAMES:
|
||||
# Un combo interrompt la saisie texte en cours
|
||||
self._flush_text_buffer()
|
||||
# Attacher les raw_keys accumulés (press des modificateurs + press de la touche)
|
||||
with self._text_lock:
|
||||
raw_keys = list(self._raw_key_buffer)
|
||||
# NB: on ne clear pas encore — le release va suivre et sera
|
||||
# capturé pour le prochain buffer. On prend un snapshot.
|
||||
event = {
|
||||
"type": "key_combo",
|
||||
"keys": list(self.modifiers) + [key_name],
|
||||
"raw_keys": raw_keys,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
self._inject_screen_metadata(event)
|
||||
self.on_event(event)
|
||||
# Reset le buffer raw_keys après émission du combo
|
||||
with self._text_lock:
|
||||
self._raw_key_buffer.clear()
|
||||
return
|
||||
|
||||
# --- Saisie texte (pas de Ctrl/Alt enfoncé) ---
|
||||
# --- Saisie texte (pas de Ctrl/Alt/Win enfoncé) ---
|
||||
self._handle_text_key(key)
|
||||
|
||||
def _handle_text_key(self, key):
|
||||
@@ -217,6 +378,7 @@ class EventCaptorV1:
|
||||
if key == Key.esc:
|
||||
# Annuler la saisie en cours
|
||||
self._text_buffer.clear()
|
||||
self._raw_key_buffer.clear()
|
||||
self._text_start_pos = None
|
||||
self._cancel_flush_timer()
|
||||
return
|
||||
@@ -234,31 +396,65 @@ class EventCaptorV1:
|
||||
self._reset_flush_timer()
|
||||
return
|
||||
|
||||
elif isinstance(key, KeyCode) and key.char is not None:
|
||||
elif isinstance(key, KeyCode):
|
||||
# Caractère alphanumérique / ponctuation
|
||||
# pynput renvoie déjà le bon caractère selon le layout
|
||||
# (AZERTY inclus) — on ne convertit rien.
|
||||
if not self._text_buffer:
|
||||
self._text_start_pos = self._last_mouse_pos
|
||||
self._text_buffer.append(key.char)
|
||||
self._reset_flush_timer()
|
||||
char = key.char
|
||||
|
||||
# AZERTY Windows : quand key.char est None (Shift+chiffres,
|
||||
# dead keys, etc.), utiliser ToUnicodeEx avec le layout clavier
|
||||
# actif pour obtenir le vrai caractère traduit par Windows.
|
||||
if char is None and IS_WINDOWS:
|
||||
vk = getattr(key, 'vk', None)
|
||||
if vk is not None:
|
||||
char = self._vk_to_char(vk)
|
||||
|
||||
if char is not None and len(char) == 1:
|
||||
if not self._text_buffer:
|
||||
self._text_start_pos = self._last_mouse_pos
|
||||
self._text_buffer.append(char)
|
||||
self._reset_flush_timer()
|
||||
return
|
||||
|
||||
# key.char None et pas de vk exploitable → ignorer
|
||||
return
|
||||
else:
|
||||
# Touche spéciale non gérée (F1, Insert, etc.) — on ignore
|
||||
return
|
||||
|
||||
# Si on arrive ici, c'est Enter ou Tab → flush immédiat
|
||||
# Si on arrive ici, c'est Enter ou Tab → flush le buffer en cours
|
||||
# puis émettre le caractère spécial comme text_input séparé
|
||||
self._flush_text_buffer()
|
||||
|
||||
# Émettre Enter comme "\n" et Tab comme "\t" pour ne pas perdre
|
||||
# les retours à la ligne dans la saisie.
|
||||
# Attacher les raw_keys restants (press de Enter/Tab, le release suivra)
|
||||
with self._text_lock:
|
||||
raw_keys = list(self._raw_key_buffer)
|
||||
self._raw_key_buffer.clear()
|
||||
special_char = "\n" if key == Key.enter else "\t"
|
||||
event = {
|
||||
"type": "text_input",
|
||||
"text": special_char,
|
||||
"pos": list(self._last_mouse_pos) if self._last_mouse_pos else [0, 0],
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
if raw_keys:
|
||||
event["raw_keys"] = raw_keys
|
||||
self.on_event(event)
|
||||
|
||||
def _reset_flush_timer(self):
|
||||
"""Réarme le timer de flush après chaque frappe.
|
||||
|
||||
Doit être appelé avec self._text_lock déjà acquis.
|
||||
Utilise un compteur de génération pour garantir que seul le
|
||||
dernier timer programmé puisse effectivement flush le buffer.
|
||||
"""
|
||||
if self._text_flush_timer is not None:
|
||||
self._text_flush_timer.cancel()
|
||||
self._text_flush_generation += 1
|
||||
gen = self._text_flush_generation
|
||||
self._text_flush_timer = threading.Timer(
|
||||
TEXT_FLUSH_DELAY, self._flush_text_buffer
|
||||
TEXT_FLUSH_DELAY, self._flush_text_buffer_if_current, args=(gen,)
|
||||
)
|
||||
self._text_flush_timer.daemon = True
|
||||
self._text_flush_timer.start()
|
||||
@@ -272,18 +468,30 @@ class EventCaptorV1:
|
||||
self._text_flush_timer.cancel()
|
||||
self._text_flush_timer = None
|
||||
|
||||
def _flush_text_buffer_if_current(self, generation: int):
|
||||
"""Appelé par le timer. Ne flush que si la génération correspond
|
||||
à celle du timer en cours (= pas de frappe entre-temps)."""
|
||||
with self._text_lock:
|
||||
if generation != self._text_flush_generation:
|
||||
# Un timer plus récent a été programmé, celui-ci est obsolète
|
||||
return
|
||||
self._flush_text_buffer()
|
||||
|
||||
def _flush_text_buffer(self):
|
||||
"""Émet un événement text_input avec le contenu du buffer, puis
|
||||
le vide. Thread-safe — peut être appelé depuis le timer, le
|
||||
listener souris ou le listener clavier."""
|
||||
with self._text_lock:
|
||||
if not self._text_buffer:
|
||||
# Rien à émettre
|
||||
# Rien à émettre — purger aussi les raw_keys orphelins
|
||||
self._raw_key_buffer.clear()
|
||||
self._cancel_flush_timer()
|
||||
return
|
||||
text = "".join(self._text_buffer)
|
||||
pos = self._text_start_pos or self._last_mouse_pos
|
||||
raw_keys = list(self._raw_key_buffer)
|
||||
self._text_buffer.clear()
|
||||
self._raw_key_buffer.clear()
|
||||
self._text_start_pos = None
|
||||
self._cancel_flush_timer()
|
||||
|
||||
@@ -295,32 +503,75 @@ class EventCaptorV1:
|
||||
"pos": pos,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
logger.debug(f"text_input émis : {len(text)} caractères")
|
||||
# Attacher les raw_keys pour le replay exact (solution AZERTY)
|
||||
if raw_keys:
|
||||
event["raw_keys"] = raw_keys
|
||||
self._inject_screen_metadata(event)
|
||||
logger.debug(f"text_input émis : {len(text)} caractères, {len(raw_keys)} raw_keys")
|
||||
self.on_event(event)
|
||||
|
||||
def _on_release(self, key):
|
||||
# TOUJOURS enregistrer le release brut dans le buffer raw_keys
|
||||
with self._text_lock:
|
||||
self._raw_key_buffer.append({
|
||||
"action": "release",
|
||||
**self._encode_key(key),
|
||||
})
|
||||
|
||||
if key in (Key.ctrl, Key.ctrl_l, Key.ctrl_r):
|
||||
self.modifiers.discard("ctrl")
|
||||
elif key in (Key.alt, Key.alt_l, Key.alt_r):
|
||||
self.modifiers.discard("alt")
|
||||
elif key in (Key.shift, Key.shift_l, Key.shift_r):
|
||||
self.modifiers.discard("shift")
|
||||
elif key in (Key.cmd, Key.cmd_l, Key.cmd_r):
|
||||
self.modifiers.discard("win")
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# Métadonnées système
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
def _refresh_screen_metadata(self):
|
||||
"""Rafraîchit le cache des métadonnées système.
|
||||
|
||||
Appelé au démarrage et à chaque changement de focus fenêtre.
|
||||
Thread-safe — peut être appelé depuis le thread focus.
|
||||
"""
|
||||
try:
|
||||
metadata = get_screen_metadata()
|
||||
with self._screen_metadata_lock:
|
||||
self._screen_metadata = metadata
|
||||
logger.debug(f"Métadonnées système rafraîchies : {metadata}")
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur refresh métadonnées système : {e}")
|
||||
|
||||
def _inject_screen_metadata(self, event: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Injecte les métadonnées système cachées dans un événement."""
|
||||
with self._screen_metadata_lock:
|
||||
if self._screen_metadata:
|
||||
event["screen_metadata"] = self._screen_metadata.copy()
|
||||
return event
|
||||
|
||||
def _watch_window_focus(self):
|
||||
"""Surveille proactivement le changement de fenêtre pour le stagiaire."""
|
||||
# Importation relative simple
|
||||
from ..window_info_crossplatform import get_active_window_info
|
||||
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
info = get_active_window_info()
|
||||
if info and info != self.last_window:
|
||||
# Rafraîchir les métadonnées (la fenêtre a peut-être
|
||||
# changé de moniteur, de taille, etc.)
|
||||
self._refresh_screen_metadata()
|
||||
|
||||
event = {
|
||||
"type": "window_focus_change",
|
||||
"from": self.last_window,
|
||||
"to": info,
|
||||
"timestamp": time.time()
|
||||
}
|
||||
self._inject_screen_metadata(event)
|
||||
self.last_window = info
|
||||
self.on_event(event)
|
||||
except Exception as e:
|
||||
|
||||
@@ -6,17 +6,28 @@ Opere par coordonnees normalisees (proportions) pour le rejeu en univers ferme (
|
||||
Supporte deux modes :
|
||||
- Watchdog fichier (command.json) — legacy
|
||||
- Polling serveur (GET /replay/next) — mode replay P0-5
|
||||
|
||||
NOTE DPI : Ce module depend du DPI awareness configure dans config.py.
|
||||
L'appel a SetProcessDpiAwareness(2) DOIT avoir ete fait avant l'import de
|
||||
pynput et mss, sinon les coordonnees seront en pixels logiques (faux sur
|
||||
les ecrans haute resolution avec DPI scaling > 100%).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
|
||||
# Forcer l'import de config AVANT pynput/mss pour garantir que le
|
||||
# DPI awareness est configure (SetProcessDpiAwareness(2) sur Windows).
|
||||
# Sans cela, pynput et mss utilisent des coordonnees logiques (virtualisees).
|
||||
from ..config import MACHINE_ID as _ # noqa: F401 — side-effect import
|
||||
|
||||
import mss
|
||||
from pynput.mouse import Button, Controller as MouseController
|
||||
from pynput.keyboard import Controller as KeyboardController, Key
|
||||
from pynput.keyboard import Controller as KeyboardController, Key, KeyCode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -68,6 +79,20 @@ class ActionExecutorV1:
|
||||
self._poll_backoff_factor = 1.5 # Multiplicateur en cas d'echec
|
||||
# Token d'authentification API
|
||||
self._api_token = os.environ.get("RPA_API_TOKEN", "")
|
||||
# Log de la resolution physique pour le diagnostic DPI
|
||||
self._log_screen_info()
|
||||
|
||||
def _log_screen_info(self):
|
||||
"""Log la resolution physique de l'ecran au demarrage pour le diagnostic DPI."""
|
||||
try:
|
||||
monitor = self.sct.monitors[1]
|
||||
w, h = monitor["width"], monitor["height"]
|
||||
logger.info(
|
||||
f"Executor initialise — resolution physique : {w}x{h} "
|
||||
f"(mss monitors[1], DPI-aware process)"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Impossible de lire la resolution ecran : {e}")
|
||||
|
||||
def _auth_headers(self) -> dict:
|
||||
"""Headers d'authentification Bearer pour les requetes au serveur."""
|
||||
@@ -180,11 +205,30 @@ class ActionExecutorV1:
|
||||
f"-> ({x_pct:.4f}, {y_pct:.4f})"
|
||||
)
|
||||
|
||||
# ---- Hash AVANT l'action (pour verification post-action) ----
|
||||
# Seules les actions click et key_combo sont verifiees : elles
|
||||
# provoquent un changement visible de l'ecran (ouverture de fenetre,
|
||||
# focus, etc.). Les actions type/wait/scroll ne sont pas verifiees.
|
||||
needs_screen_check = action_type in ("click", "key_combo")
|
||||
hash_before = ""
|
||||
if needs_screen_check:
|
||||
hash_before = self._quick_screenshot_hash()
|
||||
|
||||
if action_type == "click":
|
||||
# Si visual_mode est activé, le resolve DOIT réussir.
|
||||
# Pas de fallback blind — on arrête le replay si la cible
|
||||
# n'est pas trouvée visuellement. C'est un RPA VISUEL.
|
||||
if visual_mode and not result.get("visual_resolved"):
|
||||
result["success"] = False
|
||||
result["error"] = "Visual resolve échoué — cible non trouvée à l'écran"
|
||||
print(f" [ERREUR] Visual resolve échoué — STOP (pas de clic blind)")
|
||||
logger.error(f"Action {action_id} : visual resolve échoué, replay stoppé")
|
||||
return result
|
||||
|
||||
real_x = int(x_pct * width)
|
||||
real_y = int(y_pct * height)
|
||||
button = action.get("button", "left")
|
||||
mode = "VISUAL" if result["visual_resolved"] else "BLIND"
|
||||
mode = "VISUAL" if result.get("visual_resolved") else "COORD"
|
||||
print(
|
||||
f" [CLICK] [{mode}] ({x_pct:.3f}, {y_pct:.3f}) -> "
|
||||
f"({real_x}, {real_y}) sur ({width}x{height}), bouton={button}"
|
||||
@@ -198,7 +242,10 @@ class ActionExecutorV1:
|
||||
|
||||
elif action_type == "type":
|
||||
text = action.get("text", "")
|
||||
raw_keys = action.get("raw_keys")
|
||||
print(f" [TYPE] Texte: '{text[:50]}' ({len(text)} chars)")
|
||||
if raw_keys:
|
||||
print(f" [TYPE] raw_keys disponibles ({len(raw_keys)} events) — replay exact")
|
||||
# Cliquer sur le champ avant de taper (si coordonnees disponibles)
|
||||
if x_pct > 0 and y_pct > 0:
|
||||
real_x = int(x_pct * width)
|
||||
@@ -206,16 +253,26 @@ class ActionExecutorV1:
|
||||
print(f" [TYPE] Clic prealable sur ({real_x}, {real_y})")
|
||||
self._click((real_x, real_y), "left")
|
||||
time.sleep(0.3)
|
||||
self.keyboard.type(text)
|
||||
if raw_keys:
|
||||
self._replay_raw_keys(raw_keys)
|
||||
else:
|
||||
# Fallback copier-coller (anciens enregistrements sans raw_keys)
|
||||
self._type_text(text)
|
||||
print(f" [TYPE] Termine.")
|
||||
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars)")
|
||||
logger.info(f"Replay type : '{text[:30]}...' ({len(text)} chars, raw_keys={'oui' if raw_keys else 'non'})")
|
||||
|
||||
elif action_type == "key_combo":
|
||||
keys = action.get("keys", [])
|
||||
raw_keys = action.get("raw_keys")
|
||||
print(f" [KEY_COMBO] Touches: {keys}")
|
||||
self._execute_key_combo(keys)
|
||||
if raw_keys:
|
||||
print(f" [KEY_COMBO] raw_keys disponibles ({len(raw_keys)} events) — replay exact")
|
||||
self._replay_raw_keys(raw_keys)
|
||||
else:
|
||||
# Fallback (anciens enregistrements sans raw_keys)
|
||||
self._execute_key_combo(keys)
|
||||
print(f" [KEY_COMBO] Termine.")
|
||||
logger.info(f"Replay key_combo : {keys}")
|
||||
logger.info(f"Replay key_combo : {keys} (raw_keys={'oui' if raw_keys else 'non'})")
|
||||
|
||||
elif action_type == "scroll":
|
||||
real_x = int(x_pct * width) if x_pct > 0 else int(0.5 * width)
|
||||
@@ -235,6 +292,25 @@ class ActionExecutorV1:
|
||||
print(f" [WAIT] Termine.")
|
||||
logger.info(f"Replay wait : {duration_ms}ms")
|
||||
|
||||
elif action_type == "verify_screen":
|
||||
# Vérification visuelle entre les groupes du replay hybride.
|
||||
# Pour l'instant, on fait un wait de 2s pour laisser l'écran
|
||||
# se stabiliser. La vérification réelle sera faite par le
|
||||
# pre-check côté serveur dans GET /replay/next.
|
||||
expected_node = action.get("expected_node", "?")
|
||||
timeout_ms = action.get("timeout_ms", 5000)
|
||||
wait_s = min(timeout_ms / 1000.0, 2.0)
|
||||
print(
|
||||
f" [VERIFY] Attente verification ecran "
|
||||
f"(node attendu: {expected_node}, wait={wait_s}s)"
|
||||
)
|
||||
time.sleep(wait_s)
|
||||
print(f" [VERIFY] Termine (verification deferred au serveur).")
|
||||
logger.info(
|
||||
f"Replay verify_screen : node={expected_node}, "
|
||||
f"wait={wait_s}s (verification serveur)"
|
||||
)
|
||||
|
||||
else:
|
||||
result["error"] = f"Type d'action inconnu : {action_type}"
|
||||
logger.warning(result["error"])
|
||||
@@ -242,8 +318,33 @@ class ActionExecutorV1:
|
||||
|
||||
result["success"] = True
|
||||
|
||||
# Capturer un screenshot post-action
|
||||
time.sleep(0.5)
|
||||
# ---- Verification post-action : l'ecran a-t-il change ? ----
|
||||
# Verifie UNIQUEMENT, ne tente PAS de gerer les popups
|
||||
# (Enter/Escape perturbent l'application).
|
||||
# Signale l'echec honnêtement — le serveur decide du retry.
|
||||
if needs_screen_check and hash_before:
|
||||
screen_changed = self._wait_for_screen_change(
|
||||
hash_before, timeout_ms=3000
|
||||
)
|
||||
if not screen_changed:
|
||||
result["success"] = False
|
||||
result["warning"] = "no_screen_change"
|
||||
result["error"] = "Ecran inchange apres l'action"
|
||||
print(
|
||||
f" [ECHEC] Ecran inchange apres {action_type} — "
|
||||
f"l'action n'a pas eu d'effet visible"
|
||||
)
|
||||
logger.warning(
|
||||
f"Action {action_id} ({action_type}) : ecran inchange "
|
||||
f"— action sans effet visible"
|
||||
)
|
||||
else:
|
||||
print(f" [OK] Changement d'ecran detecte apres {action_type}")
|
||||
else:
|
||||
# Pour type/wait/scroll, petit delai pour laisser l'ecran se stabiliser
|
||||
time.sleep(0.5)
|
||||
|
||||
# Capturer un screenshot post-action (apres stabilisation)
|
||||
result["screenshot"] = self._capture_screenshot_b64()
|
||||
|
||||
except Exception as e:
|
||||
@@ -257,62 +358,136 @@ class ActionExecutorV1:
|
||||
fallback_x: float, fallback_y: float,
|
||||
screen_width: int, screen_height: int,
|
||||
) -> dict:
|
||||
"""
|
||||
Envoyer un screenshot au serveur pour resolution visuelle de la cible.
|
||||
"""Résoudre la position d'un clic visuellement.
|
||||
|
||||
Capture l'ecran en haute resolution (pas de downscale pour le template
|
||||
matching), l'encode en base64 JPEG, et POST au endpoint
|
||||
/replay/resolve_target. Retourne les coordonnees resolues.
|
||||
"""
|
||||
import requests
|
||||
Stratégie VLM-DIRECT : appelle Ollama directement depuis l'agent
|
||||
(pas via le serveur streaming) pour éviter les timeouts quand le
|
||||
serveur est occupé par le worker.
|
||||
|
||||
1. VLM direct (screenshot + crop → Ollama) ~3-8s
|
||||
2. Serveur streaming (fallback si Ollama échoue)
|
||||
"""
|
||||
import requests as _requests
|
||||
import json as _json
|
||||
|
||||
screenshot_b64 = self._capture_screenshot_b64(max_width=0, quality=75)
|
||||
if not screenshot_b64:
|
||||
logger.warning("Capture screenshot echouee pour visual resolve")
|
||||
return None
|
||||
|
||||
# ---- VLM DIRECT (Ollama) ----
|
||||
vlm_result = self._vlm_direct_resolve(screenshot_b64, target_spec)
|
||||
if vlm_result and vlm_result.get("resolved"):
|
||||
return vlm_result
|
||||
|
||||
# ---- FALLBACK : serveur streaming ----
|
||||
print(" [VISUAL] VLM direct echoue, fallback serveur...")
|
||||
try:
|
||||
# Capturer à 1280px max — assez pour le template matching
|
||||
# et raisonnable pour le transfert réseau (~200-400Ko)
|
||||
screenshot_b64 = self._capture_screenshot_b64(
|
||||
max_width=1280,
|
||||
quality=75,
|
||||
)
|
||||
if not screenshot_b64:
|
||||
logger.warning("Capture screenshot echouee pour visual resolve")
|
||||
return None
|
||||
|
||||
print(
|
||||
f" [VISUAL] Envoi screenshot ({len(screenshot_b64) // 1024} Ko) "
|
||||
f"au serveur pour resolution..."
|
||||
)
|
||||
|
||||
# Appel au serveur
|
||||
resolve_url = f"{server_url}/traces/stream/replay/resolve_target"
|
||||
payload = {
|
||||
"session_id": "", # Pas critique pour la resolution
|
||||
"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,
|
||||
"strict_mode": True,
|
||||
}
|
||||
|
||||
resp = requests.post(resolve_url, json=payload, headers=self._auth_headers(), timeout=60)
|
||||
resp = _requests.post(resolve_url, json=payload, headers=self._auth_headers(), timeout=30)
|
||||
if resp.ok:
|
||||
data = resp.json()
|
||||
method = data.get("method", "?")
|
||||
resolved = data.get("resolved", False)
|
||||
print(
|
||||
f" [VISUAL] Reponse serveur : resolved={resolved}, "
|
||||
f"method={method}, score={data.get('score', 'N/A')}"
|
||||
)
|
||||
print(f" [VISUAL] Serveur : resolved={data.get('resolved')}, method={data.get('method')}")
|
||||
return data
|
||||
else:
|
||||
logger.warning(f"Visual resolve HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Visual resolve serveur echoue: {e}")
|
||||
return None
|
||||
|
||||
def _vlm_direct_resolve(self, screenshot_b64: str, target_spec: dict) -> dict:
|
||||
"""Appeler Ollama directement pour trouver l'élément à l'écran."""
|
||||
import requests as _requests
|
||||
import json as _json
|
||||
import re
|
||||
|
||||
anchor_b64 = target_spec.get("anchor_image_base64", "")
|
||||
vlm_description = target_spec.get("vlm_description", "")
|
||||
by_text = target_spec.get("by_text", "")
|
||||
window_title = target_spec.get("window_title", "")
|
||||
|
||||
if not anchor_b64 and not vlm_description:
|
||||
return None
|
||||
|
||||
# Prompt
|
||||
if anchor_b64 and vlm_description:
|
||||
prompt = f"""The first image is the current screen. The second image shows the element to find.
|
||||
{vlm_description}
|
||||
Return the CENTER coordinates as percentage of the FIRST image dimensions.
|
||||
Return ONLY JSON: {{"x_pct": 0.XX, "y_pct": 0.XX, "confidence": 0.XX}}
|
||||
If not found: {{"x_pct": null, "y_pct": null, "confidence": 0.0}}"""
|
||||
elif vlm_description:
|
||||
prompt = f"""{vlm_description}
|
||||
Return coordinates as percentage: {{"x_pct": 0.XX, "y_pct": 0.XX, "confidence": 0.XX}}"""
|
||||
else:
|
||||
prompt = f"""Find the element shown in the second image on the first image.
|
||||
Return coordinates: {{"x_pct": 0.XX, "y_pct": 0.XX, "confidence": 0.XX}}"""
|
||||
|
||||
images = [screenshot_b64]
|
||||
if anchor_b64:
|
||||
images.append(anchor_b64)
|
||||
|
||||
ollama_host = os.environ.get("RPA_SERVER_HOST", "localhost")
|
||||
ollama_url = f"http://{ollama_host}:11434/api/chat"
|
||||
|
||||
payload = {
|
||||
"model": os.environ.get("RPA_VLM_MODEL", "qwen3-vl:8b"),
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a UI element locator. Output raw JSON only."},
|
||||
{"role": "user", "content": prompt, "images": images},
|
||||
{"role": "assistant", "content": "{"},
|
||||
],
|
||||
"stream": False,
|
||||
"think": False,
|
||||
"options": {"temperature": 0.1, "num_predict": 100, "num_ctx": 2048},
|
||||
}
|
||||
|
||||
try:
|
||||
print(f" [VLM-DIRECT] Appel Ollama ({ollama_host}:11434)...")
|
||||
start = time.time()
|
||||
resp = _requests.post(ollama_url, json=payload, timeout=30)
|
||||
elapsed = time.time() - start
|
||||
|
||||
if not resp.ok:
|
||||
print(f" [VLM-DIRECT] HTTP {resp.status_code} ({elapsed:.1f}s)")
|
||||
return None
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("Visual resolve timeout (30s)")
|
||||
content = "{" + resp.json().get("message", {}).get("content", "")
|
||||
print(f" [VLM-DIRECT] Réponse en {elapsed:.1f}s : {content[:80]}")
|
||||
|
||||
# Parser JSON
|
||||
match = re.search(r'\{[^}]+\}', content)
|
||||
if not match:
|
||||
return None
|
||||
data = _json.loads(match.group())
|
||||
|
||||
x = data.get("x_pct")
|
||||
y = data.get("y_pct")
|
||||
conf = data.get("confidence", 0)
|
||||
|
||||
if x is None or y is None or conf < 0.3:
|
||||
print(f" [VLM-DIRECT] Non trouvé (conf={conf})")
|
||||
return None
|
||||
if not (0.0 <= x <= 1.0 and 0.0 <= y <= 1.0):
|
||||
print(f" [VLM-DIRECT] Hors limites ({x}, {y})")
|
||||
return None
|
||||
|
||||
print(f" [VLM-DIRECT] TROUVÉ ({x:.3f}, {y:.3f}) conf={conf:.2f} en {elapsed:.1f}s")
|
||||
return {"resolved": True, "method": "vlm_direct", "x_pct": x, "y_pct": y, "score": conf}
|
||||
|
||||
except _requests.exceptions.Timeout:
|
||||
print(" [VLM-DIRECT] Timeout 30s")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Visual resolve echoue: {e}")
|
||||
print(f" [VLM-DIRECT] Erreur: {e}")
|
||||
return None
|
||||
|
||||
def poll_and_execute(self, session_id: str, server_url: str, machine_id: str = "default") -> bool:
|
||||
@@ -347,8 +522,19 @@ class ActionExecutorV1:
|
||||
)
|
||||
if not resp.ok:
|
||||
logger.debug(f"Poll replay echoue : HTTP {resp.status_code}")
|
||||
# Backoff sur erreur HTTP (serveur en erreur, route inconnue, etc.)
|
||||
self._poll_backoff = min(
|
||||
self._poll_backoff * self._poll_backoff_factor,
|
||||
self._poll_backoff_max,
|
||||
)
|
||||
return False
|
||||
|
||||
# Le serveur a repondu 200 — reset le backoff immediatement,
|
||||
# meme s'il n'y a pas d'action en attente. Cela garantit que
|
||||
# l'agent reprend un polling rapide des que le serveur est OK.
|
||||
self._poll_backoff = self._poll_backoff_min
|
||||
self._last_conn_error_logged = False
|
||||
|
||||
data = resp.json()
|
||||
action = data.get("action")
|
||||
if action is None:
|
||||
@@ -360,7 +546,7 @@ class ActionExecutorV1:
|
||||
self._poll_backoff * self._poll_backoff_factor,
|
||||
self._poll_backoff_max,
|
||||
)
|
||||
if not hasattr(self, '_last_conn_error_logged'):
|
||||
if not hasattr(self, '_last_conn_error_logged') or not self._last_conn_error_logged:
|
||||
self._last_conn_error_logged = True
|
||||
print(f"[REPLAY] Serveur non disponible (backoff={self._poll_backoff:.1f}s) : {e}")
|
||||
logger.warning(f"Serveur non disponible pour replay (backoff={self._poll_backoff:.1f}s): {e}")
|
||||
@@ -374,10 +560,6 @@ class ActionExecutorV1:
|
||||
logger.error(f"Erreur poll GET : {e}")
|
||||
return False
|
||||
|
||||
# Reset du flag d'erreur connexion et du backoff (on a reussi le GET)
|
||||
self._last_conn_error_logged = False
|
||||
self._poll_backoff = self._poll_backoff_min
|
||||
|
||||
# Phase 2 : Executer l'action et rapporter le resultat
|
||||
# TOUJOURS rapporter au serveur, meme en cas d'erreur d'execution
|
||||
action_type = action.get('type', '?')
|
||||
@@ -412,6 +594,7 @@ class ActionExecutorV1:
|
||||
"action_id": result["action_id"],
|
||||
"success": result["success"],
|
||||
"error": result.get("error"),
|
||||
"warning": result.get("warning"),
|
||||
"screenshot": result.get("screenshot"),
|
||||
}
|
||||
try:
|
||||
@@ -438,10 +621,167 @@ class ActionExecutorV1:
|
||||
|
||||
return True
|
||||
|
||||
# =========================================================================
|
||||
# Gestion automatique des popups imprevues
|
||||
# =========================================================================
|
||||
|
||||
def _handle_possible_popup(self) -> bool:
|
||||
"""Tenter de gerer une popup imprevue.
|
||||
|
||||
Appelee quand l'ecran n'a pas change apres une action click ou key_combo,
|
||||
ce qui peut indiquer l'apparition d'une popup modale (dialogue de
|
||||
confirmation "Voulez-vous remplacer ?", erreur, etc.) qui bloque
|
||||
l'interaction attendue.
|
||||
|
||||
Strategie simple (non bloquante, max ~3s) :
|
||||
1. Essayer Enter (valide le bouton par defaut de la popup)
|
||||
2. Si ca ne marche pas, essayer Escape (ferme la popup)
|
||||
3. Si ca ne marche pas, essayer Tab + Enter (selectionne "Oui" puis valide)
|
||||
|
||||
ATTENTION : ne PAS appeler pour les actions 'type' (la saisie de texte
|
||||
ne change pas forcement l'ecran de facon detectable).
|
||||
|
||||
Returns:
|
||||
True si une popup a ete geree (l'ecran a change), False sinon.
|
||||
"""
|
||||
hash_before = self._quick_screenshot_hash()
|
||||
if not hash_before:
|
||||
return False
|
||||
|
||||
strategies = [
|
||||
("Enter", lambda: self._press_key(Key.enter)),
|
||||
("Escape", lambda: self._press_key(Key.esc)),
|
||||
("Tab+Enter", lambda: self._press_tab_enter()),
|
||||
]
|
||||
|
||||
for name, action_fn in strategies:
|
||||
logger.info(f"Popup handler : tentative {name}")
|
||||
print(f" [POPUP] Tentative : {name}")
|
||||
action_fn()
|
||||
# Attendre max 1s pour voir si l'ecran change (non bloquant)
|
||||
changed = self._wait_for_screen_change(hash_before, timeout_ms=1000)
|
||||
if changed:
|
||||
logger.info(f"Popup handler : {name} a fonctionne (ecran change)")
|
||||
print(f" [POPUP] {name} a fonctionne — popup geree")
|
||||
return True
|
||||
|
||||
logger.info("Popup handler : aucune strategie n'a fonctionne")
|
||||
print(" [POPUP] Aucune strategie n'a fonctionne")
|
||||
return False
|
||||
|
||||
def _press_key(self, key):
|
||||
"""Appuyer et relacher une touche unique."""
|
||||
self.keyboard.press(key)
|
||||
self.keyboard.release(key)
|
||||
|
||||
def _press_tab_enter(self):
|
||||
"""Tab puis Enter (selectionner le bouton suivant puis valider)."""
|
||||
self.keyboard.press(Key.tab)
|
||||
self.keyboard.release(Key.tab)
|
||||
time.sleep(0.1)
|
||||
self.keyboard.press(Key.enter)
|
||||
self.keyboard.release(Key.enter)
|
||||
|
||||
# =========================================================================
|
||||
# Verification post-action (comparaison screenshots avant/apres)
|
||||
# =========================================================================
|
||||
|
||||
def _quick_screenshot_hash(self) -> str:
|
||||
"""Hash rapide du screenshot actuel (MD5 de l'image redimensionnee 64x64 en niveaux de gris).
|
||||
|
||||
Utilise une instance mss locale pour la thread-safety.
|
||||
Retourne une chaine vide en cas d'erreur (PIL absent, etc.).
|
||||
"""
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with mss.mss() as local_sct:
|
||||
monitor = local_sct.monitors[1]
|
||||
raw = local_sct.grab(monitor)
|
||||
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
|
||||
# Redimensionner a 64x64 en niveaux de gris pour un hash perceptuel rapide
|
||||
small = img.resize((64, 64)).convert("L")
|
||||
return hashlib.md5(small.tobytes()).hexdigest()
|
||||
except Exception as e:
|
||||
logger.debug(f"Impossible de calculer le hash screenshot : {e}")
|
||||
return ""
|
||||
|
||||
def _wait_for_screen_change(self, hash_before: str, timeout_ms: int = 5000) -> bool:
|
||||
"""Attendre que l'ecran change apres une action (max timeout_ms).
|
||||
|
||||
Verifie toutes les 200ms si le hash du screenshot a change.
|
||||
Retourne True si l'ecran a change, False si timeout atteint.
|
||||
"""
|
||||
if not hash_before:
|
||||
return True # Pas de reference → considerer comme change
|
||||
|
||||
deadline = time.time() + timeout_ms / 1000
|
||||
check_count = 0
|
||||
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.2) # 200ms entre chaque verification
|
||||
current_hash = self._quick_screenshot_hash()
|
||||
check_count += 1
|
||||
|
||||
if current_hash and current_hash != hash_before:
|
||||
logger.info(f"Ecran change apres ~{check_count * 200}ms")
|
||||
return True
|
||||
|
||||
logger.warning(
|
||||
f"Ecran inchange apres {timeout_ms}ms ({check_count} verifications)"
|
||||
)
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# Helpers
|
||||
# =========================================================================
|
||||
|
||||
def _type_text(self, text: str):
|
||||
"""Saisir du texte via copier-coller (methode principale) ou keyboard.type (fallback).
|
||||
|
||||
Le copier-coller via le presse-papiers est la methode principale car
|
||||
keyboard.type() de pynput envoie les scancodes QWERTY, ce qui produit
|
||||
des caracteres incorrects sur les claviers AZERTY (ex: "ce" -> "ci").
|
||||
Le copier-coller est agnostique du layout clavier.
|
||||
"""
|
||||
if not text:
|
||||
return
|
||||
|
||||
clipboard_ok = False
|
||||
try:
|
||||
import pyperclip
|
||||
# Sauvegarder le contenu actuel du presse-papiers
|
||||
try:
|
||||
old_clipboard = pyperclip.paste()
|
||||
except Exception:
|
||||
old_clipboard = None
|
||||
|
||||
pyperclip.copy(text)
|
||||
# Ctrl+V pour coller
|
||||
self.keyboard.press(Key.ctrl)
|
||||
time.sleep(0.02)
|
||||
self.keyboard.press('v')
|
||||
self.keyboard.release('v')
|
||||
self.keyboard.release(Key.ctrl)
|
||||
time.sleep(0.1)
|
||||
|
||||
# Restaurer le presse-papiers original
|
||||
if old_clipboard is not None:
|
||||
try:
|
||||
pyperclip.copy(old_clipboard)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
clipboard_ok = True
|
||||
logger.debug(f"Texte saisi via presse-papiers ({len(text)} chars)")
|
||||
except ImportError:
|
||||
logger.debug("pyperclip non disponible, fallback sur keyboard.type()")
|
||||
except Exception as e:
|
||||
logger.warning(f"Copier-coller echoue ({e}), fallback sur keyboard.type()")
|
||||
|
||||
if not clipboard_ok:
|
||||
self.keyboard.type(text)
|
||||
|
||||
def _click(self, pos, button_name):
|
||||
"""Deplacer la souris et cliquer.
|
||||
|
||||
@@ -500,6 +840,50 @@ class ActionExecutorV1:
|
||||
for mod in reversed(modifiers):
|
||||
self.keyboard.release(mod)
|
||||
|
||||
def _replay_raw_keys(self, raw_keys: list):
|
||||
"""Rejouer une séquence press/release exacte via virtual key codes.
|
||||
|
||||
Utilise KeyCode.from_vk() pour reconstituer les touches à partir
|
||||
de leur vk code, ce qui garantit un replay fidèle indépendant du
|
||||
layout clavier (AZERTY, QWERTZ, etc.).
|
||||
|
||||
Chaque événement raw_key est un dict avec :
|
||||
- "action": "press" ou "release"
|
||||
- "kind": "vk" (touche avec virtual key code) ou "key" (touche spéciale pynput)
|
||||
- "vk": int (si kind == "vk")
|
||||
- "name": str (si kind == "key", ex: "ctrl_l", "enter")
|
||||
- "char": str ou None (si kind == "vk", informatif)
|
||||
"""
|
||||
for event in raw_keys:
|
||||
key = self._decode_raw_key(event)
|
||||
if key is None:
|
||||
continue
|
||||
action = event.get("action", "")
|
||||
if action == "press":
|
||||
self.keyboard.press(key)
|
||||
elif action == "release":
|
||||
self.keyboard.release(key)
|
||||
else:
|
||||
logger.warning(f"Action raw_key inconnue : {action}")
|
||||
continue
|
||||
time.sleep(0.01) # Petit délai entre chaque événement
|
||||
|
||||
@staticmethod
|
||||
def _decode_raw_key(data: dict):
|
||||
"""Décoder un événement raw_key en objet pynput (Key ou KeyCode).
|
||||
|
||||
Retourne None si le décodage échoue (touche inconnue).
|
||||
"""
|
||||
kind = data.get("kind", "")
|
||||
if kind == "key":
|
||||
name = data.get("name", "")
|
||||
return getattr(Key, name, None)
|
||||
if kind == "vk":
|
||||
vk = data.get("vk")
|
||||
if vk is not None:
|
||||
return KeyCode.from_vk(vk)
|
||||
return None
|
||||
|
||||
def _capture_screenshot_b64(self, max_width: int = 800, quality: int = 60) -> str:
|
||||
"""
|
||||
Capturer l'ecran et retourner le screenshot en base64.
|
||||
@@ -512,8 +896,12 @@ class ActionExecutorV1:
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
monitor = self.sct.monitors[1]
|
||||
raw = self.sct.grab(monitor)
|
||||
# Créer une instance mss locale (thread-safe)
|
||||
# mss utilise des handles Windows thread-local (srcdc, memdc)
|
||||
# qui ne peuvent pas être partagés entre threads
|
||||
with mss.mss() as local_sct:
|
||||
monitor = local_sct.monitors[1]
|
||||
raw = local_sct.grab(monitor)
|
||||
img = Image.frombytes("RGB", raw.size, raw.bgra, "raw", "BGRX")
|
||||
|
||||
# Redimensionner si max_width > 0
|
||||
@@ -530,5 +918,7 @@ class ActionExecutorV1:
|
||||
logger.debug("PIL non disponible, pas de screenshot base64")
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.debug(f"Capture screenshot base64 echouee : {e}")
|
||||
logger.warning(f"Capture screenshot base64 echouee : {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return ""
|
||||
|
||||
@@ -14,7 +14,10 @@ import uuid
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
from .config import SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS
|
||||
from .config import (
|
||||
SESSIONS_ROOT, AGENT_VERSION, SERVER_URL, MACHINE_ID, LOG_RETENTION_DAYS,
|
||||
SCREEN_RESOLUTION, DPI_SCALE, OS_THEME,
|
||||
)
|
||||
from .core.captor import EventCaptorV1
|
||||
from .core.executor import ActionExecutorV1
|
||||
from .network.streamer import TraceStreamer
|
||||
@@ -103,6 +106,14 @@ class AgentV1:
|
||||
self._capture_server = CaptureServer()
|
||||
self._capture_server.start()
|
||||
|
||||
# Bannière de démarrage avec métadonnées système
|
||||
logger.info(
|
||||
f"Agent V1 v{AGENT_VERSION} | Machine={self.machine_id} | "
|
||||
f"Ecran={SCREEN_RESOLUTION[0]}x{SCREEN_RESOLUTION[1]} | "
|
||||
f"DPI={DPI_SCALE}% | Theme={OS_THEME} | "
|
||||
f"Serveur={SERVER_URL}"
|
||||
)
|
||||
|
||||
# UI Tray intelligent (remplace TrayAppV1, plus de PyQt5)
|
||||
self.ui = SmartTrayV1(
|
||||
self.start_session,
|
||||
@@ -142,8 +153,9 @@ class AgentV1:
|
||||
# Watchdog de Commandes (GHOST Replay — legacy fichier)
|
||||
threading.Thread(target=self._command_watchdog_loop, daemon=True).start()
|
||||
|
||||
# Boucle de polling replay (P0-5 — pull depuis le serveur)
|
||||
threading.Thread(target=self._replay_poll_loop, daemon=True).start()
|
||||
# Note: la boucle de polling replay est déjà lancée dans __init__ (ligne 102)
|
||||
# Ne PAS en relancer une ici — deux threads poll simultanés causent
|
||||
# une race condition où les actions sont consommées mais pas exécutées.
|
||||
|
||||
logger.info(f"Session {self.session_id} ({workflow_name}) sur machine {self.machine_id} en cours...")
|
||||
|
||||
@@ -159,7 +171,7 @@ class AgentV1:
|
||||
else:
|
||||
cmd_path = str(BASE_DIR / "command.json")
|
||||
|
||||
while self.running:
|
||||
while self.running and self.session_id:
|
||||
# Ne pas traiter les commandes fichier pendant un replay serveur
|
||||
if self._replay_active:
|
||||
time.sleep(1)
|
||||
@@ -197,8 +209,11 @@ class AgentV1:
|
||||
time.sleep(REPLAY_POLL_INTERVAL)
|
||||
continue
|
||||
|
||||
# Utiliser la session active ou un ID par défaut pour le replay
|
||||
poll_session = self.session_id or f"agent_{self.user_id}"
|
||||
# TOUJOURS utiliser un session_id stable pour le replay.
|
||||
# L'enregistrement et le replay sont indépendants : le serveur
|
||||
# envoie les actions sur agent_{user_id}, pas sur la session
|
||||
# d'enregistrement (sess_xxx).
|
||||
poll_session = f"agent_{self.user_id}"
|
||||
|
||||
# Log periodique pour confirmer que la boucle tourne (toutes les 60s)
|
||||
poll_count += 1
|
||||
@@ -290,18 +305,40 @@ class AgentV1:
|
||||
time.sleep(5)
|
||||
|
||||
def stop_session(self):
|
||||
self.running = False
|
||||
# Arrêter la capture et le streaming de la session d'enregistrement
|
||||
if self.captor: self.captor.stop()
|
||||
if self.streamer: self.streamer.stop()
|
||||
logger.info(f"Session {self.session_id} terminée.")
|
||||
|
||||
# Reset le session_id pour que le poll replay utilise l'ID stable
|
||||
self.session_id = None
|
||||
|
||||
# Reset le backoff de l'executor pour reprendre le polling immédiatement
|
||||
if self._executor:
|
||||
self._executor._poll_backoff = self._executor._poll_backoff_min
|
||||
self._executor._server_available = True
|
||||
if hasattr(self._executor, '_last_conn_error_logged'):
|
||||
self._executor._last_conn_error_logged = False
|
||||
|
||||
# NE PAS mettre self.running = False ici !
|
||||
# self.running contrôle la boucle _replay_poll_loop (permanente).
|
||||
# Seule la sortie du programme doit le mettre à False.
|
||||
# Les boucles _heartbeat_loop et _command_watchdog_loop vérifieront
|
||||
# self.session_id pour savoir si elles doivent fonctionner.
|
||||
|
||||
logger.info(
|
||||
f"Session arrêtée — replay poll actif avec session="
|
||||
f"agent_{self.user_id}"
|
||||
)
|
||||
|
||||
_last_heartbeat_hash: str = ""
|
||||
|
||||
def _heartbeat_loop(self):
|
||||
"""Capture périodique pour donner du contexte au stagiaire.
|
||||
Déduplication : n'envoie que si l'écran a changé.
|
||||
Tourne tant que session_id est défini (= enregistrement actif).
|
||||
"""
|
||||
while self.running:
|
||||
while self.running and self.session_id:
|
||||
try:
|
||||
full_path = self.vision.capture_full_context("heartbeat")
|
||||
if full_path:
|
||||
|
||||
@@ -413,10 +413,8 @@ class ChatWindow:
|
||||
|
||||
buttons = [
|
||||
("\U0001f393 Apprenez-moi", self._on_quick_record),
|
||||
("\u25b6\ufe0f Lancer", self._on_quick_tasks),
|
||||
("\U0001f4ca Donn\u00e9es", self._on_quick_import),
|
||||
("\u25b6\ufe0f Lancer une t\u00e2che", self._on_quick_tasks),
|
||||
("\u23f9\ufe0f Arr\u00eater", self._on_quick_stop),
|
||||
("\u2753 Aide", self._on_quick_help),
|
||||
]
|
||||
|
||||
for text, cmd in buttons:
|
||||
|
||||
195
agent_v0/agent_v1/vision/system_info.py
Normal file
195
agent_v0/agent_v1/vision/system_info.py
Normal file
@@ -0,0 +1,195 @@
|
||||
# agent_v1/vision/system_info.py
|
||||
"""
|
||||
Capture des metadonnees systeme pour enrichir les evenements.
|
||||
|
||||
Collecte DPI, resolution, fenetre active, moniteur, theme OS et langue.
|
||||
Les fonctions Windows (ctypes.windll, winreg) ont des fallbacks gracieux
|
||||
pour Linux/Mac.
|
||||
"""
|
||||
|
||||
import platform
|
||||
import locale
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache du systeme d'exploitation pour eviter les appels repetes
|
||||
_SYSTEM = platform.system()
|
||||
|
||||
|
||||
def get_dpi_scale() -> int:
|
||||
"""Retourne le facteur DPI en % (100 = normal, 150 = haute resolution).
|
||||
|
||||
Windows : ctypes.windll.user32.GetDpiForSystem()
|
||||
Linux/Mac : fallback 100
|
||||
|
||||
NOTE : Le process DOIT deja etre DPI-aware (via SetProcessDpiAwareness(2)
|
||||
appele dans config.py) pour que GetDpiForSystem retourne le vrai DPI.
|
||||
"""
|
||||
if _SYSTEM == "Windows":
|
||||
try:
|
||||
import ctypes
|
||||
dpi = ctypes.windll.user32.GetDpiForSystem()
|
||||
return round(dpi * 100 / 96) # 96 DPI = 100%
|
||||
except Exception as e:
|
||||
logger.debug(f"Impossible de lire le DPI Windows : {e}")
|
||||
return 100
|
||||
return 100 # Linux/Mac fallback
|
||||
|
||||
|
||||
def get_window_bounds() -> Optional[List[int]]:
|
||||
"""Retourne [x, y, width, height] de la fenetre active.
|
||||
|
||||
Windows : ctypes GetWindowRect(GetForegroundWindow())
|
||||
Linux/Mac : fallback None
|
||||
"""
|
||||
if _SYSTEM == "Windows":
|
||||
try:
|
||||
import ctypes
|
||||
import ctypes.wintypes
|
||||
|
||||
hwnd = ctypes.windll.user32.GetForegroundWindow()
|
||||
if not hwnd:
|
||||
return None
|
||||
rect = ctypes.wintypes.RECT()
|
||||
ctypes.windll.user32.GetWindowRect(hwnd, ctypes.byref(rect))
|
||||
return [
|
||||
rect.left,
|
||||
rect.top,
|
||||
rect.right - rect.left,
|
||||
rect.bottom - rect.top,
|
||||
]
|
||||
except Exception as e:
|
||||
logger.debug(f"Impossible de lire les bounds fenetre : {e}")
|
||||
return None
|
||||
|
||||
# Linux : tentative via xdotool
|
||||
if _SYSTEM == "Linux":
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
wid = subprocess.check_output(
|
||||
["xdotool", "getactivewindow"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode().strip()
|
||||
geom = subprocess.check_output(
|
||||
["xdotool", "getwindowgeometry", "--shell", wid],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode()
|
||||
# Parse "X=...\nY=...\nWIDTH=...\nHEIGHT=..."
|
||||
vals: Dict[str, int] = {}
|
||||
for line in geom.strip().splitlines():
|
||||
if "=" in line:
|
||||
k, v = line.split("=", 1)
|
||||
vals[k.strip()] = int(v.strip())
|
||||
if {"X", "Y", "WIDTH", "HEIGHT"} <= vals.keys():
|
||||
return [vals["X"], vals["Y"], vals["WIDTH"], vals["HEIGHT"]]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_monitor_info() -> Tuple[int, List[Dict[str, int]]]:
|
||||
"""Retourne (monitor_index, liste_moniteurs).
|
||||
|
||||
Chaque moniteur : {width, height, x, y}
|
||||
monitor_index : index du moniteur contenant la fenetre active
|
||||
"""
|
||||
monitors: List[Dict[str, int]] = []
|
||||
active_index = 0
|
||||
|
||||
try:
|
||||
import mss
|
||||
|
||||
with mss.mss() as sct:
|
||||
for mon in sct.monitors[1:]: # Skip le moniteur virtuel (index 0)
|
||||
monitors.append({
|
||||
"width": mon["width"],
|
||||
"height": mon["height"],
|
||||
"x": mon["left"],
|
||||
"y": mon["top"],
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"mss indisponible, resolution par defaut : {e}")
|
||||
monitors = [{"width": 1920, "height": 1080, "x": 0, "y": 0}]
|
||||
|
||||
# Determiner quel moniteur contient la fenetre active
|
||||
bounds = get_window_bounds()
|
||||
if bounds and len(monitors) > 1:
|
||||
wx, wy = bounds[0], bounds[1]
|
||||
for i, mon in enumerate(monitors):
|
||||
if (mon["x"] <= wx < mon["x"] + mon["width"]
|
||||
and mon["y"] <= wy < mon["y"] + mon["height"]):
|
||||
active_index = i
|
||||
break
|
||||
|
||||
return active_index, monitors
|
||||
|
||||
|
||||
def get_os_theme() -> str:
|
||||
"""Retourne 'light', 'dark' ou 'unknown'."""
|
||||
if _SYSTEM == "Windows":
|
||||
try:
|
||||
import winreg
|
||||
|
||||
key = winreg.OpenKey(
|
||||
winreg.HKEY_CURRENT_USER,
|
||||
r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
|
||||
)
|
||||
value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
|
||||
winreg.CloseKey(key)
|
||||
return "light" if value == 1 else "dark"
|
||||
except Exception as e:
|
||||
logger.debug(f"Impossible de lire le theme Windows : {e}")
|
||||
return "unknown"
|
||||
|
||||
# Linux : tentative via gsettings (GNOME)
|
||||
if _SYSTEM == "Linux":
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.check_output(
|
||||
["gsettings", "get", "org.gnome.desktop.interface", "color-scheme"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode().strip().strip("'\"")
|
||||
if "dark" in result.lower():
|
||||
return "dark"
|
||||
elif "light" in result.lower() or "default" in result.lower():
|
||||
return "light"
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_os_language() -> str:
|
||||
"""Retourne le code langue (fr, en, de, etc.)."""
|
||||
try:
|
||||
lang = locale.getdefaultlocale()[0] # ex: 'fr_FR'
|
||||
if lang:
|
||||
return lang[:2] # ex: 'fr'
|
||||
except Exception:
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_screen_metadata() -> Dict[str, Any]:
|
||||
"""Capture toutes les metadonnees systeme en une fois.
|
||||
|
||||
Appelee une fois au demarrage + a chaque changement de focus.
|
||||
Resultat injecte dans les evenements envoyes au serveur.
|
||||
"""
|
||||
monitor_index, monitors = get_monitor_info()
|
||||
primary = monitors[0] if monitors else {"width": 1920, "height": 1080}
|
||||
|
||||
return {
|
||||
"dpi_scale": get_dpi_scale(),
|
||||
"monitor_index": monitor_index,
|
||||
"monitors": monitors,
|
||||
"screen_resolution": [primary["width"], primary["height"]],
|
||||
"window_bounds": get_window_bounds(),
|
||||
"os_theme": get_os_theme(),
|
||||
"os_language": get_os_language(),
|
||||
}
|
||||
Reference in New Issue
Block a user