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 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
@@ -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"