diff --git a/.GCC/branches/plan_modular_architecture.md b/.GCC/branches/plan_modular_architecture.md new file mode 100644 index 0000000..0dea8ae --- /dev/null +++ b/.GCC/branches/plan_modular_architecture.md @@ -0,0 +1,66 @@ +# Execution Plan: Modular Architecture Scaffolding (Issue #129) + +## 📋 Target Invariant & Pre-requisites +- **Target Invariant**: CI 100% vert (Ruff, Mypy, Pytest). Arborescence modulaire importable. +- **Pre-requisites**: Branche `refactor/modular-architecture-issue-129`. Restauration complĂšte du contexte (Protocole A) validĂ©e avant toute modification hors `.GCC/`. + +## đŸ› ïž Step-by-Step Sequence + +### Step 0: Validation des prĂ©requis et verrouillage d'alignement (Protocole A) +- [x] **Action**: VĂ©rification de l'alignement GCC et interdiction de modifications hors `.GCC/` avant confirmation. +- [x] **Verify**: `python3 -c "c1 = open('.GCC/main.md', encoding='utf-8').read(); c2 = open('.GCC/resume.md', encoding='utf-8').read(); assert len(c1) > 100 and len(c2) > 100; print('PASS')"` +- **Verification Proof**: +```text +PASS +``` + +### Step 1: Restauration du macro-Ă©tat projet (Protocole A - Étape 1) +- [x] **Action**: Lecture de `.GCC/main.md` pour charger les objectifs, milestones et branches actives. +- [x] **Verify**: `python3 -c "content = open('.GCC/main.md', encoding='utf-8').read(); assert len(content) > 100 and '## 🎯 Objective' in content; print('PASS')"` +- **Verification Proof**: +```text +PASS +``` + +### Step 2: Restauration de l'Ă©tat de transition technique (Protocole A - Étape 2) +- [x] **Action**: Lecture de `.GCC/resume.md` pour charger les directives de transition immĂ©diates. +- [x] **Verify**: `python3 -c "content = open('.GCC/resume.md', encoding='utf-8').read(); assert len(content) > 100 and '## 🎯 Functional Outcome' in content; print('PASS')"` +- **Verification Proof**: +```text +PASS +``` + +### Step 3: DĂ©ploiement arborescence et modules initiaux +- [x] **Action**: `mkdir -p gui_agent/{core,layers,utils}` et crĂ©ation des modules initiaux typĂ©s. +- [x] **Verify**: `python3 -c "import gui_agent.core, gui_agent.layers, gui_agent.utils; print('PASS')"` +- **Verification Proof**: +```text +PASS +``` + +### Step 4: Ajustement dĂ©pendances et validation CI +- [x] **Action**: Ajout de la dĂ©pendance conditionnelle `evdev` dans `pyproject.toml`, synchronisation de `uv.lock`, et mise Ă  jour des paquets systĂšme dans `install.sh`. +- [x] **Verify**: `uv lock --check && ./ci.sh` +- **Verification Proof**: +```text +$ uv lock --check +Resolved 100 packages in 1ms +``` +```text +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 RÉSUMÉ D'EXÉCUTION CI (CI Summary) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +| Étape de Validation | Statut | DurĂ©e | +|--------------------------------------------|------------|------------| +| Compilation Bytecode Python (compileall) | PASS | 209ms | +| Validation Workflows GitHub Actions | PASS | 82ms | +| Linter de Code (Ruff Check) | PASS | 123ms | +| Formatage de Code (Ruff Format) | PASS | 62ms | +| Typage Statique Strict (Mypy) | PASS | 572ms | +| Suite de Tests Pytest | PASS | 36352ms | +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎉 Toutes les Ă©tapes CI sont validĂ©es avec succĂšs ! +``` + +## ⚠ Mitigations & Edge Cases +- **Risk & Mitigation**: Process group SIGKILL et vidange dĂ©fensive contre les processus orphelins. diff --git a/.GCC/main.md b/.GCC/main.md index 90831ac..164c4b2 100644 --- a/.GCC/main.md +++ b/.GCC/main.md @@ -13,7 +13,7 @@ - [2026-08-27] Bornage DĂ©terministe et Deadline Globale pour le Listing X11 (PR #55, Confidence Score 5/5 Greptile) ## 🎯 Objective -High-performance, monolithic FastMCP server engineered for direct, low-latency Computer Use on Linux (X11/XWayland) and Windows desktop environments (<50 MB RAM, 21 tools, zero-leak process lifecycle). +High-performance FastMCP server engineered with a decoupled modular architecture (core, layers, utils) for direct, low-latency Computer Use on Linux (X11/XWayland) and Windows desktop environments (<50 MB RAM, 21 tools, zero-leak process lifecycle). ## đŸ›Ąïž Protocole de Validation par Pull Request & CritĂšres 5/5 Inviolables - **Mode de travail exclusif par Pull Request (PR)** : Toute Ă©volution, correctif de sĂ©curitĂ© ou refactorisation est dĂ©veloppĂ©e sur une branche dĂ©diĂ©e et soumise via PR. @@ -49,6 +49,10 @@ High-performance, monolithic FastMCP server engineered for direct, low-latency C - *P1 - Course TOCTOU lors de la suppression par chemin* : La sĂ©quence `os.stat()` puis `os.unlink(filename, dir_fd)` permet Ă  un attaquant de remplacer l'entrĂ©e entre les deux appels et d'entraĂźner la suppression de son fichier tiers. Solution : bannir la suppression destructive basĂ©e sur le nom dans un rĂ©pertoire concurrent ; retenir le descripteur ouvert de la rĂ©servation Ă  l'Ă©criture, ou s'abstenir de tout `unlink` non liĂ© de maniĂšre exclusive. ## 🧠 Decisions Made +- [2026-09-11] Architecture Modulaire DĂ©couplĂ©e (core, layers, utils #129) + - **Context**: Monolithe historique couplant REPL, gestion PTY, drivers bas niveau et helpers. + - **Discarded Options**: Monolithe persistant ; micro-paquets distribuĂ©s sĂ©parĂ©ment. + - **Rationale**: DĂ©coupage en 3 sous-packages (core: REPL/PTY/SDK, layers: accessibilitĂ©/fenĂȘtres/entrĂ©es/OCR, utils: gĂ©omĂ©trie/vidĂ©o/cinĂ©matique) garantissant isolation et testabilitĂ© unitaire sans rĂ©gression. - [2026-08-28] Support Bivalent Multi-Versions SDK MCP (1.x et 2.x+) - **Context**: Dependabot et les environnements clients rĂ©cents migrent vers `mcp>=2.0.0`. Le test unitaire `test_fastmcp_tools_registration` dĂ©pendait d'attributs privĂ©s fragiles (`_tool_manager`), et `gui_agent/server.py` restreignait explicitement la compatibilitĂ© Ă  la version 1.x. - **Discarded Options**: Bloquer strictement sur `mcp<2.0.0` (empĂȘche les montĂ©es de versions et mises Ă  jour de sĂ©curitĂ© de Dependabot) ; réécrire l'intĂ©gralitĂ© du serveur en MCP bas niveau (complexe et inutile car FastMCP est prĂ©servĂ© en v2). @@ -107,6 +111,7 @@ High-performance, monolithic FastMCP server engineered for direct, low-latency C - **Rationale**: Geler la structure jusqu'Ă  la revue utilisateur afin de ne pas invalider les chemins de son audit, et reporter les corrections futures dans l'audit. ## 🌿 Active Branches / Plans +- `refactor/modular-architecture-issue-129` : DĂ©ploiement arborescence modulaire (#129) ([plan_modular_architecture.md](branches/plan_modular_architecture.md)). - `fix/ci-verify-workflows-logic` : Enrichissement de `verify_workflows.py` pour valider la logique mĂ©tier et les invariants de sĂ©curitĂ© des workflows GitHub Actions ([plan_verify_workflows_logic.md](branches/plan_verify_workflows_logic.md)). - `main` : Stable production release with complete bilingual landing pages, 64/64 Zero-Slop test harness, hardened screenshot rollback lifecycle, bounded X11 timeouts and thread-safe video recording. - `organize_repo` : Plan de rĂ©organisation et harmonisation gouvernance/CI ([plan_organize_repo.md](branches/plan_organize_repo.md)) — *En attente de revue utilisateur*. @@ -123,11 +128,11 @@ High-performance, monolithic FastMCP server engineered for direct, low-latency C - Fermeture des issues rĂ©solues (#42, #56, #69, #106, #70, #68, #63, #59, #46, #45, #13, #107, #43). - Nettoyage et suppression de l'ensemble des branches rĂ©siduelles distantes et locales. - Validation CI 64/64 tests, quality gate PASS, Greptile CLI 5/5 sur l'arbre de travail. -- 🔄 In progress: Aucun (arbre propre sur `main`). +- 🔄 In progress: refactor/modular-architecture-issue-129 (dĂ©ploiement de l'arborescence modulaire et durcissement des primitives d'exĂ©cution #129). - ⏳ Pending: - 1. **Assainissement Gouvernance/CI/Hooks** : Traiter #34 (Ă©pinglage versions uv run), #33 (matrice Python 3.10-3.13), #32 (fallback silencieux pip dev) et #24 (Mypy strict). - 2. **Refactoring Arborescence (#30)** : Migrer vers `src/gui_agent/` selon le plan `plan_organize_repo.md`. - 3. **Bugs Fonctionnels & PrĂ©requis (#39, #38, #37, #31, #18, #17, #19, #20, #21)**. ## 👉 Next Session Direction -DĂ©marrer le traitement d'une nouvelle issue prioritaire sur une branche dĂ©diĂ©e (ex: #34, #39 ou #37). +Finaliser la validation et prĂ©parer la fusion de la PR #136. diff --git a/.GCC/resume.md b/.GCC/resume.md index a13b86f..89eb4c9 100644 --- a/.GCC/resume.md +++ b/.GCC/resume.md @@ -1,39 +1,58 @@ # Session Handoff ## 🎯 Functional Outcome & Task Reality -- **Requested Task**: Rendre le projet compatible avec `mcp>=2.0.0` (support bivalent 1.x et 2.x+) suite aux Ă©checs de CI sur les PRs Dependabot. +- **Requested Task**: Audit, durcissement et validation de la PR #136 (`refactor/modular-architecture-issue-129` : https://github.com/leandre755/gui_agent/pull/136) pour obtenir un score Greptile de 5/5 sans commentaire rĂ©siduel, faire approuver et lever toute change request de CodeRabbit (0 finding), et synchroniser la branche distante. - **Functional Status**: SUCCESS -- **Behavioral Proof**: ExĂ©cution intĂ©grale du pipeline `./ci.sh` : 64/64 tests passĂ©s avec succĂšs, 0 erreur Mypy, 0 warning Ruff, compilation bytecode propre. +- **Behavioral Proof**: + - `greptile review --json` : Confidence Score 5/5, 0 constat. + - `coderabbit review --agent --base origin/main` : 0 finding sur l'ensemble des 20 fichiers auditĂ©s. + - `./ci.sh` : 65/65 tests Pytest passĂ©s avec succĂšs sous Xvfb, Mypy strict 17 fichiers PASS, Ruff check & format PASS. + - Synchronisation Git distante : commits `4050217` et `113a0ed` poussĂ©s sur `origin/refactor/modular-architecture-issue-129`. + - Gouvernance du dĂ©pĂŽt : 729 lignes modifiĂ©es au total (`git diff --numstat origin/main...HEAD`), strictement sous la limite de 1000 lignes. ## ⚡ Technical Diffs / Atomic Modifications -- **File**: `pyproject.toml` - - **Scope**: DĂ©pendances core - - **Exact Technical Change**: Plage de dĂ©pendance Ă©largie de `"mcp>=1.2.0,<2.0.0"` vers `"mcp>=1.2.0,<3.0.0"`. -- **File**: `gui_agent/server.py` - - **Scope**: Import FastMCP - - **Exact Technical Change**: Neutralisation de la mention restrictive "version 1.x" dans le message d'erreur d'importation. +- **File**: `gui_agent/core/pty_session.py` + - **Scope**: Gestionnaire de session pseudo-terminal PTY + - **Exact Technical Change**: Ajout du bornage configurable `max_output_chars` (dĂ©faut 1_000_000) dans les boucles de lecture et de vidange, dĂ©tection de timeout dans la boucle de drain final avec terminaison du groupe de processus (`_kill_pty`), et appel inconditionnel Ă  `_kill_pty(proc)` dans le bloc `finally:` pour Ă©radiquer tout processus orphelin en arriĂšre-plan dĂ©tachĂ© dans le groupe de session. +- **File**: `gui_agent/utils/coordinates.py` + - **Scope**: `check_display_env` + - **Exact Technical Change**: Utilisation de `not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY")` pour considĂ©rer les valeurs vides comme absentes et lever de maniĂšre dĂ©terministe un `RuntimeError`. +- **File**: `gui_agent/core/repl.py` + - **Scope**: Moteur CodeAct local REPL + - **Exact Technical Change**: Bloc `finally:` garantissant le nettoyage du groupe de processus et la fermeture des descripteurs, factorisation de `_write_input` (complexitĂ© cognitive McCabe < 25), et troncature stricte de la sortie via `_clamp_output`. - **File**: `tests/test_package.py` - - **Scope**: `test_fastmcp_tools_registration` - - **Exact Technical Change**: ImplĂ©mentation d'une inspection multi-niveaux robuste pour inspecter les 21 outils FastMCP enregistrĂ©s (compatible architectures internes v1 et v2). + - **Scope**: Suite de tests unitaires + - **Exact Technical Change**: Ajout de rĂ©gressions validant le bornage du buffer PTY, l'Ă©radication dĂ©terministe d'un processus fils dĂ©tachĂ© en arriĂšre-plan via `os.kill(bg_pid, 0)`, et la dĂ©tection d'environnement d'affichage vide. +- **File**: `.GCC/main.md` + - **Scope**: Registre architectural du projet + - **Exact Technical Change**: Mise Ă  jour de la section Objective dĂ©crivant l'architecture modulaire dĂ©couplĂ©e (core, layers, utils). +- **File**: `.GCC/branches/plan_modular_architecture.md` + - **Scope**: Plan tactique d'exĂ©cution + - **Exact Technical Change**: Restructuration de la sĂ©quence (Step 0 prĂ©requis et Steps 1 & 2 consommant l'intĂ©gralitĂ© des documents de contexte), validation `uv lock --check`, et insertion des preuves brutes de terminal. ## đŸ› ïž Static Codebase Health - **Verification Command Run**: `./ci.sh` -- **Linter/Compiler Status**: +- **Linter/Compiler Status**: ```text +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 RÉSUMÉ D'EXÉCUTION CI (CI Summary) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ | Étape de Validation | Statut | DurĂ©e | |--------------------------------------------|------------|------------| -| Compilation Bytecode Python (compileall) | PASS | 2194ms | -| Validation Workflows GitHub Actions | PASS | 88ms | -| Linter de Code (Ruff Check) | PASS | 579ms | -| Formatage de Code (Ruff Format) | PASS | 90ms | -| Typage Statique Strict (Mypy) | PASS | 2490ms | -| Suite de Tests Pytest | PASS | 18042ms | +| Compilation Bytecode Python (compileall) | PASS | 258ms | +| Validation Workflows GitHub Actions | PASS | 77ms | +| Linter de Code (Ruff Check) | PASS | 268ms | +| Formatage de Code (Ruff Format) | PASS | 74ms | +| Typage Statique Strict (Mypy) | PASS | 16461ms | +| Suite de Tests Pytest | PASS | 25153ms | +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +🎉 Toutes les Ă©tapes CI sont validĂ©es avec succĂšs ! ``` ## 🚧 Unfinished Work & Technical Failures -- Aucun Ă©chec ni bloqueur. Le support 1.x / 2.x+ est opĂ©rationnel et validĂ©. +- Aucun bug rĂ©siduel. Tous les constats de revue Greptile et CodeRabbit sont rĂ©solus Ă  100%. ## 👉 Handover Directives for the Next Agent -1. **Target File**: `pyproject.toml` -2. **Immediate Action**: CrĂ©er la branche / PR pour fusionner la compatibilitĂ© `mcp>=1.2.0,<3.0.0`. -3. **Verification Command**: `./ci.sh` +1. **Target File**: `PR #136` sur GitHub +2. **Immediate Action**: ProcĂ©der Ă  la fusion (merge) de la PR #136 vers `main`. +3. **Verification Command**: `gh pr view 136` diff --git a/.gitignore b/.gitignore index ca07ca8..44d868a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,5 @@ credentials.json assets/ *.excalidraw mermaid_diagrams.txt +todo.md + diff --git a/gui_agent/core/__init__.py b/gui_agent/core/__init__.py new file mode 100644 index 0000000..494af17 --- /dev/null +++ b/gui_agent/core/__init__.py @@ -0,0 +1,7 @@ +"""Moteur d'exĂ©cution local CodeAct et SDK unifiĂ© mcp_core.""" + +from gui_agent.core.mcp_core import mcp_core +from gui_agent.core.pty_session import PTYSession +from gui_agent.core.repl import execute_script + +__all__ = ["PTYSession", "execute_script", "mcp_core"] diff --git a/gui_agent/core/mcp_core.py b/gui_agent/core/mcp_core.py new file mode 100644 index 0000000..7e83106 --- /dev/null +++ b/gui_agent/core/mcp_core.py @@ -0,0 +1,30 @@ +"""SDK Python unifiĂ© mcp_core injectĂ© dans l'espace REPL.""" + +from __future__ import annotations + +from gui_agent import layers + + +class MCPCoreSDK: + """Interface unifiĂ©e exposant l'ensemble des primitives systĂšme.""" + + activate_window = staticmethod(layers.activate_window) + find_text = staticmethod(layers.find_text) + get_app_state = staticmethod(layers.get_app_state) + key_tap = staticmethod(layers.key_tap) + mouse_click_at = staticmethod(layers.mouse_click_at) + mouse_drag_smooth = staticmethod(layers.mouse_drag_smooth) + mouse_scroll = staticmethod(layers.mouse_scroll) + perform_action = staticmethod(layers.perform_action) + process_list = staticmethod(layers.process_list) + process_run = staticmethod(layers.process_run) + screen_capture = staticmethod(layers.screen_capture) + set_value = staticmethod(layers.set_value) + + @property + def mcp_core(self) -> MCPCoreSDK: + return self + + +mcp_core = MCPCoreSDK() +__all__ = ["MCPCoreSDK", "mcp_core"] diff --git a/gui_agent/core/pty_session.py b/gui_agent/core/pty_session.py new file mode 100644 index 0000000..94f0e9a --- /dev/null +++ b/gui_agent/core/pty_session.py @@ -0,0 +1,111 @@ +"""Gestionnaire de session PTY pour l'exĂ©cution interactive et sĂ©curisĂ©e de sous-processus.""" + +from __future__ import annotations + +import contextlib +import logging +import os +import select +import signal +import subprocess +import sys +import time +from typing import Any + +if sys.platform != "win32": + import pty + +logger = logging.getLogger("gui_agent.core.pty") + + +MAX_PTY_OUTPUT_CHARS = 1_000_000 + + +class PTYSession: + """Session pseudo-terminal (PTY) gĂ©rant l'interaction avec stdin/stdout/stderr.""" + + def __init__(self, timeout: float = 30.0, max_output_chars: int = MAX_PTY_OUTPUT_CHARS) -> None: + self.timeout = timeout + self.max_output_chars = max_output_chars + + def execute( + self, cmd: list[str], stdin_payload: str | None = None, max_output_chars: int | None = None + ) -> tuple[int, str]: + """ExĂ©cute une commande dans un pseudo-terminal PTY isolĂ©.""" + if sys.platform == "win32" or "pty" not in sys.modules: + return -1, "PTY non supportĂ© sur cette plateforme." + limit = max_output_chars if max_output_chars is not None else self.max_output_chars + master_fd, slave_fd = pty.openpty() + chunks: list[str] = [] + total_chars = 0 + start = time.monotonic() + proc: subprocess.Popen[Any] | None = None + try: + os.set_blocking(master_fd, False) + proc = subprocess.Popen( + cmd, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, close_fds=True, preexec_fn=os.setsid + ) + with contextlib.suppress(OSError): + os.close(slave_fd) + slave_fd = -1 + + payload, written, eof_sent = (stdin_payload.encode("utf-8") if stdin_payload else b""), 0, False + while proc.poll() is None: + if time.monotonic() - start > self.timeout: + _kill_pty(proc) + return -1, "Timeout d'exĂ©cution PTY dĂ©passĂ©." + w = [master_fd] if (written < len(payload) or not eof_sent) else [] + rf, wf, _ = select.select([master_fd], w, [], 0.05) + if wf: + if written < len(payload): + try: + written += os.write(master_fd, payload[written:]) + except OSError: + written = len(payload) + elif not eof_sent: + eof_sent = True + eof = b"\x04" if (not payload or payload.endswith(b"\n")) else b"\x04\x04" + with contextlib.suppress(OSError): + os.write(master_fd, eof) + if rf: + c = _read_pty(master_fd) + if c: + chunks.append(c) + total_chars += len(c) + if total_chars > limit: + _kill_pty(proc) + return -1, "Taille de sortie PTY maximale dĂ©passĂ©e." + + while select.select([master_fd], [], [], 0.05)[0]: + if time.monotonic() - start > self.timeout: + _kill_pty(proc) + break + c = _read_pty(master_fd) + if not c: + break + chunks.append(c) + total_chars += len(c) + if total_chars > limit: + _kill_pty(proc) + return -1, "Taille de sortie PTY maximale dĂ©passĂ©e." + returncode = proc.wait() + finally: + for fd in (slave_fd, master_fd): + with contextlib.suppress(OSError): + if fd >= 0: + os.close(fd) + if proc is not None: + _kill_pty(proc) + return returncode, "".join(chunks) + + +def _read_pty(fd: int) -> str: + with contextlib.suppress(OSError): + return os.read(fd, 4096).decode("utf-8", errors="replace") + return "" + + +def _kill_pty(proc: subprocess.Popen[Any]) -> None: + with contextlib.suppress(Exception): + os.killpg(proc.pid, signal.SIGKILL) + proc.wait(timeout=0.5) diff --git a/gui_agent/core/repl.py b/gui_agent/core/repl.py new file mode 100644 index 0000000..62340d3 --- /dev/null +++ b/gui_agent/core/repl.py @@ -0,0 +1,150 @@ +"""Moteur d'exĂ©cution local CodeAct : ExĂ©cute des scripts Python/Bash avec mcp_core prĂ©chargĂ©.""" + +from __future__ import annotations + +import contextlib +import logging +import os +import select +import signal +import subprocess +import sys +import time +from typing import Any + +logger = logging.getLogger("gui_agent.core.repl") +MAX_OUTPUT_CHARS = 1_000_000 +RUNNER_HARNESS = ( + "import sys, os\nsys.path.insert(0, os.getcwd())\n" + "import gui_agent.core.mcp_core as _sdk\nsys.modules['mcp_core'] = sys.modules['gui_agent.core.mcp_core']\n" + "code = sys.stdin.read()\nexec(compile(code, '', 'exec'), {'__name__': '__main__', 'mcp_core': _sdk.mcp_core})\n" +) + + +def execute_script(code: str, timeout: float = 30.0, max_output_chars: int = MAX_OUTPUT_CHARS) -> dict[str, Any]: + """ExĂ©cute un script Python localement dans un processus isolĂ© avec mcp_core prĂ©chargĂ©.""" + if not code or not code.strip(): + return {"status": "error", "message": "Code Ă  exĂ©cuter vide."} + start = time.monotonic() + status, err = "success", None + out_ch: list[str] = [] + err_ch: list[str] = [] + proc: subprocess.Popen[str] | None = None + try: + proc = subprocess.Popen( + [sys.executable, "-c", RUNNER_HARNESS], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + start_new_session=True, + ) + if proc.stdin is None or proc.stdout is None or proc.stderr is None: + return {"status": "error", "message": "Échec d'ouverture des flux d'exĂ©cution."} + in_fd, out_fd, err_fd = proc.stdin.fileno(), proc.stdout.fileno(), proc.stderr.fileno() + for fd in (in_fd, out_fd, err_fd): + os.set_blocking(fd, False) + inp, off, in_closed, out_len, err_len, readers = code.encode("utf-8"), 0, False, 0, 0, [out_fd, err_fd] + + while True: + el = time.monotonic() - start + if el > timeout: + _kill_proc(proc) + status, err = "error", f"Timeout ({timeout}s)." + break + wl = [in_fd] if (not in_closed and off < len(inp)) else [] + if not readers and not wl: + if proc.poll() is None: + try: + proc.wait(timeout=max(0.01, timeout - el)) + except subprocess.TimeoutExpired: + _kill_proc(proc) + status, err = "error", f"Timeout ({timeout}s)." + break + r, w, _ = select.select(readers, wl, [], min(max(0.01, timeout - el), 0.05)) + if w and not in_closed: + off, in_closed = _write_input(proc, in_fd, inp, off) + for fd in r: + chunk = _safe_read(fd) + if not chunk: + if fd in readers: + readers.remove(fd) + continue + (out_ch if fd == out_fd else err_ch).append(chunk) + out_len += len(chunk) if fd == out_fd else 0 + err_len += len(chunk) if fd != out_fd else 0 + if out_len > max_output_chars or err_len > max_output_chars: + _kill_proc(proc) + status, err = "error", f"Taille de sortie maximale dĂ©passĂ©e ({max_output_chars} caractĂšres)." + break + if err: + break + + if not err: + for fd in readers: + while c := _safe_read(fd): + (out_ch if fd == out_fd else err_ch).append(c) + if proc.returncode is None: + _close_proc(proc) + if proc.returncode != 0 and status == "success": + status, err = "error", "".join(err_ch).strip() or f"Code {proc.returncode}" + except Exception as exc: + status, err = "error", f"{type(exc).__name__}: {exc!s}" + finally: + if proc is not None: + if proc.poll() is None: + _kill_proc(proc) + _close_proc(proc) + + dur = round((time.monotonic() - start) * 1000, 2) + s_out, s_err, status, err = _clamp_output(out_ch, err_ch, max_output_chars, status, err) + res: dict[str, Any] = {"status": status, "stdout": s_out, "stderr": s_err, "duration_ms": dur} + if err: + res["error"] = err + return res + + +def _clamp_output( + out_ch: list[str], err_ch: list[str], max_chars: int, status: str, err: str | None +) -> tuple[str, str, str, str | None]: + s_out, s_err = "".join(out_ch), "".join(err_ch) + if len(s_out) > max_chars: + s_out, status, err = s_out[:max_chars], "error", f"Taille de sortie maximale dĂ©passĂ©e ({max_chars} caractĂšres)." + if len(s_err) > max_chars: + s_err, status, err = s_err[:max_chars], "error", f"Taille de sortie maximale dĂ©passĂ©e ({max_chars} caractĂšres)." + return s_out, s_err, status, err + + +def _close_proc(proc: subprocess.Popen[str]) -> None: + with contextlib.suppress(Exception): + proc.wait(timeout=0.5) + for s in (proc.stdin, proc.stdout, proc.stderr): + with contextlib.suppress(Exception): + if s: + s.close() + + +def _safe_read(fd: int) -> str: + with contextlib.suppress(OSError): + return os.read(fd, 4096).decode("utf-8", errors="replace") + return "" + + +def _kill_proc(proc: subprocess.Popen[str]) -> None: + with contextlib.suppress(Exception): + os.killpg(proc.pid, signal.SIGKILL) + proc.wait(timeout=0.5) + + +def _write_input(proc: subprocess.Popen[str], in_fd: int, inp: bytes, off: int) -> tuple[int, bool]: + try: + off += os.write(in_fd, inp[off:]) + if off >= len(inp): + if proc.stdin: + proc.stdin.close() + return off, True + except OSError: + if proc.stdin: + proc.stdin.close() + return off, True + return off, False diff --git a/gui_agent/layers/__init__.py b/gui_agent/layers/__init__.py new file mode 100644 index 0000000..d708c88 --- /dev/null +++ b/gui_agent/layers/__init__.py @@ -0,0 +1,21 @@ +"""Couches d'interaction et de mĂ©diation systĂšme pour gui_agent.""" + +from gui_agent.layers.accessibility import get_app_state, perform_action, set_value +from gui_agent.layers.input_emulation import key_tap, mouse_click_at, mouse_drag_smooth, mouse_scroll, screen_capture +from gui_agent.layers.visual_perception import find_text +from gui_agent.layers.window_management import activate_window, process_list, process_run + +__all__ = [ + "activate_window", + "find_text", + "get_app_state", + "key_tap", + "mouse_click_at", + "mouse_drag_smooth", + "mouse_scroll", + "perform_action", + "process_list", + "process_run", + "screen_capture", + "set_value", +] diff --git a/gui_agent/layers/accessibility.py b/gui_agent/layers/accessibility.py new file mode 100644 index 0000000..8e5d7b5 --- /dev/null +++ b/gui_agent/layers/accessibility.py @@ -0,0 +1,23 @@ +"""Couche d'AccessibilitĂ© Programmatique (AT-SPI / D-Bus).""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger("gui_agent.layers.accessibility") + + +def get_app_state(include_screenshot: bool = False) -> dict[str, Any]: + """Extrait l'arbre d'accessibilitĂ© applicatif en JSON texte pur via AT-SPI.""" + return {"status": "not_implemented", "layer": "accessibility", "include_screenshot": include_screenshot} + + +def perform_action(element_id: str, action: str) -> bool: + """DĂ©clenche l'action du composant accessible par le bus.""" + return False + + +def set_value(element_id: str, text: str) -> bool: + """Écrit directement la valeur textuelle dans la mĂ©moire du composant.""" + return False diff --git a/gui_agent/layers/input_emulation.py b/gui_agent/layers/input_emulation.py new file mode 100644 index 0000000..16b01b7 --- /dev/null +++ b/gui_agent/layers/input_emulation.py @@ -0,0 +1,37 @@ +"""Couche d'Émulation d'EntrĂ©es Bas-Niveau (uinput/evdev).""" + +from __future__ import annotations + +import logging +from typing import Any + +logger = logging.getLogger("gui_agent.layers.input_emulation") + + +def screen_capture(show_grid: bool = False, grid_step: int = 100) -> dict[str, Any]: + """Capture le framebuffer avec grille optionnelle.""" + return {"status": "not_implemented", "show_grid": show_grid, "grid_step": grid_step, "message": "Phase 3 (#132)."} + + +def mouse_click_at(x: int, y: int, button: str = "left") -> dict[str, Any]: + """Émet un clic absolu via uinput.""" + if isinstance(x, bool) or isinstance(y, bool) or not (isinstance(x, int) and isinstance(y, int)): + return {"status": "error", "message": "Les coordonnĂ©es x et y doivent ĂȘtre des entiers."} + return {"status": "not_implemented", "action": "click", "x": x, "y": y, "button": button} + + +def mouse_drag_smooth(start_x: int, start_y: int, end_x: int, end_y: int, duration: float = 0.5) -> dict[str, Any]: + """Interpolation cinĂ©matique continue de glisser-dĂ©poser.""" + return {"status": "not_implemented", "action": "drag_smooth", "start": (start_x, start_y), "end": (end_x, end_y)} + + +def mouse_scroll(clicks: int, direction: str = "down") -> dict[str, Any]: + """Émet des Ă©vĂ©nements de dĂ©filement.""" + return {"status": "not_implemented", "action": "scroll", "clicks": clicks, "direction": direction} + + +def key_tap(key_sequence: str) -> dict[str, Any]: + """Injecte des Ă©vĂ©nements d'entrĂ©e clavier via evdev.""" + if not key_sequence: + return {"status": "error", "message": "SĂ©quence de touches vide."} + return {"status": "not_implemented", "action": "key_tap", "key_sequence": key_sequence} diff --git a/gui_agent/layers/visual_perception.py b/gui_agent/layers/visual_perception.py new file mode 100644 index 0000000..f91d328 --- /dev/null +++ b/gui_agent/layers/visual_perception.py @@ -0,0 +1,12 @@ +"""Couche de Perception Visuelle & Reconnaissance Optique (OCR).""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger("gui_agent.layers.visual_perception") + + +def find_text(text: str, confidence: float = 0.85) -> dict[str, int] | None: + """Analyse le framebuffer local via OCR (RapidOCR) et renvoie les coordonnĂ©es (x, y).""" + return None diff --git a/gui_agent/layers/window_management.py b/gui_agent/layers/window_management.py new file mode 100644 index 0000000..8108f30 --- /dev/null +++ b/gui_agent/layers/window_management.py @@ -0,0 +1,35 @@ +"""Couche de Gestion de FenĂȘtrage & Processus SystĂšme.""" + +from __future__ import annotations + +import logging +import os +from typing import Any + +logger = logging.getLogger("gui_agent.layers.window_management") + + +def process_run(command: list[str], sudo_password: str | None = None, timeout: float = 30.0) -> dict[str, Any]: + """ExĂ©cute un processus fils via PTY.""" + if not command: + return {"status": "error", "message": "Commande vide non autorisĂ©e."} + return {"status": "not_implemented", "command": command, "has_sudo": sudo_password is not None, "timeout": timeout} + + +def process_list() -> list[dict[str, Any]]: + """Interroge /proc en lecture seule pour lister les processus.""" + if not os.path.exists("/proc"): + logger.warning("SystĂšme /proc non disponible sur cette plateforme.") + return [{"status": "error", "message": "SystĂšme de fichiers /proc non disponible sur cette plateforme."}] + try: + return [{"pid": int(e)} for e in os.listdir("/proc") if e.isdigit()] + except Exception as e: + logger.warning(f"Impossible d'inspecter /proc : {e}") + return [{"status": "error", "message": f"Erreur d'inspection /proc : {e}"}] + + +def activate_window(window_id: str | None = None, title: str | None = None) -> dict[str, Any]: + """Commute le focus auprĂšs du compositeur par Window ID ou titre.""" + if not window_id and not title: + return {"status": "error", "message": "Au moins window_id ou title doit ĂȘtre spĂ©cifiĂ©."} + return {"status": "not_implemented", "window_id": window_id, "title": title} diff --git a/gui_agent/utils/__init__.py b/gui_agent/utils/__init__.py new file mode 100644 index 0000000..79a928f --- /dev/null +++ b/gui_agent/utils/__init__.py @@ -0,0 +1,14 @@ +"""Utilitaires et drivers partagĂ©s pour gui_agent.""" + +from gui_agent.utils.coordinates import get_monitor_geometry, normalize_coordinates +from gui_agent.utils.human_mimic import generate_smooth_path, sleep_human, type_char_human +from gui_agent.utils.video import validate_video_recording_params + +__all__ = [ + "generate_smooth_path", + "get_monitor_geometry", + "normalize_coordinates", + "sleep_human", + "type_char_human", + "validate_video_recording_params", +] diff --git a/gui_agent/utils/coordinates.py b/gui_agent/utils/coordinates.py new file mode 100644 index 0000000..b9adf02 --- /dev/null +++ b/gui_agent/utils/coordinates.py @@ -0,0 +1,52 @@ +"""Gestion des coordonnĂ©es gĂ©omĂ©triques multi-Ă©crans et normalisation.""" + +from __future__ import annotations + +import math +import os +import mss + + +def check_display_env() -> None: + """VĂ©rifie la prĂ©sence d'un serveur d'affichage graphique valide.""" + if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"): + raise RuntimeError("Aucun serveur graphique dĂ©tectĂ© (DISPLAY ou WAYLAND_DISPLAY requis).") + + +def get_monitor_geometry(monitor_index: int = 1) -> tuple[int, int, int, int]: + """Obtient les coordonnĂ©es (left, top, width, height) d'un moniteur spĂ©cifique.""" + if not isinstance(monitor_index, int) or isinstance(monitor_index, bool): + raise TypeError(f"monitor_index doit ĂȘtre un entier, reçu : {type(monitor_index).__name__}") + check_display_env() + with mss.mss() as sct: + if monitor_index < 0 or monitor_index >= len(sct.monitors): + raise ValueError( + f"Index de moniteur invalide : {monitor_index}. Index valides : 0 Ă  {len(sct.monitors) - 1}." + ) + m = sct.monitors[monitor_index] + return m["left"], m["top"], m["width"], m["height"] + + +def normalize_coordinates( + x: float, y: float, normalized: bool = False, inverse: bool = False, monitor_index: int = 1 +) -> tuple[int, int]: + """Convertit des coordonnĂ©es du rĂ©fĂ©rentiel [0, 1000] vers les pixels rĂ©els (ou inversement).""" + if not isinstance(normalized, bool) or not isinstance(inverse, bool): + raise TypeError("Les paramĂštres normalized et inverse doivent ĂȘtre des boolĂ©ens.") + if isinstance(x, bool) or isinstance(y, bool) or not (isinstance(x, (int, float)) and isinstance(y, (int, float))): + raise TypeError("Les coordonnĂ©es x et y doivent ĂȘtre des nombres rĂ©els (int ou float).") + if math.isnan(x) or math.isnan(y) or math.isinf(x) or math.isinf(y): + raise ValueError("Les coordonnĂ©es x et y ne peuvent pas ĂȘtre NaN ou Infinity.") + left, top, width, height = get_monitor_geometry(monitor_index) + if inverse: + rx = round(max(0.0, min(1000.0, ((float(x) - left) / max(1, width - 1)) * 1000.0))) + ry = round(max(0.0, min(1000.0, ((float(y) - top) / max(1, height - 1)) * 1000.0))) + return rx, ry + if normalized: + fx, fy = max(0.0, min(1000.0, float(x))), max(0.0, min(1000.0, float(y))) + return left + round((fx / 1000.0) * float(max(0, width - 1))), top + round( + (fy / 1000.0) * float(max(0, height - 1)) + ) + return round(max(float(left), min(float(left + max(0, width - 1)), float(x)))), round( + max(float(top), min(float(top + max(0, height - 1)), float(y))) + ) diff --git a/gui_agent/utils/human_mimic.py b/gui_agent/utils/human_mimic.py new file mode 100644 index 0000000..3a9349e --- /dev/null +++ b/gui_agent/utils/human_mimic.py @@ -0,0 +1,64 @@ +"""Algorithmes cinĂ©matiques et temporisation pseudo-alĂ©atoire mimant l'humain.""" + +from __future__ import annotations + +import math +import os +import random +import shutil +import subprocess +import time + + +def sleep_human(base_delay: float = 0.05) -> None: + """Introduit un dĂ©lai pseudo-alĂ©atoire rĂ©aliste.""" + if not isinstance(base_delay, (int, float)) or not math.isfinite(base_delay) or base_delay < 0: + delay = 0.05 + else: + delay = float(base_delay) + val = random.normalvariate(delay, delay * 0.3) + if not math.isfinite(val): + val = delay + time.sleep(max(0.01, min(60.0, val))) + + +def type_char_human(char: str, base_delay: float = 0.06) -> bool: + """Simule la frappe d'un caractĂšre avec temporisation naturelle.""" + if not isinstance(char, str) or len(char) != 1: + return False + sleep_human(base_delay) + xdotool_bin = shutil.which("xdotool") or "xdotool" + try: + env = os.environ.copy() + env["DISPLAY"] = env.get("DISPLAY", ":0") + res = subprocess.run([xdotool_bin, "type", char], env=env, capture_output=True, check=False, timeout=5) + return res.returncode == 0 + except Exception: + return False + + +def translate_key(key: str) -> str: + """Traduit les touches systĂšme abrĂ©gĂ©es en symboles standards.""" + if not isinstance(key, str) or not key: + return "" + km = { + "super": "Super_L", + "win": "Super_L", + "enter": "Return", + "return": "Return", + "ctrl": "control", + "escape": "Escape", + "esc": "Escape", + } + return km.get(key.lower(), key) + + +def generate_smooth_path(start_x: int, start_y: int, end_x: int, end_y: int, steps: int = 20) -> list[tuple[int, int]]: + """GĂ©nĂšre une trajectoire cinĂ©matique continue interpolĂ©e entre deux points.""" + if steps <= 1: + return [(start_x, start_y), (end_x, end_y)] + res = [] + for i in range(steps + 1): + t = (i / steps) ** 2 * (3.0 - 2.0 * (i / steps)) + res.append((round(start_x + (end_x - start_x) * t), round(start_y + (end_y - start_y) * t))) + return res diff --git a/gui_agent/utils/video.py b/gui_agent/utils/video.py new file mode 100644 index 0000000..dfd3954 --- /dev/null +++ b/gui_agent/utils/video.py @@ -0,0 +1,64 @@ +"""Utilitaires d'enregistrement vidĂ©o sĂ©curisĂ© pour vision LLM.""" + +from __future__ import annotations + +import contextlib +import os +import subprocess +import tempfile +import threading +import uuid +from typing import Any + +video_recording_lock = threading.Lock() + + +def close_subprocess_streams(proc: subprocess.Popen[Any] | None) -> None: + """Ferme de façon dĂ©fensive les flux standard d'un sous-processus.""" + if proc is None: + return + for s in (proc.stdin, proc.stdout, proc.stderr): + if s and not s.closed: + with contextlib.suppress(Exception): + s.close() + + +def _check_int(val: Any, min_v: int | None = None, max_v: int | None = None) -> int | None: + if isinstance(val, bool) or (isinstance(val, float) and not val.is_integer()): + return None + try: + iv = int(val) + if (min_v is not None and iv < min_v) or (max_v is not None and iv > max_v): + return None + return iv + except (ValueError, TypeError): + return None + + +def validate_video_recording_params( + output_path: str | None, + fps: int, + monitor_index: int, + duration: int | None, + default_dir: str | None = None, +) -> tuple[int, int, int | None, str] | dict[str, Any]: + """Valide et normalise les paramĂštres d'enregistrement vidĂ©o.""" + fps_val = _check_int(fps, 1, 30) + if fps_val is None: + return {"status": "error", "message": "fps doit ĂȘtre un entier entre 1 et 30."} + mon_idx = _check_int(monitor_index, 0) + if mon_idx is None: + return {"status": "error", "message": "monitor_index doit ĂȘtre un entier positif ou nul."} + dur_val = None + if duration is not None: + dur_val = _check_int(duration, 1) + if dur_val is None: + return {"status": "error", "message": "duration doit ĂȘtre un entier strictement positif."} + + path = output_path or os.path.join(default_dir or tempfile.gettempdir(), f"recording_{uuid.uuid4().hex}.mp4") + norm_path = os.path.abspath(os.path.expanduser(str(path))) + if os.path.isdir(norm_path): + return {"status": "error", "message": "output_path ne peut pas ĂȘtre un rĂ©pertoire existant."} + if not norm_path.lower().endswith(".mp4"): + return {"status": "error", "message": "output_path doit comporter l'extension .mp4."} + return fps_val, mon_idx, dur_val, norm_path diff --git a/install.sh b/install.sh index 56833a3..0d7cc0c 100755 --- a/install.sh +++ b/install.sh @@ -128,17 +128,20 @@ if ! command -v "tesseract" >/dev/null 2>&1; then MISSING_SYS_DEPS+=("tesseract-ocr") fi +python3 -c "import dbus" >/dev/null 2>&1 || MISSING_SYS_DEPS+=("python3-dbus") +python3 -c "import tkinter" >/dev/null 2>&1 || MISSING_SYS_DEPS+=("python3-tk") +pkg-config --exists atspi-2 2>/dev/null || [ -d "/usr/include/at-spi-2.0" ] || command -v at-spi-bus-launcher >/dev/null 2>&1 || MISSING_SYS_DEPS+=("at-spi2-core") if [[ ${#MISSING_SYS_DEPS[@]} -gt 0 ]]; then log_warn "DĂ©pendances systĂšme manquantes dĂ©tectĂ©es : ${MISSING_SYS_DEPS[*]}" # DĂ©tection du gestionnaire de paquets INSTALL_CMD="" if command -v apt-get >/dev/null 2>&1; then - INSTALL_CMD="sudo apt-get update && sudo apt-get install -y xdotool wmctrl spectacle ffmpeg xclip tesseract-ocr" + INSTALL_CMD="sudo apt-get update && sudo apt-get install -y xdotool wmctrl spectacle ffmpeg xclip tesseract-ocr python3-dbus at-spi2-core python3-tk" elif command -v dnf >/dev/null 2>&1; then - INSTALL_CMD="sudo dnf install -y xdotool wmctrl spectacle ffmpeg xclip tesseract" + INSTALL_CMD="sudo dnf install -y xdotool wmctrl spectacle ffmpeg xclip tesseract python3-dbus at-spi2-core python3-tkinter" elif command -v pacman >/dev/null 2>&1; then - INSTALL_CMD="sudo pacman -S --needed xdotool wmctrl spectacle ffmpeg xclip tesseract" + INSTALL_CMD="sudo pacman -S --needed xdotool wmctrl spectacle ffmpeg xclip tesseract python-dbus at-spi2-core tk" fi if [[ -n "$INSTALL_CMD" ]]; then diff --git a/pyproject.toml b/pyproject.toml index aa4c522..69c6080 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ dependencies = [ "numpy>=2.2.6", "python-xlib>=0.33", "pytesseract>=0.3.13", + "evdev>=1.7.0; sys_platform == 'linux'", "playwright>=1.62.0", ] diff --git a/tests/test_package.py b/tests/test_package.py index d72646d..7e64c0b 100644 --- a/tests/test_package.py +++ b/tests/test_package.py @@ -837,3 +837,50 @@ def fake_check_output(cmd, stderr=None, timeout=None): # VĂ©rifier que la boucle s'est interrompue sans parcourir les 10 fenĂȘtres # Chaque fenĂȘtre fait jusqu'Ă  3 appels, donc 10 fenĂȘtres feraient 30 appels de mĂ©tadonnĂ©es + 1 search assert len(calls) < 10 + + +def test_modular_architecture_scaffolding(monkeypatch): + """Valide l'existence et le contrat des sous-packages core, layers et utils.""" + import gui_agent.core as core + import gui_agent.layers as layers + import gui_agent.utils as utils + + assert all(hasattr(core, a) for a in core.__all__) and len(core.__all__) >= 3 + assert all(hasattr(layers, a) for a in layers.__all__) and len(layers.__all__) >= 10 + assert all(hasattr(utils, a) for a in utils.__all__) and len(utils.__all__) >= 6 + assert "30" in core.execute_script("from __future__ import annotations\nprint(10 + 20)")["stdout"] + assert "not_impl" in core.execute_script("print(mcp_core.screen_capture()['status'])")["stdout"] + assert core.execute_script("while True: print('X'*50)", max_output_chars=100)["status"] == "error" + pty_rc, pty_out = core.PTYSession(timeout=2.0).execute(["cat"], "eof_ok") + assert pty_rc == 0 and "eof_ok" in pty_out + pty_rc_ov, pty_out_ov = core.PTYSession(timeout=2.0, max_output_chars=30).execute( + ["sh", "-c", "while true; do echo 'unbounded'; done"] + ) + assert pty_rc_ov == -1 and "dĂ©passĂ©e" in pty_out_ov + pty_rc_bg, pty_out_bg = core.PTYSession(timeout=2.0).execute(["sh", "-c", "sleep 30 & echo $!"]) + assert pty_rc_bg == 0 + bg_pid = int(pty_out_bg.strip().split()[-1]) + import time + + time.sleep(0.1) + import os + + with pytest.raises(OSError): + os.kill(bg_pid, 0) + monkeypatch.setenv("DISPLAY", "") + monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) + with pytest.raises(RuntimeError, match="Aucun serveur graphique"): + utils.coordinates.check_display_env() + monkeypatch.undo() + assert layers.get_app_state()["status"] == "not_implemented" + assert layers.mouse_click_at(100, 200)["status"] == "not_implemented" + monkeypatch.setattr("os.path.exists", lambda p: p != "/proc") + assert layers.process_list()[0]["status"] == "error" + monkeypatch.undo() + assert len(utils.generate_smooth_path(0, 0, 100, 100, steps=10)) == 11 + for f, m, d in ((10.5, 0, 1), (10, 1.5, 1), (10, 0, 1.5)): + assert utils.validate_video_recording_params(None, fps=f, monitor_index=m, duration=d)["status"] == "error" + + emitted: list[list[str]] = [] + monkeypatch.setattr("subprocess.run", lambda c, **kw: (emitted.append(c), type("R", (), {"returncode": 0})())[1]) + assert utils.type_char_human("a", base_delay=0.0) is True and any("type" in c and "a" in c for c in emitted) diff --git a/uv.lock b/uv.lock index 6056a08..6e50ce5 100644 --- a/uv.lock +++ b/uv.lock @@ -533,6 +533,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, ] +[[package]] +name = "evdev" +version = "1.9.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/a5/f5/397b61091120a9ca5001041dd7bf76c385b3bfd67a0e5bcb74b852bd22a4/evdev-1.9.3.tar.gz", hash = "sha256:2c140e01ac8437758fa23fe5c871397412461f42d421aa20241dc8fe8cfccbc9", size = 32723, upload-time = "2026-02-05T21:54:24.987Z" } + +[[package]] +name = "evdev" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version == '3.14.*'", + "python_full_version >= '3.12' and python_full_version < '3.14'", + "python_full_version == '3.11.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/07/eb30593524303d1367f0dc00c1c66582c994dba412a47751e34bad9fa29b/evdev-2.0.0.tar.gz", hash = "sha256:442fb3f4c8dfc9e61e901133c356220c02d663eca8f34722e0cecdd637eba504", size = 33766, upload-time = "2026-08-23T10:31:26.835Z" } + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -645,6 +666,8 @@ name = "gui-agent" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "evdev", version = "1.9.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'linux'" }, + { name = "evdev", version = "2.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'linux'" }, { name = "mcp" }, { name = "mss" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -671,6 +694,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "bandit", marker = "extra == 'dev'", specifier = ">=1.9.4" }, + { name = "evdev", marker = "sys_platform == 'linux'", specifier = ">=1.7.0" }, { name = "mcp", specifier = ">=1.2.0,<3.0.0" }, { name = "mss", specifier = ">=10.2.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=2.3.1" },