OpenSpace/openspace/grounding/backends/gui/transport/connector.py
2026-07-17 11:43:42 +08:00

393 lines
15 KiB
Python

import asyncio
import os
import re
from typing import Any, Dict, Optional
from openspace.grounding.core.transport.connectors import AioHttpConnector
from .actions import build_pyautogui_command, KEYBOARD_KEYS
from openspace.utils.logging import Logger
logger = Logger.get_logger(__name__)
class GUIConnector(AioHttpConnector):
"""
Connector for desktop_env HTTP API.
Provides action execution and observation methods.
"""
def __init__(
self,
vm_ip: str,
server_port: int = 5000,
timeout: int = 90,
retry_times: int = 3,
retry_interval: float = 5.0,
pkgs_prefix: str = "import pyautogui; import time; pyautogui.FAILSAFE = False; {command}",
auth_token: str | None = None,
):
"""
Initialize GUI connector.
Args:
vm_ip: IP address of the VM running desktop_env
server_port: Port of the desktop_env HTTP server
timeout: Request timeout in seconds
retry_times: Number of retries for failed requests
retry_interval: Interval between retries in seconds
pkgs_prefix: Python command prefix for pyautogui setup
"""
base_url = f"http://{vm_ip}:{server_port}"
token = auth_token or os.environ.get("OPENSPACE_LOCAL_SERVER_TOKEN", "").strip()
headers = {"Authorization": f"Bearer {token}"} if token else None
super().__init__(base_url, timeout=timeout, headers=headers)
self.vm_ip = vm_ip
self.server_port = server_port
self.retry_times = retry_times
self.retry_interval = retry_interval
self.pkgs_prefix = pkgs_prefix
self.timeout = timeout
async def _retry_invoke(
self,
operation_name: str,
operation_func,
*args,
**kwargs
):
"""
Execute operation with retry logic.
Args:
operation_name: Name of operation for logging
operation_func: Async function to execute
*args: Positional arguments for operation_func
**kwargs: Keyword arguments for operation_func
Returns:
Operation result
Raises:
Exception: Last exception after all retries fail
"""
last_exc: Exception | None = None
for attempt in range(1, self.retry_times + 1):
try:
result = await operation_func(*args, **kwargs)
logger.debug("%s executed successfully (attempt %d/%d)", operation_name, attempt, self.retry_times)
return result
except asyncio.TimeoutError as exc:
logger.error("%s timed out", operation_name)
raise RuntimeError(f"{operation_name} timed out after {self.timeout} seconds") from exc
except Exception as exc:
last_exc = exc
if attempt == self.retry_times:
break
logger.warning(
"%s failed (attempt %d/%d): %s, retrying in %.1f seconds...",
operation_name, attempt, self.retry_times, exc, self.retry_interval
)
await asyncio.sleep(self.retry_interval)
error_msg = f"{operation_name} failed after {self.retry_times} retries"
logger.error(error_msg)
raise last_exc or RuntimeError(error_msg)
@staticmethod
def _is_valid_image_response(content_type: str, data: Optional[bytes]) -> bool:
"""Validate image response using magic bytes."""
if not isinstance(data, (bytes, bytearray)) or not data:
return False
# PNG magic
if len(data) >= 8 and data[:8] == b"\x89PNG\r\n\x1a\n":
return True
# JPEG magic
if len(data) >= 3 and data[:3] == b"\xff\xd8\xff":
return True
# Fallback to content-type
if content_type and ("image/png" in content_type or "image/jpeg" in content_type):
return True
return False
@staticmethod
def _fix_pyautogui_less_than_bug(command: str) -> str:
"""
Fix PyAutoGUI '<' character bug by converting it to hotkey("shift", ',') calls.
This fixes the known PyAutoGUI issue where typing '<' produces '>' instead.
References:
- https://github.com/asweigart/pyautogui/issues/198
- https://github.com/xlang-ai/OSWorld/issues/257
Args:
command (str): The original pyautogui command
Returns:
str: The fixed command with '<' characters handled properly
"""
# Pattern to match press('<') or press('\u003c') calls
press_pattern = r'pyautogui\.press\(["\'](?:<|\\u003c)["\']\)'
# Handle press('<') calls
def replace_press_less_than(match):
return 'pyautogui.hotkey("shift", ",")'
# First handle press('<') calls
command = re.sub(press_pattern, replace_press_less_than, command)
# Pattern to match typewrite calls with quoted strings
typewrite_pattern = r'pyautogui\.typewrite\((["\'])(.*?)\1\)'
# Then handle typewrite calls
def process_typewrite_match(match):
quote_char = match.group(1)
content = match.group(2)
# Preprocess: Try to decode Unicode escapes like \u003c to actual '<'
# This handles cases where '<' is represented as escaped Unicode
try:
# Attempt to decode unicode escapes
decoded_content = content.encode('utf-8').decode('unicode_escape')
content = decoded_content
except UnicodeDecodeError:
# If decoding fails, proceed with original content to avoid breaking existing logic
pass # Graceful degradation - fall back to original content if decoding fails
# Check if content contains '<'
if '<' not in content:
return match.group(0)
# Split by '<' and rebuild
parts = content.split('<')
result_parts = []
for i, part in enumerate(parts):
if i == 0:
# First part
if part:
result_parts.append(f"pyautogui.typewrite({quote_char}{part}{quote_char})")
else:
# Add hotkey for '<' and then typewrite for the rest
result_parts.append('pyautogui.hotkey("shift", ",")')
if part:
result_parts.append(f"pyautogui.typewrite({quote_char}{part}{quote_char})")
return '; '.join(result_parts)
command = re.sub(typewrite_pattern, process_typewrite_match, command)
return command
async def get_screen_size(self) -> Optional[tuple[int, int]]:
"""
Get actual screen size from desktop environment using pyautogui.
Returns:
(width, height) tuple, or None on failure
"""
try:
command = "print(pyautogui.size())"
result = await self.execute_python_command(command)
if result and result.get("status") == "success":
output = result.get("output", "")
# Parse output like "Size(width=2880, height=1800)"
import re
match = re.search(r'width=(\d+).*height=(\d+)', output)
if match:
width = int(match.group(1))
height = int(match.group(2))
logger.info(f"Detected screen size: {width}x{height}")
return (width, height)
logger.warning(f"Failed to detect screen size, output: {result}")
return None
except Exception as e:
logger.error(f"Failed to get screen size: {e}")
return None
async def get_screenshot(self) -> Optional[bytes]:
"""
Get screenshot from desktop environment.
Returns:
Screenshot image bytes (PNG/JPEG), or None on failure
"""
try:
async def _get():
response = await self._request("GET", "/screenshot", timeout=10)
if response.status == 200:
content_type = response.headers.get("Content-Type", "")
content = await response.read()
if self._is_valid_image_response(content_type, content):
return content
else:
raise ValueError("Invalid screenshot format")
else:
raise RuntimeError(f"HTTP {response.status}")
return await self._retry_invoke("get_screenshot", _get)
except Exception as e:
logger.error(f"Failed to get screenshot: {e}")
return None
async def execute_python_command(self, command: str) -> Optional[Dict[str, Any]]:
"""
Execute a Python command on desktop environment.
Used for pyautogui commands.
Args:
command: Python command to execute
Returns:
Response dict with execution result, or None on failure
"""
try:
# Apply '<' character fix for PyAutoGUI bug
fixed_command = self._fix_pyautogui_less_than_bug(command)
command_list = ["python", "-c", self.pkgs_prefix.format(command=fixed_command)]
payload = {"command": command_list, "shell": False}
async def _execute():
return await self.post_json("/execute", payload)
return await self._retry_invoke("execute_python_command", _execute)
except Exception as e:
logger.error(f"Failed to execute command: {e}")
return None
async def execute_action(self, action_type: str, parameters: Dict[str, Any] = None) -> Dict[str, Any]:
"""
Execute a desktop action.
This is the main method for action space execution.
Args:
action_type: Action type (e.g., 'CLICK', 'TYPING')
parameters: Action parameters
Returns:
Result dict with execution status
"""
parameters = parameters or {}
# Handle control actions
if action_type in ['WAIT', 'FAIL', 'DONE']:
return {
"status": "success",
"action_type": action_type,
"message": f"Control action {action_type} acknowledged"
}
# Validate keyboard keys
if action_type in ['PRESS', 'KEY_DOWN', 'KEY_UP']:
key = parameters.get('key')
if key and key not in KEYBOARD_KEYS:
return {
"status": "error",
"action_type": action_type,
"error": f"Invalid key: {key}. Must be in supported keyboard keys."
}
if action_type == 'HOTKEY':
keys = parameters.get('keys', [])
invalid_keys = [k for k in keys if k not in KEYBOARD_KEYS]
if invalid_keys:
return {
"status": "error",
"action_type": action_type,
"error": f"Invalid keys: {invalid_keys}"
}
# Build pyautogui command
command = build_pyautogui_command(action_type, parameters)
if command is None:
return {
"status": "error",
"action_type": action_type,
"error": f"Unsupported action type: {action_type}"
}
# Execute command
result = await self.execute_python_command(command)
if result:
return {
"status": "success",
"action_type": action_type,
"parameters": parameters,
"result": result
}
else:
return {
"status": "error",
"action_type": action_type,
"parameters": parameters,
"error": "Command execution failed"
}
async def get_accessibility_tree(self, max_depth: int = 5) -> Optional[Dict[str, Any]]:
"""
Get accessibility tree from desktop environment.
Args:
max_depth: Maximum depth of accessibility tree traversal
Returns:
Accessibility tree as dict, or None on failure
"""
try:
async def _get():
response = await self._request("GET", "/accessibility", timeout=10)
if response.status == 200:
data = await response.json()
return data.get("AT")
else:
raise RuntimeError(f"HTTP {response.status}")
return await self._retry_invoke("get_accessibility_tree", _get)
except Exception as e:
logger.error(f"Failed to get accessibility tree: {e}")
return None
async def get_cursor_position(self) -> Optional[tuple[int, int]]:
"""
Get current mouse cursor position.
Useful for GUI debugging and relative positioning.
Returns:
(x, y) tuple, or None on failure
"""
try:
async def _get():
result = await self.get_json("/cursor_position")
return (result.get("x"), result.get("y"))
return await self._retry_invoke("get_cursor_position", _get)
except Exception as e:
logger.error(f"Failed to get cursor position: {e}")
return None
async def invoke(self, name: str, params: dict[str, Any]) -> Any:
"""
Unified RPC entry for operations.
Required by BaseConnector.
Args:
name: Operation name (action_type or observation method)
params: Operation parameters
Returns:
Operation result
"""
# Handle observation methods
if name == "screenshot":
return await self.get_screenshot()
elif name == "accessibility_tree":
max_depth = params.get("max_depth", 5) if params else 5
return await self.get_accessibility_tree(max_depth)
elif name == "cursor_position":
return await self.get_cursor_position()
else:
# Treat as action
return await self.execute_action(name.upper(), params or {})