chore: import upstream snapshot with attribution
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:03:19 +08:00
commit 91e75e620b
3227 changed files with 1307078 additions and 0 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,431 @@
import asyncio
import logging
import sys
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional, Tuple
logger = logging.getLogger(__name__)
class BaseAccessibilityHandler(ABC):
"""Abstract base class for OS-specific accessibility handlers."""
@abstractmethod
async def get_accessibility_tree(self) -> Dict[str, Any]:
"""Get the accessibility tree of the current window."""
pass
@abstractmethod
async def find_element(
self, role: Optional[str] = None, title: Optional[str] = None, value: Optional[str] = None
) -> Dict[str, Any]:
"""Find an element in the accessibility tree by criteria."""
pass
class BaseFileHandler(ABC):
"""Abstract base class for OS-specific file handlers."""
@abstractmethod
async def file_exists(self, path: str) -> Dict[str, Any]:
"""Check if a file exists at the specified path."""
pass
@abstractmethod
async def directory_exists(self, path: str) -> Dict[str, Any]:
"""Check if a directory exists at the specified path."""
pass
@abstractmethod
async def list_dir(self, path: str) -> Dict[str, Any]:
"""List the contents of a directory."""
pass
@abstractmethod
async def read_text(self, path: str) -> Dict[str, Any]:
"""Read the text contents of a file."""
pass
@abstractmethod
async def write_text(self, path: str, content: str) -> Dict[str, Any]:
"""Write text content to a file."""
pass
@abstractmethod
async def write_bytes(self, path: str, content_b64: str) -> Dict[str, Any]:
"""Write binary content to a file. Sent over the websocket as a base64 string."""
pass
@abstractmethod
async def delete_file(self, path: str) -> Dict[str, Any]:
"""Delete a file."""
pass
@abstractmethod
async def create_dir(self, path: str) -> Dict[str, Any]:
"""Create a directory."""
pass
@abstractmethod
async def delete_dir(self, path: str) -> Dict[str, Any]:
"""Delete a directory."""
pass
@abstractmethod
async def read_bytes(
self, path: str, offset: int = 0, length: Optional[int] = None
) -> Dict[str, Any]:
"""Read the binary contents of a file. Sent over the websocket as a base64 string.
Args:
path: Path to the file
offset: Byte offset to start reading from (default: 0)
length: Number of bytes to read (default: None for entire file)
"""
pass
@abstractmethod
async def get_file_size(self, path: str) -> Dict[str, Any]:
"""Get the size of a file in bytes."""
pass
class BaseDesktopHandler(ABC):
"""Abstract base class for OS-specific desktop handlers.
Categories:
- Wallpaper Actions: Methods for wallpaper operations
- Desktop shortcut actions: Methods for managing desktop shortcuts
"""
# Wallpaper Actions
@abstractmethod
async def get_desktop_environment(self) -> Dict[str, Any]:
"""Get the current desktop environment name."""
pass
@abstractmethod
async def set_wallpaper(self, path: str) -> Dict[str, Any]:
"""Set the desktop wallpaper to the file at path."""
pass
class BaseWindowHandler(ABC):
"""Abstract class for OS-specific window management handlers.
Categories:
- Window Management: Methods for application/window control
"""
# Window Management
@abstractmethod
async def open(self, target: str) -> Dict[str, Any]:
"""Open a file or URL with the default application."""
pass
@abstractmethod
async def launch(self, app: str, args: Optional[List[str]] = None) -> Dict[str, Any]:
"""Launch an application with optional arguments."""
pass
@abstractmethod
async def get_current_window_id(self) -> Dict[str, Any]:
"""Get the currently active window ID."""
pass
@abstractmethod
async def get_application_windows(self, app: str) -> Dict[str, Any]:
"""Get windows belonging to an application (by name or bundle)."""
pass
@abstractmethod
async def get_window_name(self, window_id: str) -> Dict[str, Any]:
"""Get the title/name of a window by ID."""
pass
@abstractmethod
async def get_window_size(self, window_id: str | int) -> Dict[str, Any]:
"""Get the size of a window by ID as {width, height}."""
pass
@abstractmethod
async def activate_window(self, window_id: str | int) -> Dict[str, Any]:
"""Bring a window to the foreground by ID."""
pass
@abstractmethod
async def close_window(self, window_id: str | int) -> Dict[str, Any]:
"""Close a window by ID."""
pass
@abstractmethod
async def get_window_position(self, window_id: str | int) -> Dict[str, Any]:
"""Get the top-left position of a window as {x, y}."""
pass
@abstractmethod
async def set_window_size(
self, window_id: str | int, width: int, height: int
) -> Dict[str, Any]:
"""Set the size of a window by ID."""
pass
@abstractmethod
async def set_window_position(self, window_id: str | int, x: int, y: int) -> Dict[str, Any]:
"""Set the position of a window by ID."""
pass
@abstractmethod
async def maximize_window(self, window_id: str | int) -> Dict[str, Any]:
"""Maximize a window by ID."""
pass
@abstractmethod
async def minimize_window(self, window_id: str | int) -> Dict[str, Any]:
"""Minimize a window by ID."""
pass
_SUPPORTED_FORMATS = {"png", "jpeg"}
_FORMAT_ALIASES = {"jpg": "jpeg"}
def normalize_screenshot_format(format: str, quality: int) -> tuple[str, int]:
"""Normalize and validate screenshot format/quality.
Returns ``(normalized_format, clamped_quality)`` or raises ``ValueError``.
- Lowercases the format string.
- Maps "jpg""jpeg".
- Rejects anything not in ``{"png", "jpeg"}``.
- Clamps JPEG quality to 195 (silently caps values above 95).
"""
fmt = _FORMAT_ALIASES.get(format.lower(), format.lower())
if fmt not in _SUPPORTED_FORMATS:
raise ValueError(
f"Unsupported screenshot format {format!r}. " f"Supported: {sorted(_SUPPORTED_FORMATS)}"
)
if fmt == "jpeg":
quality = max(1, min(95, quality))
return fmt, quality
class BaseAutomationHandler(ABC):
"""Abstract base class for OS-specific automation handlers.
Categories:
- Mouse Actions: Methods for mouse control
- Keyboard Actions: Methods for keyboard input
- Scrolling Actions: Methods for scrolling
- Screen Actions: Methods for screen interaction
- Clipboard Actions: Methods for clipboard operations
"""
# Mouse Actions
@abstractmethod
async def mouse_down(
self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left"
) -> Dict[str, Any]:
"""Perform a mouse down at the current or specified position."""
pass
@abstractmethod
async def mouse_up(
self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left"
) -> Dict[str, Any]:
"""Perform a mouse up at the current or specified position."""
pass
@abstractmethod
async def left_click(self, x: Optional[int] = None, y: Optional[int] = None) -> Dict[str, Any]:
"""Perform a left click at the current or specified position."""
pass
@abstractmethod
async def right_click(self, x: Optional[int] = None, y: Optional[int] = None) -> Dict[str, Any]:
"""Perform a right click at the current or specified position."""
pass
@abstractmethod
async def middle_click(
self, x: Optional[int] = None, y: Optional[int] = None
) -> Dict[str, Any]:
"""Perform a middle click at the current or specified position."""
pass
@abstractmethod
async def double_click(
self, x: Optional[int] = None, y: Optional[int] = None
) -> Dict[str, Any]:
"""Perform a double click at the current or specified position."""
pass
@abstractmethod
async def move_cursor(self, x: int, y: int) -> Dict[str, Any]:
"""Move the cursor to the specified position."""
pass
@abstractmethod
async def drag_to(
self, x: int, y: int, button: str = "left", duration: float = 0.5
) -> Dict[str, Any]:
"""Drag the cursor from current position to specified coordinates.
Args:
x: The x coordinate to drag to
y: The y coordinate to drag to
button: The mouse button to use ('left', 'middle', 'right')
duration: How long the drag should take in seconds
"""
pass
@abstractmethod
async def drag(
self, path: List[Tuple[int, int]], button: str = "left", duration: float = 0.5
) -> Dict[str, Any]:
"""Drag the cursor from current position to specified coordinates.
Args:
path: A list of tuples of x and y coordinates to drag to
button: The mouse button to use ('left', 'middle', 'right')
duration: How long the drag should take in seconds
"""
pass
# Keyboard Actions
@abstractmethod
async def key_down(self, key: str) -> Dict[str, Any]:
"""Press and hold the specified key."""
pass
@abstractmethod
async def key_up(self, key: str) -> Dict[str, Any]:
"""Release the specified key."""
pass
@abstractmethod
async def type_text(self, text: str) -> Dict[str, Any]:
"""Type the specified text."""
pass
@abstractmethod
async def press_key(self, key: str) -> Dict[str, Any]:
"""Press the specified key."""
pass
@abstractmethod
async def hotkey(self, keys: List[str]) -> Dict[str, Any]:
"""Press a combination of keys together."""
pass
# Scrolling Actions
@abstractmethod
async def scroll(self, x: int, y: int) -> Dict[str, Any]:
"""Scroll the specified amount."""
pass
@abstractmethod
async def scroll_down(self, clicks: int = 1) -> Dict[str, Any]:
"""Scroll down by the specified number of clicks."""
pass
@abstractmethod
async def scroll_up(self, clicks: int = 1) -> Dict[str, Any]:
"""Scroll up by the specified number of clicks."""
pass
# Screen Actions
@abstractmethod
async def screenshot(self, format: str = "png", quality: int = 95) -> Dict[str, Any]:
"""Take a screenshot and return base64 encoded image data.
Args:
format: Image format - "png" (lossless, default) or "jpeg" (lossy, smaller).
quality: JPEG quality 1-95, ignored for PNG.
"""
pass
@abstractmethod
async def get_screen_size(self) -> Dict[str, Any]:
"""Get the screen size of the VM."""
pass
@abstractmethod
async def get_cursor_position(self) -> Dict[str, Any]:
"""Get the current cursor position."""
pass
# Clipboard Actions
async def copy_to_clipboard(self) -> Dict[str, Any]:
"""Get the current clipboard content using pyperclip."""
try:
import pyperclip
content = pyperclip.paste()
return {"success": True, "content": content}
except Exception as e:
return {"success": False, "error": str(e)}
async def set_clipboard(self, text: str) -> Dict[str, Any]:
"""Set the clipboard content using pyperclip."""
try:
import pyperclip
pyperclip.copy(text)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
# Command Execution
async def run_command(self, command: str, timeout: Optional[float] = None) -> Dict[str, Any]:
"""Run a shell command locally and return its output.
When IS_CUA_ANDROID env var is set, routes the command through
``adb shell`` so execution runs inside the Android emulator.
Args:
command: The shell command to run.
timeout: Optional timeout in seconds. When ``None`` (default),
the command runs until completion with no upper bound. SDK
callers set this via ``sb.shell.run(cmd, timeout=...)``
(see ``cua_sandbox.interfaces.shell.Shell.run``). On
expiry the subprocess is killed and the result is
``{"success": False, "stderr": "Command timed out after <t>s",
"return_code": -1}``.
"""
import os
try:
if os.environ.get("IS_CUA_ANDROID") == "true":
process = await asyncio.create_subprocess_exec(
"adb",
"shell",
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
else:
process = await asyncio.create_subprocess_shell(
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
if timeout is None:
stdout, stderr = await process.communicate()
else:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
except asyncio.TimeoutError:
process.kill()
return {
"success": False,
"stdout": "",
"stderr": f"Command timed out after {timeout}s",
"return_code": -1,
}
return {
"success": True,
"stdout": stdout.decode() if stdout else "",
"stderr": stderr.decode() if stderr else "",
"return_code": process.returncode,
}
except Exception as e:
return {"success": False, "error": str(e)}
@@ -0,0 +1,120 @@
import logging
import os
from typing import Tuple
from computer_server.diorama.base import BaseDioramaHandler
from ..utils.helpers import get_current_os
from .base import (
BaseAccessibilityHandler,
BaseAutomationHandler,
BaseDesktopHandler,
BaseFileHandler,
BaseWindowHandler,
)
logger = logging.getLogger(__name__)
OS_TYPE = get_current_os()
if OS_TYPE == "android":
from .android import (
AndroidAccessibilityHandler,
AndroidAutomationHandler,
AndroidDesktopHandler,
AndroidFileHandler,
AndroidWindowHandler,
)
elif OS_TYPE == "darwin":
from computer_server.diorama.macos import MacOSDioramaHandler
from .macos import MacOSAccessibilityHandler, MacOSAutomationHandler
elif OS_TYPE == "linux":
from .linux import LinuxAccessibilityHandler, LinuxAutomationHandler
elif OS_TYPE == "windows":
from .windows import WindowsAccessibilityHandler, WindowsAutomationHandler
from .generic import GenericDesktopHandler, GenericFileHandler, GenericWindowHandler
class HandlerFactory:
"""Factory for creating OS-specific handlers."""
@staticmethod
def create_handlers() -> Tuple[
BaseAccessibilityHandler,
BaseAutomationHandler,
BaseDioramaHandler,
BaseFileHandler,
BaseDesktopHandler,
BaseWindowHandler,
]:
"""Create and return appropriate handlers for the current OS.
Returns:
Tuple[BaseAccessibilityHandler, BaseAutomationHandler, BaseDioramaHandler, BaseFileHandler]: A tuple containing
the appropriate accessibility, automation, diorama, and file handlers for the current OS.
Raises:
NotImplementedError: If the current OS is not supported
RuntimeError: If unable to determine the current OS
"""
backend = os.environ.get("CUA_BACKEND", "native")
vnc_host = os.environ.get("CUA_VNC_HOST")
if backend == "vnc" or vnc_host:
if not vnc_host:
raise RuntimeError(
"CUA_VNC_HOST must be set when using VNC backend "
"(--backend=vnc requires --vnc-host)"
)
from .vnc import VNCAccessibilityHandler, VNCAutomationHandler
vnc_port = int(os.environ.get("CUA_VNC_PORT", "5900"))
vnc_password = os.environ.get("CUA_VNC_PASSWORD", "")
logger.info(f"Using VNC backend → {vnc_host}:{vnc_port}")
return (
VNCAccessibilityHandler(),
VNCAutomationHandler(host=vnc_host, port=vnc_port, password=vnc_password),
BaseDioramaHandler(),
GenericFileHandler(),
GenericDesktopHandler(),
GenericWindowHandler(),
)
elif OS_TYPE == "android":
return (
AndroidAccessibilityHandler(),
AndroidAutomationHandler(),
BaseDioramaHandler(),
AndroidFileHandler(),
AndroidDesktopHandler(),
AndroidWindowHandler(),
)
elif OS_TYPE == "darwin":
return (
MacOSAccessibilityHandler(),
MacOSAutomationHandler(),
MacOSDioramaHandler(),
GenericFileHandler(),
GenericDesktopHandler(),
GenericWindowHandler(),
)
elif OS_TYPE == "linux":
return (
LinuxAccessibilityHandler(),
LinuxAutomationHandler(),
BaseDioramaHandler(),
GenericFileHandler(),
GenericDesktopHandler(),
GenericWindowHandler(),
)
elif OS_TYPE == "windows":
return (
WindowsAccessibilityHandler(),
WindowsAutomationHandler(),
BaseDioramaHandler(),
GenericFileHandler(),
GenericDesktopHandler(),
GenericWindowHandler(),
)
else:
raise NotImplementedError(f"OS '{OS_TYPE}' is not supported")
@@ -0,0 +1,474 @@
"""
Generic handlers for all OSes.
Includes:
- DesktopHandler
- FileHandler
"""
import base64
import os
import platform
import subprocess
import webbrowser
from pathlib import Path
from typing import Any, Dict, Optional
from ..utils import wallpaper
from .base import BaseDesktopHandler, BaseFileHandler, BaseWindowHandler
try:
import pywinctl as pwc
except Exception: # pragma: no cover
pwc = None # type: ignore
def resolve_path(path: str) -> Path:
"""Resolve a path to its absolute path. Expand ~ to the user's home directory.
Args:
path: The file or directory path to resolve
Returns:
Path: The resolved absolute path
"""
return Path(path).expanduser().resolve()
# ===== Cross-platform Desktop command handlers =====
class GenericDesktopHandler(BaseDesktopHandler):
"""
Generic desktop handler providing desktop-related operations.
Implements:
- get_desktop_environment: detect current desktop environment
- set_wallpaper: set desktop wallpaper path
"""
async def get_desktop_environment(self) -> Dict[str, Any]:
"""
Get the current desktop environment.
Returns:
Dict containing 'success' boolean and either 'environment' string or 'error' string
"""
try:
env = wallpaper.get_desktop_environment()
return {"success": True, "environment": env}
except Exception as e:
return {"success": False, "error": str(e)}
async def set_wallpaper(self, path: str) -> Dict[str, Any]:
"""
Set the desktop wallpaper to the specified path.
Args:
path: The file path to set as wallpaper
Returns:
Dict containing 'success' boolean and optionally 'error' string
"""
try:
file_path = resolve_path(path)
ok = wallpaper.set_wallpaper(str(file_path))
return {"success": bool(ok)}
except Exception as e:
return {"success": False, "error": str(e)}
# ===== Cross-platform window control command handlers =====
class GenericWindowHandler(BaseWindowHandler):
"""
Cross-platform window management using pywinctl where possible.
"""
async def open(self, target: str) -> Dict[str, Any]:
try:
if target.startswith("http://") or target.startswith("https://"):
ok = webbrowser.open(target)
return {"success": bool(ok)}
path = str(resolve_path(target))
sys = platform.system().lower()
if sys == "darwin":
subprocess.Popen(["open", path])
elif sys == "linux":
subprocess.Popen(["xdg-open", path])
elif sys == "windows":
os.startfile(path) # type: ignore[attr-defined]
else:
return {"success": False, "error": f"Unsupported OS: {sys}"}
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def launch(self, app: str, args: Optional[list[str]] = None) -> Dict[str, Any]:
try:
if args:
proc = subprocess.Popen([app, *args])
else:
# allow shell command like "libreoffice --writer"
proc = subprocess.Popen(app, shell=True)
return {"success": True, "pid": proc.pid}
except Exception as e:
return {"success": False, "error": str(e)}
def _get_window_by_id(self, window_id: int | str) -> Optional[Any]:
if pwc is None:
raise RuntimeError("pywinctl not available")
# Find by native handle among Window objects; getAllWindowsDict keys are titles
try:
for w in pwc.getAllWindows():
if str(w.getHandle()) == str(window_id):
return w
return None
except Exception:
return None
async def get_current_window_id(self) -> Dict[str, Any]:
try:
if pwc is None:
return {"success": False, "error": "pywinctl not available"}
win = pwc.getActiveWindow()
if not win:
return {"success": False, "error": "No active window"}
return {"success": True, "window_id": win.getHandle()}
except Exception as e:
return {"success": False, "error": str(e)}
async def get_application_windows(self, app: str) -> Dict[str, Any]:
try:
if pwc is None:
return {"success": False, "error": "pywinctl not available"}
wins = pwc.getWindowsWithTitle(app, condition=pwc.Re.CONTAINS, flags=pwc.Re.IGNORECASE)
ids = [w.getHandle() for w in wins]
return {"success": True, "windows": ids}
except Exception as e:
return {"success": False, "error": str(e)}
async def get_window_name(self, window_id: int | str) -> Dict[str, Any]:
try:
if pwc is None:
return {"success": False, "error": "pywinctl not available"}
w = self._get_window_by_id(window_id)
if not w:
return {"success": False, "error": "Window not found"}
return {"success": True, "name": w.title}
except Exception as e:
return {"success": False, "error": str(e)}
async def get_window_size(self, window_id: int | str) -> Dict[str, Any]:
try:
if pwc is None:
return {"success": False, "error": "pywinctl not available"}
w = self._get_window_by_id(window_id)
if not w:
return {"success": False, "error": "Window not found"}
width, height = w.size
return {"success": True, "width": int(width), "height": int(height)}
except Exception as e:
return {"success": False, "error": str(e)}
async def get_window_position(self, window_id: int | str) -> Dict[str, Any]:
try:
if pwc is None:
return {"success": False, "error": "pywinctl not available"}
w = self._get_window_by_id(window_id)
if not w:
return {"success": False, "error": "Window not found"}
x, y = w.position
return {"success": True, "x": int(x), "y": int(y)}
except Exception as e:
return {"success": False, "error": str(e)}
async def set_window_size(
self, window_id: int | str, width: int, height: int
) -> Dict[str, Any]:
try:
if pwc is None:
return {"success": False, "error": "pywinctl not available"}
w = self._get_window_by_id(window_id)
if not w:
return {"success": False, "error": "Window not found"}
ok = w.resizeTo(int(width), int(height))
return {"success": bool(ok)}
except Exception as e:
return {"success": False, "error": str(e)}
async def set_window_position(self, window_id: int | str, x: int, y: int) -> Dict[str, Any]:
try:
if pwc is None:
return {"success": False, "error": "pywinctl not available"}
w = self._get_window_by_id(window_id)
if not w:
return {"success": False, "error": "Window not found"}
ok = w.moveTo(int(x), int(y))
return {"success": bool(ok)}
except Exception as e:
return {"success": False, "error": str(e)}
async def maximize_window(self, window_id: int | str) -> Dict[str, Any]:
try:
if pwc is None:
return {"success": False, "error": "pywinctl not available"}
w = self._get_window_by_id(window_id)
if not w:
return {"success": False, "error": "Window not found"}
ok = w.maximize()
return {"success": bool(ok)}
except Exception as e:
return {"success": False, "error": str(e)}
async def minimize_window(self, window_id: int | str) -> Dict[str, Any]:
try:
if pwc is None:
return {"success": False, "error": "pywinctl not available"}
w = self._get_window_by_id(window_id)
if not w:
return {"success": False, "error": "Window not found"}
ok = w.minimize()
return {"success": bool(ok)}
except Exception as e:
return {"success": False, "error": str(e)}
async def activate_window(self, window_id: int | str) -> Dict[str, Any]:
try:
if pwc is None:
return {"success": False, "error": "pywinctl not available"}
w = self._get_window_by_id(window_id)
if not w:
return {"success": False, "error": "Window not found"}
ok = w.activate()
return {"success": bool(ok)}
except Exception as e:
return {"success": False, "error": str(e)}
async def close_window(self, window_id: int | str) -> Dict[str, Any]:
try:
if pwc is None:
return {"success": False, "error": "pywinctl not available"}
w = self._get_window_by_id(window_id)
if not w:
return {"success": False, "error": "Window not found"}
ok = w.close()
return {"success": bool(ok)}
except Exception as e:
return {"success": False, "error": str(e)}
# ===== Cross-platform file system command handlers =====
class GenericFileHandler(BaseFileHandler):
"""
Generic file handler that provides file system operations for all operating systems.
This class implements the BaseFileHandler interface and provides methods for
file and directory operations including reading, writing, creating, and deleting
files and directories.
"""
async def file_exists(self, path: str) -> Dict[str, Any]:
"""
Check if a file exists at the specified path.
Args:
path: The file path to check
Returns:
Dict containing 'success' boolean and either 'exists' boolean or 'error' string
"""
try:
return {"success": True, "exists": resolve_path(path).is_file()}
except Exception as e:
return {"success": False, "error": str(e)}
async def directory_exists(self, path: str) -> Dict[str, Any]:
"""
Check if a directory exists at the specified path.
Args:
path: The directory path to check
Returns:
Dict containing 'success' boolean and either 'exists' boolean or 'error' string
"""
try:
return {"success": True, "exists": resolve_path(path).is_dir()}
except Exception as e:
return {"success": False, "error": str(e)}
async def list_dir(self, path: str) -> Dict[str, Any]:
"""
List all files and directories in the specified directory.
Args:
path: The directory path to list
Returns:
Dict containing 'success' boolean and either 'files' list of names or 'error' string
"""
try:
return {
"success": True,
"files": [
p.name for p in resolve_path(path).iterdir() if p.is_file() or p.is_dir()
],
}
except Exception as e:
return {"success": False, "error": str(e)}
async def read_text(self, path: str) -> Dict[str, Any]:
"""
Read the contents of a text file.
Args:
path: The file path to read from
Returns:
Dict containing 'success' boolean and either 'content' string or 'error' string
"""
try:
return {"success": True, "content": resolve_path(path).read_text()}
except Exception as e:
return {"success": False, "error": str(e)}
async def write_text(self, path: str, content: str) -> Dict[str, Any]:
"""
Write text content to a file.
Args:
path: The file path to write to
content: The text content to write
Returns:
Dict containing 'success' boolean and optionally 'error' string
"""
try:
resolve_path(path).write_text(content)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def write_bytes(
self, path: str, content_b64: str, append: bool = False
) -> Dict[str, Any]:
"""
Write binary content to a file from base64 encoded string.
Args:
path: The file path to write to
content_b64: Base64 encoded binary content
append: If True, append to existing file; if False, overwrite
Returns:
Dict containing 'success' boolean and optionally 'error' string
"""
try:
mode = "ab" if append else "wb"
with open(resolve_path(path), mode) as f:
f.write(base64.b64decode(content_b64))
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def read_bytes(
self, path: str, offset: int = 0, length: Optional[int] = None
) -> Dict[str, Any]:
"""
Read binary content from a file and return as base64 encoded string.
Args:
path: The file path to read from
offset: Byte offset to start reading from
length: Number of bytes to read; if None, read entire file from offset
Returns:
Dict containing 'success' boolean and either 'content_b64' string or 'error' string
"""
try:
file_path = resolve_path(path)
with open(file_path, "rb") as f:
if offset > 0:
f.seek(offset)
if length is not None:
content = f.read(length)
else:
content = f.read()
return {"success": True, "content_b64": base64.b64encode(content).decode("utf-8")}
except Exception as e:
return {"success": False, "error": str(e)}
async def get_file_size(self, path: str) -> Dict[str, Any]:
"""
Get the size of a file in bytes.
Args:
path: The file path to get size for
Returns:
Dict containing 'success' boolean and either 'size' integer or 'error' string
"""
try:
file_path = resolve_path(path)
size = file_path.stat().st_size
return {"success": True, "size": size}
except Exception as e:
return {"success": False, "error": str(e)}
async def delete_file(self, path: str) -> Dict[str, Any]:
"""
Delete a file at the specified path.
Args:
path: The file path to delete
Returns:
Dict containing 'success' boolean and optionally 'error' string
"""
try:
resolve_path(path).unlink()
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def create_dir(self, path: str) -> Dict[str, Any]:
"""
Create a directory at the specified path.
Creates parent directories if they don't exist and doesn't raise an error
if the directory already exists.
Args:
path: The directory path to create
Returns:
Dict containing 'success' boolean and optionally 'error' string
"""
try:
resolve_path(path).mkdir(parents=True, exist_ok=True)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def delete_dir(self, path: str) -> Dict[str, Any]:
"""
Delete an empty directory at the specified path.
Args:
path: The directory path to delete
Returns:
Dict containing 'success' boolean and optionally 'error' string
"""
try:
resolve_path(path).rmdir()
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@@ -0,0 +1,553 @@
"""
Linux implementation of automation and accessibility handlers.
This implementation uses pynput for GUI automation. For screenshots and screen size,
it uses PIL's ImageGrab (works with X11/Xvfb) and provides simulated fallbacks where needed.
To use GUI automation in a headless environment:
1. Install Xvfb: sudo apt-get install xvfb
2. Run with virtual display: xvfb-run python -m computer_server
"""
import asyncio
import base64
import json
import logging
import os
import subprocess
from io import BytesIO
from typing import Any, Dict, List, Optional, Tuple
from PIL import Image, ImageGrab
# Configure logger
logger = logging.getLogger(__name__)
from pynput.keyboard import Controller as KeyboardController
from pynput.keyboard import Key
from pynput.mouse import Button
from pynput.mouse import Controller as MouseController
from .base import (
BaseAccessibilityHandler,
BaseAutomationHandler,
normalize_screenshot_format,
)
class LinuxAccessibilityHandler(BaseAccessibilityHandler):
"""Linux implementation of accessibility handler."""
async def get_accessibility_tree(self) -> Dict[str, Any]:
"""Get the accessibility tree of the current window.
Returns:
Dict[str, Any]: A dictionary containing success status and a simulated tree structure
since Linux doesn't have equivalent accessibility API like macOS.
"""
# Linux doesn't have equivalent accessibility API like macOS
# Return a minimal dummy tree
logger.info(
"Getting accessibility tree (simulated, no accessibility API available on Linux)"
)
return {
"success": True,
"tree": {
"role": "Window",
"title": "Linux Window",
"position": {"x": 0, "y": 0},
"size": {"width": 1920, "height": 1080},
"children": [],
},
}
async def find_element(
self, role: Optional[str] = None, title: Optional[str] = None, value: Optional[str] = None
) -> Dict[str, Any]:
"""Find an element in the accessibility tree by criteria.
Args:
role: The role of the element to find.
title: The title of the element to find.
value: The value of the element to find.
Returns:
Dict[str, Any]: A dictionary indicating that element search is not supported on Linux.
"""
logger.info(
f"Finding element with role={role}, title={title}, value={value} (not supported on Linux)"
)
return {"success": False, "message": "Element search not supported on Linux"}
def get_cursor_position(self) -> Tuple[int, int]:
"""Get the current cursor position.
Returns:
Tuple[int, int]: The x and y coordinates of the cursor position.
Returns (0, 0) if cursor position cannot be determined.
"""
try:
# Use pynput mouse controller
from pynput.mouse import Controller as MouseController
m = MouseController()
x, y = m.position
return int(x), int(y)
except Exception as e:
logger.warning(f"Failed to get cursor position: {e}")
logger.info("Getting cursor position (simulated)")
return 0, 0
def get_screen_size(self) -> Tuple[int, int]:
"""Get the screen size.
Returns:
Tuple[int, int]: The width and height of the screen in pixels.
Returns (1920, 1080) if screen size cannot be determined.
"""
try:
img = ImageGrab.grab()
return img.width, img.height
except Exception as e:
logger.warning(f"Failed to get screen size via ImageGrab: {e}")
logger.info("Getting screen size (simulated)")
return 1920, 1080
class LinuxAutomationHandler(BaseAutomationHandler):
"""Linux implementation of automation handler using pynput."""
keyboard = KeyboardController()
mouse = MouseController()
# Mouse Actions
async def mouse_down(
self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left"
) -> Dict[str, Any]:
"""Press and hold a mouse button at the specified coordinates.
Args:
x: The x coordinate to move to before pressing. If None, uses current position.
y: The y coordinate to move to before pressing. If None, uses current position.
button: The mouse button to press ("left", "right", or "middle").
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
if x is not None and y is not None:
self.mouse.position = (x, y)
from pynput.mouse import Button
btn = getattr(Button, button if button in ["left", "right", "middle"] else "left")
self.mouse.press(btn)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def mouse_up(
self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left"
) -> Dict[str, Any]:
"""Release a mouse button at the specified coordinates.
Args:
x: The x coordinate to move to before releasing. If None, uses current position.
y: The y coordinate to move to before releasing. If None, uses current position.
button: The mouse button to release ("left", "right", or "middle").
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
if x is not None and y is not None:
self.mouse.position = (x, y)
from pynput.mouse import Button
btn = getattr(Button, button if button in ["left", "right", "middle"] else "left")
self.mouse.release(btn)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def move_cursor(self, x: int, y: int) -> Dict[str, Any]:
"""Move the cursor to the specified coordinates.
Args:
x: The x coordinate to move to.
y: The y coordinate to move to.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
self.mouse.position = (x, y)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def left_click(self, x: Optional[int] = None, y: Optional[int] = None) -> Dict[str, Any]:
"""Perform a left mouse click at the specified coordinates.
Args:
x: The x coordinate to click at. If None, clicks at current position.
y: The y coordinate to click at. If None, clicks at current position.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
from pynput.mouse import Button
if x is not None and y is not None:
self.mouse.position = (x, y)
self.mouse.click(Button.left, 1)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def right_click(self, x: Optional[int] = None, y: Optional[int] = None) -> Dict[str, Any]:
"""Perform a right mouse click at the specified coordinates.
Args:
x: The x coordinate to click at. If None, clicks at current position.
y: The y coordinate to click at. If None, clicks at current position.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
from pynput.mouse import Button
if x is not None and y is not None:
self.mouse.position = (x, y)
self.mouse.click(Button.right, 1)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def middle_click(
self, x: Optional[int] = None, y: Optional[int] = None
) -> Dict[str, Any]:
"""Perform a middle mouse click at the specified coordinates."""
try:
from pynput.mouse import Button
if x is not None and y is not None:
self.mouse.position = (x, y)
self.mouse.click(Button.middle, 1)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def double_click(
self, x: Optional[int] = None, y: Optional[int] = None
) -> Dict[str, Any]:
"""Perform a double click at the specified coordinates.
Args:
x: The x coordinate to double click at. If None, clicks at current position.
y: The y coordinate to double click at. If None, clicks at current position.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
from pynput.mouse import Button
if x is not None and y is not None:
self.mouse.position = (x, y)
self.mouse.click(Button.left, 2)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def click(
self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left"
) -> Dict[str, Any]:
"""Perform a mouse click with the specified button at the given coordinates.
Args:
x: The x coordinate to click at. If None, clicks at current position.
y: The y coordinate to click at. If None, clicks at current position.
button: The mouse button to click ("left", "right", or "middle").
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
from pynput.mouse import Button
if x is not None and y is not None:
self.mouse.position = (x, y)
btn = getattr(Button, button if button in ["left", "right", "middle"] else "left")
self.mouse.click(btn, 1)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def drag_to(
self, x: int, y: int, button: str = "left", duration: float = 0.5
) -> Dict[str, Any]:
"""Drag from the current position to the specified coordinates.
Args:
x: The x coordinate to drag to.
y: The y coordinate to drag to.
button: The mouse button to use for dragging.
duration: The time in seconds to take for the drag operation.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
from pynput.mouse import Button
btn = getattr(Button, button if button in ["left", "right", "middle"] else "left")
self.mouse.press(btn)
self.mouse.position = (x, y)
self.mouse.release(btn)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def drag(
self, path: List[Tuple[int, int]], button: str = "left", duration: float = 0.5
) -> Dict[str, Any]:
"""Drag along a path defined by a list of coordinates.
Args:
path: A list of (x, y) coordinate tuples defining the drag path.
button: The mouse button to use for dragging.
duration: The time in seconds to take for each segment of the drag.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
from pynput.mouse import Button
if not path:
return {"success": False, "error": "Path is empty"}
btn = getattr(Button, button if button in ["left", "right", "middle"] else "left")
self.mouse.position = path[0]
for x, y in path[1:]:
self.mouse.press(btn)
self.mouse.position = (x, y)
self.mouse.release(btn)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
# Keyboard Actions
async def key_down(self, key: str) -> Dict[str, Any]:
"""Press and hold a key.
Args:
key: The key to press down.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
from pynput.keyboard import Key
k = getattr(Key, key) if hasattr(Key, key) else (key if len(key) == 1 else None)
if k is None:
return {"success": False, "error": f"Unknown key: {key}"}
self.keyboard.press(k)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def key_up(self, key: str) -> Dict[str, Any]:
"""Release a key.
Args:
key: The key to release.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
from pynput.keyboard import Key
k = getattr(Key, key) if hasattr(Key, key) else (key if len(key) == 1 else None)
if k is None:
return {"success": False, "error": f"Unknown key: {key}"}
self.keyboard.release(k)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def type_text(self, text: str) -> Dict[str, Any]:
"""Type the specified text using the keyboard.
Args:
text: The text to type.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
# use pynput for Unicode support
self.keyboard.type(text)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def press_key(self, key: str) -> Dict[str, Any]:
"""Press and release a key.
Args:
key: The key to press.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
from pynput.keyboard import Key
k = getattr(Key, key) if hasattr(Key, key) else (key if len(key) == 1 else None)
if k is None:
return {"success": False, "error": f"Unknown key: {key}"}
self.keyboard.press(k)
self.keyboard.release(k)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def hotkey(self, keys: List[str]) -> Dict[str, Any]:
"""Press a combination of keys simultaneously.
Args:
keys: A list of keys to press together as a hotkey combination.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
from pynput.keyboard import Key
seq = []
for k in keys:
kk = getattr(Key, k) if hasattr(Key, k) else (k if len(k) == 1 else None)
if kk is None:
return {"success": False, "error": f"Unknown key in hotkey: {k}"}
seq.append(kk)
for k in seq[:-1]:
self.keyboard.press(k)
last = seq[-1]
self.keyboard.press(last)
self.keyboard.release(last)
for k in reversed(seq[:-1]):
self.keyboard.release(k)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
# Scrolling Actions
async def scroll(self, x: int, y: int) -> Dict[str, Any]:
"""Scroll the mouse wheel.
Args:
x: The horizontal scroll amount.
y: The vertical scroll amount.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
self.mouse.scroll(x, y)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def scroll_down(self, clicks: int = 1) -> Dict[str, Any]:
"""Scroll down by the specified number of clicks.
Args:
clicks: The number of scroll clicks to perform downward.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
self.mouse.scroll(0, -abs(clicks))
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def scroll_up(self, clicks: int = 1) -> Dict[str, Any]:
"""Scroll up by the specified number of clicks.
Args:
clicks: The number of scroll clicks to perform upward.
Returns:
Dict[str, Any]: A dictionary with success status and error message if failed.
"""
try:
self.mouse.scroll(0, abs(clicks))
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
# Screen Actions
async def screenshot(self, format: str = "png", quality: int = 95) -> Dict[str, Any]:
"""Take a screenshot of the current screen.
Args:
format: "png" (lossless, default), "jpeg" or "jpg" (lossy, smaller).
quality: JPEG quality 1-95 (clamped); ignored for PNG.
"""
try:
fmt, quality = normalize_screenshot_format(format, quality)
except ValueError as e:
return {"success": False, "error": str(e)}
try:
screenshot = ImageGrab.grab()
if not isinstance(screenshot, Image.Image):
return {"success": False, "error": "Failed to capture screenshot"}
buffered = BytesIO()
if fmt == "jpeg":
screenshot.convert("RGB").save(
buffered, format="JPEG", quality=quality, optimize=True
)
else:
screenshot.save(buffered, format="PNG", optimize=True)
buffered.seek(0)
image_data = base64.b64encode(buffered.getvalue()).decode()
return {"success": True, "image_data": image_data, "format": fmt}
except Exception as e:
return {"success": False, "error": f"Screenshot error: {str(e)}"}
async def get_screen_size(self) -> Dict[str, Any]:
"""Get the size of the screen.
Returns:
Dict[str, Any]: A dictionary containing success status and screen dimensions,
or error message if failed.
"""
try:
img = ImageGrab.grab()
return {"success": True, "size": {"width": img.width, "height": img.height}}
except Exception as e:
return {"success": False, "error": str(e)}
async def get_cursor_position(self) -> Dict[str, Any]:
"""Get the current position of the cursor.
Returns:
Dict[str, Any]: A dictionary containing success status and cursor coordinates,
or error message if failed.
"""
try:
from pynput.mouse import Controller as MouseController
m = MouseController()
x, y = m.position
return {"success": True, "position": {"x": int(x), "y": int(y)}}
except Exception as e:
return {"success": False, "error": str(e)}
# Clipboard and run_command inherited from BaseAutomationHandler
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,598 @@
"""
VNC backend for automation and accessibility handlers.
A cross-platform alternative to the native (OS-specific) handlers. Connects to
any VNC server to perform screenshots, mouse, keyboard, and scroll operations
over the RFB protocol. Works with Linux, macOS, and Windows targets — the only
requirement is a reachable VNC server.
Built on vncdotool (Twisted-based RFB client). The Twisted reactor runs in a
background daemon thread; all handler methods bridge from asyncio via
`asyncio.to_thread`.
Usage:
# Via CLI
python -m computer_server --vnc-host 192.168.64.1 --vnc-port 5900 --vnc-password secret
# Via env vars
CUA_VNC_HOST=192.168.64.1 CUA_VNC_PORT=5900 CUA_VNC_PASSWORD=secret python -m computer_server
"""
import asyncio
import base64
import logging
from io import BytesIO
from typing import Any, Dict, List, Optional, Tuple
from .base import BaseAccessibilityHandler, BaseAutomationHandler
logger = logging.getLogger(__name__)
# Key name aliases: map cua-computer-server key names → vncdotool key names.
# vncdotool's KEYMAP already covers most X11 keysym names; these bridge
# the naming differences used by the rest of the computer-server API.
_KEY_ALIASES = {
"return": "enter",
"escape": "esc",
"backspace": "bsp",
"delete": "del",
"page_up": "pgup",
"pageup": "pgup",
"page_down": "pgdn",
"pagedown": "pgdn",
"insert": "ins",
"caps_lock": "caplk",
"num_lock": "numlk",
"shift_l": "lshift",
"shift_r": "rshift",
"ctrl_l": "lctrl",
"ctrl_r": "rctrl",
"control": "ctrl",
"control_l": "lctrl",
"control_r": "rctrl",
"alt_l": "lalt",
"alt_r": "ralt",
"meta_l": "lmeta",
"meta_r": "rmeta",
"super_l": "lsuper",
"super_r": "rsuper",
# macOS-specific names
"command": "alt",
"cmd": "alt",
"option": "meta",
"option_l": "lmeta",
"option_r": "rmeta",
}
# Button name → vncdotool button number (1-indexed)
_BUTTON_MAP = {"left": 1, "middle": 2, "right": 3}
def _translate_key(key: str) -> str:
"""Translate a key name to vncdotool's KEYMAP name."""
lower = key.lower()
return _KEY_ALIASES.get(lower, lower)
# ---------------------------------------------------------------------------
# Lazy vncdotool client wrapper
# ---------------------------------------------------------------------------
class _VNCConnection:
"""Manages vncdotool client connections to a VNC server.
Uses fresh connections per operation to avoid a bug in vncdotool's
ThreadedVNCClientProxy where the Twisted deferred chain corrupts
coordinate state after operations like refreshScreen. Each public
method creates its own connection, executes, and disconnects.
All methods are synchronous (blocking) — the handler calls them via
asyncio.to_thread to avoid blocking the event loop.
"""
def __init__(self, host: str, port: int, password: str = ""):
self._host = host
self._port = port
self._password = password
# Track cursor position locally
self._cursor_x: int = 0
self._cursor_y: int = 0
def _with_client(self, fn):
"""Execute fn(client) with a fresh VNC connection via the global Twisted reactor.
Starts the reactor in a background thread on first use, then reuses it.
``fn(client)`` receives a raw ``VNCDoToolClient`` whose methods return
Twisted Deferreds.
"""
import threading
from twisted.internet import defer, reactor
from vncdotool.client import VNCDoToolFactory
result_holder: list = [None]
error_holder: list = [None]
done_event = threading.Event()
def _work():
factory = VNCDoToolFactory()
factory.password = self._password or None
@defer.inlineCallbacks
def _do():
client = None
try:
reactor.connectTCP(self._host, self._port, factory)
client = yield factory.deferred
res = yield defer.maybeDeferred(fn, client)
result_holder[0] = res
except Exception as e:
error_holder[0] = e
finally:
if client is not None and hasattr(client, "transport") and client.transport:
client.transport.loseConnection()
done_event.set()
_do()
# Ensure the reactor is running in a background thread
if not reactor.running:
t = threading.Thread(
target=reactor.run,
kwargs={"installSignalHandlers": False},
daemon=True,
)
t.start()
reactor.callFromThread(_work)
done_event.wait(timeout=30)
if not done_event.is_set():
raise TimeoutError("VNC operation timed out")
if error_holder[0] is not None:
raise error_holder[0]
return result_holder[0]
def disconnect(self):
pass # No persistent connection to close
def _reset_on_error(self):
pass # No persistent connection to reset
# -- Screenshot ---------------------------------------------------------
def capture_screenshot(self) -> bytes:
"""Capture the screen and return PNG bytes."""
from twisted.internet import defer
@defer.inlineCallbacks
def _do(client):
yield client.refreshScreen(incremental=False)
screen = client.screen
buf = BytesIO()
screen.save(buf, format="PNG")
defer.returnValue(buf.getvalue())
return self._with_client(_do)
# -- Mouse --------------------------------------------------------------
def mouse_move(self, x: int, y: int):
self._with_client(lambda c: c.mouseMove(x, y))
self._cursor_x = x
self._cursor_y = y
def mouse_click(self, x: int, y: int, button: int = 1, clicks: int = 1):
from twisted.internet import defer
@defer.inlineCallbacks
def _do(client):
yield client.mouseMove(x, y)
for _ in range(clicks):
yield client.mousePress(button)
self._with_client(_do)
self._cursor_x = x
self._cursor_y = y
def mouse_down(self, x: int, y: int, button: int = 1):
from twisted.internet import defer
@defer.inlineCallbacks
def _do(client):
yield client.mouseMove(x, y)
yield client.mouseDown(button)
self._with_client(_do)
self._cursor_x = x
self._cursor_y = y
def mouse_up(self, x: int, y: int, button: int = 1):
from twisted.internet import defer
@defer.inlineCallbacks
def _do(client):
yield client.mouseMove(x, y)
yield client.mouseUp(button)
self._with_client(_do)
self._cursor_x = x
self._cursor_y = y
def mouse_drag(self, x: int, y: int, step: int = 1):
from twisted.internet import defer
sx, sy = self._cursor_x, self._cursor_y
@defer.inlineCallbacks
def _do(client):
# Move in increments from current position to target
dx, dy = x - sx, y - sy
steps = max(abs(dx), abs(dy)) // max(step, 1)
steps = max(steps, 1)
yield client.mouseDown(1)
for i in range(1, steps + 1):
ix = sx + dx * i // steps
iy = sy + dy * i // steps
yield client.mouseMove(ix, iy)
yield client.mouseUp(1)
self._with_client(_do)
self._cursor_x = x
self._cursor_y = y
def drag_to(
self, start_x: int, start_y: int, end_x: int, end_y: int, button: int = 1, step: int = 5
):
"""Drag from start to end on a single connection."""
from twisted.internet import defer
@defer.inlineCallbacks
def _do(client):
yield client.mouseMove(start_x, start_y)
yield client.mouseDown(button)
# Manual drag in increments instead of mouseDrag (avoids doPoll)
dx, dy = end_x - start_x, end_y - start_y
steps = max(abs(dx), abs(dy)) // max(step, 1)
steps = max(steps, 1)
for i in range(1, steps + 1):
ix = start_x + dx * i // steps
iy = start_y + dy * i // steps
yield client.mouseMove(ix, iy)
yield client.mouseUp(button)
self._with_client(_do)
self._cursor_x = end_x
self._cursor_y = end_y
def drag_path(self, path: List[Tuple[int, int]], button: int = 1):
"""Drag along a path of points on a single connection."""
from twisted.internet import defer
@defer.inlineCallbacks
def _do(client):
yield client.mouseMove(path[0][0], path[0][1])
yield client.mouseDown(button)
for px, py in path[1:]:
yield client.mouseMove(px, py)
yield client.mouseUp(button)
self._with_client(_do)
self._cursor_x = path[-1][0]
self._cursor_y = path[-1][1]
def scroll(self, x: int, y: int):
"""Scroll. y>0 = up, y<0 = down (matches macOS native handler convention).
Uses arrow key presses instead of mouse buttons 4/5 because Apple's
_VZVNCServer (used by Lume) does not translate RFB mouse buttons 4-7
into scroll wheel events.
"""
from twisted.internet import defer
@defer.inlineCallbacks
def _do(client):
if y > 0:
for _ in range(y):
yield client.keyPress("up")
elif y < 0:
for _ in range(abs(y)):
yield client.keyPress("down")
if x > 0:
for _ in range(x):
yield client.keyPress("right")
elif x < 0:
for _ in range(abs(x)):
yield client.keyPress("left")
self._with_client(_do)
# -- Keyboard -----------------------------------------------------------
def key_press(self, key: str):
self._with_client(lambda c: c.keyPress(_translate_key(key)))
def key_down(self, key: str):
self._with_client(lambda c: c.keyDown(_translate_key(key)))
def key_up(self, key: str):
self._with_client(lambda c: c.keyUp(_translate_key(key)))
def type_text(self, text: str):
"""Type text character by character, handling shift for uppercase/symbols."""
from twisted.internet import defer
@defer.inlineCallbacks
def _do(client):
for ch in text:
if ch == " ":
yield client.keyPress("space")
elif ch == "\n":
yield client.keyPress("enter")
elif ch == "\t":
yield client.keyPress("tab")
else:
yield client.keyPress(ch)
self._with_client(_do)
def hotkey(self, keys: List[str]):
"""Press a key combination (e.g. ['command', 'a'])."""
translated = [_translate_key(k) for k in keys]
combo = "-".join(translated)
self._with_client(lambda c: c.keyPress(combo))
# -- Clipboard ----------------------------------------------------------
def paste_text(self, text: str):
self._with_client(lambda c: c.paste(text))
# -- Info ---------------------------------------------------------------
@property
def screen_size(self) -> Tuple[int, int]:
from twisted.internet import defer
@defer.inlineCallbacks
def _do(client):
yield client.refreshScreen(incremental=False)
if client.screen is not None:
defer.returnValue(client.screen.size)
defer.returnValue((0, 0))
return self._with_client(_do)
@property
def cursor_position(self) -> Tuple[int, int]:
return self._cursor_x, self._cursor_y
# ---------------------------------------------------------------------------
# Automation Handler
# ---------------------------------------------------------------------------
class VNCAutomationHandler(BaseAutomationHandler):
"""Cross-platform automation handler that operates via VNC/RFB protocol.
Works with any OS target (Linux, macOS, Windows) that has a VNC server.
All operations go through the network, bypassing local permission systems.
"""
def __init__(
self,
host: str = "127.0.0.1",
port: int = 5900,
password: str = "",
):
self._conn = _VNCConnection(host, port, password)
def _resolve_coords(self, x: Optional[int], y: Optional[int]) -> Tuple[int, int]:
if x is not None and y is not None:
return x, y
return self._conn.cursor_position
# -- Screenshot ---------------------------------------------------------
async def screenshot(self) -> Dict[str, Any]:
try:
png_bytes = await asyncio.to_thread(self._conn.capture_screenshot)
image_data = base64.b64encode(png_bytes).decode()
return {"success": True, "image_data": image_data}
except Exception as e:
logger.error(f"VNC screenshot error: {e}")
self._conn._reset_on_error()
return {"success": False, "error": f"VNC screenshot error: {e}"}
# -- Mouse actions ------------------------------------------------------
async def left_click(self, x: Optional[int] = None, y: Optional[int] = None) -> Dict[str, Any]:
try:
cx, cy = self._resolve_coords(x, y)
await asyncio.to_thread(self._conn.mouse_click, cx, cy, 1)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def right_click(self, x: Optional[int] = None, y: Optional[int] = None) -> Dict[str, Any]:
try:
cx, cy = self._resolve_coords(x, y)
await asyncio.to_thread(self._conn.mouse_click, cx, cy, 3)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def middle_click(
self, x: Optional[int] = None, y: Optional[int] = None
) -> Dict[str, Any]:
try:
cx, cy = self._resolve_coords(x, y)
await asyncio.to_thread(self._conn.mouse_click, cx, cy, 2)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def double_click(
self, x: Optional[int] = None, y: Optional[int] = None
) -> Dict[str, Any]:
try:
cx, cy = self._resolve_coords(x, y)
await asyncio.to_thread(self._conn.mouse_click, cx, cy, 1, 2)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def move_cursor(self, x: int, y: int) -> Dict[str, Any]:
try:
await asyncio.to_thread(self._conn.mouse_move, x, y)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def mouse_down(
self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left"
) -> Dict[str, Any]:
try:
cx, cy = self._resolve_coords(x, y)
btn = _BUTTON_MAP.get(button, 1)
await asyncio.to_thread(self._conn.mouse_down, cx, cy, btn)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def mouse_up(
self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left"
) -> Dict[str, Any]:
try:
cx, cy = self._resolve_coords(x, y)
btn = _BUTTON_MAP.get(button, 1)
await asyncio.to_thread(self._conn.mouse_up, cx, cy, btn)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def drag_to(
self, x: int, y: int, button: str = "left", duration: float = 0.5
) -> Dict[str, Any]:
try:
btn = _BUTTON_MAP.get(button, 1)
cx, cy = self._conn.cursor_position
step = max(1, int(1 / max(duration, 0.01) * 5))
await asyncio.to_thread(self._conn.drag_to, cx, cy, x, y, btn, step)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def drag(
self, path: List[Tuple[int, int]], button: str = "left", duration: float = 0.5
) -> Dict[str, Any]:
try:
if not path:
return {"success": False, "error": "Empty path"}
btn = _BUTTON_MAP.get(button, 1)
await asyncio.to_thread(self._conn.drag_path, path, btn)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
# -- Keyboard actions ---------------------------------------------------
async def type_text(self, text: str) -> Dict[str, Any]:
try:
await asyncio.to_thread(self._conn.type_text, text)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def press_key(self, key: str) -> Dict[str, Any]:
try:
await asyncio.to_thread(self._conn.key_press, key)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def key_down(self, key: str) -> Dict[str, Any]:
try:
await asyncio.to_thread(self._conn.key_down, key)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def key_up(self, key: str) -> Dict[str, Any]:
try:
await asyncio.to_thread(self._conn.key_up, key)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def hotkey(self, keys: List[str]) -> Dict[str, Any]:
try:
await asyncio.to_thread(self._conn.hotkey, keys)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
# -- Scrolling ----------------------------------------------------------
async def scroll(self, x: int, y: int) -> Dict[str, Any]:
try:
await asyncio.to_thread(self._conn.scroll, x, y)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
async def scroll_down(self, clicks: int = 1) -> Dict[str, Any]:
return await self.scroll(0, -clicks)
async def scroll_up(self, clicks: int = 1) -> Dict[str, Any]:
return await self.scroll(0, clicks)
# -- Screen info --------------------------------------------------------
async def get_screen_size(self) -> Dict[str, Any]:
try:
w, h = await asyncio.to_thread(lambda: self._conn.screen_size)
return {"success": True, "size": {"width": w, "height": h}}
except Exception as e:
return {"success": False, "error": str(e)}
async def get_cursor_position(self) -> Dict[str, Any]:
try:
x, y = self._conn.cursor_position
return {"success": True, "position": {"x": x, "y": y}}
except Exception as e:
return {"success": False, "error": str(e)}
# Clipboard and run_command inherited from BaseAutomationHandler
# ---------------------------------------------------------------------------
# Accessibility Handler (stub)
# ---------------------------------------------------------------------------
class VNCAccessibilityHandler(BaseAccessibilityHandler):
"""Stub accessibility handler for VNC — no accessibility tree available."""
async def get_accessibility_tree(self) -> Dict[str, Any]:
return {
"success": True,
"tree": {
"role": "Window",
"title": "VNC Remote Desktop",
"position": {"x": 0, "y": 0},
"size": {"width": 0, "height": 0},
"children": [],
},
}
async def find_element(
self,
role: Optional[str] = None,
title: Optional[str] = None,
value: Optional[str] = None,
) -> Dict[str, Any]:
return {
"success": False,
"error": "Accessibility tree not available over VNC",
}
@@ -0,0 +1,772 @@
"""
Windows implementation of automation and accessibility handlers.
This implementation uses pynput for GUI automation and Windows-specific APIs
for accessibility and system operations.
"""
import asyncio
import base64
import functools
import logging
import os
import subprocess
from io import BytesIO
from typing import Any, Callable, Dict, List, Optional, Tuple, TypeVar, Union
F = TypeVar("F", bound=Callable[..., Any])
def require_unlocked_desktop(func: F) -> F:
"""Decorator that checks if the Windows desktop is locked before executing.
Returns an error response if the desktop is locked, preventing automation
actions that would silently fail on the Windows Secure Desktop.
"""
@functools.wraps(func)
async def wrapper(
self: "WindowsAutomationHandler", *args: Any, **kwargs: Any
) -> Dict[str, Any]:
if self.is_desktop_locked():
return {
"success": False,
"error": "Windows desktop is locked. Automation input is blocked by OS security.",
}
return await func(self, *args, **kwargs)
return wrapper # type: ignore[return-value]
from PIL import Image, ImageGrab
from pynput.keyboard import Controller as KeyboardController
from pynput.keyboard import Key as KBKey
from pynput.mouse import Button as MouseButton
from pynput.mouse import Controller as MouseController
# Configure logger
logger = logging.getLogger(__name__)
# pyautogui removed in favor of pynput
# Try to import Windows-specific modules
try:
import win32api
import win32con
import win32gui
logger.info("Windows API modules successfully imported")
WINDOWS_API_AVAILABLE = True
except Exception as e:
logger.error(
f"Windows API modules import failed: {str(e)}. Some Windows-specific features will be unavailable."
)
WINDOWS_API_AVAILABLE = False
from .base import (
BaseAccessibilityHandler,
BaseAutomationHandler,
normalize_screenshot_format,
)
class WindowsAccessibilityHandler(BaseAccessibilityHandler):
"""Windows implementation of accessibility handler."""
async def get_accessibility_tree(self) -> Dict[str, Any]:
"""Get the accessibility tree of the current window.
Returns:
Dict[str, Any]: A dictionary containing the success status and either
the accessibility tree or an error message.
Structure: {"success": bool, "tree": dict} or
{"success": bool, "error": str}
"""
if not WINDOWS_API_AVAILABLE:
return {"success": False, "error": "Windows API not available"}
try:
# Get the foreground window
hwnd = win32gui.GetForegroundWindow()
if not hwnd:
return {"success": False, "error": "No foreground window found"}
# Get window information
window_text = win32gui.GetWindowText(hwnd)
rect = win32gui.GetWindowRect(hwnd)
tree = {
"role": "Window",
"title": window_text,
"position": {"x": rect[0], "y": rect[1]},
"size": {"width": rect[2] - rect[0], "height": rect[3] - rect[1]},
"children": [],
}
# Enumerate child windows
def enum_child_proc(hwnd_child, children_list):
"""Callback function to enumerate child windows and collect their information.
Args:
hwnd_child: Handle to the child window being enumerated.
children_list: List to append child window information to.
Returns:
bool: True to continue enumeration, False to stop.
"""
try:
child_text = win32gui.GetWindowText(hwnd_child)
child_rect = win32gui.GetWindowRect(hwnd_child)
child_class = win32gui.GetClassName(hwnd_child)
child_info = {
"role": child_class,
"title": child_text,
"position": {"x": child_rect[0], "y": child_rect[1]},
"size": {
"width": child_rect[2] - child_rect[0],
"height": child_rect[3] - child_rect[1],
},
"children": [],
}
children_list.append(child_info)
except Exception as e:
logger.debug(f"Error getting child window info: {e}")
return True
win32gui.EnumChildWindows(hwnd, enum_child_proc, tree["children"])
return {"success": True, "tree": tree}
except Exception as e:
logger.error(f"Error getting accessibility tree: {e}")
return {"success": False, "error": str(e)}
async def find_element(
self, role: Optional[str] = None, title: Optional[str] = None, value: Optional[str] = None
) -> Dict[str, Any]:
"""Find an element in the accessibility tree by criteria.
Args:
role (Optional[str]): The role or class name of the element to find.
title (Optional[str]): The title or text of the element to find.
value (Optional[str]): The value of the element (not used in Windows implementation).
Returns:
Dict[str, Any]: A dictionary containing the success status and either
the found element or an error message.
Structure: {"success": bool, "element": dict} or
{"success": bool, "error": str}
"""
if not WINDOWS_API_AVAILABLE:
return {"success": False, "error": "Windows API not available"}
try:
# Find window by title if specified
if title:
hwnd = win32gui.FindWindow(None, title)
if hwnd:
rect = win32gui.GetWindowRect(hwnd)
return {
"success": True,
"element": {
"role": "Window",
"title": title,
"position": {"x": rect[0], "y": rect[1]},
"size": {"width": rect[2] - rect[0], "height": rect[3] - rect[1]},
},
}
# Find window by class name if role is specified
if role:
hwnd = win32gui.FindWindow(role, None)
if hwnd:
window_text = win32gui.GetWindowText(hwnd)
rect = win32gui.GetWindowRect(hwnd)
return {
"success": True,
"element": {
"role": role,
"title": window_text,
"position": {"x": rect[0], "y": rect[1]},
"size": {"width": rect[2] - rect[0], "height": rect[3] - rect[1]},
},
}
return {"success": False, "error": "Element not found"}
except Exception as e:
logger.error(f"Error finding element: {e}")
return {"success": False, "error": str(e)}
class WindowsAutomationHandler(BaseAutomationHandler):
"""Windows implementation of automation handler using pynput and Windows APIs."""
mouse = MouseController()
keyboard = KeyboardController()
def is_desktop_locked(self) -> bool:
try:
import ctypes
user32 = ctypes.windll.user32
hwnd = user32.GetForegroundWindow()
return hwnd == 0
except Exception:
return False
def _map_button(self, button: str) -> MouseButton:
"""Map a string button name to pynput MouseButton."""
b = (button or "left").lower()
if b == "left":
return MouseButton.left
if b == "right":
return MouseButton.right
if b == "middle":
return MouseButton.middle
# default to left
return MouseButton.left
def _key_from_string(self, key: str):
"""Convert a key string (e.g., 'enter', 'ctrl', 'a') to pynput Key or char."""
if not key:
return None
lk = key.lower()
special = {
"enter": KBKey.enter,
"return": KBKey.enter,
"esc": KBKey.esc,
"escape": KBKey.esc,
"space": KBKey.space,
"tab": KBKey.tab,
"backspace": KBKey.backspace,
"delete": KBKey.delete,
"home": KBKey.home,
"end": KBKey.end,
"pageup": KBKey.page_up,
"pagedown": KBKey.page_down,
"up": KBKey.up,
"down": KBKey.down,
"left": KBKey.left,
"right": KBKey.right,
"shift": KBKey.shift,
"ctrl": KBKey.ctrl,
"control": KBKey.ctrl,
"alt": KBKey.alt,
"cmd": KBKey.cmd,
"win": KBKey.cmd,
"meta": KBKey.cmd,
"capslock": KBKey.caps_lock,
"f1": KBKey.f1,
"f2": KBKey.f2,
"f3": KBKey.f3,
"f4": KBKey.f4,
"f5": KBKey.f5,
"f6": KBKey.f6,
"f7": KBKey.f7,
"f8": KBKey.f8,
"f9": KBKey.f9,
"f10": KBKey.f10,
"f11": KBKey.f11,
"f12": KBKey.f12,
}
if lk in special:
return special[lk]
# single character
if len(key) == 1:
return key
return None
# Mouse Actions
@require_unlocked_desktop
async def mouse_down(
self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left"
) -> Dict[str, Any]:
"""Press and hold a mouse button at the specified coordinates.
Args:
x (Optional[int]): The x-coordinate to move to before pressing. If None, uses current position.
y (Optional[int]): The y-coordinate to move to before pressing. If None, uses current position.
button (str): The mouse button to press ("left", "right", or "middle").
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
if x is not None and y is not None:
self.mouse.position = (x, y)
self.mouse.press(self._map_button(button))
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def mouse_up(
self, x: Optional[int] = None, y: Optional[int] = None, button: str = "left"
) -> Dict[str, Any]:
"""Release a mouse button at the specified coordinates.
Args:
x (Optional[int]): The x-coordinate to move to before releasing. If None, uses current position.
y (Optional[int]): The y-coordinate to move to before releasing. If None, uses current position.
button (str): The mouse button to release ("left", "right", or "middle").
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
if x is not None and y is not None:
self.mouse.position = (x, y)
self.mouse.release(self._map_button(button))
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def move_cursor(self, x: int, y: int) -> Dict[str, Any]:
"""Move the mouse cursor to the specified coordinates.
Args:
x (int): The x-coordinate to move to.
y (int): The y-coordinate to move to.
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
self.mouse.position = (x, y)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def left_click(self, x: Optional[int] = None, y: Optional[int] = None) -> Dict[str, Any]:
"""Perform a left mouse click at the specified coordinates.
Args:
x (Optional[int]): The x-coordinate to click at. If None, clicks at current position.
y (Optional[int]): The y-coordinate to click at. If None, clicks at current position.
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
if x is not None and y is not None:
self.mouse.position = (x, y)
self.mouse.click(MouseButton.left, 1)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def right_click(self, x: Optional[int] = None, y: Optional[int] = None) -> Dict[str, Any]:
"""Perform a right mouse click at the specified coordinates.
Args:
x (Optional[int]): The x-coordinate to click at. If None, clicks at current position.
y (Optional[int]): The y-coordinate to click at. If None, clicks at current position.
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
if x is not None and y is not None:
self.mouse.position = (x, y)
self.mouse.click(MouseButton.right, 1)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def middle_click(
self, x: Optional[int] = None, y: Optional[int] = None
) -> Dict[str, Any]:
"""Perform a middle mouse click at the specified coordinates.
Args:
x (Optional[int]): The x-coordinate to click at. If None, clicks at current position.
y (Optional[int]): The y-coordinate to click at. If None, clicks at current position.
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
if x is not None and y is not None:
self.mouse.position = (x, y)
self.mouse.click(MouseButton.middle, 1)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def double_click(
self, x: Optional[int] = None, y: Optional[int] = None
) -> Dict[str, Any]:
"""Perform a double left mouse click at the specified coordinates.
Args:
x (Optional[int]): The x-coordinate to double-click at. If None, clicks at current position.
y (Optional[int]): The y-coordinate to double-click at. If None, clicks at current position.
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
if x is not None and y is not None:
self.mouse.position = (x, y)
self.mouse.click(MouseButton.left, 2)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def drag_to(
self, x: int, y: int, button: str = "left", duration: float = 0.5
) -> Dict[str, Any]:
"""Drag from the current position to the specified coordinates.
Args:
x (int): The x-coordinate to drag to.
y (int): The y-coordinate to drag to.
button (str): The mouse button to use for dragging ("left", "right", or "middle").
duration (float): The time in seconds to take for the drag operation.
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
# simple drag implementation
self.mouse.press(self._map_button(button))
self.mouse.position = (x, y)
self.mouse.release(self._map_button(button))
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def drag(
self, path: List[Tuple[int, int]], button: str = "left", duration: float = 0.5
) -> Dict[str, Any]:
"""Drag the mouse through a series of coordinates.
Args:
path (List[Tuple[int, int]]): A list of (x, y) coordinate tuples to drag through.
button (str): The mouse button to use for dragging ("left", "right", or "middle").
duration (float): The total time in seconds for the entire drag operation.
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
if not path:
return {"success": False, "error": "Path is empty"}
# Move to first position
self.mouse.position = path[0]
# Drag through all positions
for x, y in path[1:]:
self.mouse.press(self._map_button(button))
self.mouse.position = (x, y)
self.mouse.release(self._map_button(button))
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
# Keyboard Actions
@require_unlocked_desktop
async def key_down(self, key: str) -> Dict[str, Any]:
"""Press and hold a keyboard key.
Args:
key (str): The key to press down (e.g., 'ctrl', 'shift', 'a').
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
k = self._key_from_string(key)
if k is None:
return {"success": False, "error": f"Unknown key: {key}"}
self.keyboard.press(k)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def key_up(self, key: str) -> Dict[str, Any]:
"""Release a keyboard key.
Args:
key (str): The key to release (e.g., 'ctrl', 'shift', 'a').
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
k = self._key_from_string(key)
if k is None:
return {"success": False, "error": f"Unknown key: {key}"}
self.keyboard.release(k)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def type_text(self, text: str) -> Dict[str, Any]:
"""Type the specified text.
Args:
text (str): The text to type.
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
# use pynput for Unicode support
self.keyboard.type(text)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def press_key(self, key: str) -> Dict[str, Any]:
"""Press and release a keyboard key.
Args:
key (str): The key to press (e.g., 'enter', 'space', 'tab').
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
k = self._key_from_string(key)
if k is None:
return {"success": False, "error": f"Unknown key: {key}"}
self.keyboard.press(k)
self.keyboard.release(k)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def hotkey(self, keys: List[str]) -> Dict[str, Any]:
"""Press a combination of keys simultaneously.
Args:
keys (List[str]): The keys to press together (e.g., ['ctrl', 'c'], ['alt', 'tab']).
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
# press keys sequentially while holding modifiers
resolved = [self._key_from_string(k) for k in keys]
if any(k is None for k in resolved):
return {"success": False, "error": "Unknown key in hotkey sequence"}
seq: List[Union[str, KBKey]] = [k for k in resolved if k is not None] # type: ignore[assignment]
if not seq:
return {"success": False, "error": "Empty hotkey sequence"}
# hold all except the last
for k in seq[:-1]:
self.keyboard.press(k)
# tap last
last = seq[-1]
self.keyboard.press(last)
self.keyboard.release(last)
# release modifiers
for k in reversed(seq[:-1]):
self.keyboard.release(k)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
# Scrolling Actions
@require_unlocked_desktop
async def scroll(self, x: int, y: int) -> Dict[str, Any]:
"""Scroll vertically at the current cursor position.
Args:
x (int): Horizontal scroll amount.
y (int): Vertical scroll amount. Positive values scroll up, negative values scroll down.
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
self.mouse.scroll(x, y)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def scroll_down(self, clicks: int = 1) -> Dict[str, Any]:
"""Scroll down by the specified number of clicks.
Args:
clicks (int): The number of scroll clicks to perform downward.
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
# negative y to scroll down
self.mouse.scroll(0, -abs(clicks))
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
@require_unlocked_desktop
async def scroll_up(self, clicks: int = 1) -> Dict[str, Any]:
"""Scroll up by the specified number of clicks.
Args:
clicks (int): The number of scroll clicks to perform upward.
Returns:
Dict[str, Any]: A dictionary with success status and optional error message.
"""
try:
self.mouse.scroll(0, abs(clicks))
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
# Screen Actions
@require_unlocked_desktop
async def screenshot(self, format: str = "png", quality: int = 95) -> Dict[str, Any]:
"""Capture a screenshot of the entire screen.
Args:
format: "png" (lossless, default), "jpeg" or "jpg" (lossy, smaller).
quality: JPEG quality 1-95 (clamped); ignored for PNG.
"""
try:
fmt, quality = normalize_screenshot_format(format, quality)
except ValueError as e:
return {"success": False, "error": str(e)}
try:
screenshot = ImageGrab.grab()
if not isinstance(screenshot, Image.Image):
return {"success": False, "error": "Failed to capture screenshot"}
buffered = BytesIO()
if fmt == "jpeg":
screenshot.convert("RGB").save(
buffered, format="JPEG", quality=quality, optimize=True
)
else:
screenshot.save(buffered, format="PNG", optimize=True)
buffered.seek(0)
image_data = base64.b64encode(buffered.getvalue()).decode()
return {"success": True, "image_data": image_data, "format": fmt}
except Exception as e:
return {"success": False, "error": f"Screenshot error: {str(e)}"}
async def get_screen_size(self) -> Dict[str, Any]:
"""Get the size of the screen in pixels.
Returns:
Dict[str, Any]: A dictionary containing the success status and either
screen size information or an error message.
Structure: {"success": bool, "size": {"width": int, "height": int}} or
{"success": bool, "error": str}
"""
try:
if WINDOWS_API_AVAILABLE:
width = win32api.GetSystemMetrics(win32con.SM_CXSCREEN)
height = win32api.GetSystemMetrics(win32con.SM_CYSCREEN)
return {"success": True, "size": {"width": width, "height": height}}
else:
# Fallback: use ImageGrab
img = ImageGrab.grab()
return {"success": True, "size": {"width": img.width, "height": img.height}}
except Exception as e:
return {"success": False, "error": str(e)}
async def get_cursor_position(self) -> Dict[str, Any]:
"""Get the current position of the mouse cursor.
Returns:
Dict[str, Any]: A dictionary containing the success status and either
cursor position or an error message.
Structure: {"success": bool, "position": {"x": int, "y": int}} or
{"success": bool, "error": str}
"""
try:
if WINDOWS_API_AVAILABLE:
pos = win32gui.GetCursorPos()
return {"success": True, "position": {"x": pos[0], "y": pos[1]}}
else:
# Fallback: use pynput controller
x, y = self.mouse.position
return {"success": True, "position": {"x": int(x), "y": int(y)}}
except Exception as e:
return {"success": False, "error": str(e)}
# Clipboard inherited from BaseAutomationHandler
# Command Execution (Windows override for multi-encoding support)
async def run_command(self, command: str, timeout: Optional[float] = None) -> Dict[str, Any]:
"""Execute a shell command asynchronously.
Args:
command (str): The shell command to execute.
timeout (Optional[float]): Optional timeout in seconds. When
``None`` (default), waits indefinitely. SDK callers set
this via ``sb.shell.run(cmd, timeout=...)``.
Returns:
Dict[str, Any]: A dictionary containing the success status and either
command output or an error message.
Structure: {"success": bool, "stdout": str, "stderr": str, "return_code": int} or
{"success": bool, "error": str}
"""
def decode_output(data: bytes) -> str:
if not data:
return ""
encodings = ["utf-8", "gbk", "gb2312", "cp936", "latin1"]
for enc in encodings:
try:
return data.decode(enc)
except (UnicodeDecodeError, LookupError):
continue
return data.decode("utf-8", errors="replace")
try:
if os.environ.get("IS_CUA_ANDROID") == "true":
process = await asyncio.create_subprocess_exec(
"adb",
"shell",
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
else:
process = await asyncio.create_subprocess_shell(
command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
)
try:
if timeout is None:
stdout, stderr = await process.communicate()
else:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
except asyncio.TimeoutError:
process.kill()
return {
"success": False,
"stdout": "",
"stderr": f"Command timed out after {timeout}s",
"return_code": -1,
}
return {
"success": True,
"stdout": decode_output(stdout),
"stderr": decode_output(stderr),
"return_code": process.returncode,
}
except Exception as e:
return {"success": False, "error": str(e)}