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
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:
+216
File diff suppressed because one or more lines are too long
+2568
File diff suppressed because it is too large
Load Diff
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 1–95 (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)}
|
||||
Reference in New Issue
Block a user