cua 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cua-0.1.0/PKG-INFO +25 -0
- cua-0.1.0/README.md +0 -0
- cua-0.1.0/computer/__init__.py +33 -0
- cua-0.1.0/computer/computer.py +185 -0
- cua-0.1.0/computer/interface/__init__.py +15 -0
- cua-0.1.0/computer/interface/base.py +150 -0
- cua-0.1.0/computer/interface/factory.py +33 -0
- cua-0.1.0/computer/interface/linux.py +190 -0
- cua-0.1.0/computer/interface/macos.py +209 -0
- cua-0.1.0/computer/models.py +30 -0
- cua-0.1.0/pyproject.toml +35 -0
cua-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: cua
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CUA (Computer Use) interface for host-VM communication on Apple Silicon
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: macos,virtualization,vm,apple-silicon
|
|
7
|
+
Author: TryCua
|
|
8
|
+
Author-email: f@trycua.com
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: MacOS :: MacOS X
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Requires-Dist: paramiko (>=3.4.0,<4.0.0)
|
|
19
|
+
Requires-Dist: pylume (>=0.1.8,<0.2.0)
|
|
20
|
+
Requires-Dist: websockets (>=12.0,<13.0)
|
|
21
|
+
Project-URL: Homepage, https://github.com/trycua/computer
|
|
22
|
+
Project-URL: Repository, https://github.com/trycua/computer
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
|
cua-0.1.0/README.md
ADDED
|
File without changes
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Computer SDK - A Python SDK for facilitating the development of Computer-use AI agents (CUA).
|
|
3
|
+
|
|
4
|
+
Example:
|
|
5
|
+
>>> from computer import Computer, Image, Display
|
|
6
|
+
>>> computer = Computer(
|
|
7
|
+
... image=Image(
|
|
8
|
+
... image="macos-sequoia-xcode",
|
|
9
|
+
... tag="latest",
|
|
10
|
+
... name="macos-sequoia-xcode-py"
|
|
11
|
+
... ),
|
|
12
|
+
... display=Display(
|
|
13
|
+
... width=1920,
|
|
14
|
+
... height=1080
|
|
15
|
+
... ),
|
|
16
|
+
... memory="16GB",
|
|
17
|
+
... cpu="4",
|
|
18
|
+
... )
|
|
19
|
+
>>> computer.run(start_vnc_client=True)
|
|
20
|
+
>>> computer.type("Hello, world!")
|
|
21
|
+
>>> computer.press_key("Enter")
|
|
22
|
+
>>> computer.stop()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .models import Image, Display
|
|
26
|
+
from .computer import Computer
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
__all__ = [
|
|
30
|
+
"Computer",
|
|
31
|
+
"Image",
|
|
32
|
+
"Display",
|
|
33
|
+
]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from typing import Optional, List, Literal
|
|
2
|
+
from pylume import PyLume, VMRunOpts, SharedDirectory
|
|
3
|
+
import asyncio
|
|
4
|
+
from .models import Computer as ComputerConfig, Image, Display
|
|
5
|
+
from .interface.factory import InterfaceFactory
|
|
6
|
+
from pylume import ImageRef, VMUpdateOpts, PyLume
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
OSType = Literal['macos', 'linux']
|
|
10
|
+
|
|
11
|
+
class Computer:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
display: Display,
|
|
15
|
+
memory: str,
|
|
16
|
+
cpu: str,
|
|
17
|
+
os: OSType = 'macos',
|
|
18
|
+
name: str = '',
|
|
19
|
+
image: str = "macos-sequoia-xcode:latest",
|
|
20
|
+
shared_directories: Optional[List[SharedDirectory]] = None,
|
|
21
|
+
use_host_computer_server: bool = False,
|
|
22
|
+
):
|
|
23
|
+
"""Initialize a new Computer instance.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
image: The VM image name
|
|
27
|
+
tag: The VM image tag
|
|
28
|
+
name: The VM name
|
|
29
|
+
display: The display configuration
|
|
30
|
+
memory: The VM memory allocation
|
|
31
|
+
cpu: The VM CPU allocation
|
|
32
|
+
os: The operating system type ('macos' or 'linux')
|
|
33
|
+
shared_directories: Optional list of directories to share with the VM
|
|
34
|
+
use_host_computer_server: If True, target localhost instead of starting a VM
|
|
35
|
+
"""
|
|
36
|
+
if not use_host_computer_server:
|
|
37
|
+
if ':' not in image or len(image.split(':')) != 2:
|
|
38
|
+
raise ValueError("Image must be in the format <image_name>:<tag>")
|
|
39
|
+
|
|
40
|
+
if not name:
|
|
41
|
+
# Normalize the name to be used for the VM
|
|
42
|
+
name = image.replace(':', '_')
|
|
43
|
+
|
|
44
|
+
self.config = ComputerConfig(
|
|
45
|
+
image=image.split(':')[0],
|
|
46
|
+
tag=image.split(':')[1],
|
|
47
|
+
name=name,
|
|
48
|
+
display=display,
|
|
49
|
+
memory=memory,
|
|
50
|
+
cpu=cpu,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
self._interface = None
|
|
54
|
+
self.os = os
|
|
55
|
+
self.shared_directories = shared_directories or []
|
|
56
|
+
self._pylume_context = None
|
|
57
|
+
self.use_host_computer_server = use_host_computer_server
|
|
58
|
+
|
|
59
|
+
async def __aenter__(self):
|
|
60
|
+
"""Enter the async context manager."""
|
|
61
|
+
if not self.use_host_computer_server:
|
|
62
|
+
self.config.pylume = PyLume(debug=True, port=3000, use_existing_server=False)
|
|
63
|
+
self._pylume_context = await self.config.pylume.__aenter__()
|
|
64
|
+
return self
|
|
65
|
+
|
|
66
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
67
|
+
"""Exit the async context manager."""
|
|
68
|
+
if self._pylume_context:
|
|
69
|
+
await self.config.pylume.__aexit__(exc_type, exc_val, exc_tb)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def ip(self) -> str:
|
|
73
|
+
"""Get the IP address of the VM or localhost if using host computer server."""
|
|
74
|
+
if self.use_host_computer_server:
|
|
75
|
+
return "127.0.0.1"
|
|
76
|
+
return self.config.ip
|
|
77
|
+
|
|
78
|
+
async def wait_vm_ready(self, timeout: int = 60, interval: float = 1.0) -> None:
|
|
79
|
+
"""Wait for the VM to be ready with required properties (ip_address, vnc_address).
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
timeout: Maximum time to wait in seconds
|
|
83
|
+
interval: Time between attempts in seconds
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
TimeoutError: If the VM is not ready within the timeout period
|
|
87
|
+
"""
|
|
88
|
+
if self.use_host_computer_server:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
start_time = time.time()
|
|
92
|
+
while time.time() - start_time < timeout:
|
|
93
|
+
try:
|
|
94
|
+
vm = await self.config.pylume.get_vm(self.config.name)
|
|
95
|
+
if vm.ip_address and vm.vnc_url:
|
|
96
|
+
return vm
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
await asyncio.sleep(interval)
|
|
100
|
+
|
|
101
|
+
raise TimeoutError(f"VM {self.config.name} not ready after {timeout} seconds")
|
|
102
|
+
|
|
103
|
+
async def run(self, no_display: bool = False):
|
|
104
|
+
"""Run the VM and initialize the computer interface."""
|
|
105
|
+
if not self._pylume_context and not self.use_host_computer_server:
|
|
106
|
+
raise RuntimeError("Computer must be used as an async context manager when not using host computer server")
|
|
107
|
+
|
|
108
|
+
ip_address = "127.0.0.1"
|
|
109
|
+
|
|
110
|
+
if not self.use_host_computer_server:
|
|
111
|
+
# Try to get the VM, if it doesn't exist, create it and pull the image
|
|
112
|
+
try:
|
|
113
|
+
vm = await self.config.pylume.get_vm(self.config.name)
|
|
114
|
+
except Exception:
|
|
115
|
+
image_ref = ImageRef(
|
|
116
|
+
image=self.config.image,
|
|
117
|
+
tag=self.config.tag,
|
|
118
|
+
registry="ghcr.io",
|
|
119
|
+
organization="trycua"
|
|
120
|
+
)
|
|
121
|
+
await self.config.pylume.pull_image(image_ref, name=self.config.name)
|
|
122
|
+
|
|
123
|
+
# Run with shared directory
|
|
124
|
+
run_opts = VMRunOpts(
|
|
125
|
+
no_display=False,
|
|
126
|
+
shared_directories=self.shared_directories
|
|
127
|
+
)
|
|
128
|
+
await self.config.pylume.run_vm(self.config.name, run_opts)
|
|
129
|
+
|
|
130
|
+
# Wait for VM to be ready with required properties
|
|
131
|
+
vm = await self.wait_vm_ready()
|
|
132
|
+
ip_address = vm.ip_address
|
|
133
|
+
|
|
134
|
+
# Initialize the interface using the factory with the specified OS
|
|
135
|
+
self._interface = InterfaceFactory.create_interface_for_os(
|
|
136
|
+
os=self.os,
|
|
137
|
+
ip_address=ip_address
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# wait for the websocket interface to be ready
|
|
141
|
+
await self._interface.wait_for_ready()
|
|
142
|
+
|
|
143
|
+
# Create an event to keep the VM running in background if needed
|
|
144
|
+
if not self.use_host_computer_server:
|
|
145
|
+
self._stop_event = asyncio.Event()
|
|
146
|
+
self._keep_alive_task = asyncio.create_task(self._stop_event.wait())
|
|
147
|
+
|
|
148
|
+
async def stop(self):
|
|
149
|
+
"""Stop the VM and close the interface."""
|
|
150
|
+
if hasattr(self, '_stop_event'):
|
|
151
|
+
self._stop_event.set()
|
|
152
|
+
if hasattr(self, '_keep_alive_task'):
|
|
153
|
+
await self._keep_alive_task
|
|
154
|
+
if self._interface:
|
|
155
|
+
self._interface.close()
|
|
156
|
+
await self.config.pylume.stop_vm(self.config.image.name)
|
|
157
|
+
|
|
158
|
+
async def update(self, cpu: Optional[int] = None, memory: Optional[str] = None):
|
|
159
|
+
"""Update VM settings."""
|
|
160
|
+
update_opts = VMUpdateOpts(
|
|
161
|
+
cpu=cpu or int(self.config.cpu),
|
|
162
|
+
memory=memory or self.config.memory
|
|
163
|
+
)
|
|
164
|
+
await self.config.pylume.update_vm(self.config.image.name, update_opts)
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def interface(self):
|
|
168
|
+
"""Get the computer interface for interacting with the VM.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
BaseComputerInterface: The interface for controlling the VM
|
|
172
|
+
|
|
173
|
+
Raises:
|
|
174
|
+
RuntimeError: If the interface is not initialized (run() not called)
|
|
175
|
+
"""
|
|
176
|
+
if self._interface is None:
|
|
177
|
+
raise RuntimeError("Computer interface not initialized. Call run() first.")
|
|
178
|
+
return self._interface
|
|
179
|
+
|
|
180
|
+
def __getattr__(self, name: str):
|
|
181
|
+
"""Delegate all other method calls to the interface.
|
|
182
|
+
|
|
183
|
+
This is kept for backward compatibility, prefer using computer.interface directly.
|
|
184
|
+
"""
|
|
185
|
+
return getattr(self.interface, name)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interface package for Computer SDK.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .factory import InterfaceFactory
|
|
6
|
+
from .base import BaseComputerInterface
|
|
7
|
+
from .linux import LinuxComputerInterface
|
|
8
|
+
from .macos import MacOSComputerInterface
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"InterfaceFactory",
|
|
12
|
+
"BaseComputerInterface",
|
|
13
|
+
"LinuxComputerInterface",
|
|
14
|
+
"MacOSComputerInterface",
|
|
15
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Optional, Dict, Any, Tuple
|
|
3
|
+
|
|
4
|
+
class BaseComputerInterface(ABC):
|
|
5
|
+
"""Abstract base class for OS-specific computer interfaces.
|
|
6
|
+
|
|
7
|
+
This class defines the contract that all OS-specific implementations must follow.
|
|
8
|
+
|
|
9
|
+
Categories:
|
|
10
|
+
- Connection Management: Methods for managing connections to the remote system
|
|
11
|
+
- Mouse Actions: Methods for controlling the mouse
|
|
12
|
+
- Keyboard Actions: Methods for keyboard input
|
|
13
|
+
- Scrolling Actions: Methods for scrolling
|
|
14
|
+
- Screen Actions: Methods for screen interaction and information
|
|
15
|
+
- Clipboard Actions: Methods for clipboard operations
|
|
16
|
+
- File System Actions: Methods for file system operations
|
|
17
|
+
- Accessibility Actions: Methods for accessibility features
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, ip_address: str, username: str = "lume", password: str = "lume"):
|
|
21
|
+
self.ip_address = ip_address
|
|
22
|
+
self.username = username
|
|
23
|
+
self.password = password
|
|
24
|
+
|
|
25
|
+
@abstractmethod
|
|
26
|
+
async def wait_for_ready(self, timeout: int = 60, interval: float = 1.0) -> None:
|
|
27
|
+
"""Wait for the interface to be ready."""
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
@abstractmethod
|
|
31
|
+
async def close(self) -> None:
|
|
32
|
+
"""Close all connections."""
|
|
33
|
+
pass
|
|
34
|
+
|
|
35
|
+
# Mouse Actions
|
|
36
|
+
@abstractmethod
|
|
37
|
+
async def left_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None:
|
|
38
|
+
"""Perform a left click."""
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
@abstractmethod
|
|
42
|
+
async def right_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None:
|
|
43
|
+
"""Perform a right click."""
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
@abstractmethod
|
|
47
|
+
async def double_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None:
|
|
48
|
+
"""Perform a double click."""
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
@abstractmethod
|
|
52
|
+
async def move_cursor(self, x: int, y: int) -> None:
|
|
53
|
+
"""Move the cursor to specified position."""
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
@abstractmethod
|
|
57
|
+
async def drag_to(self, x: int, y: int, button: str = "left", duration: float = 0.5) -> None:
|
|
58
|
+
"""Drag from current position to specified coordinates.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
x: The x coordinate to drag to
|
|
62
|
+
y: The y coordinate to drag to
|
|
63
|
+
button: The mouse button to use ('left', 'middle', 'right')
|
|
64
|
+
duration: How long the drag should take in seconds
|
|
65
|
+
"""
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
# Keyboard Actions
|
|
69
|
+
@abstractmethod
|
|
70
|
+
async def type(self, text: str) -> None:
|
|
71
|
+
"""Type the specified text."""
|
|
72
|
+
pass
|
|
73
|
+
|
|
74
|
+
@abstractmethod
|
|
75
|
+
async def press_key(self, key: str) -> None:
|
|
76
|
+
"""Press a single key."""
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
@abstractmethod
|
|
80
|
+
async def hotkey(self, *keys: str) -> None:
|
|
81
|
+
"""Press multiple keys simultaneously."""
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
# Scrolling Actions
|
|
85
|
+
@abstractmethod
|
|
86
|
+
async def scroll_down(self, clicks: int = 1) -> None:
|
|
87
|
+
"""Scroll down."""
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
@abstractmethod
|
|
91
|
+
async def scroll_up(self, clicks: int = 1) -> None:
|
|
92
|
+
"""Scroll up."""
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
# Screen Actions
|
|
96
|
+
@abstractmethod
|
|
97
|
+
async def screenshot(self) -> bytes:
|
|
98
|
+
"""Take a screenshot."""
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
@abstractmethod
|
|
102
|
+
async def get_screen_size(self) -> Dict[str, int]:
|
|
103
|
+
"""Get screen dimensions."""
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
@abstractmethod
|
|
107
|
+
async def get_cursor_position(self) -> Dict[str, int]:
|
|
108
|
+
"""Get current cursor position."""
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
# Clipboard Actions
|
|
112
|
+
@abstractmethod
|
|
113
|
+
async def copy_to_clipboard(self) -> str:
|
|
114
|
+
"""Get clipboard content."""
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
@abstractmethod
|
|
118
|
+
async def set_clipboard(self, text: str) -> None:
|
|
119
|
+
"""Set clipboard content."""
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
# File System Actions
|
|
123
|
+
@abstractmethod
|
|
124
|
+
async def file_exists(self, path: str) -> bool:
|
|
125
|
+
"""Check if file exists."""
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
@abstractmethod
|
|
129
|
+
async def directory_exists(self, path: str) -> bool:
|
|
130
|
+
"""Check if directory exists."""
|
|
131
|
+
pass
|
|
132
|
+
|
|
133
|
+
@abstractmethod
|
|
134
|
+
async def run_command(self, command: str) -> Tuple[str, str]:
|
|
135
|
+
"""Run shell command."""
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
# Accessibility Actions
|
|
139
|
+
@abstractmethod
|
|
140
|
+
async def get_accessibility_tree(self) -> Dict:
|
|
141
|
+
"""Get accessibility tree."""
|
|
142
|
+
pass
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
async def find_element_by_criteria(self,
|
|
146
|
+
role: Optional[str] = None,
|
|
147
|
+
title: Optional[str] = None,
|
|
148
|
+
value: Optional[str] = None) -> Optional[Dict]:
|
|
149
|
+
"""Find element by accessibility criteria."""
|
|
150
|
+
pass
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
from typing import Type, Literal
|
|
3
|
+
from .base import BaseComputerInterface
|
|
4
|
+
from .macos import MacOSComputerInterface
|
|
5
|
+
from .linux import LinuxComputerInterface
|
|
6
|
+
|
|
7
|
+
OSType = Literal['macos', 'linux']
|
|
8
|
+
|
|
9
|
+
class InterfaceFactory:
|
|
10
|
+
"""Factory for creating OS-specific computer interfaces."""
|
|
11
|
+
|
|
12
|
+
@staticmethod
|
|
13
|
+
def create_interface_for_os(os: OSType, ip_address: str, username: str = "lume", password: str = "lume") -> BaseComputerInterface:
|
|
14
|
+
"""Create and return appropriate interface for the specified OS.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
os: The operating system type ('macos' or 'linux')
|
|
18
|
+
ip_address: The IP address of the remote computer
|
|
19
|
+
username: The username for connection
|
|
20
|
+
password: The password for connection
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
BaseComputerInterface: The appropriate interface implementation for the OS
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
NotImplementedError: If the OS is not supported
|
|
27
|
+
"""
|
|
28
|
+
if os == 'macos':
|
|
29
|
+
return MacOSComputerInterface(ip_address, username, password)
|
|
30
|
+
elif os == 'linux':
|
|
31
|
+
return LinuxComputerInterface(ip_address, username, password)
|
|
32
|
+
else:
|
|
33
|
+
raise NotImplementedError(f"OS '{os}' is not supported")
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import websockets
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional, Dict, Any, Tuple
|
|
8
|
+
from .base import BaseComputerInterface
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
class LinuxComputerInterface(BaseComputerInterface):
|
|
13
|
+
"""Linux-specific implementation of the computer interface."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, ip_address: str, username: str = "lume", password: str = "lume"):
|
|
16
|
+
super().__init__(ip_address, username, password)
|
|
17
|
+
self.ws_uri = f"ws://{ip_address}:8000/ws"
|
|
18
|
+
self._ws = None
|
|
19
|
+
self._reconnect_task = None
|
|
20
|
+
self._closed = False
|
|
21
|
+
|
|
22
|
+
async def _keep_alive(self):
|
|
23
|
+
"""Keep the WebSocket connection alive with automatic reconnection."""
|
|
24
|
+
while not self._closed:
|
|
25
|
+
try:
|
|
26
|
+
if self._ws is None or self._ws.closed:
|
|
27
|
+
self._ws = await websockets.connect(
|
|
28
|
+
self.ws_uri,
|
|
29
|
+
max_size=1024 * 1024 * 10, # 10MB limit
|
|
30
|
+
max_queue=32
|
|
31
|
+
)
|
|
32
|
+
logger.info("WebSocket connection established")
|
|
33
|
+
|
|
34
|
+
await asyncio.sleep(30)
|
|
35
|
+
if self._ws and not self._ws.closed:
|
|
36
|
+
try:
|
|
37
|
+
pong = await self._ws.ping()
|
|
38
|
+
await asyncio.wait_for(pong, timeout=10)
|
|
39
|
+
except:
|
|
40
|
+
await self._ws.close()
|
|
41
|
+
self._ws = None
|
|
42
|
+
|
|
43
|
+
except Exception as e:
|
|
44
|
+
logger.error(f"WebSocket connection error: {e}")
|
|
45
|
+
if self._ws:
|
|
46
|
+
await self._ws.close()
|
|
47
|
+
self._ws = None
|
|
48
|
+
await asyncio.sleep(5)
|
|
49
|
+
|
|
50
|
+
async def _ensure_connection(self):
|
|
51
|
+
"""Ensure WebSocket connection is established."""
|
|
52
|
+
if self._reconnect_task is None:
|
|
53
|
+
self._reconnect_task = asyncio.create_task(self._keep_alive())
|
|
54
|
+
|
|
55
|
+
retry_count = 0
|
|
56
|
+
while self._ws is None or self._ws.closed:
|
|
57
|
+
if retry_count >= 10:
|
|
58
|
+
raise ConnectionError("Failed to establish WebSocket connection")
|
|
59
|
+
await asyncio.sleep(0.5)
|
|
60
|
+
retry_count += 1
|
|
61
|
+
|
|
62
|
+
async def _send_command(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]:
|
|
63
|
+
"""Send command through WebSocket."""
|
|
64
|
+
await self._ensure_connection()
|
|
65
|
+
message = {
|
|
66
|
+
"command": command,
|
|
67
|
+
"params": params or {}
|
|
68
|
+
}
|
|
69
|
+
await self._ws.send(json.dumps(message))
|
|
70
|
+
response = await self._ws.recv()
|
|
71
|
+
return json.loads(response)
|
|
72
|
+
|
|
73
|
+
async def wait_for_ready(self, timeout: int = 60, interval: float = 1.0):
|
|
74
|
+
"""Wait for WebSocket connection to become available."""
|
|
75
|
+
start_time = time.time()
|
|
76
|
+
while time.time() - start_time < timeout:
|
|
77
|
+
try:
|
|
78
|
+
if self._reconnect_task is None:
|
|
79
|
+
self._reconnect_task = asyncio.create_task(self._keep_alive())
|
|
80
|
+
await self._ensure_connection()
|
|
81
|
+
return
|
|
82
|
+
except Exception:
|
|
83
|
+
await asyncio.sleep(interval)
|
|
84
|
+
continue
|
|
85
|
+
raise TimeoutError(f"Could not connect to {self.ip_address} after {timeout} seconds")
|
|
86
|
+
|
|
87
|
+
def close(self):
|
|
88
|
+
"""Close WebSocket connection."""
|
|
89
|
+
self._closed = True
|
|
90
|
+
if self._reconnect_task:
|
|
91
|
+
self._reconnect_task.cancel()
|
|
92
|
+
if self._ws:
|
|
93
|
+
asyncio.create_task(self._ws.close())
|
|
94
|
+
self._ws = None
|
|
95
|
+
|
|
96
|
+
# Mouse Actions
|
|
97
|
+
async def left_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None:
|
|
98
|
+
await self._send_command("left_click", {"x": x, "y": y})
|
|
99
|
+
|
|
100
|
+
async def right_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None:
|
|
101
|
+
await self._send_command("right_click", {"x": x, "y": y})
|
|
102
|
+
|
|
103
|
+
async def double_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None:
|
|
104
|
+
await self._send_command("double_click", {"x": x, "y": y})
|
|
105
|
+
|
|
106
|
+
async def move_cursor(self, x: int, y: int) -> None:
|
|
107
|
+
await self._send_command("move_cursor", {"x": x, "y": y})
|
|
108
|
+
|
|
109
|
+
async def drag_to(self, x: int, y: int, button: str = "left", duration: float = 0.5) -> None:
|
|
110
|
+
await self._send_command("drag_to", {"x": x, "y": y, "button": button, "duration": duration})
|
|
111
|
+
|
|
112
|
+
# Keyboard Actions
|
|
113
|
+
async def type(self, text: str) -> None:
|
|
114
|
+
await self._send_command("type", {"text": text})
|
|
115
|
+
|
|
116
|
+
async def press_key(self, key: str) -> None:
|
|
117
|
+
await self._send_command("press_key", {"key": key})
|
|
118
|
+
|
|
119
|
+
async def hotkey(self, *keys: str) -> None:
|
|
120
|
+
await self._send_command("hotkey", {"keys": list(keys)})
|
|
121
|
+
|
|
122
|
+
# Scrolling Actions
|
|
123
|
+
async def scroll_down(self, clicks: int = 1) -> None:
|
|
124
|
+
await self._send_command("scroll", {"direction": "down", "clicks": clicks})
|
|
125
|
+
|
|
126
|
+
async def scroll_up(self, clicks: int = 1) -> None:
|
|
127
|
+
await self._send_command("scroll", {"direction": "up", "clicks": clicks})
|
|
128
|
+
|
|
129
|
+
# Screen Actions
|
|
130
|
+
async def screenshot(self) -> bytes:
|
|
131
|
+
result = await self._send_command("screenshot")
|
|
132
|
+
if result.get('image_data'):
|
|
133
|
+
import base64
|
|
134
|
+
return base64.b64decode(result['image_data'])
|
|
135
|
+
raise RuntimeError("Failed to take screenshot")
|
|
136
|
+
|
|
137
|
+
async def get_screen_size(self) -> Dict[str, int]:
|
|
138
|
+
result = await self._send_command("get_screen_size")
|
|
139
|
+
if result['success'] and result['size']:
|
|
140
|
+
return result['size']
|
|
141
|
+
raise RuntimeError("Failed to get screen size")
|
|
142
|
+
|
|
143
|
+
async def get_cursor_position(self) -> Dict[str, int]:
|
|
144
|
+
result = await self._send_command("get_cursor_position")
|
|
145
|
+
if result['success'] and result['position']:
|
|
146
|
+
return result['position']
|
|
147
|
+
raise RuntimeError("Failed to get cursor position")
|
|
148
|
+
|
|
149
|
+
# Clipboard Actions
|
|
150
|
+
async def copy_to_clipboard(self) -> str:
|
|
151
|
+
result = await self._send_command("copy_to_clipboard")
|
|
152
|
+
if result['success'] and result['content']:
|
|
153
|
+
return result['content']
|
|
154
|
+
raise RuntimeError("Failed to get clipboard content")
|
|
155
|
+
|
|
156
|
+
async def set_clipboard(self, text: str) -> None:
|
|
157
|
+
await self._send_command("set_clipboard", {"text": text})
|
|
158
|
+
|
|
159
|
+
# File System Actions
|
|
160
|
+
async def file_exists(self, path: str) -> bool:
|
|
161
|
+
result = await self._send_command("file_exists", {"path": path})
|
|
162
|
+
return result.get('exists', False)
|
|
163
|
+
|
|
164
|
+
async def directory_exists(self, path: str) -> bool:
|
|
165
|
+
result = await self._send_command("directory_exists", {"path": path})
|
|
166
|
+
return result.get('exists', False)
|
|
167
|
+
|
|
168
|
+
async def run_command(self, command: str) -> Tuple[str, str]:
|
|
169
|
+
result = await self._send_command("run_command", {"command": command})
|
|
170
|
+
if not result.get('success', False):
|
|
171
|
+
raise RuntimeError(result.get('error', 'Failed to run command'))
|
|
172
|
+
return result.get('stdout', ''), result.get('stderr', '')
|
|
173
|
+
|
|
174
|
+
# Accessibility Actions
|
|
175
|
+
async def get_accessibility_tree(self) -> Dict:
|
|
176
|
+
result = await self._send_command("get_accessibility_tree")
|
|
177
|
+
if 'error' in result:
|
|
178
|
+
raise RuntimeError(f"Failed to get accessibility tree: {result['error']}")
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
async def find_element_by_criteria(self,
|
|
182
|
+
role: Optional[str] = None,
|
|
183
|
+
title: Optional[str] = None,
|
|
184
|
+
value: Optional[str] = None) -> Optional[Dict]:
|
|
185
|
+
result = await self._send_command("find_element", {
|
|
186
|
+
"role": role,
|
|
187
|
+
"title": title,
|
|
188
|
+
"value": value
|
|
189
|
+
})
|
|
190
|
+
return result.get('element')
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import websockets
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import socket
|
|
6
|
+
import time
|
|
7
|
+
from typing import Optional, Dict, Any, Tuple, List, TypedDict
|
|
8
|
+
from .base import BaseComputerInterface
|
|
9
|
+
import tempfile
|
|
10
|
+
import subprocess
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
class AccessibilityWindow(TypedDict):
|
|
16
|
+
app_name: str
|
|
17
|
+
pid: int
|
|
18
|
+
frontmost: bool
|
|
19
|
+
has_windows: bool
|
|
20
|
+
windows: List[Dict[str, Any]]
|
|
21
|
+
|
|
22
|
+
class AccessibilityTree(TypedDict):
|
|
23
|
+
success: bool
|
|
24
|
+
frontmost_application: str
|
|
25
|
+
windows: List[AccessibilityWindow]
|
|
26
|
+
|
|
27
|
+
class MacOSComputerInterface(BaseComputerInterface):
|
|
28
|
+
"""macOS-specific implementation of the computer interface."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, ip_address: str, username: str = "lume", password: str = "lume"):
|
|
31
|
+
super().__init__(ip_address, username, password)
|
|
32
|
+
self.ws_uri = f"ws://{ip_address}:8000/ws"
|
|
33
|
+
self._ws = None
|
|
34
|
+
self._reconnect_task = None
|
|
35
|
+
self._closed = False
|
|
36
|
+
|
|
37
|
+
async def _keep_alive(self):
|
|
38
|
+
"""Keep the WebSocket connection alive with automatic reconnection."""
|
|
39
|
+
while not self._closed:
|
|
40
|
+
try:
|
|
41
|
+
if self._ws is None or self._ws.closed:
|
|
42
|
+
self._ws = await websockets.connect(
|
|
43
|
+
self.ws_uri,
|
|
44
|
+
max_size=1024 * 1024 * 10, # 10MB limit
|
|
45
|
+
max_queue=32
|
|
46
|
+
)
|
|
47
|
+
logger.info("WebSocket connection established")
|
|
48
|
+
|
|
49
|
+
await asyncio.sleep(30)
|
|
50
|
+
if self._ws and not self._ws.closed:
|
|
51
|
+
try:
|
|
52
|
+
pong = await self._ws.ping()
|
|
53
|
+
await asyncio.wait_for(pong, timeout=10)
|
|
54
|
+
except:
|
|
55
|
+
await self._ws.close()
|
|
56
|
+
self._ws = None
|
|
57
|
+
|
|
58
|
+
except Exception as e:
|
|
59
|
+
logger.error(f"WebSocket connection error: {e}")
|
|
60
|
+
if self._ws:
|
|
61
|
+
await self._ws.close()
|
|
62
|
+
self._ws = None
|
|
63
|
+
await asyncio.sleep(5)
|
|
64
|
+
|
|
65
|
+
async def _ensure_connection(self):
|
|
66
|
+
"""Ensure WebSocket connection is established."""
|
|
67
|
+
if self._reconnect_task is None:
|
|
68
|
+
self._reconnect_task = asyncio.create_task(self._keep_alive())
|
|
69
|
+
|
|
70
|
+
retry_count = 0
|
|
71
|
+
while self._ws is None or self._ws.closed:
|
|
72
|
+
if retry_count >= 10:
|
|
73
|
+
raise ConnectionError("Failed to establish WebSocket connection")
|
|
74
|
+
await asyncio.sleep(0.5)
|
|
75
|
+
retry_count += 1
|
|
76
|
+
|
|
77
|
+
async def _send_command(self, command: str, params: Optional[Dict] = None) -> Dict[str, Any]:
|
|
78
|
+
"""Send command through WebSocket."""
|
|
79
|
+
await self._ensure_connection()
|
|
80
|
+
message = {
|
|
81
|
+
"command": command,
|
|
82
|
+
"params": params or {}
|
|
83
|
+
}
|
|
84
|
+
await self._ws.send(json.dumps(message))
|
|
85
|
+
response = await self._ws.recv()
|
|
86
|
+
return json.loads(response)
|
|
87
|
+
|
|
88
|
+
async def wait_for_ready(self, timeout: int = 60, interval: float = 1.0):
|
|
89
|
+
"""Wait for WebSocket connection to become available."""
|
|
90
|
+
start_time = time.time()
|
|
91
|
+
while time.time() - start_time < timeout:
|
|
92
|
+
try:
|
|
93
|
+
if self._reconnect_task is None:
|
|
94
|
+
self._reconnect_task = asyncio.create_task(self._keep_alive())
|
|
95
|
+
await self._ensure_connection()
|
|
96
|
+
return
|
|
97
|
+
except Exception:
|
|
98
|
+
await asyncio.sleep(interval)
|
|
99
|
+
continue
|
|
100
|
+
raise TimeoutError(f"Could not connect to {self.ip_address} after {timeout} seconds")
|
|
101
|
+
|
|
102
|
+
def close(self):
|
|
103
|
+
"""Close WebSocket connection."""
|
|
104
|
+
self._closed = True
|
|
105
|
+
if self._reconnect_task:
|
|
106
|
+
self._reconnect_task.cancel()
|
|
107
|
+
if self._ws:
|
|
108
|
+
asyncio.create_task(self._ws.close())
|
|
109
|
+
self._ws = None
|
|
110
|
+
|
|
111
|
+
# Mouse Actions
|
|
112
|
+
async def left_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None:
|
|
113
|
+
await self._send_command("left_click", {"x": x, "y": y})
|
|
114
|
+
|
|
115
|
+
async def right_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None:
|
|
116
|
+
await self._send_command("right_click", {"x": x, "y": y})
|
|
117
|
+
|
|
118
|
+
async def double_click(self, x: Optional[int] = None, y: Optional[int] = None) -> None:
|
|
119
|
+
await self._send_command("double_click", {"x": x, "y": y})
|
|
120
|
+
|
|
121
|
+
async def move_cursor(self, x: int, y: int) -> None:
|
|
122
|
+
await self._send_command("move_cursor", {"x": x, "y": y})
|
|
123
|
+
|
|
124
|
+
async def drag_to(self, x: int, y: int, button: str = "left", duration: float = 0.5) -> None:
|
|
125
|
+
await self._send_command("drag_to", {"x": x, "y": y, "button": button, "duration": duration})
|
|
126
|
+
|
|
127
|
+
# Keyboard Actions
|
|
128
|
+
async def type(self, text: str) -> None:
|
|
129
|
+
await self._send_command("type", {"text": text})
|
|
130
|
+
|
|
131
|
+
async def press_key(self, key: str) -> None:
|
|
132
|
+
await self._send_command("press_key", {"key": key})
|
|
133
|
+
|
|
134
|
+
async def hotkey(self, *keys: str) -> None:
|
|
135
|
+
await self._send_command("hotkey", {"keys": list(keys)})
|
|
136
|
+
|
|
137
|
+
# Scrolling Actions
|
|
138
|
+
async def scroll_down(self, clicks: int = 1) -> None:
|
|
139
|
+
await self._send_command("scroll", {"direction": "down", "clicks": clicks})
|
|
140
|
+
|
|
141
|
+
async def scroll_up(self, clicks: int = 1) -> None:
|
|
142
|
+
await self._send_command("scroll", {"direction": "up", "clicks": clicks})
|
|
143
|
+
|
|
144
|
+
# Screen Actions
|
|
145
|
+
async def screenshot(self) -> bytes:
|
|
146
|
+
result = await self._send_command("screenshot")
|
|
147
|
+
if result.get('image_data'):
|
|
148
|
+
import base64
|
|
149
|
+
return base64.b64decode(result['image_data'])
|
|
150
|
+
raise RuntimeError("Failed to take screenshot")
|
|
151
|
+
|
|
152
|
+
async def get_screen_size(self) -> Dict[str, int]:
|
|
153
|
+
result = await self._send_command("get_screen_size")
|
|
154
|
+
if result['success'] and result['size']:
|
|
155
|
+
return result['size']
|
|
156
|
+
raise RuntimeError("Failed to get screen size")
|
|
157
|
+
|
|
158
|
+
async def get_cursor_position(self) -> Dict[str, int]:
|
|
159
|
+
result = await self._send_command("get_cursor_position")
|
|
160
|
+
if result['success'] and result['position']:
|
|
161
|
+
return result['position']
|
|
162
|
+
raise RuntimeError("Failed to get cursor position")
|
|
163
|
+
|
|
164
|
+
# Clipboard Actions
|
|
165
|
+
async def copy_to_clipboard(self) -> str:
|
|
166
|
+
result = await self._send_command("copy_to_clipboard")
|
|
167
|
+
if result['success'] and result['content']:
|
|
168
|
+
return result['content']
|
|
169
|
+
raise RuntimeError("Failed to get clipboard content")
|
|
170
|
+
|
|
171
|
+
async def set_clipboard(self, text: str) -> None:
|
|
172
|
+
await self._send_command("set_clipboard", {"text": text})
|
|
173
|
+
|
|
174
|
+
# File System Actions
|
|
175
|
+
async def file_exists(self, path: str) -> bool:
|
|
176
|
+
result = await self._send_command("file_exists", {"path": path})
|
|
177
|
+
return result.get('exists', False)
|
|
178
|
+
|
|
179
|
+
async def directory_exists(self, path: str) -> bool:
|
|
180
|
+
result = await self._send_command("directory_exists", {"path": path})
|
|
181
|
+
return result.get('exists', False)
|
|
182
|
+
|
|
183
|
+
async def run_command(self, command: str) -> Tuple[str, str]:
|
|
184
|
+
result = await self._send_command("run_command", {"command": command})
|
|
185
|
+
if not result.get('success', False):
|
|
186
|
+
raise RuntimeError(result.get('error', 'Failed to run command'))
|
|
187
|
+
return result.get('stdout', ''), result.get('stderr', '')
|
|
188
|
+
|
|
189
|
+
# Accessibility Actions
|
|
190
|
+
async def get_accessibility_tree(self) -> AccessibilityTree:
|
|
191
|
+
"""Get accessibility tree."""
|
|
192
|
+
result = await self._send_command("get_accessibility_tree")
|
|
193
|
+
if not result.get('success', False):
|
|
194
|
+
raise RuntimeError(result.get('error', 'Failed to get accessibility tree'))
|
|
195
|
+
return result
|
|
196
|
+
|
|
197
|
+
async def find_element_by_criteria(self,
|
|
198
|
+
role: Optional[str] = None,
|
|
199
|
+
title: Optional[str] = None,
|
|
200
|
+
value: Optional[str] = None) -> Optional[Dict]:
|
|
201
|
+
"""Find element by accessibility criteria."""
|
|
202
|
+
result = await self._send_command("find_element", {
|
|
203
|
+
"role": role,
|
|
204
|
+
"title": title,
|
|
205
|
+
"value": value
|
|
206
|
+
})
|
|
207
|
+
if not result.get('success', False):
|
|
208
|
+
raise RuntimeError(result.get('error', 'Failed to find element'))
|
|
209
|
+
return result.get('element')
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Optional
|
|
3
|
+
from pylume import PyLume
|
|
4
|
+
|
|
5
|
+
@dataclass
|
|
6
|
+
class Display:
|
|
7
|
+
width: int
|
|
8
|
+
height: int
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class Image:
|
|
12
|
+
image: str
|
|
13
|
+
tag: str
|
|
14
|
+
name: str
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Computer:
|
|
18
|
+
image: str
|
|
19
|
+
tag: str
|
|
20
|
+
name: str
|
|
21
|
+
display: Display
|
|
22
|
+
memory: str
|
|
23
|
+
cpu: str
|
|
24
|
+
pylume: Optional[PyLume] = None
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def ip(self) -> str:
|
|
28
|
+
"""Get the IP address of the VM."""
|
|
29
|
+
vm = self.pylume.get_vm(self.image.name)
|
|
30
|
+
return vm.ip
|
cua-0.1.0/pyproject.toml
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "cua"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "CUA (Computer Use) interface for host-VM communication on Apple Silicon"
|
|
5
|
+
authors = ["TryCua <f@trycua.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
homepage = "https://github.com/trycua/computer"
|
|
9
|
+
repository = "https://github.com/trycua/computer"
|
|
10
|
+
keywords = ["macos", "virtualization", "vm", "apple-silicon"]
|
|
11
|
+
packages = [
|
|
12
|
+
{ include = "computer" }
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Operating System :: MacOS :: MacOS X",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[tool.poetry.dependencies]
|
|
25
|
+
python = "^3.10"
|
|
26
|
+
paramiko = "^3.4.0"
|
|
27
|
+
pylume = "^0.1.8"
|
|
28
|
+
websockets = "^12.0"
|
|
29
|
+
|
|
30
|
+
[tool.poetry.group.dev.dependencies]
|
|
31
|
+
ipykernel = "^6.29.5"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["poetry-core"]
|
|
35
|
+
build-backend = "poetry.core.masonry.api"
|