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:
@@ -0,0 +1,10 @@
|
||||
[bumpversion]
|
||||
current_version = 0.3.42
|
||||
commit = True
|
||||
tag = True
|
||||
tag_name = computer-server-v{new_version}
|
||||
message = Bump cua-computer-server to v{new_version}
|
||||
|
||||
[bumpversion:file:pyproject.toml]
|
||||
search = version = "{current_version}"
|
||||
replace = version = "{new_version}"
|
||||
@@ -0,0 +1,127 @@
|
||||
# Cua Computer Server
|
||||
|
||||
Server component for the Computer-Use Interface (CUI) framework providing low-level computer control primitives.
|
||||
|
||||
**[Documentation](https://cua.ai/docs/cua/guide/advanced/local-computer-server)** - Installation, guides, and configuration.
|
||||
|
||||
## Interfaces
|
||||
|
||||
The Computer Server exposes multiple interfaces simultaneously:
|
||||
|
||||
- **HTTP/WebSocket** - REST API and WebSocket for programmatic access
|
||||
- **MCP via HTTP** - Model Context Protocol over streamable HTTP at `/mcp` endpoint
|
||||
|
||||
> **Android emulators** also expose the emulator's built-in **EmulatorController gRPC service** on port 8554 (via `-grpc 8554` launch flag). Local Android sandboxes (`AndroidEmulatorRuntime`) use `GRPCEmulatorTransport` to call `getScreenshot()` directly on this service, bypassing the computer-server HTTP layer entirely. Benchmark: p50 ~20ms, ~49 RPS vs ADB-backed HTTP p50 ~519ms, ~1.8 RPS.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Basic installation (HTTP/WebSocket only)
|
||||
pip install cua-computer-server
|
||||
|
||||
# With MCP support
|
||||
pip install cua-computer-server[mcp]
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Start the server on default port 8000
|
||||
python -m computer_server
|
||||
|
||||
# Or with custom port
|
||||
python -m computer_server --port 8080
|
||||
|
||||
# Allow external access explicitly
|
||||
python -m computer_server --host 0.0.0.0
|
||||
|
||||
# With resolution scaling (useful for Retina displays or VMs)
|
||||
python -m computer_server --width 1512 --height 982
|
||||
```
|
||||
|
||||
By default the server binds to `127.0.0.1`. Deployments that need access from
|
||||
other hosts should pass `--host 0.0.0.0` or another explicit interface.
|
||||
|
||||
This provides:
|
||||
|
||||
- HTTP API at `/ws`, `/cmd`, `/status` endpoints
|
||||
- MCP server at `/mcp` endpoint (requires `fastmcp` package)
|
||||
|
||||
MCP clients can connect via streamable HTTP at `http://localhost:8000/mcp`.
|
||||
|
||||
#### Resolution Scaling
|
||||
|
||||
When running on Retina displays or in VMs where the coordinate system may differ, use the `--width` and `--height` flags to specify the target resolution:
|
||||
|
||||
- Screenshots will be resized to the target resolution
|
||||
- Click coordinates received will be scaled from target to actual screen coordinates
|
||||
- Cursor position will be reported in target coordinates
|
||||
|
||||
This ensures the AI model sees consistent coordinates between screenshots and mouse actions.
|
||||
|
||||
#### Claude Code Integration
|
||||
|
||||
1. Start the server (or run as a service/LaunchAgent):
|
||||
|
||||
```bash
|
||||
python -m computer_server --port 8000
|
||||
```
|
||||
|
||||
2. Add the MCP server URL to Claude Code:
|
||||
|
||||
```bash
|
||||
claude mcp add cua-computer-server --transport http http://localhost:8000/mcp
|
||||
```
|
||||
|
||||
## Available MCP Tools
|
||||
|
||||
The MCP interface exposes 40+ tools for computer control:
|
||||
|
||||
### Screen & Mouse
|
||||
|
||||
- `computer_screenshot` - Capture current screen
|
||||
- `computer_click` - Click at coordinates
|
||||
- `computer_double_click` - Double-click
|
||||
- `computer_move` - Move cursor
|
||||
- `computer_drag` - Drag from start to end coordinates
|
||||
- `computer_scroll` - Scroll at position
|
||||
- `computer_get_screen_size` - Get screen dimensions
|
||||
- `computer_get_cursor_position` - Get cursor position
|
||||
|
||||
### Keyboard
|
||||
|
||||
- `computer_type` - Type text
|
||||
- `computer_press_key` - Press a single key
|
||||
- `computer_hotkey` - Press key combination (e.g., Ctrl+C)
|
||||
- `computer_key_down` / `computer_key_up` - Hold/release keys
|
||||
|
||||
### Clipboard
|
||||
|
||||
- `computer_clipboard_get` - Get clipboard content
|
||||
- `computer_clipboard_set` - Set clipboard content
|
||||
|
||||
### Shell
|
||||
|
||||
- `computer_run_command` - Execute shell command
|
||||
|
||||
### File System
|
||||
|
||||
- `computer_file_read` / `computer_file_write` - Read/write files
|
||||
- `computer_file_exists` / `computer_directory_exists` - Check existence
|
||||
- `computer_list_directory` - List directory contents
|
||||
- `computer_create_directory` - Create directory
|
||||
- `computer_delete_file` / `computer_delete_directory` - Delete files/directories
|
||||
|
||||
### Window Management
|
||||
|
||||
- `computer_open` - Open file or URL
|
||||
- `computer_launch_app` - Launch application
|
||||
- `computer_get_active_window` - Get active window
|
||||
- `computer_activate_window` - Focus a window
|
||||
- `computer_minimize_window` / `computer_maximize_window` - Window state
|
||||
- `computer_close_window` - Close window
|
||||
|
||||
### Accessibility
|
||||
|
||||
- `computer_get_accessibility_tree` - Get UI element tree
|
||||
- `computer_find_element` - Find UI element by role/title
|
||||
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
Computer API package.
|
||||
Provides a server interface for the Computer API.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__version__: str = "0.1.0"
|
||||
|
||||
# Explicitly export Server for static type checkers
|
||||
from .server import Server as Server # noqa: F401
|
||||
|
||||
__all__ = ["Server", "run_cli"]
|
||||
|
||||
|
||||
def run_cli() -> None:
|
||||
"""Entry point for CLI"""
|
||||
from .cli import main
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Main entry point for running the Computer Server as a module.
|
||||
This allows the server to be started with `python -m computer_server`.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from .cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
Browser manager using Playwright for programmatic browser control.
|
||||
This allows agents to control a browser that runs visibly on the XFCE desktop.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
try:
|
||||
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
||||
except ImportError:
|
||||
async_playwright = None
|
||||
Browser = None
|
||||
BrowserContext = None
|
||||
Page = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BrowserManager:
|
||||
"""
|
||||
Manages a Playwright browser instance that runs visibly on the XFCE desktop.
|
||||
Uses persistent context to maintain cookies and sessions.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the BrowserManager."""
|
||||
self.playwright = None
|
||||
self.browser: Optional[Browser] = None
|
||||
self.context: Optional[BrowserContext] = None
|
||||
self.page: Optional[Page] = None
|
||||
self._initialized = False
|
||||
self._initialization_error: Optional[str] = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
async def _ensure_initialized(self):
|
||||
"""Ensure the browser is initialized."""
|
||||
# Check if browser was closed and needs reinitialization
|
||||
if self._initialized:
|
||||
try:
|
||||
# Check if context is still valid by trying to access it
|
||||
if self.context:
|
||||
# Try to get pages - this will raise if context is closed
|
||||
_ = self.context.pages
|
||||
# If we get here, context is still alive
|
||||
return
|
||||
else:
|
||||
# Context was closed, need to reinitialize
|
||||
self._initialized = False
|
||||
logger.warning("Browser context was closed, will reinitialize...")
|
||||
except Exception as e:
|
||||
# Context is dead, need to reinitialize
|
||||
logger.warning(f"Browser context is dead ({e}), will reinitialize...")
|
||||
self._initialized = False
|
||||
self.context = None
|
||||
self.page = None
|
||||
# Clean up playwright if it exists
|
||||
if self.playwright:
|
||||
try:
|
||||
await self.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.playwright = None
|
||||
|
||||
async with self._lock:
|
||||
# Double-check after acquiring lock (another thread might have initialized it)
|
||||
if self._initialized:
|
||||
try:
|
||||
if self.context:
|
||||
_ = self.context.pages
|
||||
return
|
||||
except Exception:
|
||||
self._initialized = False
|
||||
self.context = None
|
||||
self.page = None
|
||||
if self.playwright:
|
||||
try:
|
||||
await self.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.playwright = None
|
||||
|
||||
if async_playwright is None:
|
||||
raise RuntimeError(
|
||||
"playwright is not installed. Please install it with: pip install playwright && playwright install --with-deps firefox"
|
||||
)
|
||||
|
||||
try:
|
||||
# Get display from environment or default to :1
|
||||
display = os.environ.get("DISPLAY", ":1")
|
||||
logger.info(f"Initializing browser with DISPLAY={display}")
|
||||
|
||||
# Start playwright
|
||||
self.playwright = await async_playwright().start()
|
||||
|
||||
# Launch Firefox with persistent context (keeps cookies/sessions)
|
||||
# headless=False is CRITICAL so the visual agent can see it
|
||||
user_data_dir = os.path.join(os.path.expanduser("~"), ".playwright-firefox")
|
||||
os.makedirs(user_data_dir, exist_ok=True)
|
||||
|
||||
# launch_persistent_context returns a BrowserContext, not a Browser
|
||||
# Note: Removed --kiosk mode so the desktop remains visible
|
||||
self.context = await self.playwright.firefox.launch_persistent_context(
|
||||
user_data_dir=user_data_dir,
|
||||
headless=False, # CRITICAL: visible for visual agent
|
||||
viewport={"width": 1024, "height": 768},
|
||||
# Removed --kiosk to allow desktop visibility
|
||||
)
|
||||
|
||||
# Add init script to make the browser less detectable
|
||||
await self.context.add_init_script(
|
||||
"""const defaultGetter = Object.getOwnPropertyDescriptor(
|
||||
Navigator.prototype,
|
||||
"webdriver"
|
||||
).get;
|
||||
defaultGetter.apply(navigator);
|
||||
defaultGetter.toString();
|
||||
Object.defineProperty(Navigator.prototype, "webdriver", {
|
||||
set: undefined,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: new Proxy(defaultGetter, {
|
||||
apply: (target, thisArg, args) => {
|
||||
Reflect.apply(target, thisArg, args);
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
});
|
||||
const patchedGetter = Object.getOwnPropertyDescriptor(
|
||||
Navigator.prototype,
|
||||
"webdriver"
|
||||
).get;
|
||||
patchedGetter.apply(navigator);
|
||||
patchedGetter.toString();"""
|
||||
)
|
||||
|
||||
# Get the first page or create one
|
||||
pages = self.context.pages
|
||||
if pages:
|
||||
self.page = pages[0]
|
||||
else:
|
||||
self.page = await self.context.new_page()
|
||||
|
||||
self._initialized = True
|
||||
logger.info("Browser initialized successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize browser: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
# Don't raise - return error in execute_command instead
|
||||
self._initialization_error = str(e)
|
||||
raise
|
||||
|
||||
async def _execute_command_impl(self, cmd: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Internal implementation of command execution."""
|
||||
if cmd == "visit_url":
|
||||
url = params.get("url")
|
||||
if not url:
|
||||
return {"success": False, "error": "url parameter is required"}
|
||||
await self.page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||||
return {"success": True, "url": self.page.url}
|
||||
|
||||
elif cmd == "click":
|
||||
x = params.get("x")
|
||||
y = params.get("y")
|
||||
if x is None or y is None:
|
||||
return {"success": False, "error": "x and y parameters are required"}
|
||||
await self.page.mouse.click(x, y)
|
||||
return {"success": True}
|
||||
|
||||
elif cmd == "type":
|
||||
text = params.get("text")
|
||||
if text is None:
|
||||
return {"success": False, "error": "text parameter is required"}
|
||||
await self.page.keyboard.type(text)
|
||||
return {"success": True}
|
||||
|
||||
elif cmd == "scroll":
|
||||
delta_x = params.get("delta_x", 0)
|
||||
delta_y = params.get("delta_y", 0)
|
||||
await self.page.mouse.wheel(delta_x, delta_y)
|
||||
return {"success": True}
|
||||
|
||||
elif cmd == "web_search":
|
||||
query = params.get("query")
|
||||
if not query:
|
||||
return {"success": False, "error": "query parameter is required"}
|
||||
# Navigate to Google search
|
||||
search_url = f"https://www.google.com/search?q={query}"
|
||||
await self.page.goto(search_url, wait_until="domcontentloaded", timeout=30000)
|
||||
return {"success": True, "url": self.page.url}
|
||||
|
||||
elif cmd == "screenshot":
|
||||
# Take a screenshot and return as base64
|
||||
import base64
|
||||
|
||||
screenshot_bytes = await self.page.screenshot(type="png")
|
||||
screenshot_b64 = base64.b64encode(screenshot_bytes).decode("utf-8")
|
||||
return {"success": True, "screenshot": screenshot_b64}
|
||||
|
||||
elif cmd == "get_current_url":
|
||||
# Get the current URL
|
||||
current_url = self.page.url
|
||||
return {"success": True, "url": current_url}
|
||||
|
||||
elif cmd == "go_back":
|
||||
await self.page.go_back()
|
||||
return {"success": True, "url": self.page.url}
|
||||
|
||||
elif cmd == "go_forward":
|
||||
await self.page.go_forward()
|
||||
return {"success": True, "url": self.page.url}
|
||||
|
||||
else:
|
||||
return {"success": False, "error": f"Unknown command: {cmd}"}
|
||||
|
||||
async def execute_command(self, cmd: str, params: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a browser command with automatic recovery.
|
||||
|
||||
Args:
|
||||
cmd: Command name (visit_url, click, type, scroll, web_search)
|
||||
params: Command parameters
|
||||
|
||||
Returns:
|
||||
Result dictionary with success status and any data
|
||||
"""
|
||||
max_retries = 2
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
await self._ensure_initialized()
|
||||
except Exception as e:
|
||||
error_msg = getattr(self, "_initialization_error", None) or str(e)
|
||||
logger.error(f"Browser initialization failed: {error_msg}")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Browser initialization failed: {error_msg}. "
|
||||
f"Make sure Playwright and Firefox are installed, and DISPLAY is set correctly.",
|
||||
}
|
||||
|
||||
# Check if page is still valid and get a new one if needed
|
||||
page_valid = False
|
||||
try:
|
||||
if self.page is not None and not self.page.is_closed():
|
||||
# Try to access page.url to check if it's still valid
|
||||
_ = self.page.url
|
||||
page_valid = True
|
||||
except Exception as e:
|
||||
logger.warning(f"Page is invalid: {e}, will get a new page...")
|
||||
self.page = None
|
||||
|
||||
# Get a valid page if we don't have one
|
||||
if not page_valid or self.page is None:
|
||||
try:
|
||||
if self.context:
|
||||
pages = self.context.pages
|
||||
if pages:
|
||||
# Find first non-closed page
|
||||
for p in pages:
|
||||
try:
|
||||
if not p.is_closed():
|
||||
self.page = p
|
||||
logger.info("Reusing existing open page")
|
||||
page_valid = True
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# If no valid page found, create a new one
|
||||
if not page_valid:
|
||||
self.page = await self.context.new_page()
|
||||
logger.info("Created new page")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get new page: {e}, browser may be closed")
|
||||
# Browser was closed - force reinitialization
|
||||
self._initialized = False
|
||||
self.context = None
|
||||
self.page = None
|
||||
if self.playwright:
|
||||
try:
|
||||
await self.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.playwright = None
|
||||
|
||||
# If this isn't the last attempt, continue to retry
|
||||
if attempt < max_retries - 1:
|
||||
logger.info("Browser was closed, retrying with fresh initialization...")
|
||||
continue
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Browser was closed and cannot be recovered: {e}",
|
||||
}
|
||||
|
||||
# Try to execute the command
|
||||
try:
|
||||
return await self._execute_command_impl(cmd, params)
|
||||
except Exception as e:
|
||||
error_str = str(e)
|
||||
logger.error(f"Error executing command {cmd}: {e}")
|
||||
|
||||
# Check if this is a "browser/page/context closed" error
|
||||
if any(keyword in error_str.lower() for keyword in ["closed", "target", "context"]):
|
||||
logger.warning(
|
||||
f"Browser/page was closed during command execution (attempt {attempt + 1}/{max_retries})"
|
||||
)
|
||||
|
||||
# Force reinitialization
|
||||
self._initialized = False
|
||||
self.context = None
|
||||
self.page = None
|
||||
if self.playwright:
|
||||
try:
|
||||
await self.playwright.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self.playwright = None
|
||||
|
||||
# If this isn't the last attempt, retry
|
||||
if attempt < max_retries - 1:
|
||||
logger.info("Retrying command after browser reinitialization...")
|
||||
continue
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Command failed after {max_retries} attempts: {error_str}",
|
||||
}
|
||||
else:
|
||||
# Not a browser closed error, return immediately
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return {"success": False, "error": error_str}
|
||||
|
||||
# Should never reach here, but just in case
|
||||
return {"success": False, "error": "Command failed after all retries"}
|
||||
|
||||
async def close(self):
|
||||
"""Close the browser and cleanup resources."""
|
||||
async with self._lock:
|
||||
try:
|
||||
if self.context:
|
||||
await self.context.close()
|
||||
self.context = None
|
||||
if self.browser:
|
||||
await self.browser.close()
|
||||
self.browser = None
|
||||
|
||||
if self.playwright:
|
||||
await self.playwright.stop()
|
||||
self.playwright = None
|
||||
|
||||
self.page = None
|
||||
self._initialized = False
|
||||
logger.info("Browser closed successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing browser: {e}")
|
||||
|
||||
|
||||
# Global instance
|
||||
_browser_manager: Optional[BrowserManager] = None
|
||||
|
||||
|
||||
def get_browser_manager() -> BrowserManager:
|
||||
"""Get or create the global BrowserManager instance."""
|
||||
global _browser_manager
|
||||
if _browser_manager is None:
|
||||
_browser_manager = BrowserManager()
|
||||
return _browser_manager
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Command-line interface for the Computer API server.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def parse_args(args: Optional[List[str]] = None) -> argparse.Namespace:
|
||||
"""Parse command-line arguments."""
|
||||
parser = argparse.ArgumentParser(description="Start the Computer API server")
|
||||
parser.add_argument(
|
||||
"--width",
|
||||
type=int,
|
||||
help="Target width for screenshots (coordinates will be scaled accordingly)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--height",
|
||||
type=int,
|
||||
help="Target height for screenshots (coordinates will be scaled accordingly)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--detect-resolution",
|
||||
action="store_true",
|
||||
help="Auto-detect and log the actual screen resolution at startup",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="127.0.0.1",
|
||||
help="Host to bind the server to (default: 127.0.0.1; use 0.0.0.0 for external access)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=8000, help="Port to bind the server to (default: 8000)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
choices=["debug", "info", "warning", "error", "critical"],
|
||||
default="info",
|
||||
help="Logging level (default: info)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ssl-keyfile",
|
||||
type=str,
|
||||
help="Path to SSL private key file (enables HTTPS)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ssl-certfile",
|
||||
type=str,
|
||||
help="Path to SSL certificate file (enables HTTPS)",
|
||||
)
|
||||
# Backend selection
|
||||
parser.add_argument(
|
||||
"--backend",
|
||||
choices=["native", "vnc"],
|
||||
default="native",
|
||||
help="Handler backend: 'native' uses OS-specific handlers, 'vnc' uses VNC (default: native)",
|
||||
)
|
||||
|
||||
# VNC backend options
|
||||
parser.add_argument(
|
||||
"--vnc-host",
|
||||
type=str,
|
||||
help="VNC server host (required when --backend=vnc)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vnc-port",
|
||||
type=int,
|
||||
default=5900,
|
||||
help="VNC server port (default: 5900)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--vnc-password",
|
||||
type=str,
|
||||
default="",
|
||||
help="VNC server password",
|
||||
)
|
||||
|
||||
return parser.parse_args(args)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main entry point for the CLI."""
|
||||
args = parse_args()
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, args.log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
stream=sys.stderr, # Use stderr for MCP compatibility
|
||||
)
|
||||
|
||||
# Set backend env vars from CLI args before Server import triggers handler creation
|
||||
if args.backend == "vnc" or args.vnc_host:
|
||||
if not args.vnc_host and not os.environ.get("CUA_VNC_HOST"):
|
||||
parser_err = "--vnc-host is required when using --backend=vnc"
|
||||
logger.error(parser_err)
|
||||
sys.exit(1)
|
||||
os.environ["CUA_BACKEND"] = "vnc"
|
||||
if args.vnc_host:
|
||||
os.environ["CUA_VNC_HOST"] = args.vnc_host
|
||||
# Only override env vars from CLI args when explicitly provided
|
||||
if args.vnc_port != 5900 or "CUA_VNC_PORT" not in os.environ:
|
||||
os.environ["CUA_VNC_PORT"] = str(args.vnc_port)
|
||||
if args.vnc_password:
|
||||
os.environ["CUA_VNC_PASSWORD"] = args.vnc_password
|
||||
vnc_host = args.vnc_host or os.environ.get("CUA_VNC_HOST")
|
||||
logger.info(f"VNC backend enabled → {vnc_host}:{args.vnc_port}")
|
||||
|
||||
# Create and start the server
|
||||
logger.info(f"Starting Cua Computer API server on {args.host}:{args.port}...")
|
||||
logger.info("HTTP API available at /ws, /cmd, /status endpoints")
|
||||
logger.info("MCP server available at /mcp endpoint (if fastmcp installed)")
|
||||
|
||||
# Handle SSL configuration
|
||||
ssl_args = {}
|
||||
if args.ssl_keyfile and args.ssl_certfile:
|
||||
ssl_args = {
|
||||
"ssl_keyfile": args.ssl_keyfile,
|
||||
"ssl_certfile": args.ssl_certfile,
|
||||
}
|
||||
logger.info("HTTPS mode enabled with SSL certificates")
|
||||
elif args.ssl_keyfile or args.ssl_certfile:
|
||||
logger.warning(
|
||||
"Both --ssl-keyfile and --ssl-certfile are required for HTTPS. Running in HTTP mode."
|
||||
)
|
||||
else:
|
||||
logger.info("HTTP mode (no SSL certificates provided)")
|
||||
|
||||
# Import Server lazily so env vars (e.g. CUA_VNC_HOST) are set before
|
||||
# the module-level handler factory runs in main.py.
|
||||
from .server import Server
|
||||
|
||||
server = Server(host=args.host, port=args.port, log_level=args.log_level, **ssl_args)
|
||||
|
||||
try:
|
||||
server.start()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Server stopped by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting server: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,5 @@
|
||||
class BaseDioramaHandler:
|
||||
"""Base Diorama handler for unsupported OSes."""
|
||||
|
||||
async def diorama_cmd(self, action: str, arguments: dict = None) -> dict:
|
||||
return {"success": False, "error": "Diorama is not supported on this OS yet."}
|
||||
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Diorama: A virtual desktop manager for macOS"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Union
|
||||
|
||||
from computer_server.diorama.diorama_computer import DioramaComputer
|
||||
from computer_server.diorama.draw import (
|
||||
AppActivationContext,
|
||||
capture_all_apps,
|
||||
get_all_windows,
|
||||
get_frontmost_and_active_app,
|
||||
get_running_apps,
|
||||
)
|
||||
from computer_server.handlers.macos import *
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# simple, nicely formatted logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
automation_handler = MacOSAutomationHandler()
|
||||
|
||||
|
||||
class Diorama:
|
||||
"""Virtual desktop manager that provides automation capabilities for macOS applications.
|
||||
|
||||
Manages application windows and provides an interface for taking screenshots,
|
||||
mouse interactions, keyboard input, and coordinate transformations between
|
||||
screenshot space and screen space.
|
||||
"""
|
||||
|
||||
_scheduler_queue = None
|
||||
_scheduler_task = None
|
||||
_loop = None
|
||||
_scheduler_started = False
|
||||
|
||||
@classmethod
|
||||
def create_from_apps(cls, *args) -> DioramaComputer:
|
||||
"""Create a DioramaComputer instance from a list of application names.
|
||||
|
||||
Args:
|
||||
*args: Variable number of application names to include in the desktop
|
||||
|
||||
Returns:
|
||||
DioramaComputer: A computer interface for the specified applications
|
||||
"""
|
||||
cls._ensure_scheduler()
|
||||
return cls(args).computer
|
||||
|
||||
# Dictionary to store cursor positions for each unique app_list hash
|
||||
_cursor_positions = {}
|
||||
|
||||
def __init__(self, app_list):
|
||||
"""Initialize a Diorama instance for the specified applications.
|
||||
|
||||
Args:
|
||||
app_list: List of application names to manage
|
||||
"""
|
||||
self.app_list = app_list
|
||||
self.interface = self.Interface(self)
|
||||
self.computer = DioramaComputer(self)
|
||||
self.focus_context = None
|
||||
|
||||
# Create a hash for this app_list to use as a key
|
||||
self.app_list_hash = hash(tuple(sorted(app_list)))
|
||||
|
||||
# Initialize cursor position for this app_list if it doesn't exist
|
||||
if self.app_list_hash not in Diorama._cursor_positions:
|
||||
Diorama._cursor_positions[self.app_list_hash] = (0, 0)
|
||||
|
||||
@classmethod
|
||||
def _ensure_scheduler(cls):
|
||||
"""Ensure the async scheduler loop is running.
|
||||
|
||||
Creates and starts the scheduler task if it hasn't been started yet.
|
||||
"""
|
||||
if not cls._scheduler_started:
|
||||
logger.info("Starting Diorama scheduler loop…")
|
||||
cls._scheduler_queue = asyncio.Queue()
|
||||
cls._loop = asyncio.get_event_loop()
|
||||
cls._scheduler_task = cls._loop.create_task(cls._scheduler_loop())
|
||||
cls._scheduler_started = True
|
||||
|
||||
@classmethod
|
||||
async def _scheduler_loop(cls):
|
||||
"""Main scheduler loop that processes automation commands.
|
||||
|
||||
Continuously processes commands from the scheduler queue, handling
|
||||
screenshots, mouse actions, keyboard input, and scrolling operations.
|
||||
"""
|
||||
while True:
|
||||
cmd = await cls._scheduler_queue.get()
|
||||
action = cmd.get("action")
|
||||
args = cmd.get("arguments", {})
|
||||
future = cmd.get("future")
|
||||
logger.info(f"Processing command: {action} | args={args}")
|
||||
|
||||
app_whitelist = args.get("app_list", [])
|
||||
|
||||
all_windows = get_all_windows()
|
||||
running_apps = get_running_apps()
|
||||
frontmost_app, active_app_to_use, active_app_pid = get_frontmost_and_active_app(
|
||||
all_windows, running_apps, app_whitelist
|
||||
)
|
||||
focus_context = AppActivationContext(active_app_pid, active_app_to_use, logger)
|
||||
|
||||
with focus_context:
|
||||
try:
|
||||
if action == "screenshot":
|
||||
logger.info(f"Taking screenshot for apps: {app_whitelist}")
|
||||
result, img = capture_all_apps(
|
||||
app_whitelist=app_whitelist, save_to_disk=False, take_focus=False
|
||||
)
|
||||
logger.info("Screenshot complete.")
|
||||
if future:
|
||||
future.set_result((result, img))
|
||||
# Mouse actions
|
||||
elif action in [
|
||||
"left_click",
|
||||
"right_click",
|
||||
"double_click",
|
||||
"move_cursor",
|
||||
"drag_to",
|
||||
]:
|
||||
x = args.get("x")
|
||||
y = args.get("y")
|
||||
|
||||
duration = args.get("duration", 0.5)
|
||||
if action == "left_click":
|
||||
await automation_handler.left_click(x, y)
|
||||
elif action == "right_click":
|
||||
await automation_handler.right_click(x, y)
|
||||
elif action == "double_click":
|
||||
await automation_handler.double_click(x, y)
|
||||
elif action == "move_cursor":
|
||||
await automation_handler.move_cursor(x, y)
|
||||
elif action == "drag_to":
|
||||
await automation_handler.drag_to(x, y, duration=duration)
|
||||
if future:
|
||||
future.set_result(None)
|
||||
elif action in ["scroll_up", "scroll_down"]:
|
||||
x = args.get("x")
|
||||
y = args.get("y")
|
||||
if x is not None and y is not None:
|
||||
await automation_handler.move_cursor(x, y)
|
||||
|
||||
clicks = args.get("clicks", 1)
|
||||
if action == "scroll_up":
|
||||
await automation_handler.scroll_up(clicks)
|
||||
else:
|
||||
await automation_handler.scroll_down(clicks)
|
||||
if future:
|
||||
future.set_result(None)
|
||||
# Keyboard actions
|
||||
elif action == "type_text":
|
||||
text = args.get("text")
|
||||
await automation_handler.type_text(text)
|
||||
if future:
|
||||
future.set_result(None)
|
||||
elif action == "press_key":
|
||||
key = args.get("key")
|
||||
await automation_handler.press_key(key)
|
||||
if future:
|
||||
future.set_result(None)
|
||||
elif action == "hotkey":
|
||||
keys = args.get("keys", [])
|
||||
await automation_handler.hotkey(keys)
|
||||
if future:
|
||||
future.set_result(None)
|
||||
elif action == "get_cursor_position":
|
||||
pos = await automation_handler.get_cursor_position()
|
||||
if future:
|
||||
future.set_result(pos)
|
||||
else:
|
||||
logger.warning(f"Unknown action: {action}")
|
||||
if future:
|
||||
future.set_exception(ValueError(f"Unknown action: {action}"))
|
||||
except Exception as e:
|
||||
logger.error(f"Exception during {action}: {e}", exc_info=True)
|
||||
if future:
|
||||
future.set_exception(e)
|
||||
|
||||
class Interface:
|
||||
"""Interface for interacting with the virtual desktop.
|
||||
|
||||
Provides methods for taking screenshots, mouse interactions, keyboard input,
|
||||
and coordinate transformations between screenshot and screen coordinates.
|
||||
"""
|
||||
|
||||
def __init__(self, diorama):
|
||||
"""Initialize the interface with a reference to the parent Diorama instance.
|
||||
|
||||
Args:
|
||||
diorama: The parent Diorama instance
|
||||
"""
|
||||
self._diorama = diorama
|
||||
|
||||
self._scene_hitboxes = []
|
||||
self._scene_size = None
|
||||
|
||||
async def _send_cmd(self, action, arguments=None):
|
||||
"""Send a command to the scheduler queue.
|
||||
|
||||
Args:
|
||||
action (str): The action to perform
|
||||
arguments (dict, optional): Arguments for the action
|
||||
|
||||
Returns:
|
||||
The result of the command execution
|
||||
"""
|
||||
Diorama._ensure_scheduler()
|
||||
loop = asyncio.get_event_loop()
|
||||
future = loop.create_future()
|
||||
logger.info(f"Enqueuing {action} command for apps: {self._diorama.app_list}")
|
||||
await Diorama._scheduler_queue.put(
|
||||
{
|
||||
"action": action,
|
||||
"arguments": {"app_list": self._diorama.app_list, **(arguments or {})},
|
||||
"future": future,
|
||||
}
|
||||
)
|
||||
try:
|
||||
return await future
|
||||
except asyncio.CancelledError:
|
||||
logger.warning(f"Command was cancelled: {action}")
|
||||
return None
|
||||
|
||||
async def screenshot(self, as_bytes: bool = True) -> Union[str, Image.Image]:
|
||||
"""Take a screenshot of the managed applications.
|
||||
|
||||
Args:
|
||||
as_bytes (bool): If True, return base64-encoded bytes; if False, return PIL Image
|
||||
|
||||
Returns:
|
||||
Union[str, Image.Image]: Base64-encoded PNG bytes or PIL Image object
|
||||
"""
|
||||
import base64
|
||||
|
||||
result, img = await self._send_cmd("screenshot")
|
||||
self._scene_hitboxes = result.get("hitboxes", [])
|
||||
self._scene_size = img.size
|
||||
|
||||
if as_bytes:
|
||||
# PIL Image to bytes, then base64 encode for JSON
|
||||
import io
|
||||
|
||||
img_byte_arr = io.BytesIO()
|
||||
img.save(img_byte_arr, format="PNG")
|
||||
img_bytes = img_byte_arr.getvalue()
|
||||
img_b64 = base64.b64encode(img_bytes).decode("ascii")
|
||||
return img_b64
|
||||
else:
|
||||
return img
|
||||
|
||||
async def left_click(self, x, y):
|
||||
"""Perform a left mouse click at the specified coordinates.
|
||||
|
||||
Args:
|
||||
x (int): X coordinate in screenshot space (or None to use last position)
|
||||
y (int): Y coordinate in screenshot space (or None to use last position)
|
||||
"""
|
||||
# Get last cursor position for this app_list hash
|
||||
app_list_hash = hash(tuple(sorted(self._diorama.app_list)))
|
||||
last_pos = Diorama._cursor_positions.get(app_list_hash, (0, 0))
|
||||
x, y = x or last_pos[0], y or last_pos[1]
|
||||
# Update cursor position for this app_list hash
|
||||
Diorama._cursor_positions[app_list_hash] = (x, y)
|
||||
|
||||
sx, sy = await self.to_screen_coordinates(x, y)
|
||||
await self._send_cmd("left_click", {"x": sx, "y": sy})
|
||||
|
||||
async def right_click(self, x, y):
|
||||
"""Perform a right mouse click at the specified coordinates.
|
||||
|
||||
Args:
|
||||
x (int): X coordinate in screenshot space (or None to use last position)
|
||||
y (int): Y coordinate in screenshot space (or None to use last position)
|
||||
"""
|
||||
# Get last cursor position for this app_list hash
|
||||
app_list_hash = hash(tuple(sorted(self._diorama.app_list)))
|
||||
last_pos = Diorama._cursor_positions.get(app_list_hash, (0, 0))
|
||||
x, y = x or last_pos[0], y or last_pos[1]
|
||||
# Update cursor position for this app_list hash
|
||||
Diorama._cursor_positions[app_list_hash] = (x, y)
|
||||
|
||||
sx, sy = await self.to_screen_coordinates(x, y)
|
||||
await self._send_cmd("right_click", {"x": sx, "y": sy})
|
||||
|
||||
async def double_click(self, x, y):
|
||||
"""Perform a double mouse click at the specified coordinates.
|
||||
|
||||
Args:
|
||||
x (int): X coordinate in screenshot space (or None to use last position)
|
||||
y (int): Y coordinate in screenshot space (or None to use last position)
|
||||
"""
|
||||
# Get last cursor position for this app_list hash
|
||||
app_list_hash = hash(tuple(sorted(self._diorama.app_list)))
|
||||
last_pos = Diorama._cursor_positions.get(app_list_hash, (0, 0))
|
||||
x, y = x or last_pos[0], y or last_pos[1]
|
||||
# Update cursor position for this app_list hash
|
||||
Diorama._cursor_positions[app_list_hash] = (x, y)
|
||||
|
||||
sx, sy = await self.to_screen_coordinates(x, y)
|
||||
await self._send_cmd("double_click", {"x": sx, "y": sy})
|
||||
|
||||
async def move_cursor(self, x, y):
|
||||
"""Move the mouse cursor to the specified coordinates.
|
||||
|
||||
Args:
|
||||
x (int): X coordinate in screenshot space (or None to use last position)
|
||||
y (int): Y coordinate in screenshot space (or None to use last position)
|
||||
"""
|
||||
# Get last cursor position for this app_list hash
|
||||
app_list_hash = hash(tuple(sorted(self._diorama.app_list)))
|
||||
last_pos = Diorama._cursor_positions.get(app_list_hash, (0, 0))
|
||||
x, y = x or last_pos[0], y or last_pos[1]
|
||||
# Update cursor position for this app_list hash
|
||||
Diorama._cursor_positions[app_list_hash] = (x, y)
|
||||
|
||||
sx, sy = await self.to_screen_coordinates(x, y)
|
||||
await self._send_cmd("move_cursor", {"x": sx, "y": sy})
|
||||
|
||||
async def drag_to(self, x, y, duration=0.5):
|
||||
"""Drag the mouse from current position to the specified coordinates.
|
||||
|
||||
Args:
|
||||
x (int): X coordinate in screenshot space (or None to use last position)
|
||||
y (int): Y coordinate in screenshot space (or None to use last position)
|
||||
duration (float): Duration of the drag operation in seconds
|
||||
"""
|
||||
# Get last cursor position for this app_list hash
|
||||
app_list_hash = hash(tuple(sorted(self._diorama.app_list)))
|
||||
last_pos = Diorama._cursor_positions.get(app_list_hash, (0, 0))
|
||||
x, y = x or last_pos[0], y or last_pos[1]
|
||||
# Update cursor position for this app_list hash
|
||||
Diorama._cursor_positions[app_list_hash] = (x, y)
|
||||
|
||||
sx, sy = await self.to_screen_coordinates(x, y)
|
||||
await self._send_cmd("drag_to", {"x": sx, "y": sy, "duration": duration})
|
||||
|
||||
async def get_cursor_position(self):
|
||||
"""Get the current cursor position in screen coordinates.
|
||||
|
||||
Returns:
|
||||
tuple: (x, y) coordinates of the cursor in screen space
|
||||
"""
|
||||
return await self._send_cmd("get_cursor_position")
|
||||
|
||||
async def type_text(self, text):
|
||||
"""Type the specified text using the keyboard.
|
||||
|
||||
Args:
|
||||
text (str): The text to type
|
||||
"""
|
||||
await self._send_cmd("type_text", {"text": text})
|
||||
|
||||
async def press_key(self, key):
|
||||
"""Press a single key on the keyboard.
|
||||
|
||||
Args:
|
||||
key (str): The key to press
|
||||
"""
|
||||
await self._send_cmd("press_key", {"key": key})
|
||||
|
||||
async def hotkey(self, keys):
|
||||
"""Press a combination of keys simultaneously.
|
||||
|
||||
Args:
|
||||
keys (list): List of keys to press together
|
||||
"""
|
||||
await self._send_cmd("hotkey", {"keys": list(keys)})
|
||||
|
||||
async def scroll_up(self, clicks: int = 1):
|
||||
"""Scroll up at the current cursor position.
|
||||
|
||||
Args:
|
||||
clicks (int): Number of scroll clicks to perform
|
||||
"""
|
||||
# Get last cursor position for this app_list hash
|
||||
app_list_hash = hash(tuple(sorted(self._diorama.app_list)))
|
||||
last_pos = Diorama._cursor_positions.get(app_list_hash, (0, 0))
|
||||
x, y = last_pos[0], last_pos[1]
|
||||
|
||||
await self._send_cmd("scroll_up", {"clicks": clicks, "x": x, "y": y})
|
||||
|
||||
async def scroll_down(self, clicks: int = 1):
|
||||
"""Scroll down at the current cursor position.
|
||||
|
||||
Args:
|
||||
clicks (int): Number of scroll clicks to perform
|
||||
"""
|
||||
# Get last cursor position for this app_list hash
|
||||
app_list_hash = hash(tuple(sorted(self._diorama.app_list)))
|
||||
last_pos = Diorama._cursor_positions.get(app_list_hash, (0, 0))
|
||||
x, y = last_pos[0], last_pos[1]
|
||||
|
||||
await self._send_cmd("scroll_down", {"clicks": clicks, "x": x, "y": y})
|
||||
|
||||
async def get_screen_size(self) -> dict[str, int]:
|
||||
"""Get the size of the screenshot area.
|
||||
|
||||
Returns:
|
||||
dict[str, int]: Dictionary with 'width' and 'height' keys
|
||||
"""
|
||||
if not self._scene_size:
|
||||
await self.screenshot()
|
||||
return {"width": self._scene_size[0], "height": self._scene_size[1]}
|
||||
|
||||
async def to_screen_coordinates(self, x: float, y: float) -> tuple[float, float]:
|
||||
"""Convert screenshot coordinates to screen coordinates.
|
||||
|
||||
Args:
|
||||
x: X absolute coordinate in screenshot space
|
||||
y: Y absolute coordinate in screenshot space
|
||||
|
||||
Returns:
|
||||
tuple[float, float]: (x, y) absolute coordinates in screen space
|
||||
"""
|
||||
if not self._scene_hitboxes:
|
||||
await self.screenshot() # get hitboxes
|
||||
# Try all hitboxes
|
||||
for h in self._scene_hitboxes[::-1]:
|
||||
rect_from = h.get("hitbox")
|
||||
rect_to = h.get("target")
|
||||
if not rect_from or len(rect_from) != 4:
|
||||
continue
|
||||
|
||||
# check if (x, y) is inside rect_from
|
||||
x0, y0, x1, y1 = rect_from
|
||||
if x0 <= x <= x1 and y0 <= y <= y1:
|
||||
logger.info(f"Found hitbox: {h}")
|
||||
# remap (x, y) to rect_to
|
||||
tx0, ty0, tx1, ty1 = rect_to
|
||||
|
||||
# calculate offset from x0, y0
|
||||
offset_x = x - x0
|
||||
offset_y = y - y0
|
||||
|
||||
# remap offset to rect_to
|
||||
tx = tx0 + offset_x
|
||||
ty = ty0 + offset_y
|
||||
|
||||
return tx, ty
|
||||
return x, y
|
||||
|
||||
async def to_screenshot_coordinates(self, x: float, y: float) -> tuple[float, float]:
|
||||
"""Convert screen coordinates to screenshot coordinates.
|
||||
|
||||
Args:
|
||||
x: X absolute coordinate in screen space
|
||||
y: Y absolute coordinate in screen space
|
||||
|
||||
Returns:
|
||||
tuple[float, float]: (x, y) absolute coordinates in screenshot space
|
||||
"""
|
||||
if not self._scene_hitboxes:
|
||||
await self.screenshot() # get hitboxes
|
||||
# Try all hitboxes
|
||||
for h in self._scene_hitboxes[::-1]:
|
||||
rect_from = h.get("target")
|
||||
rect_to = h.get("hitbox")
|
||||
if not rect_from or len(rect_from) != 4:
|
||||
continue
|
||||
|
||||
# check if (x, y) is inside rect_from
|
||||
x0, y0, x1, y1 = rect_from
|
||||
if x0 <= x <= x1 and y0 <= y <= y1:
|
||||
# remap (x, y) to rect_to
|
||||
tx0, ty0, tx1, ty1 = rect_to
|
||||
|
||||
# calculate offset from x0, y0
|
||||
offset_x = x - x0
|
||||
offset_y = y - y0
|
||||
|
||||
# remap offset to rect_to
|
||||
tx = tx0 + offset_x
|
||||
ty = ty0 + offset_y
|
||||
|
||||
return tx, ty
|
||||
return x, y
|
||||
|
||||
|
||||
import time
|
||||
|
||||
from pynput.mouse import Controller as MouseController
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main function demonstrating Diorama usage with multiple desktops and mouse tracking."""
|
||||
desktop1 = Diorama.create_from_apps(["Discord", "Notes"])
|
||||
desktop2 = Diorama.create_from_apps(["Terminal"])
|
||||
|
||||
img1 = await desktop1.interface.screenshot(as_bytes=False)
|
||||
img2 = await desktop2.interface.screenshot(as_bytes=False)
|
||||
|
||||
img1.save("app_screenshots/desktop1.png")
|
||||
img2.save("app_screenshots/desktop2.png")
|
||||
# Initialize Diorama desktop
|
||||
desktop3 = Diorama.create_from_apps("Safari")
|
||||
screen_size = await desktop3.interface.get_screen_size()
|
||||
print(screen_size)
|
||||
|
||||
# Take initial screenshot
|
||||
img = await desktop3.interface.screenshot(as_bytes=False)
|
||||
img.save("app_screenshots/desktop3.png")
|
||||
|
||||
# Prepare hitboxes and draw on the single screenshot
|
||||
hitboxes = desktop3.interface._scene_hitboxes[::-1]
|
||||
base_img = img.copy()
|
||||
draw = ImageDraw.Draw(base_img)
|
||||
for h in hitboxes:
|
||||
rect = h.get("hitbox")
|
||||
if not rect or len(rect) != 4:
|
||||
continue
|
||||
draw.rectangle(rect, outline="red", width=2)
|
||||
|
||||
# Track and draw mouse position in real time (single screenshot size)
|
||||
last_mouse_pos = None
|
||||
print("Tracking mouse... Press Ctrl+C to stop.")
|
||||
try:
|
||||
mouse = MouseController()
|
||||
while True:
|
||||
mx, my = mouse.position
|
||||
mouse_x, mouse_y = int(mx), int(my)
|
||||
if last_mouse_pos != (mouse_x, mouse_y):
|
||||
last_mouse_pos = (mouse_x, mouse_y)
|
||||
# Map to screenshot coordinates
|
||||
sx, sy = await desktop3.interface.to_screenshot_coordinates(mouse_x, mouse_y)
|
||||
# Draw on a copy of the screenshot
|
||||
frame = base_img.copy()
|
||||
frame_draw = ImageDraw.Draw(frame)
|
||||
frame_draw.ellipse((sx - 5, sy - 5, sx + 5, sy + 5), fill="blue", outline="blue")
|
||||
# Save the frame
|
||||
frame.save("app_screenshots/desktop3_mouse.png")
|
||||
print(f"Mouse at screen ({mouse_x}, {mouse_y}) -> screenshot ({sx:.1f}, {sy:.1f})")
|
||||
time.sleep(0.05) # Throttle updates to ~20 FPS
|
||||
except KeyboardInterrupt:
|
||||
print("Stopped tracking.")
|
||||
|
||||
draw.text((rect[0], rect[1]), str(idx), fill="red")
|
||||
|
||||
canvas.save("app_screenshots/desktop3_hitboxes.png")
|
||||
|
||||
# move mouse in a square spiral around the screen
|
||||
import math
|
||||
import random
|
||||
|
||||
step = 20 # pixels per move
|
||||
dot_radius = 10
|
||||
width = screen_size["width"]
|
||||
height = screen_size["height"]
|
||||
x, y = 0, 10
|
||||
|
||||
while x < width and y < height:
|
||||
await desktop3.interface.move_cursor(x, y)
|
||||
img = await desktop3.interface.screenshot(as_bytes=False)
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.ellipse((x - dot_radius, y - dot_radius, x + dot_radius, y + dot_radius), fill="red")
|
||||
img.save("current.png")
|
||||
await asyncio.sleep(0.03)
|
||||
x += step
|
||||
y = math.sin(x / width * math.pi * 2) * 50 + 25
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,52 @@
|
||||
import asyncio
|
||||
|
||||
|
||||
class DioramaComputer:
|
||||
"""
|
||||
A minimal Computer-like interface for Diorama, compatible with ComputerAgent.
|
||||
Implements _initialized, run(), and __aenter__ for agent compatibility.
|
||||
"""
|
||||
|
||||
def __init__(self, diorama):
|
||||
"""
|
||||
Initialize the DioramaComputer with a diorama instance.
|
||||
|
||||
Args:
|
||||
diorama: The diorama instance to wrap with a computer-like interface.
|
||||
"""
|
||||
self.diorama = diorama
|
||||
self.interface = self.diorama.interface
|
||||
self._initialized = False
|
||||
|
||||
async def __aenter__(self):
|
||||
"""
|
||||
Async context manager entry method for compatibility with ComputerAgent.
|
||||
|
||||
Ensures an event loop is running and marks the instance as initialized.
|
||||
Creates a new event loop if none is currently running.
|
||||
|
||||
Returns:
|
||||
DioramaComputer: The initialized instance.
|
||||
"""
|
||||
# Ensure the event loop is running (for compatibility)
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
asyncio.set_event_loop(asyncio.new_event_loop())
|
||||
self._initialized = True
|
||||
return self
|
||||
|
||||
async def run(self):
|
||||
"""
|
||||
Run method stub for compatibility with ComputerAgent interface.
|
||||
|
||||
Ensures the instance is initialized before returning. If not already
|
||||
initialized, calls __aenter__ to perform initialization.
|
||||
|
||||
Returns:
|
||||
DioramaComputer: The initialized instance.
|
||||
"""
|
||||
# This is a stub for compatibility
|
||||
if not self._initialized:
|
||||
await self.__aenter__()
|
||||
return self
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,36 @@
|
||||
import inspect
|
||||
import platform
|
||||
import sys
|
||||
from typing import Optional
|
||||
|
||||
from computer_server.diorama.base import BaseDioramaHandler
|
||||
from computer_server.diorama.diorama import Diorama
|
||||
|
||||
|
||||
class MacOSDioramaHandler(BaseDioramaHandler):
|
||||
"""Handler for Diorama commands on macOS, using local diorama module."""
|
||||
|
||||
async def diorama_cmd(self, action: str, arguments: Optional[dict] = None) -> dict:
|
||||
if platform.system().lower() != "darwin":
|
||||
return {"success": False, "error": "Diorama is only supported on macOS."}
|
||||
try:
|
||||
app_list = arguments.get("app_list") if arguments else None
|
||||
if not app_list:
|
||||
return {"success": False, "error": "Missing 'app_list' in arguments"}
|
||||
diorama = Diorama(app_list)
|
||||
interface = diorama.interface
|
||||
if not hasattr(interface, action):
|
||||
return {"success": False, "error": f"Unknown diorama action: {action}"}
|
||||
method = getattr(interface, action)
|
||||
# Remove app_list from arguments before calling the method
|
||||
filtered_arguments = dict(arguments)
|
||||
filtered_arguments.pop("app_list", None)
|
||||
if inspect.iscoroutinefunction(method):
|
||||
result = await method(**(filtered_arguments or {}))
|
||||
else:
|
||||
result = method(**(filtered_arguments or {}))
|
||||
return {"success": True, "result": result}
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
return {"success": False, "error": str(e), "trace": traceback.format_exc()}
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
UI Safezone Helper - A utility to get accurate bounds for macOS UI elements
|
||||
|
||||
This module provides helper functions to get accurate bounds for macOS UI elements
|
||||
like the menubar and dock, which are needed for proper screenshot composition.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
# Import Objective-C bridge libraries
|
||||
try:
|
||||
import AppKit
|
||||
import Foundation
|
||||
from AppKit import NSRunningApplication, NSWorkspace
|
||||
from ApplicationServices import (
|
||||
AXUIElementCopyAttributeValue,
|
||||
AXUIElementCopyAttributeValues,
|
||||
AXUIElementCreateApplication,
|
||||
AXUIElementCreateSystemWide,
|
||||
AXUIElementGetTypeID,
|
||||
AXValueGetType,
|
||||
AXValueGetValue,
|
||||
kAXChildrenAttribute,
|
||||
kAXErrorSuccess,
|
||||
kAXMenuBarAttribute,
|
||||
kAXPositionAttribute,
|
||||
kAXRoleAttribute,
|
||||
kAXSizeAttribute,
|
||||
kAXTitleAttribute,
|
||||
kAXValueCGPointType,
|
||||
kAXValueCGSizeType,
|
||||
)
|
||||
except ImportError:
|
||||
print("Error: This script requires PyObjC to be installed.")
|
||||
print("Please install it with: pip install pyobjc")
|
||||
sys.exit(1)
|
||||
|
||||
# Constants for accessibility API
|
||||
kAXErrorSuccess = 0
|
||||
kAXRoleAttribute = "AXRole"
|
||||
kAXSubroleAttribute = "AXSubrole"
|
||||
kAXTitleAttribute = "AXTitle"
|
||||
kAXPositionAttribute = "AXPosition"
|
||||
kAXSizeAttribute = "AXSize"
|
||||
kAXChildrenAttribute = "AXChildren"
|
||||
kAXMenuBarAttribute = "AXMenuBar"
|
||||
|
||||
|
||||
def element_attribute(element, attribute):
|
||||
"""Get an attribute from an accessibility element"""
|
||||
if attribute == kAXChildrenAttribute:
|
||||
err, value = AXUIElementCopyAttributeValues(element, attribute, 0, 999, None)
|
||||
if err == kAXErrorSuccess:
|
||||
if isinstance(value, Foundation.NSArray):
|
||||
return list(value)
|
||||
else:
|
||||
return value
|
||||
err, value = AXUIElementCopyAttributeValue(element, attribute, None)
|
||||
if err == kAXErrorSuccess:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def element_value(element, type):
|
||||
"""Get a value from an accessibility element"""
|
||||
err, value = AXValueGetValue(element, type, None)
|
||||
if err == True:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def get_element_bounds(element):
|
||||
"""Get the bounds of an accessibility element"""
|
||||
bounds = {"x": 0, "y": 0, "width": 0, "height": 0}
|
||||
|
||||
# Get position
|
||||
position_value = element_attribute(element, kAXPositionAttribute)
|
||||
if position_value:
|
||||
position_value = element_value(position_value, kAXValueCGPointType)
|
||||
if position_value:
|
||||
bounds["x"] = position_value.x
|
||||
bounds["y"] = position_value.y
|
||||
|
||||
# Get size
|
||||
size_value = element_attribute(element, kAXSizeAttribute)
|
||||
if size_value:
|
||||
size_value = element_value(size_value, kAXValueCGSizeType)
|
||||
if size_value:
|
||||
bounds["width"] = size_value.width
|
||||
bounds["height"] = size_value.height
|
||||
|
||||
return bounds
|
||||
|
||||
|
||||
def find_dock_process():
|
||||
"""Find the Dock process"""
|
||||
running_apps = NSWorkspace.sharedWorkspace().runningApplications()
|
||||
for app in running_apps:
|
||||
if app.localizedName() == "Dock" and app.bundleIdentifier() == "com.apple.dock":
|
||||
return app.processIdentifier()
|
||||
return None
|
||||
|
||||
|
||||
def get_menubar_bounds():
|
||||
"""Get the bounds of the macOS menubar
|
||||
|
||||
Returns:
|
||||
Dictionary with x, y, width, height of the menubar
|
||||
"""
|
||||
# Get the system-wide accessibility element
|
||||
system_element = AXUIElementCreateSystemWide()
|
||||
|
||||
# Try to find the menubar
|
||||
menubar = element_attribute(system_element, kAXMenuBarAttribute)
|
||||
if menubar is None:
|
||||
# If we can't get it directly, try through the frontmost app
|
||||
frontmost_app = NSWorkspace.sharedWorkspace().frontmostApplication()
|
||||
if frontmost_app:
|
||||
app_pid = frontmost_app.processIdentifier()
|
||||
app_element = AXUIElementCreateApplication(app_pid)
|
||||
menubar = element_attribute(app_element, kAXMenuBarAttribute)
|
||||
|
||||
if menubar is None:
|
||||
print("Error: Could not get menubar")
|
||||
# Return default menubar bounds as fallback
|
||||
return {"x": 0, "y": 0, "width": 1800, "height": 24}
|
||||
|
||||
# Get menubar bounds
|
||||
return get_element_bounds(menubar)
|
||||
|
||||
|
||||
def get_dock_bounds():
|
||||
"""Get the bounds of the macOS Dock
|
||||
|
||||
Returns:
|
||||
Dictionary with x, y, width, height of the Dock
|
||||
"""
|
||||
dock_pid = find_dock_process()
|
||||
if dock_pid is None:
|
||||
print("Error: Could not find Dock process")
|
||||
# Return empty bounds as fallback
|
||||
return {"x": 0, "y": 0, "width": 0, "height": 0}
|
||||
|
||||
# Create an accessibility element for the Dock
|
||||
dock_element = AXUIElementCreateApplication(dock_pid)
|
||||
if dock_element is None:
|
||||
print(f"Error: Could not create accessibility element for Dock (PID {dock_pid})")
|
||||
return {"x": 0, "y": 0, "width": 0, "height": 0}
|
||||
|
||||
# Get the Dock's children
|
||||
children = element_attribute(dock_element, kAXChildrenAttribute)
|
||||
if not children or len(children) == 0:
|
||||
print("Error: Could not get Dock children")
|
||||
return {"x": 0, "y": 0, "width": 0, "height": 0}
|
||||
|
||||
# Find the Dock's list (first child is usually the main dock list)
|
||||
dock_list = None
|
||||
for child in children:
|
||||
role = element_attribute(child, kAXRoleAttribute)
|
||||
if role == "AXList":
|
||||
dock_list = child
|
||||
break
|
||||
|
||||
if dock_list is None:
|
||||
print("Error: Could not find Dock list")
|
||||
return {"x": 0, "y": 0, "width": 0, "height": 0}
|
||||
|
||||
# Get the bounds of the dock list
|
||||
return get_element_bounds(dock_list)
|
||||
|
||||
|
||||
def get_ui_element_bounds():
|
||||
"""Get the bounds of important UI elements like menubar and dock
|
||||
|
||||
Returns:
|
||||
Dictionary with menubar and dock bounds
|
||||
"""
|
||||
menubar_bounds = get_menubar_bounds()
|
||||
dock_bounds = get_dock_bounds()
|
||||
|
||||
return {"menubar": menubar_bounds, "dock": dock_bounds}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
bounds = get_ui_element_bounds()
|
||||
print("Menubar bounds:", bounds["menubar"])
|
||||
print("Dock bounds:", bounds["dock"])
|
||||
+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)}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,846 @@
|
||||
"""
|
||||
MCP (Model Context Protocol) server interface for Computer Server.
|
||||
|
||||
This module provides an MCP interface to the Computer Server's functionality,
|
||||
enabling Claude Code and other MCP-compatible tools to directly control computers.
|
||||
|
||||
Usage:
|
||||
python -m computer_server --mcp
|
||||
python -m computer_server --mcp --width 1512 --height 982
|
||||
python -m computer_server --mcp --detect-resolution
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.utilities.types import Image
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Lazy handler initialization to avoid crashes during import
|
||||
_handlers = None
|
||||
|
||||
# Resolution scaling configuration
|
||||
_scale_x: float = 1.0
|
||||
_scale_y: float = 1.0
|
||||
_target_width: Optional[int] = None
|
||||
_target_height: Optional[int] = None
|
||||
_actual_width: Optional[int] = None
|
||||
_actual_height: Optional[int] = None
|
||||
|
||||
|
||||
def _get_handlers():
|
||||
"""Lazily initialize handlers on first use."""
|
||||
global _handlers
|
||||
if _handlers is None:
|
||||
from .handlers.factory import HandlerFactory
|
||||
|
||||
_handlers = HandlerFactory.create_handlers()
|
||||
return _handlers
|
||||
|
||||
|
||||
async def _detect_actual_resolution_async() -> Tuple[int, int]:
|
||||
"""Detect the actual screen resolution using the automation handler."""
|
||||
try:
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
result = await automation_handler.get_screen_size()
|
||||
return result["width"], result["height"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to detect resolution via handler: {e}")
|
||||
return 1920, 1080 # Default fallback
|
||||
|
||||
|
||||
def _detect_actual_resolution() -> Tuple[int, int]:
|
||||
"""Detect the actual screen resolution (sync wrapper)."""
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# If called from async context, can't use run_until_complete
|
||||
# Return default and let async init handle it
|
||||
return 1920, 1080
|
||||
return loop.run_until_complete(_detect_actual_resolution_async())
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to detect resolution: {e}")
|
||||
return 1920, 1080 # Default fallback
|
||||
|
||||
|
||||
def _configure_scaling(
|
||||
target_width: Optional[int] = None,
|
||||
target_height: Optional[int] = None,
|
||||
detect_resolution: bool = False,
|
||||
) -> None:
|
||||
"""Configure resolution scaling based on target and actual resolution."""
|
||||
global _scale_x, _scale_y, _target_width, _target_height, _actual_width, _actual_height
|
||||
|
||||
# Detect actual resolution
|
||||
_actual_width, _actual_height = _detect_actual_resolution()
|
||||
logger.info(f"Detected actual screen resolution: {_actual_width}x{_actual_height}")
|
||||
|
||||
if target_width and target_height:
|
||||
_target_width = target_width
|
||||
_target_height = target_height
|
||||
_scale_x = _actual_width / target_width
|
||||
_scale_y = _actual_height / target_height
|
||||
logger.info(
|
||||
f"Target resolution: {target_width}x{target_height}, "
|
||||
f"Scale factors: x={_scale_x:.3f}, y={_scale_y:.3f}"
|
||||
)
|
||||
else:
|
||||
# No target specified or detect-resolution mode, use actual resolution
|
||||
_target_width = _actual_width
|
||||
_target_height = _actual_height
|
||||
_scale_x = 1.0
|
||||
_scale_y = 1.0
|
||||
if detect_resolution:
|
||||
logger.info("Resolution detection enabled - coordinates will use actual resolution")
|
||||
|
||||
|
||||
def _scale_to_actual(x: int, y: int) -> Tuple[int, int]:
|
||||
"""Scale coordinates from target resolution to actual resolution."""
|
||||
return int(x * _scale_x), int(y * _scale_y)
|
||||
|
||||
|
||||
def _scale_to_target(x: int, y: int) -> Tuple[int, int]:
|
||||
"""Scale coordinates from actual resolution to target resolution."""
|
||||
if _scale_x == 0 or _scale_y == 0:
|
||||
return x, y
|
||||
return int(x / _scale_x), int(y / _scale_y)
|
||||
|
||||
|
||||
def create_mcp_server() -> FastMCP:
|
||||
"""
|
||||
Create and configure the MCP server with all computer control tools.
|
||||
|
||||
Returns:
|
||||
FastMCP: Configured MCP server instance
|
||||
"""
|
||||
mcp = FastMCP(
|
||||
name="cua-computer-server",
|
||||
instructions="""You are connected to a computer control server that provides low-level
|
||||
primitives for interacting with a desktop computer. You can take screenshots, click,
|
||||
type text, press keys, scroll, manage windows, read/write files, and run commands.
|
||||
|
||||
Always take a screenshot first to see the current state before performing actions.
|
||||
After performing actions, take another screenshot to verify the result.""",
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# SCREEN & MOUSE ACTIONS
|
||||
# ============================================================
|
||||
|
||||
@mcp.tool
|
||||
async def computer_screenshot() -> Image:
|
||||
"""
|
||||
Capture a screenshot of the current screen.
|
||||
|
||||
Returns the current screen state as an image. Always call this first
|
||||
to see what's on screen before performing any actions.
|
||||
"""
|
||||
from io import BytesIO
|
||||
|
||||
from PIL import Image as PILImage
|
||||
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
result = await automation_handler.screenshot()
|
||||
image_data = base64.b64decode(result["image_data"])
|
||||
|
||||
# Decode the image
|
||||
img = PILImage.open(BytesIO(image_data))
|
||||
|
||||
# If target resolution is set, resize to that
|
||||
if _target_width and _target_height and (_scale_x != 1.0 or _scale_y != 1.0):
|
||||
img = img.resize((_target_width, _target_height), PILImage.Resampling.LANCZOS)
|
||||
else:
|
||||
# Resize large images to keep under 1MB limit
|
||||
# Max dimension of 1280 is a good balance for visibility and size
|
||||
max_dimension = 1280
|
||||
width, height = img.size
|
||||
if width > max_dimension or height > max_dimension:
|
||||
if width > height:
|
||||
new_width = max_dimension
|
||||
new_height = int(height * max_dimension / width)
|
||||
else:
|
||||
new_height = max_dimension
|
||||
new_width = int(width * max_dimension / height)
|
||||
img = img.resize((new_width, new_height), PILImage.Resampling.LANCZOS)
|
||||
|
||||
# Initialize coordinate scaling if not already configured
|
||||
# This ensures clicks are properly scaled from resized space to actual device space
|
||||
if not _target_width:
|
||||
_configure_scaling(target_width=new_width, target_height=new_height)
|
||||
|
||||
# Convert to RGB if necessary (for JPEG compatibility)
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Use JPEG with quality setting to ensure small file size
|
||||
buffered = BytesIO()
|
||||
img.save(buffered, format="JPEG", quality=85, optimize=True)
|
||||
buffered.seek(0)
|
||||
image_data = buffered.getvalue()
|
||||
|
||||
# If still too large, reduce quality further
|
||||
if len(image_data) > 900000: # 900KB threshold
|
||||
buffered = BytesIO()
|
||||
img.save(buffered, format="JPEG", quality=70, optimize=True)
|
||||
buffered.seek(0)
|
||||
image_data = buffered.getvalue()
|
||||
|
||||
return Image(data=image_data, format="jpeg")
|
||||
|
||||
@mcp.tool
|
||||
async def computer_get_screen_size() -> Dict[str, Any]:
|
||||
"""
|
||||
Get the screen dimensions.
|
||||
|
||||
Returns:
|
||||
Dictionary with 'width' and 'height' keys in pixels.
|
||||
"""
|
||||
# Return target resolution if scaling is configured
|
||||
if _target_width is not None and _target_height is not None:
|
||||
return {"width": int(_target_width), "height": int(_target_height)}
|
||||
# Use handler to get screen size
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.get_screen_size()
|
||||
|
||||
@mcp.tool
|
||||
async def computer_get_cursor_position() -> Dict[str, Any]:
|
||||
"""
|
||||
Get the current cursor position.
|
||||
|
||||
Returns:
|
||||
Dictionary with 'x' and 'y' coordinates.
|
||||
"""
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
result = await automation_handler.get_cursor_position()
|
||||
# Scale to target coordinates if scaling is configured
|
||||
if "x" in result and "y" in result:
|
||||
scaled_x, scaled_y = _scale_to_target(int(result["x"]), int(result["y"]))
|
||||
return {"x": scaled_x, "y": scaled_y}
|
||||
return result
|
||||
|
||||
@mcp.tool
|
||||
async def computer_click(x: int, y: int, button: str = "left") -> Dict[str, Any]:
|
||||
"""
|
||||
Click at the specified screen coordinates.
|
||||
|
||||
Args:
|
||||
x: X coordinate in pixels
|
||||
y: Y coordinate in pixels
|
||||
button: Mouse button - 'left', 'right', or 'middle' (default: 'left')
|
||||
"""
|
||||
# Scale from target to actual coordinates
|
||||
actual_x, actual_y = _scale_to_actual(x, y)
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
if button == "left":
|
||||
return await automation_handler.left_click(actual_x, actual_y)
|
||||
elif button == "right":
|
||||
return await automation_handler.right_click(actual_x, actual_y)
|
||||
else:
|
||||
return await automation_handler.left_click(actual_x, actual_y)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_double_click(x: int, y: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Double-click at the specified screen coordinates.
|
||||
|
||||
Args:
|
||||
x: X coordinate in pixels
|
||||
y: Y coordinate in pixels
|
||||
"""
|
||||
# Scale from target to actual coordinates
|
||||
actual_x, actual_y = _scale_to_actual(x, y)
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.double_click(actual_x, actual_y)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_move(x: int, y: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Move the cursor to the specified screen coordinates.
|
||||
|
||||
Args:
|
||||
x: X coordinate in pixels
|
||||
y: Y coordinate in pixels
|
||||
"""
|
||||
# Scale from target to actual coordinates
|
||||
actual_x, actual_y = _scale_to_actual(x, y)
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.move_cursor(actual_x, actual_y)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_drag(
|
||||
start_x: int,
|
||||
start_y: int,
|
||||
end_x: int,
|
||||
end_y: int,
|
||||
button: str = "left",
|
||||
duration: float = 0.5,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Drag from start coordinates to end coordinates.
|
||||
|
||||
Args:
|
||||
start_x: Starting X coordinate
|
||||
start_y: Starting Y coordinate
|
||||
end_x: Ending X coordinate
|
||||
end_y: Ending Y coordinate
|
||||
button: Mouse button to use (default: 'left')
|
||||
duration: Duration of the drag in seconds (default: 0.5)
|
||||
"""
|
||||
# Scale from target to actual coordinates
|
||||
actual_start_x, actual_start_y = _scale_to_actual(start_x, start_y)
|
||||
actual_end_x, actual_end_y = _scale_to_actual(end_x, end_y)
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
# Move to start position first
|
||||
await automation_handler.move_cursor(actual_start_x, actual_start_y)
|
||||
return await automation_handler.drag_to(
|
||||
actual_end_x, actual_end_y, button=button, duration=duration
|
||||
)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_scroll(
|
||||
x: int, y: int, scroll_x: int = 0, scroll_y: int = 0
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Scroll at the specified position.
|
||||
|
||||
Args:
|
||||
x: X coordinate where to scroll
|
||||
y: Y coordinate where to scroll
|
||||
scroll_x: Horizontal scroll amount (positive = right, negative = left)
|
||||
scroll_y: Vertical scroll amount (positive = down, negative = up)
|
||||
"""
|
||||
# Scale from target to actual coordinates
|
||||
actual_x, actual_y = _scale_to_actual(x, y)
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
await automation_handler.move_cursor(actual_x, actual_y)
|
||||
return await automation_handler.scroll(scroll_x, scroll_y)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_mouse_down(
|
||||
x: Optional[int] = None, y: Optional[int] = None, button: str = "left"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Press and hold a mouse button.
|
||||
|
||||
Args:
|
||||
x: Optional X coordinate (uses current position if not specified)
|
||||
y: Optional Y coordinate (uses current position if not specified)
|
||||
button: Mouse button - 'left', 'right', or 'middle' (default: 'left')
|
||||
"""
|
||||
# Scale from target to actual coordinates if provided
|
||||
actual_x, actual_y = None, None
|
||||
if x is not None and y is not None:
|
||||
actual_x, actual_y = _scale_to_actual(x, y)
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.mouse_down(actual_x, actual_y, button=button)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_mouse_up(
|
||||
x: Optional[int] = None, y: Optional[int] = None, button: str = "left"
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Release a mouse button.
|
||||
|
||||
Args:
|
||||
x: Optional X coordinate (uses current position if not specified)
|
||||
y: Optional Y coordinate (uses current position if not specified)
|
||||
button: Mouse button - 'left', 'right', or 'middle' (default: 'left')
|
||||
"""
|
||||
# Scale from target to actual coordinates if provided
|
||||
actual_x, actual_y = None, None
|
||||
if x is not None and y is not None:
|
||||
actual_x, actual_y = _scale_to_actual(x, y)
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.mouse_up(actual_x, actual_y, button=button)
|
||||
|
||||
# ============================================================
|
||||
# KEYBOARD ACTIONS
|
||||
# ============================================================
|
||||
|
||||
@mcp.tool
|
||||
async def computer_type(text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Type text at the current cursor position.
|
||||
|
||||
Args:
|
||||
text: The text to type
|
||||
"""
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.type_text(text)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_press_key(key: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Press a single key.
|
||||
|
||||
Args:
|
||||
key: The key to press (e.g., 'enter', 'tab', 'escape', 'a', 'b', etc.)
|
||||
"""
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.press_key(key)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_hotkey(keys: List[str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Press a key combination (hotkey).
|
||||
|
||||
Args:
|
||||
keys: List of keys to press together (e.g., ['ctrl', 'c'] for copy,
|
||||
['cmd', 'v'] for paste on Mac, ['alt', 'tab'] for window switch)
|
||||
"""
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.hotkey(keys)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_key_down(key: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Press and hold a key.
|
||||
|
||||
Args:
|
||||
key: The key to hold down
|
||||
"""
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.key_down(key)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_key_up(key: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Release a held key.
|
||||
|
||||
Args:
|
||||
key: The key to release
|
||||
"""
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.key_up(key)
|
||||
|
||||
# ============================================================
|
||||
# CLIPBOARD ACTIONS
|
||||
# ============================================================
|
||||
|
||||
@mcp.tool
|
||||
async def computer_clipboard_get() -> Dict[str, Any]:
|
||||
"""
|
||||
Get the current clipboard content.
|
||||
|
||||
Returns:
|
||||
Dictionary with clipboard content.
|
||||
"""
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.copy_to_clipboard()
|
||||
|
||||
@mcp.tool
|
||||
async def computer_clipboard_set(text: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Set the clipboard content.
|
||||
|
||||
Args:
|
||||
text: Text to copy to clipboard
|
||||
"""
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.set_clipboard(text)
|
||||
|
||||
# ============================================================
|
||||
# SHELL COMMANDS
|
||||
# ============================================================
|
||||
|
||||
@mcp.tool
|
||||
async def computer_run_command(command: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a shell command and return the output.
|
||||
|
||||
Args:
|
||||
command: The shell command to execute
|
||||
|
||||
Returns:
|
||||
Dictionary with 'stdout', 'stderr', and 'returncode' keys.
|
||||
"""
|
||||
_, automation_handler, _, _, _, _ = _get_handlers()
|
||||
return await automation_handler.run_command(command)
|
||||
|
||||
# ============================================================
|
||||
# FILE SYSTEM OPERATIONS
|
||||
# ============================================================
|
||||
|
||||
@mcp.tool
|
||||
async def computer_file_read(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Read the text content of a file.
|
||||
|
||||
Args:
|
||||
path: Path to the file to read
|
||||
|
||||
Returns:
|
||||
Dictionary with 'content' key containing the file contents.
|
||||
"""
|
||||
_, _, _, file_handler, _, _ = _get_handlers()
|
||||
return await file_handler.read_text(path)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_file_write(path: str, content: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Write text content to a file.
|
||||
|
||||
Args:
|
||||
path: Path to the file to write
|
||||
content: Text content to write
|
||||
"""
|
||||
_, _, _, file_handler, _, _ = _get_handlers()
|
||||
return await file_handler.write_text(path, content)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_file_exists(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if a file exists.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
Dictionary with 'exists' boolean key.
|
||||
"""
|
||||
_, _, _, file_handler, _, _ = _get_handlers()
|
||||
return await file_handler.file_exists(path)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_directory_exists(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if a directory exists.
|
||||
|
||||
Args:
|
||||
path: Path to check
|
||||
|
||||
Returns:
|
||||
Dictionary with 'exists' boolean key.
|
||||
"""
|
||||
_, _, _, file_handler, _, _ = _get_handlers()
|
||||
return await file_handler.directory_exists(path)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_list_directory(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
List the contents of a directory.
|
||||
|
||||
Args:
|
||||
path: Path to the directory
|
||||
|
||||
Returns:
|
||||
Dictionary with 'entries' list containing file/folder names.
|
||||
"""
|
||||
_, _, _, file_handler, _, _ = _get_handlers()
|
||||
return await file_handler.list_dir(path)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_create_directory(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Create a directory.
|
||||
|
||||
Args:
|
||||
path: Path of the directory to create
|
||||
"""
|
||||
_, _, _, file_handler, _, _ = _get_handlers()
|
||||
return await file_handler.create_dir(path)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_delete_file(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a file.
|
||||
|
||||
Args:
|
||||
path: Path to the file to delete
|
||||
"""
|
||||
_, _, _, file_handler, _, _ = _get_handlers()
|
||||
return await file_handler.delete_file(path)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_delete_directory(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Delete a directory.
|
||||
|
||||
Args:
|
||||
path: Path to the directory to delete
|
||||
"""
|
||||
_, _, _, file_handler, _, _ = _get_handlers()
|
||||
return await file_handler.delete_dir(path)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_get_file_size(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the size of a file in bytes.
|
||||
|
||||
Args:
|
||||
path: Path to the file
|
||||
|
||||
Returns:
|
||||
Dictionary with 'size' key in bytes.
|
||||
"""
|
||||
_, _, _, file_handler, _, _ = _get_handlers()
|
||||
return await file_handler.get_file_size(path)
|
||||
|
||||
# ============================================================
|
||||
# WINDOW MANAGEMENT
|
||||
# ============================================================
|
||||
|
||||
@mcp.tool
|
||||
async def computer_open(target: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Open a file or URL with the default application.
|
||||
|
||||
Args:
|
||||
target: File path or URL to open
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.open(target)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_launch_app(app: str, args: Optional[List[str]] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Launch an application.
|
||||
|
||||
Args:
|
||||
app: Application name or path
|
||||
args: Optional list of arguments to pass to the application
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.launch(app, args)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_get_active_window() -> Dict[str, Any]:
|
||||
"""
|
||||
Get the currently active window ID.
|
||||
|
||||
Returns:
|
||||
Dictionary with 'window_id' key.
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.get_current_window_id()
|
||||
|
||||
@mcp.tool
|
||||
async def computer_get_window_name(window_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the title/name of a window.
|
||||
|
||||
Args:
|
||||
window_id: Window identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with 'name' key.
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.get_window_name(window_id)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_get_window_size(window_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the size of a window.
|
||||
|
||||
Args:
|
||||
window_id: Window identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with 'width' and 'height' keys.
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.get_window_size(window_id)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_get_window_position(window_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get the position of a window.
|
||||
|
||||
Args:
|
||||
window_id: Window identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with 'x' and 'y' keys.
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.get_window_position(window_id)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_set_window_size(window_id: str, width: int, height: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Set the size of a window.
|
||||
|
||||
Args:
|
||||
window_id: Window identifier
|
||||
width: New width in pixels
|
||||
height: New height in pixels
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.set_window_size(window_id, width, height)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_set_window_position(window_id: str, x: int, y: int) -> Dict[str, Any]:
|
||||
"""
|
||||
Set the position of a window.
|
||||
|
||||
Args:
|
||||
window_id: Window identifier
|
||||
x: New X position
|
||||
y: New Y position
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.set_window_position(window_id, x, y)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_activate_window(window_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Bring a window to the foreground and focus it.
|
||||
|
||||
Args:
|
||||
window_id: Window identifier
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.activate_window(window_id)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_minimize_window(window_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Minimize a window.
|
||||
|
||||
Args:
|
||||
window_id: Window identifier
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.minimize_window(window_id)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_maximize_window(window_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Maximize a window.
|
||||
|
||||
Args:
|
||||
window_id: Window identifier
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.maximize_window(window_id)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_close_window(window_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Close a window.
|
||||
|
||||
Args:
|
||||
window_id: Window identifier
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.close_window(window_id)
|
||||
|
||||
@mcp.tool
|
||||
async def computer_get_app_windows(app: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all windows belonging to an application.
|
||||
|
||||
Args:
|
||||
app: Application name or bundle identifier
|
||||
|
||||
Returns:
|
||||
Dictionary with 'windows' list.
|
||||
"""
|
||||
_, _, _, _, _, window_handler = _get_handlers()
|
||||
return await window_handler.get_application_windows(app)
|
||||
|
||||
# ============================================================
|
||||
# DESKTOP ENVIRONMENT
|
||||
# ============================================================
|
||||
|
||||
@mcp.tool
|
||||
async def computer_get_desktop_environment() -> Dict[str, Any]:
|
||||
"""
|
||||
Get information about the current desktop environment.
|
||||
|
||||
Returns:
|
||||
Dictionary with desktop environment details.
|
||||
"""
|
||||
_, _, _, _, desktop_handler, _ = _get_handlers()
|
||||
return await desktop_handler.get_desktop_environment()
|
||||
|
||||
@mcp.tool
|
||||
async def computer_set_wallpaper(path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Set the desktop wallpaper.
|
||||
|
||||
Args:
|
||||
path: Path to the image file to use as wallpaper
|
||||
"""
|
||||
_, _, _, _, desktop_handler, _ = _get_handlers()
|
||||
return await desktop_handler.set_wallpaper(path)
|
||||
|
||||
# ============================================================
|
||||
# ACCESSIBILITY
|
||||
# ============================================================
|
||||
|
||||
@mcp.tool
|
||||
async def computer_get_accessibility_tree() -> Dict[str, Any]:
|
||||
"""
|
||||
Get the accessibility tree of the current window.
|
||||
|
||||
This provides detailed information about UI elements that can be
|
||||
useful for finding clickable elements or understanding the UI structure.
|
||||
|
||||
Returns:
|
||||
Dictionary with the accessibility tree structure.
|
||||
"""
|
||||
accessibility_handler, _, _, _, _, _ = _get_handlers()
|
||||
return await accessibility_handler.get_accessibility_tree()
|
||||
|
||||
@mcp.tool
|
||||
async def computer_find_element(
|
||||
role: Optional[str] = None, title: Optional[str] = None, value: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Find a UI element in the accessibility tree.
|
||||
|
||||
Args:
|
||||
role: Element role/type to search for (e.g., 'button', 'textfield')
|
||||
title: Element title/label to search for
|
||||
value: Element value to search for
|
||||
|
||||
Returns:
|
||||
Dictionary with matching element information including position.
|
||||
"""
|
||||
accessibility_handler, _, _, _, _, _ = _get_handlers()
|
||||
return await accessibility_handler.find_element(role=role, title=title, value=value)
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
def run_mcp_server(
|
||||
target_width: Optional[int] = None,
|
||||
target_height: Optional[int] = None,
|
||||
detect_resolution: bool = False,
|
||||
) -> None:
|
||||
"""Run the MCP server.
|
||||
|
||||
Args:
|
||||
target_width: Target width for screenshots (coordinates will be scaled accordingly)
|
||||
target_height: Target height for screenshots (coordinates will be scaled accordingly)
|
||||
detect_resolution: If True, auto-detect and log the actual screen resolution
|
||||
"""
|
||||
logger.info("Starting CUA Computer MCP server...")
|
||||
|
||||
# Configure resolution scaling
|
||||
_configure_scaling(
|
||||
target_width=target_width,
|
||||
target_height=target_height,
|
||||
detect_resolution=detect_resolution,
|
||||
)
|
||||
|
||||
mcp = create_mcp_server()
|
||||
mcp.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
run_mcp_server()
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Async PTY session manager for computer-server.
|
||||
|
||||
Wraps :class:`cua_auto.terminal.Terminal` and adds asyncio-compatible
|
||||
output broadcasting via per-consumer :class:`asyncio.Queue` objects.
|
||||
|
||||
Queue items are dicts:
|
||||
- ``{"type": "output", "data": <bytes>}`` — terminal output chunk
|
||||
- ``{"type": "exit", "code": <int>}`` — session terminated (sentinel)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PtyManager:
|
||||
"""Manage PTY sessions with async broadcast queues.
|
||||
|
||||
Usage::
|
||||
|
||||
mgr = PtyManager()
|
||||
|
||||
# Create a session
|
||||
info = await mgr.create(command="bash", cols=80, rows=24)
|
||||
pid = info["pid"]
|
||||
|
||||
# Subscribe to output
|
||||
q = mgr.subscribe(pid)
|
||||
async for msg in _drain(q):
|
||||
... # msg is {"type": "output", "data": b"..."} or {"type": "exit", "code": 0}
|
||||
|
||||
# Write to stdin
|
||||
await mgr.send_stdin(pid, b"echo hello\\n")
|
||||
|
||||
# Resize
|
||||
await mgr.resize(pid, 120, 40)
|
||||
|
||||
# Kill
|
||||
await mgr.kill(pid)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# pid → list of subscriber queues
|
||||
self._queues: Dict[int, List[asyncio.Queue]] = {}
|
||||
# pid → {"cols": int, "rows": int}
|
||||
self._sessions: Dict[int, dict] = {}
|
||||
self._terminal = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Lazy terminal access
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_terminal(self):
|
||||
if self._terminal is None:
|
||||
from cua_auto.terminal import Terminal
|
||||
|
||||
self._terminal = Terminal()
|
||||
return self._terminal
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public async API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def create(
|
||||
self,
|
||||
command: Optional[str] = None,
|
||||
cols: int = 80,
|
||||
rows: int = 24,
|
||||
cwd: Optional[str] = None,
|
||||
envs: Optional[dict] = None,
|
||||
) -> dict:
|
||||
"""Spawn a new PTY session.
|
||||
|
||||
Returns:
|
||||
``{"pid": int, "cols": int, "rows": int}``
|
||||
"""
|
||||
loop = asyncio.get_running_loop()
|
||||
terminal = self._get_terminal()
|
||||
|
||||
# We need pid to route output, but we only know it after create().
|
||||
# Use a mutable cell so the callback can look up the pid after creation.
|
||||
# early_buffer holds chunks that arrive before pid_cell[0] is set (the
|
||||
# reader thread can fire before asyncio.to_thread returns the session).
|
||||
pid_cell: List[Optional[int]] = [None]
|
||||
early_buffer: List[bytes] = []
|
||||
|
||||
def _on_data(data: bytes) -> None:
|
||||
pid = pid_cell[0]
|
||||
if pid is None:
|
||||
early_buffer.append(data)
|
||||
return
|
||||
msg = {"type": "output", "data": data}
|
||||
for q in list(self._queues.get(pid, [])):
|
||||
try:
|
||||
loop.call_soon_threadsafe(q.put_nowait, msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
session = await asyncio.to_thread(
|
||||
terminal.create,
|
||||
command=command,
|
||||
cols=cols,
|
||||
rows=rows,
|
||||
on_data=_on_data,
|
||||
cwd=cwd,
|
||||
envs=envs,
|
||||
)
|
||||
|
||||
pid_cell[0] = session.pid
|
||||
self._queues[session.pid] = []
|
||||
self._sessions[session.pid] = {"cols": session.cols, "rows": session.rows}
|
||||
|
||||
# Flush any output that arrived before pid_cell[0] was set.
|
||||
for chunk in early_buffer:
|
||||
msg = {"type": "output", "data": chunk}
|
||||
for q in list(self._queues[session.pid]):
|
||||
try:
|
||||
loop.call_soon_threadsafe(q.put_nowait, msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Watch for process exit in a daemon thread, then broadcast sentinel.
|
||||
def _watch_exit() -> None:
|
||||
exit_code = terminal.wait(session.pid) or 0
|
||||
sentinel = {"type": "exit", "code": exit_code}
|
||||
for q in list(self._queues.get(session.pid, [])):
|
||||
try:
|
||||
loop.call_soon_threadsafe(q.put_nowait, sentinel)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
threading.Thread(target=_watch_exit, daemon=True, name=f"pty-exit-{session.pid}").start()
|
||||
|
||||
logger.info(
|
||||
"PTY session created: pid=%d cmd=%r cols=%d rows=%d", session.pid, command, cols, rows
|
||||
)
|
||||
return {"pid": session.pid, "cols": session.cols, "rows": session.rows}
|
||||
|
||||
async def send_stdin(self, pid: int, data: bytes) -> None:
|
||||
"""Write *data* to the stdin of session *pid*."""
|
||||
terminal = self._get_terminal()
|
||||
await asyncio.to_thread(terminal.send_stdin, pid, data)
|
||||
|
||||
async def resize(self, pid: int, cols: int, rows: int) -> None:
|
||||
"""Resize the terminal for session *pid*."""
|
||||
terminal = self._get_terminal()
|
||||
await asyncio.to_thread(terminal.resize, pid, cols, rows)
|
||||
if pid in self._sessions:
|
||||
self._sessions[pid]["cols"] = cols
|
||||
self._sessions[pid]["rows"] = rows
|
||||
|
||||
def get_info(self, pid: int) -> Optional[dict]:
|
||||
"""Return ``{"pid": int, "cols": int, "rows": int}`` for *pid*, or ``None`` if unknown."""
|
||||
info = self._sessions.get(pid)
|
||||
if info is None:
|
||||
return None
|
||||
return {"pid": pid, "cols": info["cols"], "rows": info["rows"]}
|
||||
|
||||
async def kill(self, pid: int) -> bool:
|
||||
"""Kill session *pid*.
|
||||
|
||||
Also broadcasts the exit sentinel to all subscribers.
|
||||
Returns:
|
||||
``True`` if the signal was delivered.
|
||||
"""
|
||||
terminal = self._get_terminal()
|
||||
result = await asyncio.to_thread(terminal.kill, pid)
|
||||
# Broadcast sentinel immediately (the exit watcher will also fire,
|
||||
# but duplicate sentinels are harmless — consumers stop after the first).
|
||||
sentinel = {"type": "exit", "code": -1}
|
||||
for q in list(self._queues.get(pid, [])):
|
||||
try:
|
||||
q.put_nowait(sentinel)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Queue-based pub/sub
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def subscribe(self, pid: int) -> asyncio.Queue:
|
||||
"""Return a new :class:`asyncio.Queue` that will receive output for *pid*.
|
||||
|
||||
The queue receives dicts with keys ``type`` (``"output"`` or ``"exit"``)
|
||||
and either ``data`` (bytes) or ``code`` (int).
|
||||
"""
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
self._queues.setdefault(pid, []).append(q)
|
||||
return q
|
||||
|
||||
def unsubscribe(self, pid: int, queue: asyncio.Queue) -> None:
|
||||
"""Remove *queue* from the subscriber list for *pid*."""
|
||||
qs = self._queues.get(pid, [])
|
||||
try:
|
||||
qs.remove(queue)
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Server interface for Computer API.
|
||||
Provides a clean API for starting and stopping the server.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from .main import app as fastapi_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Server:
|
||||
"""
|
||||
Server interface for Computer API.
|
||||
|
||||
Usage:
|
||||
from computer_api import Server
|
||||
|
||||
# Synchronous usage
|
||||
server = Server()
|
||||
server.start() # Blocks until server is stopped
|
||||
|
||||
# Asynchronous usage
|
||||
server = Server()
|
||||
await server.start_async() # Starts server in background
|
||||
# Do other things
|
||||
await server.stop() # Stop the server
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 8000,
|
||||
log_level: str = "info",
|
||||
ssl_keyfile: Optional[str] = None,
|
||||
ssl_certfile: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the server.
|
||||
|
||||
Args:
|
||||
host: Host to bind the server to. Defaults to localhost; pass
|
||||
"0.0.0.0" explicitly for external access.
|
||||
port: Port to bind the server to
|
||||
log_level: Logging level (debug, info, warning, error, critical)
|
||||
ssl_keyfile: Path to SSL private key file (for HTTPS)
|
||||
ssl_certfile: Path to SSL certificate file (for HTTPS)
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.log_level = log_level
|
||||
self.ssl_keyfile = ssl_keyfile
|
||||
self.ssl_certfile = ssl_certfile
|
||||
self.app = fastapi_app
|
||||
self._server_task: Optional[asyncio.Task] = None
|
||||
self._should_exit = asyncio.Event()
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
Start the server synchronously. This will block until the server is stopped.
|
||||
"""
|
||||
uvicorn.run(
|
||||
self.app,
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
log_level=self.log_level,
|
||||
ssl_keyfile=self.ssl_keyfile,
|
||||
ssl_certfile=self.ssl_certfile,
|
||||
)
|
||||
|
||||
async def start_async(self) -> None:
|
||||
"""
|
||||
Start the server asynchronously. This will return immediately and the server
|
||||
will run in the background.
|
||||
"""
|
||||
server_config = uvicorn.Config(
|
||||
self.app,
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
log_level=self.log_level,
|
||||
ssl_keyfile=self.ssl_keyfile,
|
||||
ssl_certfile=self.ssl_certfile,
|
||||
)
|
||||
|
||||
self._should_exit.clear()
|
||||
server = uvicorn.Server(server_config)
|
||||
|
||||
# Create a task to run the server
|
||||
self._server_task = asyncio.create_task(server.serve())
|
||||
|
||||
# Wait a short time to ensure the server starts
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
protocol = "https" if self.ssl_certfile else "http"
|
||||
logger.info(f"Server started at {protocol}://{self.host}:{self.port}")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""
|
||||
Stop the server if it's running asynchronously.
|
||||
"""
|
||||
if self._server_task and not self._server_task.done():
|
||||
# Signal the server to exit
|
||||
self._should_exit.set()
|
||||
|
||||
# Cancel the server task
|
||||
self._server_task.cancel()
|
||||
|
||||
try:
|
||||
await self._server_task
|
||||
except asyncio.CancelledError:
|
||||
logger.info("Server stopped")
|
||||
|
||||
self._server_task = None
|
||||
@@ -0,0 +1,3 @@
|
||||
from . import helpers, wallpaper
|
||||
|
||||
__all__ = ["helpers", "wallpaper"]
|
||||
@@ -0,0 +1,81 @@
|
||||
import asyncio
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import overload
|
||||
|
||||
|
||||
def get_current_os() -> str:
|
||||
"""Determine the current OS.
|
||||
|
||||
Returns:
|
||||
str: The OS type ('android', 'darwin' for macOS, 'linux' for Linux, or 'windows' for Windows)
|
||||
|
||||
Raises:
|
||||
RuntimeError: If unable to determine the current OS
|
||||
"""
|
||||
try:
|
||||
if os.environ.get("IS_CUA_ANDROID") == "true":
|
||||
# Verify emulator is actually running by checking adb devices
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["adb", "devices"], capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0 and "emulator-5554" in result.stdout:
|
||||
return "android"
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"IS_CUA_ANDROID is set but no emulator found. "
|
||||
"Ensure Android emulator is running and accessible via adb."
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
raise RuntimeError(
|
||||
"IS_CUA_ANDROID is set but adb command timed out. "
|
||||
"Emulator may be starting up or unresponsive."
|
||||
)
|
||||
|
||||
system = platform.system().lower()
|
||||
if system in ["darwin", "linux", "windows"]:
|
||||
return system
|
||||
|
||||
# Fallback to uname if platform.system() doesn't return expected values (Unix-like systems only)
|
||||
result = subprocess.run(["uname", "-s"], capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip().lower()
|
||||
|
||||
raise RuntimeError(f"Unsupported OS: {system}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to determine current OS: {str(e)}")
|
||||
|
||||
|
||||
class CommandExecutor:
|
||||
def __init__(self, *base_cmd: str) -> None:
|
||||
"""Initialize with a base command.
|
||||
|
||||
Args:
|
||||
base_cmd: The base command and its initial arguments.
|
||||
"""
|
||||
self.__base_cmd = list(base_cmd)
|
||||
|
||||
@overload
|
||||
async def run(self, *args: str, timeout: int = 10) -> tuple[bool, bytes]: ...
|
||||
@overload
|
||||
async def run(self, *args: str, decode: bool = True, timeout: int = 10) -> tuple[bool, str]: ...
|
||||
|
||||
async def run(
|
||||
self, *args: str, decode: bool = False, timeout: int = 10
|
||||
) -> tuple[bool, bytes | str]:
|
||||
cmd = self.__base_cmd + list(args)
|
||||
try:
|
||||
result = await asyncio.create_subprocess_exec(
|
||||
*cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(result.communicate(), timeout=timeout)
|
||||
output = stdout or stderr
|
||||
if decode:
|
||||
output = output.decode("utf-8")
|
||||
return result.returncode == 0, output
|
||||
except asyncio.TimeoutError:
|
||||
return False, f"Command timed out after {timeout}s".encode("utf-8")
|
||||
except Exception as e:
|
||||
return False, str(e).encode("utf-8")
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Set the desktop wallpaper."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_desktop_environment() -> str:
|
||||
"""
|
||||
Returns the name of the current desktop environment.
|
||||
"""
|
||||
# From https://stackoverflow.com/a/21213358/2624876
|
||||
# which takes from:
|
||||
# http://stackoverflow.com/questions/2035657/what-is-my-current-desktop-environment
|
||||
# and http://ubuntuforums.org/showthread.php?t=652320
|
||||
# and http://ubuntuforums.org/showthread.php?t=1139057
|
||||
if sys.platform in ["win32", "cygwin"]:
|
||||
return "windows"
|
||||
elif sys.platform == "darwin":
|
||||
return "mac"
|
||||
else: # Most likely either a POSIX system or something not much common
|
||||
desktop_session = os.environ.get("DESKTOP_SESSION")
|
||||
if (
|
||||
desktop_session is not None
|
||||
): # easier to match if we doesn't have to deal with character cases
|
||||
desktop_session = desktop_session.lower()
|
||||
if desktop_session in [
|
||||
"gnome",
|
||||
"unity",
|
||||
"cinnamon",
|
||||
"mate",
|
||||
"xfce4",
|
||||
"lxde",
|
||||
"fluxbox",
|
||||
"blackbox",
|
||||
"openbox",
|
||||
"icewm",
|
||||
"jwm",
|
||||
"afterstep",
|
||||
"trinity",
|
||||
"kde",
|
||||
]:
|
||||
return desktop_session
|
||||
## Special cases ##
|
||||
# Canonical sets $DESKTOP_SESSION to Lubuntu rather than LXDE if using LXDE.
|
||||
# There is no guarantee that they will not do the same with the other desktop environments.
|
||||
elif "xfce" in desktop_session or desktop_session.startswith("xubuntu"):
|
||||
return "xfce4"
|
||||
elif desktop_session.startswith("ubuntustudio"):
|
||||
return "kde"
|
||||
elif desktop_session.startswith("ubuntu"):
|
||||
return "gnome"
|
||||
elif desktop_session.startswith("lubuntu"):
|
||||
return "lxde"
|
||||
elif desktop_session.startswith("kubuntu"):
|
||||
return "kde"
|
||||
elif desktop_session.startswith("razor"): # e.g. razorkwin
|
||||
return "razor-qt"
|
||||
elif desktop_session.startswith("wmaker"): # e.g. wmaker-common
|
||||
return "windowmaker"
|
||||
gnome_desktop_session_id = os.environ.get("GNOME_DESKTOP_SESSION_ID")
|
||||
if os.environ.get("KDE_FULL_SESSION") == "true":
|
||||
return "kde"
|
||||
elif gnome_desktop_session_id:
|
||||
if "deprecated" not in gnome_desktop_session_id:
|
||||
return "gnome2"
|
||||
# From http://ubuntuforums.org/showthread.php?t=652320
|
||||
elif is_running("xfce-mcs-manage"):
|
||||
return "xfce4"
|
||||
elif is_running("ksmserver"):
|
||||
return "kde"
|
||||
return "unknown"
|
||||
|
||||
|
||||
def is_running(process: str) -> bool:
|
||||
"""Returns whether a process with the given name is (likely) currently running.
|
||||
|
||||
Uses a basic text search, and so may have false positives.
|
||||
"""
|
||||
# From http://www.bloggerpolis.com/2011/05/how-to-check-if-a-process-is-running-using-python/
|
||||
# and http://richarddingwall.name/2009/06/18/windows-equivalents-of-ps-and-kill-commands/
|
||||
try: # Linux/Unix
|
||||
s = subprocess.Popen(["ps", "axw"], stdout=subprocess.PIPE)
|
||||
except: # Windows
|
||||
s = subprocess.Popen(["tasklist", "/v"], stdout=subprocess.PIPE)
|
||||
assert s.stdout is not None
|
||||
for x in s.stdout:
|
||||
# if re.search(process, x):
|
||||
if process in str(x):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def set_wallpaper(file_loc: str, first_run: bool = True):
|
||||
"""Sets the wallpaper to the given file location."""
|
||||
# From https://stackoverflow.com/a/21213504/2624876
|
||||
# I have not personally tested most of this. -- @1j01
|
||||
# -----------------------------------------
|
||||
|
||||
# Note: There are two common Linux desktop environments where
|
||||
# I have not been able to set the desktop background from
|
||||
# command line: KDE, Enlightenment
|
||||
desktop_env = get_desktop_environment()
|
||||
if desktop_env in ["gnome", "unity", "cinnamon"]:
|
||||
# Tested on Ubuntu 22 -- @1j01
|
||||
uri = Path(file_loc).as_uri()
|
||||
SCHEMA = "org.gnome.desktop.background"
|
||||
KEY = "picture-uri"
|
||||
# Needed for Ubuntu 22 in dark mode
|
||||
# Might be better to set only one or the other, depending on the current theme
|
||||
# In the settings it will say "This background selection only applies to the dark style"
|
||||
# even if it's set for both, arguably referring to the selection that you can make on that page.
|
||||
# -- @1j01
|
||||
KEY_DARK = "picture-uri-dark"
|
||||
try:
|
||||
from gi.repository import Gio # type: ignore
|
||||
|
||||
gsettings = Gio.Settings.new(SCHEMA) # type: ignore
|
||||
gsettings.set_string(KEY, uri)
|
||||
gsettings.set_string(KEY_DARK, uri)
|
||||
except Exception:
|
||||
# Fallback tested on Ubuntu 22 -- @1j01
|
||||
args = ["gsettings", "set", SCHEMA, KEY, uri]
|
||||
subprocess.Popen(args)
|
||||
args = ["gsettings", "set", SCHEMA, KEY_DARK, uri]
|
||||
subprocess.Popen(args)
|
||||
elif desktop_env == "mate":
|
||||
try: # MATE >= 1.6
|
||||
# info from http://wiki.mate-desktop.org/docs:gsettings
|
||||
args = ["gsettings", "set", "org.mate.background", "picture-filename", file_loc]
|
||||
subprocess.Popen(args)
|
||||
except Exception: # MATE < 1.6
|
||||
# From https://bugs.launchpad.net/variety/+bug/1033918
|
||||
args = [
|
||||
"mateconftool-2",
|
||||
"-t",
|
||||
"string",
|
||||
"--set",
|
||||
"/desktop/mate/background/picture_filename",
|
||||
file_loc,
|
||||
]
|
||||
subprocess.Popen(args)
|
||||
elif desktop_env == "gnome2": # Not tested
|
||||
# From https://bugs.launchpad.net/variety/+bug/1033918
|
||||
args = [
|
||||
"gconftool-2",
|
||||
"-t",
|
||||
"string",
|
||||
"--set",
|
||||
"/desktop/gnome/background/picture_filename",
|
||||
file_loc,
|
||||
]
|
||||
subprocess.Popen(args)
|
||||
## KDE4 is difficult
|
||||
## see http://blog.zx2c4.com/699 for a solution that might work
|
||||
elif desktop_env in ["kde3", "trinity"]:
|
||||
# From http://ubuntuforums.org/archive/index.php/t-803417.html
|
||||
args = ["dcop", "kdesktop", "KBackgroundIface", "setWallpaper", "0", file_loc, "6"]
|
||||
subprocess.Popen(args)
|
||||
elif desktop_env == "xfce4":
|
||||
# Iterate over all wallpaper-related keys and set to file_loc
|
||||
try:
|
||||
list_proc = subprocess.run(
|
||||
["xfconf-query", "-c", "xfce4-desktop", "-l"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
keys = []
|
||||
if list_proc.stdout:
|
||||
for line in list_proc.stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Common keys: .../last-image and .../image-path
|
||||
if "/last-image" in line or "/image-path" in line:
|
||||
keys.append(line)
|
||||
# Fallback: known defaults if none were listed
|
||||
if not keys:
|
||||
keys = [
|
||||
"/backdrop/screen0/monitorVNC-0/workspace0/last-image",
|
||||
"/backdrop/screen0/monitor0/image-path",
|
||||
]
|
||||
for key in keys:
|
||||
subprocess.run(
|
||||
[
|
||||
"xfconf-query",
|
||||
"-c",
|
||||
"xfce4-desktop",
|
||||
"-p",
|
||||
key,
|
||||
"-s",
|
||||
file_loc,
|
||||
],
|
||||
check=False,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# Reload xfdesktop to apply changes
|
||||
subprocess.Popen(["xfdesktop", "--reload"])
|
||||
elif desktop_env == "razor-qt": # TODO: implement reload of desktop when possible
|
||||
if first_run:
|
||||
import configparser
|
||||
|
||||
desktop_conf = configparser.ConfigParser()
|
||||
# Development version
|
||||
desktop_conf_file = os.path.join(get_config_dir("razor"), "desktop.conf")
|
||||
if os.path.isfile(desktop_conf_file):
|
||||
config_option = R"screens\1\desktops\1\wallpaper"
|
||||
else:
|
||||
desktop_conf_file = os.path.join(get_home_dir(), ".razor/desktop.conf")
|
||||
config_option = R"desktops\1\wallpaper"
|
||||
desktop_conf.read(os.path.join(desktop_conf_file))
|
||||
try:
|
||||
if desktop_conf.has_option("razor", config_option): # only replacing a value
|
||||
desktop_conf.set("razor", config_option, file_loc)
|
||||
with open(desktop_conf_file, "w", encoding="utf-8", errors="replace") as f:
|
||||
desktop_conf.write(f)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# TODO: reload desktop when possible
|
||||
pass
|
||||
elif desktop_env in ["fluxbox", "jwm", "openbox", "afterstep"]:
|
||||
# http://fluxbox-wiki.org/index.php/Howto_set_the_background
|
||||
# used fbsetbg on jwm too since I am too lazy to edit the XML configuration
|
||||
# now where fbsetbg does the job excellent anyway.
|
||||
# and I have not figured out how else it can be set on Openbox and AfterSTep
|
||||
# but fbsetbg works excellent here too.
|
||||
try:
|
||||
args = ["fbsetbg", file_loc]
|
||||
subprocess.Popen(args)
|
||||
except Exception:
|
||||
sys.stderr.write("ERROR: Failed to set wallpaper with fbsetbg!\n")
|
||||
sys.stderr.write("Please make sre that You have fbsetbg installed.\n")
|
||||
elif desktop_env == "icewm":
|
||||
# command found at http://urukrama.wordpress.com/2007/12/05/desktop-backgrounds-in-window-managers/
|
||||
args = ["icewmbg", file_loc]
|
||||
subprocess.Popen(args)
|
||||
elif desktop_env == "blackbox":
|
||||
# command found at http://blackboxwm.sourceforge.net/BlackboxDocumentation/BlackboxBackground
|
||||
args = ["bsetbg", "-full", file_loc]
|
||||
subprocess.Popen(args)
|
||||
elif desktop_env == "lxde":
|
||||
args = ["pcmanfm", "--set-wallpaper", file_loc, "--wallpaper-mode=scaled"]
|
||||
subprocess.Popen(args)
|
||||
elif desktop_env == "windowmaker":
|
||||
# From http://www.commandlinefu.com/commands/view/3857/set-wallpaper-on-windowmaker-in-one-line
|
||||
args = ["wmsetbg", "-s", "-u", file_loc]
|
||||
subprocess.Popen(args)
|
||||
# elif desktop_env == "enlightenment": # I have not been able to make it work on e17. On e16 it would have been something in this direction
|
||||
# args = ["enlightenment_remote", "-desktop-bg-add", "0", "0", "0", "0", file_loc]
|
||||
# subprocess.Popen(args)
|
||||
elif desktop_env == "windows":
|
||||
# From https://stackoverflow.com/questions/1977694/change-desktop-background
|
||||
# Tested on Windows 10. -- @1j01
|
||||
import ctypes
|
||||
|
||||
SPI_SETDESKWALLPAPER = 20
|
||||
ctypes.windll.user32.SystemParametersInfoW(SPI_SETDESKWALLPAPER, 0, file_loc, 0) # type: ignore
|
||||
elif desktop_env == "mac":
|
||||
# From https://stackoverflow.com/questions/431205/how-can-i-programatically-change-the-background-in-mac-os-x
|
||||
try:
|
||||
# Tested on macOS 10.14.6 (Mojave) -- @1j01
|
||||
assert (
|
||||
sys.platform == "darwin"
|
||||
) # ignore `Import "appscript" could not be resolved` for other platforms
|
||||
from appscript import app, mactypes
|
||||
|
||||
app("Finder").desktop_picture.set(mactypes.File(file_loc))
|
||||
except ImportError:
|
||||
# Tested on macOS 10.14.6 (Mojave) -- @1j01
|
||||
# import subprocess
|
||||
# SCRIPT = f"""/usr/bin/osascript<<END
|
||||
# tell application "Finder" to set desktop picture to POSIX file "{file_loc}"
|
||||
# END"""
|
||||
# subprocess.Popen(SCRIPT, shell=True)
|
||||
|
||||
# Safer version, avoiding string interpolation,
|
||||
# to protect against command injection (both in the shell and in AppleScript):
|
||||
OSASCRIPT = """
|
||||
on run (clp)
|
||||
if clp's length is not 1 then error "Incorrect Parameters"
|
||||
local file_loc
|
||||
set file_loc to clp's item 1
|
||||
tell application "Finder" to set desktop picture to POSIX file file_loc
|
||||
end run
|
||||
"""
|
||||
subprocess.Popen(["osascript", "-e", OSASCRIPT, "--", file_loc])
|
||||
else:
|
||||
if first_run: # don't spam the user with the same message over and over again
|
||||
sys.stderr.write(
|
||||
"Warning: Failed to set wallpaper. Your desktop environment is not supported."
|
||||
)
|
||||
sys.stderr.write(f"You can try manually to set your wallpaper to {file_loc}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_config_dir(app_name: str) -> str:
|
||||
"""Returns the configuration directory for the given application name."""
|
||||
if "XDG_CONFIG_HOME" in os.environ:
|
||||
config_home = os.environ["XDG_CONFIG_HOME"]
|
||||
elif "APPDATA" in os.environ: # On Windows
|
||||
config_home = os.environ["APPDATA"]
|
||||
else:
|
||||
try:
|
||||
from xdg import BaseDirectory
|
||||
|
||||
config_home = BaseDirectory.xdg_config_home
|
||||
except ImportError: # Most likely a Linux/Unix system anyway
|
||||
config_home = os.path.join(get_home_dir(), ".config")
|
||||
config_dir = os.path.join(config_home, app_name)
|
||||
return config_dir
|
||||
|
||||
|
||||
def get_home_dir() -> str:
|
||||
"""Returns the home directory of the current user."""
|
||||
return os.path.expanduser("~")
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Examples package for the Cua Computer API.
|
||||
"""
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Example showing how to use the Cua Computer API as an imported package.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# For type checking only
|
||||
if TYPE_CHECKING:
|
||||
from computer_api import Server
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Example 1: Synchronous usage (blocks until server is stopped)
|
||||
def example_sync():
|
||||
"""
|
||||
Example of synchronous server usage. This will block until interrupted.
|
||||
Run with: python3 -m examples.usage_example sync
|
||||
"""
|
||||
# Import directly to avoid any confusion
|
||||
from computer_api.server import Server
|
||||
|
||||
server = Server(port=8080)
|
||||
print("Server started at http://localhost:8080")
|
||||
print("Press Ctrl+C to stop the server")
|
||||
|
||||
try:
|
||||
server.start() # This will block until the server is stopped
|
||||
except KeyboardInterrupt:
|
||||
print("Server stopped by user")
|
||||
|
||||
|
||||
# Example 2: Asynchronous usage
|
||||
async def example_async():
|
||||
"""
|
||||
Example of asynchronous server usage. This will start the server in the background
|
||||
and allow other operations to run concurrently.
|
||||
Run with: python3 -m examples.usage_example async
|
||||
"""
|
||||
# Import directly to avoid any confusion
|
||||
from computer_api.server import Server
|
||||
|
||||
server = Server(port=8080)
|
||||
|
||||
# Start the server in the background
|
||||
await server.start_async()
|
||||
|
||||
print("Server is running in the background")
|
||||
print("Performing other tasks...")
|
||||
|
||||
# Do other things while the server is running
|
||||
for i in range(5):
|
||||
print(f"Doing work iteration {i+1}/5...")
|
||||
await asyncio.sleep(2)
|
||||
|
||||
print("Work complete, stopping server...")
|
||||
|
||||
# Stop the server when done
|
||||
await server.stop()
|
||||
print("Server stopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
if len(sys.argv) > 1 and sys.argv[1] == "async":
|
||||
asyncio.run(example_async())
|
||||
else:
|
||||
example_sync()
|
||||
@@ -0,0 +1,92 @@
|
||||
[build-system]
|
||||
requires = ["pdm-backend"]
|
||||
build-backend = "pdm.backend"
|
||||
|
||||
[project]
|
||||
name = "cua-computer-server"
|
||||
version = "0.3.42"
|
||||
|
||||
description = "Server component for the Computer-Use Interface (CUI) framework powering Cua"
|
||||
authors = [
|
||||
{ name = "TryCua", email = "gh@trycua.com" }
|
||||
]
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.12,<3.14"
|
||||
dependencies = [
|
||||
"fastapi>=0.111.0",
|
||||
"uvicorn[standard]>=0.27.0",
|
||||
"pydantic>=2.0.0",
|
||||
"pynput>=1.8.1",
|
||||
"pillow>=10.2.0",
|
||||
"aiohttp>=3.9.1",
|
||||
"pyperclip>=1.9.0",
|
||||
"websockets>=12.0",
|
||||
"pywinctl>=0.4.1",
|
||||
"playwright>=1.40.0",
|
||||
"fastmcp>=3.2.0,<3.3.0",
|
||||
# PTY support (wraps cua-auto terminal engine)
|
||||
"cua-auto>=0.1.0",
|
||||
# OS-specific runtime deps
|
||||
"pyobjc-framework-Cocoa>=10.1; sys_platform == 'darwin'",
|
||||
"pyobjc-framework-Quartz>=10.1; sys_platform == 'darwin'",
|
||||
"pyobjc-framework-ApplicationServices>=10.1; sys_platform == 'darwin'",
|
||||
"python-xlib>=0.33; sys_platform == 'linux'",
|
||||
"pywin32>=310; sys_platform == 'win32'",
|
||||
"python-certifi-win32; sys_platform == 'win32'",
|
||||
"cua-core>=0.3.0,<0.4.0",
|
||||
"grpcio==1.78.0",
|
||||
"protobuf==6.33.6",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
macos = [
|
||||
"pyobjc-framework-Cocoa>=10.1",
|
||||
"pyobjc-framework-Quartz>=10.1",
|
||||
"pyobjc-framework-ApplicationServices>=10.1"
|
||||
]
|
||||
linux = [
|
||||
"python-xlib>=0.33"
|
||||
]
|
||||
windows = [
|
||||
"pywin32>=310"
|
||||
]
|
||||
vnc = [
|
||||
"vncdotool>=1.2.0"
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
homepage = "https://github.com/trycua/cua"
|
||||
repository = "https://github.com/trycua/cua"
|
||||
|
||||
[project.scripts]
|
||||
cua-computer-server = "computer_server:run_cli"
|
||||
|
||||
[tool.pdm]
|
||||
distribution = true
|
||||
|
||||
[tool.pdm.build]
|
||||
includes = ["computer_server"]
|
||||
package-data = {"computer_server" = ["py.typed"]}
|
||||
|
||||
[tool.pdm.dev-dependencies]
|
||||
test = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.23.0"
|
||||
]
|
||||
format = [
|
||||
"black>=23.0.0",
|
||||
"isort>=5.12.0"
|
||||
]
|
||||
dev = [
|
||||
"ruff>=0.0.241",
|
||||
"mypy>=0.971"
|
||||
]
|
||||
|
||||
[tool.pdm.scripts]
|
||||
# Starts HTTP server with MCP at /mcp endpoint
|
||||
api = "python -m computer_server"
|
||||
|
||||
[tool.uv.sources]
|
||||
cua-core = { workspace = true }
|
||||
cua-auto = { path = "../cua-auto", editable = true }
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Entrypoint script for the Computer Server.
|
||||
|
||||
This script provides a simple way to start the Computer Server from the command line
|
||||
or using a launch configuration in an IDE.
|
||||
|
||||
Usage:
|
||||
python run_server.py [--host HOST] [--port PORT] [--log-level LEVEL]
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
from computer_server.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
Connection test script for Computer Server.
|
||||
|
||||
This script tests both WebSocket (/ws) and REST (/cmd) connections to the Computer Server
|
||||
and keeps it alive, allowing you to verify the server is running correctly.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
import aiohttp
|
||||
import dotenv
|
||||
import websockets
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
|
||||
async def test_websocket_connection(
|
||||
host="localhost", port=8000, keep_alive=False, container_name=None, api_key=None
|
||||
):
|
||||
"""Test WebSocket connection to the Computer Server."""
|
||||
if container_name:
|
||||
# Container mode: use WSS with container domain and port 8443
|
||||
uri = f"wss://{container_name}.containers.cloud.trycua.com:8443/ws"
|
||||
print(f"Connecting to container {container_name} at {uri}...")
|
||||
else:
|
||||
# Local mode: use WS with specified host and port
|
||||
uri = f"ws://{host}:{port}/ws"
|
||||
print(f"Connecting to local server at {uri}...")
|
||||
|
||||
try:
|
||||
async with websockets.connect(uri) as websocket:
|
||||
print("WebSocket connection established!")
|
||||
|
||||
# If container connection, send authentication first
|
||||
if container_name:
|
||||
if not api_key:
|
||||
print("Error: API key required for container connections")
|
||||
return False
|
||||
|
||||
print("Sending authentication...")
|
||||
auth_message = {
|
||||
"command": "authenticate",
|
||||
"params": {"api_key": api_key, "container_name": container_name},
|
||||
}
|
||||
await websocket.send(json.dumps(auth_message))
|
||||
auth_response = await websocket.recv()
|
||||
print(f"Authentication response: {auth_response}")
|
||||
|
||||
# Check if authentication was successful
|
||||
auth_data = json.loads(auth_response)
|
||||
if not auth_data.get("success", False):
|
||||
print("Authentication failed!")
|
||||
return False
|
||||
print("Authentication successful!")
|
||||
|
||||
# Send a test command to get version
|
||||
await websocket.send(json.dumps({"command": "version", "params": {}}))
|
||||
response = await websocket.recv()
|
||||
print(f"Version response: {response}")
|
||||
|
||||
# Send a test command to get screen size
|
||||
await websocket.send(json.dumps({"command": "get_screen_size", "params": {}}))
|
||||
response = await websocket.recv()
|
||||
print(f"Screen size response: {response}")
|
||||
|
||||
if keep_alive:
|
||||
print("\nKeeping WebSocket connection alive. Press Ctrl+C to exit...")
|
||||
while True:
|
||||
# Send a command every 5 seconds to keep the connection alive
|
||||
await asyncio.sleep(5)
|
||||
await websocket.send(
|
||||
json.dumps({"command": "get_cursor_position", "params": {}})
|
||||
)
|
||||
response = await websocket.recv()
|
||||
print(f"Cursor position: {response}")
|
||||
except websockets.exceptions.ConnectionClosed as e:
|
||||
print(f"WebSocket connection closed: {e}")
|
||||
return False
|
||||
except ConnectionRefusedError:
|
||||
print(f"Connection refused. Is the server running at {host}:{port}?")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"WebSocket error: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def test_rest_connection(
|
||||
host="localhost", port=8000, keep_alive=False, container_name=None, api_key=None
|
||||
):
|
||||
"""Test REST connection to the Computer Server."""
|
||||
if container_name:
|
||||
# Container mode: use HTTPS with container domain and port 8443
|
||||
base_url = f"https://{container_name}.containers.cloud.trycua.com:8443"
|
||||
print(f"Connecting to container {container_name} at {base_url}...")
|
||||
else:
|
||||
# Local mode: use HTTP with specified host and port
|
||||
base_url = f"http://{host}:{port}"
|
||||
print(f"Connecting to local server at {base_url}...")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
print("REST connection established!")
|
||||
|
||||
# Prepare headers for container authentication
|
||||
headers = {}
|
||||
if container_name:
|
||||
if not api_key:
|
||||
print("Error: API key required for container connections")
|
||||
return False
|
||||
headers["X-Container-Name"] = container_name
|
||||
headers["X-API-Key"] = api_key
|
||||
print("Using container authentication headers")
|
||||
|
||||
# Test screenshot endpoint
|
||||
async with session.post(
|
||||
f"{base_url}/cmd", json={"command": "screenshot", "params": {}}, headers=headers
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
text = await response.text()
|
||||
print(f"Screenshot response: {text}")
|
||||
else:
|
||||
print(f"Screenshot request failed with status: {response.status}")
|
||||
print(await response.text())
|
||||
return False
|
||||
|
||||
# Test screen size endpoint
|
||||
async with session.post(
|
||||
f"{base_url}/cmd",
|
||||
json={"command": "get_screen_size", "params": {}},
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
text = await response.text()
|
||||
print(f"Screen size response: {text}")
|
||||
else:
|
||||
print(f"Screen size request failed with status: {response.status}")
|
||||
print(await response.text())
|
||||
return False
|
||||
|
||||
if keep_alive:
|
||||
print("\nKeeping REST connection alive. Press Ctrl+C to exit...")
|
||||
while True:
|
||||
# Send a command every 5 seconds to keep testing
|
||||
await asyncio.sleep(5)
|
||||
async with session.post(
|
||||
f"{base_url}/cmd",
|
||||
json={"command": "get_cursor_position", "params": {}},
|
||||
headers=headers,
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
text = await response.text()
|
||||
print(f"Cursor position: {text}")
|
||||
else:
|
||||
print(f"Cursor position request failed with status: {response.status}")
|
||||
print(await response.text())
|
||||
return False
|
||||
|
||||
except aiohttp.ClientError as e:
|
||||
print(f"REST connection error: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"REST error: {e}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def test_connection(
|
||||
host="localhost", port=8000, keep_alive=False, container_name=None, use_rest=False, api_key=None
|
||||
):
|
||||
"""Test connection to the Computer Server using WebSocket or REST."""
|
||||
if use_rest:
|
||||
return await test_rest_connection(host, port, keep_alive, container_name, api_key)
|
||||
else:
|
||||
return await test_websocket_connection(host, port, keep_alive, container_name, api_key)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Test connection to Computer Server")
|
||||
parser.add_argument("--host", default="localhost", help="Host address (default: localhost)")
|
||||
parser.add_argument("-p", "--port", type=int, default=8000, help="Port number (default: 8000)")
|
||||
parser.add_argument(
|
||||
"-c",
|
||||
"--container-name",
|
||||
help="Container name for cloud connection (uses WSS/HTTPS and port 8443)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--api-key", help="API key for container authentication (can also use CUA_API_KEY env var)"
|
||||
)
|
||||
parser.add_argument("--keep-alive", action="store_true", help="Keep connection alive")
|
||||
parser.add_argument(
|
||||
"--rest", action="store_true", help="Use REST endpoint (/cmd) instead of WebSocket (/ws)"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
async def main():
|
||||
args = parse_args()
|
||||
|
||||
# Convert hyphenated argument to underscore for function parameter
|
||||
container_name = getattr(args, "container_name", None)
|
||||
|
||||
# Get API key from argument or environment variable
|
||||
api_key = getattr(args, "api_key", None) or os.environ.get("CUA_API_KEY")
|
||||
|
||||
# Check if container name is provided but API key is missing
|
||||
if container_name and not api_key:
|
||||
print("Warning: Container name provided but no API key found.")
|
||||
print("Please provide --api-key argument or set CUA_API_KEY environment variable.")
|
||||
return 1
|
||||
|
||||
print(f"Testing {'REST' if args.rest else 'WebSocket'} connection...")
|
||||
if container_name:
|
||||
print(f"Container: {container_name}")
|
||||
print(
|
||||
f"API Key: {'***' + api_key[-4:] if api_key and len(api_key) > 4 else 'Not provided'}"
|
||||
)
|
||||
|
||||
success = await test_connection(
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
keep_alive=args.keep_alive,
|
||||
container_name=container_name,
|
||||
use_rest=args.rest,
|
||||
api_key=api_key,
|
||||
)
|
||||
return 0 if success else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(asyncio.run(main()))
|
||||
except KeyboardInterrupt:
|
||||
print("\nExiting...")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Pytest configuration and shared fixtures for computer-server package tests.
|
||||
|
||||
This file contains shared fixtures and configuration for all computer-server tests.
|
||||
Following SRP: This file ONLY handles test setup/teardown.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_websocket():
|
||||
"""Mock WebSocket connection for testing.
|
||||
|
||||
Use this fixture to test WebSocket logic without real connections.
|
||||
"""
|
||||
websocket = AsyncMock()
|
||||
websocket.send = AsyncMock()
|
||||
websocket.recv = AsyncMock()
|
||||
websocket.close = AsyncMock()
|
||||
|
||||
return websocket
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_computer_interface():
|
||||
"""Mock computer interface for server tests.
|
||||
|
||||
Use this fixture to test server logic without real computer operations.
|
||||
"""
|
||||
interface = AsyncMock()
|
||||
interface.screenshot = AsyncMock(return_value=b"fake_screenshot")
|
||||
interface.left_click = AsyncMock()
|
||||
interface.type = AsyncMock()
|
||||
interface.key = AsyncMock()
|
||||
|
||||
return interface
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_telemetry(monkeypatch):
|
||||
"""Disable telemetry for tests.
|
||||
|
||||
Use this fixture to ensure no telemetry is sent during tests.
|
||||
"""
|
||||
monkeypatch.setenv("CUA_TELEMETRY_ENABLED", "false")
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Integration tests for UNAVAILABLE_WITHOUT_CONTAINER_NAME behavior.
|
||||
|
||||
These tests verify two things:
|
||||
|
||||
1. **Backwards compat** — when neither ``CONTAINER_NAME`` nor
|
||||
``UNAVAILABLE_WITHOUT_CONTAINER_NAME`` is set, the server continues to
|
||||
operate in local development mode (no auth required, requests succeed).
|
||||
|
||||
2. **New behavior** — when ``UNAVAILABLE_WITHOUT_CONTAINER_NAME`` is truthy
|
||||
and ``CONTAINER_NAME`` is unset, requests are rejected with the status
|
||||
code configured by
|
||||
``UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE`` (default 503)
|
||||
rather than being allowed through.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
try:
|
||||
from computer_server.main import _unavailable_status_code, app
|
||||
except Exception as import_error: # pragma: no cover - environment-dependent
|
||||
pytest.skip(
|
||||
f"computer_server.main unavailable in this environment: {import_error}",
|
||||
allow_module_level=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_env(monkeypatch):
|
||||
"""Remove all env vars that influence auth availability for a clean baseline."""
|
||||
for var in (
|
||||
"CONTAINER_NAME",
|
||||
"UNAVAILABLE_WITHOUT_CONTAINER_NAME",
|
||||
"UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
return monkeypatch
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client():
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
class TestUnavailableStatusCode:
|
||||
"""Unit tests for the `_unavailable_status_code` helper."""
|
||||
|
||||
def test_returns_none_when_both_unset(self, clean_env):
|
||||
assert _unavailable_status_code() is None
|
||||
|
||||
def test_returns_none_when_container_name_set(self, clean_env):
|
||||
clean_env.setenv("CONTAINER_NAME", "vm-abc")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
# CONTAINER_NAME being set overrides the unavailable flag.
|
||||
assert _unavailable_status_code() is None
|
||||
|
||||
def test_returns_default_503_when_flag_truthy_and_container_missing(self, clean_env):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
assert _unavailable_status_code() == 503
|
||||
|
||||
@pytest.mark.parametrize("value", ["1", "true", "True", "YES", "y", "on"])
|
||||
def test_accepts_various_truthy_values(self, clean_env, value):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", value)
|
||||
assert _unavailable_status_code() == 503
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "false", "no", "", "random"])
|
||||
def test_rejects_falsy_values(self, clean_env, value):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", value)
|
||||
assert _unavailable_status_code() is None
|
||||
|
||||
def test_custom_status_code(self, clean_env):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "1")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE", "418")
|
||||
assert _unavailable_status_code() == 418
|
||||
|
||||
def test_invalid_status_code_falls_back_to_503(self, clean_env):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "1")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE", "not-a-number")
|
||||
assert _unavailable_status_code() == 503
|
||||
|
||||
|
||||
class TestCmdEndpoint:
|
||||
"""Integration tests for the POST /cmd endpoint."""
|
||||
|
||||
def test_backwards_compat_local_dev_allows_requests(self, clean_env, client):
|
||||
# No CONTAINER_NAME, no availability flag — old "local dev" behavior.
|
||||
resp = client.post("/cmd", json={"command": "version", "params": {}})
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert "success" in resp.text
|
||||
|
||||
def test_unavailable_flag_rejects_with_default_503(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.post("/cmd", json={"command": "version", "params": {}})
|
||||
assert resp.status_code == 503
|
||||
assert "CONTAINER_NAME" in resp.json()["detail"]
|
||||
|
||||
def test_unavailable_flag_with_custom_status_code(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "1")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE", "418")
|
||||
resp = client.post("/cmd", json={"command": "version", "params": {}})
|
||||
assert resp.status_code == 418
|
||||
|
||||
def test_container_name_set_bypasses_unavailable_flag(self, clean_env, client):
|
||||
# CONTAINER_NAME being set means auth is required — but the unavailable
|
||||
# flag should NOT apply. Without valid creds, this should 401, not 503.
|
||||
clean_env.setenv("CONTAINER_NAME", "vm-xyz")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.post("/cmd", json={"command": "version", "params": {}})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
class TestPtyEndpointAuthGate:
|
||||
"""Integration tests for PTY endpoints (via `_require_auth`)."""
|
||||
|
||||
def test_backwards_compat_local_dev_allows_access(self, clean_env, client):
|
||||
# Use a non-existent PID — we just want to verify we get past the auth gate.
|
||||
# If auth passes, we get 404 (PTY not found); if not, we get 401/503.
|
||||
resp = client.get("/pty/999999")
|
||||
assert resp.status_code == 404, resp.text
|
||||
|
||||
def test_unavailable_flag_rejects_with_default_503(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.get("/pty/999999")
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_unavailable_flag_with_custom_status_code(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE", "599")
|
||||
resp = client.get("/pty/999999")
|
||||
assert resp.status_code == 599
|
||||
|
||||
|
||||
class TestPlaywrightExecEndpoint:
|
||||
def test_backwards_compat_local_dev_accepts_auth(self, clean_env, client):
|
||||
# Browser manager may fail for other reasons, but it should NOT be 503/401.
|
||||
resp = client.post("/playwright_exec", json={"command": "noop", "params": {}})
|
||||
assert resp.status_code not in (401, 503)
|
||||
|
||||
def test_unavailable_flag_rejects(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.post("/playwright_exec", json={"command": "noop", "params": {}})
|
||||
assert resp.status_code == 503
|
||||
|
||||
|
||||
class TestStatusEndpointMiddlewareGating:
|
||||
"""The middleware applies uniformly — /status is reachable when the flag is off."""
|
||||
|
||||
def test_status_accessible_in_local_dev_mode(self, clean_env, client):
|
||||
resp = client.get("/status")
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "ok"
|
||||
|
||||
def test_status_rejected_by_middleware_when_flag_set(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.get("/status")
|
||||
assert resp.status_code == 503
|
||||
|
||||
def test_status_accessible_when_container_name_set(self, clean_env, client):
|
||||
clean_env.setenv("CONTAINER_NAME", "vm-abc")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
resp = client.get("/status")
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
class TestWebSocketEndpoint:
|
||||
def test_backwards_compat_local_dev_allows_commands(self, clean_env, client):
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
ws.send_json({"command": "version", "params": {}})
|
||||
data = ws.receive_json()
|
||||
assert data["success"] is True
|
||||
|
||||
def test_unavailable_flag_closes_with_error(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
data = ws.receive_json()
|
||||
assert data["success"] is False
|
||||
assert data["status_code"] == 503
|
||||
assert "CONTAINER_NAME" in data["error"]
|
||||
|
||||
def test_unavailable_flag_reports_custom_status_code(self, clean_env, client):
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME", "true")
|
||||
clean_env.setenv("UNAVAILABLE_WITHOUT_CONTAINER_NAME_RESPONSE_STATUS_CODE", "599")
|
||||
with client.websocket_connect("/ws") as ws:
|
||||
data = ws.receive_json()
|
||||
assert data["success"] is False
|
||||
assert data["status_code"] == 599
|
||||
@@ -0,0 +1,113 @@
|
||||
"""Tests for keypress layout-independence fix (issue #1605).
|
||||
|
||||
Verifies that single printable characters are routed through type_text
|
||||
(layout-independent) rather than press_key (layout-dependent).
|
||||
"""
|
||||
|
||||
import unicodedata
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _is_printable_char(key: str) -> bool:
|
||||
"""Mirror of the routing logic in DirectComputer.keypress()."""
|
||||
return len(key) == 1 and unicodedata.category(key) not in ("Cc", "Cs", "Cn")
|
||||
|
||||
|
||||
class TestPrintableCharDetection:
|
||||
"""Unit tests for the printable-character detection logic."""
|
||||
|
||||
def test_ascii_letter_is_printable(self):
|
||||
assert _is_printable_char("a") is True
|
||||
assert _is_printable_char("Z") is True
|
||||
|
||||
def test_ascii_digit_is_printable(self):
|
||||
assert _is_printable_char("0") is True
|
||||
assert _is_printable_char("9") is True
|
||||
|
||||
def test_ascii_punctuation_is_printable(self):
|
||||
assert _is_printable_char("/") is True
|
||||
assert _is_printable_char(".") is True
|
||||
assert _is_printable_char("_") is True
|
||||
|
||||
def test_space_is_printable(self):
|
||||
# Space (U+0020) has category Zs — printable, should use type_text.
|
||||
assert _is_printable_char(" ") is True
|
||||
|
||||
def test_special_key_names_are_not_printable(self):
|
||||
# Multi-char strings are never single printable chars.
|
||||
assert _is_printable_char("return") is False
|
||||
assert _is_printable_char("tab") is False
|
||||
assert _is_printable_char("escape") is False
|
||||
assert _is_printable_char("f1") is False
|
||||
assert _is_printable_char("backspace") is False
|
||||
assert _is_printable_char("up") is False
|
||||
|
||||
def test_control_characters_are_not_printable(self):
|
||||
# Cc category — should go through press_key, not type_text.
|
||||
assert _is_printable_char("\x00") is False
|
||||
assert _is_printable_char("\x1b") is False # ESC
|
||||
assert _is_printable_char("\n") is False # newline
|
||||
|
||||
def test_empty_string_is_not_printable(self):
|
||||
assert _is_printable_char("") is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keypress_single_printable_uses_type_text():
|
||||
"""Single printable char must call type_text, not press_key."""
|
||||
auto = MagicMock()
|
||||
auto.type_text = AsyncMock()
|
||||
auto.press_key = AsyncMock()
|
||||
auto.hotkey = AsyncMock()
|
||||
|
||||
# Simulate the routing logic directly.
|
||||
key = "a"
|
||||
if _is_printable_char(key):
|
||||
await auto.type_text(key)
|
||||
else:
|
||||
await auto.press_key(key)
|
||||
|
||||
auto.type_text.assert_awaited_once_with("a")
|
||||
auto.press_key.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keypress_special_key_uses_press_key():
|
||||
"""Special key names must still call press_key."""
|
||||
auto = MagicMock()
|
||||
auto.type_text = AsyncMock()
|
||||
auto.press_key = AsyncMock()
|
||||
|
||||
key = "return"
|
||||
if _is_printable_char(key):
|
||||
await auto.type_text(key)
|
||||
else:
|
||||
await auto.press_key(key)
|
||||
|
||||
auto.press_key.assert_awaited_once_with("return")
|
||||
auto.type_text.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keypress_combo_uses_hotkey():
|
||||
"""Multi-key combos must still call hotkey."""
|
||||
auto = MagicMock()
|
||||
auto.hotkey = AsyncMock()
|
||||
auto.type_text = AsyncMock()
|
||||
auto.press_key = AsyncMock()
|
||||
|
||||
parts = ["cmd", "shift", "g"]
|
||||
if len(parts) == 1:
|
||||
key = parts[0]
|
||||
if _is_printable_char(key):
|
||||
await auto.type_text(key)
|
||||
else:
|
||||
await auto.press_key(key)
|
||||
else:
|
||||
await auto.hotkey(parts)
|
||||
|
||||
auto.hotkey.assert_awaited_once_with(["cmd", "shift", "g"])
|
||||
auto.type_text.assert_not_awaited()
|
||||
auto.press_key.assert_not_awaited()
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Tests for SDK-configurable ``run_command`` timeout propagation.
|
||||
|
||||
The SDK's ``cua_sandbox.interfaces.shell.Shell.run(cmd, timeout=...)`` sends
|
||||
the timeout as a ``/cmd`` param; ``computer_server.main``'s dispatcher
|
||||
filters kwargs by the handler signature before forwarding. These tests
|
||||
cover the handler-side contract:
|
||||
|
||||
* handler accepts ``timeout`` as a float keyword
|
||||
* ``timeout=None`` → waits indefinitely
|
||||
* ``timeout=<float>`` → times out at expiry with a ``success=False`` result
|
||||
containing the standardised ``Command timed out after <t>s`` stderr and
|
||||
``return_code=-1``
|
||||
|
||||
The Android / Windows variants share the same shape; the base handler is
|
||||
exercised here because it exposes a POSIX subprocess without adb/emulator
|
||||
state being required for a focused unit test.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
from computer_server.handlers.base import BaseAutomationHandler
|
||||
|
||||
|
||||
class _MinimalHandler(BaseAutomationHandler):
|
||||
"""Concrete BaseAutomationHandler for exercising ``run_command`` only.
|
||||
|
||||
We clear ``__abstractmethods__`` AFTER class creation (below) instead
|
||||
of stubbing every abstract method — the set changes as the interface
|
||||
grows and keeping a mirror of the full abstract surface here is pure
|
||||
maintenance overhead for a test that only touches ``run_command``.
|
||||
|
||||
Note: setting ``__abstractmethods__ = frozenset()`` inside the class
|
||||
body does NOT work — Python's ``ABCMeta`` overwrites it during class
|
||||
creation. The override must happen after the class statement.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# Bypass ABC enforcement — ABCMeta.__call__ checks this at instantiation time.
|
||||
_MinimalHandler.__abstractmethods__ = frozenset()
|
||||
|
||||
|
||||
class TestRunCommandTimeout:
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_timeout_defaults_to_indefinite_wait(self, monkeypatch):
|
||||
# Ensure we hit the subprocess_shell branch (not the android adb branch).
|
||||
monkeypatch.delenv("IS_CUA_ANDROID", raising=False)
|
||||
h = _MinimalHandler()
|
||||
|
||||
result = await h.run_command("echo hello")
|
||||
assert result["success"] is True
|
||||
assert result["stdout"].strip() == "hello"
|
||||
assert result["return_code"] == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_timeout_honoured_when_passed(self, monkeypatch):
|
||||
monkeypatch.delenv("IS_CUA_ANDROID", raising=False)
|
||||
h = _MinimalHandler()
|
||||
|
||||
# sleep 5, cap at 0.2 — should time out
|
||||
start = asyncio.get_event_loop().time()
|
||||
result = await h.run_command("sleep 5", timeout=0.2)
|
||||
elapsed = asyncio.get_event_loop().time() - start
|
||||
|
||||
assert result["success"] is False
|
||||
assert "timed out" in result["stderr"].lower()
|
||||
assert result["return_code"] == -1
|
||||
# Must have returned within a small factor of the requested timeout.
|
||||
assert elapsed < 2.0, f"expected quick timeout, took {elapsed:.2f}s"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fast_command_under_timeout_succeeds(self, monkeypatch):
|
||||
monkeypatch.delenv("IS_CUA_ANDROID", raising=False)
|
||||
h = _MinimalHandler()
|
||||
|
||||
result = await h.run_command("echo done", timeout=10.0)
|
||||
assert result["success"] is True
|
||||
assert result["stdout"].strip() == "done"
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Unit tests for computer-server package.
|
||||
|
||||
This file tests ONLY basic server functionality.
|
||||
Following SRP: This file tests server initialization and basic operations.
|
||||
All external dependencies are mocked.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestServerImports:
|
||||
"""Test server module imports (SRP: Only tests imports)."""
|
||||
|
||||
def test_server_module_exists(self):
|
||||
"""Test that server module can be imported."""
|
||||
try:
|
||||
import computer_server
|
||||
|
||||
assert computer_server is not None
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
|
||||
|
||||
class TestServerInitialization:
|
||||
"""Test server initialization (SRP: Only tests initialization)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_server_can_be_imported(self):
|
||||
"""Basic smoke test: verify server components can be imported."""
|
||||
try:
|
||||
from computer_server import server
|
||||
|
||||
assert server is not None
|
||||
except ImportError:
|
||||
pytest.skip("Server module not available")
|
||||
except Exception as e:
|
||||
# Some initialization errors are acceptable in unit tests
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
|
||||
class TestMcpBarePathRewrite:
|
||||
"""Test the ASGI shim that serves MCP at both /mcp and /mcp/ (SRP:
|
||||
Only tests the path rewrite). The class is pure ASGI with no
|
||||
dependencies, so it is tested standalone against a recording stub."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bare_mcp_path_is_rewritten(self):
|
||||
try:
|
||||
from computer_server.main import McpBarePathRewrite
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
seen = {}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
seen.update(scope)
|
||||
|
||||
shim = McpBarePathRewrite(inner)
|
||||
await shim({"type": "http", "path": "/mcp"}, None, None)
|
||||
assert seen["path"] == "/mcp/"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_other_paths_untouched(self):
|
||||
try:
|
||||
from computer_server.main import McpBarePathRewrite
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
seen = {}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
seen.update(scope)
|
||||
|
||||
shim = McpBarePathRewrite(inner)
|
||||
for path in ("/mcp/", "/mcpx", "/status", "/"):
|
||||
await shim({"type": "http", "path": path}, None, None)
|
||||
assert seen["path"] == path
|
||||
|
||||
|
||||
class TestMcpServerSmoke:
|
||||
"""Smoke-test the real FastMCP integration so dependency major bumps cannot
|
||||
silently remove the /mcp endpoint while unit tests stay green."""
|
||||
|
||||
@staticmethod
|
||||
def _load_mcp_server_module():
|
||||
# Import the module file directly. Importing through
|
||||
# `computer_server.mcp_server` first executes computer_server/__init__.py,
|
||||
# which imports platform input backends and fails on headless Linux CI
|
||||
# with no DISPLAY. The MCP module itself has no runtime dependency on
|
||||
# those input backends until a tool is actually invoked.
|
||||
module_path = Path(__file__).parents[1] / "computer_server" / "mcp_server.py"
|
||||
spec = importlib.util.spec_from_file_location("_computer_server_mcp_smoke", module_path)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def test_create_mcp_server_and_http_app(self):
|
||||
mcp_module = self._load_mcp_server_module()
|
||||
|
||||
mcp_server = mcp_module.create_mcp_server()
|
||||
mcp_app = mcp_server.http_app(path="/")
|
||||
|
||||
assert mcp_server is not None
|
||||
assert mcp_app is not None
|
||||
|
||||
def test_mcp_http_app_accepts_initialize_post(self):
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
mcp_module = self._load_mcp_server_module()
|
||||
mcp_server = mcp_module.create_mcp_server()
|
||||
mcp_app = mcp_server.http_app(path="/")
|
||||
|
||||
payload = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "initialize",
|
||||
"params": {
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "computer-server-smoke", "version": "0"},
|
||||
},
|
||||
}
|
||||
|
||||
with TestClient(mcp_app) as client:
|
||||
response = client.post(
|
||||
"/",
|
||||
json=payload,
|
||||
headers={"accept": "application/json, text/event-stream"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "cua-computer-server" in response.text
|
||||
assert '"id":1' in response.text
|
||||
|
||||
|
||||
class TestMcpAcceptHeaderShim:
|
||||
"""Test the ASGI shim that normalizes the Accept header for /mcp so the
|
||||
MCP SDK's strict Accept check passes for any client (SRP: Only tests the
|
||||
Accept-header rewrite). Pure ASGI with no deps; tested standalone."""
|
||||
|
||||
@staticmethod
|
||||
def _accept(headers):
|
||||
for k, v in headers:
|
||||
if k == b"accept":
|
||||
return v
|
||||
return None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_accept_header_is_normalized(self):
|
||||
try:
|
||||
from computer_server.main import McpAcceptHeaderShim
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
seen = {}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
seen.update(scope)
|
||||
|
||||
shim = McpAcceptHeaderShim(inner)
|
||||
# claude.ai-style request that only advertises application/json:
|
||||
for path in ("/mcp", "/mcp/", "/mcp/anything"):
|
||||
await shim(
|
||||
{"type": "http", "path": path, "headers": [(b"accept", b"application/json")]},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
assert self._accept(seen["headers"]) == b"application/json, text/event-stream"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_accept_header_is_added(self):
|
||||
try:
|
||||
from computer_server.main import McpAcceptHeaderShim
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
seen = {}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
seen.update(scope)
|
||||
|
||||
shim = McpAcceptHeaderShim(inner)
|
||||
await shim({"type": "http", "path": "/mcp", "headers": []}, None, None)
|
||||
assert self._accept(seen["headers"]) == b"application/json, text/event-stream"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_mcp_paths_untouched(self):
|
||||
try:
|
||||
from computer_server.main import McpAcceptHeaderShim
|
||||
except ImportError:
|
||||
pytest.skip("computer_server module not installed")
|
||||
except Exception as e:
|
||||
pytest.skip(f"Server initialization requires specific setup: {e}")
|
||||
|
||||
seen = {}
|
||||
|
||||
async def inner(scope, receive, send):
|
||||
seen.update(scope)
|
||||
|
||||
shim = McpAcceptHeaderShim(inner)
|
||||
for path in ("/status", "/", "/mcpx", "/ws"):
|
||||
await shim(
|
||||
{"type": "http", "path": path, "headers": [(b"accept", b"application/json")]},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
# Untouched: still exactly what the client sent.
|
||||
assert self._accept(seen["headers"]) == b"application/json"
|
||||
Reference in New Issue
Block a user